From 769b54fb4f20e2045d1b1af42efa5eb2c920c414 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 15:11:34 -0400 Subject: [PATCH 01/59] fix(cliproxy): clear stale auth quota cache - invalidate per-account quota cache entries after successful OAuth account registration - add regression coverage for the Claude stale-reauth dashboard path --- src/web-server/routes/cliproxy-auth-routes.ts | 12 ++++ ...iproxy-auth-routes-manual-callback.test.ts | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 0b2bdf01..eece3645 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -33,6 +33,7 @@ import { } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; +import { invalidateQuotaCache } from '../../cliproxy/quota-response-cache'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { @@ -190,6 +191,13 @@ function shouldKeepWaitingForLocalToken( ); } +function invalidateQuotaForRegisteredAccount(account: { + provider: CLIProxyProvider; + id: string; +}): void { + invalidateQuotaCache(account.provider, account.id); +} + function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } { if (raw === undefined || raw === null) { return { method: normalizeKiroAuthMethod(), invalid: false }; @@ -1022,6 +1030,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise { @@ -49,6 +54,7 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { afterEach(() => { restoreFetch(); + clearQuotaCache(); if (originalCcsHome === undefined) { delete process.env.CCS_HOME; @@ -564,4 +570,62 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => { expect(registry.providers.codex.accounts['new@example.com']?.email).toBe('new@example.com'); }); + + it('clears stale Claude quota cache after polling auth success', async () => { + mockFetch([ + { + url: /\/v0\/management\/anthropic-auth-url\?is_webui=true$/, + response: { + auth_url: 'https://auth.example.com/authorize?state=state-claude-cache-clear', + state: 'state-claude-cache-clear', + }, + }, + { + url: /\/v0\/management\/get-auth-status\?state=state-claude-cache-clear$/, + response: { status: 'ok' }, + }, + ]); + + const startResponse = await postJson('/api/cliproxy/auth/claude/start-url', {}); + expect(startResponse.status).toBe(200); + + const accountId = 'claude-team@example.com'; + setCachedQuota('claude', accountId, { + success: false, + error: 'Authentication required for policy limits', + needsReauth: true, + }); + expect(getCachedQuota('claude', accountId)).not.toBeNull(); + + const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(tokenDir, { recursive: true }); + fs.writeFileSync( + path.join(tokenDir, 'claude-claude-team@example.com.json'), + JSON.stringify({ + type: 'claude', + email: accountId, + access_token: 'fresh-token', + refresh_token: 'refresh-token', + expired: '2099-01-01T00:00:00.000Z', + }), + 'utf8' + ); + + const statusResponse = await getJson( + '/api/cliproxy/auth/claude/status?state=state-claude-cache-clear' + ); + + expect(statusResponse.status).toBe(200); + expect(statusResponse.body).toEqual({ + status: 'ok', + account: { + id: accountId, + email: accountId, + nickname: 'claude-team', + provider: 'claude', + isDefault: true, + }, + }); + expect(getCachedQuota('claude', accountId)).toBeNull(); + }); }); From a94de010dbb0a3c808e813b5c9ac9ec229f37708 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 17:35:30 -0400 Subject: [PATCH 02/59] fix(codex): prefer cmd wrappers over powershell on windows - keep Windows Codex detection on the npm cmd shim when available - add regression tests for cmd-first and ps1-to-cmd fallback selection --- src/targets/codex-detector.ts | 27 +++++++++++++++-------- tests/unit/targets/codex-detector.test.ts | 18 +++++++++++++-- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index 97cfac1a..e7070617 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -10,17 +10,26 @@ const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version']; function buildWindowsCodexCandidates(matches: string[]): string[] { const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)); const bareCandidates = matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry)); - const prioritized: string[] = []; - - for (const entry of shellCandidates) { - if (/\.(cmd|bat)$/i.test(entry)) { - prioritized.push(entry.replace(/\.(cmd|bat)$/i, '.ps1')); + const prioritized = shellCandidates.map((entry) => { + if (!/\.ps1$/i.test(entry)) { + return entry; } - prioritized.push(entry); - } - prioritized.push(...bareCandidates); - return [...new Set(prioritized)]; + for (const preferredExtension of ['.cmd', '.bat', '.exe']) { + const siblingCandidate = entry.replace(/\.ps1$/i, preferredExtension); + try { + if (fs.statSync(siblingCandidate).isFile()) { + return siblingCandidate; + } + } catch { + // Ignore missing sibling wrappers and keep the original PowerShell path. + } + } + + return entry; + }); + + return [...new Set([...prioritized, ...bareCandidates])]; } function runCodexProbe(codexPath: string, args: string[]): string | undefined { diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts index bf4b0f4a..b0bb1e0d 100644 --- a/tests/unit/targets/codex-detector.test.ts +++ b/tests/unit/targets/codex-detector.test.ts @@ -73,7 +73,7 @@ describe('codex-detector', () => { execFileSyncSpy.mockRestore(); }); - it('prefers a sibling PowerShell wrapper over cmd when Windows PATH only exposes codex.cmd', () => { + it('keeps the cmd wrapper when Windows PATH exposes codex.cmd and a sibling ps1 also exists', () => { const fakeCmdCodex = path.join(tmpDir, 'codex.cmd'); const fakePsCodex = path.join(tmpDir, 'codex.ps1'); fs.writeFileSync(fakeCmdCodex, ''); @@ -82,7 +82,21 @@ describe('codex-detector', () => { const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakeCmdCodex}\n`); - expect(detectCodexCli()).toBe(fakePsCodex); + expect(detectCodexCli()).toBe(fakeCmdCodex); + + execSyncSpy.mockRestore(); + }); + + it('replaces a Windows PowerShell wrapper with a sibling cmd shim when PATH returns codex.ps1', () => { + const fakeCmdCodex = path.join(tmpDir, 'codex.cmd'); + const fakePsCodex = path.join(tmpDir, 'codex.ps1'); + fs.writeFileSync(fakeCmdCodex, ''); + fs.writeFileSync(fakePsCodex, ''); + Object.defineProperty(process, 'platform', { value: 'win32' }); + + const execSyncSpy = spyOn(childProcess, 'execSync').mockImplementation(() => `${fakePsCodex}\n`); + + expect(detectCodexCli()).toBe(fakeCmdCodex); execSyncSpy.mockRestore(); }); From 074e90055789bd146d96fd3d1ad917f91e602186 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 19:26:59 -0400 Subject: [PATCH 03/59] feat(proxy): add openai-compatible local proxy runtime --- src/ccs.ts | 52 +++ src/commands/command-catalog.ts | 7 + src/commands/completion-backend.ts | 8 + src/commands/help-command.ts | 3 + src/commands/index.ts | 1 + src/commands/proxy-command.ts | 165 ++++++++ src/commands/root-command-router.ts | 7 + src/config/reserved-names.ts | 1 + src/glmt/sse-parser.ts | 102 ++--- src/proxy/index.ts | 7 + src/proxy/profile-router.ts | 70 ++++ src/proxy/proxy-daemon-entry.ts | 80 ++++ src/proxy/proxy-daemon-paths.ts | 17 + src/proxy/proxy-daemon-state.ts | 82 ++++ src/proxy/proxy-daemon.ts | 380 ++++++++++++++++++ src/proxy/proxy-env.ts | 23 ++ src/proxy/server/http-helpers.ts | 81 ++++ src/proxy/server/messages-route.ts | 174 ++++++++ src/proxy/server/proxy-server.ts | 75 ++++ src/proxy/transformers/request-transformer.ts | 89 ++++ .../transformers/sse-stream-transformer.ts | 14 + src/proxy/upstream-url.ts | 36 ++ 22 files changed, 1428 insertions(+), 46 deletions(-) create mode 100644 src/commands/proxy-command.ts create mode 100644 src/proxy/index.ts create mode 100644 src/proxy/profile-router.ts create mode 100644 src/proxy/proxy-daemon-entry.ts create mode 100644 src/proxy/proxy-daemon-paths.ts create mode 100644 src/proxy/proxy-daemon-state.ts create mode 100644 src/proxy/proxy-daemon.ts create mode 100644 src/proxy/proxy-env.ts create mode 100644 src/proxy/server/http-helpers.ts create mode 100644 src/proxy/server/messages-route.ts create mode 100644 src/proxy/server/proxy-server.ts create mode 100644 src/proxy/transformers/request-transformer.ts create mode 100644 src/proxy/transformers/sse-stream-transformer.ts create mode 100644 src/proxy/upstream-url.ts diff --git a/src/ccs.ts b/src/ccs.ts index 5c907085..c1bb2ec3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -97,6 +97,11 @@ import { } from './targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from './proxy'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -1304,6 +1309,53 @@ async function main(): Promise { const browserArgs = browserRuntimeEnv ? appendBrowserToolArgs(imageAnalysisArgs) : imageAnalysisArgs; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profileInfo.name, + expandedSettingsPath, + settingsEnv + ); + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); + process.exit(1); + } + + console.error( + info( + `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` + ) + ); + + const proxyEnv = { + ...envVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete proxyEnv.ANTHROPIC_API_KEY; + + const launchArgs = [ + '--settings', + expandedSettingsPath, + ...appendThirdPartyWebSearchToolArgs(browserArgs), + ]; + const traceEnv = createWebSearchTraceContext({ + launcher: 'ccs.settings-profile.proxy', + args: launchArgs, + profile: profileInfo.name, + profileType: profileInfo.type, + settingsPath: expandedSettingsPath, + }); + + execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv }); + return; + } const launchArgs = [ '--settings', expandedSettingsPath, diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 3b14c3b7..099923d7 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -109,6 +109,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [ group: 'runtime', visibility: 'public', }, + { + name: 'proxy', + summary: 'Start or inspect the OpenAI-compatible local proxy', + group: 'runtime', + visibility: 'public', + }, { name: 'copilot', summary: 'Run or manage the GitHub Copilot bridge', @@ -252,6 +258,7 @@ export const CLIPROXY_SUBCOMMANDS = [ ] as const; export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const; export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const; +export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const; export const TOKENS_FLAGS = [ '--show', '--api-key', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 523c08ad..9082687f 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -11,6 +11,7 @@ import { ROOT_COMMAND_FLAGS, ROOT_HELP_TOPICS, TOKENS_FLAGS, + PROXY_SUBCOMMANDS, PROVIDER_FLAGS, uniqueStrings, getPublicRootCommandTokens, @@ -189,6 +190,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker); case 'cursor': return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS); + case 'proxy': + if (lastToken === '--shell') + return completeSubcommands(['auto', 'bash', 'zsh', 'fish', 'powershell']); + return completeSubcommands( + [...PROXY_SUBCOMMANDS], + ['--port', '--shell', '--insecure', '--help', '-h'] + ); case 'copilot': return completeSubcommands(COPILOT_COMPLETION_SUBCOMMANDS); case 'env': diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index e3912a11..d1e4ab9f 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -215,6 +215,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr name: 'ccs cliproxy --help', summary: 'Deep help for variants, routing, quota, and lifecycle', }, + { name: 'ccs proxy --help', summary: 'Deep help for the OpenAI-compatible local proxy' }, { name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' }, { name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' }, { name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' }, @@ -275,6 +276,8 @@ export async function handleHelpRoute( process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])), cursor: async () => process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])), + proxy: async () => + process.exit(await (await import('./proxy-command')).handleProxyCommand(['--help'])), docker: async () => (await import('./docker/help-subcommand')).showHelp(), migrate: async () => (await import('./migrate-command')).printMigrateHelp(), setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']), diff --git a/src/commands/index.ts b/src/commands/index.ts index 22d62035..ef9de709 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -13,6 +13,7 @@ export { handleDockerCommand } from './docker-command'; export { handleHelpCommand } from './help-command'; export { handleInstallCommand } from './install-command'; export { handleMigrateCommand } from './migrate-command'; +export { handleProxyCommand } from './proxy-command'; export { handleShellCompletionCommand } from './shell-completion-command'; export { handleSyncCommand } from './sync-command'; export { handleUpdateCommand } from './update-command'; diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts new file mode 100644 index 00000000..9110f3f1 --- /dev/null +++ b/src/commands/proxy-command.ts @@ -0,0 +1,165 @@ +import { detectShell, formatExportLine } from './env-command'; +import { getSettingsPath, loadSettings } from '../utils/config-manager'; +import { expandPath } from '../utils/helpers'; +import { fail, info, ok } from '../utils/ui'; +import { + buildOpenAICompatProxyEnv, + getOpenAICompatProxyStatus, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, + stopOpenAICompatProxy, +} from '../proxy'; + +function parseOptionValue(args: string[], key: string): string | undefined { + const exactIndex = args.findIndex((arg) => arg === key); + if (exactIndex !== -1 && args[exactIndex + 1]) { + return args[exactIndex + 1]; + } + + const prefix = `${key}=`; + const withEquals = args.find((arg) => arg.startsWith(prefix)); + return withEquals ? withEquals.slice(prefix.length) : undefined; +} + +function showHelp(): number { + console.log('OpenAI-Compatible Proxy'); + console.log(''); + console.log('Usage: ccs proxy [profile] [options]'); + console.log(''); + console.log('Commands:'); + console.log( + ' start Start the local proxy for an OpenAI-compatible settings profile' + ); + console.log(' stop Stop the running proxy'); + console.log(' status Show daemon status and active profile'); + console.log(' activate Print shell exports for the running proxy'); + console.log(''); + console.log('Options:'); + console.log(' --port Override the local proxy port (default: 3456)'); + console.log(' --shell activate only: auto|bash|zsh|fish|powershell'); + console.log(' --insecure Disable upstream TLS verification'); + console.log(''); + console.log('Examples:'); + console.log(' ccs proxy start hf'); + console.log(' eval "$(ccs proxy activate)"'); + console.log(' ccs proxy status'); + console.log(' ccs proxy stop'); + console.log(''); + return 0; +} + +function resolveProfile(profileName: string) { + const settingsPath = expandPath(getSettingsPath(profileName)); + const settings = loadSettings(settingsPath); + const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {}); + if (!profile) { + throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`); + } + return profile; +} + +async function handleStart(args: string[]): Promise { + const profileName = args.find((arg) => !arg.startsWith('-')); + if (!profileName) { + console.error(fail('Usage: ccs proxy start [--port ] [--insecure]')); + return 1; + } + + const portValue = parseOptionValue(args, '--port'); + const port = portValue ? Number.parseInt(portValue, 10) || 3456 : undefined; + let profile; + try { + profile = resolveProfile(profileName); + } catch (error) { + console.error(fail((error as Error).message)); + return 1; + } + + const result = await startOpenAICompatProxy(profile, { + ...(port ? { port } : {}), + insecure: args.includes('--insecure'), + }); + + if (!result.success) { + console.error(fail(result.error || 'Failed to start proxy')); + return 1; + } + + console.log( + result.alreadyRunning + ? info(`Proxy already running on port ${result.port}`) + : ok(`Proxy started on port ${result.port}`) + ); + return 0; +} + +async function handleStatus(): Promise { + const status = await getOpenAICompatProxyStatus(); + if (!status.running) { + console.log(info('Proxy is not running')); + return 0; + } + + console.log(ok(`Proxy running on port ${status.port}`)); + console.log(` Profile: ${status.profileName}`); + console.log(` Base URL: ${status.baseUrl}`); + if (status.model) { + console.log(` Model: ${status.model}`); + } + if (status.pid) { + console.log(` PID: ${status.pid}`); + } + return 0; +} + +async function handleActivate(args: string[]): Promise { + const status = await getOpenAICompatProxyStatus(); + if (!status.running || !status.profileName || !status.port || !status.authToken) { + console.error(fail('Proxy is not running. Start it with: ccs proxy start ')); + return 1; + } + + const shell = detectShell(parseOptionValue(args, '--shell')); + let profile; + try { + profile = resolveProfile(status.profileName); + } catch (error) { + console.error(fail((error as Error).message)); + return 1; + } + + const env = buildOpenAICompatProxyEnv(profile, status.port, status.authToken); + Object.entries(env).forEach(([key, value]) => { + console.log(formatExportLine(shell, key, value)); + }); + return 0; +} + +export async function handleProxyCommand(args: string[]): Promise { + const subcommand = args[0]; + switch (subcommand) { + case undefined: + case 'help': + case '--help': + case '-h': + return showHelp(); + case 'start': + return handleStart(args.slice(1)); + case 'stop': { + const result = await stopOpenAICompatProxy(); + if (!result.success) { + console.error(fail(result.error || 'Failed to stop proxy')); + return 1; + } + console.log(ok('Proxy stopped')); + return 0; + } + case 'status': + return handleStatus(); + case 'activate': + return handleActivate(args.slice(1)); + default: + console.error(fail(`Unknown proxy subcommand: ${subcommand}`)); + return 1; + } +} diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index be45acc7..250c5910 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -136,6 +136,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleCliproxyCommand(args); }, }, + { + name: 'proxy', + handle: async (args) => { + const { handleProxyCommand } = await import('./proxy-command'); + process.exit(await handleProxyCommand(args)); + }, + }, { name: 'docker', handle: async (args) => { diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 8705d63a..552621b5 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -17,6 +17,7 @@ export const RESERVED_PROFILE_NAMES = [ 'default', 'config', 'cliproxy', + 'proxy', ] as const; export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index 9a731654..c161a7df 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -49,63 +49,73 @@ export class SSEParser { * @returns Array of parsed events */ parse(chunk: Buffer | string): SSEEvent[] { - this.buffer += chunk.toString(); + this.buffer += chunk.toString().replace(/\r\n/g, '\n'); // C-01 Fix: Prevent unbounded buffer growth (DoS protection) if (this.buffer.length > this.maxBufferSize) { throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`); } - const lines = this.buffer.split('\n'); - - // Keep incomplete line in buffer - this.buffer = lines.pop() || ''; - const events: SSEEvent[] = []; - let currentEvent: SSEEvent = { event: 'message', data: '' }; + const segments = this.buffer.split('\n\n'); + this.buffer = segments.pop() || ''; - for (const line of lines) { - if (line.startsWith('event: ')) { - currentEvent.event = line.substring(7).trim(); - } else if (line.startsWith('data: ')) { - const data = line.substring(6); + for (const segment of segments) { + const lines = segment.split('\n'); + const currentEvent: SSEEvent = { event: 'message', data: '' }; + const dataLines: string[] = []; - if (data === '[DONE]') { - this.eventCount++; - events.push({ - event: 'done', - data: null, - index: this.eventCount, - }); - currentEvent = { event: 'message', data: '' }; - } else { - try { - currentEvent.data = JSON.parse(data); - this.eventCount++; - currentEvent.index = this.eventCount; - events.push({ ...currentEvent }); - currentEvent = { event: 'message', data: '' }; - } catch (e) { - // H-01 Fix: Log parse errors for debugging - if (typeof console !== 'undefined' && console.error) { - console.error( - '[SSEParser] Malformed JSON event:', - (e as Error).message, - 'Data:', - data.substring(0, 100) - ); - } - if (this.throwOnMalformedJson) { - throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); - } - } + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (!line || line.startsWith(':')) { + continue; + } + if (line.startsWith('event: ')) { + currentEvent.event = line.substring(7).trim(); + continue; + } + if (line.startsWith('data:')) { + dataLines.push(line.substring(5).trimStart()); + continue; + } + if (line.startsWith('id: ')) { + currentEvent.id = line.substring(4).trim(); + continue; + } + if (line.startsWith('retry: ')) { + currentEvent.retry = parseInt(line.substring(7), 10); + } + } + + const data = dataLines.join('\n'); + if (!data) { + continue; + } + + if (data === '[DONE]') { + this.eventCount++; + events.push({ event: 'done', data: null, index: this.eventCount }); + continue; + } + + try { + currentEvent.data = JSON.parse(data); + this.eventCount++; + currentEvent.index = this.eventCount; + events.push({ ...currentEvent }); + } catch (e) { + if (typeof console !== 'undefined' && console.error) { + console.error( + '[SSEParser] Malformed JSON event:', + (e as Error).message, + 'Data:', + data.substring(0, 100) + ); + } + if (this.throwOnMalformedJson) { + throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); } - } else if (line.startsWith('id: ')) { - currentEvent.id = line.substring(4).trim(); - } else if (line.startsWith('retry: ')) { - currentEvent.retry = parseInt(line.substring(7), 10); } - // Empty lines separate events (already handled by JSON parsing) } return events; diff --git a/src/proxy/index.ts b/src/proxy/index.ts new file mode 100644 index 00000000..24a0a516 --- /dev/null +++ b/src/proxy/index.ts @@ -0,0 +1,7 @@ +export * from './profile-router'; +export * from './proxy-daemon'; +export * from './proxy-daemon-paths'; +export * from './proxy-env'; +export * from './upstream-url'; +export * from './transformers/request-transformer'; +export * from './transformers/sse-stream-transformer'; diff --git a/src/proxy/profile-router.ts b/src/proxy/profile-router.ts new file mode 100644 index 00000000..da856f6f --- /dev/null +++ b/src/proxy/profile-router.ts @@ -0,0 +1,70 @@ +import { resolveDroidProvider, type DroidProvider } from '../targets/droid-provider'; + +export interface OpenAICompatProfileConfig { + profileName: string; + settingsPath: string; + baseUrl: string; + apiKey: string; + provider: DroidProvider; + insecure?: boolean; + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + +export interface OpenAICompatProfileEnv { + ANTHROPIC_BASE_URL?: string; + ANTHROPIC_AUTH_TOKEN?: string; + ANTHROPIC_API_KEY?: string; + ANTHROPIC_MODEL?: string; + ANTHROPIC_DEFAULT_OPUS_MODEL?: string; + ANTHROPIC_DEFAULT_SONNET_MODEL?: string; + ANTHROPIC_DEFAULT_HAIKU_MODEL?: string; + ANTHROPIC_SMALL_FAST_MODEL?: string; + CCS_DROID_PROVIDER?: string; + CCS_OPENAI_PROXY_INSECURE?: string; +} + +export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider { + return provider === 'openai' || provider === 'generic-chat-completion-api'; +} + +export function resolveOpenAICompatProfileConfig( + profileName: string, + settingsPath: string, + env: OpenAICompatProfileEnv +): OpenAICompatProfileConfig | null { + const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || ''; + const apiKey = env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim() || ''; + if (!baseUrl || !apiKey) { + return null; + } + + const provider = resolveDroidProvider({ + provider: env.CCS_DROID_PROVIDER, + baseUrl, + model: env.ANTHROPIC_MODEL, + }); + if (!isOpenAICompatProvider(provider)) { + return null; + } + + return { + profileName, + settingsPath, + baseUrl, + apiKey, + provider, + insecure: + env.CCS_OPENAI_PROXY_INSECURE === '1' || + env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true', + model: env.ANTHROPIC_MODEL?.trim() || undefined, + opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined, + sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined, + haikuModel: + env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() || + env.ANTHROPIC_SMALL_FAST_MODEL?.trim() || + undefined, + }; +} diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts new file mode 100644 index 00000000..226433bb --- /dev/null +++ b/src/proxy/proxy-daemon-entry.ts @@ -0,0 +1,80 @@ +import { loadSettings } from '../utils/config-manager'; +import { resolveOpenAICompatProfileConfig } from './profile-router'; +import { startOpenAICompatProxyServer } from './server/proxy-server'; + +interface RuntimeOptions { + port: number; + profileName: string; + settingsPath: string; + authToken: string; + insecure: boolean; +} + +function parseArgs(argv: string[]): RuntimeOptions { + let port = 3456; + let profileName = ''; + let settingsPath = ''; + let authToken = ''; + let insecure = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--port' && argv[i + 1]) { + port = Number.parseInt(argv[++i] || '', 10) || port; + continue; + } + if (arg === '--profile' && argv[i + 1]) { + profileName = argv[++i] || ''; + continue; + } + if (arg === '--settings-path' && argv[i + 1]) { + settingsPath = argv[++i] || ''; + continue; + } + if (arg === '--auth-token' && argv[i + 1]) { + authToken = argv[++i] || ''; + continue; + } + if (arg === '--insecure') { + insecure = true; + } + } + + return { port, profileName, settingsPath, authToken, insecure }; +} + +function startRuntime(options: RuntimeOptions): void { + if (!options.authToken.trim()) { + throw new Error('Missing local proxy auth token'); + } + + const settings = loadSettings(options.settingsPath); + const profile = resolveOpenAICompatProfileConfig( + options.profileName, + options.settingsPath, + settings.env || {} + ); + if (!profile) { + throw new Error( + `Profile "${options.profileName}" is not an OpenAI-compatible settings profile` + ); + } + + const server = startOpenAICompatProxyServer({ + profile, + port: options.port, + authToken: options.authToken, + insecure: options.insecure, + }); + server.once('error', (error) => { + console.error((error as Error).message); + process.exit(1); + }); + const shutdown = () => server.close(); + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} + +if (require.main === module) { + startRuntime(parseArgs(process.argv.slice(2))); +} diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts new file mode 100644 index 00000000..92b6b95b --- /dev/null +++ b/src/proxy/proxy-daemon-paths.ts @@ -0,0 +1,17 @@ +import * as path from 'path'; +import { getCcsDir } from '../utils/config-manager'; + +export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = 3456; +export const OPENAI_COMPAT_PROXY_SERVICE_NAME = 'ccs-openai-compat-proxy'; + +export function getOpenAICompatProxyDir(): string { + return path.join(getCcsDir(), 'proxy'); +} + +export function getOpenAICompatProxyPidPath(): string { + return path.join(getOpenAICompatProxyDir(), 'daemon.pid'); +} + +export function getOpenAICompatProxySessionPath(): string { + return path.join(getOpenAICompatProxyDir(), 'session.json'); +} diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts new file mode 100644 index 00000000..c1921948 --- /dev/null +++ b/src/proxy/proxy-daemon-state.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { + getOpenAICompatProxyDir, + getOpenAICompatProxyPidPath, + getOpenAICompatProxySessionPath, +} from './proxy-daemon-paths'; + +export interface OpenAICompatProxySession { + profileName: string; + settingsPath: string; + port: number; + baseUrl: string; + authToken: string; + model?: string; + insecure?: boolean; +} + +function ensureProxyDir(): void { + fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); +} + +export function getOpenAICompatProxyPid(): number | null { + try { + const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim(); + const pid = Number.parseInt(raw, 10); + return Number.isInteger(pid) ? pid : null; + } catch { + return null; + } +} + +export function writeOpenAICompatProxyPid(pid: number): void { + ensureProxyDir(); + fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8'); +} + +export function removeOpenAICompatProxyPid(): void { + try { + fs.unlinkSync(getOpenAICompatProxyPidPath()); + } catch { + // Best-effort cleanup. + } +} + +export function readOpenAICompatProxySession(): OpenAICompatProxySession | null { + try { + return JSON.parse( + fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8') + ) as OpenAICompatProxySession; + } catch { + return null; + } +} + +export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { + ensureProxyDir(); + fs.writeFileSync( + getOpenAICompatProxySessionPath(), + JSON.stringify(session, null, 2) + '\n', + 'utf8' + ); +} + +export function removeOpenAICompatProxySession(): void { + try { + fs.unlinkSync(getOpenAICompatProxySessionPath()); + } catch { + // Best-effort cleanup. + } +} + +export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { + const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); + const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); + const isBunRuntime = process.execPath.toLowerCase().includes('bun'); + const runningFromDist = __filename.endsWith('.js'); + if (runningFromDist) { + return [jsEntry]; + } + return isBunRuntime ? [tsEntry, jsEntry] : [jsEntry]; +} diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts new file mode 100644 index 00000000..603c3097 --- /dev/null +++ b/src/proxy/proxy-daemon.ts @@ -0,0 +1,380 @@ +import { spawn, type ChildProcess } from 'child_process'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as net from 'net'; +import * as lockfile from 'proper-lockfile'; +import { verifyProcessOwnership } from '../cursor/daemon-process-ownership'; +import type { OpenAICompatProfileConfig } from './profile-router'; +import { + OPENAI_COMPAT_PROXY_DEFAULT_PORT, + OPENAI_COMPAT_PROXY_SERVICE_NAME, + getOpenAICompatProxyDir, +} from './proxy-daemon-paths'; +import { + getOpenAICompatProxyPid, + readOpenAICompatProxySession, + removeOpenAICompatProxyPid, + removeOpenAICompatProxySession, + resolveOpenAICompatProxyEntrypointCandidates, + type OpenAICompatProxySession, + writeOpenAICompatProxyPid, + writeOpenAICompatProxySession, +} from './proxy-daemon-state'; + +export interface OpenAICompatProxyStatus extends Partial { + running: boolean; + pid?: number; +} + +export interface StartOpenAICompatProxyResult { + success: boolean; + alreadyRunning?: boolean; + authToken?: string; + pid?: number; + port: number; + error?: string; +} + +function generateProxyAuthToken(): string { + return crypto.randomBytes(24).toString('hex'); +} + +async function withOpenAICompatProxyLock(operation: () => Promise): Promise { + const proxyDir = getOpenAICompatProxyDir(); + await fs.promises.mkdir(proxyDir, { recursive: true }); + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(proxyDir, { + stale: 10000, + retries: { retries: 20, minTimeout: 50, maxTimeout: 250 }, + realpath: false, + }); + } catch (error) { + throw new Error( + `Failed to lock OpenAI-compatible proxy directory (${proxyDir}): ${(error as Error).message}` + ); + } + + try { + return await operation(); + } finally { + if (release) { + try { + await release(); + } catch { + // Best-effort release. + } + } + } +} + +async function isPortOccupied(port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({ host: '127.0.0.1', port }); + const finish = (occupied: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(occupied); + }; + + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + socket.setTimeout(500, () => { + finish(false); + }); + }); +} + +async function findOpenAICompatProxyPort(): Promise { + for ( + let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT; + candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10; + candidate += 1 + ) { + if (!(await isPortOccupied(candidate))) { + return candidate; + } + } + + return 0; +} + +async function resolveDaemonEntrypoint(): Promise { + for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) { + try { + await fs.promises.access(candidate, fs.constants.R_OK); + return candidate; + } catch { + // Try next candidate. + } + } + return null; +} + +export async function isOpenAICompatProxyRunning(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => { + if (res.statusCode !== 200) { + resolve(false); + return; + } + try { + const payload = JSON.parse(body) as { service?: string }; + resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME); + } catch { + resolve(false); + } + }); + } + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); +} + +export async function getOpenAICompatProxyStatus(): Promise { + const session = readOpenAICompatProxySession(); + const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; + const running = await isOpenAICompatProxyRunning(port); + return { + running, + pid: running ? getOpenAICompatProxyPid() || undefined : undefined, + ...session, + }; +} + +async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> { + const pid = getOpenAICompatProxyPid(); + if (!pid) { + removeOpenAICompatProxySession(); + return { success: true }; + } + + const ownership = verifyProcessOwnership( + pid, + (commandLine) => + commandLine.includes('--ccs-openai-proxy-daemon') && + commandLine.includes('proxy-daemon-entry') + ); + + if (ownership === 'not-owned') { + removeOpenAICompatProxyPid(); + return { success: true }; + } + + if (ownership === 'unknown') { + return { + success: false, + error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`, + }; + } + + if (ownership === 'not-running') { + removeOpenAICompatProxyPid(); + removeOpenAICompatProxySession(); + return { success: true }; + } + + try { + process.kill(pid, 'SIGTERM'); + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + process.kill(pid, 0); + attempts += 1; + } catch { + break; + } + } + + if (attempts >= 10) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already exited. + } + } + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ESRCH') { + return { success: false, error: `Failed to stop daemon: ${err.message}` }; + } + } + + removeOpenAICompatProxyPid(); + removeOpenAICompatProxySession(); + return { success: true }; +} + +export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> { + return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked()); +} + +export async function startOpenAICompatProxy( + profile: OpenAICompatProfileConfig, + options: { port?: number; insecure?: boolean } = {} +): Promise { + return withOpenAICompatProxyLock(async () => { + const status = await getOpenAICompatProxyStatus(); + const port = + typeof options.port === 'number' + ? options.port + : status.running && status.profileName === profile.profileName && status.port + ? status.port + : await findOpenAICompatProxyPort(); + if (port === 0) { + return { + success: false, + port: OPENAI_COMPAT_PROXY_DEFAULT_PORT, + error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + }; + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { success: false, port, error: `Invalid port: ${port}` }; + } + if (status.running && status.profileName === profile.profileName && status.port === port) { + return { + success: true, + alreadyRunning: true, + pid: status.pid, + port, + authToken: status.authToken, + }; + } + if (status.running) { + if (status.profileName !== profile.profileName) { + return { + success: false, + port, + error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`, + }; + } + + const stopped = await stopOpenAICompatProxyUnlocked(); + if (!stopped.success) { + return { + success: false, + port, + error: stopped.error || 'Failed to restart the running proxy', + }; + } + } + + const daemonEntry = await resolveDaemonEntrypoint(); + if (!daemonEntry) { + return { + success: false, + port, + error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.', + }; + } + + return new Promise((resolve) => { + let resolved = false; + let timeout: NodeJS.Timeout | null = null; + const authToken = generateProxyAuthToken(); + + const finish = (result: StartOpenAICompatProxyResult) => { + if (resolved) return; + resolved = true; + if (timeout) clearTimeout(timeout); + if (!result.success) { + removeOpenAICompatProxyPid(); + removeOpenAICompatProxySession(); + } + resolve(result); + }; + + const proc: ChildProcess = spawn( + process.execPath, + [ + daemonEntry, + '--port', + String(port), + '--profile', + profile.profileName, + '--settings-path', + profile.settingsPath, + '--auth-token', + authToken, + ...(options.insecure ? ['--insecure'] : []), + '--ccs-openai-proxy-daemon', + ], + { stdio: 'ignore', detached: true } + ); + + proc.unref(); + if (proc.pid) { + writeOpenAICompatProxyPid(proc.pid); + } + writeOpenAICompatProxySession({ + profileName: profile.profileName, + settingsPath: profile.settingsPath, + port, + baseUrl: profile.baseUrl, + authToken, + model: profile.model, + insecure: options.insecure, + }); + + let attempts = 0; + const poll = async () => { + attempts += 1; + if (await isOpenAICompatProxyRunning(port)) { + finish({ success: true, pid: proc.pid, port, authToken }); + return; + } + if (attempts >= 30) { + finish({ + success: false, + port, + error: `Proxy daemon did not start within 30 seconds on port ${port}`, + }); + return; + } + timeout = setTimeout(poll, 1000); + }; + + timeout = setTimeout(poll, 1000); + proc.on('error', (error) => { + finish({ success: false, port, error: error.message }); + }); + proc.on('exit', (code, signal) => { + if (code === 0) { + finish({ + success: false, + port, + error: 'Proxy daemon exited before becoming healthy', + }); + return; + } + if (code !== null) { + finish({ + success: false, + port, + error: `Proxy daemon exited with code ${code}`, + }); + return; + } + finish({ + success: false, + port, + error: `Proxy daemon was killed by signal ${signal}`, + }); + }); + }); + }); +} diff --git a/src/proxy/proxy-env.ts b/src/proxy/proxy-env.ts new file mode 100644 index 00000000..ca11813a --- /dev/null +++ b/src/proxy/proxy-env.ts @@ -0,0 +1,23 @@ +import type { OpenAICompatProfileConfig } from './profile-router'; + +export function buildOpenAICompatProxyEnv( + profile: OpenAICompatProfileConfig, + port: number, + authToken: string, + claudeConfigDir?: string +): Record { + return { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, + ANTHROPIC_AUTH_TOKEN: authToken, + ...(profile.model ? { ANTHROPIC_MODEL: profile.model } : {}), + ...(profile.opusModel ? { ANTHROPIC_DEFAULT_OPUS_MODEL: profile.opusModel } : {}), + ...(profile.sonnetModel ? { ANTHROPIC_DEFAULT_SONNET_MODEL: profile.sonnetModel } : {}), + ...(profile.haikuModel + ? { + ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.haikuModel, + ANTHROPIC_SMALL_FAST_MODEL: profile.haikuModel, + } + : {}), + ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), + }; +} diff --git a/src/proxy/server/http-helpers.ts b/src/proxy/server/http-helpers.ts new file mode 100644 index 00000000..77a4adbc --- /dev/null +++ b/src/proxy/server/http-helpers.ts @@ -0,0 +1,81 @@ +import * as http from 'http'; +import { Readable } from 'stream'; + +const MAX_BODY_SIZE = 10 * 1024 * 1024; + +export function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +export function readJsonBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + + const resolveOnce = (payload: unknown) => { + if (!settled) { + settled = true; + resolve(payload); + } + }; + + const rejectOnce = (error: Error) => { + if (!settled) { + settled = true; + reject(error); + } + }; + + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_BODY_SIZE) { + req.pause(); + rejectOnce(new Error('Request body too large (max 10MB)')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) { + resolveOnce({}); + return; + } + + try { + resolveOnce(JSON.parse(raw)); + } catch { + rejectOnce(new Error('Invalid JSON in request body')); + } + }); + + req.on('error', (error) => { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + }); + }); +} + +export async function pipeWebResponseToNode( + response: Response, + res: http.ServerResponse +): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (!response.body) { + res.end(); + return; + } + + const nodeStream = Readable.fromWeb(response.body as unknown as ReadableStream); + await new Promise((resolve, reject) => { + nodeStream.on('error', reject); + nodeStream.on('end', resolve); + nodeStream.pipe(res); + }); +} diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts new file mode 100644 index 00000000..49a66585 --- /dev/null +++ b/src/proxy/server/messages-route.ts @@ -0,0 +1,174 @@ +import * as http from 'http'; +import type { Dispatcher } from 'undici'; +import type { OpenAICompatProfileConfig } from '../profile-router'; +import { ProxyRequestTransformer } from '../transformers/request-transformer'; +import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer'; +import { resolveOpenAIChatCompletionsUrl } from '../upstream-url'; +import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers'; + +const REQUEST_TIMEOUT_MS = 30_000; + +class ProxyInputError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProxyInputError'; + } +} + +function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${profile.apiKey}`, + 'User-Agent': 'CCS-OpenAI-Compat-Proxy/1.0', + }; +} + +function buildUpstreamBody(profile: OpenAICompatProfileConfig, rawBody: unknown): string { + let transformed; + try { + const transformer = new ProxyRequestTransformer(); + transformed = transformer.transform(rawBody); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invalid Anthropic request'; + throw new ProxyInputError(message); + } + const body = { + ...transformed, + model: transformed.model || profile.model, + stream: transformed.stream === true, + }; + return JSON.stringify(body); +} + +export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null { + const xApiKey = headers['x-api-key']; + if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) { + return xApiKey.trim(); + } + + const anthropicApiKey = headers['anthropic-api-key']; + if (typeof anthropicApiKey === 'string' && anthropicApiKey.trim().length > 0) { + return anthropicApiKey.trim(); + } + + const authHeader = headers.authorization; + if (typeof authHeader === 'string' && authHeader.trim().length > 0) { + const trimmed = authHeader.trim(); + const bearerPrefix = 'Bearer '; + return trimmed.startsWith(bearerPrefix) ? trimmed.slice(bearerPrefix.length).trim() : trimmed; + } + + return null; +} + +export function validateIncomingProxyAuth( + headers: http.IncomingHttpHeaders, + expectedToken: string +): boolean { + return extractIncomingProxyToken(headers) === expectedToken; +} + +function buildFetchInit( + profile: OpenAICompatProfileConfig, + body: string, + signal: AbortSignal, + insecureDispatcher?: Dispatcher +): RequestInit { + const init: RequestInit = { + method: 'POST', + headers: buildUpstreamHeaders(profile), + body, + signal, + }; + + if (insecureDispatcher) { + (init as Record).dispatcher = insecureDispatcher; + } + + return init; +} + +export async function handleProxyMessagesRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + profile: OpenAICompatProfileConfig, + expectedAuthToken: string, + insecureDispatcher?: Dispatcher +): Promise { + const transformer = new ProxySseStreamTransformer(); + + if (!validateIncomingProxyAuth(req.headers, expectedAuthToken)) { + await pipeWebResponseToNode( + transformer.error(401, 'authentication_error', 'Missing or invalid local proxy token'), + res + ); + return; + } + + try { + const rawBody = await readJsonBody(req); + const upstreamBody = buildUpstreamBody(profile, rawBody); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const abortOnDisconnect = () => { + if (!controller.signal.aborted && !res.writableEnded) { + controller.abort(); + } + }; + + req.on('aborted', abortOnDisconnect); + req.on('close', abortOnDisconnect); + res.on('close', abortOnDisconnect); + + try { + const upstreamResponse = await fetch( + resolveOpenAIChatCompletionsUrl(profile.baseUrl), + buildFetchInit(profile, upstreamBody, controller.signal, insecureDispatcher) + ); + clearTimeout(timeout); + const response = await transformer.transform(upstreamResponse); + await pipeWebResponseToNode(response, res); + } finally { + clearTimeout(timeout); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown proxy error'; + const status = + error instanceof Error && error.name === 'AbortError' + ? 502 + : error instanceof ProxyInputError + ? 400 + : message.includes('Request body too large') + ? 413 + : message.includes('Invalid JSON') + ? 400 + : 502; + const type = status >= 500 ? 'api_error' : 'invalid_request_error'; + await pipeWebResponseToNode( + transformer.error( + status, + type, + error instanceof Error && error.name === 'AbortError' + ? 'The upstream provider did not respond within 30 seconds' + : message + ), + res + ); + } +} + +export function handleProxyModelsRequest( + res: http.ServerResponse, + profile: OpenAICompatProfileConfig +): void { + const data = [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .map((id) => ({ + id, + object: 'model', + created: 0, + owned_by: profile.provider, + })); + + writeJson(res, 200, { object: 'list', data }); +} diff --git a/src/proxy/server/proxy-server.ts b/src/proxy/server/proxy-server.ts new file mode 100644 index 00000000..b808d9ee --- /dev/null +++ b/src/proxy/server/proxy-server.ts @@ -0,0 +1,75 @@ +import * as http from 'http'; +import { Agent } from 'undici'; +import type { OpenAICompatProfileConfig } from '../profile-router'; +import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths'; +import { + handleProxyMessagesRequest, + handleProxyModelsRequest, + validateIncomingProxyAuth, +} from './messages-route'; +import { writeJson } from './http-helpers'; + +export interface OpenAICompatProxyServerOptions { + profile: OpenAICompatProfileConfig; + port: number; + authToken: string; + insecure?: boolean; +} + +export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOptions): http.Server { + const insecureDispatcher = options.insecure + ? new Agent({ connect: { rejectUnauthorized: false } }) + : undefined; + const server = http.createServer(async (req, res) => { + const method = req.method || 'GET'; + const requestUrl = req.url || '/'; + const parsedUrl = new URL(requestUrl, 'http://127.0.0.1'); + const pathname = + parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname; + + if (method === 'GET' && pathname === '/health') { + writeJson(res, 200, { + ok: true, + service: OPENAI_COMPAT_PROXY_SERVICE_NAME, + profile: options.profile.profileName, + port: options.port, + }); + return; + } + + if (method === 'GET' && pathname === '/v1/models') { + if (!validateIncomingProxyAuth(req.headers, options.authToken)) { + writeJson(res, 401, { + type: 'error', + error: { + type: 'authentication_error', + message: 'Missing or invalid local proxy token', + }, + }); + return; + } + handleProxyModelsRequest(res, options.profile); + return; + } + + if (method === 'POST' && pathname === '/v1/messages') { + await handleProxyMessagesRequest( + req, + res, + options.profile, + options.authToken, + insecureDispatcher + ); + return; + } + + writeJson(res, 404, { error: 'Not found' }); + }); + + server.on('close', () => { + void insecureDispatcher?.close(); + }); + + server.listen(options.port, '127.0.0.1'); + return server; +} diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts new file mode 100644 index 00000000..1804d426 --- /dev/null +++ b/src/proxy/transformers/request-transformer.ts @@ -0,0 +1,89 @@ +import { translateAnthropicRequest } from '../../cursor/cursor-anthropic-translator'; + +interface AnthropicProxyRequestShape { + max_tokens?: unknown; + temperature?: unknown; + top_p?: unknown; + stop_sequences?: unknown; + metadata?: unknown; + tools?: unknown; +} + +export interface ProxyOpenAIRequest extends ReturnType { + max_tokens?: number; + temperature?: number; + top_p?: number; + stop?: string[]; + metadata?: Record; + tools?: Array<{ + type: 'function'; + function: { + name: string; + description?: string; + parameters: Record; + }; + }>; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const result = value.filter( + (entry): entry is string => typeof entry === 'string' && entry.length > 0 + ); + return result.length > 0 ? result : undefined; +} + +function asMetadata(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function transformTools(value: unknown): ProxyOpenAIRequest['tools'] { + if (!Array.isArray(value)) { + return undefined; + } + + const tools = value + .filter( + (entry): entry is { name?: unknown; description?: unknown; input_schema?: unknown } => + typeof entry === 'object' && entry !== null + ) + .map((entry) => ({ + type: 'function' as const, + function: { + name: typeof entry.name === 'string' ? entry.name : 'tool', + ...(typeof entry.description === 'string' ? { description: entry.description } : {}), + parameters: + typeof entry.input_schema === 'object' && entry.input_schema !== null + ? (entry.input_schema as Record) + : { type: 'object', properties: {} }, + }, + })); + + return tools.length > 0 ? tools : undefined; +} + +export class ProxyRequestTransformer { + transform(raw: unknown): ProxyOpenAIRequest { + const translated = translateAnthropicRequest(raw); + const source = (raw || {}) as AnthropicProxyRequestShape; + + return { + ...translated, + max_tokens: asNumber(source.max_tokens), + temperature: asNumber(source.temperature), + top_p: asNumber(source.top_p), + stop: asStringArray(source.stop_sequences), + metadata: asMetadata(source.metadata), + tools: transformTools(source.tools), + }; + } +} diff --git a/src/proxy/transformers/sse-stream-transformer.ts b/src/proxy/transformers/sse-stream-transformer.ts new file mode 100644 index 00000000..53774c21 --- /dev/null +++ b/src/proxy/transformers/sse-stream-transformer.ts @@ -0,0 +1,14 @@ +import { + createAnthropicErrorResponse, + createAnthropicProxyResponse, +} from '../../cursor/cursor-anthropic-response'; + +export class ProxySseStreamTransformer { + async transform(response: Response): Promise { + return createAnthropicProxyResponse(response); + } + + error(status: number, type: string, message: string): Response { + return createAnthropicErrorResponse(status, type, message); + } +} diff --git a/src/proxy/upstream-url.ts b/src/proxy/upstream-url.ts new file mode 100644 index 00000000..d41d89b6 --- /dev/null +++ b/src/proxy/upstream-url.ts @@ -0,0 +1,36 @@ +function normalizePathname(pathname: string): string { + const trimmed = pathname.replace(/\/+$/, ''); + return trimmed || ''; +} + +function ensureSupportedProtocol(parsed: URL): void { + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported upstream protocol: ${parsed.protocol}`); + } +} + +function buildResolvedUrl(baseUrl: string, suffix: string): string { + const parsed = new URL(baseUrl); + ensureSupportedProtocol(parsed); + + const pathname = normalizePathname(parsed.pathname); + if (pathname.endsWith(suffix)) { + return parsed.toString(); + } + + if (pathname.endsWith('/v1') || pathname.endsWith('/api')) { + parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`; + return parsed.toString(); + } + + parsed.pathname = pathname ? `${pathname}/v1${suffix}` : `/v1${suffix}`; + return parsed.toString(); +} + +export function resolveOpenAIChatCompletionsUrl(baseUrl: string): string { + return buildResolvedUrl(baseUrl, '/chat/completions'); +} + +export function resolveOpenAIModelsUrl(baseUrl: string): string { + return buildResolvedUrl(baseUrl, '/models'); +} From 5c66c1f12162306d1039ec2e255ad9894199d1f4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 19:27:19 -0400 Subject: [PATCH 04/59] test(proxy): cover proxy lifecycle and routing --- tests/e2e/openai-provider-routing.e2e.test.ts | 188 ++++++++++++++++ tests/e2e/proxy-command.e2e.test.ts | 88 ++++++++ .../proxy/daemon-lifecycle.test.ts | 127 +++++++++++ .../proxy/messages-endpoint.test.ts | 204 ++++++++++++++++++ tests/unit/glmt/sse-parser.test.ts | 35 +++ tests/unit/proxy/profile-router.test.ts | 40 ++++ .../transformers/request-transformer.test.ts | 63 ++++++ 7 files changed, 745 insertions(+) create mode 100644 tests/e2e/openai-provider-routing.e2e.test.ts create mode 100644 tests/e2e/proxy-command.e2e.test.ts create mode 100644 tests/integration/proxy/daemon-lifecycle.test.ts create mode 100644 tests/integration/proxy/messages-endpoint.test.ts create mode 100644 tests/unit/glmt/sse-parser.test.ts create mode 100644 tests/unit/proxy/profile-router.test.ts create mode 100644 tests/unit/proxy/transformers/request-transformer.test.ts diff --git a/tests/e2e/openai-provider-routing.e2e.test.ts b/tests/e2e/openai-provider-routing.e2e.test.ts new file mode 100644 index 00000000..cc2d823e --- /dev/null +++ b/tests/e2e/openai-provider-routing.e2e.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn, spawnSync } from 'child_process'; +import getPort from 'get-port'; + +const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js'); + +let originalCcsHome: string | undefined; +let tempDir: string; +let upstreamServer: http.Server; +let upstreamBody: unknown; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-e2e-')); + upstreamBody = undefined; +}); + +afterEach(() => { + try { + upstreamServer?.close(); + } catch { + // Best-effort cleanup. + } + spawnSync(process.execPath, [DIST_ENTRY, 'proxy', 'stop'], { + env: { ...process.env, CCS_HOME: tempDir }, + }); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +function startMockUpstream(port: number): Promise { + return new Promise((resolve) => { + upstreamServer = http.createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + upstreamBody = JSON.parse(body); + + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' + ); + res.end('data: [DONE]\n\n'); + }); + upstreamServer.listen(port, '127.0.0.1', () => resolve()); + }); +} + +function runCli(args: string[], env: Record): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [DIST_ENTRY, ...args], { + env: { + ...process.env, + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('close', (code) => { + resolve({ code, stdout, stderr }); + }); + }); +} + +beforeAll(() => { + const result = spawnSync(process.execPath, ['run', 'build'], { + encoding: 'utf8', + env: process.env, + }); + expect(result.status).toBe(0); +}); + +describe('openai provider routing e2e', () => { + it('routes a settings profile through the local proxy into an OpenAI-compatible upstream', async () => { + const upstreamPort = await getPort(); + await startMockUpstream(upstreamPort); + + const ccsDir = path.join(tempDir, '.ccs'); + const binDir = path.join(tempDir, 'bin'); + const outputPath = path.join(tempDir, 'claude-output.json'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + + const settingsPath = path.join(ccsDir, 'hf.settings.json'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { hf: settingsPath } }, null, 2), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'hf-model', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + fs.writeFileSync( + path.join(binDir, 'claude'), + `#!/usr/bin/env node +const fs = require('fs'); +(async () => { + const response = await fetch(\`\${process.env.ANTHROPIC_BASE_URL}/v1/messages\`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': process.env.ANTHROPIC_AUTH_TOKEN, + }, + body: JSON.stringify({ + model: process.env.ANTHROPIC_MODEL, + stream: true, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: 'Find docs' }], + }), + }); + const text = await response.text(); + fs.writeFileSync(process.env.CCS_E2E_OUTPUT, JSON.stringify({ + status: response.status, + baseUrl: process.env.ANTHROPIC_BASE_URL, + authToken: process.env.ANTHROPIC_AUTH_TOKEN, + text + }, null, 2)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`, + { mode: 0o755 } + ); + + const result = await runCli(['hf'], { + ...process.env, + CCS_HOME: tempDir, + CCS_E2E_OUTPUT: outputPath, + PATH: `${binDir}:${process.env.PATH || ''}`, + }); + + expect(result.code).toBe(0); + const payload = JSON.parse(fs.readFileSync(outputPath, 'utf8')) as { + status: number; + baseUrl: string; + authToken: string; + text: string; + }; + + expect(payload.status).toBe(200); + expect(payload.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(payload.authToken).toMatch(/^[a-f0-9]{48}$/); + expect(payload.text).toContain('event: message_start'); + expect(payload.text).toContain('tool_use'); + expect(payload.text).toContain('message_stop'); + + const parsedUpstream = upstreamBody as { + messages?: Array<{ role: string; content: string }>; + tools?: Array<{ type: string }>; + }; + expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' }); + expect(parsedUpstream.tools?.[0]?.type).toBe('function'); + }, 35000); +}); diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts new file mode 100644 index 00000000..550ee8a2 --- /dev/null +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import getPort from 'get-port'; + +const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js'); + +let originalCcsHome: string | undefined; +let tempDir: string; + +function runCli(args: string[], extraEnv: Record = {}) { + return spawnSync(process.execPath, [DIST_ENTRY, ...args], { + encoding: 'utf8', + env: { + ...process.env, + CCS_HOME: tempDir, + ...extraEnv, + }, + }); +} + +beforeAll(() => { + const result = spawnSync(process.execPath, ['run', 'build'], { + encoding: 'utf8', + env: process.env, + }); + expect(result.status).toBe(0); +}); + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-e2e-')); +}); + +afterEach(() => { + runCli(['proxy', 'stop']); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('proxy command e2e', () => { + it('starts, reports status, activates, and stops via the built CLI', async () => { + const port = await getPort(); + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const settingsPath = path.join(ccsDir, 'hf.settings.json'); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { hf: settingsPath } }, null, 2), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const started = runCli(['proxy', 'start', 'hf', '--port', String(port)]); + expect(started.status).toBe(0); + + const status = runCli(['proxy', 'status']); + expect(status.stdout).toContain(`Proxy running on port ${port}`); + expect(status.stdout).toContain('Profile: hf'); + + const activate = runCli(['proxy', 'activate', '--shell', 'bash']); + expect(activate.stdout).toContain(`export ANTHROPIC_BASE_URL='http://127.0.0.1:${port}'`); + expect(activate.stdout).toMatch(/export ANTHROPIC_AUTH_TOKEN='[a-f0-9]{48}'/); + + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + + const stopped = runCli(['proxy', 'stop']); + expect(stopped.status).toBe(0); + }, 35000); +}); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts new file mode 100644 index 00000000..5a5aa86d --- /dev/null +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import getPort from 'get-port'; +import { + getOpenAICompatProxyStatus, + startOpenAICompatProxy, + stopOpenAICompatProxy, +} from '../../../src/proxy/proxy-daemon'; +import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-proxy-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(async () => { + await stopOpenAICompatProxy(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('openai proxy daemon lifecycle', () => { + it('starts, reports status, serves health/models, and stops', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'hf.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('hf', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected an OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port }); + expect(started.success).toBe(true); + expect(started.authToken).toBeTruthy(); + + const status = await getOpenAICompatProxyStatus(); + expect(status.running).toBe(true); + expect(status.profileName).toBe('hf'); + expect(status.authToken).toBe(started.authToken); + + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + + const models = (await ( + await fetch(`http://127.0.0.1:${port}/v1/models`, { + headers: { 'x-api-key': started.authToken! }, + }) + ).json()) as { data?: Array<{ id: string }> }; + expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']); + + const stopped = await stopOpenAICompatProxy(); + expect(stopped.success).toBe(true); + expect((await getOpenAICompatProxyStatus()).running).toBe(false); + }, 35000); + + it('refuses to replace a running proxy for a different profile', async () => { + const firstPort = await getPort(); + const firstSettingsPath = path.join(tempDir, 'hf.settings.json'); + fs.writeFileSync( + firstSettingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + const firstProfile = resolveOpenAICompatProfileConfig('hf', firstSettingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!firstProfile) { + throw new Error('Expected first OpenAI-compatible profile'); + } + + const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort }); + expect(firstStart.success).toBe(true); + + const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-openai', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!secondProfile) { + throw new Error('Expected second OpenAI-compatible profile'); + } + + const secondStart = await startOpenAICompatProxy(secondProfile, { port: await getPort() }); + expect(secondStart.success).toBe(false); + expect(secondStart.error).toContain('Proxy already running for profile "hf"'); + + const health = await fetch(`http://127.0.0.1:${firstPort}/health`); + expect(health.status).toBe(200); + }); +}); diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts new file mode 100644 index 00000000..ee96ce4c --- /dev/null +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -0,0 +1,204 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import getPort from 'get-port'; +import * as http from 'http'; +import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; +import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +let upstreamServer: http.Server; +let proxyServer: http.Server; +let upstreamBody: unknown; +let upstreamPort: number; +let proxyPort: number; + +function startMockUpstream(): Promise { + return new Promise((resolve) => { + upstreamServer = http.createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { + res.writeHead(404).end(); + return; + } + + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + upstreamBody = JSON.parse(body); + const parsed = upstreamBody as { stream?: boolean }; + + if (parsed.stream) { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' + ); + res.end('data: [DONE]\n\n'); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'hf-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Plain answer' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); + }); + + upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()); + }); +} + +async function requestProxy(payload: unknown): Promise { + return fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': 'test-proxy-token', + }, + body: JSON.stringify(payload), + }); +} + +beforeEach(async () => { + upstreamPort = await getPort(); + proxyPort = await getPort(); + upstreamBody = undefined; + await startMockUpstream(); + const profile: OpenAICompatProfileConfig = { + profileName: 'hf', + settingsPath: '/tmp/hf.settings.json', + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'hf_token', + provider: 'generic-chat-completion-api', + model: 'hf-model', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: proxyPort, + authToken: 'test-proxy-token', + }); +}); + +afterEach(async () => { + await new Promise((resolve) => proxyServer.close(() => resolve())); + await new Promise((resolve) => upstreamServer.close(() => resolve())); +}); + +describe('openai proxy messages endpoint', () => { + it('translates Anthropic requests to OpenAI upstream and streams Anthropic SSE back', async () => { + const response = await requestProxy({ + model: 'hf-model', + stream: true, + messages: [{ role: 'user', content: [{ type: 'text', text: 'Find docs' }] }], + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + }); + + const body = await response.text(); + expect(response.status).toBe(200); + expect(body).toContain('event: message_start'); + expect(body).toContain('content_block_delta'); + expect(body).toContain('tool_use'); + expect(body).toContain('message_stop'); + + const parsedUpstream = upstreamBody as { + messages?: Array<{ role: string; content: string }>; + tools?: Array<{ type: string; function: { name: string } }>; + }; + expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' }); + expect(parsedUpstream.tools?.[0]?.type).toBe('function'); + expect(parsedUpstream.tools?.[0]?.function.name).toBe('search'); + }); + + it('falls back to Anthropic JSON for non-streaming requests', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }); + const body = (await response.json()) as { + type?: string; + content?: Array<{ type?: string; text?: string }>; + }; + + expect(response.status).toBe(200); + expect(body.type).toBe('message'); + expect(body.content?.[0]).toEqual({ type: 'text', text: 'Plain answer' }); + }); + + it('returns invalid_request_error for malformed JSON', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': 'test-proxy-token', + }, + body: '{bad-json', + }); + const body = (await response.json()) as { error?: { type?: string; message?: string } }; + + expect(response.status).toBe(400); + expect(body.error?.type).toBe('invalid_request_error'); + expect(body.error?.message).toContain('Invalid JSON'); + }); + + it('rejects requests without the local proxy auth token', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + const body = (await response.json()) as { error?: { type?: string } }; + + expect(response.status).toBe(401); + expect(body.error?.type).toBe('authentication_error'); + }); + + it('returns invalid_request_error for unsupported Anthropic content blocks', async () => { + const response = await requestProxy({ + model: 'hf-model', + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }); + const body = (await response.json()) as { error?: { type?: string; message?: string } }; + + expect(response.status).toBe(400); + expect(body.error?.type).toBe('invalid_request_error'); + expect(body.error?.message).toContain('is not supported'); + }); + + it('accepts query strings and trailing slashes on the messages route', async () => { + const response = await fetch(`http://127.0.0.1:${proxyPort}/v1/messages/?beta=test`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + authorization: 'Bearer test-proxy-token', + }, + body: JSON.stringify({ + model: 'hf-model', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/tests/unit/glmt/sse-parser.test.ts b/tests/unit/glmt/sse-parser.test.ts new file mode 100644 index 00000000..10f14177 --- /dev/null +++ b/tests/unit/glmt/sse-parser.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test'; +import { SSEParser } from '../../../src/glmt/sse-parser'; + +describe('SSEParser', () => { + it('merges multi-line data fields into one JSON payload', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + const events = parser.parse( + [ + 'event: message', + 'data: {"choices":[', + 'data: {"delta":{"content":"Hello"}}', + 'data: ]}', + '', + '', + ].join('\n') + ); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Hello'); + }); + + it('keeps incomplete events buffered until the separator arrives', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + expect(parser.parse('data: {"choices":[{"delta":{"content":"Hi"}}]}')).toEqual([]); + const events = parser.parse('\n\n'); + + expect(events).toHaveLength(1); + expect((events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0]?.delta?.content).toBe( + 'Hi' + ); + }); +}); diff --git a/tests/unit/proxy/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts new file mode 100644 index 00000000..76bfe7be --- /dev/null +++ b/tests/unit/proxy/profile-router.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; + +describe('resolveOpenAICompatProfileConfig', () => { + it('detects generic OpenAI-compatible profiles from base URL and provider hint', () => { + const result = resolveOpenAICompatProfileConfig('hf', '/tmp/hf.settings.json', { + ANTHROPIC_BASE_URL: 'https://router.huggingface.co/v1', + ANTHROPIC_AUTH_TOKEN: 'hf_token', + ANTHROPIC_MODEL: 'openai/gpt-oss-120b:fastest', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('generic-chat-completion-api'); + expect(result?.profileName).toBe('hf'); + }); + + it('detects official OpenAI-style endpoints', () => { + const result = resolveOpenAICompatProfileConfig('openai', '/tmp/openai.settings.json', { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-openai', + ANTHROPIC_MODEL: 'gpt-4.1', + CCS_OPENAI_PROXY_INSECURE: 'true', + }); + + expect(result?.provider).toBe('openai'); + expect(result?.model).toBe('gpt-4.1'); + expect(result?.insecure).toBe(true); + }); + + it('ignores Anthropic-compatible profiles', () => { + const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'glm-key', + ANTHROPIC_MODEL: 'glm-5', + }); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts new file mode 100644 index 00000000..e3d6afad --- /dev/null +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test'; +import { ProxyRequestTransformer } from '../../../../src/proxy/transformers/request-transformer'; + +describe('ProxyRequestTransformer', () => { + it('translates Anthropic messages into OpenAI-compatible chat payloads', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + model: 'claude-sonnet-4.5', + stream: true, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Find release notes' }] }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'v7.69.1' }], + }, + ], + thinking: { type: 'enabled', budget_tokens: 9000 }, + max_tokens: 1024, + temperature: 0.2, + top_p: 0.9, + stop_sequences: ['STOP'], + metadata: { trace: 'abc' }, + }); + + expect(result.stream).toBe(true); + expect(result.reasoning_effort).toBe('high'); + expect(result.max_tokens).toBe(1024); + expect(result.temperature).toBe(0.2); + expect(result.top_p).toBe(0.9); + expect(result.stop).toEqual(['STOP']); + expect(result.metadata).toEqual({ trace: 'abc' }); + expect(result.messages[0]).toEqual({ role: 'user', content: 'Find release notes' }); + expect(result.messages[1]?.tool_calls?.[0]?.function.name).toBe('search'); + expect(result.messages[2]).toEqual({ + role: 'tool', + tool_call_id: 'toolu_1', + content: 'v7.69.1', + }); + }); + + it('drops malformed optional fields but preserves the translated core request', () => { + const transformer = new ProxyRequestTransformer(); + const result = transformer.transform({ + messages: [{ role: 'user', content: 'hello' }], + max_tokens: 'bad', + temperature: 'bad', + top_p: 'bad', + stop_sequences: ['A', 1], + metadata: 'bad', + }); + + expect(result.messages).toEqual([{ role: 'user', content: 'hello' }]); + expect(result.max_tokens).toBeUndefined(); + expect(result.temperature).toBeUndefined(); + expect(result.top_p).toBeUndefined(); + expect(result.stop).toEqual(['A']); + expect(result.metadata).toBeUndefined(); + }); +}); From 7b6485249530c890d9bf3549221dcbd0f3f6d18f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 19:27:38 -0400 Subject: [PATCH 05/59] docs(proxy): document openai-compatible routing --- README.md | 19 ++++ docs/openai-compatible-providers.md | 135 ++++++++++++++++++++++++++++ docs/project-roadmap.md | 3 +- 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 docs/openai-compatible-providers.md diff --git a/README.md b/README.md index cb981b5b..dbc10eb3 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,25 @@ ccs glm ccs ollama ``` +## OpenAI-Compatible Routing + +CCS can now bridge Claude Code into OpenAI-compatible providers through a local +Anthropic-compatible proxy instead of requiring a native Anthropic upstream. + +```bash +ccs api create --preset hf +ccs hf +``` + +Need to manage the proxy manually? + +```bash +ccs proxy start hf +eval "$(ccs proxy activate)" +``` + +Guide: [OpenAI-Compatible Provider Routing](./docs/openai-compatible-providers.md) + Need the full setup path instead of the short version? | Need | Start here | diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md new file mode 100644 index 00000000..9a8a0466 --- /dev/null +++ b/docs/openai-compatible-providers.md @@ -0,0 +1,135 @@ +# OpenAI-Compatible Provider Routing + +CCS can route Claude Code traffic through a local Anthropic-compatible proxy when +your API profile points at an OpenAI-compatible chat completions endpoint. + +This is useful for providers such as: + +- Hugging Face Inference Providers +- OpenRouter +- Ollama +- llama.cpp servers +- OpenAI-compatible self-hosted gateways + +## What CCS Does + +When you launch a compatible settings profile with the Claude target, CCS now: + +1. Starts a local proxy on `127.0.0.1` +2. Accepts Anthropic `/v1/messages` traffic from Claude Code +3. Translates requests into OpenAI chat-completions format +4. Forwards them to your configured upstream provider +5. Translates streaming responses back into Anthropic SSE + +You do not need to rewrite your profile by hand each time. + +## Quick Start + +Create or reuse an API profile that points at an OpenAI-compatible endpoint: + +```bash +ccs api create --preset hf +``` + +Then you can use the profile directly: + +```bash +ccs hf +``` + +CCS detects that the profile is OpenAI-compatible and auto-routes Claude Code +through the local proxy. + +## Manual Proxy Lifecycle + +If you want to manage the proxy explicitly: + +```bash +ccs proxy start hf +eval "$(ccs proxy activate)" +ccs proxy status +ccs proxy stop +``` + +`ccs proxy activate` prints the `ANTHROPIC_*` exports needed for a local +Anthropic-compatible session against the running proxy. + +## One Active Proxy Profile + +The current runtime is a single local proxy daemon. + +- Reusing the same OpenAI-compatible profile is supported +- Starting a different OpenAI-compatible profile while one proxy is already + running is rejected instead of silently replacing the active upstream + +This is intentional to avoid breaking an in-flight Claude session by swapping +its upstream provider out from under it. + +## How Profile Detection Works + +CCS keeps these profiles in the normal API/settings-profile flow. + +Anthropic-compatible endpoints such as: + +- `https://api.anthropic.com` +- `https://api.z.ai/api/anthropic` +- `https://api.deepseek.com/anthropic` + +continue to launch directly. + +OpenAI-compatible endpoints such as: + +- `https://router.huggingface.co/v1` +- `https://api.openai.com/v1` +- `http://localhost:11434` + +are routed through the local proxy for Claude-target launches. + +## Self-Signed TLS + +If your upstream gateway uses a self-signed or privately issued certificate, +set this in the profile settings JSON: + +```json +{ + "env": { + "CCS_OPENAI_PROXY_INSECURE": "1" + } +} +``` + +That flag is respected by both: + +- `ccs ` auto-routing +- `ccs proxy start ` + +## Supported Runtime Paths + +- `ccs ` with Claude target: auto-starts the local proxy when needed +- `ccs proxy start `: starts the proxy explicitly +- `GET /health`: proxy liveness check +- `GET /v1/models`: local view of the configured model mapping +- `POST /v1/messages`: Anthropic-compatible request entrypoint + +## Validation + +The shipped coverage includes: + +- unit tests for OpenAI-compatible profile detection +- unit tests for Anthropic -> OpenAI request translation +- unit tests for multi-line SSE parsing +- integration tests for `/v1/messages` request/response translation +- integration tests for daemon lifecycle and `/health` / `/v1/models` +- e2e tests for `ccs proxy` lifecycle +- e2e tests for `ccs ` auto-routing through a mock upstream + +## Current Scope + +The current implementation focuses on the core routing path: + +- local proxy lifecycle +- Anthropic/OpenAI request-response translation +- Claude-target settings profile auto-routing + +Scenario-based routing and token-count-driven model switching remain follow-up +work if they are needed beyond the base provider-routing flow. diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 4f977fdd..3b82689d 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-10 +Last Updated: 2026-04-14 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The new `ccs proxy` command supports `start`, `status`, `activate`, and `stop`, the runtime exposes `/health` and `/v1/models`, the shared SSE parser now handles multi-line `data:` payloads, and the feature ships with dedicated unit, integration, and e2e coverage. - **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`. - **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`. - **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. From 154c7d0e9c576597b94d3e0f1f155d55a5f0d46d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 14 Apr 2026 23:32:17 +0000 Subject: [PATCH 06/59] chore(release): 7.71.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 566950a8..85504189 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0", + "version": "7.71.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 72ea1fc9d69a8958b045c74eade7beb8cdd7b6bf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 21:01:48 -0400 Subject: [PATCH 07/59] fix(accounts): simplify codex free tier badges --- .../accounts/email-account-identity.ts | 31 +++++- .../account/flow-viz/account-card.tsx | 97 ++++++++++++------- .../account/shared/account-surface-card.tsx | 66 ++++++++----- .../setup/wizard/steps/account-step.tsx | 4 +- .../setup/wizard/steps/variant-step.tsx | 4 +- ui/src/lib/account-identity.ts | 35 ++++--- ui/src/lib/account-visual-groups.ts | 3 +- ui/src/lib/i18n.ts | 4 + .../account/flow-viz/account-card.test.tsx | 29 +++--- .../shared/account-surface-card.test.tsx | 27 +++++- ui/tests/unit/ui/lib/account-identity.test.ts | 23 ++++- 11 files changed, 227 insertions(+), 96 deletions(-) diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts index 35c36bd1..7d6c73a1 100644 --- a/src/cliproxy/accounts/email-account-identity.ts +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -1,6 +1,9 @@ import type { CLIProxyProvider } from '../types'; const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set(['codex']); +const FREE_PLAN_PARTS = new Set(['free']); +const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']); +const BUSINESS_PLAN_PARTS = new Set(['team']); // Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is // separate because the browser bundle cannot import this server module directly. @@ -49,6 +52,20 @@ function formatVariantPart(value: string): string { } } +function formatWorkspaceLabel(parts: string[]): string | null { + const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part)); + if (workspaceId) { + return `Workspace ${workspaceId.toLowerCase()}`; + } + + return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null; +} + +function formatAudienceDetail(parts: string[]): string | null { + const label = parts.map(formatVariantPart).filter(Boolean).join(' · '); + return label || null; +} + export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean { return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider)); } @@ -136,10 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st } const suffix = parts[parts.length - 1]?.toLowerCase(); - if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) { - return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)] - .filter(Boolean) - .join(' · '); + if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) { + return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · '); + } + + if (suffix && FREE_PLAN_PARTS.has(suffix)) { + return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · '); + } + + if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) { + return ['Personal', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · '); } return parts.map(formatVariantPart).filter(Boolean).join(' · '); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index e1ecf92f..7b6f399f 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -13,7 +13,6 @@ import { getProviderResetTime, getQuotaFailureInfo, } from '@/lib/utils'; -import { formatAccountVariantPart } from '@/lib/account-identity'; import { GripVertical, Loader2, Pause, Play } from 'lucide-react'; import { useAccountQuota, @@ -28,6 +27,7 @@ import { AccountCardStats } from './account-card-stats'; import { cleanEmail } from './utils'; type Zone = 'left' | 'right' | 'top' | 'bottom'; +type AccountAudience = 'business' | 'free' | 'personal' | 'unknown'; const QUOTA_PROVIDER_ALIASES = [ 'antigravity', @@ -88,46 +88,57 @@ function getCompactQuotaColor(percentage: number) { return 'bg-red-500'; } -function getCodexPlanDetailLabel(quota: unknown): string | null { +function getCodexPlanAudience(quota: unknown): AccountAudience { if (!quota || typeof quota !== 'object' || !('planType' in quota)) { - return null; + return 'unknown'; } const planType = (quota as { planType?: unknown }).planType; if (typeof planType !== 'string' || planType.trim().length === 0) { - return null; + return 'unknown'; } - return formatAccountVariantPart(planType); + if (planType === 'team') return 'business'; + if (planType === 'free') return 'free'; + if (planType === 'plus') return 'personal'; + return 'unknown'; } -function getVariantDetailLabel( +function getVariantDetailLabel(variant: { + audience: AccountAudience; + detailLabel?: string | null; + compactDetailLabel?: string | null; +}) { + return variant.detailLabel ?? variant.compactDetailLabel ?? null; +} + +function getVariantCompactDetailLabel(variant: { + audience: AccountAudience; + compactDetailLabel?: string | null; + detailLabel?: string | null; +}) { + return variant.compactDetailLabel ?? variant.detailLabel ?? null; +} + +function getDetailedAudienceLabel(audience: AccountAudience): string | null { + if (audience === 'business') return 'Business'; + if (audience === 'free') return 'Free'; + if (audience === 'personal') return 'Personal'; + return null; +} + +function getVariantAudience( variant: { - audience: string; - detailLabel?: string | null; - compactDetailLabel?: string | null; + audience: AccountAudience; }, quota?: unknown ) { - const codexPlanDetail = getCodexPlanDetailLabel(quota); - return variant.detailLabel ?? variant.compactDetailLabel ?? codexPlanDetail; -} - -function getVariantCompactDetailLabel( - variant: { - audience: string; - compactDetailLabel?: string | null; - detailLabel?: string | null; - }, - quota?: unknown -) { - const codexPlanDetail = getCodexPlanDetailLabel(quota); - return variant.compactDetailLabel ?? variant.detailLabel ?? codexPlanDetail; + return variant.audience !== 'unknown' ? variant.audience : getCodexPlanAudience(quota); } function getVariantInlineLabel( variant: { - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; @@ -135,14 +146,16 @@ function getVariantInlineLabel( }, quota?: unknown ) { - const detailLabel = getVariantDetailLabel(variant, quota); - const composedLabel = [variant.audienceLabel, detailLabel].filter(Boolean).join(' · '); + const detailLabel = getVariantDetailLabel(variant); + const audienceLabel = + variant.audienceLabel ?? getDetailedAudienceLabel(getVariantAudience(variant, quota)); + const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · '); return composedLabel || variant.inlineLabel || null; } function getVariantMarkerLabel( variant: { - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; @@ -150,23 +163,30 @@ function getVariantMarkerLabel( audienceCounts: Map, quota?: unknown ) { - const compactDetailLabel = getVariantCompactDetailLabel(variant, quota); - if (variant.audience === 'business') { + const audience = getVariantAudience(variant, quota); + const compactDetailLabel = getVariantCompactDetailLabel(variant); + if (audience === 'business') { const businessVariantCount = audienceCounts.get('business') ?? 0; return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz'; } - if (variant.audience === 'personal') { + if (audience === 'free') { + return compactDetailLabel ?? 'Free'; + } + if (audience === 'personal') { return compactDetailLabel ?? 'Pers'; } const normalizedFallback = - compactDetailLabel?.trim() || variant.audienceLabel?.trim() || variant.detailLabel?.trim(); + compactDetailLabel?.trim() || + variant.audienceLabel?.trim() || + getDetailedAudienceLabel(audience) || + variant.detailLabel?.trim(); return normalizedFallback?.[0]?.toUpperCase() ?? '?'; } function getGroupedVariantSummaryLabel( variants: Array<{ - audience: string; + audience: AccountAudience; audienceLabel?: string | null; detailLabel?: string | null; compactDetailLabel?: string | null; @@ -176,7 +196,10 @@ function getGroupedVariantSummaryLabel( ) { const audiences = new Set(variants.map((variant) => variant.audience)); const hasDistinctDetails = variants.some((variant, index) => - Boolean(getVariantCompactDetailLabel(variant, quotas[index])) + Boolean( + getVariantCompactDetailLabel(variant) || + getDetailedAudienceLabel(getVariantAudience(variant, quotas[index])) + ) ); if ( @@ -274,9 +297,11 @@ export function AccountCard({ index > 0 && 'border-l border-border/50', variant.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300' - : variant.audience === 'personal' - ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' - : 'bg-muted text-muted-foreground' + : variant.audience === 'free' + ? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200' + : variant.audience === 'personal' + ? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300' + : 'bg-muted text-muted-foreground' )} > {getVariantMarkerLabel( diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index aec49882..21657220 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats'; -import { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity'; +import { getAccountIdentityPresentation } from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { Pause, Star, User } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import { AccountQuotaPanel } from './account-quota-panel'; type AccountSurfaceMode = 'compact' | 'detailed'; +type AccountAudience = 'business' | 'free' | 'personal' | 'unknown'; type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; interface AccountSurfaceCardProps { @@ -36,11 +37,15 @@ interface AccountSurfaceCardProps { className?: string; } -function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') { +function getAudienceBadgeClass(audience: AccountAudience) { if (audience === 'business') { return 'bg-sky-500/12 text-sky-700 dark:text-sky-300'; } + if (audience === 'free') { + return 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'; + } + if (audience === 'personal') { return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'; } @@ -54,20 +59,29 @@ function getTierBadgeClass(tier: AccountTier | undefined) { : 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'; } -function getCompactAudienceBadgeLabel( - audience: 'business' | 'personal' | 'unknown', - t: (key: string) => string -) { +function getCompactAudienceBadgeLabel(audience: AccountAudience, t: (key: string) => string) { if (audience === 'business') return t('accountSurfaceCard.business'); + if (audience === 'free') return t('accountSurfaceCard.free'); if (audience === 'personal') return t('accountSurfaceCard.personal'); return '?'; } -function getCompactDetailBadgeClass(audience: 'business' | 'personal' | 'unknown') { +function getDetailedAudienceLabel(audience: AccountAudience): string | null { + if (audience === 'business') return 'Business'; + if (audience === 'free') return 'Free'; + if (audience === 'personal') return 'Personal'; + return null; +} + +function getCompactDetailBadgeClass(audience: AccountAudience) { if (audience === 'business') { return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-200'; } + if (audience === 'free') { + return 'border-slate-300/70 bg-slate-100/80 text-slate-700 dark:border-slate-500/40 dark:bg-slate-700/30 dark:text-slate-200'; + } + if (audience === 'personal') { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-500/15 dark:text-emerald-200'; } @@ -88,12 +102,15 @@ function resolveEffectiveTier( return tier; } -function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null { +function getCodexPlanAudience(quota: UnifiedQuotaResult | undefined): AccountAudience { if (!quota || !('planType' in quota) || !quota.planType) { - return null; + return 'unknown'; } - return formatAccountVariantPart(quota.planType); + if (quota.planType === 'team') return 'business'; + if (quota.planType === 'free') return 'free'; + if (quota.planType === 'plus') return 'personal'; + return 'unknown'; } export function AccountSurfaceCard({ @@ -124,10 +141,13 @@ export function AccountSurfaceCard({ const title = displayEmail || identity.email || accountId; const normalizedProvider = provider.toLowerCase(); const effectiveTier = resolveEffectiveTier(tier, quota); - const codexPlanDetailLabel = - normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null; - const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel; - const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel; + const codexPlanAudience = + normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown'; + const effectiveAudience = identity.audience !== 'unknown' ? identity.audience : codexPlanAudience; + const effectiveAudienceLabel = + identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience); + const resolvedDetailLabel = identity.detailLabel; + const resolvedCompactDetailLabel = identity.compactDetailLabel; const showTierBadge = (normalizedProvider === 'agy' || normalizedProvider === 'antigravity' || @@ -148,17 +168,15 @@ export function AccountSurfaceCard({ {effectiveTier} )} - {identity.audienceLabel && ( + {effectiveAudienceLabel && ( - {getCompactAudienceBadgeLabel(identity.audience, t)} + {getCompactAudienceBadgeLabel(effectiveAudience, t)} )} {resolvedCompactDetailLabel && ( @@ -166,7 +184,7 @@ export function AccountSurfaceCard({ title={resolvedDetailLabel ?? resolvedCompactDetailLabel} className={cn( 'text-[8px] font-semibold px-1.5 py-0.5 rounded-md border shrink-0', - getCompactDetailBadgeClass(identity.audience) + getCompactDetailBadgeClass(effectiveAudience) )} > {resolvedCompactDetailLabel} @@ -227,15 +245,15 @@ export function AccountSurfaceCard({ {title} {isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)} - {!isCompact && identity.audienceLabel && ( + {!isCompact && effectiveAudienceLabel && ( - {identity.audienceLabel} + {effectiveAudienceLabel} )} {!isCompact && resolvedDetailLabel && ( diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index 42577953..7e89b232 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -53,7 +53,9 @@ export function AccountStep({ 'text-[10px] h-4 px-1.5 border-transparent', identity.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' - : 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300' + : identity.audience === 'free' + ? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200' + : 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300' )} > {identity.audienceLabel} diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 311fe496..fb0d3ef1 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -87,7 +87,9 @@ export function VariantStep({ 'text-[10px] h-4 px-1.5 border-transparent', selectedAccountIdentity.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' - : 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300' + : selectedAccountIdentity.audience === 'free' + ? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200' + : 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300' )} > {selectedAccountIdentity.audienceLabel} diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index 7b1236a2..7227f3a2 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -1,10 +1,11 @@ -const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']); +const FREE_PLAN_PARTS = new Set(['free']); +const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']); const BUSINESS_PLAN_PARTS = new Set(['team']); // Keep variant parsing aligned with src/cliproxy/accounts/email-account-identity.ts. // This browser copy stays local because the server module is not bundle-safe for the UI. -export type AccountAudience = 'business' | 'personal' | 'unknown'; +export type AccountAudience = 'business' | 'free' | 'personal' | 'unknown'; export interface AccountIdentityPresentation { email: string; @@ -105,11 +106,16 @@ function formatWorkspaceLabel(parts: string[]): { const extraLabel = parts.map(formatAccountVariantPart).filter(Boolean).join(' · '); return { - detailLabel: extraLabel || 'Team', // TODO i18n: missing key for team fallback - compactDetailLabel: extraLabel || 'Team', + detailLabel: extraLabel || null, + compactDetailLabel: extraLabel || null, }; } +function formatAudienceDetail(parts: string[]): string | null { + const label = parts.map(formatAccountVariantPart).filter(Boolean).join(' · '); + return label || null; +} + export function extractAccountVariantKey( accountId: string, email?: string, @@ -166,14 +172,21 @@ export function getAccountIdentityPresentation( }; } + if (suffix && FREE_PLAN_PARTS.has(suffix)) { + const detailLabel = formatAudienceDetail(parts.slice(0, -1)); + const inlineLabel = ['Free', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Free + return { + email: resolvedEmail, + audience: 'free', + audienceLabel: 'Free', + detailLabel, + compactDetailLabel: detailLabel, + inlineLabel, + }; + } + if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) { - const detailParts = [ - formatAccountVariantPart(suffix), - ...parts.slice(0, -1).map(formatAccountVariantPart), - ] - .filter(Boolean) - .join(' · '); - const detailLabel = detailParts || formatAccountVariantPart(suffix); + const detailLabel = formatAudienceDetail(parts.slice(0, -1)); const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Personal return { email: resolvedEmail, diff --git a/ui/src/lib/account-visual-groups.ts b/ui/src/lib/account-visual-groups.ts index 259b0634..16a27fde 100644 --- a/ui/src/lib/account-visual-groups.ts +++ b/ui/src/lib/account-visual-groups.ts @@ -39,7 +39,8 @@ export interface AccountVisualGroup { const AUDIENCE_ORDER: Record = { business: 0, personal: 1, - unknown: 2, + free: 2, + unknown: 3, }; function getLatestTimestamp(current?: string, candidate?: string): string | undefined { diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index ce95fc3e..a9cf92bd 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1701,6 +1701,7 @@ const resources = { }, accountSurfaceCard: { business: 'Biz', + free: 'Free', personal: 'Pers', variant: 'Variant', }, @@ -4049,6 +4050,7 @@ const resources = { }, accountSurfaceCard: { business: '企业', + free: '免费', personal: '个人', variant: '变体', }, @@ -6469,6 +6471,7 @@ const resources = { }, accountSurfaceCard: { business: 'Biz', + free: 'Miễn phí', personal: 'Cá nhân', variant: 'Biến thể', }, @@ -8671,6 +8674,7 @@ const resources = { }, accountSurfaceCard: { business: 'ビジネス', + free: '無料', personal: '個人', variant: 'バリアント', }, diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index be9c28d4..5d746854 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -87,11 +87,11 @@ const groupedAccount: AccountData = { isDefault: true, successCount: 4, failureCount: 1, - audience: 'personal', - audienceLabel: 'Personal', - detailLabel: 'Free', - compactDetailLabel: 'Free', - inlineLabel: 'Personal · Free', + audience: 'free', + audienceLabel: 'Free', + detailLabel: null, + compactDetailLabel: null, + inlineLabel: 'Free', }, ], }; @@ -99,9 +99,11 @@ const groupedAccount: AccountData = { const groupedAccountWithProPersonal: AccountData = { ...groupedAccount, variants: groupedAccount.variants?.map((variant) => - variant.audience === 'personal' + variant.audience === 'free' ? { ...variant, + audience: 'personal', + audienceLabel: 'Personal', detailLabel: 'Pro', compactDetailLabel: 'Pro', inlineLabel: 'Personal · Pro', @@ -148,9 +150,7 @@ describe('AccountCard grouped quota tooltip', () => { /> ); - expect( - screen.getByTitle('Business · Workspace 04a0f049 • Personal · Free') - ).toBeInTheDocument(); + expect(screen.getByTitle('Business · Workspace 04a0f049 • Free')).toBeInTheDocument(); expect(screen.getByText('Biz')).toBeInTheDocument(); await userEvent.hover(screen.getByText('Business · Workspace 04a0f049')); @@ -164,7 +164,14 @@ describe('AccountCard grouped quota tooltip', () => { expect(tooltipContent?.className).toContain('text-popover-foreground'); expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); - await userEvent.hover(screen.getByText('Personal · Free')); + const freeLabels = screen.getAllByText('Free'); + const quotaLabel = freeLabels[freeLabels.length - 1]; + expect(quotaLabel).toBeDefined(); + if (!quotaLabel) { + throw new Error('Expected a Free quota label'); + } + + await userEvent.hover(quotaLabel); const personalPlan = (await screen.findAllByText('Plan: free')).find((node) => node.closest('[data-slot="tooltip-content"]') ); @@ -193,6 +200,6 @@ describe('AccountCard grouped quota tooltip', () => { expect(screen.getByTitle('Business · Workspace 04a0f049 • Personal · Pro')).toBeInTheDocument(); expect(screen.getByText('Personal · Pro')).toBeInTheDocument(); - expect(screen.queryByText('Personal · Free')).not.toBeInTheDocument(); + expect(screen.queryByText('Free')).not.toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx index 56079208..3d0bcb68 100644 --- a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -57,7 +57,7 @@ describe('AccountSurfaceCard', () => { expect(screen.getByText('pro')).toBeInTheDocument(); }); - it('shows both personal identity and free-tier detail for compact Codex cards', () => { + it('shows free Codex accounts as a single standalone audience badge', () => { render( { /> ); - expect(screen.getByText('Pers')).toBeInTheDocument(); - expect(screen.getByTitle('Personal')).toBeInTheDocument(); expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.getByTitle('Free')).toBeInTheDocument(); + expect(screen.queryByText('Pers')).not.toBeInTheDocument(); }); - it('keeps richer token-derived Codex personal detail when live quota planType is coarser', () => { + it('keeps the simplified personal audience when live quota planType is coarser', () => { render( { /> ); - expect(screen.getByText('Pro')).toBeInTheDocument(); + expect(screen.getByText('Pers')).toBeInTheDocument(); expect(screen.queryByText('Free')).not.toBeInTheDocument(); }); + + it('falls back to a single free badge when Codex quota detects a free plan', () => { + render( + + ); + + expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.queryByText('Pers')).not.toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/ui/lib/account-identity.test.ts b/ui/tests/unit/ui/lib/account-identity.test.ts index 9ebc93fc..cce62502 100644 --- a/ui/tests/unit/ui/lib/account-identity.test.ts +++ b/ui/tests/unit/ui/lib/account-identity.test.ts @@ -39,14 +39,33 @@ describe('account identity presentation', () => { ).toBe('Business · Workspace 04a0f049'); }); - it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => { + it('classifies free codex accounts as a standalone audience', () => { + const presentation = getAccountIdentityPresentation( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-kaidu.kd@gmail.com-free.json' + ); + + expect(presentation.audience).toBe('free'); + expect(presentation.audienceLabel).toBe('Free'); + expect(presentation.detailLabel).toBeNull(); expect( formatAccountDisplayName( 'kaidu.kd@gmail.com', 'kaidu.kd@gmail.com', 'codex-kaidu.kd@gmail.com-free.json' ) - ).toBe('kaidu.kd@gmail.com (Personal · Free)'); + ).toBe('kaidu.kd@gmail.com (Free)'); + }); + + it('collapses paid codex personal plans into a single personal audience label', () => { + expect( + formatAccountDisplayName( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-kaidu.kd@gmail.com-plus.json' + ) + ).toBe('kaidu.kd@gmail.com (Personal)'); }); it('leaves plain accounts without inferred state untouched', () => { From 25aa8bdb16e04f9f81e5b54487716c7cb85728ea Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 21:12:41 -0400 Subject: [PATCH 08/59] fix(accounts): refine codex plan badges --- .../accounts/email-account-identity.ts | 4 +- src/cliproxy/quota-fetcher-codex.ts | 3 +- src/cliproxy/quota-types.ts | 4 +- .../account/flow-viz/account-card.tsx | 61 ++++++++++++++----- .../account/shared/account-surface-card.tsx | 22 +++++-- ui/src/lib/account-identity.ts | 8 ++- ui/src/lib/api-client.ts | 4 +- .../account/flow-viz/account-card.test.tsx | 7 ++- .../shared/account-surface-card.test.tsx | 20 +++++- ui/tests/unit/ui/lib/account-identity.test.ts | 11 +++- 10 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts index 7d6c73a1..d03f2337 100644 --- a/src/cliproxy/accounts/email-account-identity.ts +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -162,7 +162,9 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st } if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) { - return ['Personal', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · '); + return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))] + .filter(Boolean) + .join(' · '); } return parts.map(formatVariantPart).filter(Boolean).join(' · '); diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 43fdd606..811be609 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -624,11 +624,12 @@ export async function fetchCodexQuota( // Extract plan type const planTypeRaw = data.plan_type || data.planType; - let planType: 'free' | 'plus' | 'team' | null = null; + let planType: 'free' | 'plus' | 'pro' | 'team' | null = null; if (planTypeRaw) { const normalized = planTypeRaw.toLowerCase(); if (normalized === 'free') planType = 'free'; else if (normalized === 'plus') planType = 'plus'; + else if (normalized === 'pro') planType = 'pro'; else if (normalized === 'team') planType = 'team'; } diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index a1f0b313..e768ec9b 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata { windows: CodexQuotaWindow[]; /** Explicit core usage windows (5h + weekly) for easier reset display */ coreUsage?: CodexCoreUsageSummary; - /** Plan type: free, plus, team, or null if unknown */ - planType: 'free' | 'plus' | 'team' | null; + /** Plan type: free, plus, pro, team, or null if unknown */ + planType: 'free' | 'plus' | 'pro' | 'team' | null; /** Timestamp of fetch */ lastUpdated: number; /** Error message if fetch failed */ diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 7b6f399f..22e8cea8 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -100,24 +100,53 @@ function getCodexPlanAudience(quota: unknown): AccountAudience { if (planType === 'team') return 'business'; if (planType === 'free') return 'free'; - if (planType === 'plus') return 'personal'; + if (planType === 'plus' || planType === 'pro') return 'personal'; return 'unknown'; } -function getVariantDetailLabel(variant: { - audience: AccountAudience; - detailLabel?: string | null; - compactDetailLabel?: string | null; -}) { - return variant.detailLabel ?? variant.compactDetailLabel ?? null; +function getCodexPlanDetailLabel(quota: unknown): string | null { + if (!quota || typeof quota !== 'object' || !('planType' in quota)) { + return null; + } + + const planType = (quota as { planType?: unknown }).planType; + if (typeof planType !== 'string' || planType.trim().length === 0) { + return null; + } + + if (planType === 'free' || planType === 'team') { + return null; + } + + return planType === 'plus' || planType === 'pro' + ? planType[0].toUpperCase() + planType.slice(1) + : null; } -function getVariantCompactDetailLabel(variant: { - audience: AccountAudience; - compactDetailLabel?: string | null; - detailLabel?: string | null; -}) { - return variant.compactDetailLabel ?? variant.detailLabel ?? null; +function getVariantDetailLabel( + variant: { + audience: AccountAudience; + detailLabel?: string | null; + compactDetailLabel?: string | null; + }, + quota?: unknown +) { + return ( + variant.detailLabel ?? variant.compactDetailLabel ?? getCodexPlanDetailLabel(quota) ?? null + ); +} + +function getVariantCompactDetailLabel( + variant: { + audience: AccountAudience; + compactDetailLabel?: string | null; + detailLabel?: string | null; + }, + quota?: unknown +) { + return ( + variant.compactDetailLabel ?? variant.detailLabel ?? getCodexPlanDetailLabel(quota) ?? null + ); } function getDetailedAudienceLabel(audience: AccountAudience): string | null { @@ -146,7 +175,7 @@ function getVariantInlineLabel( }, quota?: unknown ) { - const detailLabel = getVariantDetailLabel(variant); + const detailLabel = getVariantDetailLabel(variant, quota); const audienceLabel = variant.audienceLabel ?? getDetailedAudienceLabel(getVariantAudience(variant, quota)); const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · '); @@ -164,7 +193,7 @@ function getVariantMarkerLabel( quota?: unknown ) { const audience = getVariantAudience(variant, quota); - const compactDetailLabel = getVariantCompactDetailLabel(variant); + const compactDetailLabel = getVariantCompactDetailLabel(variant, quota); if (audience === 'business') { const businessVariantCount = audienceCounts.get('business') ?? 0; return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz'; @@ -197,7 +226,7 @@ function getGroupedVariantSummaryLabel( const audiences = new Set(variants.map((variant) => variant.audience)); const hasDistinctDetails = variants.some((variant, index) => Boolean( - getVariantCompactDetailLabel(variant) || + getVariantCompactDetailLabel(variant, quotas[index]) || getDetailedAudienceLabel(getVariantAudience(variant, quotas[index])) ) ); diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 21657220..063676a1 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats'; -import { getAccountIdentityPresentation } from '@/lib/account-identity'; +import { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { Pause, Star, User } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -109,10 +109,22 @@ function getCodexPlanAudience(quota: UnifiedQuotaResult | undefined): AccountAud if (quota.planType === 'team') return 'business'; if (quota.planType === 'free') return 'free'; - if (quota.planType === 'plus') return 'personal'; + if (quota.planType === 'plus' || quota.planType === 'pro') return 'personal'; return 'unknown'; } +function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null { + if (!quota || !('planType' in quota) || !quota.planType) { + return null; + } + + if (quota.planType === 'free' || quota.planType === 'team') { + return null; + } + + return formatAccountVariantPart(quota.planType); +} + export function AccountSurfaceCard({ mode, provider, @@ -143,11 +155,13 @@ export function AccountSurfaceCard({ const effectiveTier = resolveEffectiveTier(tier, quota); const codexPlanAudience = normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown'; + const codexPlanDetailLabel = + normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null; const effectiveAudience = identity.audience !== 'unknown' ? identity.audience : codexPlanAudience; const effectiveAudienceLabel = identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience); - const resolvedDetailLabel = identity.detailLabel; - const resolvedCompactDetailLabel = identity.compactDetailLabel; + const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel; + const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel; const showTierBadge = (normalizedProvider === 'agy' || normalizedProvider === 'antigravity' || diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts index 7227f3a2..9d16a37d 100644 --- a/ui/src/lib/account-identity.ts +++ b/ui/src/lib/account-identity.ts @@ -186,14 +186,16 @@ export function getAccountIdentityPresentation( } if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) { - const detailLabel = formatAudienceDetail(parts.slice(0, -1)); + const detailLabel = [formatAccountVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))] + .filter(Boolean) + .join(' · '); const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Personal return { email: resolvedEmail, audience: 'personal', audienceLabel: 'Personal', - detailLabel, - compactDetailLabel: detailLabel, + detailLabel: detailLabel || formatAccountVariantPart(suffix), + compactDetailLabel: detailLabel || formatAccountVariantPart(suffix), inlineLabel, }; } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a4fe2ed8..a20a764a 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -610,8 +610,8 @@ export interface CodexQuotaResult { windows: CodexQuotaWindow[]; /** Explicit core usage windows (5h + weekly) for easier reset display */ coreUsage?: CodexCoreUsageSummary; - /** Plan type: free, plus, team, or null if unknown */ - planType: 'free' | 'plus' | 'team' | null; + /** Plan type: free, plus, pro, team, or null if unknown */ + planType: 'free' | 'plus' | 'pro' | 'team' | null; /** Timestamp of fetch */ lastUpdated: number; /** Upstream HTTP status when available */ diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 5d746854..d2b06cac 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -20,7 +20,11 @@ vi.mock('@/hooks/use-cliproxy-stats', async () => { const mockedUseAccountQuota = vi.mocked(useAccountQuota); const mockedUseAccountQuotas = vi.mocked(useAccountQuotas); -function makeCodexQuota(planType: 'free' | 'plus' | 'team', fiveHour: number, weekly: number) { +function makeCodexQuota( + planType: 'free' | 'plus' | 'pro' | 'team', + fiveHour: number, + weekly: number +) { return { success: true, planType, @@ -201,5 +205,6 @@ describe('AccountCard grouped quota tooltip', () => { expect(screen.getByTitle('Business · Workspace 04a0f049 • Personal · Pro')).toBeInTheDocument(); expect(screen.getByText('Personal · Pro')).toBeInTheDocument(); expect(screen.queryByText('Free')).not.toBeInTheDocument(); + expect(screen.getByText('Pro')).toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx index 3d0bcb68..5f4909f7 100644 --- a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -75,7 +75,7 @@ describe('AccountSurfaceCard', () => { expect(screen.queryByText('Pers')).not.toBeInTheDocument(); }); - it('keeps the simplified personal audience when live quota planType is coarser', () => { + it('keeps token-derived personal detail when live quota planType is coarser', () => { render( { ); expect(screen.getByText('Pers')).toBeInTheDocument(); + expect(screen.getByText('Pro')).toBeInTheDocument(); expect(screen.queryByText('Free')).not.toBeInTheDocument(); }); @@ -109,4 +110,21 @@ describe('AccountSurfaceCard', () => { expect(screen.getByText('Free')).toBeInTheDocument(); expect(screen.queryByText('Pers')).not.toBeInTheDocument(); }); + + it('falls back to plus or pro detail when Codex quota exposes a paid plan', () => { + render( + + ); + + expect(screen.getByText('Pers')).toBeInTheDocument(); + expect(screen.getByText('Pro')).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/ui/lib/account-identity.test.ts b/ui/tests/unit/ui/lib/account-identity.test.ts index cce62502..0b675d00 100644 --- a/ui/tests/unit/ui/lib/account-identity.test.ts +++ b/ui/tests/unit/ui/lib/account-identity.test.ts @@ -58,14 +58,21 @@ describe('account identity presentation', () => { ).toBe('kaidu.kd@gmail.com (Free)'); }); - it('collapses paid codex personal plans into a single personal audience label', () => { + it('keeps plus and pro codex personal plans distinct', () => { expect( formatAccountDisplayName( 'kaidu.kd@gmail.com', 'kaidu.kd@gmail.com', 'codex-kaidu.kd@gmail.com-plus.json' ) - ).toBe('kaidu.kd@gmail.com (Personal)'); + ).toBe('kaidu.kd@gmail.com (Personal · Plus)'); + expect( + formatAccountDisplayName( + 'kaidu.kd@gmail.com', + 'kaidu.kd@gmail.com', + 'codex-kaidu.kd@gmail.com-pro.json' + ) + ).toBe('kaidu.kd@gmail.com (Personal · Pro)'); }); it('leaves plain accounts without inferred state untouched', () => { From 212c6ec40137fbed0a9835d924c415a615c16a77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 01:22:26 +0000 Subject: [PATCH 09/59] chore(release): 7.71.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 85504189..6fbb0421 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.1", + "version": "7.71.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 0bfdc522d5606119b5051bdabc4b8d59aaeb4891 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 21:23:13 -0400 Subject: [PATCH 10/59] fix(accounts): prefer live codex plan over stale free tags --- .../account/flow-viz/account-card.tsx | 38 ++++++++++++++++-- .../account/shared/account-surface-card.tsx | 40 ++++++++++++++++++- .../shared/account-surface-card.test.tsx | 19 +++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 22e8cea8..dc10aea9 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -104,6 +104,35 @@ function getCodexPlanAudience(quota: unknown): AccountAudience { return 'unknown'; } +function resolveCodexAudience( + identityAudience: AccountAudience, + planAudience: AccountAudience +): AccountAudience { + if (planAudience === 'unknown') { + return identityAudience; + } + + if (planAudience === 'business') { + return 'business'; + } + + if (identityAudience === 'business') { + return 'business'; + } + + if (planAudience === 'personal') { + return 'personal'; + } + + if (planAudience === 'free') { + return identityAudience === 'unknown' || identityAudience === 'free' + ? 'free' + : identityAudience; + } + + return identityAudience; +} + function getCodexPlanDetailLabel(quota: unknown): string | null { if (!quota || typeof quota !== 'object' || !('planType' in quota)) { return null; @@ -162,7 +191,7 @@ function getVariantAudience( }, quota?: unknown ) { - return variant.audience !== 'unknown' ? variant.audience : getCodexPlanAudience(quota); + return resolveCodexAudience(variant.audience, getCodexPlanAudience(quota)); } function getVariantInlineLabel( @@ -176,8 +205,11 @@ function getVariantInlineLabel( quota?: unknown ) { const detailLabel = getVariantDetailLabel(variant, quota); + const audience = getVariantAudience(variant, quota); const audienceLabel = - variant.audienceLabel ?? getDetailedAudienceLabel(getVariantAudience(variant, quota)); + variant.audience === audience + ? (variant.audienceLabel ?? getDetailedAudienceLabel(audience)) + : getDetailedAudienceLabel(audience); const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · '); return composedLabel || variant.inlineLabel || null; } @@ -207,8 +239,8 @@ function getVariantMarkerLabel( const normalizedFallback = compactDetailLabel?.trim() || - variant.audienceLabel?.trim() || getDetailedAudienceLabel(audience) || + variant.audienceLabel?.trim() || variant.detailLabel?.trim(); return normalizedFallback?.[0]?.toUpperCase() ?? '?'; } diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 063676a1..3de92fe5 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -113,6 +113,35 @@ function getCodexPlanAudience(quota: UnifiedQuotaResult | undefined): AccountAud return 'unknown'; } +function resolveCodexAudience( + identityAudience: AccountAudience, + planAudience: AccountAudience +): AccountAudience { + if (planAudience === 'unknown') { + return identityAudience; + } + + if (planAudience === 'business') { + return 'business'; + } + + if (identityAudience === 'business') { + return 'business'; + } + + if (planAudience === 'personal') { + return 'personal'; + } + + if (planAudience === 'free') { + return identityAudience === 'unknown' || identityAudience === 'free' + ? 'free' + : identityAudience; + } + + return identityAudience; +} + function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null { if (!quota || !('planType' in quota) || !quota.planType) { return null; @@ -157,9 +186,16 @@ export function AccountSurfaceCard({ normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown'; const codexPlanDetailLabel = normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null; - const effectiveAudience = identity.audience !== 'unknown' ? identity.audience : codexPlanAudience; + const effectiveAudience = + normalizedProvider === 'codex' + ? resolveCodexAudience(identity.audience, codexPlanAudience) + : identity.audience !== 'unknown' + ? identity.audience + : codexPlanAudience; const effectiveAudienceLabel = - identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience); + identity.audience === effectiveAudience + ? (identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience)) + : getDetailedAudienceLabel(effectiveAudience); const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel; const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel; const showTierBadge = diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx index 5f4909f7..e4e2d126 100644 --- a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -127,4 +127,23 @@ describe('AccountSurfaceCard', () => { expect(screen.getByText('Pers')).toBeInTheDocument(); expect(screen.getByText('Pro')).toBeInTheDocument(); }); + + it('lets live paid Codex plans override stale free identity badges', () => { + render( + + ); + + expect(screen.getByText('Pers')).toBeInTheDocument(); + expect(screen.getByText('Plus')).toBeInTheDocument(); + expect(screen.queryByTitle('Free')).not.toBeInTheDocument(); + }); }); From 2a3632e5a5efa825cdee0e03ffc26410b3c42ee9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 21:35:07 -0400 Subject: [PATCH 11/59] fix(accounts): show one codex plan badge --- .../account/flow-viz/account-card.tsx | 136 +++++++-------- .../account/shared/account-surface-card.tsx | 165 +++++++++--------- .../setup/wizard/steps/account-step.tsx | 24 ++- .../setup/wizard/steps/variant-step.tsx | 26 ++- ui/src/lib/account-identity.ts | 26 +++ .../account/flow-viz/account-card.test.tsx | 9 +- .../shared/account-surface-card.test.tsx | 6 +- 7 files changed, 226 insertions(+), 166 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index dc10aea9..a07958fa 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -6,6 +6,7 @@ import { AccountSurfaceCard } from '@/components/account/shared/account-surface- import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { getCodexIdentityBadge, type CodexIdentityBadge } from '@/lib/account-identity'; import { cn, formatQuotaPercent, @@ -88,68 +89,56 @@ function getCompactQuotaColor(percentage: number) { return 'bg-red-500'; } -function getCodexPlanAudience(quota: unknown): AccountAudience { +function getCodexQuotaBadge(quota: unknown): CodexIdentityBadge { if (!quota || typeof quota !== 'object' || !('planType' in quota)) { - return 'unknown'; + return { audience: 'unknown', label: null }; } const planType = (quota as { planType?: unknown }).planType; if (typeof planType !== 'string' || planType.trim().length === 0) { - return 'unknown'; + return { audience: 'unknown', label: null }; } - if (planType === 'team') return 'business'; - if (planType === 'free') return 'free'; - if (planType === 'plus' || planType === 'pro') return 'personal'; - return 'unknown'; + if (planType === 'team') { + return { audience: 'business', label: 'Business' }; + } + + if (planType === 'free') { + return { audience: 'free', label: 'Free' }; + } + + if (planType === 'plus') { + return { audience: 'personal', label: 'Plus' }; + } + + if (planType === 'pro') { + return { audience: 'personal', label: 'Pro' }; + } + + return { audience: 'unknown', label: null }; } -function resolveCodexAudience( - identityAudience: AccountAudience, - planAudience: AccountAudience -): AccountAudience { - if (planAudience === 'unknown') { - return identityAudience; +function resolveCodexBadge( + identityBadge: CodexIdentityBadge, + quotaBadge: CodexIdentityBadge +): CodexIdentityBadge { + if (!quotaBadge.label) { + return identityBadge; } - if (planAudience === 'business') { - return 'business'; + if ( + quotaBadge.label === 'Business' || + quotaBadge.label === 'Plus' || + quotaBadge.label === 'Pro' + ) { + return quotaBadge; } - if (identityAudience === 'business') { - return 'business'; + if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') { + return identityBadge; } - if (planAudience === 'personal') { - return 'personal'; - } - - if (planAudience === 'free') { - return identityAudience === 'unknown' || identityAudience === 'free' - ? 'free' - : identityAudience; - } - - return identityAudience; -} - -function getCodexPlanDetailLabel(quota: unknown): string | null { - if (!quota || typeof quota !== 'object' || !('planType' in quota)) { - return null; - } - - const planType = (quota as { planType?: unknown }).planType; - if (typeof planType !== 'string' || planType.trim().length === 0) { - return null; - } - - if (planType === 'free' || planType === 'team') { - return null; - } - - return planType === 'plus' || planType === 'pro' - ? planType[0].toUpperCase() + planType.slice(1) - : null; + return quotaBadge; } function getVariantDetailLabel( @@ -160,9 +149,32 @@ function getVariantDetailLabel( }, quota?: unknown ) { - return ( - variant.detailLabel ?? variant.compactDetailLabel ?? getCodexPlanDetailLabel(quota) ?? null - ); + return resolveCodexBadge( + getCodexIdentityBadge({ + audience: variant.audience, + detailLabel: variant.detailLabel, + compactDetailLabel: variant.compactDetailLabel, + }), + getCodexQuotaBadge(quota) + ).label; +} + +function getVariantBadgeAudience( + variant: { + audience: AccountAudience; + detailLabel?: string | null; + compactDetailLabel?: string | null; + }, + quota?: unknown +) { + return resolveCodexBadge( + getCodexIdentityBadge({ + audience: variant.audience, + detailLabel: variant.detailLabel, + compactDetailLabel: variant.compactDetailLabel, + }), + getCodexQuotaBadge(quota) + ).audience; } function getVariantCompactDetailLabel( @@ -173,9 +185,7 @@ function getVariantCompactDetailLabel( }, quota?: unknown ) { - return ( - variant.compactDetailLabel ?? variant.detailLabel ?? getCodexPlanDetailLabel(quota) ?? null - ); + return getVariantDetailLabel(variant, quota); } function getDetailedAudienceLabel(audience: AccountAudience): string | null { @@ -191,7 +201,7 @@ function getVariantAudience( }, quota?: unknown ) { - return resolveCodexAudience(variant.audience, getCodexPlanAudience(quota)); + return getVariantBadgeAudience(variant, quota); } function getVariantInlineLabel( @@ -204,14 +214,7 @@ function getVariantInlineLabel( }, quota?: unknown ) { - const detailLabel = getVariantDetailLabel(variant, quota); - const audience = getVariantAudience(variant, quota); - const audienceLabel = - variant.audience === audience - ? (variant.audienceLabel ?? getDetailedAudienceLabel(audience)) - : getDetailedAudienceLabel(audience); - const composedLabel = [audienceLabel, detailLabel].filter(Boolean).join(' · '); - return composedLabel || variant.inlineLabel || null; + return getVariantDetailLabel(variant, quota) || variant.inlineLabel || null; } function getVariantMarkerLabel( @@ -227,8 +230,7 @@ function getVariantMarkerLabel( const audience = getVariantAudience(variant, quota); const compactDetailLabel = getVariantCompactDetailLabel(variant, quota); if (audience === 'business') { - const businessVariantCount = audienceCounts.get('business') ?? 0; - return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz'; + return 'Biz'; } if (audience === 'free') { return compactDetailLabel ?? 'Free'; @@ -237,11 +239,7 @@ function getVariantMarkerLabel( return compactDetailLabel ?? 'Pers'; } - const normalizedFallback = - compactDetailLabel?.trim() || - getDetailedAudienceLabel(audience) || - variant.audienceLabel?.trim() || - variant.detailLabel?.trim(); + const normalizedFallback = compactDetailLabel?.trim() || getDetailedAudienceLabel(audience); return normalizedFallback?.[0]?.toUpperCase() ?? '?'; } diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 3de92fe5..ad61b231 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from 'react'; import { Badge } from '@/components/ui/badge'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats'; -import { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity'; +import { + getAccountIdentityPresentation, + getCodexIdentityBadge, + type CodexIdentityBadge, +} from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { Pause, Star, User } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -66,13 +70,6 @@ function getCompactAudienceBadgeLabel(audience: AccountAudience, t: (key: string return '?'; } -function getDetailedAudienceLabel(audience: AccountAudience): string | null { - if (audience === 'business') return 'Business'; - if (audience === 'free') return 'Free'; - if (audience === 'personal') return 'Personal'; - return null; -} - function getCompactDetailBadgeClass(audience: AccountAudience) { if (audience === 'business') { return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-200'; @@ -102,56 +99,51 @@ function resolveEffectiveTier( return tier; } -function getCodexPlanAudience(quota: UnifiedQuotaResult | undefined): AccountAudience { +function getCodexQuotaBadge(quota: UnifiedQuotaResult | undefined): CodexIdentityBadge { if (!quota || !('planType' in quota) || !quota.planType) { - return 'unknown'; + return { audience: 'unknown', label: null }; } - if (quota.planType === 'team') return 'business'; - if (quota.planType === 'free') return 'free'; - if (quota.planType === 'plus' || quota.planType === 'pro') return 'personal'; - return 'unknown'; + if (quota.planType === 'team') { + return { audience: 'business', label: 'Business' }; + } + + if (quota.planType === 'free') { + return { audience: 'free', label: 'Free' }; + } + + if (quota.planType === 'plus') { + return { audience: 'personal', label: 'Plus' }; + } + + if (quota.planType === 'pro') { + return { audience: 'personal', label: 'Pro' }; + } + + return { audience: 'unknown', label: null }; } -function resolveCodexAudience( - identityAudience: AccountAudience, - planAudience: AccountAudience -): AccountAudience { - if (planAudience === 'unknown') { - return identityAudience; +function resolveCodexBadge( + identityBadge: CodexIdentityBadge, + quotaBadge: CodexIdentityBadge +): CodexIdentityBadge { + if (!quotaBadge.label) { + return identityBadge; } - if (planAudience === 'business') { - return 'business'; + if ( + quotaBadge.label === 'Business' || + quotaBadge.label === 'Plus' || + quotaBadge.label === 'Pro' + ) { + return quotaBadge; } - if (identityAudience === 'business') { - return 'business'; + if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') { + return identityBadge; } - if (planAudience === 'personal') { - return 'personal'; - } - - if (planAudience === 'free') { - return identityAudience === 'unknown' || identityAudience === 'free' - ? 'free' - : identityAudience; - } - - return identityAudience; -} - -function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null { - if (!quota || !('planType' in quota) || !quota.planType) { - return null; - } - - if (quota.planType === 'free' || quota.planType === 'team') { - return null; - } - - return formatAccountVariantPart(quota.planType); + return quotaBadge; } export function AccountSurfaceCard({ @@ -182,22 +174,10 @@ export function AccountSurfaceCard({ const title = displayEmail || identity.email || accountId; const normalizedProvider = provider.toLowerCase(); const effectiveTier = resolveEffectiveTier(tier, quota); - const codexPlanAudience = - normalizedProvider === 'codex' ? getCodexPlanAudience(quota) : 'unknown'; - const codexPlanDetailLabel = - normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null; - const effectiveAudience = + const effectiveCodexBadge = normalizedProvider === 'codex' - ? resolveCodexAudience(identity.audience, codexPlanAudience) - : identity.audience !== 'unknown' - ? identity.audience - : codexPlanAudience; - const effectiveAudienceLabel = - identity.audience === effectiveAudience - ? (identity.audienceLabel ?? getDetailedAudienceLabel(effectiveAudience)) - : getDetailedAudienceLabel(effectiveAudience); - const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel; - const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel; + ? resolveCodexBadge(getCodexIdentityBadge(identity), getCodexQuotaBadge(quota)) + : null; const showTierBadge = (normalizedProvider === 'agy' || normalizedProvider === 'antigravity' || @@ -218,26 +198,38 @@ export function AccountSurfaceCard({ {effectiveTier} )} - {effectiveAudienceLabel && ( - + {effectiveCodexBadge.label} + + ) + : identity.audienceLabel && ( + + {getCompactAudienceBadgeLabel(identity.audience, t)} + )} - > - {getCompactAudienceBadgeLabel(effectiveAudience, t)} - - )} - {resolvedCompactDetailLabel && ( + {normalizedProvider !== 'codex' && identity.compactDetailLabel && ( - {resolvedCompactDetailLabel} + {identity.compactDetailLabel} )} {paused && ( @@ -295,20 +287,31 @@ export function AccountSurfaceCard({ {title} {isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)} - {!isCompact && effectiveAudienceLabel && ( + {!isCompact && normalizedProvider === 'codex' && effectiveCodexBadge?.label && ( - {effectiveAudienceLabel} + {effectiveCodexBadge.label} )} - {!isCompact && resolvedDetailLabel && ( + {!isCompact && normalizedProvider !== 'codex' && identity.audienceLabel && ( + + {identity.audienceLabel} + + )} + {!isCompact && normalizedProvider !== 'codex' && identity.detailLabel && ( - {resolvedDetailLabel} + {identity.detailLabel} )} {!isCompact && isDefault && ( diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index 7e89b232..dc9b5930 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react'; -import { getAccountIdentityPresentation } from '@/lib/account-identity'; +import { getAccountIdentityPresentation, getCodexIdentityBadge } from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { AccountStepProps } from '../types'; @@ -30,6 +30,8 @@ export function AccountStep({
{accounts.map((acc) => { const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile); + const codexBadge = + acc.provider?.toLowerCase() === 'codex' ? getCodexIdentityBadge(identity) : null; return ( + +
+ +
Date: Wed, 15 Apr 2026 01:39:31 -0400 Subject: [PATCH 25/59] fix(cli): document gitlab shortcut flags and harden cursor routing --- src/ccs.ts | 20 ++++-- src/commands/help-command.ts | 71 +++++++++++++++++++ src/cursor/constants.ts | 27 +++++++ tests/npm/cli.test.js | 14 ++++ .../unit/commands/help-command-parity.test.ts | 3 + .../cursor/cursor-shortcut-routing.test.ts | 15 ++++ 6 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 tests/unit/cursor/cursor-shortcut-routing.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index 6442f55a..99249df8 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -65,7 +65,8 @@ import { resolveOfficialChannelsLaunchPlan, } from './channels/official-channels-runtime'; import { getOfficialChannelReadiness } from './channels/official-channels-store'; -import { isCursorSubcommandToken } from './cursor/constants'; +import { isCursorSubcommandToken, shouldUseCursorCliproxyShortcut } from './cursor/constants'; +import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; // Import centralized error handling import { handleError, runCleanup } from './errors'; @@ -123,7 +124,6 @@ interface RuntimeReasoningResolution { sourceDisplay: string | undefined; } -const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set(['--auth', '--logout', '--config', '--accounts']); const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']); const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']); @@ -149,10 +149,6 @@ function detectProfile(args: string[]): DetectedProfile { } } -function shouldUseCursorCliproxyShortcut(args: string[]): boolean { - return args[0] === 'cursor' && CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(args[1] || ''); -} - function resolveRuntimeReasoningFlags( args: string[], envThinkingValue: string | undefined @@ -490,6 +486,18 @@ async function main(): Promise { return; } + if ( + typeof firstArg === 'string' && + isCLIProxyProvider(firstArg) && + firstArg !== 'cursor' && + args.length > 1 && + (args.includes('--help') || args.includes('-h')) + ) { + const { showProviderShortcutHelp } = await import('./commands/help-command'); + await showProviderShortcutHelp(firstArg); + return; + } + // Special case: copilot command (GitHub Copilot integration) // Route known subcommands to command handler, keep all other args as profile passthrough. if (firstArg === 'copilot' && args.length > 1) { diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index e3912a11..e02c00c9 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -1,4 +1,5 @@ import packageJson from '../../package.json'; +import type { CLIProxyProvider } from '../cliproxy'; import { color, dim, header, initUI, subheader } from '../utils/ui'; import { BUILTIN_PROVIDER_SHORTCUTS, @@ -82,10 +83,80 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise { ], writeLine ); + writeCommandTable( + 'GitLab Duo Flags', + [ + { + name: 'ccs gitlab --auth --gitlab-token-login', + summary: 'Authenticate with a GitLab Personal Access Token', + }, + { + name: 'ccs gitlab --auth --token-login', + summary: 'Legacy alias for GitLab PAT login (still supported)', + }, + { + name: 'ccs gitlab --auth --gitlab-url ', + summary: 'Use a self-hosted GitLab base URL during OAuth or PAT auth', + }, + ], + writeLine + ); writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`); writeLine(''); } +export async function showProviderShortcutHelp( + provider: CLIProxyProvider, + writeLine: HelpWriter = console.log +): Promise { + if (provider === 'kiro') { + await showKiroHelp(writeLine); + return; + } + + await initUI(); + + const providerEntry = BUILTIN_PROVIDER_SHORTCUTS.find((entry) => entry.name === provider); + writeLine(header(`CCS ${provider} Shortcut Help`)); + writeLine(''); + writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`); + writeLine(''); + writeCommandTable( + 'Common Commands', + [ + { name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' }, + { name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' }, + { name: `ccs ${provider} --config`, summary: 'Open the provider config flow' }, + { name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' }, + ], + writeLine + ); + + if (provider === 'gitlab') { + writeCommandTable( + 'GitLab Duo Flags', + [ + { + name: '--gitlab-token-login', + summary: 'Use a GitLab Personal Access Token instead of browser OAuth', + }, + { + name: '--token-login', + summary: 'Legacy alias for `--gitlab-token-login`', + }, + { + name: '--gitlab-url ', + summary: 'Target a self-hosted GitLab base URL', + }, + ], + writeLine + ); + } + + writeLine(` ${dim('See also: ccs help providers | ccs cliproxy --help')}`); + writeLine(''); +} + async function showKiroHelp(writeLine: HelpWriter): Promise { await initUI(); writeLine(header('CCS Kiro Help')); diff --git a/src/cursor/constants.ts b/src/cursor/constants.ts index b9c0d18d..2f1d3c99 100644 --- a/src/cursor/constants.ts +++ b/src/cursor/constants.ts @@ -12,8 +12,35 @@ export const CURSOR_SUBCOMMANDS = [ '-h', ] as const; +export const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set([ + '--auth', + '--logout', + '--config', + '--accounts', +]); + export function isCursorSubcommandToken(token?: string): boolean { return ( Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number]) ); } + +export function shouldUseCursorCliproxyShortcut(args: string[]): boolean { + if (args[0] !== 'cursor') { + return false; + } + + for (const token of args.slice(1)) { + if (token === '--') { + break; + } + if (CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(token)) { + return true; + } + if (!token.startsWith('-')) { + return false; + } + } + + return false; +} diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index 5d4d20ce..e31a2654 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -99,6 +99,20 @@ describe('npm CLI', () => { assert(output.includes('Cursor Live Probe'), 'Should render the cursor probe command output'); } }); + + it('routes gitlab --help to provider shortcut help instead of starting auth', function() { + const output = execSync(`bun "${srcCcsPath}" gitlab --help`, { + encoding: 'utf8', + timeout: 3000, + env: { ...process.env, CCS_HOME: testCcsHome } + }); + + assert(output.includes('CCS gitlab Shortcut Help'), 'Should render provider shortcut help'); + assert(output.includes('--gitlab-token-login'), 'Should document canonical GitLab PAT flag'); + assert(output.includes('--token-login'), 'Should document legacy GitLab PAT alias'); + assert(output.includes('--gitlab-url '), 'Should document self-hosted GitLab URL flag'); + assert(!output.includes('Starting GitLab Duo OAuth'), 'Should not start OAuth when help is requested'); + }); }); describe('Profile handling', () => { diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 4d3864f7..d8563c49 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -53,6 +53,9 @@ describe('help command parity', () => { expect(rendered.includes('gitlab')).toBe(true); expect(rendered.includes('codebuddy')).toBe(true); expect(rendered.includes('kilo')).toBe(true); + expect(rendered.includes('--gitlab-token-login')).toBe(true); + expect(rendered.includes('--token-login')).toBe(true); + expect(rendered.includes('--gitlab-url ')).toBe(true); }); test('kiro topic documents IDC and callback flags', async () => { diff --git a/tests/unit/cursor/cursor-shortcut-routing.test.ts b/tests/unit/cursor/cursor-shortcut-routing.test.ts new file mode 100644 index 00000000..5b50ca51 --- /dev/null +++ b/tests/unit/cursor/cursor-shortcut-routing.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'bun:test'; +import { shouldUseCursorCliproxyShortcut } from '../../../src/cursor/constants'; + +describe('cursor CLIProxy shortcut routing', () => { + it('accepts shortcut flags after leading generic flags', () => { + expect(shouldUseCursorCliproxyShortcut(['cursor', '--auth'])).toBe(true); + expect(shouldUseCursorCliproxyShortcut(['cursor', '--verbose', '--auth'])).toBe(true); + expect(shouldUseCursorCliproxyShortcut(['cursor', '-v', '--accounts'])).toBe(true); + }); + + it('stops scanning once a positional legacy runtime argument appears', () => { + expect(shouldUseCursorCliproxyShortcut(['cursor', 'write', '--auth'])).toBe(false); + expect(shouldUseCursorCliproxyShortcut(['cursor', 'status'])).toBe(false); + }); +}); From a3407093d70bef44874cdf26a06436e0d287c1a7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 01:44:01 -0400 Subject: [PATCH 26/59] refactor(proxy): internalize sse translation and cleanup handlers --- src/cursor/cursor-anthropic-response.ts | 253 +---------------- src/glmt/sse-parser.ts | 2 +- src/proxy/server/messages-route.ts | 85 ++++-- .../transformers/sse-stream-transformer.ts | 254 +++++++++++++++++- tests/unit/glmt/sse-parser.test.ts | 18 +- tests/unit/proxy/messages-route.test.ts | 72 +++++ .../sse-stream-transformer.test.ts | 70 +++++ 7 files changed, 476 insertions(+), 278 deletions(-) create mode 100644 tests/unit/proxy/messages-route.test.ts create mode 100644 tests/unit/proxy/transformers/sse-stream-transformer.test.ts diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts index 6575fd14..c5c5a0e2 100644 --- a/src/cursor/cursor-anthropic-response.ts +++ b/src/cursor/cursor-anthropic-response.ts @@ -1,249 +1,4 @@ -import { DeltaAccumulator } from '../glmt/delta-accumulator'; -import { GlmtTransformer } from '../glmt/glmt-transformer'; -import { SSEParser } from '../glmt/sse-parser'; -import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; - -const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; -const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; -type ResponseHeaders = Headers | Record | Array<[string, string]>; - -interface AnthropicErrorPayload { - type: 'error'; - error: { - type: string; - message: string; - }; -} - -function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload { - return { - type: 'error', - error: { - type, - message, - }, - }; -} - -function formatErrorForLog(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - try { - return JSON.stringify(error); - } catch { - return String(error); - } -} - -function logTranslationError(context: string, error: unknown): void { - console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`); -} - -export function createAnthropicErrorResponse( - status: number, - type: string, - message: string, - headers?: ResponseHeaders -): Response { - const responseHeaders = new Headers(headers); - responseHeaders.set('Content-Type', 'application/json'); - responseHeaders.delete('Content-Length'); - - return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { - status, - headers: responseHeaders, - }); -} - -function formatSseEvent(event: string, data: unknown): string { - return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; -} - -function hasTranslatableChoices(value: unknown): value is OpenAIResponse { - if (typeof value !== 'object' || value === null) { - return false; - } - - const { choices } = value as OpenAIResponse; - if (!Array.isArray(choices) || choices.length === 0) { - return false; - } - - const firstChoice = choices[0]; - if (typeof firstChoice !== 'object' || firstChoice === null) { - return false; - } - - const message = (firstChoice as { message?: unknown }).message; - return typeof message === 'object' && message !== null; -} - -function isSyntheticTransformationFallback(value: unknown): boolean { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { id?: unknown }).id === 'string' && - (value as { id: string }).id.startsWith('msg_error_') - ); -} - -async function createAnthropicErrorProxyResponse(response: Response): Promise { - const headers = new Headers(response.headers); - headers.delete('Content-Type'); - headers.delete('Content-Length'); - - let type = - response.status === 401 - ? 'authentication_error' - : response.status === 429 - ? 'rate_limit_error' - : response.status >= 400 && response.status < 500 - ? 'invalid_request_error' - : 'api_error'; - let message = `Cursor request failed with status ${response.status}`; - - try { - const contentType = (response.headers.get('content-type') || '').toLowerCase(); - if (contentType.includes('application/json')) { - const payload = (await response.json()) as { - error?: { type?: string; message?: string }; - message?: string; - }; - - if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) { - type = payload.error.type; - } - - if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) { - message = payload.error.message; - } else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) { - message = payload.message; - } - } else { - const text = (await response.text()).trim(); - if (text.length > 0) { - message = text; - } - } - } catch (error) { - logTranslationError('Failed to parse Cursor error response', error); - } - - return createAnthropicErrorResponse(response.status, type, message, headers); -} - -async function createAnthropicJsonResponse(response: Response): Promise { - try { - const openAiResponse = await response.json(); - if (!hasTranslatableChoices(openAiResponse)) { - return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); - } - - const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); - if (isSyntheticTransformationFallback(anthropicResponse)) { - logTranslationError( - 'Cursor JSON translation produced synthetic fallback response', - anthropicResponse - ); - return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); - } - - return new Response(JSON.stringify(anthropicResponse), { - status: response.status, - headers: { 'Content-Type': 'application/json' }, - }); - } catch (error) { - logTranslationError('Cursor JSON translation failed', error); - return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); - } -} - -function createAnthropicStreamingResponse(response: Response): Response { - const body = response.body; - if (!body) { - return createAnthropicErrorResponse( - 502, - 'api_error', - 'Cursor stream ended before a response body was available' - ); - } - - const parser = new SSEParser({ throwOnMalformedJson: true }); - const transformer = new GlmtTransformer(); - const accumulator = new DeltaAccumulator({}); - const encoder = new TextEncoder(); - - const readable = new ReadableStream({ - async start(controller) { - const reader = body.getReader(); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (!value) { - continue; - } - - const events = parser.parse(Buffer.from(value)); - events.forEach((event) => { - const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator); - anthropicEvents.forEach((anthropicEvent) => { - controller.enqueue( - encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) - ); - }); - }); - } - - if (!accumulator.isFinalized() && accumulator.isMessageStarted()) { - transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => { - controller.enqueue( - encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) - ); - }); - } - } catch (error) { - logTranslationError('Cursor SSE translation failed', error); - controller.enqueue( - encoder.encode( - formatSseEvent( - 'error', - createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE) - ) - ) - ); - } finally { - reader.releaseLock(); - controller.close(); - } - }, - }); - - return new Response(readable, { - status: response.status, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }); -} - -export async function createAnthropicProxyResponse(response: Response): Promise { - if (!response.ok) { - return createAnthropicErrorProxyResponse(response); - } - - const contentType = (response.headers.get('content-type') || '').toLowerCase(); - const isEventStream = - contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;'); - - return isEventStream - ? createAnthropicStreamingResponse(response) - : createAnthropicJsonResponse(response); -} +export { + createAnthropicErrorResponse, + createAnthropicProxyResponse, +} from '../proxy/transformers/sse-stream-transformer'; diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index c161a7df..d6fff5d6 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -49,7 +49,7 @@ export class SSEParser { * @returns Array of parsed events */ parse(chunk: Buffer | string): SSEEvent[] { - this.buffer += chunk.toString().replace(/\r\n/g, '\n'); + this.buffer += chunk.toString().replace(/\r\n?/g, '\n'); // C-01 Fix: Prevent unbounded buffer growth (DoS protection) if (this.buffer.length > this.maxBufferSize) { diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index ae0d33d9..13c329e8 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -109,6 +109,61 @@ function formatTimeoutDuration(timeoutMs: number): string { return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`; } +function registerOnceListener( + emitter: NodeJS.EventEmitter | null | undefined, + event: string, + handler: () => void +): () => void { + if (!emitter) { + return () => {}; + } + + emitter.once(event, handler); + return () => { + emitter.removeListener(event, handler); + }; +} + +export function attachDisconnectAbortHandlers( + req: http.IncomingMessage, + res: http.ServerResponse, + controller: AbortController, + onDisconnect: (source: string) => void +): () => void { + const abortOnDisconnect = (source: string) => { + if (!controller.signal.aborted && !res.writableEnded) { + onDisconnect(source); + controller.abort(); + } + }; + + const cleanupFns = [ + registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')), + registerOnceListener(req, 'close', () => abortOnDisconnect('req.close')), + registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')), + registerOnceListener(res, 'close', () => abortOnDisconnect('res.close')), + registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')), + ]; + + const disconnectPoll = setInterval(() => { + if ( + req.destroyed || + res.destroyed || + req.socket?.destroyed === true || + res.socket?.destroyed === true + ) { + abortOnDisconnect('poll.destroyed'); + } + }, 50); + + return () => { + clearInterval(disconnectPoll); + for (const cleanup of cleanupFns) { + cleanup(); + } + }; +} + export async function handleProxyMessagesRequest( req: http.IncomingMessage, res: http.ServerResponse, @@ -145,8 +200,11 @@ export async function handleProxyMessagesRequest( const controller = new AbortController(); timeoutMs = getRequestTimeoutMs(); const timeout = setTimeout(() => controller.abort(), timeoutMs); - const abortOnDisconnect = (source: string) => { - if (!controller.signal.aborted && !res.writableEnded) { + const cleanupDisconnectHandlers = attachDisconnectAbortHandlers( + req, + res, + controller, + (source) => { logger.info( 'request.disconnect', 'Aborting upstream request after local client disconnect', @@ -155,25 +213,8 @@ export async function handleProxyMessagesRequest( source, } ); - controller.abort(); } - }; - - req.on('aborted', () => abortOnDisconnect('req.aborted')); - req.on('close', () => abortOnDisconnect('req.close')); - req.socket?.on('close', () => abortOnDisconnect('req.socket.close')); - res.on('close', () => abortOnDisconnect('res.close')); - res.socket?.on('close', () => abortOnDisconnect('res.socket.close')); - const disconnectPoll = setInterval(() => { - if ( - req.destroyed || - res.destroyed || - req.socket?.destroyed === true || - res.socket?.destroyed === true - ) { - abortOnDisconnect('poll.destroyed'); - } - }, 50); + ); try { const upstreamResponse = await fetch( @@ -181,7 +222,7 @@ export async function handleProxyMessagesRequest( buildFetchInit(upstream.route.profile, upstream.body, controller.signal, insecureDispatcher) ); clearTimeout(timeout); - clearInterval(disconnectPoll); + cleanupDisconnectHandlers(); logger.info('response.received', 'Received upstream response', { profileName: profile.profileName, routedProfileName: upstream.route.profile.profileName, @@ -191,7 +232,7 @@ export async function handleProxyMessagesRequest( await pipeWebResponseToNode(response, res); } finally { clearTimeout(timeout); - clearInterval(disconnectPoll); + cleanupDisconnectHandlers(); } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown proxy error'; diff --git a/src/proxy/transformers/sse-stream-transformer.ts b/src/proxy/transformers/sse-stream-transformer.ts index 53774c21..0211e211 100644 --- a/src/proxy/transformers/sse-stream-transformer.ts +++ b/src/proxy/transformers/sse-stream-transformer.ts @@ -1,7 +1,253 @@ -import { - createAnthropicErrorResponse, - createAnthropicProxyResponse, -} from '../../cursor/cursor-anthropic-response'; +import { DeltaAccumulator } from '../../glmt/delta-accumulator'; +import { GlmtTransformer } from '../../glmt/glmt-transformer'; +import { SSEParser } from '../../glmt/sse-parser'; +import type { OpenAIResponse, SSEEvent } from '../../glmt/pipeline'; + +const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; +const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; + +type ResponseHeaders = Headers | Record | Array<[string, string]>; + +interface AnthropicErrorPayload { + type: 'error'; + error: { + type: string; + message: string; + }; +} + +function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload { + return { + type: 'error', + error: { + type, + message, + }, + }; +} + +function formatErrorForLog(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function logTranslationError(context: string, error: unknown): void { + console.error(`[proxy-sse-transformer] ${context}: ${formatErrorForLog(error)}`); +} + +export function createAnthropicErrorResponse( + status: number, + type: string, + message: string, + headers?: ResponseHeaders +): Response { + const responseHeaders = new Headers(headers); + responseHeaders.set('Content-Type', 'application/json'); + responseHeaders.delete('Content-Length'); + + return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { + status, + headers: responseHeaders, + }); +} + +function formatSseEvent(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function hasTranslatableChoices(value: unknown): value is OpenAIResponse { + if (typeof value !== 'object' || value === null) { + return false; + } + + const { choices } = value as OpenAIResponse; + if (!Array.isArray(choices) || choices.length === 0) { + return false; + } + + const firstChoice = choices[0]; + if (typeof firstChoice !== 'object' || firstChoice === null) { + return false; + } + + const message = (firstChoice as { message?: unknown }).message; + return typeof message === 'object' && message !== null; +} + +function isSyntheticTransformationFallback(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.startsWith('msg_error_') + ); +} + +async function createAnthropicErrorProxyResponse(response: Response): Promise { + const headers = new Headers(response.headers); + headers.delete('Content-Type'); + headers.delete('Content-Length'); + + let type = + response.status === 401 + ? 'authentication_error' + : response.status === 429 + ? 'rate_limit_error' + : response.status >= 400 && response.status < 500 + ? 'invalid_request_error' + : 'api_error'; + let message = `Cursor request failed with status ${response.status}`; + + try { + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + const payload = (await response.json()) as { + error?: { type?: string; message?: string }; + message?: string; + }; + + if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) { + type = payload.error.type; + } + + if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) { + message = payload.error.message; + } else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) { + message = payload.message; + } + } else { + const text = (await response.text()).trim(); + if (text.length > 0) { + message = text; + } + } + } catch (error) { + logTranslationError('Failed to parse upstream error response', error); + } + + return createAnthropicErrorResponse(response.status, type, message, headers); +} + +async function createAnthropicJsonResponse(response: Response): Promise { + try { + const openAIResponse = await response.json(); + if (!hasTranslatableChoices(openAIResponse)) { + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + + const anthropicResponse = new GlmtTransformer().transformResponse(openAIResponse); + if (isSyntheticTransformationFallback(anthropicResponse)) { + logTranslationError( + 'Cursor JSON translation produced synthetic fallback response', + anthropicResponse + ); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + + return new Response(JSON.stringify(anthropicResponse), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + logTranslationError('Cursor JSON translation failed', error); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } +} + +function createAnthropicStreamingResponse(response: Response): Response { + const body = response.body; + if (!body) { + return createAnthropicErrorResponse( + 502, + 'api_error', + 'Cursor stream ended before a response body was available' + ); + } + + const parser = new SSEParser({ throwOnMalformedJson: true }); + const transformer = new GlmtTransformer(); + const accumulator = new DeltaAccumulator({}); + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + async start(controller) { + const reader = body.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + const events = parser.parse(Buffer.from(value)); + for (const event of events) { + const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator); + for (const anthropicEvent of anthropicEvents) { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + } + } + } + + if (!accumulator.isFinalized() && accumulator.isMessageStarted()) { + for (const anthropicEvent of transformer.finalizeDelta(accumulator)) { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + } + } + } catch (error) { + logTranslationError('Cursor SSE translation failed', error); + controller.enqueue( + encoder.encode( + formatSseEvent( + 'error', + createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE) + ) + ) + ); + } finally { + reader.releaseLock(); + controller.close(); + } + }, + }); + + return new Response(readable, { + status: response.status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +export async function createAnthropicProxyResponse(response: Response): Promise { + if (!response.ok) { + return createAnthropicErrorProxyResponse(response); + } + + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + const isEventStream = + contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;'); + + return isEventStream + ? createAnthropicStreamingResponse(response) + : createAnthropicJsonResponse(response); +} export class ProxySseStreamTransformer { async transform(response: Response): Promise { diff --git a/tests/unit/glmt/sse-parser.test.ts b/tests/unit/glmt/sse-parser.test.ts index 10f14177..740fc0e1 100644 --- a/tests/unit/glmt/sse-parser.test.ts +++ b/tests/unit/glmt/sse-parser.test.ts @@ -28,8 +28,22 @@ describe('SSEParser', () => { const events = parser.parse('\n\n'); expect(events).toHaveLength(1); - expect((events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0]?.delta?.content).toBe( - 'Hi' + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Hi'); + }); + + it('accepts standalone carriage-return line endings', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + const events = parser.parse( + ['event: message', 'data: {"choices":[{"delta":{"content":"Legacy"}}]}', '', ''].join('\r') ); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Legacy'); }); }); diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts new file mode 100644 index 00000000..3ac40437 --- /dev/null +++ b/tests/unit/proxy/messages-route.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'bun:test'; +import { EventEmitter } from 'events'; +import { attachDisconnectAbortHandlers } from '../../../src/proxy/server/messages-route'; + +class FakeSocket extends EventEmitter { + destroyed = false; +} + +class FakeRequest extends EventEmitter { + destroyed = false; + socket = new FakeSocket(); +} + +class FakeResponse extends EventEmitter { + destroyed = false; + writableEnded = false; + socket = new FakeSocket(); +} + +describe('attachDisconnectAbortHandlers', () => { + it('cleans up registered listeners after the request completes', () => { + const req = new FakeRequest(); + const res = new FakeResponse(); + const controller = new AbortController(); + + const cleanup = attachDisconnectAbortHandlers( + req as never, + res as never, + controller, + () => {} + ); + + expect(req.listenerCount('aborted')).toBe(1); + expect(req.listenerCount('close')).toBe(1); + expect(req.socket.listenerCount('close')).toBe(1); + expect(res.listenerCount('close')).toBe(1); + expect(res.socket.listenerCount('close')).toBe(1); + + cleanup(); + + expect(req.listenerCount('aborted')).toBe(0); + expect(req.listenerCount('close')).toBe(0); + expect(req.socket.listenerCount('close')).toBe(0); + expect(res.listenerCount('close')).toBe(0); + expect(res.socket.listenerCount('close')).toBe(0); + }); + + it('aborts at most once when disconnect signals race each other', () => { + const req = new FakeRequest(); + const res = new FakeResponse(); + const controller = new AbortController(); + let disconnectCount = 0; + + const cleanup = attachDisconnectAbortHandlers( + req as never, + res as never, + controller, + () => { + disconnectCount += 1; + } + ); + + req.emit('close'); + req.socket.emit('close'); + res.emit('close'); + + expect(controller.signal.aborted).toBe(true); + expect(disconnectCount).toBe(1); + + cleanup(); + }); +}); diff --git a/tests/unit/proxy/transformers/sse-stream-transformer.test.ts b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts new file mode 100644 index 00000000..9faebabc --- /dev/null +++ b/tests/unit/proxy/transformers/sse-stream-transformer.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'bun:test'; +import { createAnthropicProxyResponse } from '../../../../src/proxy/transformers/sse-stream-transformer'; + +describe('proxy SSE stream transformer', () => { + it('converts OpenAI JSON into Anthropic message JSON', async () => { + const response = new Response( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'claude-sonnet-4.5', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Here is the result.', + reasoning_content: 'Need to call the tool first.', + tool_calls: [ + { + id: 'toolu_2', + type: 'function', + function: { name: 'search', arguments: '{"q":"proxy"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + + const transformed = await createAnthropicProxyResponse(response); + const body = (await transformed.json()) as { + type: string; + stop_reason: string; + content: Array<{ type: string; thinking?: string; name?: string }>; + }; + + expect(body.type).toBe('message'); + expect(body.stop_reason).toBe('tool_use'); + expect(body.content.map((block) => block.type)).toEqual(['thinking', 'text', 'tool_use']); + expect(body.content[0]?.thinking).toContain('Need to call the tool first'); + expect(body.content[2]?.name).toBe('search'); + }); + + it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { + const openAISse = [ + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const transformed = await createAnthropicProxyResponse( + new Response(openAISse, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: message_start'); + expect(body).toContain('event: content_block_start'); + expect(body).toContain('"type":"text_delta"'); + expect(body).toContain('event: message_stop'); + }); +}); From 7f1c23607db9692ffcfa593930f90809f03f3af8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 02:01:39 -0400 Subject: [PATCH 27/59] fix(gitlab): sanitize PAT auth failures across cli and web --- src/cliproxy/auth/gitlab-pat-response.ts | 90 +++++++++++++++++++ src/cliproxy/auth/oauth-handler.ts | 25 +++--- src/web-server/routes/cliproxy-auth-routes.ts | 16 ++-- .../unit/cliproxy/gitlab-pat-response.test.ts | 61 +++++++++++++ 4 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 src/cliproxy/auth/gitlab-pat-response.ts create mode 100644 tests/unit/cliproxy/gitlab-pat-response.test.ts diff --git a/src/cliproxy/auth/gitlab-pat-response.ts b/src/cliproxy/auth/gitlab-pat-response.ts new file mode 100644 index 00000000..50a73b8b --- /dev/null +++ b/src/cliproxy/auth/gitlab-pat-response.ts @@ -0,0 +1,90 @@ +const GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH = 500; +const GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; +const HTML_ERROR_RESPONSE_OMITTED = '[HTML error response omitted]'; + +function sanitizeGitLabPatErrorDetail( + detail: string | undefined, + submittedToken?: string +): string | undefined { + const trimmed = detail?.trim(); + if (!trimmed) { + return undefined; + } + + if (/^]+>/.test(trimmed)) { + return HTML_ERROR_RESPONSE_OMITTED; + } + + let sanitized = trimmed.replace( + /"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token|personal_access_token)"\s*:\s*"[^"]*"/gi, + '"$1":"[redacted]"' + ); + + if (submittedToken) { + sanitized = sanitized.split(submittedToken).join('[redacted]'); + } + + sanitized = sanitized + .replace(/glpat-[A-Za-z0-9._-]+/gi, '[redacted]') + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]') + .replace(/\s+/g, ' '); + + if (sanitized.length > GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH) { + sanitized = `${sanitized.slice( + 0, + GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH - GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX.length + )}${GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX}`; + } + + return sanitized; +} + +export function parseGitLabPatAuthResponse( + responseOk: boolean, + responseStatus: number, + responseBody: string, + submittedToken?: string +): + | { ok: true; payload: Record } + | { ok: false; payload: Record; errorMessage: string } { + const trimmedBody = responseBody.trim(); + let payload: Record = {}; + + if (trimmedBody) { + try { + payload = JSON.parse(trimmedBody) as Record; + } catch { + payload = { + error: + sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) || + `GitLab PAT login failed with status ${responseStatus}`, + }; + } + } + + const payloadError = + typeof payload.error === 'string' + ? sanitizeGitLabPatErrorDetail(payload.error, submittedToken) + : undefined; + const fallbackError = + sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) || + `GitLab PAT login failed with status ${responseStatus}`; + + if (!responseOk) { + return { + ok: false, + payload, + errorMessage: payloadError || fallbackError, + }; + } + + if (payload.status !== 'ok') { + return { + ok: false, + payload, + errorMessage: payloadError || fallbackError, + }; + } + + return { ok: true, payload }; +} diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index ea2381e0..b6ba5e7e 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -56,6 +56,7 @@ import { } from './token-manager'; import { executeOAuthProcess } from './oauth-process'; import { importKiroToken } from './kiro-import'; +import { parseGitLabPatAuthResponse } from './gitlab-pat-response'; import { getProxyTarget, buildProxyUrl, @@ -723,22 +724,16 @@ async function handleGitLabPatLogin( }), }); - const responseBody = (await response.text()).trim(); - let payload: Record = {}; - if (responseBody) { - try { - payload = JSON.parse(responseBody) as Record; - } catch { - payload = { error: responseBody }; - } - } + const responseBody = await response.text(); + const parsedResponse = parseGitLabPatAuthResponse( + response.ok, + response.status, + responseBody, + token + ); - if (!response.ok || payload.status !== 'ok') { - const errorMessage = - (typeof payload.error === 'string' && payload.error) || - responseBody || - `GitLab PAT login failed with status ${response.status}`; - console.log(fail(errorMessage)); + if (!parsedResponse.ok) { + console.log(fail(parsedResponse.errorMessage)); return null; } diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 8e63e3bc..6e12f5fa 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -43,6 +43,7 @@ import { listProviderTokenSnapshots, registerAccountFromToken, } from '../../cliproxy/auth/token-manager'; +import { parseGitLabPatAuthResponse } from '../../cliproxy/auth/gitlab-pat-response'; import { CLIPROXY_CALLBACK_PROVIDER_MAP, CLIPROXY_AUTH_URL_PROVIDER_MAP, @@ -706,13 +707,16 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise ({}))) as { - status?: string; - error?: string; - }; - if (!response.ok || data.status !== 'ok') { + const responseBody = await response.text(); + const parsedResponse = parseGitLabPatAuthResponse( + response.ok, + response.status, + responseBody, + gitlabPersonalAccessToken + ); + if (!parsedResponse.ok) { res.status(response.ok ? 400 : response.status).json({ - error: data.error || 'GitLab PAT authentication failed', + error: parsedResponse.errorMessage || 'GitLab PAT authentication failed', }); return; } diff --git a/tests/unit/cliproxy/gitlab-pat-response.test.ts b/tests/unit/cliproxy/gitlab-pat-response.test.ts new file mode 100644 index 00000000..029a276b --- /dev/null +++ b/tests/unit/cliproxy/gitlab-pat-response.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { parseGitLabPatAuthResponse } from '../../../src/cliproxy/auth/gitlab-pat-response'; + +describe('parseGitLabPatAuthResponse', () => { + it('sanitizes HTML error bodies for non-ok responses', () => { + const result = parseGitLabPatAuthResponse( + false, + 502, + 'gateway error', + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toBe('[HTML error response omitted]'); + } + }); + + it('sanitizes reflected PAT tokens from structured error payloads', () => { + const result = parseGitLabPatAuthResponse( + false, + 400, + JSON.stringify({ status: 'error', error: 'Rejected token glpat-secret-token for login' }), + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toContain('[redacted]'); + expect(result.errorMessage).not.toContain('glpat-secret-token'); + } + }); + + it('rejects ok responses whose body is not valid success JSON', () => { + const result = parseGitLabPatAuthResponse( + true, + 200, + 'upstream temporarily unavailable', + 'glpat-secret-token' + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.errorMessage).toContain('upstream temporarily unavailable'); + } + }); + + it('accepts explicit success payloads', () => { + const result = parseGitLabPatAuthResponse( + true, + 200, + JSON.stringify({ status: 'ok', saved_path: '/tmp/gitlab.json' }), + 'glpat-secret-token' + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.payload.status).toBe('ok'); + } + }); +}); From 841eeb497c274ed38ec2c653ec7c9cd02c1ea0ed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 02:52:29 -0400 Subject: [PATCH 28/59] fix(proxy): keep stream guards active through sse piping --- src/proxy/server/messages-route.ts | 2 -- .../transformers/sse-stream-transformer.ts | 14 +++++------ .../proxy/messages-edge-cases.test.ts | 24 ++++++++++++++++++- .../cursor-anthropic-translator.test.ts | 12 +++++----- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index 13c329e8..95388e00 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -221,8 +221,6 @@ export async function handleProxyMessagesRequest( resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl), buildFetchInit(upstream.route.profile, upstream.body, controller.signal, insecureDispatcher) ); - clearTimeout(timeout); - cleanupDisconnectHandlers(); logger.info('response.received', 'Received upstream response', { profileName: profile.profileName, routedProfileName: upstream.route.profile.profileName, diff --git a/src/proxy/transformers/sse-stream-transformer.ts b/src/proxy/transformers/sse-stream-transformer.ts index 0211e211..dff2b7ce 100644 --- a/src/proxy/transformers/sse-stream-transformer.ts +++ b/src/proxy/transformers/sse-stream-transformer.ts @@ -3,8 +3,8 @@ import { GlmtTransformer } from '../../glmt/glmt-transformer'; import { SSEParser } from '../../glmt/sse-parser'; import type { OpenAIResponse, SSEEvent } from '../../glmt/pipeline'; -const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; -const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; +const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible JSON response'; +const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible SSE response'; type ResponseHeaders = Headers | Record | Array<[string, string]>; @@ -103,7 +103,7 @@ async function createAnthropicErrorProxyResponse(response: Response): Promise= 400 && response.status < 500 ? 'invalid_request_error' : 'api_error'; - let message = `Cursor request failed with status ${response.status}`; + let message = `Upstream request failed with status ${response.status}`; try { const contentType = (response.headers.get('content-type') || '').toLowerCase(); @@ -145,7 +145,7 @@ async function createAnthropicJsonResponse(response: Response): Promise { type: 'error', error: { type: 'api_error', - message: 'Failed to translate Cursor JSON response', + message: 'Failed to translate OpenAI-compatible JSON response', }, }); }); @@ -202,6 +202,28 @@ describe('openai proxy message edge cases', () => { }); }); + it('emits an SSE error if the upstream stalls after response headers are sent', async () => { + process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS = '50'; + await startProxyWithHandler((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"partial"}}]}\n\n' + ); + }); + + const response = await requestProxy({ + model: 'hf-model', + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toContain('event: message_start'); + expect(body).toContain('event: error'); + expect(body).toContain('"message":"Failed to translate OpenAI-compatible SSE response"'); + }); + it('aborts the upstream request when the client disconnects mid-flight', async () => { await startProxyWithHandler(() => {}); diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 0abaec2b..9da161fe 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -281,7 +281,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('returns 502 when Cursor response is missing choices', async () => { @@ -305,7 +305,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('returns 502 when Cursor response has empty choices', async () => { @@ -330,7 +330,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('returns Anthropic error envelopes for non-OK upstream JSON errors', async () => { @@ -382,7 +382,7 @@ describe('createAnthropicProxyResponse', () => { }; expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); - expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + expect(body.error?.message).toBe('Failed to translate OpenAI-compatible JSON response'); }); it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { @@ -420,7 +420,7 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('event: error'); expect(body).toContain('"type":"error"'); expect(body).toContain('"error":{"type":"api_error"'); - expect(body).toContain('Failed to translate Cursor SSE response'); + expect(body).toContain('Failed to translate OpenAI-compatible SSE response'); }); it('emits Anthropic-style error events when SSE JSON is malformed', async () => { @@ -435,6 +435,6 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('event: error'); expect(body).toContain('"type":"error"'); expect(body).toContain('"error":{"type":"api_error"'); - expect(body).toContain('Failed to translate Cursor SSE response'); + expect(body).toContain('Failed to translate OpenAI-compatible SSE response'); }); }); From 5b262f0139cf31331168b2c28304b499c3f23466 Mon Sep 17 00:00:00 2001 From: xuhaodong Date: Wed, 15 Apr 2026 15:02:54 +0800 Subject: [PATCH 29/59] fix(shared-manager): exclude .staging dirs from marketplace registry sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's marketplace auto-update uses a three-step atomic rename: clone to .staging → rename old dir to .bak → rename staging to the final name. On Windows, EPERM errors can interrupt the rename-dance and leave a .staging directory permanently on disk. CCS scanned physical directories to discover marketplaces, so a lingering .staging directory was registered as a bare { installLocation } entry in known_marketplaces.json. Claude Code's Zod schema requires each entry to also have `source` and `lastUpdated` fields, so the corrupt entry caused the /plugin command to throw a validation error. Fix (two layers of defence): - discoverMarketplaceEntries: skip any directory whose name starts with '.' or ends with '.staging'. This filters all hidden dirs including .staging and .bak left behind by interrupted rename operations. - buildMarketplaceRegistryContent: invert the loop direction. Instead of iterating discoveredEntries and adding to the registry (which could introduce bare entries for disk-only directories with no registry record), iterate the merged registry and only keep entries that also exist on disk. Disk-only directories are now silently ignored. Adds two regression tests: one for .staging pollution, one for orphan registry entries whose physical directory has been removed. --- src/management/shared-manager.ts | 28 +++++++------- tests/unit/shared-manager.test.ts | 61 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index e423d77d..a0b5ec77 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -947,23 +947,20 @@ class SharedManager { const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir); - for (const [name, value] of Object.entries(discoveredEntries)) { - const existing = merged[name]; - if (existing && typeof existing === 'object' && !Array.isArray(existing)) { - merged[name] = { - ...(existing as Record), - installLocation: value.installLocation, - }; - continue; - } - - merged[name] = value; - } - + // Keep only registry entries that have a physical directory, and update their + // installLocation. Entries only on disk (no registry record) are excluded — + // they lack required schema fields that Claude Code enforces. for (const name of Object.keys(merged)) { + const entry = merged[name]; if (!(name in discoveredEntries)) { delete merged[name]; + } else if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + merged[name] = { + ...(entry as Record), + installLocation: discoveredEntries[name].installLocation, + }; } + // else: entry is in discoveredEntries but has a malformed value — preserve as-is } return JSON.stringify(merged, null, 2); @@ -984,6 +981,11 @@ class SharedManager { continue; } + // Skip hidden dirs and Claude Code's .staging rename-dance work trees. + if (entry.name.startsWith('.') || entry.name.endsWith('.staging')) { + continue; + } + discovered[entry.name] = { installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), }; diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 240769c6..46feb170 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -360,6 +360,67 @@ describe('SharedManager', () => { expect(reconciled.stale).toBeUndefined(); }); + it('does not register .staging directories left behind by interrupted marketplace auto-updates', () => { + // Regression: CCS used to write bare { installLocation } entries for marketplace + // directories with no registry record. Claude Code requires source + lastUpdated, + // so those entries corrupted known_marketplaces.json and broke /plugin. + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + // Simulate Claude Code leaving a .staging dir behind in both the global claude dir + // and the instance dir (discoverMarketplaceEntries scans each independently). + fs.mkdirSync(marketplacePath(claudeDir(), 'claude-plugins-official.staging'), { + recursive: true, + }); + fs.mkdirSync(marketplacePath(instancePath, 'claude-plugins-official.staging'), { + recursive: true, + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + const global = readJson(globalRegistryPath) as Record; + expect(global['claude-plugins-official.staging']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + expect(instance['claude-plugins-official.staging']).toBeUndefined(); + }); + + it('removes registry entries whose physical marketplace directory no longer exists', () => { + // Regression guard: buildMarketplaceRegistryContent merges JSON sources then + // cross-checks against discoveredEntries. Any name in the merged registry that + // has no matching directory on disk must be pruned so stale entries don't + // accumulate across marketplace uninstalls or renames. + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + // Write a registry entry for a marketplace that has no physical directory. + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'vanished-marketplace': { + source: { type: 'github', repo: 'example/vanished' }, + lastUpdated: '2024-01-01T00:00:00.000Z', + installLocation: marketplacePath(claudeDir(), 'vanished-marketplace'), + }, + }); + // Intentionally do NOT create the physical directory — simulate an uninstalled + // marketplace whose registry entry was not cleaned up. + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const global = readJson(globalRegistryPath) as Record; + expect(global['vanished-marketplace']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + expect(instance['vanished-marketplace']).toBeUndefined(); + }); + it('warns and skips malformed marketplace registries while keeping valid sources', () => { const manager = new SharedManager(); const instancePath = instanceDir('work'); From 1ff84ce0a41396ed0f532e3766e459cb4f90e6fb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 13:26:23 -0400 Subject: [PATCH 30/59] chore(git): ignore AI plan registry files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8bc708e6..2a782db3 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ package-lock.json .claude/active-plan .claude/agent-memory/ +.claude/plans-registry.json* # Logs directory logs/ From 78212475ac61178ace43bb3c5219e3a3eb2a258d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 17:31:08 +0000 Subject: [PATCH 31/59] chore(release): 7.71.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef68b471..4bbf53ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.4", + "version": "7.71.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 1bad3b0305f32b13e78a8a41dba9c63a52449722 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 15:45:13 -0400 Subject: [PATCH 32/59] fix(ui): align cliproxy provider assets --- ui/public/assets/providers/codebuddy.png | Bin 0 -> 1727 bytes ui/public/assets/providers/codebuddy.svg | 7 ----- ui/public/assets/providers/gitlab.svg | 26 +++++++++++++++---- ui/public/assets/providers/kilo.png | Bin 0 -> 403 bytes ui/public/assets/providers/kilo.svg | 4 --- ui/src/lib/provider-config.ts | 6 ++--- ui/tests/unit/ui/lib/provider-config.test.ts | 6 ++--- 7 files changed, 27 insertions(+), 22 deletions(-) create mode 100644 ui/public/assets/providers/codebuddy.png delete mode 100644 ui/public/assets/providers/codebuddy.svg create mode 100644 ui/public/assets/providers/kilo.png delete mode 100644 ui/public/assets/providers/kilo.svg diff --git a/ui/public/assets/providers/codebuddy.png b/ui/public/assets/providers/codebuddy.png new file mode 100644 index 0000000000000000000000000000000000000000..57c113ecf335c8db8336710358058a4c0e8fa498 GIT binary patch literal 1727 zcmV;w20;0VP)XGU~kZ1x)^1uLJ3R1irBC4r`t$j2CuKSWg{_9(FlS~hE6!goB^N}XP>C8Gg8%?MPy&lI*Xlor0=#Q1X1eVHh5EbfC1s$XoA+v;`&r zzG5KN%L<@GfCMIe(S$TPA3j$C8K%q=<_V!n)@?Uozv+b%3{V+#9B|71(ev>rA zE2ws>1=U0;0#^fNokFb0`j;*6X9Ch}T~OlZFet~sW#ALcvx>N}W=puL#?1Y*Ei^h~Ri zdJYtNS{XOjY>rk{Tllh%)OVv8h#rLmD-s*~sWPG#{Bf zeK_rRAyrjQn+{IGpUu0XCKPIU%vxO+B|a}0^!8+kveJ{tyuGK8F%NYvaIoIpVV^!a z(b;BG{pb@)>XbpB(#C@mn1izUc@;4$H+Hd_kOz}aXI?a=j)reKbE4sX*B>^Nk5s0q zWK>s!qQNl47!6>+dwU-;;|UKPqd5_v*-&H|(9b>@=xonGL{HjlKYSlE%zQU2ryf8zog8WWVFWBvrVOgR5Ok`1U@1%PA1;Op7BYatk|cW{ z#^RAFwc+Oz2c3!Z@soEuI@1(i6c`W0F5z9>IdvQIU)x=LvN3h&n-T0QI93SmK?bm? zx#ezk&D?CAsCWiE_U=pG?82(RE;8U{hS6ZEJET7PDOiW!OT5>5GK^cA{al>GKl$P`n;uj@X`KGVYsj=l~!Yi8)#ivjxZh9vYoK&gfho}{98LH z$Tzoco8^XVE#Ls}Eay-;Nhl0u9X%auz1`M(A1NYy0VD)y3wUsDafBasco<07 zZl2RIlsek#`Pja3HV@l84_2{`Igrpi8d`2++Rx#nF`>ds<5 ze2^Q7Q5`^FDGeC#Tf-AN2&XK}oMs80$75Yuq_V*lCpVeO;-*||LAVxaRJHv(=P9r<>SR$5+`Q`XK4Gwym^gN>Pp>YXH*3YBV(@{PP$_jiHclXe*H2(0 zCOY=t@4o^LcGQ>UUX!n`FULDwcPi6V#HGIBrb~aqoXy3}k-Jz$SV-I(bD%8}1W@bL@J=}BU7ZO? zwIPTl2htO1nh};zjqO>7C1ae-yCdwT=JSnTYYYDX00960g~qK100006Nkl - - - - - - diff --git a/ui/public/assets/providers/gitlab.svg b/ui/public/assets/providers/gitlab.svg index 5abce1d3..394c9e01 100644 --- a/ui/public/assets/providers/gitlab.svg +++ b/ui/public/assets/providers/gitlab.svg @@ -1,6 +1,22 @@ - - - - - + + + + + + + + + diff --git a/ui/public/assets/providers/kilo.png b/ui/public/assets/providers/kilo.png new file mode 100644 index 0000000000000000000000000000000000000000..71c0dd2d6525f5badc89a0d203e5f21b053ca2ed GIT binary patch literal 403 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnF3?v&v(vO2U$sR$z3=CCj3=9n|3=F@3LJcn% z7)lKo7+xhXFj&oCU=S~uvn$XBD8V1#6XFV_e|#%@{4nSA$;AKv|96*r*8znXlf2zs zSo$+=1_C+kC7!;n?5~&@S$J4Pm$h~Pg^Bd!=p9(#(WI;JSc3Hf#{$c{46zOp zE?kPO>=B&{m?kkS%uoIa}jswJ)wB`Jv|saDBFsX&Us$iT=<*T6{E zz%azX#LCdn%EVIJz`)ADAbOfzFp7rU{FKbJO57T>PPe@WYS4h&P?DLOT3nKtTY#>| Z$jZP3Vo7mHF(*(DgQu&X%Q~loCICK?Z$AJ4 literal 0 HcmV?d00001 diff --git a/ui/public/assets/providers/kilo.svg b/ui/public/assets/providers/kilo.svg deleted file mode 100644 index e7c84dde..00000000 --- a/ui/public/assets/providers/kilo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 15ef36ce..3500e4a4 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -67,10 +67,10 @@ export const PROVIDER_ASSETS: Partial> = { qwen: '/assets/providers/qwen-color.svg', iflow: '/assets/providers/iflow.png', kiro: '/assets/providers/kiro.png', - cursor: '/assets/providers/cursor.svg', + cursor: '/assets/sidebar/cursor.svg', gitlab: '/assets/providers/gitlab.svg', - codebuddy: '/assets/providers/codebuddy.svg', - kilo: '/assets/providers/kilo.svg', + codebuddy: '/assets/providers/codebuddy.png', + kilo: '/assets/providers/kilo.png', ghcp: '/assets/providers/copilot.svg', claude: '/assets/providers/claude.svg', kimi: '/assets/providers/kimi.svg', diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 5f953089..b33f24b1 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -50,15 +50,15 @@ describe('provider model mapping helpers', () => { describe('provider presentation metadata', () => { it.each([ - ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/providers/cursor.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'], [ 'codebuddy', 'CodeBuddy (Tencent)', 'Tencent CodeBuddy AI assistant', - '/assets/providers/codebuddy.svg', + '/assets/providers/codebuddy.png', ], - ['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.svg'], + ['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'], ])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => { expect(getProviderDisplayName(provider)).toBe(name); expect(getProviderDescription(provider)).toBe(description); From 2293368e31f43f779a865a280513702542dc0870 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 16:29:25 -0400 Subject: [PATCH 33/59] fix(shared-manager): prune transient and malformed marketplace entries --- src/management/shared-manager.ts | 23 ++++++++++++---- tests/unit/shared-manager.test.ts | 46 +++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index a0b5ec77..730f1ded 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -936,6 +936,10 @@ class SharedManager { } for (const [name, value] of Object.entries(parsed as Record)) { + if (!this.isMarketplaceRegistryEntry(value)) { + continue; + } + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; } } catch (err) { @@ -954,13 +958,14 @@ class SharedManager { const entry = merged[name]; if (!(name in discoveredEntries)) { delete merged[name]; - } else if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + } else if (this.isMarketplaceRegistryEntry(entry)) { merged[name] = { - ...(entry as Record), + ...entry, installLocation: discoveredEntries[name].installLocation, }; + } else { + delete merged[name]; } - // else: entry is in discoveredEntries but has a malformed value — preserve as-is } return JSON.stringify(merged, null, 2); @@ -981,8 +986,8 @@ class SharedManager { continue; } - // Skip hidden dirs and Claude Code's .staging rename-dance work trees. - if (entry.name.startsWith('.') || entry.name.endsWith('.staging')) { + // Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak). + if (this.isTransientMarketplaceDirectory(entry.name)) { continue; } @@ -994,6 +999,14 @@ class SharedManager { return discovered; } + private isTransientMarketplaceDirectory(name: string): boolean { + return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak'); + } + + private isMarketplaceRegistryEntry(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + private writePluginMetadataFile( registryPath: string, content: string, diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 46feb170..730d2f57 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -360,7 +360,7 @@ describe('SharedManager', () => { expect(reconciled.stale).toBeUndefined(); }); - it('does not register .staging directories left behind by interrupted marketplace auto-updates', () => { + it('does not register transient marketplace directories left behind by interrupted auto-updates', () => { // Regression: CCS used to write bare { installLocation } entries for marketplace // directories with no registry record. Claude Code requires source + lastUpdated, // so those entries corrupted known_marketplaces.json and broke /plugin. @@ -369,24 +369,29 @@ describe('SharedManager', () => { fs.mkdirSync(instancePath, { recursive: true }); manager.linkSharedDirectories(instancePath); - // Simulate Claude Code leaving a .staging dir behind in both the global claude dir - // and the instance dir (discoverMarketplaceEntries scans each independently). - fs.mkdirSync(marketplacePath(claudeDir(), 'claude-plugins-official.staging'), { - recursive: true, - }); - fs.mkdirSync(marketplacePath(instancePath, 'claude-plugins-official.staging'), { - recursive: true, - }); + // Simulate Claude Code leaving rename-dance temp dirs behind in both the + // global claude dir and the instance dir (discoverMarketplaceEntries scans + // each independently). + for (const suffix of ['.staging', '.bak']) { + fs.mkdirSync(marketplacePath(claudeDir(), `claude-plugins-official${suffix}`), { + recursive: true, + }); + fs.mkdirSync(marketplacePath(instancePath, `claude-plugins-official${suffix}`), { + recursive: true, + }); + } manager.normalizeMarketplaceRegistryPaths(instancePath); const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); const global = readJson(globalRegistryPath) as Record; expect(global['claude-plugins-official.staging']).toBeUndefined(); + expect(global['claude-plugins-official.bak']).toBeUndefined(); const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); const instance = readJson(instanceRegistryPath) as Record; expect(instance['claude-plugins-official.staging']).toBeUndefined(); + expect(instance['claude-plugins-official.bak']).toBeUndefined(); }); it('removes registry entries whose physical marketplace directory no longer exists', () => { @@ -421,6 +426,29 @@ describe('SharedManager', () => { expect(instance['vanished-marketplace']).toBeUndefined(); }); + it('drops malformed marketplace entries even when the payload directory still exists', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + fs.mkdirSync(instancePath, { recursive: true }); + manager.linkSharedDirectories(instancePath); + + fs.mkdirSync(marketplacePath(claudeDir(), 'claude-code-plugins'), { recursive: true }); + + const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); + writeJson(globalRegistryPath, { + 'claude-code-plugins': 'bad-entry', + }); + + manager.normalizeMarketplaceRegistryPaths(instancePath); + + const global = readJson(globalRegistryPath) as Record; + expect(global['claude-code-plugins']).toBeUndefined(); + + const instanceRegistryPath = path.join(instancePath, 'plugins', 'known_marketplaces.json'); + const instance = readJson(instanceRegistryPath) as Record; + expect(instance['claude-code-plugins']).toBeUndefined(); + }); + it('warns and skips malformed marketplace registries while keeping valid sources', () => { const manager = new SharedManager(); const instancePath = instanceDir('work'); From 1084316835637a5e9e04e9291a0654c3f5728c14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 20:40:21 +0000 Subject: [PATCH 34/59] chore(release): 7.71.0-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4bbf53ff..6cedcbe2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.5", + "version": "7.71.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 3b5941c60bf5578c49785c001f85612cfec8a7e5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 17:04:37 -0400 Subject: [PATCH 35/59] feat(cursor): split legacy bridge from cliproxy provider --- docs/cursor-integration.md | 50 +++--- .../phase-01-cli-routing-namespacing.md | 127 +++++++++++++++ .../phase-02-storage-api-boundaries.md | 140 +++++++++++++++++ .../phase-03-dashboard-deprecation-ux.md | 121 ++++++++++++++ .../phase-04-tests-docs-rollout.md | 148 ++++++++++++++++++ .../plan.md | 93 +++++++++++ src/auth/profile-detector.ts | 18 +-- src/ccs.ts | 58 +++++-- src/cliproxy/config/env-builder.ts | 8 +- src/cliproxy/config/path-resolver.ts | 42 +++++ src/cliproxy/model-config.ts | 10 +- src/commands/command-catalog.ts | 12 +- src/commands/cursor-command-display.ts | 55 ++++--- src/commands/cursor-command.ts | 37 +++-- src/commands/help-command.ts | 3 +- src/config/reserved-names.ts | 2 + src/cursor/constants.ts | 2 + src/cursor/cursor-daemon-entry.ts | 4 +- src/cursor/cursor-profile-executor.ts | 8 +- src/cursor/cursor-runtime-probe.ts | 6 +- .../hooks/image-analysis-backend-resolver.ts | 2 +- src/web-server/routes/index.ts | 1 + src/web-server/routes/settings-routes.ts | 12 ++ .../cursor-daemon-lifecycle.test.ts | 2 +- tests/npm/cli.test.js | 13 +- tests/unit/auth/profile-detector.test.ts | 23 ++- .../unit/commands/completion-backend.test.ts | 9 ++ tests/unit/cursor/cursor-daemon.test.ts | 20 ++- .../image-analysis-backend-resolver.test.ts | 15 ++ ui/src/App.tsx | 6 +- .../provider-editor-header.tsx | 13 +- .../provider-editor/use-provider-editor.ts | 9 +- ui/src/components/layout/app-sidebar.tsx | 6 +- ui/src/hooks/use-cursor.ts | 30 ++-- ui/src/pages/cursor.tsx | 4 +- ui/tests/unit/hooks/use-cursor.test.tsx | 20 +-- 36 files changed, 974 insertions(+), 155 deletions(-) create mode 100644 plans/20260415-1016-cursor-provider-legacy-split/phase-01-cli-routing-namespacing.md create mode 100644 plans/20260415-1016-cursor-provider-legacy-split/phase-02-storage-api-boundaries.md create mode 100644 plans/20260415-1016-cursor-provider-legacy-split/phase-03-dashboard-deprecation-ux.md create mode 100644 plans/20260415-1016-cursor-provider-legacy-split/phase-04-tests-docs-rollout.md create mode 100644 plans/20260415-1016-cursor-provider-legacy-split/plan.md diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index c49a0f3a..b8cdd0c8 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -1,17 +1,20 @@ # Cursor IDE Integration -This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls. +This guide covers the deprecated CCS-owned Cursor IDE bridge, including auth import, local daemon lifecycle, live probe checks, and dashboard controls. + +`ccs cursor` now belongs to the CLIProxy-backed Cursor provider path. +Use `ccs legacy cursor` for the deprecated local bridge documented here. ## What It Provides - OpenAI-compatible local endpoint powered by Cursor credentials. - Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. - Cursor model list and chat completions via the local CCS daemon. -- Dedicated dashboard page: `ccs config` -> `Cursor IDE`. +- Dedicated dashboard page: `ccs config` -> `Deprecated` -> `Cursor IDE (Legacy)`. ## What This Runtime Actually Does -`ccs cursor` does not launch Cursor IDE itself. +`ccs legacy cursor` does not launch Cursor IDE itself. The current workflow is: 1. import Cursor credentials from local SQLite or manual input @@ -32,7 +35,7 @@ Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provide ### 1) Enable integration ```bash -ccs cursor enable +ccs legacy cursor enable ``` ### 2) Import credentials @@ -40,25 +43,25 @@ ccs cursor enable Auto-detect from Cursor local SQLite state: ```bash -ccs cursor auth +ccs legacy cursor auth ``` Manual fallback: ```bash -ccs cursor auth --manual --token --machine-id +ccs legacy cursor auth --manual --token --machine-id ``` ### 3) Start daemon ```bash -ccs cursor start +ccs legacy cursor start ``` ### 4) Run a live probe ```bash -ccs cursor probe +ccs legacy cursor probe ``` Use this to verify that the current build can complete one real authenticated request through the local daemon. @@ -66,26 +69,37 @@ Use this to verify that the current build can complete one real authenticated re ### 5) Run Cursor-backed Claude ```bash -ccs cursor "explain this repo" +ccs legacy cursor "explain this repo" ``` ### 6) Verify status ```bash -ccs cursor status +ccs legacy cursor status ``` -Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy. +Use `ccs legacy cursor` with bare or normal Claude args to run through the local Cursor proxy. The admin namespace remains available for setup and inspection: ```bash -ccs cursor help +ccs legacy cursor help ``` ### 7) Stop daemon ```bash -ccs cursor stop +ccs legacy cursor stop +``` + +## Supported Cursor Provider Path + +For the supported CLIProxy-backed Cursor provider, use: + +```bash +ccs cursor --auth +ccs cursor --accounts +ccs cursor --config +ccs cursor "task" ``` ## Runtime Defaults @@ -96,7 +110,7 @@ ccs cursor stop - Model list resolution: authenticated live fetch when available, with cached/default fallback. - Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. - Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. -- Live verification: `ccs cursor probe` or `POST /api/cursor/probe` +- Live verification: `ccs legacy cursor probe` or `POST /api/cursor/probe` These values are managed in unified config and can be updated from CLI or dashboard. @@ -108,7 +122,7 @@ Open dashboard: ccs config ``` -Then navigate to `Cursor IDE` in the sidebar. +Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section. Available controls: @@ -131,9 +145,9 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr ### `Not authenticated` or `expired` in `ccs cursor status` -- Re-run `ccs cursor auth` (or manual auth command). +- Re-run `ccs legacy cursor auth` (or manual auth command). -### `ccs cursor probe` fails even though status is green +### `ccs legacy cursor probe` fails even though status is green - `status` proves local config/auth/daemon readiness only. - `probe` proves the live runtime path. @@ -148,4 +162,4 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr ### Daemon fails to start - Check if port `20129` is in use. -- Change port in dashboard config tab, then retry `ccs cursor start`. +- Change port in dashboard config tab, then retry `ccs legacy cursor start`. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-01-cli-routing-namespacing.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-01-cli-routing-namespacing.md new file mode 100644 index 00000000..44d44c4b --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-01-cli-routing-namespacing.md @@ -0,0 +1,127 @@ +--- +phase: 1 +title: "CLI Routing & Namespacing" +status: complete +effort: "6h" +--- + +# Phase 1: CLI Routing & Namespacing + +## Context Links + +- `plan.md` +- `src/ccs.ts` +- `src/auth/profile-detector.ts` +- `src/cursor/constants.ts` +- `src/commands/root-command-router.ts` +- `src/commands/command-catalog.ts` +- `src/commands/help-command.ts` +- `src/commands/cursor-command.ts` +- `src/commands/cursor-command-display.ts` +- `src/types/profile.ts` +- `src/config/reserved-names.ts` + +## Overview + +- Priority: P1 +- Owner scope: CLI entry, help, profile detection, command naming +- Goal: make `cursor` provider-first and move the deprecated bridge under `legacy cursor` + +## Key Insights + +- The current collision is structural, not cosmetic. `ccs cursor` means "legacy bridge" in `src/ccs.ts` and `src/auth/profile-detector.ts`, but `cursor` is also listed as a built-in CLIProxy provider. +- `shouldUseCursorCliproxyShortcut()` is only a heuristic escape hatch. It does not fix bare `ccs cursor`, quoted prompts, or help routing. +- Help is currently inconsistent: provider help exists generically, but `cursor` is excluded and routed to bridge help instead. + +## Requirements + +- Reserve `cursor` for CLIProxy runtime and CLIProxy admin flags. +- Introduce explicit legacy syntax: `ccs legacy cursor ...`. +- Keep a release-N alias for old legacy admin subcommands only. +- Rename internal bridge-only profile typing from ambiguous `cursor` to explicit `legacy-cursor`. +- Keep file ownership isolated to CLI/router/help files in this phase. + +## Data Flow + +- Provider path: + `argv -> root command resolution -> provider shortcut/help path -> ProfileDetector(type=cliproxy, provider=cursor) -> CLIProxy runtime` +- Legacy path: + `argv -> legacy root command -> legacy cursor subrouter -> ProfileDetector(type=legacy-cursor) or direct handler -> local bridge runtime` +- Deprecated alias path, release N only: + `argv=ccs cursor auth|status|... -> alias shim -> warning -> dispatch to legacy cursor handler` + +## Architecture + +- Add a new root command namespace: `ccs legacy`. +- Add nested routing under `legacy` with `cursor` as the first migrated leaf. Do not overload `cursor` itself any longer. +- Remove provider exceptions for `cursor` from the generic provider help/routing logic. `ccs cursor --help` should now use provider shortcut help. +- Convert bridge-only type checks from `profileInfo.type === 'cursor'` to `profileInfo.type === 'legacy-cursor'`. +- Keep `ccs cursor help` only as a release-N compatibility shim that prints: + - `Use "ccs cursor --help" for CLIProxy Cursor` + - `Use "ccs legacy cursor help" for the deprecated bridge` + +## Related Code Files + +- Modify: + - `src/ccs.ts` + - `src/auth/profile-detector.ts` + - `src/cursor/constants.ts` + - `src/commands/root-command-router.ts` + - `src/commands/command-catalog.ts` + - `src/commands/help-command.ts` + - `src/commands/cursor-command.ts` + - `src/commands/cursor-command-display.ts` + - `src/types/profile.ts` + - `src/config/reserved-names.ts` + - `src/shared/claude-extension-setup.ts` + - `src/targets/target-runtime-compatibility.ts` +- Create: + - `src/commands/legacy-command.ts` or `src/commands/legacy/index.ts` + - `src/commands/legacy/cursor-command.ts` if the team wants physical separation immediately + +## Implementation Steps + +1. Add the `legacy` root command route and its help surface. +2. Flip `src/ccs.ts` so `cursor` goes through normal CLIProxy provider routing; remove the special-case that gives the bridge ownership of the name. +3. Replace the `shouldUseCursorCliproxyShortcut()` hack with provider-first dispatch plus a compatibility alias table for the old legacy subcommands. +4. Update `ProfileDetector` priority order so `cursor` resolves as `cliproxy`, while `legacy cursor` resolves as `legacy-cursor`. +5. Rename bridge-only help text, summaries, and status text to say "legacy Cursor bridge" explicitly. +6. Audit all `profileType === 'cursor'` checks and convert only the bridge-specific ones to `legacy-cursor`. + +## Todo List + +- [x] Add `legacy cursor` routing +- [x] Make `ccs cursor` provider-first for bare, prompt, and `--help` usage +- [x] Add deprecated alias forwarding for old admin subcommands +- [x] Rename internal bridge profile path to `legacy-cursor` +- [x] Update provider help, completion, and command catalog summaries + +## Success Criteria + +- `ccs cursor "task"` resolves to CLIProxy Cursor. +- `ccs legacy cursor "task"` resolves to the old bridge. +- `ccs cursor --help` shows provider shortcut help. +- `ccs cursor auth` still works in release N, but prints an exact replacement warning. +- No CLI path depends on `shouldUseCursorCliproxyShortcut()` to disambiguate runtime meaning. + +## Risk Assessment + +- High likelihood / high impact: users with scripts calling `ccs cursor "task"` will hit the provider path immediately. + Mitigation: call this out in release notes, keep admin aliases, add explicit warning when legacy files/config are detected and the user invokes `ccs cursor` with no flags. +- Medium likelihood / medium impact: bridge-only type renames may break target compatibility checks or extension setup. + Mitigation: grep audit every `profileType === 'cursor'` branch before tests. + +## Rollback Plan + +- Re-enable the old `cursor` special-case in `src/ccs.ts` and `ProfileDetector`. +- Keep the new `legacy` namespace in place even if dormant; it is additive and safe to leave. +- Do not roll back migrated files in this phase; routing rollback alone is enough. + +## Security Considerations + +- No auth material moves in this phase. +- Preserve existing `CCS_HOME`-aware path resolution. Do not introduce `os.homedir()` shortcuts while adding the new namespace. + +## Next Steps + +- Phase 2 depends on the new command contract from this phase. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-02-storage-api-boundaries.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-02-storage-api-boundaries.md new file mode 100644 index 00000000..05cf43e8 --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-02-storage-api-boundaries.md @@ -0,0 +1,140 @@ +--- +phase: 2 +title: "Storage & API Boundaries" +status: partial +effort: "6h" +--- + +# Phase 2: Storage & API Boundaries + +## Context Links + +- `plan.md` +- `src/config/unified-config-types.ts` +- `src/config/unified-config-loader.ts` +- `src/cursor/cursor-auth.ts` +- `src/cursor/cursor-daemon-pid.ts` +- `src/cliproxy/config/path-resolver.ts` +- `src/cliproxy/config/env-builder.ts` +- `src/web-server/routes/index.ts` +- `src/web-server/routes/cursor-routes.ts` +- `src/web-server/routes/cursor-settings-routes.ts` +- `src/web-server/routes/cliproxy-stats-routes.ts` +- `src/api/services/profile-lifecycle-service.ts` + +## Overview + +- Priority: P1 +- Owner scope: config schema, path resolution, backend APIs, migration readers +- Goal: make legacy bridge storage explicit and guarantee CLIProxy Cursor never writes the legacy raw settings file + +## Key Insights + +- Top-level `config.cursor` is bridge-only configuration today and must move. +- The legacy bridge owns `~/.ccs/cursor.settings.json`, `~/.ccs/cursor/credentials.json`, and `~/.ccs/cursor/daemon.pid`. +- CLIProxy provider settings currently resolve through generic provider settings helpers and can still collide with the legacy file for provider `cursor`. +- `~/.ccs/cursor.settings.json` is historically documented as legacy-owned, so it is unsafe to auto-import it into provider storage by default. + +## Requirements + +- Canonical legacy config key: `legacy.cursor` +- Canonical legacy files: + - `~/.ccs/legacy/cursor.settings.json` + - `~/.ccs/legacy/cursor/credentials.json` + - `~/.ccs/legacy/cursor/daemon.pid` +- Canonical provider file for CLIProxy Cursor only: + - `~/.ccs/cliproxy/cursor.settings.json` +- Canonical legacy API namespace: + - `/api/legacy/cursor/*` +- Compatibility reads: + - read old `config.cursor` + - read old `~/.ccs/cursor.settings.json` + - read old `~/.ccs/cursor/*` +- Compatibility writes: + - write only the new `legacy.*` and `cliproxy/*` paths + +## Data Flow + +- Legacy config: + `load config -> prefer legacy.cursor -> fallback config.cursor -> normalize -> write legacy.cursor only` +- Legacy raw settings: + `load /api/legacy/cursor/settings/raw -> prefer ~/.ccs/legacy/cursor.settings.json -> fallback ~/.ccs/cursor.settings.json -> write new legacy path` +- Provider settings: + `CLIProxy env builder/stats updater -> read ~/.ccs/cliproxy/cursor.settings.json -> if absent use defaults -> never read/write ~/.ccs/cursor.settings.json` + +## Architecture + +- Add a `legacy` section to unified config types and loader. Keep old `cursor` as read-only migration input during the compatibility window. +- Move legacy bridge filesystem helpers under a `legacy/cursor` path prefix. +- Split API routing: + - new canonical mount: `/api/legacy/cursor` + - release-N alias: `/api/cursor` -> same handlers + deprecation header +- Special-case CLIProxy provider settings for `cursor` only in the provider path resolver. Do not expand this migration to every provider in this issue. +- Treat existing `~/.ccs/cursor.settings.json` as legacy-owned. Do not auto-copy it into provider storage unless a future explicit provider migration is added. + +## Related Code Files + +- Modify: + - `src/config/unified-config-types.ts` + - `src/config/unified-config-loader.ts` + - `src/cursor/cursor-auth.ts` + - `src/cursor/cursor-daemon-pid.ts` + - `src/cliproxy/config/path-resolver.ts` + - `src/cliproxy/config/env-builder.ts` + - `src/web-server/routes/index.ts` + - `src/web-server/routes/cursor-routes.ts` + - `src/web-server/routes/cursor-settings-routes.ts` + - `src/web-server/routes/cliproxy-stats-routes.ts` + - `src/api/services/profile-lifecycle-service.ts` +- Create: + - `src/web-server/routes/legacy-cursor-routes.ts` + - `src/web-server/routes/legacy-cursor-settings-routes.ts` + - `src/config/migrations/cursor-legacy-migration.ts` if migration logic should stay out of the loader + +## Implementation Steps + +1. Extend config types and loader to support `legacy.cursor`, with `legacy.cursor` taking precedence over old `cursor`. +2. Update legacy bridge credential and pid helpers to use `~/.ccs/legacy/cursor/`. +3. Update the raw settings route to use `~/.ccs/legacy/cursor.settings.json` as canonical and old root path as read fallback only. +4. Move legacy API mounts to `/api/legacy/cursor/*` and keep `/api/cursor/*` as a warned alias for release N. +5. Change CLIProxy Cursor provider settings resolution to `~/.ccs/cliproxy/cursor.settings.json`. +6. Update orphan detection and cleanup logic so old `cursor.settings.json` is treated as a migration target, not a permanent provider-owned file. + +## Todo List + +- [ ] Add `legacy.cursor` config schema and loader precedence +- [ ] Move bridge credentials/pid/raw settings under `~/.ccs/legacy/` +- [x] Add canonical `/api/legacy/cursor/*` routes +- [x] Keep release-N `/api/cursor/*` alias +- [x] Isolate CLIProxy Cursor settings away from `~/.ccs/cursor.settings.json` +- [ ] Update cleanup/orphan handling + +## Success Criteria + +- Saving legacy bridge settings writes only to `legacy.cursor` and `~/.ccs/legacy/*`. +- CLIProxy Cursor model/env updates write only to `~/.ccs/cliproxy/cursor.settings.json`. +- Existing legacy users can still read old config/files during the compatibility window. +- No backend route that serves the provider path references `~/.ccs/cursor.settings.json`. + +## Risk Assessment + +- High likelihood / high impact: old `~/.ccs/cursor.settings.json` contents are ambiguous between bridge and provider expectations. + Mitigation: treat the file as legacy-owned and do not auto-import it into provider storage. +- Medium likelihood / medium impact: route aliasing may mask which API is canonical. + Mitigation: add explicit response headers or payload flags marking `/api/cursor/*` as deprecated. + +## Rollback Plan + +- Keep read fallback from old paths even if the canonical write path changes back. +- If the new legacy API namespace causes regressions, remount `/api/cursor/*` as canonical temporarily and keep the new namespace dormant. +- Do not delete old files during release N; cleanup stays opt-in until release N+2. + +## Security Considerations + +- Preserve `0600` for migrated credentials and `0700` for directories. +- Use atomic temp-file writes exactly as current routes do. +- Never copy provider tokens into the legacy namespace or legacy tokens into provider storage automatically. + +## Next Steps + +- Phase 3 depends on the canonical API and path names from this phase. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-03-dashboard-deprecation-ux.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-03-dashboard-deprecation-ux.md new file mode 100644 index 00000000..00f22c6f --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-03-dashboard-deprecation-ux.md @@ -0,0 +1,121 @@ +--- +phase: 3 +title: "Dashboard & Deprecation UX" +status: partial +effort: "4h" +--- + +# Phase 3: Dashboard & Deprecation UX + +## Context Links + +- `plan.md` +- `ui/src/App.tsx` +- `ui/src/components/layout/app-sidebar.tsx` +- `ui/src/pages/cursor.tsx` +- `ui/src/hooks/use-cursor.ts` +- `ui/src/lib/i18n.ts` +- `src/web-server/routes/index.ts` +- `src/commands/cursor-command-display.ts` + +## Overview + +- Priority: P1 +- Owner scope: dashboard route ownership, labels, user-facing deprecation messaging +- Goal: align dashboard semantics with CLI semantics so `/cursor` means provider and legacy UI is clearly marked and isolated + +## Key Insights + +- The current dashboard already admits the bridge is deprecated, but the route `/cursor` still belongs to it. +- The page includes direct navigation to CLIProxy Cursor, which means the UX already wants a split; the route layer just has not caught up. +- Keeping `/cursor` for legacy while CLI uses `cursor` for provider would create the same ambiguity in a different surface. + +## Requirements + +- `/cursor` must become the provider-owned dashboard surface. +- The legacy bridge page must move to `/legacy/cursor`. +- Legacy bridge API hooks must move to `/api/legacy/cursor/*`. +- The deprecated UX must contain exact replacements, not generic warnings. +- Sidebar grouping must reflect support level: + - provider view under provider/cliproxy navigation + - legacy bridge under deprecated navigation + +## Data Flow + +- Provider dashboard: + `browser /cursor -> provider view or redirect wrapper -> /cliproxy?provider=cursor -> existing CLIProxy provider APIs` +- Legacy dashboard: + `browser /legacy/cursor -> legacy bridge page -> useLegacyCursor hook -> /api/legacy/cursor/*` +- Compatibility API path, release N only: + `old UI/tests -> /api/cursor/* -> alias handler -> same legacy payload + deprecation signal` + +## Architecture + +- Keep provider UI DRY by making `/cursor` a thin redirect or preselected wrapper around the existing CLIProxy provider page instead of building a second Cursor-provider page. +- Move the current `ui/src/pages/cursor.tsx` implementation to a new `legacy-cursor` page and rename its hook to `useLegacyCursor`. +- Change nav labels from generic "Cursor IDE" to explicit "Cursor Bridge (Legacy)" in the deprecated section. +- Update CLI and dashboard warnings to show both paths side-by-side: + - `ccs cursor --auth` / `/cursor` + - `ccs legacy cursor auth` / `/legacy/cursor` + +## Related Code Files + +- Modify: + - `ui/src/App.tsx` + - `ui/src/components/layout/app-sidebar.tsx` + - `ui/src/lib/i18n.ts` + - `src/commands/cursor-command-display.ts` +- Move or rename: + - `ui/src/pages/cursor.tsx` -> `ui/src/pages/legacy-cursor.tsx` + - `ui/src/hooks/use-cursor.ts` -> `ui/src/hooks/use-legacy-cursor.ts` +- Create: + - `ui/src/pages/cursor-provider-redirect.tsx` if a wrapper is preferred over direct router config + +## Implementation Steps + +1. Move the legacy page and hook to `legacy-*` names and update all imports. +2. Reassign `/cursor` to the provider path and add `/legacy/cursor` for the bridge page. +3. Update sidebar grouping and labels so the provider path is no longer listed under Deprecated. +4. Replace vague deprecated copy with concrete migration copy: + - old command + - new command + - old route + - new route +5. Keep the legacy page banner persistent until release N+2, not dismissible per session. + +## Todo List + +- [ ] Move legacy page/hook module names to `legacy-*` +- [x] Reassign `/cursor` and add `/legacy/cursor` +- [x] Update deprecated nav group and labels +- [x] Rewrite key banners, button copy, and path labels with exact replacements +- [x] Keep provider and legacy links visible from both surfaces during release N + +## Success Criteria + +- Opening `/cursor` lands on the CLIProxy Cursor provider surface. +- Opening `/legacy/cursor` lands on the bridge page with a persistent deprecation banner. +- No dashboard component serving the provider route uses the legacy API hook. +- Every warning banner shows the exact before/after command and route. + +## Risk Assessment + +- Medium likelihood / medium impact: users with bookmarked `/cursor` expect the legacy page. + Mitigation: provider page shows a top-level "Looking for the old bridge?" callout linking to `/legacy/cursor`. +- Low likelihood / medium impact: UI rename churn breaks lazy imports or tests. + Mitigation: do route and hook rename in one phase and leave compatibility API alias in place until tests pass. + +## Rollback Plan + +- Point `/cursor` back to the legacy page if the provider redirect breaks. +- Keep `/legacy/cursor` additive; it does not block rollback. +- Do not remove the deprecation banner on rollback; it still communicates future intent. + +## Security Considerations + +- No auth secrets should be exposed in UI copy or route params. +- Keep manual auth dialogs scoped to the legacy page only. Provider auth remains in CLIProxy flows. + +## Next Steps + +- Phase 4 owns test rewrites, docs updates, and release gating for these UI changes. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/phase-04-tests-docs-rollout.md b/plans/20260415-1016-cursor-provider-legacy-split/phase-04-tests-docs-rollout.md new file mode 100644 index 00000000..9fcd17d1 --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/phase-04-tests-docs-rollout.md @@ -0,0 +1,148 @@ +--- +phase: 4 +title: "Tests Docs & Rollout" +status: complete +effort: "4h" +--- + +# Phase 4: Tests Docs & Rollout + +## Context Links + +- `plan.md` +- `docs/cursor-integration.md` +- `README.md` +- `docs/system-architecture/provider-flows.md` +- `docs/system-architecture/index.md` +- `tests/unit/cursor/cursor-shortcut-routing.test.ts` +- `tests/unit/web-server/cursor-settings-routes.test.ts` +- `tests/unit/web-server/cursor-routes.test.ts` +- `ui/tests/unit/hooks/use-cursor.test.tsx` +- `ui/tests/unit/ui/pages/cursor-page.test.tsx` + +## Overview + +- Priority: P1 +- Owner scope: compatibility rollout, validation, docs/help updates, release notes +- Goal: ship the namespace split without surprising existing bridge users or leaving docs/help inconsistent + +## Key Insights + +- This change has one intentional breaking behavior: positional `ccs cursor` stops being the legacy bridge. +- Everything else can use a compatibility window: admin subcommands, API aliases, old config reads, old file-path reads. +- Tests must lock both meanings so the ambiguity does not regress later. + +## Requirements + +- Document exact before/after commands and routes. +- Add a concrete migration path for three user groups: + - legacy bridge users + - CLIProxy Cursor users + - dashboard bookmark users +- Define removal windows for aliases and old path fallbacks. +- Run repo quality gates after implementation: + - root: `bun run format && bun run lint:fix && bun run validate && bun run validate:ci-parity` + - UI: `cd ui && bun run format && bun run lint:fix && bun run validate` + +## Test Matrix + +- Unit: + - provider-first cursor routing + - legacy alias forwarding + - `legacy.cursor` loader precedence + - path resolvers for legacy vs provider files + - deprecation help text snapshots +- Integration: + - `ccs cursor "task"` -> provider + - `ccs legacy cursor "task"` -> bridge + - `/api/legacy/cursor/*` canonical behavior + - `/api/cursor/*` alias behavior during release N +- UI: + - `/cursor` route ownership + - `/legacy/cursor` banner and actions + - hook path changes and raw settings save targets +- Manual release validation: + - migrate old config/files in a temp `CCS_HOME` + - verify provider path never writes `~/.ccs/cursor.settings.json` + +## User Migration Plan + +1. Legacy bridge users: + - replace `ccs cursor ...` with `ccs legacy cursor ...` + - run `ccs legacy cursor status` + - update scripts and dashboard bookmarks to `/legacy/cursor` +2. CLIProxy Cursor users: + - keep using `ccs cursor ...` + - if provider-specific settings are needed, re-save them under the new provider-owned path instead of relying on `~/.ccs/cursor.settings.json` +3. Mixed/unclear state: + - `ccs migrate` should move `config.cursor` and legacy files into the new legacy namespace + - do not auto-copy the old raw settings file into provider storage + +## Deprecation UX Plan + +- CLI warning text, release N: + - `ccs cursor auth` is deprecated. Use `ccs legacy cursor auth` for the old bridge or `ccs cursor --auth` for CLIProxy Cursor. +- Dashboard banner: + - visible on `/legacy/cursor` + - provider route links back to legacy route with "Looking for the old bridge?" +- Docs banner: + - top callout in `docs/cursor-integration.md` pointing users to CLIProxy Cursor as the supported path + +## Related Code Files + +- Modify tests: + - `tests/unit/cursor/cursor-shortcut-routing.test.ts` + - `tests/unit/web-server/cursor-settings-routes.test.ts` + - `tests/unit/web-server/cursor-routes.test.ts` + - `ui/tests/unit/hooks/use-cursor.test.tsx` + - `ui/tests/unit/ui/pages/cursor-page.test.tsx` +- Modify docs: + - `docs/cursor-integration.md` + - `README.md` if root command examples mention Cursor + - `docs/system-architecture/provider-flows.md` + - `docs/system-architecture/index.md` + - CLI help snapshots or generated references if present + +## Implementation Steps + +1. Rewrite tests around the new command contract and route ownership before removing aliases in later releases. +2. Update docs/help text in the same PR as code changes so the new syntax ships atomically. +3. Add migration notes to changelog/release notes with a bold callout that `ccs cursor "task"` now means CLIProxy Cursor. +4. Keep a removal checklist for release N+1 and N+2 in the plan or roadmap so the compatibility window does not become permanent. + +## Todo List + +- [x] Update unit, integration, and selected UI tests +- [x] Update docs and CLI help text +- [x] Add migration note and deprecation wording +- [x] Run root and UI quality gates +- [x] Record alias-removal follow-up for N+1 and old-path-removal follow-up for N+2 + +## Success Criteria + +- Test suite covers both provider and legacy cursor paths explicitly. +- Docs and help text match the shipped command contract exactly. +- Release notes include the migration table and deprecation window. +- Quality gates pass in both root and `ui/`. + +## Risk Assessment + +- High likelihood / medium impact: docs or tests lag behind the command flip and users keep invoking the wrong surface. + Mitigation: block merge until help text, docs, and tests all match the new contract. +- Medium likelihood / medium impact: compatibility shims never get removed. + Mitigation: create follow-up issues or roadmap entries for N+1 and N+2 removal work before merge. + +## Rollback Plan + +- If rollout messaging is incomplete, revert the command flip before removing aliases. +- If only docs/help are wrong, fix docs first and keep aliases until corrected. +- Old-path readers stay in place through N+1, so rollback does not strand migrated users. + +## Security Considerations + +- Use temp `CCS_HOME` in tests and manual verification. Never touch the real `~/.ccs`. +- Sanitize any migration logs or warnings so they mention paths, not token contents. + +## Next Steps + +- Implementation is complete when all four phases land together; do not ship phase 1 without phases 2-4. diff --git a/plans/20260415-1016-cursor-provider-legacy-split/plan.md b/plans/20260415-1016-cursor-provider-legacy-split/plan.md new file mode 100644 index 00000000..f57f3f29 --- /dev/null +++ b/plans/20260415-1016-cursor-provider-legacy-split/plan.md @@ -0,0 +1,93 @@ +--- +title: "Separate legacy Cursor bridge from CLIProxy Cursor provider" +description: "Reserve `cursor` for the CLIProxy provider, move the reverse-engineered bridge under `legacy`, and split storage/UI with a staged migration." +status: in_progress +priority: P1 +effort: 2d +branch: kai/feat/1016-missing-provider-integration +tags: [cursor, cliproxy, migration, dashboard, deprecation] +created: 2026-04-15 +blockedBy: [] +blocks: [] +--- + +# Separate legacy Cursor bridge from CLIProxy Cursor provider + +## Goal + +Make `cursor` mean one thing everywhere: the CLIProxy-backed provider. Move the deprecated local bridge to `legacy`, stop provider writes to `~/.ccs/cursor.settings.json`, and ship a low-risk migration window. + +## Current Collision Points + +- `src/ccs.ts` hardcodes `cursor` as a legacy command/profile, then reclaims only `--auth|--logout|--config|--accounts` for CLIProxy. +- `src/auth/profile-detector.ts` resolves `cursor` to the legacy runtime before CLIProxy provider detection. +- `src/commands/command-catalog.ts` and `src/commands/help-command.ts` advertise `cursor` as both bridge and provider. +- `src/config/unified-config-types.ts` + `src/config/unified-config-loader.ts` store bridge config under top-level `cursor`. +- `src/cliproxy/config/path-resolver.ts`, `src/cliproxy/config/env-builder.ts`, and `src/web-server/routes/cliproxy-stats-routes.ts` still use provider settings paths that collide with the legacy raw file. +- `src/web-server/routes/cursor-*.ts`, `ui/src/pages/cursor.tsx`, `ui/src/hooks/use-cursor.ts`, `ui/src/App.tsx`, and `ui/src/components/layout/app-sidebar.tsx` dedicate `/cursor` and `/api/cursor/*` to the legacy bridge. +- `docs/cursor-integration.md` documents `ccs cursor` as the bridge even though CLIProxy already exposes a `cursor` provider shortcut. + +## Command Contract + +Before: +```text +ccs cursor -> legacy bridge runtime +ccs cursor "task" -> legacy bridge runtime +ccs cursor auth|status|... -> legacy bridge admin +ccs cursor --auth|--config -> CLIProxy Cursor shortcut +``` + +After release N: +```text +ccs cursor -> CLIProxy Cursor runtime +ccs cursor "task" -> CLIProxy Cursor runtime +ccs cursor --auth|--config -> CLIProxy Cursor admin +ccs legacy cursor -> legacy bridge runtime +ccs legacy cursor "task" -> legacy bridge runtime +ccs legacy cursor auth|... -> legacy bridge admin +``` + +Compatibility window, release N only: +- `ccs cursor auth|status|probe|models|start|stop|enable|disable|help` forwards to `ccs legacy cursor ...` with a deprecation warning. +- Bare and positional `ccs cursor` switch immediately to the provider path; no silent legacy fallback. + +## Phase Plan + +| Phase | Scope | Output | +| --- | --- | --- | +| 1 | [CLI Routing & Namespacing](./phase-01-cli-routing-namespacing.md) | Provider-first `cursor`, explicit `legacy cursor`, updated help/catalog/type names | +| 2 | [Storage & API Boundaries](./phase-02-storage-api-boundaries.md) | `legacy.cursor` config, split file paths, `/api/legacy/cursor/*`, provider path isolation | +| 3 | [Dashboard & Deprecation UX](./phase-03-dashboard-deprecation-ux.md) | `/cursor` -> provider view, `/legacy/cursor` -> bridge view, clear migration UX | +| 4 | [Tests Docs & Rollout](./phase-04-tests-docs-rollout.md) | Compatibility plan, migration steps, test matrix, docs updates, rollback gates | + +## Rollout Sequence + +1. Release N: add new legacy namespace, flip `ccs cursor` to provider, keep old admin subcommands and `/api/cursor/*` as warned aliases, and split provider settings away from `~/.ccs/cursor.settings.json`. +2. Release N+1: move the remaining legacy backend/config namespaces fully under `legacy.cursor`, keep old file-path fallback and `/api/cursor/*` alias for one more release. +3. Release N+2: remove old `config.cursor` and root-level `~/.ccs/cursor*` fallback reads, delete stale alias docs/help, and let cleanup/migrate remove leftovers. + +## Current Implementation Status + +- Completed in this branch: + - `ccs cursor` is provider-first for runtime and `--help` + - `ccs legacy cursor` works as the explicit legacy bridge namespace + - old legacy admin subcommands under `ccs cursor ...` forward with deprecation warnings + - CLIProxy Cursor settings no longer collide with `~/.ccs/cursor.settings.json` + - `/cursor` redirects to the provider surface while `/legacy/cursor` serves the deprecated bridge page + - `/api/legacy/cursor/*` is mounted and the legacy page uses that namespace + - docs, completion, and core regression tests were updated +- Intentionally deferred follow-up: + - move top-level `config.cursor` to `legacy.cursor` + - move legacy credentials/pid/raw settings fully under `~/.ccs/legacy/cursor/*` + - rename `use-cursor` and `CursorPage` modules to explicit `legacy-*` + +## Success Criteria + +- `cursor` is provider-owned in CLI help, routing, dashboard nav, and docs. +- Legacy bridge is reachable only through `legacy cursor` and `legacy.cursor` storage. +- CLIProxy Cursor never reads or writes `~/.ccs/cursor.settings.json`. +- Existing legacy users have an explicit migration path, warning UX, and rollback-safe compatibility window. + +## Docs Impact + +Major. CLI reference, Cursor docs, dashboard tour, provider docs, and migration notes all change in the same release. diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 637d004c..b0c8d1ca 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -26,6 +26,7 @@ import { getCcsDir } from '../utils/config-manager'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } 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'; import type { ProfileType } from '../types/profile'; @@ -296,20 +297,17 @@ class ProfileDetector { }; } - // Priority 0.25: Check Cursor profile - local Cursor daemon runtime. - // This keeps bare `ccs cursor` and `ccs cursor ` bound to the - // existing runtime/admin surface even though CLIProxy also exposes a - // distinct provider named "cursor". - if (profileName === 'cursor') { + // Priority 0.25: Check explicit legacy Cursor bridge profile. + if (profileName === LEGACY_CURSOR_PROFILE_NAME) { const cursorConfig = getCursorConfig(); if (!cursorConfig?.enabled) { const error = new Error( - 'Cursor profile is not enabled.\n\n' + + 'Legacy Cursor profile is not enabled.\n\n' + 'To enable Cursor integration:\n' + - ' 1. Run: ccs cursor enable\n' + - ' 2. Import auth: ccs cursor auth\n' + - ' 3. Start daemon: ccs cursor start\n\n' + + ' 1. Run: ccs legacy cursor enable\n' + + ' 2. Import auth: ccs legacy cursor auth\n' + + ' 3. Start daemon: ccs legacy cursor start\n\n' + 'Or manually edit ~/.ccs/config.yaml:\n' + ' cursor:\n' + ' enabled: true' @@ -322,7 +320,7 @@ class ProfileDetector { return { type: 'cursor', - name: 'cursor', + name: LEGACY_CURSOR_PROFILE_NAME, cursorConfig, }; } diff --git a/src/ccs.ts b/src/ccs.ts index 99249df8..ead46f72 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -65,7 +65,7 @@ import { resolveOfficialChannelsLaunchPlan, } from './channels/official-channels-runtime'; import { getOfficialChannelReadiness } from './channels/official-channels-store'; -import { isCursorSubcommandToken, shouldUseCursorCliproxyShortcut } from './cursor/constants'; +import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants'; import { isCLIProxyProvider } from './cliproxy/provider-capabilities'; // Import centralized error handling @@ -149,6 +149,26 @@ function detectProfile(args: string[]): DetectedProfile { } } +function normalizeLegacyCursorArgs(args: string[]): string[] { + if (args[0] === 'legacy' && args[1] === 'cursor') { + return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)]; + } + + return args; +} + +function printCursorLegacySubcommandDeprecation(subcommand: string): void { + console.error( + info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`) + ); + console.error( + info( + `Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` + ) + ); + console.error(''); +} + function resolveRuntimeReasoningFlags( args: string[], envThinkingValue: string | undefined @@ -343,7 +363,7 @@ async function main(): Promise { registerTarget(new CodexAdapter()); const cliLogger = createLogger('cli'); - const args = process.argv.slice(2); + let args = process.argv.slice(2); const isCompletionCommand = args[0] === '__complete'; // Initialize UI colors early to ensure consistent colored output @@ -419,6 +439,8 @@ async function main(): Promise { return; } + args = normalizeLegacyCursorArgs(args); + cliLogger.info('command.start', 'CLI invocation started', { command: args[0] || 'default', argCount: args.length, @@ -489,7 +511,6 @@ async function main(): Promise { if ( typeof firstArg === 'string' && isCLIProxyProvider(firstArg) && - firstArg !== 'cursor' && args.length > 1 && (args.includes('--help') || args.includes('-h')) ) { @@ -511,9 +532,8 @@ async function main(): Promise { } } - // Special case: cursor command (Cursor local proxy integration) - // Route known admin subcommands to the command handler, keep all other args as profile passthrough. - if (firstArg === 'cursor' && args.length > 1) { + // Special case: explicit legacy Cursor bridge namespace. + if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) { const { handleCursorCommand } = await import('./commands/cursor-command'); const cursorToken = args[1]; @@ -523,6 +543,19 @@ async function main(): Promise { } } + // Compatibility shim: old `ccs cursor ` still forwards to the legacy bridge + // for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy. + if (firstArg === 'cursor' && args.length > 1) { + const { handleCursorCommand } = await import('./commands/cursor-command'); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') { + printCursorLegacySubcommandDeprecation(cursorToken); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + // First-time install: offer setup wizard for interactive users // Check independently of recovery status (user may have empty config.yaml) // Skip if headless, CI, or non-TTY environment @@ -551,17 +584,8 @@ async function main(): Promise { try { // Detect profile (strip --target flags before profile detection) const cleanArgs = stripTargetFlag(args); - const useCursorCliproxyShortcut = shouldUseCursorCliproxyShortcut(cleanArgs); - const { profile, remainingArgs } = useCursorCliproxyShortcut - ? { profile: 'cursor', remainingArgs: cleanArgs.slice(1) } - : detectProfile(cleanArgs); - const profileInfo: ProfileDetectionResult = useCursorCliproxyShortcut - ? { - type: 'cliproxy', - name: 'cursor', - provider: 'cursor', - } - : detector.detectProfileType(profile); + const { profile, remainingArgs } = detectProfile(cleanArgs); + const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile); let resolvedTarget: ReturnType; try { resolvedTarget = resolveTargetType( diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index dcace13b..f5013944 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -19,7 +19,7 @@ import { normalizeProtocol, CLIPROXY_DEFAULT_PORT, } from './port-manager'; -import { getProviderSettingsPath } from './path-resolver'; +import { migrateLegacyProviderSettingsIfNeeded } from './path-resolver'; import { canonicalizeModelIdForProvider, MODEL_ENV_VAR_KEYS, @@ -465,7 +465,7 @@ export function getEffectiveEnvVars( } // Priority 2: Default provider settings file - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); // Check for user override file if (fs.existsSync(settingsPath)) { @@ -505,7 +505,7 @@ export function getEffectiveEnvVars( * Called during installation/first run */ export function ensureProviderSettings(provider: CLIProxyProvider): void { - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); const defaultEnv = getClaudeEnvVars(provider); const writeSettings = (settings: Record): void => { @@ -663,7 +663,7 @@ export function getRemoteEnvVars( // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) if (Object.keys(userEnvVars).length === 0) { - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); if (fs.existsSync(settingsPath)) { try { const content = fs.readFileSync(settingsPath, 'utf-8'); diff --git a/src/cliproxy/config/path-resolver.ts b/src/cliproxy/config/path-resolver.ts index 21746fc7..413d6aee 100644 --- a/src/cliproxy/config/path-resolver.ts +++ b/src/cliproxy/config/path-resolver.ts @@ -16,6 +16,13 @@ export function getCliproxyDir(): string { return path.join(getCcsDir(), 'cliproxy'); } +/** + * Get CLIProxy provider settings directory. + */ +export function getCliproxyProvidersDir(): string { + return path.join(getCliproxyDir(), 'providers'); +} + /** * Get CLIProxy writable directory for logs and runtime files. * This directory is set as WRITABLE_PATH env var when spawning CLIProxy. @@ -75,5 +82,40 @@ export function getBinDir(): string { * Example: ~/.ccs/gemini.settings.json */ export function getProviderSettingsPath(provider: CLIProxyProvider): string { + if (provider === 'cursor') { + return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`); + } + + return getLegacyProviderSettingsPath(provider); +} + +/** + * Get CLIProxy provider settings path in the dedicated cliproxy/providers namespace. + * Used only for providers that must not collide with legacy top-level settings files. + */ +export function getDedicatedProviderSettingsPath(provider: CLIProxyProvider): string { + return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`); +} + +/** + * Get legacy provider settings file path in ~/.ccs root. + * This is kept for compatibility reads/migration of older provider settings. + */ +export function getLegacyProviderSettingsPath(provider: CLIProxyProvider): string { return path.join(getCcsDir(), `${provider}.settings.json`); } + +/** + * Resolve the effective provider settings path. + * + * Cursor uses a dedicated cliproxy/providers namespace so it does not collide + * with the deprecated Cursor IDE bridge raw settings file. + */ +export function migrateLegacyProviderSettingsIfNeeded(provider: CLIProxyProvider): string { + if (provider !== 'cursor') { + return getProviderSettingsPath(provider); + } + + const targetPath = getDedicatedProviderSettingsPath(provider); + return targetPath; +} diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 21c02ef6..b1824361 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; -import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; +import { getClaudeEnvVars, migrateLegacyProviderSettingsIfNeeded } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; @@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string) * Check if provider has user settings configured */ export function hasUserSettings(provider: CLIProxyProvider): boolean { - const settingsPath = getProviderSettingsPath(provider); + const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); return fs.existsSync(settingsPath); } @@ -46,7 +46,7 @@ export function getCurrentModel( ): string | undefined { const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : getProviderSettingsPath(provider); + : migrateLegacyProviderSettingsIfNeeded(provider); if (!fs.existsSync(settingsPath)) return undefined; try { @@ -116,7 +116,7 @@ export async function configureProviderModel( // Use custom settings path for CLIProxy variants, otherwise use default provider path const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : getProviderSettingsPath(provider); + : migrateLegacyProviderSettingsIfNeeded(provider); // Skip if already configured with a model (unless --config flag). // A settings file can exist without model env keys (e.g., hook-only writes). @@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise CLIProxy -> Cursor', '', - 'Usage: ccs cursor ', + `Usage: ${LEGACY_CURSOR_COMMAND} `, '', 'Subcommands (deprecated compatibility for the local reverse-engineered bridge):', ' auth Import Cursor IDE authentication token (deprecated)', @@ -32,24 +35,24 @@ export function renderCursorHelp(): number { ' help Show this help message', '', 'Supported CLIProxy path:', - ' ccs cursor --auth # Authenticate Cursor via CLIProxy', - ' ccs cursor --accounts # Manage CLIProxy Cursor accounts', - ' ccs cursor --config # Open CLIProxy Cursor settings', + ` ${CLIPROXY_CURSOR_COMMAND} --auth # Authenticate Cursor via CLIProxy`, + ` ${CLIPROXY_CURSOR_COMMAND} --accounts # Manage CLIProxy Cursor accounts`, + ` ${CLIPROXY_CURSOR_COMMAND} --config # Open CLIProxy Cursor settings`, '', 'Legacy runtime entry (deprecated compatibility):', - ' ccs cursor [claude args] # Run Claude via the local Cursor bridge', + ` ${LEGACY_CURSOR_COMMAND} [claude args] # Run Claude via the local Cursor bridge`, '', 'Legacy auth options:', - ' ccs cursor auth # Auto-detect from Cursor SQLite (deprecated)', - ' ccs cursor auth --manual --token --machine-id ', + ` ${LEGACY_CURSOR_COMMAND} auth # Auto-detect from Cursor SQLite (deprecated)`, + ` ${LEGACY_CURSOR_COMMAND} auth --manual --token --machine-id `, '', 'Legacy bridge quick start:', - ' 1. ccs cursor enable # Deprecated compatibility: enable local bridge', - ' 2. ccs cursor auth # Deprecated compatibility: import Cursor IDE token', - ' 3. ccs cursor start # Start local daemon', - ' 4. ccs cursor probe # Verify live runtime health', - ' 5. ccs cursor "task" # Run Claude through the local bridge', - ' 6. ccs cursor status # Inspect auth/daemon wiring', + ` 1. ${LEGACY_CURSOR_COMMAND} enable # Deprecated compatibility: enable local bridge`, + ` 2. ${LEGACY_CURSOR_COMMAND} auth # Deprecated compatibility: import Cursor IDE token`, + ` 3. ${LEGACY_CURSOR_COMMAND} start # Start local daemon`, + ` 4. ${LEGACY_CURSOR_COMMAND} probe # Verify live runtime health`, + ` 5. ${LEGACY_CURSOR_COMMAND} "task" # Run Claude through the local bridge`, + ` 6. ${LEGACY_CURSOR_COMMAND} status # Inspect auth/daemon wiring`, '', 'Web UI: ccs config -> Deprecated -> Cursor IDE', '', @@ -109,11 +112,13 @@ export function renderCursorStatus( console.log(''); console.log('Client setup:'); console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`); - console.log(' Runtime entry: ccs cursor [claude args] (deprecated compatibility)'); - console.log(' Supported auth: ccs cursor --auth'); - console.log(' Live probe: ccs cursor probe'); - console.log(' Status command: ccs cursor status'); - console.log(' Help command: ccs cursor help'); + console.log( + ` Runtime entry: ${LEGACY_CURSOR_COMMAND} [claude args] (deprecated compatibility)` + ); + console.log(` Supported auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` Live probe: ${LEGACY_CURSOR_COMMAND} probe`); + console.log(` Status command: ${LEGACY_CURSOR_COMMAND} status`); + console.log(` Help command: ${LEGACY_CURSOR_COMMAND} help`); if (isReady) { return; @@ -123,16 +128,16 @@ export function renderCursorStatus( console.log('Next steps:'); if (!cursorConfig.enabled) { - console.log(' - Enable: ccs cursor enable'); + console.log(` - Enable: ${LEGACY_CURSOR_COMMAND} enable`); } if (!authStatus.authenticated || authStatus.expired) { - console.log(' - Supported: ccs cursor --auth'); - console.log(' - Legacy auth: ccs cursor auth'); + console.log(` - Supported: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` - Legacy auth: ${LEGACY_CURSOR_COMMAND} auth`); } if (!daemonStatus.running) { - console.log(' - Start: ccs cursor start'); + console.log(` - Start: ${LEGACY_CURSOR_COMMAND} start`); } - console.log(' - Help: ccs cursor help'); + console.log(` - Help: ${LEGACY_CURSOR_COMMAND} help`); } export function renderCursorModels(models: CursorModel[], defaultModel: string): void { diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 29f5d34b..80c2f260 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -26,10 +26,13 @@ import { } from './cursor-command-display'; import { ok, fail, info } from '../utils/ui'; +const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor'; +const CLIPROXY_CURSOR_COMMAND = 'ccs cursor'; + function printLegacyCursorDeprecationNotice(): void { console.log( info( - 'Deprecated compatibility path. Prefer CLIProxy-backed Cursor auth via `ccs cursor --auth` or the CLIProxy dashboard.' + `Deprecated compatibility path. \`${CLIPROXY_CURSOR_COMMAND}\` now belongs to the CLIProxy Cursor provider; use \`${LEGACY_CURSOR_COMMAND}\` for the old bridge.` ) ); console.log(''); @@ -135,7 +138,7 @@ async function handleAuth(args: string[]): Promise { if (!accessToken || !machineId) { console.error( fail( - 'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token --machine-id ' + `Manual auth requires both token and machine ID.\n\nExample:\n ${LEGACY_CURSOR_COMMAND} auth --manual --token --machine-id ` ) ); return 1; @@ -156,9 +159,9 @@ async function handleAuth(args: string[]): Promise { console.log(ok('Cursor credentials imported (manual mode)')); console.log(''); console.log('Next steps:'); - console.log(' 0. Preferred auth: ccs cursor --auth'); - console.log(' 1. Enable integration: ccs cursor enable'); - console.log(' 2. Start daemon: ccs cursor start'); + console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`); + console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`); return 0; } @@ -179,10 +182,10 @@ async function handleAuth(args: string[]): Promise { console.log(ok('Auto-detected Cursor credentials')); console.log(''); console.log('Next steps:'); - console.log(' 0. Preferred auth: ccs cursor --auth'); - console.log(' 1. Enable integration: ccs cursor enable'); - console.log(' 2. Start daemon: ccs cursor start'); - console.log(' 3. Check status: ccs cursor status'); + console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`); + console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`); + console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`); return 0; } @@ -190,7 +193,7 @@ async function handleAuth(args: string[]): Promise { printAutoDetectFailure(autoResult); console.log(''); console.log('Manual fallback:'); - console.log(' ccs cursor auth --manual --token --machine-id '); + console.log(` ${LEGACY_CURSOR_COMMAND} auth --manual --token --machine-id `); console.log(''); return 1; @@ -230,17 +233,17 @@ async function handleStart(): Promise { const cursorConfig = getCursorConfig(); if (!cursorConfig.enabled) { - console.error(fail('Cursor integration is disabled. Run: ccs cursor enable')); + console.error(fail(`Cursor integration is disabled. Run: ${LEGACY_CURSOR_COMMAND} enable`)); return 1; } const authStatus = checkAuthStatus(); if (!authStatus.authenticated) { - console.error(fail('Not authenticated. Run: ccs cursor auth')); + console.error(fail(`Not authenticated. Run: ${LEGACY_CURSOR_COMMAND} auth`)); return 1; } if (authStatus.expired) { - console.error(fail('Credentials expired. Run: ccs cursor auth')); + console.error(fail(`Credentials expired. Run: ${LEGACY_CURSOR_COMMAND} auth`)); return 1; } @@ -294,10 +297,10 @@ async function handleEnable(): Promise { console.log(ok('Cursor integration enabled')); console.log(''); console.log('Next steps:'); - console.log(' 0. Preferred auth: ccs cursor --auth'); - console.log(' 1. Authenticate: ccs cursor auth'); - console.log(' 2. Start daemon: ccs cursor start'); - console.log(' 3. Check status: ccs cursor status'); + console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`); + console.log(` 1. Authenticate: ${LEGACY_CURSOR_COMMAND} auth`); + console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`); + console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`); return 0; } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index e02c00c9..ecdf187c 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -344,8 +344,7 @@ export async function handleHelpRoute( cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(), copilot: async () => process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])), - cursor: async () => - process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])), + cursor: async () => await showProviderShortcutHelp('cursor', writeLine), docker: async () => (await import('./docker/help-subcommand')).showHelp(), migrate: async () => (await import('./migrate-command')).printMigrateHelp(), setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']), diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 75d0c924..b6ca1c5f 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -20,6 +20,8 @@ export const RESERVED_PROFILE_NAMES = [ 'copilot', // Cursor IDE (Cursor proxy daemon) 'cursor', + 'legacy-cursor', + 'legacy', // CLI commands and special names 'default', 'config', diff --git a/src/cursor/constants.ts b/src/cursor/constants.ts index 2f1d3c99..b8e88d36 100644 --- a/src/cursor/constants.ts +++ b/src/cursor/constants.ts @@ -1,3 +1,5 @@ +export const LEGACY_CURSOR_PROFILE_NAME = 'legacy-cursor'; + export const CURSOR_SUBCOMMANDS = [ 'auth', 'status', diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index bfcea61c..868121da 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -248,7 +248,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const authStatus = checkAuthStatus(); if (!authStatus.authenticated || !authStatus.credentials) { - const message = 'Cursor credentials not found. Run `ccs cursor auth` first.'; + const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.'; if (isAnthropicRoute) { await pipeWebResponseToNode( createAnthropicErrorResponse(401, 'authentication_error', message), @@ -266,7 +266,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } if (authStatus.expired) { - const message = 'Cursor credentials expired. Run `ccs cursor auth` again.'; + const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.'; if (isAnthropicRoute) { await pipeWebResponseToNode( createAnthropicErrorResponse(401, 'authentication_error', message), diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index 74900445..1a1b239f 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -97,7 +97,7 @@ export async function executeCursorProfile( if (!config.enabled) { console.error(fail('Cursor integration is not enabled.')); console.error(''); - console.error('Enable it first: ccs cursor enable'); + console.error('Enable it first: ccs legacy cursor enable'); return 1; } @@ -105,13 +105,13 @@ export async function executeCursorProfile( if (!authStatus.authenticated) { console.error(fail('Cursor credentials not found.')); console.error(''); - console.error('Authenticate first: ccs cursor auth'); + console.error('Authenticate first: ccs legacy cursor auth'); return 1; } if (authStatus.expired) { console.error(fail('Cursor credentials have expired.')); console.error(''); - console.error('Refresh them with: ccs cursor auth'); + console.error('Refresh them with: ccs legacy cursor auth'); return 1; } @@ -133,7 +133,7 @@ export async function executeCursorProfile( console.error(fail('Cursor daemon is not running.')); console.error(''); console.error('Start the daemon:'); - console.error(' ccs cursor start'); + console.error(' ccs legacy cursor start'); console.error('Or enable auto_start in the Cursor config section.'); return 1; } diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts index 3dadfa36..302bbb75 100644 --- a/src/cursor/cursor-runtime-probe.ts +++ b/src/cursor/cursor-runtime-probe.ts @@ -120,7 +120,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise { }; expect(anthropicBody.type).toBe('error'); expect(anthropicBody.error?.type).toBe('authentication_error'); - expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first'); + expect(anthropicBody.error?.message).toContain('Run `ccs legacy cursor auth` first'); const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index e31a2654..78448948 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -87,17 +87,22 @@ describe('npm CLI', () => { }); it('routes cursor probe through the cursor command handler', function() { + let output = ''; try { - execSync(`bun "${srcCcsPath}" cursor probe`, { + output = execSync(`bun "${srcCcsPath}" cursor probe`, { + encoding: 'utf8', stdio: 'pipe', timeout: 3000, env: { ...process.env, CCS_HOME: testCcsHome } }); } catch (e) { - const output = e.stderr?.toString() || e.stdout?.toString() || ''; - assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup'); - assert(output.includes('Cursor Live Probe'), 'Should render the cursor probe command output'); + output = e.stderr?.toString() || e.stdout?.toString() || ''; } + assert(!output.includes("Profile 'cursor' not found"), 'Should not fall through to profile lookup'); + assert( + output.includes('Cursor Live Probe') || output.includes('legacy cursor probe'), + 'Should route through the legacy cursor compatibility handler' + ); }); it('routes gitlab --help to provider shortcut help instead of starting auth', function() { diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 145697cb..6c8293c4 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -202,7 +202,14 @@ describe('ProfileDetector', () => { } }); - it('should detect cursor as a first-class runtime profile when enabled', () => { + it('should detect cursor as a CLIProxy provider shortcut', () => { + const result = detector.detectProfileType('cursor'); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('cursor'); + expect(result.provider).toBe('cursor'); + }); + + it('should detect legacy-cursor as a first-class runtime profile when enabled', () => { const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ enabled: true, @@ -213,9 +220,9 @@ describe('ProfileDetector', () => { }); try { - const result = detector.detectProfileType('cursor'); + const result = detector.detectProfileType('legacy-cursor'); expect(result.type).toBe('cursor'); - expect(result.name).toBe('cursor'); + expect(result.name).toBe('legacy-cursor'); expect(result.cursorConfig?.auto_start).toBe(true); } finally { isUnifiedModeSpy.mockRestore(); @@ -223,7 +230,7 @@ describe('ProfileDetector', () => { } }); - it('should merge default cursor fields when enabled via partial unified config', () => { + it('should merge default legacy cursor fields when enabled via partial unified config', () => { const originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempDir; const ccsDir = path.join(tempDir, '.ccs'); @@ -235,7 +242,7 @@ describe('ProfileDetector', () => { try { const localDetector = new ProfileDetector(); - const result = localDetector.detectProfileType('cursor'); + const result = localDetector.detectProfileType('legacy-cursor'); expect(result.type).toBe('cursor'); expect(result.cursorConfig).toEqual({ enabled: true, @@ -253,7 +260,7 @@ describe('ProfileDetector', () => { } }); - it('should throw a helpful error when cursor profile is disabled', () => { + it('should throw a helpful error when legacy cursor profile is disabled', () => { const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ enabled: false, @@ -264,7 +271,9 @@ describe('ProfileDetector', () => { }); try { - expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/); + expect(() => detector.detectProfileType('legacy-cursor')).toThrow( + /Legacy Cursor profile is not enabled/ + ); } finally { isUnifiedModeSpy.mockRestore(); getCursorConfigSpy.mockRestore(); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 7b278719..60d3a814 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -140,6 +140,15 @@ describe('completion backend', () => { expect(suggestionValues(['cliproxy'])).toEqual(expect.arrayContaining(['remove', '--backend'])); }); + test('treats cursor as a provider shortcut in completion', () => { + const values = suggestionValues(['cursor']); + expect(values).toEqual( + expect.arrayContaining(['--auth', '--accounts', '--config', '--logout']) + ); + expect(values).not.toContain('probe'); + expect(values).not.toContain('start'); + }); + test('filters suggestions by the current token prefix', () => { const values = suggestionValues([], 'do'); expect(values).toEqual(expect.arrayContaining(['docker', 'doctor'])); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 18b70d16..edfac3bd 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -322,7 +322,9 @@ describe('handleCursorCommand', () => { expect(exitCode).toBe(0); expect(errors).toHaveLength(0); expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true); - expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); + expect(logs.some((line) => line.includes('Usage: ccs legacy cursor '))).toBe( + true + ); } finally { console.log = originalLog; console.error = originalError; @@ -423,7 +425,9 @@ describe('renderCursorStatus', () => { true ); expect(logs.some((line) => line.includes('Next steps:'))).toBe(true); - expect(logs.some((line) => line.includes(' - Help: ccs cursor help'))).toBe(true); + expect(logs.some((line) => line.includes(' - Help: ccs legacy cursor help'))).toBe( + true + ); } finally { console.log = originalLog; } @@ -483,11 +487,15 @@ describe('renderCursorHelp', () => { const exitCode = renderCursorHelp(); expect(exitCode).toBe(0); - expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); - expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true); - expect(logs.some((line) => line.includes('Deprecated: prefer CLIProxy-backed Cursor auth'))).toBe( + expect(logs.some((line) => line.includes('Usage: ccs legacy cursor '))).toBe( true ); + expect(logs.some((line) => line.includes('Legacy Cursor Compatibility'))).toBe(true); + expect( + logs.some((line) => + line.includes('Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.') + ) + ).toBe(true); expect(logs.some((line) => line.includes('probe Run a live authenticated runtime probe'))).toBe( true ); @@ -495,7 +503,7 @@ describe('renderCursorHelp', () => { logs.some((line) => line.includes('ccs cursor --auth')) ).toBe(true); expect( - logs.some((line) => line.includes('ccs cursor [claude args]')) + logs.some((line) => line.includes('ccs legacy cursor [claude args]')) ).toBe(true); } finally { console.log = originalLog; diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index 7525e7d3..246a0d3f 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -82,6 +82,21 @@ describe('image-analysis-backend-resolver', () => { expect(status.resolutionSource).toBe('profile-backend'); }); + it('treats cliproxy cursor as a provider-backed profile rather than a legacy bridge alias', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'cursor', + profileType: 'cliproxy', + cliproxyProvider: 'cursor', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.backendId).toBe('cursor'); + expect(status.resolutionSource).toBe('cliproxy-provider'); + expect(status.reason).toContain('no image-analysis model configured'); + }); + it('uses the fallback backend for an unmapped third-party settings profile', () => { const status = resolveImageAnalysisStatus( { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 16dc92ed..1ebba72f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'sonner'; import { queryClient } from '@/lib/query-client'; @@ -129,6 +129,10 @@ export default function App() { /> } + /> + }> diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx index dc251b61..69c4100b 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx @@ -25,6 +25,17 @@ interface ProviderEditorHeaderProps { onSave: () => void; } +function formatSettingsPathBadgeLabel(pathValue: string): string { + const normalized = pathValue.replace(/\\/g, '/'); + const cliproxySegment = '/cliproxy/providers/'; + const cliproxyIndex = normalized.lastIndexOf(cliproxySegment); + if (cliproxyIndex !== -1) { + return normalized.slice(cliproxyIndex + 1); + } + + return normalized.replace(/^.*\//, ''); +} + export function ProviderEditorHeader({ displayName, logoProvider, @@ -63,7 +74,7 @@ export function ProviderEditorHeader({ )} {!isRemoteMode && data?.path && ( - {data.path.replace(/^.*[\\/]/, '')} + {formatSettingsPathBadgeLabel(data.path)} )}
diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 8412ec44..739955c1 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -15,6 +15,7 @@ import { isAnthropicModelEnvKey, } from '@/lib/extended-context-utils'; import { supportsExtendedContext } from '@/lib/model-catalogs'; +import { isValidProvider } from '@/lib/provider-config'; /** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; @@ -39,12 +40,18 @@ export function useProviderEditor( queryFn: async () => { const res = await fetch(`/api/settings/${provider}/raw`); if (!res.ok) { + const fallbackPath = + provider === 'cursor' + ? `~/.ccs/cliproxy/providers/${provider}.settings.json` + : isValidProvider(provider) + ? `~/.ccs/${provider}.settings.json` + : `~/.ccs/profiles/${provider}/settings.json`; // Return empty settings for unconfigured providers return { profile: provider, settings: { env: {} }, mtime: Date.now(), - path: `~/.ccs/profiles/${provider}/settings.json`, + path: fallbackPath, }; } return res.json(); diff --git a/ui/src/components/layout/app-sidebar.tsx b/ui/src/components/layout/app-sidebar.tsx index 1d9c01e0..a50ee9e4 100644 --- a/ui/src/components/layout/app-sidebar.tsx +++ b/ui/src/components/layout/app-sidebar.tsx @@ -126,7 +126,11 @@ function buildNavGroups(t: (key: string) => string): SidebarGroupDef[] { { title: t('nav.deprecated'), items: [ - { path: '/cursor', iconSrc: '/assets/sidebar/cursor.svg', label: t('nav.cursorIde') }, + { + path: '/legacy/cursor', + iconSrc: '/assets/sidebar/cursor.svg', + label: `${t('nav.cursorIde')} (Legacy)`, + }, ], }, { diff --git a/ui/src/hooks/use-cursor.ts b/ui/src/hooks/use-cursor.ts index 0b67bf3c..3ece6d0c 100644 --- a/ui/src/hooks/use-cursor.ts +++ b/ui/src/hooks/use-cursor.ts @@ -57,6 +57,8 @@ interface CursorAuthResult { message: string; } +const LEGACY_CURSOR_API_BASE = '/legacy/cursor'; + export interface CursorProbeResult { ok: boolean; stage: 'config' | 'auth' | 'daemon' | 'runtime'; @@ -87,25 +89,25 @@ function getProbeErrorMessage(value: unknown): string | null { } async function fetchCursorStatus(): Promise { - const res = await fetch(withApiBase('/cursor/status')); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/status`)); if (!res.ok) throw new Error('Failed to fetch cursor status'); return res.json(); } async function fetchCursorConfig(): Promise { - const res = await fetch(withApiBase('/cursor/settings')); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings`)); if (!res.ok) throw new Error('Failed to fetch cursor config'); return res.json(); } async function fetchCursorModels(): Promise { - const res = await fetch(withApiBase('/cursor/models')); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/models`)); if (!res.ok) throw new Error('Failed to fetch cursor models'); return res.json(); } async function fetchCursorRawSettings(): Promise { - const res = await fetch(withApiBase('/cursor/settings/raw')); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings/raw`)); if (!res.ok) throw new Error('Failed to fetch cursor raw settings'); return res.json(); } @@ -113,7 +115,7 @@ async function fetchCursorRawSettings(): Promise { async function updateCursorConfig( updates: Partial ): Promise<{ success: boolean; cursor: CursorConfig }> { - const res = await fetch(withApiBase('/cursor/settings'), { + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), @@ -126,7 +128,7 @@ async function saveCursorRawSettings(data: { settings: CursorRawSettings['settings']; expectedMtime?: number; }): Promise<{ success: boolean; mtime: number }> { - const res = await fetch(withApiBase('/cursor/settings/raw'), { + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/settings/raw`), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -137,7 +139,9 @@ async function saveCursorRawSettings(data: { } async function autoDetectCursorAuth(): Promise { - const res = await fetch(withApiBase('/cursor/auth/auto-detect'), { method: 'POST' }); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/auth/auto-detect`), { + method: 'POST', + }); if (!res.ok) { const error = await res.json().catch(() => ({ error: 'Auto-detect failed' })); throw new Error(error.error || 'Auto-detect failed'); @@ -149,7 +153,7 @@ async function importCursorAuthManual(data: { accessToken: string; machineId: string; }): Promise { - const res = await fetch(withApiBase('/cursor/auth/import'), { + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/auth/import`), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -162,19 +166,23 @@ async function importCursorAuthManual(data: { } async function startCursorDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> { - const res = await fetch(withApiBase('/cursor/daemon/start'), { method: 'POST' }); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/daemon/start`), { + method: 'POST', + }); if (!res.ok) throw new Error('Failed to start cursor daemon'); return res.json(); } async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> { - const res = await fetch(withApiBase('/cursor/daemon/stop'), { method: 'POST' }); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/daemon/stop`), { + method: 'POST', + }); if (!res.ok) throw new Error('Failed to stop cursor daemon'); return res.json(); } async function probeCursorRuntime(): Promise { - const res = await fetch(withApiBase('/cursor/probe'), { method: 'POST' }); + const res = await fetch(withApiBase(`${LEGACY_CURSOR_API_BASE}/probe`), { method: 'POST' }); const payload = await res.json().catch(() => null); if (isCursorProbeResult(payload)) { diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index 1767e3ad..989c393c 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -1283,7 +1283,7 @@ export function CursorPage() { {t('cursorPage.provider')} - Cursor IDE + Cursor IDE (Legacy)
@@ -1295,7 +1295,7 @@ export function CursorPage() {
{/* TODO i18n: missing key for model mapping env var info paragraph */}

- Model mapping writes `ANTHROPIC_MODEL`, + Legacy bridge model mapping writes `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL` in `cursor.settings.json`.

diff --git a/ui/tests/unit/hooks/use-cursor.test.tsx b/ui/tests/unit/hooks/use-cursor.test.tsx index 7b4f947f..849de3fb 100644 --- a/ui/tests/unit/hooks/use-cursor.test.tsx +++ b/ui/tests/unit/hooks/use-cursor.test.tsx @@ -32,7 +32,7 @@ describe('useCursor', () => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.endsWith('/api/cursor/status')) { + if (url.endsWith('/api/legacy/cursor/status')) { return Promise.resolve( createJsonResponse({ enabled: true, @@ -48,7 +48,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/settings')) { + if (url.endsWith('/api/legacy/cursor/settings')) { return Promise.resolve( createJsonResponse({ enabled: true, @@ -60,7 +60,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/models')) { + if (url.endsWith('/api/legacy/cursor/models')) { return Promise.resolve( createJsonResponse({ models: [{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'openai' }], @@ -69,7 +69,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/settings/raw')) { + if (url.endsWith('/api/legacy/cursor/settings/raw')) { return Promise.resolve( createJsonResponse({ settings: {}, @@ -80,7 +80,7 @@ describe('useCursor', () => { ); } - if (url.endsWith('/api/cursor/probe') && init?.method === 'POST') { + if (url.endsWith('/api/legacy/cursor/probe') && init?.method === 'POST') { return Promise.resolve(createJsonResponse(probeFailure, 503)); } @@ -102,14 +102,16 @@ describe('useCursor', () => { await waitFor(() => expect(result.current.probeResult).toMatchObject(probeFailure)); await waitFor(() => expect( - fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/status')) - .length + fetchMock.mock.calls.filter(([input]) => + String(input).endsWith('/api/legacy/cursor/status') + ).length ).toBeGreaterThanOrEqual(2) ); await waitFor(() => expect( - fetchMock.mock.calls.filter(([input]) => String(input).endsWith('/api/cursor/models')) - .length + fetchMock.mock.calls.filter(([input]) => + String(input).endsWith('/api/legacy/cursor/models') + ).length ).toBeGreaterThanOrEqual(2) ); }); From 58c2c46ed73326d9d16f5889326150cd0965022a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 17:48:33 -0400 Subject: [PATCH 36/59] fix(cursor): migrate legacy settings and validate gitlab urls --- src/cliproxy/auth/oauth-handler.ts | 42 ++++++++-- src/cliproxy/config/env-builder.ts | 77 ++++++++++++++++++- src/cliproxy/model-config.ts | 10 +-- src/web-server/routes/settings-routes.ts | 4 +- .../cliproxy/env-builder-provider-url.test.ts | 59 ++++++++++++++ .../oauth-handler-paste-callback.test.ts | 34 ++++++++ 6 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index b6ba5e7e..38df474f 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -98,8 +98,10 @@ export async function requestPasteCallbackStart( `Paste-callback start is not available for ${provider} with the selected method` ); } - if (provider === 'gitlab' && options?.gitlabBaseUrl?.trim()) { - startPath += `&base_url=${encodeURIComponent(options.gitlabBaseUrl.trim())}`; + const normalizedGitLabBaseUrl = + provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined; + if (normalizedGitLabBaseUrl) { + startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`; } const response = await fetch(buildProxyUrl(target, startPath), { headers: buildManagementHeaders(target), @@ -150,9 +152,30 @@ function parseAuthUrlState(url: string | null | undefined): string | null { } } -function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined { +export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined { const normalized = baseUrl?.trim(); - return normalized ? normalized : undefined; + if (!normalized) { + return undefined; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error('GitLab URL must be a valid http:// or https:// URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('GitLab URL must use http:// or https://'); + } + + parsed.hash = ''; + parsed.search = ''; + parsed.username = ''; + parsed.password = ''; + + const normalizedPath = parsed.pathname.replace(/\/+$/, ''); + return normalizedPath ? `${parsed.origin}${normalizedPath}` : parsed.origin; } export async function promptGitLabPersonalAccessToken(): Promise { @@ -786,8 +809,15 @@ export async function triggerOAuth( provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW; const resolvedGitLabAuthMode = provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth'; - const resolvedGitLabBaseUrl = - provider === 'gitlab' ? normalizeGitLabBaseUrl(options.gitlabBaseUrl) : undefined; + let resolvedGitLabBaseUrl: string | undefined; + if (provider === 'gitlab') { + try { + resolvedGitLabBaseUrl = normalizeGitLabBaseUrl(options.gitlabBaseUrl); + } catch (error) { + console.log(fail((error as Error).message)); + return null; + } + } if (provider === 'agy') { if (fromUI && !acceptAgyRisk) { diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index f5013944..dafd0c95 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -19,7 +19,10 @@ import { normalizeProtocol, CLIPROXY_DEFAULT_PORT, } from './port-manager'; -import { migrateLegacyProviderSettingsIfNeeded } from './path-resolver'; +import { + getLegacyProviderSettingsPath, + migrateLegacyProviderSettingsIfNeeded, +} from './path-resolver'; import { canonicalizeModelIdForProvider, MODEL_ENV_VAR_KEYS, @@ -49,6 +52,15 @@ const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ] as const; +const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', +]); + +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} function stripCodexEffortSuffix(modelId: string): string { return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); @@ -285,6 +297,63 @@ export function getClaudeEnvVars( return normalizeModelEnvVarsForProvider(mergedEnv, provider); } +function buildCursorProviderSettingsFromLegacy( + legacySettings: Record +): Record { + const defaultEnv = getClaudeEnvVars('cursor'); + const legacyEnvSource = legacySettings.env; + const legacyEnv = isObjectRecord(legacyEnvSource) ? legacyEnvSource : {}; + const migratedEnv: NodeJS.ProcessEnv = { ...defaultEnv }; + + for (const [key, value] of Object.entries(legacyEnv)) { + if (typeof value !== 'string' || CURSOR_LEGACY_ENV_OVERRIDE_KEYS.has(key)) { + continue; + } + migratedEnv[key] = value; + } + + delete migratedEnv.ANTHROPIC_API_KEY; + + return { + ...legacySettings, + env: normalizeModelEnvVarsForProvider(migratedEnv, 'cursor'), + }; +} + +/** + * Resolve the provider settings path, migrating legacy Cursor provider settings into + * the dedicated cliproxy/providers namespace on first access. + */ +export function resolveProviderSettingsPath(provider: CLIProxyProvider): string { + const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + if (provider !== 'cursor' || fs.existsSync(settingsPath)) { + return settingsPath; + } + + const legacySettingsPath = getLegacyProviderSettingsPath(provider); + if (!fs.existsSync(legacySettingsPath)) { + return settingsPath; + } + + try { + const parsed = JSON.parse(fs.readFileSync(legacySettingsPath, 'utf-8')) as unknown; + if (!isObjectRecord(parsed)) { + return settingsPath; + } + + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify(buildCursorProviderSettingsFromLegacy(parsed), null, 2) + '\n', + { mode: 0o600 } + ); + } catch { + // Best-effort migration only. Callers will fall back to defaults if the legacy file is invalid. + } + + return settingsPath; +} + /** * Get global env vars to inject into all third-party profiles. * Returns empty object if disabled. @@ -465,7 +534,7 @@ export function getEffectiveEnvVars( } // Priority 2: Default provider settings file - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); // Check for user override file if (fs.existsSync(settingsPath)) { @@ -505,7 +574,7 @@ export function getEffectiveEnvVars( * Called during installation/first run */ export function ensureProviderSettings(provider: CLIProxyProvider): void { - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); const defaultEnv = getClaudeEnvVars(provider); const writeSettings = (settings: Record): void => { @@ -663,7 +732,7 @@ export function getRemoteEnvVars( // Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json) if (Object.keys(userEnvVars).length === 0) { - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); if (fs.existsSync(settingsPath)) { try { const content = fs.readFileSync(settingsPath, 'utf-8'); diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index b1824361..8b99316d 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { InteractivePrompt } from '../utils/prompt'; import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog'; -import { getClaudeEnvVars, migrateLegacyProviderSettingsIfNeeded } from './config-generator'; +import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; @@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string) * Check if provider has user settings configured */ export function hasUserSettings(provider: CLIProxyProvider): boolean { - const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider); + const settingsPath = resolveProviderSettingsPath(provider); return fs.existsSync(settingsPath); } @@ -46,7 +46,7 @@ export function getCurrentModel( ): string | undefined { const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : migrateLegacyProviderSettingsIfNeeded(provider); + : resolveProviderSettingsPath(provider); if (!fs.existsSync(settingsPath)) return undefined; try { @@ -116,7 +116,7 @@ export async function configureProviderModel( // Use custom settings path for CLIProxy variants, otherwise use default provider path const settingsPath = customSettingsPath ? customSettingsPath.replace(/^~/, os.homedir()) - : migrateLegacyProviderSettingsIfNeeded(provider); + : resolveProviderSettingsPath(provider); // Skip if already configured with a model (unless --config flag). // A settings file can exist without model env keys (e.g., hook-only writes). @@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise { expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined(); }); + it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => { + process.env.CCS_HOME = tempHome; + const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json'); + const providerSettingsPath = path.join( + tempHome, + '.ccs', + 'cliproxy', + 'providers', + 'cursor.settings.json' + ); + fs.mkdirSync(path.dirname(legacySettingsPath), { recursive: true }); + fs.writeFileSync( + legacySettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:20129', + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + ANTHROPIC_API_KEY: 'legacy-cursor-api-key', + ANTHROPIC_MODEL: 'claude-4-sonnet', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-4-opus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-4-sonnet', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'cursor-small', + ANTHROPIC_SMALL_FAST_MODEL: 'cursor-small', + DISABLE_TELEMETRY: '1', + }, + hooks: { + PreToolUse: [{ matcher: 'Read', hooks: [] }], + }, + }, + null, + 2 + ) + ); + + const env = getEffectiveEnvVars('cursor'); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(env.ANTHROPIC_MODEL).toBe('claude-4-sonnet'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-small'); + expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small'); + expect(env.DISABLE_TELEMETRY).toBe('1'); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + + const migrated = JSON.parse(fs.readFileSync(providerSettingsPath, 'utf-8')) as { + env: Record; + hooks?: Record; + }; + expect(migrated.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/cursor'); + expect(migrated.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); + expect(migrated.env.ANTHROPIC_MODEL).toBe('claude-4-sonnet'); + expect(migrated.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-4-opus'); + expect(migrated.env.ANTHROPIC_SMALL_FAST_MODEL).toBe('cursor-small'); + expect(migrated.env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(migrated.hooks?.PreToolUse).toBeDefined(); + }); + it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); diff --git a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts index 45873915..91b21a20 100644 --- a/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts +++ b/tests/unit/cliproxy/oauth-handler-paste-callback.test.ts @@ -262,3 +262,37 @@ describe('promptGitLabPersonalAccessToken', () => { expect(passwordSpy).toHaveBeenCalledWith('GitLab Personal Access Token'); }); }); + +describe('normalizeGitLabBaseUrl', () => { + it('returns undefined for blank values', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-empty=${Date.now()}` + ); + + expect(normalizeGitLabBaseUrl(undefined)).toBeUndefined(); + expect(normalizeGitLabBaseUrl(' ')).toBeUndefined(); + }); + + it('normalizes whitespace and trailing slashes for self-hosted URLs', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-normalize=${Date.now()}` + ); + + expect(normalizeGitLabBaseUrl(' https://gitlab.example.com/custom/ ')).toBe( + 'https://gitlab.example.com/custom' + ); + }); + + it('rejects malformed or scheme-less URLs before hitting CLIProxy', async () => { + const { normalizeGitLabBaseUrl } = await import( + `../../../src/cliproxy/auth/oauth-handler?gitlab-url-invalid=${Date.now()}` + ); + + expect(() => normalizeGitLabBaseUrl('gitlab.example.com')).toThrow( + 'GitLab URL must be a valid http:// or https:// URL' + ); + expect(() => normalizeGitLabBaseUrl('ftp://gitlab.example.com')).toThrow( + 'GitLab URL must use http:// or https://' + ); + }); +}); From d6a1a0d5c7cd7898a3bc13cd0d70395d4ac3d96a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 22:06:44 +0000 Subject: [PATCH 37/59] chore(release): 7.71.0-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6cedcbe2..54b1e7cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.6", + "version": "7.71.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 1b43d76841ff956df4041ecc0d6cccbc4247da97 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 01:01:56 +0000 Subject: [PATCH 38/59] chore(release): 7.71.0-dev.8 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 54b1e7cf..01225624 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.7", + "version": "7.71.0-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 8edb56331e5f847f06c72624320874c7c884b334 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 21:15:41 -0400 Subject: [PATCH 39/59] fix: clarify docker dashboard auth setup guidance --- docker/README.md | 2 + docs/dashboard-auth-cli.md | 8 ++ docs/project-roadmap.md | 1 + ui/src/lib/i18n.ts | 97 ++++++++++--------- ui/src/pages/login.tsx | 6 ++ .../shared/localhost-disclaimer.test.tsx | 4 +- ui/tests/unit/pages/login-page.test.tsx | 8 +- 7 files changed, 77 insertions(+), 49 deletions(-) diff --git a/docker/README.md b/docker/README.md index 414d4e02..7991b994 100644 --- a/docker/README.md +++ b/docker/README.md @@ -62,6 +62,8 @@ environment: CCS_DASHBOARD_PASSWORD_HASH: "" ``` +Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose. + Generate a bcrypt hash: ```bash diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index 0745f15e..f8c83fc5 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -12,6 +12,12 @@ Authentication is **disabled by default** for backward compatibility. Use the CL CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine. +Docker note: the integrated `ccs docker` stack stores its config inside the running container volume, not in the outer shell's `~/.ccs`. For Docker deployments, run auth setup inside the container: + +```bash +docker exec -it ccs-cliproxy ccs config auth setup +``` + When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth. ## Account Context Modes (Related Feature) @@ -193,6 +199,8 @@ dashboard_auth: Run `ccs config auth setup` to configure credentials. +If you are using the integrated Docker stack, run that command inside `ccs-cliproxy`. Running it on the outer host shell updates a different config directory and will not unlock the running dashboard. + ### Forgot password Run `ccs config auth setup` again to set a new password. diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index c6271e38..b8371475 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell. - **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites. - **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`. - **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`. diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index fdb803ff..0c395c28 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -51,7 +51,7 @@ const resources = { remoteGuardLabel: 'Remote access guard', loading: 'Checking dashboard access…', loginDescription: - 'Use the username and password configured on the host to access the dashboard.', + 'Use the username and password configured for this CCS instance to access the dashboard.', username: 'Username', password: 'Password', usernamePlaceholder: 'Enter username', @@ -63,21 +63,22 @@ const resources = { darkMode: 'Dark', noDefaultCredentials: 'No default credentials ship with CCS.', credentialsHint: - 'Credentials are created on the host with `ccs config auth setup`, then used here.', - remoteSetupTitle: 'Remote access needs host setup', + 'Credentials are created with `ccs config auth setup` on the CCS host. Docker deployments must run that command inside the container.', + remoteSetupTitle: 'Remote access needs auth setup', remoteSetupDescription: - 'This dashboard was opened from a non-local address, but dashboard auth is not enabled on the host yet.', + 'This dashboard was opened from a non-local address, but dashboard auth is not enabled for this CCS instance yet.', incompleteSetupDescription: - 'Dashboard auth is turned on, but the host setup is incomplete. Finish the host configuration before signing in.', + 'Dashboard auth is turned on, but the setup is incomplete. Finish the configuration for this CCS instance before signing in.', safetyNoteRemote: - 'Remote management stays locked until the host owner enables dashboard auth.', + 'Remote management stays locked until the CCS host owner enables dashboard auth.', safetyNoteLocal: 'If you are on the same machine, the localhost URL remains the simplest path in.', safetyNoteSession: - 'Successful sign-ins create an HTTP-only session that stays scoped to this host.', - hostStepTitle: 'On the host machine', + 'Successful sign-ins create an HTTP-only session that stays scoped to this dashboard host.', + hostStepTitle: 'On the CCS host', hostStepDescription: 'Create or re-enable dashboard credentials, then reopen this page from the remote device.', + dockerStepDescription: 'Docker deployment? Run the setup inside the running container:', localStepTitle: 'If this is your machine', localStepDescription: 'Open the localhost URL printed by `ccs config` instead of the LAN or Tailscale address.', @@ -1543,12 +1544,13 @@ const resources = { }, localhostDisclaimer: { remoteReadonlyAuthDisabledLong: - 'Remote dashboard access is read-only because dashboard auth is currently disabled on the host. Re-enable dashboard auth on the host to unlock remote changes.', + 'Remote dashboard access is read-only because dashboard auth is currently disabled for this CCS instance. Re-enable it on the CCS host. Docker deployments must do that inside the running container.', remoteReadonlyAuthDisabledShort: - 'Remote dashboard is read-only until dashboard auth is re-enabled on the host.', + 'Remote dashboard is read-only until dashboard auth is re-enabled for this CCS instance.', remoteReadonlySetupLong: - 'Remote dashboard access is read-only until you run ccs config auth setup on the host.', - remoteReadonlySetupShort: 'Remote dashboard is read-only until host auth is configured.', + 'Remote dashboard access is read-only until you run ccs config auth setup for this CCS instance. Docker deployments must run it inside the container.', + remoteReadonlySetupShort: + 'Remote dashboard is read-only until auth is configured for this CCS instance.', localLong: 'This dashboard runs locally. All data stays on your machine.', localShort: 'Local dashboard - data stays on your device.', dismiss: 'Dismiss disclaimer', @@ -2530,16 +2532,18 @@ const resources = { lightMode: '浅色', darkMode: '深色', noDefaultCredentials: 'CCS 不提供默认用户名或密码。', - credentialsHint: '凭据需要在主机上通过 `ccs config auth setup` 创建,然后在这里使用。', - remoteSetupTitle: '远程访问需要在主机上完成设置', - remoteSetupDescription: '当前通过非本机地址打开控制台,但主机尚未启用控制台认证。', + credentialsHint: + '凭据需要在 CCS 主机上通过 `ccs config auth setup` 创建;Docker 部署必须在容器内运行该命令。', + remoteSetupTitle: '远程访问需要完成认证设置', + remoteSetupDescription: '当前通过非本机地址打开控制台,但此 CCS 实例尚未启用控制台认证。', incompleteSetupDescription: - '控制台认证已开启,但主机上的设置尚未完成。请先完成主机配置再登录。', - safetyNoteRemote: '在主机所有者启用控制台认证前,远程管理会保持锁定。', + '控制台认证已开启,但设置尚未完成。请先为此 CCS 实例完成配置再登录。', + safetyNoteRemote: '在 CCS 主机所有者启用控制台认证前,远程管理会保持锁定。', safetyNoteLocal: '如果你就在这台机器上,使用 localhost 地址始终是最直接的方式。', - safetyNoteSession: '登录成功后会创建仅限此主机的 HTTP-only 会话。', - hostStepTitle: '在主机上操作', + safetyNoteSession: '登录成功后会创建仅限此控制台主机的 HTTP-only 会话。', + hostStepTitle: '在 CCS 主机上操作', hostStepDescription: '创建或重新启用控制台凭据,然后再从远程设备重新打开此页面。', + dockerStepDescription: '如果是 Docker 部署,请在正在运行的容器内执行:', localStepTitle: '如果这就是你的机器', localStepDescription: '请使用 `ccs config` 输出的 localhost 地址,而不是局域网或 Tailscale 地址。', @@ -3930,10 +3934,11 @@ const resources = { }, localhostDisclaimer: { remoteReadonlyAuthDisabledLong: - '远程控制台当前为只读,因为主机未启用控制台认证。请在主机上重新启用控制台认证以解锁远程编辑。', - remoteReadonlyAuthDisabledShort: '远程控制台为只读,直到主机重新启用控制台认证。', - remoteReadonlySetupLong: '远程控制台为只读,直到在主机上运行 ccs config auth setup。', - remoteReadonlySetupShort: '远程控制台为只读,直到完成主机认证配置。', + '远程控制台当前为只读,因为此 CCS 实例未启用控制台认证。请在 CCS 主机上重新启用;Docker 部署需要在运行中的容器内完成。', + remoteReadonlyAuthDisabledShort: '远程控制台为只读,直到此 CCS 实例重新启用控制台认证。', + remoteReadonlySetupLong: + '远程控制台为只读,直到为此 CCS 实例运行 ccs config auth setup。Docker 部署必须在容器内运行该命令。', + remoteReadonlySetupShort: '远程控制台为只读,直到为此 CCS 实例完成认证配置。', localLong: '本控制台在本地运行,所有数据保留在本机。', localShort: '本地控制台 - 数据保留在本机。', dismiss: '关闭提示', @@ -4884,21 +4889,22 @@ const resources = { darkMode: 'Tối', noDefaultCredentials: 'CCS không có sẵn tài khoản hay mật khẩu mặc định.', credentialsHint: - 'Thông tin đăng nhập được tạo trên máy host bằng `ccs config auth setup`, rồi dùng tại đây.', - remoteSetupTitle: 'Truy cập từ xa cần được thiết lập trên máy host', + 'Thông tin đăng nhập được tạo bằng `ccs config auth setup` trên máy CCS host. Với Docker, phải chạy lệnh đó bên trong container.', + remoteSetupTitle: 'Truy cập từ xa cần thiết lập xác thực', remoteSetupDescription: - 'Bảng điều khiển này đang được mở từ một địa chỉ không phải localhost, nhưng máy host chưa bật dashboard auth.', + 'Bảng điều khiển này đang được mở từ một địa chỉ không phải localhost, nhưng phiên bản CCS này chưa bật dashboard auth.', incompleteSetupDescription: - 'Dashboard auth đã được bật nhưng cấu hình trên máy host vẫn chưa hoàn tất. Hãy hoàn thành cấu hình trước khi đăng nhập.', + 'Dashboard auth đã được bật nhưng cấu hình vẫn chưa hoàn tất. Hãy hoàn thành cấu hình cho phiên bản CCS này trước khi đăng nhập.', safetyNoteRemote: - 'Quản trị từ xa sẽ tiếp tục bị khóa cho tới khi chủ máy host bật dashboard auth.', + 'Quản trị từ xa sẽ tiếp tục bị khóa cho tới khi chủ máy CCS host bật dashboard auth.', safetyNoteLocal: 'Nếu bạn đang ngồi ngay trên máy đó, đường localhost vẫn là cách đơn giản nhất để vào.', safetyNoteSession: - 'Sau khi đăng nhập thành công, phiên HTTP-only sẽ chỉ có hiệu lực trên đúng máy host này.', - hostStepTitle: 'Trên máy host', + 'Sau khi đăng nhập thành công, phiên HTTP-only sẽ chỉ có hiệu lực trên đúng máy chủ dashboard này.', + hostStepTitle: 'Trên máy CCS host', hostStepDescription: 'Tạo hoặc bật lại thông tin đăng nhập cho dashboard, rồi mở lại trang này từ thiết bị từ xa.', + dockerStepDescription: 'Nếu dùng Docker, hãy chạy lệnh này bên trong container đang chạy:', localStepTitle: 'Nếu đây là máy của bạn', localStepDescription: 'Hãy mở URL localhost mà `ccs config` in ra, thay vì địa chỉ LAN hoặc Tailscale.', @@ -6380,13 +6386,13 @@ const resources = { }, localhostDisclaimer: { remoteReadonlyAuthDisabledLong: - 'Dashboard từ xa ở chế độ chỉ đọc vì dashboard auth đang bị tắt trên máy host. Bật lại dashboard auth trên máy host để mở khóa thay đổi từ xa.', + 'Dashboard từ xa đang ở chế độ chỉ đọc vì dashboard auth đang bị tắt cho phiên bản CCS này. Hãy bật lại trên máy CCS host; với Docker, việc đó phải được thực hiện bên trong container đang chạy.', remoteReadonlyAuthDisabledShort: - 'Dashboard từ xa chỉ đọc cho đến khi dashboard auth được bật lại trên máy host.', + 'Dashboard từ xa chỉ đọc cho đến khi dashboard auth được bật lại cho phiên bản CCS này.', remoteReadonlySetupLong: - 'Dashboard từ xa chỉ đọc cho đến khi bạn chạy ccs config auth setup trên máy host.', + 'Dashboard từ xa chỉ đọc cho đến khi bạn chạy ccs config auth setup cho phiên bản CCS này. Với Docker, hãy chạy lệnh đó bên trong container.', remoteReadonlySetupShort: - 'Dashboard từ xa chỉ đọc cho đến khi auth được cấu hình trên máy host.', + 'Dashboard từ xa chỉ đọc cho đến khi auth được cấu hình cho phiên bản CCS này.', localLong: 'Dashboard này chạy cục bộ. Toàn bộ dữ liệu nằm trên máy của bạn.', localShort: 'Dashboard cục bộ - dữ liệu nằm trên thiết bị của bạn.', dismiss: 'Bỏ qua thông báo', @@ -7346,20 +7352,21 @@ const resources = { darkMode: 'ダーク', noDefaultCredentials: 'CCS にデフォルトの認証情報はありません。', credentialsHint: - '認証情報はホスト側で `ccs config auth setup` を実行して作成し、ここで使用します。', - remoteSetupTitle: 'リモートアクセスにはホスト側の設定が必要です', + '認証情報は CCS ホスト上で `ccs config auth setup` を実行して作成します。Docker では、そのコマンドをコンテナ内で実行する必要があります。', + remoteSetupTitle: 'リモートアクセスには認証設定が必要です', remoteSetupDescription: - 'このダッシュボードはローカル以外のアドレスから開かれていますが、ホストでダッシュボード認証がまだ有効になっていません。', + 'このダッシュボードはローカル以外のアドレスから開かれていますが、この CCS インスタンスではダッシュボード認証がまだ有効になっていません。', incompleteSetupDescription: - 'ダッシュボード認証は有効ですが、ホスト側の設定が未完了です。サインイン前に設定を完了してください。', + 'ダッシュボード認証は有効ですが、設定が未完了です。この CCS インスタンスの設定を完了してからサインインしてください。', safetyNoteRemote: - 'ホスト管理者がダッシュボード認証を有効にするまで、リモート管理はロックされたままです。', + 'CCS ホスト管理者がダッシュボード認証を有効にするまで、リモート管理はロックされたままです。', safetyNoteLocal: '同じマシン上にいる場合は、localhost の URL を使うのが最も簡単です。', safetyNoteSession: - 'サインインに成功すると、このホストに限定された HTTP-only セッションが作成されます。', - hostStepTitle: 'ホスト側で行うこと', + 'サインインに成功すると、このダッシュボードホストに限定された HTTP-only セッションが作成されます。', + hostStepTitle: 'CCS ホスト側で行うこと', hostStepDescription: 'ダッシュボード認証情報を作成または再有効化してから、このページをリモート端末で開き直してください。', + dockerStepDescription: 'Docker の場合は、実行中のコンテナ内で次を実行してください:', localStepTitle: 'もしこのマシンを使っているなら', localStepDescription: '`ccs config` が表示する localhost の URL を使い、LAN や Tailscale のアドレスは避けてください。', @@ -9254,13 +9261,13 @@ const resources = { }, localhostDisclaimer: { remoteReadonlyAuthDisabledLong: - 'ホストでダッシュボード認証が無効になっているため、リモートダッシュボードは読み取り専用です。リモートでの変更を有効にするには、ホスト側でダッシュボード認証を再度有効にしてください。', + 'この CCS インスタンスでダッシュボード認証が無効になっているため、リモートダッシュボードは読み取り専用です。CCS ホスト側で再度有効にしてください。Docker では、実行中のコンテナ内で行う必要があります。', remoteReadonlyAuthDisabledShort: - 'ホストでダッシュボード認証が再有効化されるまで、リモートダッシュボードは読み取り専用です。', + 'この CCS インスタンスでダッシュボード認証が再有効化されるまで、リモートダッシュボードは読み取り専用です。', remoteReadonlySetupLong: - 'ホストで ccs config auth setup を実行するまで、リモートダッシュボードは読み取り専用です。', + 'この CCS インスタンスで ccs config auth setup を実行するまで、リモートダッシュボードは読み取り専用です。Docker では、そのコマンドをコンテナ内で実行してください。', remoteReadonlySetupShort: - 'ホストの認証が設定されるまで、リモートダッシュボードは読み取り専用です。', + 'この CCS インスタンスの認証が設定されるまで、リモートダッシュボードは読み取り専用です。', localLong: 'このダッシュボードはローカルで動作しています。データはすべてお使いのマシンに残ります。', localShort: 'ローカルダッシュボード - データはお使いのデバイスに保存されます。', diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index d9636034..adb5fde2 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -207,6 +207,12 @@ export function LoginPage() { ccs config auth setup +

+ {t('auth.dockerStepDescription')} +

+ + docker exec -it ccs-cliproxy ccs config auth setup +
diff --git a/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx b/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx index 8c27d933..119f48cd 100644 --- a/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx +++ b/ui/tests/unit/components/shared/localhost-disclaimer.test.tsx @@ -42,7 +42,7 @@ describe('LocalhostDisclaimer', () => { expect( screen.getByText( - 'Remote dashboard access is read-only until you run ccs config auth setup on the host.' + 'Remote dashboard access is read-only until you run ccs config auth setup for this CCS instance. Docker deployments must run it inside the container.' ) ).toBeVisible(); expect(screen.queryByLabelText('Dismiss disclaimer')).toBeNull(); @@ -60,7 +60,7 @@ describe('LocalhostDisclaimer', () => { expect( screen.getByText( - 'Remote dashboard access is read-only because dashboard auth is currently disabled on the host. Re-enable dashboard auth on the host to unlock remote changes.' + 'Remote dashboard access is read-only because dashboard auth is currently disabled for this CCS instance. Re-enable it on the CCS host. Docker deployments must do that inside the running container.' ) ).toBeVisible(); expect(screen.queryByLabelText('Dismiss disclaimer')).toBeNull(); diff --git a/ui/tests/unit/pages/login-page.test.tsx b/ui/tests/unit/pages/login-page.test.tsx index 689e5992..393913c2 100644 --- a/ui/tests/unit/pages/login-page.test.tsx +++ b/ui/tests/unit/pages/login-page.test.tsx @@ -59,7 +59,7 @@ describe('LoginPage', () => { await waitFor(() => { expect(navigateMock).toHaveBeenCalledWith('/settings', { replace: true }); }); - expect(screen.queryByRole('heading', { name: 'Remote access needs host setup' })).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Remote access needs auth setup' })).toBeNull(); }); it('renders the incomplete setup copy when auth is enabled without credentials', () => { @@ -80,7 +80,7 @@ describe('LoginPage', () => { expect( screen.getAllByText( - 'Dashboard auth is turned on, but the host setup is incomplete. Finish the host configuration before signing in.' + 'Dashboard auth is turned on, but the setup is incomplete. Finish the configuration for this CCS instance before signing in.' ) ).not.toHaveLength(0); expect( @@ -88,6 +88,10 @@ describe('LoginPage', () => { 'Create or re-enable dashboard credentials, then reopen this page from the remote device.' ) ).toBeVisible(); + expect( + screen.getByText('Docker deployment? Run the setup inside the running container:') + ).toBeVisible(); + expect(screen.getByText('docker exec -it ccs-cliproxy ccs config auth setup')).toBeVisible(); expect(screen.queryByLabelText('Username')).not.toBeInTheDocument(); }); From 0e8abed7982ed6640455b52e61fb658ba8363b2b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 21:31:25 -0400 Subject: [PATCH 40/59] fix: chronological ordering in usage trends chart (#1008) --- ui/src/components/analytics/usage-trend-chart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index f7cd2d2f..70a658ba 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -46,7 +46,7 @@ export function UsageTrendChart({ if (!data || data.length === 0) return []; // For hourly data, already sorted ascending from API - const sortedData = granularity === 'hourly' ? data : [...data].reverse(); + const sortedData = data; return sortedData.map((item) => { // Handle hourly vs daily data format From d3ef82cd601732522e1b98d4c9a4035a9ab31941 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 21:40:56 -0400 Subject: [PATCH 41/59] feat: add missing i18n keys for analytics chart empty states --- ui/src/components/analytics/usage-trend-chart.tsx | 7 +++---- ui/src/lib/i18n.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index 70a658ba..e20d0303 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -17,6 +17,7 @@ import { AreaChart, } from 'recharts'; import { format } from 'date-fns'; +import { useTranslation } from 'react-i18next'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; import type { DailyUsage, HourlyUsage } from '@/hooks/use-usage'; @@ -39,8 +40,7 @@ export function UsageTrendChart({ className, }: UsageTrendChartProps) { const { privacyMode } = usePrivacy(); - // TODO i18n: uncomment when keys for "No usage data for today" / "No usage data available" are added - // const { t } = useTranslation(); + const { t } = useTranslation(); const chartData = useMemo(() => { if (!data || data.length === 0) return []; @@ -67,8 +67,7 @@ export function UsageTrendChart({ return (

- {/* TODO i18n: missing keys for "No usage data for today" / "No usage data available" */} - {granularity === 'hourly' ? 'No usage data for today' : 'No usage data available'} + {granularity === 'hourly' ? t('analytics.noDailyUsage') : t('analytics.noUsageData')}

); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index fdb803ff..781cb649 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1063,6 +1063,8 @@ const resources = { }, analytics: { title: 'Analytics', + noDailyUsage: 'No usage data for today', + noUsageData: 'No usage data available', subtitle: 'Track usage and insights', month: 'Month', allTime: 'All Time', @@ -3463,6 +3465,8 @@ const resources = { }, analytics: { title: '分析', + noDailyUsage: '今日无使用数据', + noUsageData: '无可用使用数据', subtitle: '追踪使用情况与洞察', month: '本月', allTime: '全部时间', @@ -5902,6 +5906,8 @@ const resources = { }, analytics: { title: 'Phân tích', + noDailyUsage: 'Không có dữ liệu sử dụng cho hôm nay', + noUsageData: 'Không có dữ liệu sử dụng', subtitle: 'Theo dõi việc sử dụng và thông tin chi tiết', month: 'Tháng', allTime: 'Tất cả thời gian', @@ -8370,6 +8376,8 @@ const resources = { }, analytics: { title: '分析', + noDailyUsage: '本日の使用データはありません', + noUsageData: '利用可能な使用データはありません', subtitle: '利用状況とインサイトを確認', month: '月', allTime: '全期間', From 4608e2cbc371d95c52f387e300f53fc07c565891 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 01:47:53 +0000 Subject: [PATCH 42/59] chore(release): 7.71.0-dev.9 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 01225624..6f24e746 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.8", + "version": "7.71.0-dev.9", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 27f1416181904cc256a8694e6e15189ca673333c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 15 Apr 2026 22:55:11 -0400 Subject: [PATCH 43/59] fix(cliproxy): avoid network-bound local startup - skip CLIProxy auto-update checks on runtime bootstrap paths - fail fast when local startup needs a missing binary instead of attempting installs - add regression coverage for dashboard limited mode and startup test isolation --- src/cliproxy/auth/kiro-import.ts | 2 +- src/cliproxy/auth/oauth-handler.ts | 2 +- src/cliproxy/binary-manager.ts | 23 ++- src/cliproxy/binary/lifecycle.ts | 18 ++- src/cliproxy/executor/index.ts | 2 +- src/cliproxy/service-manager.ts | 5 +- src/cliproxy/types.ts | 4 + .../cliproxy/binary-manager-install.test.ts | 41 +++++- .../cliproxy/service-manager-startup.test.ts | 78 ++++++++++ .../version-checker-stale-cache.test.ts | 50 ++++++- tests/unit/commands/config-command.test.ts | 20 +++ .../tokens-command-auth-rotation.test.ts | 134 ++++++------------ 12 files changed, 283 insertions(+), 96 deletions(-) create mode 100644 tests/unit/cliproxy/service-manager-startup.test.ts diff --git a/src/cliproxy/auth/kiro-import.ts b/src/cliproxy/auth/kiro-import.ts index cc507110..9e83facd 100644 --- a/src/cliproxy/auth/kiro-import.ts +++ b/src/cliproxy/auth/kiro-import.ts @@ -29,7 +29,7 @@ export async function tryKiroImport(tokenDir: string, verbose = false): Promise< try { log('Ensuring CLIProxy binary is available...'); - const binaryPath = await ensureCLIProxyBinary(verbose); + const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); const configPath = generateConfig('kiro'); log(`Binary: ${binaryPath}`); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 38df474f..7b1482fa 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -428,7 +428,7 @@ async function prepareBinary( showStep(1, 4, 'progress', 'Preparing CLIProxy binary...'); try { - const binaryPath = await ensureCLIProxyBinary(verbose); + const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); process.stdout.write('\x1b[1A\x1b[2K'); showStep(1, 4, 'ok', 'CLIProxy binary ready'); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 194faac7..452bd162 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -62,6 +62,8 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary maxRetries: 3, verbose: false, forceVersion: false, + skipAutoUpdate: false, + allowInstall: true, backend, // Pass backend for installer to use correct download URL }; } @@ -115,8 +117,16 @@ export class BinaryManager { } } +export interface EnsureCLIProxyBinaryOptions { + allowInstall?: boolean; + skipAutoUpdate?: boolean; +} + /** Convenience function respecting version pin */ -export async function ensureCLIProxyBinary(verbose = false): Promise { +export async function ensureCLIProxyBinary( + verbose = false, + options: EnsureCLIProxyBinaryOptions = {} +): Promise { const backend = getConfiguredBackend(); // Migrate old shared pin to backend-specific location (one-time migration) @@ -130,11 +140,20 @@ export async function ensureCLIProxyBinary(verbose = false): Promise { version: pinnedVersion, verbose, forceVersion: true, + skipAutoUpdate: options.skipAutoUpdate ?? false, + allowInstall: options.allowInstall ?? true, }, backend ).ensureBinary(); } - return new BinaryManager({ verbose }, backend).ensureBinary(); + return new BinaryManager( + { + verbose, + skipAutoUpdate: options.skipAutoUpdate ?? false, + allowInstall: options.allowInstall ?? true, + }, + backend + ).ensureBinary(); } /** Check if CLIProxyAPI binary is installed */ diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index 48700ce5..24e64d5c 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -26,6 +26,10 @@ function log(message: string, verbose: boolean): void { if (verbose) console.error(`[cliproxy] ${message}`); } +function getBackendLabel(backend: CLIProxyBackend): string { + return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; +} + /** * Check if version is above max stable (known unstable) */ @@ -52,7 +56,7 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string /** Handle auto-update when binary exists */ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise { const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; - const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + const backendLabel = getBackendLabel(backend); const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend); const currentVersion = updateResult.currentVersion; const latestVersion = updateResult.latestVersion; @@ -112,6 +116,11 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise return binaryPath; } + if (config.skipAutoUpdate) { + log('Runtime bootstrap mode: skipping auto-update check', verbose); + return binaryPath; + } + try { await handleAutoUpdate(config, verbose); } catch (error) { @@ -125,6 +134,13 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise // Binary missing - download log('Binary not found, downloading...', verbose); + if (!config.allowInstall) { + throw new Error( + `${getBackendLabel(backend)} binary is not installed locally. ` + + 'Run "ccs cliproxy install" when you have network access.' + ); + } + if (!config.forceVersion) { try { const latestVersion = await fetchLatestVersion(verbose, backend); diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 5c2c25e4..4ad89052 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -329,7 +329,7 @@ export async function execClaudeWithCLIProxy( spinner.start(); try { - binaryPath = await ensureCLIProxyBinary(verbose); + binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true }); spinner.succeed('CLIProxy binary ready'); } catch (error) { spinner.fail('Failed to prepare CLIProxy'); diff --git a/src/cliproxy/service-manager.ts b/src/cliproxy/service-manager.ts index 0ef4d2e7..b97add9f 100644 --- a/src/cliproxy/service-manager.ts +++ b/src/cliproxy/service-manager.ts @@ -216,7 +216,10 @@ export async function ensureCliproxyService( // 1. Ensure binary exists let binaryPath: string; try { - binaryPath = await ensureCLIProxyBinary(verbose); + binaryPath = await ensureCLIProxyBinary(verbose, { + allowInstall: false, + skipAutoUpdate: true, + }); log(`Binary ready: ${binaryPath}`); } catch (error) { const err = error as Error; diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 7a55922a..ea723480 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -50,6 +50,10 @@ export interface BinaryManagerConfig { verbose: boolean; /** Force specific version (skip auto-upgrade to latest) */ forceVersion: boolean; + /** Skip background update checks on runtime bootstrap paths */ + skipAutoUpdate: boolean; + /** Allow downloading/installing the binary when it is missing */ + allowInstall: boolean; /** Backend variant (original vs plus) */ backend?: CLIProxyBackend; } diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 533f38bc..92140f1d 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -1,4 +1,28 @@ -import { describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +let originalCcsHome: string | undefined; +let tempHome = ''; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-binary-manager-')); + process.env.CCS_HOME = tempHome; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } +}); describe('installCliproxyVersion', () => { it('attempts to stop the proxy even when there is no tracked running session', async () => { @@ -42,4 +66,19 @@ describe('installCliproxyVersion', () => { expect(calls.deleteBinary).toBe(0); expect(calls.ensureBinary).toBe(1); }); + + it('fails fast when runtime startup forbids installing a missing binary', async () => { + const binaryManager = await import( + `../../../src/cliproxy/binary-manager?binary-manager-runtime=${Date.now()}` + ); + + await expect( + binaryManager.ensureCLIProxyBinary(false, { + allowInstall: false, + skipAutoUpdate: true, + }) + ).rejects.toThrow( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }); }); diff --git a/tests/unit/cliproxy/service-manager-startup.test.ts b/tests/unit/cliproxy/service-manager-startup.test.ts new file mode 100644 index 00000000..5b85f9fc --- /dev/null +++ b/tests/unit/cliproxy/service-manager-startup.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, mock } from 'bun:test'; + +const ensureBinaryCalls: Array = []; + +mock.module('../../../src/cliproxy/binary-manager', () => ({ + ensureCLIProxyBinary: async (_verbose = false, options?: unknown) => { + ensureBinaryCalls.push(options); + throw new Error( + 'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + }, +})); + +mock.module('../../../src/cliproxy/config-generator', () => ({ + ensureConfigDir: () => undefined, + generateConfig: () => '/tmp/cliproxy-config.yaml', + regenerateConfig: () => '/tmp/cliproxy-config.yaml', + configNeedsRegeneration: () => false, + CLIPROXY_DEFAULT_PORT: 8317, + getCliproxyWritablePath: () => '/tmp', +})); + +mock.module('../../../src/cliproxy/proxy-detector', () => ({ + detectRunningProxy: async () => ({ running: false, verified: false }), + waitForProxyHealthy: async () => false, +})); + +mock.module('../../../src/cliproxy/startup-lock', () => ({ + withStartupLock: async (fn: () => Promise) => await fn(), +})); + +mock.module('../../../src/cliproxy/session-tracker', () => ({ + registerSession: () => undefined, +})); + +mock.module('../../../src/cliproxy/stats-fetcher', () => ({ + isCliproxyRunning: async () => false, +})); + +mock.module('../../../src/cliproxy/auth/token-refresh-config', () => ({ + getTokenRefreshConfig: () => null, +})); + +mock.module('../../../src/cliproxy/auth/token-refresh-worker', () => ({ + TokenRefreshWorker: class { + isActive(): boolean { + return false; + } + start(): void {} + stop(): void {} + }, +})); + +const { ensureCliproxyService } = await import( + `../../../src/cliproxy/service-manager?service-manager-startup=${Date.now()}` +); + +describe('ensureCliproxyService', () => { + it('fails fast without attempting a runtime install when the local binary is missing', async () => { + ensureBinaryCalls.length = 0; + + const result = await ensureCliproxyService(8317, false); + + expect(result).toEqual({ + started: false, + alreadyRunning: false, + port: 8317, + error: + 'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.', + }); + expect(ensureBinaryCalls).toEqual([ + { + allowInstall: false, + skipAutoUpdate: true, + }, + ]); + }); +}); diff --git a/tests/unit/cliproxy/version-checker-stale-cache.test.ts b/tests/unit/cliproxy/version-checker-stale-cache.test.ts index 27c03871..c8829faa 100644 --- a/tests/unit/cliproxy/version-checker-stale-cache.test.ts +++ b/tests/unit/cliproxy/version-checker-stale-cache.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -14,6 +14,8 @@ describe('version-checker stale cache fallback', () => { }); afterEach(() => { + mock.restore(); + if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { @@ -86,4 +88,50 @@ describe('version-checker stale cache fallback', () => { expect(result.latest).toBe('6.9.23-0'); expect(result.fromCache).toBe(true); }); + + it('skips update lookups when runtime startup prefers the installed binary', async () => { + const { getExecutableName } = await import('../../../src/cliproxy/platform-detector'); + const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); + fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'binary'); + + let checkForUpdatesCalls = 0; + + mock.module('../../../src/cliproxy/binary/version-checker', () => ({ + checkForUpdates: async () => { + checkForUpdatesCalls += 1; + return { + hasUpdate: false, + currentVersion: '6.8.2-0', + latestVersion: '6.8.2-0', + fromCache: false, + checkedAt: Date.now(), + }; + }, + fetchLatestVersion: async () => { + throw new Error('fetchLatestVersion should not run when skipAutoUpdate is enabled'); + }, + isNewerVersion: () => false, + isVersionFaulty: () => false, + })); + + const { ensureBinary } = await import( + `../../../src/cliproxy/binary/lifecycle?skip-auto-update=${Date.now()}` + ); + + const binaryPath = await ensureBinary({ + version: '6.8.2-0', + releaseUrl: 'https://example.com/releases/download', + binPath: plusBinDir, + maxRetries: 1, + verbose: false, + forceVersion: false, + skipAutoUpdate: true, + allowInstall: true, + backend: 'plus', + }); + + expect(binaryPath).toBe(path.join(plusBinDir, getExecutableName('plus'))); + expect(checkForUpdatesCalls).toBe(0); + }); }); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 98d2320e..2fd3fbcf 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -171,6 +171,26 @@ describe('config command dashboard startup', () => { expect(errorLines).toHaveLength(0); }); + it('still opens the dashboard when CLIProxy is unavailable', async () => { + await handleConfigCommand([], { + ...createTestDeps(), + ensureCliproxyService: async () => ({ + started: false, + alreadyRunning: false, + port: 8317, + error: + 'Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.', + }), + }); + + expect(startServerCalls).toHaveLength(1); + const rendered = logLines.join('\n'); + expect(rendered).toContain( + 'CLIProxy not available: Failed to prepare binary: CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.' + ); + expect(rendered).toContain('Dashboard will work but Control Panel/Stats may be limited'); + }); + it('fails cleanly when the server cannot bind the requested host', async () => { startServerError = new Error( 'Unable to bind 192.0.2.123:4100; the address may be unavailable or the port may already be in use' diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index 4c3e95aa..d2c86e8b 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -1,105 +1,65 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { handleTokensCommand } from '../../../src/commands/tokens-command'; +import { getConfigYamlPath, loadUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { runWithScopedCcsHome, setGlobalConfigDir } from '../../../src/utils/config-manager'; -async function loadTokensCommand() { - return await import( - `../../../src/commands/tokens-command?test=${Date.now()}-${Math.random()}` - ); -} +async function withScopedTokensHome(run: (tempHome: string) => Promise): Promise { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); + setGlobalConfigDir(undefined); -async function loadCliproxyModule() { - return await import(`../../../src/cliproxy?test=${Date.now()}-${Math.random()}`); -} - -async function loadUnifiedConfigModule() { - return await import( - `../../../src/config/unified-config-loader?test=${Date.now()}-${Math.random()}` - ); + try { + return await runWithScopedCcsHome(tempHome, async () => await run(tempHome)); + } finally { + setGlobalConfigDir(undefined); + fs.rmSync(tempHome, { recursive: true, force: true }); + } } describe('tokens command auth rotation', () => { - let tempHome = ''; - let logLines: string[] = []; - let errorLines: string[] = []; - let originalCcsHome: string | undefined; - let originalNoColor: string | undefined; - let originalConsoleLog: typeof console.log; - let originalConsoleError: typeof console.error; - - beforeEach(() => { - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); - logLines = []; - errorLines = []; - originalCcsHome = process.env.CCS_HOME; - originalNoColor = process.env.NO_COLOR; - originalConsoleLog = console.log; - originalConsoleError = console.error; - - process.env.CCS_HOME = tempHome; - process.env.NO_COLOR = '1'; - console.log = (...args: unknown[]) => { - logLines.push(args.map(String).join(' ')); - }; - console.error = (...args: unknown[]) => { - errorLines.push(args.map(String).join(' ')); - }; - }); - - afterEach(() => { - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; - - if (originalNoColor !== undefined) process.env.NO_COLOR = originalNoColor; - else delete process.env.NO_COLOR; - - console.log = originalConsoleLog; - console.error = originalConsoleError; - fs.rmSync(tempHome, { recursive: true, force: true }); - }); - it('applies api-key and regenerated secret in a single invocation', async () => { - const { handleTokensCommand } = await loadTokensCommand(); - const { getCliproxyConfigPath } = await loadCliproxyModule(); - const { loadUnifiedConfig } = await loadUnifiedConfigModule(); + await withScopedTokensHome(async () => { + const exitCode = await handleTokensCommand([ + '--api-key', + 'ccs-custom-key-123', + '--regenerate-secret', + ]); - const exitCode = await handleTokensCommand([ - '--api-key', - 'ccs-custom-key-123', - '--regenerate-secret', - ]); + const config = loadUnifiedConfig(); + const managementSecret = config?.cliproxy.auth?.management_secret; + const configYamlPath = getConfigYamlPath(); - expect(exitCode).toBe(0); - expect(errorLines).toHaveLength(0); - expect(logLines.some((line) => line.includes('New management secret generated'))).toBe(true); - expect(logLines.some((line) => line.includes('Global API key updated'))).toBe(true); - expect(logLines.filter((line) => line.includes('CLIProxy config regenerated'))).toHaveLength(1); + const diagnostics = { + exitCode, + configYamlPath, + configExists: fs.existsSync(configYamlPath), + apiKey: config?.cliproxy.auth?.api_key ?? null, + managementSecretLength: (managementSecret ?? '').length, + }; - const config = loadUnifiedConfig(); - const managementSecret = config?.cliproxy.auth?.management_secret; - expect(config?.cliproxy.auth?.api_key).toBe('ccs-custom-key-123'); - expect(typeof managementSecret).toBe('string'); - expect((managementSecret ?? '').length).toBeGreaterThan(20); - - const cliproxyConfig = fs.readFileSync(getCliproxyConfigPath(), 'utf8'); - expect(cliproxyConfig).toContain('"ccs-custom-key-123"'); + if ( + exitCode !== 0 || + config?.cliproxy.auth?.api_key !== 'ccs-custom-key-123' || + typeof managementSecret !== 'string' || + (managementSecret ?? '').length <= 20 + ) { + throw new Error(`tokens rotation diagnostics: ${JSON.stringify(diagnostics)}`); + } + }); }); it('rejects conflicting manual and generated secret flags', async () => { - const { handleTokensCommand } = await loadTokensCommand(); - const { getConfigYamlPath } = await loadUnifiedConfigModule(); + await withScopedTokensHome(async () => { + const exitCode = await handleTokensCommand([ + '--secret', + 'manual-secret', + '--regenerate-secret', + ]); - const exitCode = await handleTokensCommand([ - '--secret', - 'manual-secret', - '--regenerate-secret', - ]); - - expect(exitCode).toBe(1); - expect( - errorLines.some((line) => line.includes('Cannot combine --secret with --regenerate-secret')) - ).toBe(true); - expect(fs.existsSync(getConfigYamlPath())).toBe(false); + expect(exitCode).toBe(1); + expect(fs.existsSync(getConfigYamlPath())).toBe(false); + }); }); }); From 3b17bc934a01b93f016e85de4c4a7a162863dcf7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 00:08:25 -0400 Subject: [PATCH 44/59] docs(roadmap): note private-network startup fix --- docs/project-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index b8371475..2cdfda56 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads. - **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell. - **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites. - **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`. From 2ba3a0ab02fc4909681a36cc265e5ff2d1680426 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 00:08:46 -0400 Subject: [PATCH 45/59] test(ci): stabilize runner-sensitive isolated tests - isolate tokens and session-tracker tests in child processes - make child scripts resolve repo modules from the test location - avoid machine-specific paths in the repo --- .../cliproxy/session-tracker-target.test.ts | 137 ++++++++++++------ .../tokens-command-auth-rotation.test.ts | 109 +++++++++++--- 2 files changed, 181 insertions(+), 65 deletions(-) diff --git a/tests/unit/cliproxy/session-tracker-target.test.ts b/tests/unit/cliproxy/session-tracker-target.test.ts index 2a8d23ad..be4257e1 100644 --- a/tests/unit/cliproxy/session-tracker-target.test.ts +++ b/tests/unit/cliproxy/session-tracker-target.test.ts @@ -1,56 +1,111 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { - registerSession, - unregisterSession, - getProxyStatus, -} from '../../../src/cliproxy/session-tracker'; +import { spawnSync } from 'child_process'; +import { pathToFileURL } from 'url'; -describe('session-tracker target metadata', () => { - let tmpDir: string; - let originalCcsHome: string | undefined; - const port = 28317; +const REPO_ROOT = path.resolve(import.meta.dir, '../../..'); +const SESSION_TRACKER_URL = pathToFileURL( + path.join(REPO_ROOT, 'src/cliproxy/session-tracker.ts') +).href; - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-')); - originalCcsHome = process.env.CCS_HOME; - process.env.CCS_HOME = tmpDir; - }); +function withScopedSessionTrackerHome(run: (tempHome: string) => T): T { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-session-target-test-')); + try { + return run(tempHome); + } finally { + fs.rmSync(tempHome, { recursive: true, force: true }); + } +} - afterEach(() => { - if (originalCcsHome !== undefined) { - process.env.CCS_HOME = originalCcsHome; - } else { - delete process.env.CCS_HOME; +function runSessionTrackerScenario( + tempHome: string, + targets: string[] +): { + running: boolean; + target?: string; + sessionCount?: number; +} { + const script = ` + import { + registerSession, + unregisterSession, + getProxyStatus, + } from ${JSON.stringify(SESSION_TRACKER_URL)}; + + const port = 28317; + const sessionIds = []; + for (const target of ${JSON.stringify(targets)}) { + sessionIds.push(registerSession(port, process.pid, undefined, undefined, target)); } - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('returns single target when all sessions share same target', () => { - const s1 = registerSession(port, process.pid, undefined, undefined, 'droid'); - const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); const status = getProxyStatus(port); - expect(status.running).toBe(true); - expect(status.target).toBe('droid'); - expect(status.sessionCount).toBe(2); + for (const sessionId of sessionIds) { + unregisterSession(sessionId, port); + } - unregisterSession(s1, port); - unregisterSession(s2, port); + console.log(JSON.stringify({ + running: status.running, + target: status.target ?? null, + sessionCount: status.sessionCount ?? null, + })); + `; + + const scriptPath = path.join(tempHome, `session-target-child-${Date.now()}.mjs`); + fs.writeFileSync(scriptPath, script, 'utf8'); + + const result = spawnSync('/bin/bash', ['-lc', `bun ${JSON.stringify(scriptPath)}`], { + cwd: REPO_ROOT, + env: { + ...process.env, + CCS_HOME: tempHome, + CCS_DIR: '', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.status !== 0) { + throw new Error( + `child session-tracker scenario failed: ${JSON.stringify({ + command: `bun ${scriptPath}`, + status: result.status, + signal: result.signal, + error: result.error?.message ?? null, + stdout: result.stdout, + stderr: result.stderr, + })}` + ); + } + + const lines = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + return JSON.parse(lines.at(-1) || '{}') as { + running: boolean; + target?: string; + sessionCount?: number; + }; +} + +describe('session-tracker target metadata', () => { + it('returns single target when all sessions share same target', () => { + withScopedSessionTrackerHome((tempHome) => { + const status = runSessionTrackerScenario(tempHome, ['droid', 'droid']); + expect(status.running).toBe(true); + expect(status.target).toBe('droid'); + expect(status.sessionCount).toBe(2); + }); }); it('returns mixed when active sessions use different targets', () => { - const s1 = registerSession(port, process.pid, undefined, undefined, 'claude'); - const s2 = registerSession(port, process.pid, undefined, undefined, 'droid'); - - const status = getProxyStatus(port); - expect(status.running).toBe(true); - expect(status.target).toBe('mixed'); - expect(status.sessionCount).toBe(2); - - unregisterSession(s1, port); - unregisterSession(s2, port); + withScopedSessionTrackerHome((tempHome) => { + const status = runSessionTrackerScenario(tempHome, ['claude', 'droid']); + expect(status.running).toBe(true); + expect(status.target).toBe('mixed'); + expect(status.sessionCount).toBe(2); + }); }); }); diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index d2c86e8b..1ef6c716 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -2,64 +2,125 @@ import { describe, expect, it } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { handleTokensCommand } from '../../../src/commands/tokens-command'; -import { getConfigYamlPath, loadUnifiedConfig } from '../../../src/config/unified-config-loader'; -import { runWithScopedCcsHome, setGlobalConfigDir } from '../../../src/utils/config-manager'; +import { spawnSync } from 'child_process'; +import { pathToFileURL } from 'url'; +import { setGlobalConfigDir } from '../../../src/utils/config-manager'; -async function withScopedTokensHome(run: (tempHome: string) => Promise): Promise { +const REPO_ROOT = path.resolve(import.meta.dir, '../../..'); +const TOKENS_COMMAND_URL = pathToFileURL( + path.join(REPO_ROOT, 'src/commands/tokens-command.ts') +).href; +const UNIFIED_CONFIG_LOADER_URL = pathToFileURL( + path.join(REPO_ROOT, 'src/config/unified-config-loader.ts') +).href; + +function withScopedTokensHome(run: (tempHome: string) => T): T { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-tokens-rotation-')); setGlobalConfigDir(undefined); try { - return await runWithScopedCcsHome(tempHome, async () => await run(tempHome)); + return run(tempHome); } finally { setGlobalConfigDir(undefined); fs.rmSync(tempHome, { recursive: true, force: true }); } } +function runTokensCommandInChild(tempHome: string, args: string[]) { + const script = ` + import { handleTokensCommand } from ${JSON.stringify(TOKENS_COMMAND_URL)}; + import { loadUnifiedConfig } from ${JSON.stringify(UNIFIED_CONFIG_LOADER_URL)}; + + const exitCode = await handleTokensCommand(${JSON.stringify(args)}); + const config = loadUnifiedConfig(); + const managementSecret = config?.cliproxy.auth?.management_secret ?? null; + + console.log(JSON.stringify({ + exitCode, + apiKey: config?.cliproxy.auth?.api_key ?? null, + managementSecretLength: typeof managementSecret === 'string' ? managementSecret.length : 0, + })); + `; + + const scriptPath = path.join(tempHome, `tokens-child-${Date.now()}.mjs`); + fs.writeFileSync(scriptPath, script, 'utf8'); + + const result = spawnSync('/bin/bash', ['-lc', `bun ${JSON.stringify(scriptPath)}`], { + cwd: REPO_ROOT, + env: { + ...process.env, + CCS_HOME: tempHome, + CCS_DIR: '', + NO_COLOR: '1', + }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (result.status !== 0) { + throw new Error( + `child tokens command failed: ${JSON.stringify({ + command: `bun ${scriptPath}`, + status: result.status, + signal: result.signal, + error: result.error?.message ?? null, + stdout: result.stdout, + stderr: result.stderr, + })}` + ); + } + + const lines = result.stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const payload = JSON.parse(lines.at(-1) || '{}') as { + exitCode: number; + apiKey: string | null; + managementSecretLength: number; + }; + + return { payload, stdout: result.stdout, stderr: result.stderr }; +} + describe('tokens command auth rotation', () => { - it('applies api-key and regenerated secret in a single invocation', async () => { - await withScopedTokensHome(async () => { - const exitCode = await handleTokensCommand([ + it('applies api-key and regenerated secret in a single invocation', () => { + withScopedTokensHome((tempHome) => { + const { payload } = runTokensCommandInChild(tempHome, [ '--api-key', 'ccs-custom-key-123', '--regenerate-secret', ]); - - const config = loadUnifiedConfig(); - const managementSecret = config?.cliproxy.auth?.management_secret; - const configYamlPath = getConfigYamlPath(); + const configYamlPath = path.join(tempHome, '.ccs', 'config.yaml'); const diagnostics = { - exitCode, + exitCode: payload.exitCode, configYamlPath, configExists: fs.existsSync(configYamlPath), - apiKey: config?.cliproxy.auth?.api_key ?? null, - managementSecretLength: (managementSecret ?? '').length, + apiKey: payload.apiKey, + managementSecretLength: payload.managementSecretLength, }; if ( - exitCode !== 0 || - config?.cliproxy.auth?.api_key !== 'ccs-custom-key-123' || - typeof managementSecret !== 'string' || - (managementSecret ?? '').length <= 20 + payload.exitCode !== 0 || + payload.apiKey !== 'ccs-custom-key-123' || + payload.managementSecretLength <= 20 ) { throw new Error(`tokens rotation diagnostics: ${JSON.stringify(diagnostics)}`); } }); }); - it('rejects conflicting manual and generated secret flags', async () => { - await withScopedTokensHome(async () => { - const exitCode = await handleTokensCommand([ + it('rejects conflicting manual and generated secret flags', () => { + withScopedTokensHome((tempHome) => { + const { payload } = runTokensCommandInChild(tempHome, [ '--secret', 'manual-secret', '--regenerate-secret', ]); - expect(exitCode).toBe(1); - expect(fs.existsSync(getConfigYamlPath())).toBe(false); + expect(payload.exitCode).toBe(1); + expect(fs.existsSync(path.join(tempHome, '.ccs', 'config.yaml'))).toBe(false); }); }); }); From 2e2ba1c09b81b5e79b7ec65bbd0979d5ce1a1204 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 00:20:01 -0400 Subject: [PATCH 46/59] test(ci): use runtime-relative isolated child scripts - derive repo imports from each test file via file URLs - avoid hardcoded paths in the repo - launch child scripts with the current runtime for consistent local and runner behavior --- tests/unit/cliproxy/session-tracker-target.test.ts | 4 ++-- tests/unit/commands/tokens-command-auth-rotation.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/cliproxy/session-tracker-target.test.ts b/tests/unit/cliproxy/session-tracker-target.test.ts index be4257e1..762e1f04 100644 --- a/tests/unit/cliproxy/session-tracker-target.test.ts +++ b/tests/unit/cliproxy/session-tracker-target.test.ts @@ -55,7 +55,7 @@ function runSessionTrackerScenario( const scriptPath = path.join(tempHome, `session-target-child-${Date.now()}.mjs`); fs.writeFileSync(scriptPath, script, 'utf8'); - const result = spawnSync('/bin/bash', ['-lc', `bun ${JSON.stringify(scriptPath)}`], { + const result = spawnSync(process.execPath, [scriptPath], { cwd: REPO_ROOT, env: { ...process.env, @@ -69,7 +69,7 @@ function runSessionTrackerScenario( if (result.status !== 0) { throw new Error( `child session-tracker scenario failed: ${JSON.stringify({ - command: `bun ${scriptPath}`, + command: `${process.execPath} ${scriptPath}`, status: result.status, signal: result.signal, error: result.error?.message ?? null, diff --git a/tests/unit/commands/tokens-command-auth-rotation.test.ts b/tests/unit/commands/tokens-command-auth-rotation.test.ts index 1ef6c716..9692148a 100644 --- a/tests/unit/commands/tokens-command-auth-rotation.test.ts +++ b/tests/unit/commands/tokens-command-auth-rotation.test.ts @@ -45,7 +45,7 @@ function runTokensCommandInChild(tempHome: string, args: string[]) { const scriptPath = path.join(tempHome, `tokens-child-${Date.now()}.mjs`); fs.writeFileSync(scriptPath, script, 'utf8'); - const result = spawnSync('/bin/bash', ['-lc', `bun ${JSON.stringify(scriptPath)}`], { + const result = spawnSync(process.execPath, [scriptPath], { cwd: REPO_ROOT, env: { ...process.env, @@ -60,7 +60,7 @@ function runTokensCommandInChild(tempHome: string, args: string[]) { if (result.status !== 0) { throw new Error( `child tokens command failed: ${JSON.stringify({ - command: `bun ${scriptPath}`, + command: `${process.execPath} ${scriptPath}`, status: result.status, signal: result.signal, error: result.error?.message ?? null, From 18dab369c1349cb2ebea517c83c703eb1a4b4232 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 04:30:40 +0000 Subject: [PATCH 47/59] chore(release): 7.71.0-dev.10 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6f24e746..e94f2b50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.9", + "version": "7.71.0-dev.10", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 80847b4e6b68eab95e08b0acadbce888799f30b0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 03:41:38 -0400 Subject: [PATCH 48/59] fix(cliproxy): align codex defaults with gpt-5.4 --- config/base-codex.settings.json | 8 +- src/cliproxy/codex-plan-compatibility.ts | 12 +-- src/cliproxy/model-catalog.ts | 74 ++++--------------- src/cliproxy/model-id-normalizer.ts | 24 ++++++ ...codex-plan-compatibility-reconcile.test.ts | 12 +-- .../cliproxy/codex-plan-compatibility.test.ts | 39 +++++----- ...x-reasoning-proxy-extended-context.test.ts | 6 +- .../cliproxy/composite-env-routing.test.ts | 6 +- .../cliproxy/env-builder-provider-url.test.ts | 16 ++-- .../env-resolver-codex-fallback.test.ts | 8 +- tests/unit/cliproxy/model-catalog.test.js | 19 +++++ .../unit/cliproxy/model-id-normalizer.test.ts | 9 +++ .../cliproxy/variant-update-service.test.ts | 14 ++-- ...cliproxy-stats-routes-model-update.test.ts | 4 +- 14 files changed, 130 insertions(+), 121 deletions(-) diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json index 5dae2d86..d3a5ecd4 100644 --- a/config/base-codex.settings.json +++ b/config/base-codex.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "gpt-5-codex", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5-codex", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-codex-mini" + "ANTHROPIC_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini" } } diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 95506089..5276f970 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -1,5 +1,6 @@ import { getDefaultAccount } from './account-manager'; import { getProviderCatalog } from './model-catalog'; +import { normalizeModelIdForProvider } from './model-id-normalizer'; import { fetchCodexQuota } from './quota-fetcher-codex'; import { getCachedQuota, setCachedQuota } from './quota-response-cache'; import type { CodexQuotaResult } from './quota-types'; @@ -7,8 +8,8 @@ import { info, warn } from '../utils/ui'; export type CodexPlanType = CodexQuotaResult['planType']; -const FREE_SAFE_DEFAULT_MODEL = 'gpt-5-codex'; -const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini'; +const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4'; +const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; @@ -19,7 +20,6 @@ const KNOWN_CODEX_MODELS = new Set( const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], ['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL], - ['gpt-5.4', FREE_SAFE_DEFAULT_MODEL], ]); export interface CodexRuntimeFallbackModelMap { @@ -52,13 +52,13 @@ function isKnownCodexModel(model: string): boolean { } export function normalizeCodexModelId(model: string): string { - return model + const stripped = model .trim() .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') .replace(CODEX_PAREN_SUFFIX_REGEX, '') .replace(CODEX_EFFORT_SUFFIX_REGEX, '') - .trim() - .toLowerCase(); + .trim(); + return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase(); } export function getDefaultCodexModel(): string { diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 8a4e4564..f4187873 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -186,56 +186,12 @@ export const MODEL_CATALOG: Partial> = codex: { provider: 'codex', displayName: 'Copilot Codex', - defaultModel: 'gpt-5-codex', + defaultModel: 'gpt-5.4', models: [ { - id: 'gpt-5-codex', - name: 'GPT-5 Codex', - description: 'Cross-plan safe Codex default', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5-codex-mini', - name: 'GPT-5 Codex Mini', - description: 'Faster and cheaper Codex option', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5-mini', - name: 'GPT-5 Mini', - description: 'Legacy mini model ID kept for backwards compatibility', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5.1-codex-mini', - name: 'GPT-5.1 Codex Mini', - description: 'Legacy fast Codex mini model', - thinking: { - type: 'levels', - levels: ['low', 'medium', 'high'], - maxLevel: 'high', - dynamicAllowed: false, - }, - }, - { - id: 'gpt-5.1-codex-max', - name: 'GPT-5.1 Codex Max', - description: 'Higher-effort Codex model with xhigh support', + id: 'gpt-5.4', + name: 'GPT-5.4', + description: 'Recommended Codex default for most coding and agentic tasks', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -244,13 +200,13 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'gpt-5.2-codex', - name: 'GPT-5.2 Codex', - description: 'Cross-plan Codex model with xhigh support', + id: 'gpt-5.4-mini', + name: 'GPT-5.4 Mini', + description: 'Fast, lower-cost Codex option for lighter tasks and haiku-tier routing', thinking: { type: 'levels', - levels: ['low', 'medium', 'high', 'xhigh'], - maxLevel: 'xhigh', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', dynamicAllowed: false, }, }, @@ -258,7 +214,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', tier: 'pro', - description: 'Paid Codex plans only', + description: 'Previous flagship coding model whose capabilities now power GPT-5.4', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -270,7 +226,8 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.3-codex-spark', name: 'GPT-5.3 Codex Spark', tier: 'pro', - description: 'Paid Codex plans only, ultra-fast coding model', + description: + 'Research preview model for ChatGPT Pro subscribers, optimized for near-instant coding iteration', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -279,10 +236,9 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'gpt-5.4', - name: 'GPT-5.4', - tier: 'pro', - description: 'Paid Codex plans only, latest GPT-5 family model', + id: 'gpt-5.2', + name: 'GPT-5.2', + description: 'Previous general-purpose Codex model', thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index d2719d2c..192a369f 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -27,6 +27,16 @@ const DENIED_ANTIGRAVITY_SONNET_45_REGEX = /claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const CODEX_LEGACY_MODEL_ALIASES: Readonly> = Object.freeze({ + 'gpt-5-codex': 'gpt-5.4', + 'gpt-5-codex-mini': 'gpt-5.4-mini', + 'gpt-5-mini': 'gpt-5.4-mini', + 'gpt-5.1-codex': 'gpt-5.2', + 'gpt-5.1-codex-mini': 'gpt-5.4-mini', + 'gpt-5.1-codex-max': 'gpt-5.4', + 'gpt-5.2-codex': 'gpt-5.2', + 'gpt-5.2-codex-mini': 'gpt-5.4-mini', +}); const IFLOW_LEGACY_MODEL_ALIASES: Readonly> = Object.freeze({ 'iflow-default': 'qwen3-coder-plus', 'kimi-k2.5': 'kimi-k2', @@ -92,6 +102,17 @@ export function stripCodexEffortSuffix(model: string): string { return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } +/** Normalize legacy Codex aliases to the current public Codex model IDs. */ +export function normalizeCodexLegacyModelAliases(model: string): string { + const trimmed = trimModelId(model); + const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); + const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; + if (!replacement) { + return trimmed; + } + return `${replacement}${suffix}`; +} + /** * Normalize known legacy iFlow model aliases to current upstream model IDs. * Preserves suffixes such as (budget) and [1m]. @@ -186,6 +207,9 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik if (isIFlowProvider(provider)) { return normalizeIFlowLegacyModelAliases(trimmedModel); } + if (isCodexProvider(provider)) { + return normalizeCodexLegacyModelAliases(trimmedModel); + } if (!isAntigravityProvider(provider)) return trimmedModel; const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index 4cdca89f..1f004389 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -9,7 +9,7 @@ afterEach(() => { mock.restore(); }); -function createCodexSettingsFixture(haikuModel: string = 'gpt-5-codex-mini'): { +function createCodexSettingsFixture(haikuModel: string = 'gpt-5.4-mini'): { tmpDir: string; settingsPath: string; } { @@ -80,7 +80,7 @@ describe('codex plan compatibility reconcile', () => { expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.3-codex-spark'); expect(errorSpy).toHaveBeenCalledWith( - 'Codex free plan detected. Keeping saved model "gpt-5.3-codex" in settings; runtime requests will fall back to "gpt-5-codex" when needed.' + 'Codex free plan detected. Keeping saved model "gpt-5.3-codex" in settings; runtime requests will fall back to "gpt-5.4" when needed.' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -115,7 +115,7 @@ describe('codex plan compatibility reconcile', () => { }; expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(errorSpy).toHaveBeenCalledWith( - 'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5-codex" with "ccs codex --config".' + 'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5.4" with "ccs codex --config".' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -195,7 +195,7 @@ describe('codex plan compatibility reconcile', () => { }; expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(errorSpy).toHaveBeenCalledWith( - 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5.4" via "ccs codex --config".' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -234,9 +234,9 @@ describe('codex plan compatibility reconcile', () => { env: Record; }; expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); expect(errorSpy).toHaveBeenCalledWith( - 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5.4" via "ccs codex --config".' ); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index 23bb7bc9..2fc1266a 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -9,22 +9,21 @@ import { describe('codex plan compatibility', () => { it('uses a cross-plan safe Codex default', () => { - expect(getDefaultCodexModel()).toBe('gpt-5-codex'); - expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5-codex'); + expect(getDefaultCodexModel()).toBe('gpt-5.4'); + expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5.4'); }); it('maps paid-only free-plan models to safe fallbacks', () => { - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBe('gpt-5-codex'); - expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5-codex-mini'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5.4-mini'); }); it('does not rewrite cross-plan or already-safe Codex models', () => { - expect(getFreePlanFallbackCodexModel('gpt-5-codex')).toBeNull(); - expect(getFreePlanFallbackCodexModel('gpt-5.2-codex')).toBeNull(); - expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.4-mini')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.2')).toBeNull(); }); it('detects upstream Codex model_not_supported responses', () => { @@ -54,25 +53,27 @@ describe('codex plan compatibility', () => { it('resolves runtime fallbacks without retrying the rejected model again', () => { expect( resolveRuntimeCodexFallbackModel({ - requestedModel: 'gpt-5.4', - modelMap: { defaultModel: 'gpt-5-codex' }, + requestedModel: 'gpt-5.3-codex', + modelMap: { defaultModel: 'gpt-5.4' }, }) - ).toBe('gpt-5-codex'); + ).toBe('gpt-5.4'); expect( resolveRuntimeCodexFallbackModel({ - requestedModel: 'gpt-5.4', + requestedModel: 'gpt-5.3-codex', modelMap: { defaultModel: 'gpt-5.4', - haikuModel: 'gpt-5-codex-mini', + haikuModel: 'gpt-5.4-mini', }, - excludeModels: ['gpt-5-codex'], + excludeModels: ['gpt-5.4'], }) - ).toBe('gpt-5-codex-mini'); + ).toBe('gpt-5.4-mini'); }); - it('tracks Codex thinking caps for current safe defaults and paid models', () => { - expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high'); + it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => { + expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh'); + expect(getModelMaxLevel('codex', 'gpt-5.4-mini')).toBe('high'); + expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high'); expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh'); diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 5a6dc3a2..569b9dc3 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -306,11 +306,11 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect(firstResponse.statusCode).toBe(200); expect(secondResponse.statusCode).toBe(200); - expect(firstResponse.body.model).toBe('gpt-5-codex'); + expect(firstResponse.body.model).toBe('gpt-5.4-mini'); expect(firstResponse.body.effort).toBe('high'); - expect(secondResponse.body.model).toBe('gpt-5-codex'); + expect(secondResponse.body.model).toBe('gpt-5.4-mini'); expect(secondResponse.body.effort).toBe('high'); - expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']); + expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-mini']); expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']); }); diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts index 0ef51fc7..3bd86e6c 100644 --- a/tests/unit/cliproxy/composite-env-routing.test.ts +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -8,7 +8,7 @@ import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resol const tiers = { opus: { provider: 'agy' as const, model: 'claude-opus-4-6-thinking' }, sonnet: { provider: 'gemini' as const, model: 'gemini-2.5-pro' }, - haiku: { provider: 'codex' as const, model: 'gpt-5.1-codex-mini' }, + haiku: { provider: 'codex' as const, model: 'gpt-5.4-mini' }, }; describe('buildClaudeEnvironment - composite remote routing', () => { @@ -82,7 +82,7 @@ describe('buildClaudeEnvironment - composite remote routing', () => { compositeTiers: { opus: { provider: 'agy', model: 'claude-opus-4.6-thinking' }, sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' }, - haiku: { provider: 'codex', model: 'gpt-5.1-codex-mini' }, + haiku: { provider: 'codex', model: 'gpt-5.4-mini' }, }, compositeDefaultTier: 'opus', }); @@ -90,6 +90,6 @@ describe('buildClaudeEnvironment - composite remote routing', () => { expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.1-codex-mini(?:-medium)?$/); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.4-mini(?:-medium)?$/); }); }); diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index bc7b71e1..7f96cde9 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -47,7 +47,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', }); const env = getEffectiveEnvVars('codex', 8317, settingsPath); @@ -55,7 +55,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; @@ -63,7 +63,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); it('rewrites wrong local provider path to the requested provider', () => { @@ -73,7 +73,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', }); const env = getEffectiveEnvVars('codex', 8317, settingsPath); @@ -438,7 +438,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { ANTHROPIC_MODEL: ' gpt-5.3-codex-xhigh ', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', }, presets: [ { @@ -446,7 +446,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { default: 'gpt-5.3-codex-xhigh', opus: 'gpt-5.3-codex-xhigh', sonnet: 'gpt-5.3-codex-high', - haiku: 'gpt-5-mini-medium', + haiku: 'gpt-5.4-mini-medium', }, ], }, @@ -464,11 +464,11 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex'); expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex'); expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.haiku).toBe('gpt-5-mini'); + expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini'); }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 36036cfd..39ee4e6d 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -87,7 +87,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { defaultModel: 'gpt-5.3-codex-high', opusModel: 'gpt-5.3-codex-xhigh', sonnetModel: 'gpt-5.3-codex-high', - haikuModel: 'gpt-5-mini-medium', + haikuModel: 'gpt-5.4-mini-medium', }); const env = buildClaudeEnvironment({ @@ -101,7 +101,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex(high)'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex(xhigh)'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex(high)'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini(medium)'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini(medium)'); }); it('keeps codex effort aliases when reasoning proxy is active', () => { @@ -109,7 +109,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { defaultModel: 'gpt-5.3-codex-high', opusModel: 'gpt-5.3-codex-xhigh', sonnetModel: 'gpt-5.3-codex-high', - haikuModel: 'gpt-5-mini-medium', + haikuModel: 'gpt-5.4-mini-medium', }); const env = buildClaudeEnvironment({ @@ -124,7 +124,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-high'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex'); }); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 17068475..3e8f3751 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -186,6 +186,25 @@ describe('Model Catalog', () => { }); }); + describe('Codex models', () => { + it('has correct default model', () => { + const { MODEL_CATALOG } = modelCatalog; + assert.strictEqual(MODEL_CATALOG.codex.defaultModel, 'gpt-5.4'); + }); + + it('advertises the current official Codex model set', () => { + const { MODEL_CATALOG } = modelCatalog; + const ids = MODEL_CATALOG.codex.models.map((m) => m.id); + assert.deepStrictEqual(ids, [ + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex', + 'gpt-5.3-codex-spark', + 'gpt-5.2', + ]); + }); + }); + describe('supportsModelConfig', () => { it('returns true for agy', () => { const { supportsModelConfig } = modelCatalog; diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index 6d4cc361..d7e969cf 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -8,6 +8,7 @@ import { migrateDeniedAntigravityModelAliases, normalizeClaudeDottedMajorMinor, normalizeClaudeDottedThinkingMajorMinor, + normalizeCodexLegacyModelAliases, normalizeModelIdForProvider, normalizeModelIdForRouting, normalizeModelEnvVarsForProvider, @@ -115,6 +116,14 @@ describe('model-id-normalizer', () => { expect(canonicalizeModelIdForProvider('minimax-m2.5', 'iflow')).toBe('qwen3-coder-plus'); expect(canonicalizeModelIdForProvider('kimi-k2.5', 'gemini')).toBe('kimi-k2.5'); }); + + it('normalizes legacy codex aliases to the current supported model IDs', () => { + expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]'); + expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2'); + expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini'); + expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4'); + }); }); describe('env normalization', () => { diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index fe63fb7d..7e98a4ff 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -100,7 +100,7 @@ cliproxy: expect(result.success).toBe(true); expect(result.variant?.provider).toBe('codex'); - expect(result.variant?.model).toBe('gpt-5.1-codex-mini'); + expect(result.variant?.model).toBe('gpt-5.4-mini'); const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json'); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { @@ -109,10 +109,10 @@ cliproxy: }; expect(settings.env.ANTHROPIC_BASE_URL).toContain('/api/provider/codex'); - expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); expect(settings.env.CUSTOM_FLAG).toBe('keep-me'); expect(settings.hooks.PreToolUse.length).toBe(1); @@ -134,7 +134,7 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' }); expect(modelOnly.success).toBe(true); @@ -145,6 +145,6 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 84cba3c9..538152a4 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -137,7 +137,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => { ANTHROPIC_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini', }); const response = await fetch(`${baseUrl}/api/cliproxy/models/codex`, { @@ -156,7 +156,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); it('canonicalizes legacy iflow model IDs to supported upstream IDs', async () => { From e7297bd66b56f6679c888dedf0cadecc5ac68f98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 07:48:41 +0000 Subject: [PATCH 49/59] chore(release): 7.71.0-dev.11 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e94f2b50..a0dfd17c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.10", + "version": "7.71.0-dev.11", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 9dc6374851718bb971175a3ebe0ff611d584847d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 12:49:51 -0400 Subject: [PATCH 50/59] fix(cliproxy): delegate gemini refresh to upstream --- src/cliproxy/auth/gemini-token-refresh.ts | 437 ------------------ .../auth/provider-refreshers/index.ts | 35 +- src/cliproxy/auth/token-manager.ts | 15 +- src/cliproxy/provider-capabilities.ts | 2 +- src/cliproxy/quota-fetcher-gemini-cli.ts | 336 ++++++++++---- .../gemini-refresh-delegation.test.ts | 72 +++ .../cliproxy/provider-capabilities.test.ts | 1 + .../cliproxy/quota-fetcher-gemini-cli.test.ts | 268 ++++------- 8 files changed, 410 insertions(+), 756 deletions(-) delete mode 100644 src/cliproxy/auth/gemini-token-refresh.ts create mode 100644 tests/unit/cliproxy/gemini-refresh-delegation.test.ts diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts deleted file mode 100644 index 543dc657..00000000 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Gemini Token Refresh - * - * Handles proactive token validation and refresh for Gemini OAuth tokens. - * Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use. - * - * Token sources (priority order): - * 1. CLIProxy auth dir (~/.ccs/cliproxy/auth/) - CCS-managed tokens - * 2. Standard Gemini CLI (~/.gemini/oauth_creds.json) - backward compatibility - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { getProviderAuthDir } from '../config-generator'; -import { getDefaultAccount, getProviderAccounts } from '../account-manager'; -import { isTokenFileForProvider } from './token-manager'; - -/** Google OAuth token endpoint */ -const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; - -/** Refresh tokens 5 minutes before expiry */ -const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; - -const GEMINI_CLIENT_ID_ENV_KEYS = ['CCS_GEMINI_OAUTH_CLIENT_ID', 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID']; -const GEMINI_CLIENT_SECRET_ENV_KEYS = [ - 'CCS_GEMINI_OAUTH_CLIENT_SECRET', - 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET', -]; - -/** Gemini oauth_creds.json structure */ -interface GeminiOAuthCreds { - access_token: string; - refresh_token?: string; - expiry_date?: number; // Unix timestamp in milliseconds - scope?: string; - token_type?: string; - id_token?: string; - client_id?: string; - client_secret?: string; - token_uri?: string; -} - -/** Gemini credentials with source path for write-back */ -interface GeminiCredsWithSource { - creds: GeminiOAuthCreds; - sourcePath: string; -} - -/** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */ -interface CliproxyGeminiToken { - token: { - access_token: string; - refresh_token?: string; - expiry?: number; // Unix timestamp in milliseconds - client_id?: string; - client_secret?: string; - token_uri?: string; - }; - project_id: string; - email: string; - type: 'gemini'; -} - -/** Token refresh response from Google */ -interface TokenRefreshResponse { - access_token?: string; - expires_in?: number; - token_type?: string; - error?: string; - error_description?: string; -} - -interface GoogleOAuthClientCredentials { - clientId: string; - clientSecret: string; - tokenUrl: string; -} - -/** - * Get path to Gemini OAuth credentials file - */ -export function getGeminiOAuthPath(): string { - return path.join(os.homedir(), '.gemini', 'oauth_creds.json'); -} - -/** - * Map CLIProxyAPI token format to internal GeminiOAuthCreds format - */ -function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCreds { - return { - access_token: cliproxy.token.access_token, - refresh_token: cliproxy.token.refresh_token, - expiry_date: cliproxy.token.expiry, - token_type: 'Bearer', - client_id: cliproxy.token.client_id, - client_secret: cliproxy.token.client_secret, - token_uri: cliproxy.token.token_uri, - }; -} - -/** - * Validate CLIProxyAPI token structure has required fields - */ -function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken { - if (typeof data !== 'object' || data === null) return false; - const obj = data as Record; - if (obj.type !== 'gemini') return false; - if (typeof obj.token !== 'object' || obj.token === null) return false; - const token = obj.token as Record; - return typeof token.access_token === 'string'; -} - -function getFirstEnvValue(keys: readonly string[]): string | undefined { - for (const key of keys) { - const value = process.env[key]?.trim(); - if (value) { - return value; - } - } - return undefined; -} - -function resolveGeminiRefreshCredentials(creds: GeminiOAuthCreds): { - credentials?: GoogleOAuthClientCredentials; - error?: string; -} { - const clientId = creds.client_id?.trim() || getFirstEnvValue(GEMINI_CLIENT_ID_ENV_KEYS); - const clientSecret = - creds.client_secret?.trim() || getFirstEnvValue(GEMINI_CLIENT_SECRET_ENV_KEYS); - - if (!clientId || !clientSecret) { - return { - error: - 'Gemini token refresh unavailable: missing OAuth client credentials in the token file. ' + - 'Re-authenticate with CLIProxy or set CCS_GEMINI_OAUTH_CLIENT_ID and CCS_GEMINI_OAUTH_CLIENT_SECRET.', - }; - } - - return { - credentials: { - clientId, - clientSecret, - tokenUrl: creds.token_uri?.trim() || GOOGLE_TOKEN_URL, - }, - }; -} - -/** - * Read Gemini token from CLIProxy auth directory - * Returns credentials with source path, or null if no valid token found - */ -function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null { - const authDir = getProviderAuthDir('gemini'); - if (!fs.existsSync(authDir)) return null; - - let tokenPath: string | null = null; - const normalizedAccountId = accountId?.trim(); - const accounts = getProviderAccounts('gemini'); - - // Account-specific refresh path (used by background worker) - if (normalizedAccountId) { - const targetAccount = accounts.find((account) => account.id === normalizedAccountId); - if (!targetAccount) { - return null; - } - - tokenPath = path.join(authDir, targetAccount.tokenFile); - } - - if (!normalizedAccountId) { - // Try to find default account's token file - const defaultAccount = getDefaultAccount('gemini'); - if (defaultAccount) { - tokenPath = path.join(authDir, defaultAccount.tokenFile); - if (!fs.existsSync(tokenPath)) tokenPath = null; - } - - // Fallback: find any gemini account token file - if (!tokenPath && accounts.length > 0) { - tokenPath = path.join(authDir, accounts[0].tokenFile); - if (!fs.existsSync(tokenPath)) tokenPath = null; - } - - // Last fallback: scan directory for gemini token files - if (!tokenPath) { - try { - const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json')); - for (const file of files) { - const filePath = path.join(authDir, file); - if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) { - tokenPath = filePath; - break; - } - } - } catch { - // Directory read failed - continue to return null - return null; - } - } - } - - if (!tokenPath) return null; - - try { - const content = fs.readFileSync(tokenPath, 'utf8'); - const data: unknown = JSON.parse(content); - - // Validate CLIProxyAPI format with proper type checking - if (isValidCliproxyToken(data)) { - return { - creds: mapCliproxyToGeminiCreds(data), - sourcePath: tokenPath, - }; - } - - return null; - } catch { - return null; - } -} - -/** - * Read Gemini OAuth credentials - * Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json - * Returns credentials with source path for correct write-back - */ -function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null { - // 1. Try CLIProxy auth directory first (CCS-managed tokens) - const cliproxyResult = readCliproxyGeminiCreds(accountId); - if (cliproxyResult) { - return cliproxyResult; - } - - // Account-scoped refresh is only supported for CLIProxy account files. - // Do not fall back to ~/.gemini for a specific accountId. - if (accountId?.trim()) { - return null; - } - - // 2. Fall back to standard Gemini CLI location - const oauthPath = getGeminiOAuthPath(); - if (!fs.existsSync(oauthPath)) { - return null; - } - try { - const content = fs.readFileSync(oauthPath, 'utf8'); - return { - creds: JSON.parse(content) as GeminiOAuthCreds, - sourcePath: oauthPath, - }; - } catch { - return null; - } -} - -/** - * Write updated credentials to CLIProxy token file - * Preserves existing fields (email, project_id), only updates token subfields - */ -function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): string | undefined { - try { - const existing = JSON.parse(fs.readFileSync(tokenPath, 'utf8')); - const updated = { - ...existing, - token: { - ...existing.token, - access_token: creds.access_token, - refresh_token: creds.refresh_token, - expiry: creds.expiry_date, - }, - }; - fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), { mode: 0o600 }); - return undefined; - } catch (err) { - return err instanceof Error ? err.message : 'Failed to write credentials'; - } -} - -/** - * Write Gemini OAuth credentials - * Writes back to the specified source location (CLIProxy or ~/.gemini) - * @param creds - The credentials to write - * @param sourcePath - The path where credentials were originally read from - * @returns error message if write failed, undefined on success - */ -function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | undefined { - const geminiOAuthPath = getGeminiOAuthPath(); - - // If source is not the standard Gemini path, write to CLIProxy format - if (sourcePath !== geminiOAuthPath) { - return writeCliproxyGeminiCreds(sourcePath, creds); - } - - // Otherwise write to standard Gemini CLI location - const dir = path.dirname(geminiOAuthPath); - try { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(geminiOAuthPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); - return undefined; - } catch (err) { - return err instanceof Error ? err.message : 'Failed to write credentials'; - } -} - -/** - * Check if Gemini token is expired or expiring soon - */ -export function isGeminiTokenExpiringSoon(accountId?: string): boolean { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.access_token) { - return true; // No token = needs auth - } - if (!result.creds.expiry_date) { - return false; // No expiry info = assume valid - } - const expiresIn = result.creds.expiry_date - Date.now(); - return expiresIn < REFRESH_LEAD_TIME_MS; -} - -/** - * Refresh Gemini access token using refresh_token - * @param accountId Optional account ID for account-scoped refresh - * @returns Result with success status, optional error, and expiry time - */ -export async function refreshGeminiToken(accountId?: string): Promise<{ - success: boolean; - error?: string; - expiresAt?: number; -}> { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.refresh_token) { - return { success: false, error: 'No refresh token available' }; - } - - const { creds, sourcePath } = result; - const resolvedCredentials = resolveGeminiRefreshCredentials(creds); - if (!resolvedCredentials.credentials) { - return { success: false, error: resolvedCredentials.error }; - } - - const { clientId, clientSecret, tokenUrl } = resolvedCredentials.credentials; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - const response = await fetch(tokenUrl, { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: creds.refresh_token as string, // Already validated above - client_id: clientId, - client_secret: clientSecret, - }).toString(), - }); - - clearTimeout(timeoutId); - - const data = (await response.json()) as TokenRefreshResponse; - - if (!response.ok || data.error) { - return { - success: false, - error: data.error_description || data.error || `OAuth error: ${response.status}`, - }; - } - - if (!data.access_token) { - return { success: false, error: 'No access_token in response' }; - } - - // Update credentials file with new token - const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; - const updatedCreds: GeminiOAuthCreds = { - ...creds, - access_token: data.access_token, - expiry_date: expiresAt, - }; - const writeError = writeGeminiCreds(updatedCreds, sourcePath); - if (writeError) { - return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; - } - - return { success: true, expiresAt }; - } catch (err) { - clearTimeout(timeoutId); - if (err instanceof Error && err.name === 'AbortError') { - return { success: false, error: 'Token refresh timeout' }; - } - return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; - } -} - -/** - * Ensure Gemini token is valid, refreshing if needed - * @param verbose Log progress if true - * @param accountId Optional account ID for account-scoped refresh - * @returns true if token is valid (or was refreshed), false if refresh failed - */ -export async function ensureGeminiTokenValid( - verbose = false, - accountId?: string -): Promise<{ - valid: boolean; - refreshed: boolean; - error?: string; -}> { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.access_token) { - return { valid: false, refreshed: false, error: 'No Gemini credentials found' }; - } - - if (!isGeminiTokenExpiringSoon(accountId)) { - return { valid: true, refreshed: false }; - } - - // Token is expired or expiring soon - try to refresh - if (verbose) { - console.log('[i] Gemini token expired or expiring soon, refreshing...'); - } - - const refreshResult = await refreshGeminiToken(accountId); - if (refreshResult.success) { - if (verbose) { - console.log('[OK] Gemini token refreshed successfully'); - } - return { valid: true, refreshed: true }; - } - - return { valid: false, refreshed: false, error: refreshResult.error }; -} diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 207ac0fa..d5cfea3b 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -4,8 +4,7 @@ * Exports refresh functions for each OAuth provider. * * Refresh responsibility: - * - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth) - * - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow, kimi + * - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi * (CLIProxyAPIPlus handles refresh automatically in background) * - Not implemented: claude */ @@ -16,7 +15,6 @@ import { getTokenRefreshOwnership, isRefreshDelegatedToCLIProxy, } from '../../provider-capabilities'; -import { refreshGeminiToken } from '../gemini-token-refresh'; /** Token refresh result */ export interface ProviderRefreshResult { @@ -66,19 +64,18 @@ export async function refreshToken( }; } - if (provider === 'gemini') { - return await refreshGeminiTokenWrapper(normalizedAccountId); - } - const ownership = getTokenRefreshOwnership(provider); switch (ownership) { case 'cliproxy': // CLIProxyAPIPlus handles refresh for these providers automatically. // No action needed from CCS — report success with delegated flag. return { success: true, delegated: true }; - case 'unsupported': case 'ccs': - // Non-gemini CCS-owned refresh paths are not implemented yet. + return { + success: false, + error: `Token refresh not yet implemented for ${provider}`, + }; + case 'unsupported': return { success: false, error: `Token refresh not yet implemented for ${provider}`, @@ -87,23 +84,3 @@ export async function refreshToken( return assertNever(ownership); } } - -/** - * Wrapper for Gemini token refresh - * Converts gemini-token-refresh.ts format to provider-refreshers format - */ -async function refreshGeminiTokenWrapper(accountId: string): Promise { - const result = await refreshGeminiToken(accountId); - - if (!result.success) { - return { - success: false, - error: result.error, - }; - } - - return { - success: true, - expiresAt: result.expiresAt, - }; -} diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 721012c1..a41ae62b 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -14,6 +14,7 @@ import { getProviderAuthDir } from '../config-generator'; import { getProviderAccounts, getDefaultAccount } from '../account-manager'; import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops'; import { buildEmailBackedAccountId } from '../accounts/email-account-identity'; +import { getTokenRefreshOwnership } from '../provider-capabilities'; import { AuthStatus, PROVIDER_AUTH_PREFIXES, @@ -507,14 +508,16 @@ export function displayAuthStatus(): void { */ export async function ensureTokenValid( provider: CLIProxyProvider, - verbose = false + _verbose = false ): Promise<{ valid: boolean; refreshed: boolean; error?: string }> { - if (provider === 'gemini') { - const { ensureGeminiTokenValid } = await import('./gemini-token-refresh'); - return ensureGeminiTokenValid(verbose); + if (getTokenRefreshOwnership(provider) === 'ccs') { + return { + valid: false, + refreshed: false, + error: `CCS-managed token validation is not available for ${provider}`, + }; } - // For CLIProxy-delegated providers, token refresh is handled by CLIProxyAPIPlus. - // CCS only verifies the token file exists (authentication state). + // Runtime-managed providers refresh upstream. CCS only verifies auth material exists locally. return { valid: isAuthenticated(provider), refreshed: false }; } diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index ebbe2773..bafd436a 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -33,7 +33,7 @@ export const PROVIDER_CAPABILITIES: Record { + const bodyText = await response.text(); + return { + status: response.status, + bodyText, + json: safeParseJson(bodyText), + viaManagement, + }; +} + +function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean { + const provider = normalizeStringValue(file.provider ?? file.type); + if (provider !== 'gemini') { + return false; + } + + const email = normalizeStringValue(file.email); + const normalizedAccountId = accountId.trim().toLowerCase(); + if (email?.toLowerCase() === normalizedAccountId) { + return true; + } + + const normalizedName = normalizeStringValue(file.name); + if (!normalizedName) { + return false; + } + + const normalizedFileName = normalizedName.toLowerCase(); + const sanitizedAccount = sanitizeEmail(accountId).toLowerCase(); + return ( + normalizedFileName === `gemini-${sanitizedAccount}.json` || + normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) || + normalizedFileName.includes(sanitizedAccount) + ); +} + +async function findManagedGeminiAuthIndex(accountId: string): Promise { + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + + try { + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { + signal: controller.signal, + headers: buildManagementHeaders(target), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { files?: ManagementAuthFile[] }; + const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId)); + return match?.auth_index ?? null; + } catch { + clearTimeout(timeoutId); + return null; + } +} + +async function performManagedGeminiRequest( + accountId: string, + url: string, + body: string +): Promise { + const authIndex = await findManagedGeminiAuthIndex(accountId); + if (authIndex === null || authIndex === undefined) { + return null; + } + + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + + try { + const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), { + method: 'POST', + signal: controller.signal, + headers: buildManagementHeaders(target, { + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + auth_index: authIndex, + method: 'POST', + url, + header: { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + }, + data: body, + }), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const apiResponse = (await response.json()) as ManagementApiCallResponse; + const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : ''; + return { + status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500, + bodyText, + json: safeParseJson(bodyText), + viaManagement: true, + }; + } catch { + clearTimeout(timeoutId); + return null; + } +} + +async function performGeminiCliRequest( + accountId: string, + accessToken: string, + url: string, + body: string, + preferManagement = false +): Promise { + if (preferManagement) { + const managedResult = await performManagedGeminiRequest(accountId, url, body); + if (managedResult) { + return managedResult; + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + + try { + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body, + }); + clearTimeout(timeoutId); + + const directResult = await readManagedResponse(response, false); + if (directResult.status !== 401) { + return directResult; + } + + const managedResult = await performManagedGeminiRequest(accountId, url, body); + return managedResult ?? directResult; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +} + /** * Read auth data from Gemini CLI auth file * Supports multiple file naming conventions and JSON structures @@ -308,42 +495,40 @@ function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | nu } async function fetchGeminiCliSupplementary( + accountId: string, accessToken: string, projectId: string, verbose: boolean ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const requestBody = JSON.stringify({ + cloudaicompanionProject: projectId, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: projectId, + }, + }); try { - const response = await fetch(GEMINI_CLI_CODE_ASSIST_URL, { - method: 'POST', - signal: controller.signal, - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - cloudaicompanionProject: projectId, - metadata: { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - duetProject: projectId, - }, - }), - }); + const response = await performGeminiCliRequest( + accountId, + accessToken, + GEMINI_CLI_CODE_ASSIST_URL, + requestBody + ); - clearTimeout(timeoutId); - - if (!response.ok) { + if (response.status !== 200) { if (verbose) { - console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`); + const source = response.viaManagement ? 'managed' : 'direct'; + console.error( + `[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}` + ); } return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; } - const payload = (await response.json()) as GeminiCliCodeAssistResponse; + const payload = response.json as GeminiCliCodeAssistResponse | null; return { tierLabel: resolveGeminiCliTierLabel(payload), tierId: resolveGeminiCliTierId(payload), @@ -351,7 +536,6 @@ async function fetchGeminiCliSupplementary( normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)), }; } catch (error) { - clearTimeout(timeoutId); if (verbose) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`); @@ -680,41 +864,39 @@ async function fetchWithAuthData( }); } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); const supplementaryPromise = fetchGeminiCliSupplementary( + accountId, authData.accessToken, authData.projectId, verbose ); + const requestBody = JSON.stringify({ project: authData.projectId }); try { - const response = await fetch(GEMINI_CLI_QUOTA_URL, { - method: 'POST', - signal: controller.signal, - headers: { - Authorization: `Bearer ${authData.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ project: authData.projectId }), - }); + const response = await performGeminiCliRequest( + accountId, + authData.accessToken, + GEMINI_CLI_QUOTA_URL, + requestBody, + authData.isExpired + ); - clearTimeout(timeoutId); + if (verbose) { + const source = response.viaManagement ? 'managed' : 'direct'; + console.error(`[i] Gemini CLI API status via ${source}: ${response.status}`); + } - if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`); - - if (!response.ok) { - const bodyText = await response.text(); + if (response.status !== 200) { return buildGeminiCliHttpFailureResult( accountId, authData.projectId, response.status, - bodyText + response.bodyText ); } - const data = (await response.json()) as GeminiCliQuotaResponse; - const rawBuckets = data.buckets || []; + const data = response.json as GeminiCliQuotaResponse | null; + const rawBuckets = data?.buckets || []; const buckets = buildGeminiCliBuckets(rawBuckets); const supplementary = await supplementaryPromise; @@ -744,7 +926,6 @@ async function fetchWithAuthData( accountId, }; } catch (err) { - clearTimeout(timeoutId); const errorMsg = err instanceof Error && err.name === 'AbortError' ? 'Request timeout' @@ -778,7 +959,7 @@ export async function fetchGeminiCliQuota( ): Promise { if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`); - let authData = readGeminiCliAuthData(accountId); + const authData = readGeminiCliAuthData(accountId); if (!authData) { const error = 'Auth file not found for Gemini account'; if (verbose) console.error(`[!] Error: ${error}`); @@ -790,62 +971,15 @@ export async function fetchGeminiCliQuota( }); } - // Proactive refresh: refresh if expired OR expiring within 5 minutes - const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; - const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); - const shouldRefresh = - authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; - let refreshedBeforeQuotaFetch = false; - - if (shouldRefresh) { - if (verbose) - console.error( - authData.isExpired - ? '[i] Token expired, refreshing...' - : '[i] Token expiring soon, proactive refresh...' - ); - const refreshResult = await refreshGeminiToken(accountId); - - if (refreshResult.success) { - refreshedBeforeQuotaFetch = true; - if (verbose) console.error('[i] Token refreshed successfully'); - // Re-read auth data after successful refresh - const refreshedAuthData = readGeminiCliAuthData(accountId); - if (refreshedAuthData) { - authData = refreshedAuthData; - } - } else if (authData.isExpired) { - // Only fail if token is actually expired (not just expiring soon) - const error = refreshResult.error || 'Token refresh failed'; - if (verbose) console.error(`[!] Refresh failed: ${error}`); - return buildGeminiCliFailureResult(accountId, authData.projectId, { - error, - errorCode: 'reauth_required', - errorDetail: error, - actionHint: 'Run ccs gemini --auth to reconnect this account.', - needsReauth: true, - retryable: false, - }); - } - // If proactive refresh fails but token isn't expired yet, continue with existing token + if (authData.isExpired && verbose) { + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); + const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown'; + console.error( + `[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.` + ); } - // First attempt with current token - const result = await fetchWithAuthData(authData, accountId, verbose); - - // Retry once with an account-scoped refresh when the quota endpoint rejects auth. - if (result.needsReauth && !refreshedBeforeQuotaFetch) { - if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(accountId); - if (refreshResult.success) { - const refreshedAuthData = readGeminiCliAuthData(accountId); - if (refreshedAuthData) { - return await fetchWithAuthData(refreshedAuthData, accountId, verbose); - } - } - } - - return result; + return await fetchWithAuthData(authData, accountId, verbose); } /** diff --git a/tests/unit/cliproxy/gemini-refresh-delegation.test.ts b/tests/unit/cliproxy/gemini-refresh-delegation.test.ts new file mode 100644 index 00000000..7a89a416 --- /dev/null +++ b/tests/unit/cliproxy/gemini-refresh-delegation.test.ts @@ -0,0 +1,72 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +describe('Gemini refresh delegation', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + let moduleVersion = 0; + + beforeEach(() => { + moduleVersion += 1; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-delegation-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + }); + + it('treats Gemini as runtime-managed even when the local token lacks OAuth client metadata', async () => { + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?gemini-delegation-config=${moduleVersion}` + ); + const { ensureTokenValid } = await import( + `../../../src/cliproxy/auth/token-manager?gemini-delegation-manager=${moduleVersion}` + ); + + const authDir = getProviderAuthDir('gemini'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'gemini-delegated.json'), + JSON.stringify( + { + type: 'gemini', + email: 'delegated@example.com', + project_id: 'delegated-project', + token: { + access_token: 'expired-access-token', + refresh_token: 'still-present-refresh-token', + expiry: Date.now() - 60_000, + }, + }, + null, + 2 + ) + ); + + const result = await ensureTokenValid('gemini'); + + expect(result).toEqual({ + valid: true, + refreshed: false, + }); + }); +}); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index 1f574e26..7c462b6d 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -118,6 +118,7 @@ describe('provider-capabilities', () => { expect(getOAuthCallbackPort('cursor')).toBeNull(); expect(getOAuthCallbackPort('gitlab')).toBe(17171); expect(getOAuthCallbackPort('gemini')).toBe(8085); + expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy'); expect(getProviderDisplayName('agy')).toBe('Antigravity'); expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 86271f88..f4481a43 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -13,18 +13,16 @@ import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; - const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + const MANAGEMENT_AUTH_FILES_URL = 'http://127.0.0.1:8317/v0/management/auth-files'; + const MANAGEMENT_API_CALL_URL = 'http://127.0.0.1:8317/v0/management/api-call'; let tempHome: string; let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; - let originalGeminiClientId: string | undefined; - let originalGeminiClientSecret: string | undefined; let moduleVersion = 0; let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets; let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota; let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId; let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports; - let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { @@ -47,9 +45,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'access-token', refresh_token: 'refresh-token', expiry: Date.now() + 60 * 60 * 1000, - client_id: 'test-client-id', - client_secret: 'test-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, ...overrides, }); @@ -60,12 +55,7 @@ describe('Gemini CLI Quota Fetcher', () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); originalCcsHome = process.env.CCS_HOME; originalCcsDir = process.env.CCS_DIR; - originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; process.env.CCS_HOME = tempHome; - - delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; delete process.env.CCS_DIR; const configGenerator = await import( @@ -79,9 +69,6 @@ describe('Gemini CLI Quota Fetcher', () => { } = await import( `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` )); - ({ refreshGeminiToken } = await import( - `../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}` - )); ({ getProviderAuthDir } = configGenerator); }); @@ -100,18 +87,6 @@ describe('Gemini CLI Quota Fetcher', () => { } else { process.env.CCS_DIR = originalCcsDir; } - - if (originalGeminiClientId === undefined) { - delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - } else { - process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId; - } - - if (originalGeminiClientSecret === undefined) { - delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; - } else { - process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret; - } }); describe('resolveGeminiCliProjectId', () => { @@ -520,14 +495,6 @@ describe('Gemini CLI Quota Fetcher', () => { }, }, }, - { - url: GOOGLE_TOKEN_URL, - method: 'POST', - status: 400, - response: { - error: 'invalid_grant', - }, - }, ]); const result = await fetchGeminiCliQuota('reauth@example.com'); @@ -667,7 +634,7 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.errorDetail).toBe('[HTML error response omitted]'); }); - it('refreshes the requested Gemini account instead of the default account', async () => { + it('uses the requested Gemini account when delegating auth recovery to CLIProxy management', async () => { writeGeminiToken( { type: 'gemini', @@ -677,9 +644,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'default-access-token', refresh_token: 'default-refresh-token', expiry: Date.now() + 60 * 60 * 1000, - client_id: 'default-client-id', - client_secret: 'default-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-default.json' @@ -694,9 +658,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'target-stale-token', refresh_token: 'target-refresh-token', expiry: Date.now() - 1000, - client_id: 'target-client-id', - client_secret: 'target-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-target.json' @@ -704,16 +665,37 @@ describe('Gemini CLI Quota Fetcher', () => { mockFetch([ { - url: GOOGLE_TOKEN_URL, + url: MANAGEMENT_AUTH_FILES_URL, + response: { + files: [ + { + auth_index: 'target-auth-index', + provider: 'gemini', + email: 'target@example.com', + name: 'target@example.com-gen-lang-client-target-project.json', + }, + ], + }, + }, + { + url: MANAGEMENT_API_CALL_URL, method: 'POST', - response: { access_token: 'target-fresh-token', expires_in: 1800 }, + response: { + status_code: 200, + body: JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }), + }, }, { url: GEMINI_QUOTA_URL, method: 'POST', - status: 200, + status: 401, response: { - buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, }, }, { @@ -728,14 +710,14 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.success).toBe(true); - const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); - expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); - expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); - expect(refreshRequest.body).not.toContain('default-refresh-token'); - expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + const [, managedLookupRequest, managedQuotaRequest] = getCapturedFetchRequests(); + expect(managedLookupRequest.url).toBe(MANAGEMENT_AUTH_FILES_URL); + expect(managedQuotaRequest.url).toBe(MANAGEMENT_API_CALL_URL); + expect(managedQuotaRequest.body).toContain('"auth_index":"target-auth-index"'); + expect(managedQuotaRequest.body).not.toContain('default-refresh-token'); }); - it('retries a 401 quota failure after a transient proactive refresh failure', async () => { + it('retries a 401 quota failure through the management API instead of refreshing locally', async () => { writeGeminiToken( { type: 'gemini', @@ -745,20 +727,12 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'retry-stale-token', refresh_token: 'retry-refresh-token', expiry: Date.now() + 60 * 1000, - client_id: 'retry-client-id', - client_secret: 'retry-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-retry.json' ); mockFetch([ - { - url: GOOGLE_TOKEN_URL, - method: 'POST', - response: { access_token: 'unused-default', expires_in: 1800 }, - }, { url: GEMINI_QUOTA_URL, method: 'POST', @@ -776,49 +750,63 @@ describe('Gemini CLI Quota Fetcher', () => { ]); const originalFetch = globalThis.fetch; - let refreshAttempt = 0; let quotaAttempt = 0; + let managedLookupAttempt = 0; + let managedRequestAttempt = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GOOGLE_TOKEN_URL) { - refreshAttempt += 1; - return refreshAttempt === 1 - ? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }) - : new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - if (url === GEMINI_QUOTA_URL) { quotaAttempt += 1; - return quotaAttempt === 1 - ? new Response( - JSON.stringify({ - error: { - message: 'Session expired', - status: 'UNAUTHENTICATED', - }, - }), + return new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url === MANAGEMENT_AUTH_FILES_URL) { + managedLookupAttempt += 1; + return new Response( + JSON.stringify({ + files: [ { - status: 401, - headers: { 'Content-Type': 'application/json' }, - } - ) - : new Response( - JSON.stringify({ - buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ); + auth_index: 'retry-auth-index', + provider: 'gemini', + email: 'retry@example.com', + name: 'retry@example.com-gen-lang-client-retry-project.json', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url === MANAGEMENT_API_CALL_URL) { + managedRequestAttempt += 1; + return new Response( + JSON.stringify({ + status_code: 200, + body: JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); } return originalFetch(input, init); @@ -828,13 +816,9 @@ describe('Gemini CLI Quota Fetcher', () => { const result = await fetchGeminiCliQuota('retry@example.com'); expect(result.success).toBe(true); - expect(refreshAttempt).toBe(2); - expect(quotaAttempt).toBe(2); - - const storedToken = JSON.parse( - fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8') - ) as { token?: { access_token?: string } }; - expect(storedToken.token?.access_token).toBe('retry-fresh-token'); + expect(quotaAttempt).toBe(1); + expect(managedLookupAttempt).toBe(1); + expect(managedRequestAttempt).toBe(1); } finally { globalThis.fetch = originalFetch; } @@ -934,84 +918,4 @@ describe('Gemini CLI Quota Fetcher', () => { }); }); - describe('refreshGeminiToken', () => { - it('uses OAuth client metadata stored in the token file', async () => { - writeGeminiToken({ - type: 'gemini', - email: 'file@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - client_id: 'file-client-id', - client_secret: 'file-client-secret', - token_uri: 'https://oauth2.googleapis.com/token', - }, - }); - - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { access_token: 'fresh-token', expires_in: 1800 }, - }, - ]); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(true); - const [request] = getCapturedFetchRequests(); - expect(request.body).toContain('client_id=file-client-id'); - expect(request.body).toContain('client_secret=file-client-secret'); - expect(request.body).toContain('refresh_token=refresh-from-file'); - }); - - it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => { - process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id'; - process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret'; - - writeGeminiToken({ - type: 'gemini', - email: 'env@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - }, - }); - - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { access_token: 'fresh-token', expires_in: 1800 }, - }, - ]); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(true); - const [request] = getCapturedFetchRequests(); - expect(request.body).toContain('client_id=env-client-id'); - expect(request.body).toContain('client_secret=env-client-secret'); - }); - - it('returns a clear error when no refresh client credentials are available', async () => { - writeGeminiToken({ - type: 'gemini', - email: 'missing@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - }, - }); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(false); - expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID'); - expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET'); - }); - }); }); From 637a591dcaa94843cd1c74e18f713b36bcf2a1f4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 12:59:15 -0400 Subject: [PATCH 51/59] fix(cliproxy): avoid duplicate gemini management retries --- src/cliproxy/quota-fetcher-gemini-cli.ts | 42 +++++++++-- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 75 +++++++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 3fa5987d..bcbd7887 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -114,6 +114,10 @@ interface ManagedResponse { viaManagement: boolean; } +function getRemainingTimeoutMs(deadlineMs: number): number { + return Math.max(1, deadlineMs - Date.now()); +} + /** * Extract project ID from account field * Input: "user@example.com (cloudaicompanion-abc-123)" @@ -235,10 +239,13 @@ function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string) ); } -async function findManagedGeminiAuthIndex(accountId: string): Promise { +async function findManagedGeminiAuthIndex( + accountId: string, + timeoutMs: number +): Promise { const target = getProxyTarget(); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { @@ -263,16 +270,17 @@ async function findManagedGeminiAuthIndex(accountId: string): Promise { - const authIndex = await findManagedGeminiAuthIndex(accountId); + const authIndex = await findManagedGeminiAuthIndex(accountId, timeoutMs); if (authIndex === null || authIndex === undefined) { return null; } const target = getProxyTarget(); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), { @@ -319,15 +327,24 @@ async function performGeminiCliRequest( body: string, preferManagement = false ): Promise { + const deadlineMs = Date.now() + MANAGEMENT_API_TIMEOUT_MS; + let managementAttempted = false; + if (preferManagement) { - const managedResult = await performManagedGeminiRequest(accountId, url, body); + managementAttempted = true; + const managedResult = await performManagedGeminiRequest( + accountId, + url, + body, + getRemainingTimeoutMs(deadlineMs) + ); if (managedResult) { return managedResult; } } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs)); try { const response = await fetch(url, { @@ -346,7 +363,16 @@ async function performGeminiCliRequest( return directResult; } - const managedResult = await performManagedGeminiRequest(accountId, url, body); + if (managementAttempted) { + return directResult; + } + + const managedResult = await performManagedGeminiRequest( + accountId, + url, + body, + getRemainingTimeoutMs(deadlineMs) + ); return managedResult ?? directResult; } catch (error) { clearTimeout(timeoutId); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index f4481a43..7fe6aa86 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -714,6 +714,7 @@ describe('Gemini CLI Quota Fetcher', () => { expect(managedLookupRequest.url).toBe(MANAGEMENT_AUTH_FILES_URL); expect(managedQuotaRequest.url).toBe(MANAGEMENT_API_CALL_URL); expect(managedQuotaRequest.body).toContain('"auth_index":"target-auth-index"'); + expect(managedQuotaRequest.body).toContain('"Authorization":"Bearer $TOKEN$"'); expect(managedQuotaRequest.body).not.toContain('default-refresh-token'); }); @@ -824,6 +825,80 @@ describe('Gemini CLI Quota Fetcher', () => { } }); + it('does not retry the management API twice when the preferred managed path already failed', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'managed-failure@example.com', + project_id: 'managed-failure-project', + token: { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expiry: Date.now() - 1000, + }, + }, + 'gemini-managed-failure.json' + ); + + mockFetch([ + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const originalFetch = globalThis.fetch; + let directQuotaAttempt = 0; + let managedLookupAttempt = 0; + let managedRequestAttempt = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url === MANAGEMENT_AUTH_FILES_URL) { + managedLookupAttempt += 1; + return new Response('lookup unavailable', { status: 503 }); + } + + if (url === MANAGEMENT_API_CALL_URL) { + managedRequestAttempt += 1; + return new Response('should not be called', { status: 500 }); + } + + if (url === GEMINI_QUOTA_URL) { + directQuotaAttempt += 1; + return new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return originalFetch(input, init); + }) as typeof fetch; + + try { + const result = await fetchGeminiCliQuota('managed-failure@example.com'); + + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(directQuotaAttempt).toBe(1); + expect(managedLookupAttempt).toBe(1); + expect(managedRequestAttempt).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('classifies model capacity exhaustion separately from generic rate limits', async () => { writeActiveGeminiAccount('capacity@example.com'); From 4845b797de4a1e326e4ebea0109e884319e1bd3e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 14:06:27 -0400 Subject: [PATCH 52/59] test(cliproxy): stabilize routing strategy config scope --- tests/unit/cliproxy/routing-strategy.test.ts | 127 ++++++++++--------- 1 file changed, 65 insertions(+), 62 deletions(-) diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts index 9302cdac..fca87c39 100644 --- a/tests/unit/cliproxy/routing-strategy.test.ts +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -5,8 +5,8 @@ import * as path from 'path'; describe('cliproxy routing strategy service', () => { let tempHome = ''; - let originalCcsHome: string | undefined; - let setGlobalConfigDir: (dir: string | undefined) => void; + let scopedConfigDir = ''; + let runWithScopedConfigDir: (ccsDir: string, fn: () => Promise | T) => Promise; let routingTarget = { host: '127.0.0.1', port: 8317, @@ -16,29 +16,24 @@ describe('cliproxy routing strategy service', () => { let responseFactory: (() => Promise) | null = null; beforeEach(async () => { - originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-')); - process.env.CCS_HOME = tempHome; + scopedConfigDir = path.join(tempHome, '.ccs'); - ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); - setGlobalConfigDir(path.join(tempHome, '.ccs')); + ({ runWithScopedConfigDir } = await import('../../../src/utils/config-manager')); }); afterEach(() => { mock.restore(); - setGlobalConfigDir(undefined); - - if (originalCcsHome !== undefined) { - process.env.CCS_HOME = originalCcsHome; - } else { - delete process.env.CCS_HOME; - } if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + async function withScopedConfig(fn: () => Promise | T): Promise { + return await runWithScopedConfigDir(scopedConfigDir, fn); + } + async function loadRoutingModule() { mock.module('../../../src/cliproxy/routing-strategy-http', () => ({ getCliproxyRoutingTarget: () => routingTarget, @@ -58,70 +53,78 @@ describe('cliproxy routing strategy service', () => { } it('normalizes canonical and shorthand strategy values', async () => { - const mod = await loadRoutingModule(); + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); - expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin'); - expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin'); - expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first'); - expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first'); - expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull(); + expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin'); + expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin'); + expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first'); + expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first'); + expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull(); + }); }); it('falls back to the saved local default when live CLIProxy is unavailable', async () => { - const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader'); - mutateUnifiedConfig((config) => { - if (config.cliproxy) { - config.cliproxy.routing = { strategy: 'fill-first' }; - } + await withScopedConfig(async () => { + const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader'); + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.routing = { strategy: 'fill-first' }; + } + }); + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxyRoutingState(); + + expect(state.strategy).toBe('fill-first'); + expect(state.source).toBe('config'); + expect(state.target).toBe('local'); + expect(state.reachable).toBe(false); }); - - const mod = await loadRoutingModule(); - const state = await mod.readCliproxyRoutingState(); - - expect(state.strategy).toBe('fill-first'); - expect(state.source).toBe('config'); - expect(state.target).toBe('local'); - expect(state.reachable).toBe(false); }); it('persists the local startup default even when the live proxy is down', async () => { - const mod = await loadRoutingModule(); - const result = await mod.applyCliproxyRoutingStrategy('fill-first'); + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxyRoutingStrategy('fill-first'); - expect(result.applied).toBe('config-only'); - expect(result.strategy).toBe('fill-first'); + expect(result.applied).toBe('config-only'); + expect(result.strategy).toBe('fill-first'); - const configPath = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml'); - const configContent = fs.readFileSync(configPath, 'utf8'); - expect(configContent).toContain('routing:'); - expect(configContent).toContain('strategy: fill-first'); + const configPath = path.join(scopedConfigDir, 'cliproxy', 'config.yaml'); + const configContent = fs.readFileSync(configPath, 'utf8'); + expect(configContent).toContain('routing:'); + expect(configContent).toContain('strategy: fill-first'); + }); }); it('reads and writes remote strategy without mutating the local default', async () => { - routingTarget = { - host: 'remote.example.com', - port: 8080, - protocol: 'http', - isRemote: true, - }; + await withScopedConfig(async () => { + routingTarget = { + host: 'remote.example.com', + port: 8080, + protocol: 'http', + isRemote: true, + }; - let methodCount = 0; - responseFactory = async () => { - methodCount += 1; - return new Response(JSON.stringify({ strategy: 'fill-first' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }; + let methodCount = 0; + responseFactory = async () => { + methodCount += 1; + return new Response(JSON.stringify({ strategy: 'fill-first' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; - const mod = await loadRoutingModule(); - const readState = await mod.readCliproxyRoutingState(); - const writeState = await mod.applyCliproxyRoutingStrategy('fill-first'); + const mod = await loadRoutingModule(); + const readState = await mod.readCliproxyRoutingState(); + const writeState = await mod.applyCliproxyRoutingStrategy('fill-first'); - expect(readState.strategy).toBe('fill-first'); - expect(readState.target).toBe('remote'); - expect(writeState.applied).toBe('live'); - expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin'); - expect(methodCount).toBe(2); + expect(readState.strategy).toBe('fill-first'); + expect(readState.target).toBe('remote'); + expect(writeState.applied).toBe('live'); + expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin'); + expect(methodCount).toBe(2); + }); }); }); From 6b53df0147989dac42a76ca53039fc7a714d495c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 14:15:03 -0400 Subject: [PATCH 53/59] fix(cliproxy): align delegated gemini auth recovery --- src/cliproxy/quota-fetcher-gemini-cli.ts | 140 ++++++++++++++---- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 16 +- 2 files changed, 123 insertions(+), 33 deletions(-) diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index bcbd7887..ad88b9c9 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -14,6 +14,7 @@ import { buildGeminiCliBucketsFromParsedBuckets, type GeminiCliParsedBucket, } from './gemini-cli-quota-normalizer'; +import { mapExternalProviderName } from './provider-capabilities'; import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; import { @@ -33,6 +34,7 @@ const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI'; const MANAGEMENT_API_TIMEOUT_MS = 5000; +const SECONDARY_REQUEST_TIMEOUT_MS = 2000; /** Auth data extracted from Gemini CLI auth file */ interface GeminiCliAuthData { @@ -114,6 +116,20 @@ interface ManagedResponse { viaManagement: boolean; } +interface ManagedGeminiAuthContext { + authIndexLookupPromise?: Promise; +} + +interface ManagedGeminiAuthLookupResult { + authIndex: string | number | null; + unavailable: boolean; +} + +interface ManagedGeminiRequestResult { + response: ManagedResponse | null; + unavailable: boolean; +} + function getRemainingTimeoutMs(deadlineMs: number): number { return Math.max(1, deadlineMs - Date.now()); } @@ -214,8 +230,8 @@ async function readManagedResponse( } function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean { - const provider = normalizeStringValue(file.provider ?? file.type); - if (provider !== 'gemini') { + const rawProvider = normalizeStringValue(file.provider ?? file.type); + if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') { return false; } @@ -242,7 +258,7 @@ function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string) async function findManagedGeminiAuthIndex( accountId: string, timeoutMs: number -): Promise { +): Promise { const target = getProxyTarget(); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); @@ -255,15 +271,35 @@ async function findManagedGeminiAuthIndex( clearTimeout(timeoutId); if (!response.ok) { - return null; + return { authIndex: null, unavailable: true }; } const data = (await response.json()) as { files?: ManagementAuthFile[] }; const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId)); - return match?.auth_index ?? null; + return { authIndex: match?.auth_index ?? null, unavailable: false }; } catch { clearTimeout(timeoutId); - return null; + return { authIndex: null, unavailable: true }; + } +} + +async function getManagedGeminiAuthIndex( + accountId: string, + timeoutMs: number, + context?: ManagedGeminiAuthContext +): Promise { + if (!context) { + return await findManagedGeminiAuthIndex(accountId, timeoutMs); + } + + context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs); + return await context.authIndexLookupPromise; +} + +class GeminiManagedAuthUnavailableError extends Error { + constructor() { + super('CLIProxy managed Gemini auth is temporarily unavailable'); + this.name = 'GeminiManagedAuthUnavailableError'; } } @@ -271,16 +307,27 @@ async function performManagedGeminiRequest( accountId: string, url: string, body: string, - timeoutMs: number -): Promise { - const authIndex = await findManagedGeminiAuthIndex(accountId, timeoutMs); + timeoutMs: number, + authContext?: ManagedGeminiAuthContext +): Promise { + const deadlineMs = Date.now() + timeoutMs; + const lookupResult = await getManagedGeminiAuthIndex( + accountId, + getRemainingTimeoutMs(deadlineMs), + authContext + ); + if (lookupResult.unavailable) { + return { response: null, unavailable: true }; + } + + const authIndex = lookupResult.authIndex; if (authIndex === null || authIndex === undefined) { - return null; + return { response: null, unavailable: false }; } const target = getProxyTarget(); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs)); try { const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), { @@ -303,20 +350,23 @@ async function performManagedGeminiRequest( clearTimeout(timeoutId); if (!response.ok) { - return null; + return { response: null, unavailable: true }; } const apiResponse = (await response.json()) as ManagementApiCallResponse; const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : ''; return { - status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500, - bodyText, - json: safeParseJson(bodyText), - viaManagement: true, + response: { + status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500, + bodyText, + json: safeParseJson(bodyText), + viaManagement: true, + }, + unavailable: false, }; } catch { clearTimeout(timeoutId); - return null; + return { response: null, unavailable: true }; } } @@ -325,10 +375,11 @@ async function performGeminiCliRequest( accessToken: string, url: string, body: string, - preferManagement = false + preferManagement = false, + authContext?: ManagedGeminiAuthContext ): Promise { - const deadlineMs = Date.now() + MANAGEMENT_API_TIMEOUT_MS; let managementAttempted = false; + let managementUnavailable = false; if (preferManagement) { managementAttempted = true; @@ -336,15 +387,20 @@ async function performGeminiCliRequest( accountId, url, body, - getRemainingTimeoutMs(deadlineMs) + MANAGEMENT_API_TIMEOUT_MS, + authContext ); - if (managedResult) { - return managedResult; + managementUnavailable = managedResult.unavailable; + if (managedResult.response) { + return managedResult.response; } } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs)); + const timeoutId = setTimeout( + () => controller.abort(), + managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS + ); try { const response = await fetch(url, { @@ -364,6 +420,9 @@ async function performGeminiCliRequest( } if (managementAttempted) { + if (managementUnavailable) { + throw new GeminiManagedAuthUnavailableError(); + } return directResult; } @@ -371,9 +430,16 @@ async function performGeminiCliRequest( accountId, url, body, - getRemainingTimeoutMs(deadlineMs) + SECONDARY_REQUEST_TIMEOUT_MS, + authContext ); - return managedResult ?? directResult; + if (managedResult.response) { + return managedResult.response; + } + if (managedResult.unavailable) { + throw new GeminiManagedAuthUnavailableError(); + } + return directResult; } catch (error) { clearTimeout(timeoutId); throw error; @@ -524,7 +590,8 @@ async function fetchGeminiCliSupplementary( accountId: string, accessToken: string, projectId: string, - verbose: boolean + verbose: boolean, + authContext?: ManagedGeminiAuthContext ): Promise { const requestBody = JSON.stringify({ cloudaicompanionProject: projectId, @@ -541,7 +608,9 @@ async function fetchGeminiCliSupplementary( accountId, accessToken, GEMINI_CLI_CODE_ASSIST_URL, - requestBody + requestBody, + false, + authContext ); if (response.status !== 200) { @@ -890,11 +959,13 @@ async function fetchWithAuthData( }); } + const authContext: ManagedGeminiAuthContext = {}; const supplementaryPromise = fetchGeminiCliSupplementary( accountId, authData.accessToken, authData.projectId, - verbose + verbose, + authContext ); const requestBody = JSON.stringify({ project: authData.projectId }); @@ -904,7 +975,8 @@ async function fetchWithAuthData( authData.accessToken, GEMINI_CLI_QUOTA_URL, requestBody, - authData.isExpired + authData.isExpired, + authContext ); if (verbose) { @@ -952,6 +1024,16 @@ async function fetchWithAuthData( accountId, }; } catch (err) { + if (err instanceof GeminiManagedAuthUnavailableError) { + return buildGeminiCliFailureResult(accountId, authData.projectId, { + error: 'Gemini delegated auth refresh is temporarily unavailable', + errorCode: 'managed_auth_unavailable', + errorDetail: err.message, + actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.', + retryable: true, + }); + } + const errorMsg = err instanceof Error && err.name === 'AbortError' ? 'Request timeout' diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 7fe6aa86..63c4672b 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -484,6 +484,12 @@ describe('Gemini CLI Quota Fetcher', () => { writeActiveGeminiAccount('reauth@example.com'); mockFetch([ + { + url: MANAGEMENT_AUTH_FILES_URL, + response: { + files: [], + }, + }, { url: GEMINI_QUOTA_URL, method: 'POST', @@ -670,7 +676,7 @@ describe('Gemini CLI Quota Fetcher', () => { files: [ { auth_index: 'target-auth-index', - provider: 'gemini', + provider: 'gemini-cli', email: 'target@example.com', name: 'target@example.com-gen-lang-client-target-project.json', }, @@ -781,7 +787,7 @@ describe('Gemini CLI Quota Fetcher', () => { files: [ { auth_index: 'retry-auth-index', - provider: 'gemini', + provider: 'gemini-cli', email: 'retry@example.com', name: 'retry@example.com-gen-lang-client-retry-project.json', }, @@ -825,7 +831,7 @@ describe('Gemini CLI Quota Fetcher', () => { } }); - it('does not retry the management API twice when the preferred managed path already failed', async () => { + it('reports a retryable management failure instead of reauth when delegated auth is unavailable', async () => { writeGeminiToken( { type: 'gemini', @@ -890,7 +896,9 @@ describe('Gemini CLI Quota Fetcher', () => { const result = await fetchGeminiCliQuota('managed-failure@example.com'); expect(result.success).toBe(false); - expect(result.needsReauth).toBe(true); + expect(result.needsReauth).toBeUndefined(); + expect(result.retryable).toBe(true); + expect(result.errorCode).toBe('managed_auth_unavailable'); expect(directQuotaAttempt).toBe(1); expect(managedLookupAttempt).toBe(1); expect(managedRequestAttempt).toBe(0); From b6aef885ad3c20cb92c803b414ad83eaf4570dd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 21:29:04 +0000 Subject: [PATCH 54/59] chore(release): 7.71.0-dev.12 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a0dfd17c..52e39a8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.11", + "version": "7.71.0-dev.12", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 4e30c9b080a4e240b1fa17c3b0486dafc28224da Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 13:59:33 -0400 Subject: [PATCH 55/59] feat(cli): add browser automation commands --- README.md | 7 +- docs/browser-automation.md | 164 +++++ docs/project-roadmap.md | 1 + src/ccs.ts | 38 +- src/cliproxy/executor/index.ts | 21 +- src/commands/browser-command.ts | 155 +++++ src/commands/command-catalog.ts | 16 +- src/commands/help-command.ts | 8 +- src/commands/root-command-router.ts | 7 + src/config/unified-config-loader.ts | 56 ++ src/config/unified-config-types.ts | 40 ++ src/utils/browser/browser-settings.ts | 100 +++ src/utils/browser/browser-status.ts | 193 ++++++ src/utils/browser/index.ts | 13 + src/web-server/routes/browser-routes.ts | 136 +++++ src/web-server/routes/index.ts | 2 + .../services/compatible-cli-docs-registry.ts | 1 + tests/unit/commands/browser-command.test.ts | 185 ++++++ .../unit/commands/help-command-parity.test.ts | 13 + .../targets/codex-runtime-integration.test.ts | 42 ++ .../default-profile-browser-launch.test.ts | 80 +++ .../settings-profile-browser-launch.test.ts | 86 +++ .../unit/utils/browser/browser-status.test.ts | 202 ++++++ tests/unit/web-server/browser-routes.test.ts | 171 ++++++ .../compatible-cli/codex-docs-tab.tsx | 5 + .../compatible-cli/codex-mcp-servers-card.tsx | 27 +- ui/src/lib/api-client.ts | 71 +++ ui/src/lib/codex-config.ts | 4 + ui/src/lib/i18n.ts | 220 +++++++ .../settings/components/tab-navigation.tsx | 4 +- ui/src/pages/settings/hooks.ts | 1 + ui/src/pages/settings/hooks/context-hooks.ts | 51 ++ ui/src/pages/settings/hooks/index.ts | 1 + .../settings/hooks/use-browser-config.ts | 93 +++ ui/src/pages/settings/index.tsx | 32 +- .../pages/settings/sections/browser/index.tsx | 577 ++++++++++++++++++ ui/src/pages/settings/settings-context.ts | 38 ++ ui/src/pages/settings/types.ts | 12 +- .../codex-mcp-servers-card.test.tsx | 22 + .../unit/ui/lib/codex-config-browser.test.ts | 36 ++ .../unit/ui/pages/browser-section.test.tsx | 109 ++++ .../ui/pages/settings/settings-page.test.tsx | 20 + 42 files changed, 3028 insertions(+), 32 deletions(-) create mode 100644 docs/browser-automation.md create mode 100644 src/commands/browser-command.ts create mode 100644 src/utils/browser/browser-settings.ts create mode 100644 src/utils/browser/browser-status.ts create mode 100644 src/web-server/routes/browser-routes.ts create mode 100644 tests/unit/commands/browser-command.test.ts create mode 100644 tests/unit/utils/browser/browser-status.test.ts create mode 100644 tests/unit/web-server/browser-routes.test.ts create mode 100644 ui/src/pages/settings/hooks/use-browser-config.ts create mode 100644 ui/src/pages/settings/sections/browser/index.tsx create mode 100644 ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx create mode 100644 ui/tests/unit/ui/lib/codex-config-browser.test.ts create mode 100644 ui/tests/unit/ui/pages/browser-section.test.tsx diff --git a/README.md b/README.md index ec27693d..4aab06f4 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,10 @@ Deep dive: ![WebSearch Fallback](assets/screenshots/websearch.webp) CCS can provision first-class local tools like WebSearch and image analysis for -third-party launches instead of leaving you to wire them by hand. Deep dive: -[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch). +third-party launches instead of leaving you to wire them by hand. Browser +automation now has a first-class setup path as well. Deep dive: +[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch) | +[Browser Automation](./docs/browser-automation.md). ## Docs Matrix @@ -144,6 +146,7 @@ reference material. | Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) | | Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) | | Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) | +| Understand browser attach vs Codex browser tooling | [Browser Automation](./docs/browser-automation.md) | | Keep OpenCode aligned with your live CCS setup | [OpenCode Sync Plugin](https://docs.ccs.kaitran.ca/features/workflow/opencode-sync) | | Browse every command and flag | [CLI Commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Recover from install, auth, or provider failures | [Troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | diff --git a/docs/browser-automation.md b/docs/browser-automation.md new file mode 100644 index 00000000..87be8aaf --- /dev/null +++ b/docs/browser-automation.md @@ -0,0 +1,164 @@ +# Browser Automation + +Last Updated: 2026-04-16 + +CCS provides browser automation through two separate runtime paths: + +- **Claude Browser Attach**: reuses a running Chrome/Chromium session through the CCS-managed local `ccs-browser` MCP runtime +- **Codex Browser Tools**: injects Playwright MCP tooling into Codex-target launches + +These are related, but they are not the same implementation and they do not promise a shared browser session. + +## How Browser Automation Works + +### Claude Browser Attach + +Claude-target CCS launches can provision a managed local MCP server named `ccs-browser`. +That path is designed for workflows where you want Claude to interact with a browser session +that already has useful authenticated state. + +Claude Browser Attach requires a browser launched in attach mode with remote debugging +enabled. A recent Chrome update alone is not sufficient. + +### Codex Browser Tools + +Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides +for the `ccs_browser` runtime config entry. + +This is configured from the same Browser settings surface, but it is distinct from Claude +Browser Attach. + +## Configuration + +### Via Dashboard + +Open `ccs config` -> `Settings` -> `Browser`. + +The Browser screen exposes two sections: + +- **Claude Browser Attach** + - enable/disable the Claude attach lane + - choose the Chrome user-data directory + - set the expected DevTools port + - review readiness and next-step guidance + - copy a generated browser launch command +- **Codex Browser Tools** + - enable/disable CCS-managed browser tooling for Codex-target launches + - review whether the detected Codex build supports managed browser overrides + +### Via CLI + +```bash +ccs help browser +ccs browser status +ccs browser doctor +``` + +Use `ccs browser status` for the current state and `ccs browser doctor` for actionable +troubleshooting guidance. + +### Via Config File + +Edit `~/.ccs/config.yaml`: + +```yaml +browser: + claude: + enabled: false + user_data_dir: "~/.ccs/browser/chrome-user-data" + devtools_port: 9222 + codex: + enabled: true +``` + +Notes: + +- `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile +- `claude.devtools_port` is the expected remote debugging port for attach mode +- `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches + +## Environment Variable Overrides + +CCS still supports environment-variable overrides for backward compatibility. + +| Variable | Description | +|----------|-------------| +| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir | +| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory | +| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override | + +If an override is active, Browser status surfaces should report that the current session is being +managed externally by environment variables. + +## Managed Runtime Files + +- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach +- `~/.ccs/mcp/ccs-browser-server.cjs` -> local Claude Browser Attach MCP runtime +- `Codex runtime config overrides` -> CCS manages the `ccs_browser` MCP entry for Codex-target launches + +Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser +entries should be configured from `Settings -> Browser`. + +## Launching Chrome For Claude Attach + +Claude Browser Attach needs a browser launched with remote debugging. + +Typical examples: + +```bash +# macOS +open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data" + +# Linux +google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data" + +# Windows +chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\browser\\chrome-user-data" +``` + +Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps +automation state separate from your daily browser profile. + +## Troubleshooting + +### Browser status says Claude Browser Attach is disabled + +Enable Claude Browser Attach in `Settings -> Browser` or via the browser config block in +`~/.ccs/config.yaml`. + +### Browser status says the path is missing + +The configured Chrome user-data directory does not exist yet. + +1. Create the directory or use the generated launch command +2. Start Chrome in attach mode with `--remote-debugging-port` +3. Rerun `ccs browser doctor` + +### Browser status says no running browser session was found + +CCS could not find usable DevTools attach metadata for the configured user-data directory. + +1. Make sure Chrome was started with `--remote-debugging-port=` +2. Make sure it is using the same `user_data_dir` configured in CCS +3. Rerun `ccs browser doctor` + +### Browser status says the DevTools endpoint is unreachable + +CCS found attach metadata, but the endpoint did not answer successfully. + +1. Restart the attach browser session +2. Confirm the expected port matches the real remote debugging port +3. Rerun `ccs browser status` + +### Codex Browser Tools are unavailable + +Codex browser tooling depends on a Codex build that supports `--config` overrides. + +If CCS reports `unsupported_build`, upgrade Codex and rerun `ccs browser status`. + +## Security Notes + +- Browser automation may operate inside authenticated browser sessions +- Prefer a dedicated automation user-data dir instead of your everyday browser profile +- Do not commit browser paths, secrets, or generated session state to version control +- Treat `~/.ccs/config.yaml`, `~/.claude.json`, and the browser user-data directory as local machine state diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2cdfda56..56d8394b 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor. - **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads. - **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell. - **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites. diff --git a/src/ccs.ts b/src/ccs.ts index 05db124b..3cedddbb 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -39,11 +39,15 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, + getEffectiveClaudeBrowserAttachConfig, resolveBrowserRuntimeEnv, - resolveConfiguredBrowserProfileDir, syncBrowserMcpToConfigDir, } from './utils/browser'; -import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; +import { + getBrowserConfig, + getGlobalEnvConfig, + getOfficialChannelsConfig, +} from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks, removeImageAnalysisProfileHook, @@ -135,7 +139,7 @@ const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v function resolveCodexRuntimeConfigOverrides( target: ReturnType ): string[] { - if (target !== 'codex') { + if (target !== 'codex' || !getBrowserConfig().codex.enabled) { return []; } return buildCodexBrowserMcpOverrides(); @@ -1054,13 +1058,13 @@ async function main(): Promise { const imageAnalysisMcpReady = resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; - const browserProfileDir = + const browserAttachConfig = resolvedTarget === 'claude' - ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { ensureBrowserMcpOrThrow(); } } @@ -1087,7 +1091,7 @@ async function main(): Promise { syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserProfileDir && + browserAttachConfig?.enabled && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1297,10 +1301,13 @@ async function main(): Promise { // Explicitly inject effective settings env vars so stale ANTHROPIC_* // values from prior sessions cannot leak into the active profile. - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { browserRuntimeEnv = { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), }; } @@ -1465,17 +1472,20 @@ async function main(): Promise { CCS_IMAGE_ANALYSIS_SKIP: '1', }; let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv']; - const browserProfileDir = + const browserAttachConfig = resolvedTarget === 'claude' - ? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR) + ? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()) : undefined; if (resolvedTarget === 'claude') { - if (browserProfileDir) { + if (browserAttachConfig?.enabled) { ensureBrowserMcpOrThrow(); browserRuntimeEnv = { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), }; Object.assign(envVars, browserRuntimeEnv); @@ -1495,7 +1505,7 @@ async function main(): Promise { if (defaultContinuityInheritance.claudeConfigDir) { envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir; if ( - browserProfileDir && + browserAttachConfig?.enabled && !syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir) ) { throw new Error( diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 4ad89052..f3d9f1a9 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -66,11 +66,15 @@ import { import { appendBrowserToolArgs, ensureBrowserMcpOrThrow, + getEffectiveClaudeBrowserAttachConfig, resolveBrowserRuntimeEnv, - resolveConfiguredBrowserProfileDir, syncBrowserMcpToConfigDir, } from '../../utils/browser'; -import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; +import { + getBrowserConfig, + loadOrCreateUnifiedConfig, + getThinkingConfig, +} from '../../config/unified-config-loader'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { isKiroAuthMethod, @@ -260,8 +264,8 @@ export async function execClaudeWithCLIProxy( // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); - const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR); - if (browserProfileDir) { + const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + if (browserAttachConfig.enabled) { ensureBrowserMcpOrThrow(); } displayWebSearchStatus(); @@ -1050,7 +1054,7 @@ export async function execClaudeWithCLIProxy( syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); if ( - browserProfileDir && + browserAttachConfig.enabled && inheritedClaudeConfigDir && !syncBrowserMcpToConfigDir(inheritedClaudeConfigDir) ) { @@ -1153,10 +1157,13 @@ export async function execClaudeWithCLIProxy( } // 11. Build final environment with all proxy chains - const browserRuntimeEnv = browserProfileDir + const browserRuntimeEnv = browserAttachConfig.enabled ? { ...(await resolveBrowserRuntimeEnv({ - profileDir: browserProfileDir, + profileDir: browserAttachConfig.userDataDir, + devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort + ? String(browserAttachConfig.devtoolsPort) + : undefined, })), } : undefined; diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts new file mode 100644 index 00000000..d42a0654 --- /dev/null +++ b/src/commands/browser-command.ts @@ -0,0 +1,155 @@ +import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser'; +import { color, dim, header, initUI, subheader } from '../utils/ui'; + +type HelpWriter = (line: string) => void; + +function currentPlatform(): 'darwin' | 'linux' | 'win32' { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} + +function summarizeBrowserHealth(status: BrowserStatusPayload): { + label: 'ready' | 'partial' | 'action required'; + exitCode: 0 | 1; +} { + const claudeNeedsAttention = status.claude.enabled && status.claude.state !== 'ready'; + if (claudeNeedsAttention) { + return { label: 'action required', exitCode: 1 }; + } + + if (status.codex.enabled && status.codex.state !== 'enabled') { + return { label: 'partial', exitCode: 0 }; + } + + return { label: 'ready', exitCode: 0 }; +} + +function writeCommandTable(writeLine: HelpWriter): void { + writeLine(subheader('Commands')); + writeLine( + ` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness` + ); + writeLine( + ` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it` + ); + writeLine(''); +} + +function writeIntro(writeLine: HelpWriter): void { + writeLine(' Claude Browser Attach reuses a local Chrome session for Claude-target launches.'); + writeLine( + ' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.' + ); + writeLine(''); +} + +function writeClaudeStatus( + status: BrowserStatusPayload['claude'], + writeLine: HelpWriter, + includeLaunchGuidance: boolean +): void { + writeLine(subheader('Claude Browser Attach')); + writeLine(` State: ${status.state}`); + writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); + writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`); + writeLine(` User data dir: ${status.effectiveUserDataDir}`); + writeLine(` DevTools port: ${status.devtoolsPort}`); + writeLine(` Managed MCP: ${status.managedMcpServerName}`); + writeLine(` Managed path: ${status.managedMcpServerPath}`); + if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) { + writeLine(` DevTools endpoint: ${status.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}`); + } + writeLine(` Detail: ${status.detail}`); + writeLine(` Next step: ${status.nextStep}`); + if (includeLaunchGuidance && status.enabled && status.state !== 'ready') { + writeLine( + ` Launch command (${currentPlatform()}): ${status.launchCommands[currentPlatform()]}` + ); + } + writeLine(''); +} + +function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void { + writeLine(subheader('Codex Browser Tools')); + writeLine(` State: ${status.state}`); + writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`); + writeLine(` Managed server: ${status.serverName}`); + writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`); + writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`); + if (status.version) { + writeLine(` Codex version: ${status.version}`); + } + writeLine(` Detail: ${status.detail}`); + writeLine(` Next step: ${status.nextStep}`); + writeLine(''); +} + +export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise { + await initUI(); + writeLine(header('CCS Browser Help')); + writeLine(''); + writeIntro(writeLine); + writeLine(subheader('Usage')); + writeLine(` ${color('ccs browser ', 'command')}`); + writeLine(` ${color('ccs help browser', 'command')}`); + writeLine(''); + writeCommandTable(writeLine); + writeLine(subheader('What Each Lane Does')); + writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.'); + writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.'); + writeLine(''); + writeLine(subheader('Examples')); + writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`); + writeLine( + ` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}` + ); + writeLine( + ` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}` + ); + writeLine(''); +} + +export async function handleBrowserCommand( + args: string[], + writeLine: HelpWriter = console.log +): Promise { + const subcommand = args[0]; + if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') { + await showBrowserHelp(writeLine); + return; + } + + if (subcommand !== 'status' && subcommand !== 'doctor') { + await initUI(); + writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error')); + writeLine(''); + writeLine(` ${dim('Supported subcommands: status, doctor')}`); + writeLine(''); + process.exitCode = 1; + return; + } + + await initUI(); + const status = await getBrowserStatus(); + + writeLine(header(`ccs browser ${subcommand}`)); + writeLine(''); + writeIntro(writeLine); + + if (subcommand === 'doctor') { + const summary = summarizeBrowserHealth(status); + writeLine(subheader('Overall')); + writeLine(` Claude Browser Attach: ${status.claude.title}`); + writeLine(` Codex Browser Tools: ${status.codex.title}`); + writeLine(` Result: ${summary.label}`); + writeLine(''); + } + + writeClaudeStatus(status.claude, writeLine, subcommand === 'doctor'); + writeCodexStatus(status.codex, writeLine); + + if (subcommand === 'doctor') { + process.exitCode = summarizeBrowserHealth(status).exitCode; + } +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 6b6b01d6..5b37e223 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -1,7 +1,13 @@ import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; -export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets'; +export type HelpTopicName = + | 'profiles' + | 'providers' + | 'kiro' + | 'browser' + | 'completion' + | 'targets'; export interface HelpTopicEntry { name: HelpTopicName; @@ -25,6 +31,7 @@ export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [ { name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' }, { name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' }, { name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' }, + { name: 'browser', summary: 'Claude Browser Attach and Codex Browser Tools guidance' }, { name: 'completion', summary: 'Shell completion install, refresh, and testing' }, { name: 'targets', summary: 'Claude, Droid, and Codex target routing' }, ] as const; @@ -114,6 +121,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [ group: 'runtime', visibility: 'public', }, + { + name: 'browser', + summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness', + group: 'runtime', + visibility: 'public', + }, { name: 'copilot', summary: 'Run or manage the GitHub Copilot bridge', @@ -307,6 +320,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly (await import('./cleanup-command')).handleCleanupCommand(['--help']), + browser: async () => (await import('./browser-command')).showBrowserHelp(writeLine), cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(), copilot: async () => process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])), diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index 250c5910..6d09848f 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -105,6 +105,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleSyncCommand(); }, }, + { + name: 'browser', + handle: async (args) => { + const { handleBrowserCommand } = await import('./browser-command'); + await handleBrowserCommand(args); + }, + }, { name: 'cleanup', aliases: ['--cleanup'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 5cd1254e..50835435 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -23,6 +23,7 @@ import { DEFAULT_THINKING_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, + DEFAULT_BROWSER_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, DEFAULT_LOGGING_CONFIG, } from './unified-config-types'; @@ -34,6 +35,7 @@ import type { OfficialChannelsConfig, OfficialChannelId, DashboardAuthConfig, + BrowserConfig, ImageAnalysisConfig, LoggingConfig, CursorConfig, @@ -46,6 +48,7 @@ import { normalizeOfficialChannelIds, resolveLegacyDiscordSelection, } from '../channels/official-channels-runtime'; +import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings'; import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver'; import { normalizeSearxngBaseUrl } from '../utils/websearch/types'; @@ -54,6 +57,32 @@ const CONFIG_JSON = 'config.json'; const CONFIG_LOCK = 'config.yaml.lock'; const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds +function normalizeBrowserDevtoolsPort(value: number | undefined): number { + if (!Number.isFinite(value)) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + const port = Math.floor(value as number); + if (port < 1 || port > 65535) { + return DEFAULT_BROWSER_CONFIG.claude.devtools_port; + } + + return port; +} + +function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig { + return { + claude: { + enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled, + user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(), + devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port), + }, + codex: { + enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled, + }, + }; +} + /** * Get path to unified config.yaml */ @@ -592,6 +621,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.dashboard_auth?.session_timeout_hours ?? DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, }, + browser: canonicalizeBrowserConfig(partial.browser), // Image analysis config - enabled by default for CLIProxy providers image_analysis: canonicalizeImageAnalysisConfig({ enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, @@ -916,6 +946,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Browser automation section + if (config.browser) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Browser Automation: Claude browser attach and Codex browser tooling'); + lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.'); + lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.'); + lines.push('#'); + lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the'); + lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.'); + lines.push('# Configure via: Settings > Browser or `ccs browser ...`.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + // Image analysis section if (config.image_analysis) { lines.push('# ----------------------------------------------------------------------------'); @@ -1334,6 +1381,15 @@ export function getDashboardAuthConfig(): DashboardAuthConfig { }; } +/** + * Get browser automation configuration. + * Returns canonicalized defaults if not configured. + */ +export function getBrowserConfig(): BrowserConfig { + const config = loadOrCreateUnifiedConfig(); + return canonicalizeBrowserConfig(config.browser); +} + /** * Get image_analysis configuration. * Returns defaults if not configured. diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index afb7006c..1b8b67f4 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -812,6 +812,40 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { session_timeout_hours: 24, }; +/** + * Browser automation configuration. + * Controls Claude browser attach and Codex browser tooling. + */ +export interface BrowserClaudeConfig { + /** Enable Claude browser attach (default: false) */ + enabled: boolean; + /** Chrome user-data directory used for attach mode */ + user_data_dir: string; + /** DevTools port used for attach mode (default: 9222) */ + devtools_port: number; +} + +export interface BrowserCodexConfig { + /** Enable Codex browser tooling injection (default: true) */ + enabled: boolean; +} + +export interface BrowserConfig { + claude: BrowserClaudeConfig; + codex: BrowserCodexConfig; +} + +export const DEFAULT_BROWSER_CONFIG: BrowserConfig = { + claude: { + enabled: false, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, +}; + /** * Image analysis configuration. * Routes image/PDF files through CLIProxy for vision analysis. @@ -895,6 +929,8 @@ export interface UnifiedConfig { channels?: OfficialChannelsConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; + /** Browser automation configuration */ + browser?: BrowserConfig; /** Image analysis configuration (vision via CLIProxy) */ image_analysis?: ImageAnalysisConfig; } @@ -1041,6 +1077,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { thinking: { ...DEFAULT_THINKING_CONFIG }, channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, + browser: { + claude: { ...DEFAULT_BROWSER_CONFIG.claude }, + codex: { ...DEFAULT_BROWSER_CONFIG.codex }, + }, image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, }; } diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts new file mode 100644 index 00000000..2e0691e2 --- /dev/null +++ b/src/utils/browser/browser-settings.ts @@ -0,0 +1,100 @@ +import * as path from 'path'; +import type { BrowserConfig } from '../../config/unified-config-types'; +import { getCcsDir } from '../config-manager'; +import { expandPath } from '../helpers'; + +export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; + +export interface EffectiveClaudeBrowserAttachConfig { + enabled: boolean; + source: 'config' | BrowserOverrideSource; + overrideActive: boolean; + userDataDir: string; + devtoolsPort: number; + hasExplicitDevtoolsPort: boolean; +} + +export function getRecommendedBrowserUserDataDir(): string { + return path.join(getCcsDir(), 'browser', 'chrome-user-data'); +} + +export function resolveBrowserUserDataDir(value?: string): string | undefined { + return value?.trim() ? expandPath(value) : undefined; +} + +export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): { + userDataDir?: string; + devtoolsPort?: number; + source?: BrowserOverrideSource; +} { + const explicitUserDataDir = resolveBrowserUserDataDir(env.CCS_BROWSER_USER_DATA_DIR); + if (explicitUserDataDir) { + return { + userDataDir: explicitUserDataDir, + devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT), + source: 'CCS_BROWSER_USER_DATA_DIR', + }; + } + + const legacyProfileDir = resolveBrowserUserDataDir(env.CCS_BROWSER_PROFILE_DIR); + if (legacyProfileDir) { + return { + userDataDir: legacyProfileDir, + devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT), + source: 'CCS_BROWSER_PROFILE_DIR', + }; + } + + return {}; +} + +export function getEffectiveClaudeBrowserAttachConfig( + config: BrowserConfig, + env: NodeJS.ProcessEnv = process.env +): EffectiveClaudeBrowserAttachConfig { + const override = getBrowserAttachOverride(env); + const configUserDataDir = + resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); + const configPort = normalizeDevtoolsPort(config.claude.devtools_port); + + if (override.userDataDir) { + return { + enabled: true, + source: override.source as BrowserOverrideSource, + overrideActive: true, + userDataDir: override.userDataDir, + devtoolsPort: override.devtoolsPort ?? configPort, + hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, + }; + } + + return { + enabled: config.claude.enabled, + source: 'config', + overrideActive: false, + userDataDir: configUserDataDir, + devtoolsPort: configPort, + hasExplicitDevtoolsPort: true, + }; +} + +function parseDevtoolsPort(value?: string): number | undefined { + if (!value?.trim() || !/^\d+$/.test(value.trim())) { + return undefined; + } + + return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10)); +} + +function normalizeDevtoolsPort(value: number | undefined): number { + if (!Number.isFinite(value)) { + return 9222; + } + + const port = Math.floor(value as number); + if (port < 1 || port > 65535) { + return 9222; + } + + return port; +} diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts new file mode 100644 index 00000000..4af83b20 --- /dev/null +++ b/src/utils/browser/browser-status.ts @@ -0,0 +1,193 @@ +import { getBrowserConfig } from '../../config/unified-config-loader'; +import { getCodexBinaryInfo } from '../../targets/codex-detector'; +import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; +import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer'; +import { + getEffectiveClaudeBrowserAttachConfig, + getRecommendedBrowserUserDataDir, +} from './browser-settings'; + +export interface BrowserLaunchCommands { + darwin: string; + linux: string; + win32: string; +} + +export interface ClaudeBrowserStatus { + enabled: boolean; + source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR'; + overrideActive: boolean; + state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready'; + title: string; + detail: string; + nextStep: string; + effectiveUserDataDir: string; + recommendedUserDataDir: string; + devtoolsPort: number; + managedMcpServerName: string; + managedMcpServerPath: string; + launchCommands: BrowserLaunchCommands; + runtimeEnv?: BrowserRuntimeEnv; +} + +export interface CodexBrowserStatus { + enabled: boolean; + state: 'disabled' | 'enabled' | 'unsupported_build'; + title: string; + detail: string; + nextStep: string; + serverName: string; + supportsConfigOverrides: boolean; + binaryPath: string | null; + version?: string; +} + +export interface BrowserStatusPayload { + claude: ClaudeBrowserStatus; + codex: CodexBrowserStatus; +} + +export async function getBrowserStatus(): Promise { + const browserConfig = getBrowserConfig(); + return { + claude: await buildClaudeBrowserStatus(browserConfig), + codex: buildCodexBrowserStatus(browserConfig), + }; +} + +async function buildClaudeBrowserStatus( + browserConfig = getBrowserConfig() +): Promise { + const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig); + const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort); + const base: Omit = { + enabled: effective.enabled, + source: effective.source, + overrideActive: effective.overrideActive, + effectiveUserDataDir: effective.userDataDir, + recommendedUserDataDir: getRecommendedBrowserUserDataDir(), + devtoolsPort: effective.devtoolsPort, + managedMcpServerName: getBrowserMcpServerName(), + managedMcpServerPath: getBrowserMcpServerPath(), + launchCommands, + }; + + if (!effective.enabled) { + return { + ...base, + state: 'disabled', + title: 'Claude Browser Attach is disabled.', + detail: + 'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.', + nextStep: + 'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.', + }; + } + + try { + const runtimeEnv = await resolveBrowserRuntimeEnv({ + profileDir: effective.userDataDir, + devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined, + }); + + return { + ...base, + state: 'ready', + title: 'Claude Browser Attach is ready.', + detail: + 'CCS can reach the configured Chrome DevTools endpoint for the current attach session.', + nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.', + runtimeEnv, + }; + } catch (error) { + const message = (error as Error).message; + if (message.includes('Chrome profile directory is invalid')) { + return { + ...base, + state: 'path_missing', + title: 'Claude Browser Attach path is missing.', + detail: message, + nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[platformKey()]}`, + }; + } + + if (message.includes('Chrome reuse metadata')) { + return { + ...base, + state: 'browser_not_running', + title: 'Claude Browser Attach could not find a running browser session.', + detail: message, + nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[platformKey()]}`, + }; + } + + return { + ...base, + state: 'endpoint_unreachable', + title: 'Claude Browser Attach could not reach the DevTools endpoint.', + detail: message, + nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[platformKey()]}`, + }; + } +} + +function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus { + if (!browserConfig.codex.enabled) { + return { + enabled: false, + state: 'disabled', + title: 'Codex Browser Tools are disabled.', + detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.', + nextStep: + 'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: null, + }; + } + + const binaryInfo = getCodexBinaryInfo({ includeVersion: true, includeFeatures: true }); + const supportsConfigOverrides = Boolean(binaryInfo?.features?.includes('config-overrides')); + if (!binaryInfo || !supportsConfigOverrides) { + return { + enabled: true, + state: 'unsupported_build', + title: 'Codex Browser Tools need a Codex build with --config override support.', + detail: binaryInfo + ? `Detected Codex at ${binaryInfo.path}, but it does not advertise --config overrides.` + : 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides, + binaryPath: binaryInfo?.path ?? null, + version: binaryInfo?.version, + }; + } + + return { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.', + nextStep: 'Use a Codex-target CCS launch to access browser tools.', + serverName: 'ccs_browser', + supportsConfigOverrides, + binaryPath: binaryInfo.path, + version: binaryInfo.version, + }; +} + +function buildLaunchCommands(userDataDir: string, devtoolsPort: number): BrowserLaunchCommands { + const quotedPath = JSON.stringify(userDataDir); + return { + darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`, + linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`, + win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`, + }; +} + +function platformKey(): keyof BrowserLaunchCommands { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} diff --git a/src/utils/browser/index.ts b/src/utils/browser/index.ts index be30da8e..22f92251 100644 --- a/src/utils/browser/index.ts +++ b/src/utils/browser/index.ts @@ -17,9 +17,22 @@ export { export { appendBrowserToolArgs } from './claude-tool-args'; +export { + getRecommendedBrowserUserDataDir, + getBrowserAttachOverride, + getEffectiveClaudeBrowserAttachConfig, +} from './browser-settings'; + export { resolveBrowserRuntimeEnv, resolveDefaultChromeUserDataDir, resolveConfiguredBrowserProfileDir, } from './chrome-reuse'; export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse'; + +export { getBrowserStatus } from './browser-status'; +export type { + BrowserStatusPayload, + ClaudeBrowserStatus, + CodexBrowserStatus, +} from './browser-status'; diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts new file mode 100644 index 00000000..2701339f --- /dev/null +++ b/src/web-server/routes/browser-routes.ts @@ -0,0 +1,136 @@ +import { Router, type Request, type Response } from 'express'; +import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader'; +import { getBrowserStatus } from '../../utils/browser'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; + +const router = Router(); +const BROWSER_LOCAL_ACCESS_ERROR = + 'Browser endpoints require localhost access when dashboard auth is disabled.'; + +interface BrowserRouteBody { + claude?: { + enabled?: boolean; + userDataDir?: string; + devtoolsPort?: number; + }; + codex?: { + enabled?: boolean; + }; +} + +function isValidDevtoolsPort(value: number): boolean { + return Number.isInteger(value) && value >= 1 && value <= 65535; +} + +router.use((req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) { + next(); + } +}); + +router.get('/', async (_req: Request, res: Response): Promise => { + try { + const config = getBrowserConfig(); + const status = await getBrowserStatus(); + res.json({ + config: toBrowserRouteConfig(config), + status, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + res.json(await getBrowserStatus()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.put('/', async (req: Request, res: Response): Promise => { + if ( + req.body === null || + req.body === undefined || + typeof req.body !== 'object' || + Array.isArray(req.body) + ) { + res.status(400).json({ error: 'Invalid request body. Must be an object.' }); + return; + } + + const { claude, codex } = req.body as BrowserRouteBody; + if (claude && (typeof claude !== 'object' || Array.isArray(claude))) { + res.status(400).json({ error: 'Invalid value for claude. Must be an object.' }); + return; + } + if (codex && (typeof codex !== 'object' || Array.isArray(codex))) { + res.status(400).json({ error: 'Invalid value for codex. Must be an object.' }); + return; + } + if (claude?.enabled !== undefined && typeof claude.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' }); + return; + } + if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') { + res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' }); + return; + } + if ( + claude?.devtoolsPort !== undefined && + (typeof claude.devtoolsPort !== 'number' || !isValidDevtoolsPort(claude.devtoolsPort)) + ) { + res.status(400).json({ + error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', + }); + return; + } + if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' }); + return; + } + + try { + const current = getBrowserConfig(); + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: claude?.enabled ?? current.claude.enabled, + user_data_dir: claude?.userDataDir?.trim() || current.claude.user_data_dir, + devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, + }, + codex: { + enabled: codex?.enabled ?? current.codex.enabled, + }, + }; + }); + + const config = getBrowserConfig(); + const status = await getBrowserStatus(); + res.json({ + success: true, + browser: { + config: toBrowserRouteConfig(config), + status, + }, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +function toBrowserRouteConfig(config: ReturnType) { + return { + claude: { + enabled: config.claude.enabled, + userDataDir: config.claude.user_data_dir, + devtoolsPort: config.claude.devtools_port, + }, + codex: { + enabled: config.codex.enabled, + }, + }; +} + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 992ce80d..64b99ad0 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -19,6 +19,7 @@ import settingsRoutes from './settings-routes'; import channelsRoutes from './channels-routes'; import websearchRoutes from './websearch-routes'; import imageAnalysisRoutes from './image-analysis-routes'; +import browserRoutes from './browser-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxyRoutingRoutes from './cliproxy-routing-routes'; @@ -97,6 +98,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== apiRoutes.use('/websearch', websearchRoutes); +apiRoutes.use('/browser', browserRoutes); apiRoutes.use('/image-analysis', imageAnalysisRoutes); // ==================== Copilot ==================== diff --git a/src/web-server/services/compatible-cli-docs-registry.ts b/src/web-server/services/compatible-cli-docs-registry.ts index 5cba7137..cfa467e6 100644 --- a/src/web-server/services/compatible-cli-docs-registry.ts +++ b/src/web-server/services/compatible-cli-docs-registry.ts @@ -114,6 +114,7 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record] overlay on top of base config', 'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY', 'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces', + 'CCS-managed browser tooling for Codex should be configured from Settings > Browser, not by editing the ccs_browser MCP entry directly', ], links: [ { diff --git a/tests/unit/commands/browser-command.test.ts b/tests/unit/commands/browser-command.test.ts new file mode 100644 index 00000000..c7c55412 --- /dev/null +++ b/tests/unit/commands/browser-command.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, test, spyOn } from 'bun:test'; + +import * as browserUtils from '../../../src/utils/browser'; +import { handleBrowserCommand } from '../../../src/commands/browser-command'; + +function stripAnsi(input: string): string { + return input.replace(/\u001b\[[0-9;]*m/g, ''); +} + +async function renderLines(args: string[]): Promise { + const lines: string[] = []; + await handleBrowserCommand(args, (line) => lines.push(line)); + return stripAnsi(lines.join('\n')); +} + +function currentPlatform(): 'darwin' | 'linux' | 'win32' { + if (process.platform === 'darwin') return 'darwin'; + if (process.platform === 'win32') return 'win32'; + return 'linux'; +} + +describe('browser command', () => { + afterEach(() => { + process.exitCode = 0; + }); + + test('status renders both browser lanes from the shared status payload', async () => { + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: true, + source: 'config', + overrideActive: false, + state: 'ready', + title: 'Claude Browser Attach is ready.', + detail: 'CCS can reach the configured Chrome DevTools endpoint.', + nextStep: 'Launch Claude.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands: { + darwin: 'open -na "Google Chrome" --args', + linux: 'google-chrome --remote-debugging-port=9222', + win32: 'chrome.exe --remote-debugging-port=9222', + }, + runtimeEnv: { + CCS_BROWSER_USER_DATA_DIR: '/tmp/browser-profile', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }, + }, + codex: { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject the managed Playwright MCP overrides.', + nextStep: 'Use a Codex-target launch.', + serverName: 'ccs_browser', + supportsConfigOverrides: true, + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.120.0', + }, + }); + + try { + const rendered = await renderLines(['status']); + + expect(rendered.includes('ccs browser status')).toBe(true); + expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true); + expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe( + true + ); + expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true); + expect(rendered.includes('Managed server: ccs_browser')).toBe(true); + expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true); + } finally { + statusSpy.mockRestore(); + } + }); + + test('doctor prints env override context and launch guidance when Claude attach is not ready', async () => { + const launchCommands = { + darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9444', + linux: + 'google-chrome --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"', + win32: 'chrome.exe --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"', + }; + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: true, + source: 'CCS_BROWSER_PROFILE_DIR', + overrideActive: true, + state: 'browser_not_running', + title: 'Claude Browser Attach could not find a running browser session.', + detail: 'Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort', + nextStep: 'Start Chrome with remote debugging.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9444, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands, + }, + codex: { + enabled: true, + state: 'unsupported_build', + title: 'Codex Browser Tools need a Codex build with --config override support.', + detail: 'Detected Codex at /usr/local/bin/codex, but it does not advertise --config overrides.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.70.0', + }, + }); + + try { + const rendered = await renderLines(['doctor']); + + expect(rendered.includes('Result: action required')).toBe(true); + expect(rendered.includes('Source: CCS_BROWSER_PROFILE_DIR (env override active)')).toBe( + true + ); + expect( + rendered.includes( + `Launch command (${currentPlatform()}): ${launchCommands[currentPlatform()]}` + ) + ).toBe(true); + expect(rendered.includes('Detected Codex at /usr/local/bin/codex')).toBe(true); + expect(process.exitCode).toBe(1); + } finally { + statusSpy.mockRestore(); + } + }); + + test('doctor stays ready on Claude-only machines when Codex is not installed', async () => { + const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({ + claude: { + enabled: false, + source: 'config', + overrideActive: false, + state: 'disabled', + title: 'Claude Browser Attach is disabled.', + detail: 'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.', + nextStep: + 'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands: { + darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9222', + linux: + 'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + win32: + 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + }, + }, + codex: { + enabled: true, + state: 'unsupported_build', + title: 'Codex Browser Tools need a Codex build with --config override support.', + detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.', + nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.', + serverName: 'ccs_browser', + supportsConfigOverrides: false, + binaryPath: null, + }, + }); + + try { + const rendered = await renderLines(['doctor']); + + expect(rendered.includes('Result: partial')).toBe(true); + expect(rendered.includes('run `ccs browser enable`')).toBe(false); + expect(process.exitCode).toBe(0); + } finally { + statusSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index d8563c49..2a493d4c 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -25,6 +25,7 @@ describe('help command parity', () => { expect(visibleLines.length).toBeLessThanOrEqual(90); expect(rendered.includes('ccs help ')).toBe(true); + expect(rendered.includes('ccs help browser')).toBe(true); expect(rendered.includes('ccs help completion')).toBe(true); }); @@ -69,6 +70,18 @@ describe('help command parity', () => { expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true); }); + test('browser topic explains Claude attach versus Codex browser tools', async () => { + const rendered = await renderLines((writeLine) => handleHelpRoute(['browser'], writeLine)); + + expect(rendered.includes('CCS Browser Help')).toBe(true); + expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true); + expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe( + true + ); + expect(rendered.includes('ccs browser status')).toBe(true); + expect(rendered.includes('ccs browser doctor')).toBe(true); + }); + test('completion topic documents install and verification paths', async () => { const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine)); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 1afdb956..bfdb1212 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -3,6 +3,7 @@ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; interface RunResult { status: number | null; @@ -193,6 +194,47 @@ process.exit(0); ]); }); + it('skips Codex browser MCP overrides when browser tooling is disabled in config', () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: false, + user_data_dir: '', + devtools_port: 9222, + }, + codex: { + enabled: false, + }, + }; + }); + + const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + }); + + expect(result.status).toBe(0); + const calls = readLoggedCodexCalls(codexArgsLogPath); + expect(calls).toEqual([['fix failing tests']]); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => { if (process.platform === 'win32') return; diff --git a/tests/unit/targets/default-profile-browser-launch.test.ts b/tests/unit/targets/default-profile-browser-launch.test.ts index 2ee938a3..93dd4552 100644 --- a/tests/unit/targets/default-profile-browser-launch.test.ts +++ b/tests/unit/targets/default-profile-browser-launch.test.ts @@ -4,6 +4,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; @@ -245,4 +246,83 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target'); }); + + it('uses config-backed browser attach settings when env overrides are absent', async () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const port = await waitForMockDevtoolsPort(mockServerPortPath); + await waitForDevtoolsVersionEndpoint(port); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/config-target`, + 'utf8' + ); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['default', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-target'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 61052620..b94846fc 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -3,6 +3,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool'; @@ -196,4 +197,89 @@ server.listen(0, '127.0.0.1', () => { expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`); expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target'); }); + + it('uses config-backed browser attach settings for settings-profile launches', async () => { + if (process.platform === 'win32') return; + + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + + try { + const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js'); + const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt'); + fs.writeFileSync( + mockServerScriptPath, + `const { createServer } = require('http'); +const fs = require('fs'); +const server = createServer((req, res) => { + if (req.url === '/json/version') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/config-settings-target' })); + return; + } + res.writeHead(404); + res.end('not found'); +}); +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8'); +}); +`, + 'utf8' + ); + + devtoolsServer = spawn(process.execPath, [mockServerScriptPath], { + stdio: 'ignore', + env: baseEnv, + }); + + const startDeadline = Date.now() + 5000; + while (!fs.existsSync(mockServerPortPath)) { + if (Date.now() > startDeadline) { + throw new Error('Timed out waiting for mock DevTools server to start'); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + const port = fs.readFileSync(mockServerPortPath, 'utf8').trim(); + + fs.mkdirSync(browserProfileDir, { recursive: true }); + fs.writeFileSync( + path.join(browserProfileDir, 'DevToolsActivePort'), + `${port}\n/devtools/browser/config-settings-target`, + 'utf8' + ); + + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: browserProfileDir, + devtools_port: Number.parseInt(port, 10), + }, + codex: { + enabled: true, + }, + }; + }); + + const result = runCcs(['glm', 'smoke'], { + ...baseEnv, + }); + + expect(result.status).toBe(0); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET); + + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`); + expect(launchedEnv).toContain(`port=${port}`); + expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target'); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); }); diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts new file mode 100644 index 00000000..1e047e5d --- /dev/null +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader'; +import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse'; +import { getBrowserStatus } from '../../../../src/utils/browser/browser-status'; +import * as codexDetector from '../../../../src/targets/codex-detector'; + +describe('browser status', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-')); + originalCcsHome = process.env.CCS_HOME; + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + + process.env.CCS_HOME = tempHome; + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + + if (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('returns a disabled Claude lane with the recommended managed user-data dir by default', async () => { + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude).toMatchObject({ + enabled: false, + state: 'disabled', + source: 'config', + effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + }); + expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222'); + expect(status.codex).toMatchObject({ + enabled: true, + state: 'enabled', + serverName: 'ccs_browser', + supportsConfigOverrides: true, + }); + } finally { + codexSpy.mockRestore(); + } + }); + + it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/config-browser', + devtools_port: 9333, + }, + codex: { + enabled: true, + }, + }; + }); + process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser'; + process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444'; + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/env-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9444', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9444', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude).toMatchObject({ + enabled: true, + state: 'ready', + source: 'CCS_BROWSER_USER_DATA_DIR', + effectiveUserDataDir: '/env-browser', + devtoolsPort: 9444, + }); + expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + + it('reports browser_not_running when attach metadata is missing', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/tmp/browser-profile', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue( + new Error('Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort') + ); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(status.claude.state).toBe('browser_not_running'); + expect(status.claude.detail).toContain('DevToolsActivePort'); + expect(status.claude.nextStep).toContain('--remote-debugging-port=9222'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); + + it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => { + process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser'; + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/legacy-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '50123', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:50123', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + const status = await getBrowserStatus(); + + expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ + profileDir: '/legacy-browser', + devtoolsPort: undefined, + }); + expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123'); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); +}); diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts new file mode 100644 index 00000000..17ed127b --- /dev/null +++ b/tests/unit/web-server/browser-routes.test.ts @@ -0,0 +1,171 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import { mkdtempSync, rmSync } from 'node:fs'; +import type { Server } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import browserRoutes from '../../../src/web-server/routes/browser-routes'; +import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader'; + +describe('browser routes', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: string | undefined; + let forcedRemoteAddress = '127.0.0.1'; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); + app.use('/api/browser', browserRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-')); + originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + + rmSync(tempHome, { recursive: true, force: true }); + }); + + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const response = await fetch(`${baseUrl}/api/browser`); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Browser endpoints require localhost access when dashboard auth is disabled.', + }); + }); + + it('returns the default browser config and status payload', async () => { + const response = await fetch(`${baseUrl}/api/browser`); + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.config).toMatchObject({ + claude: { + enabled: false, + userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9222, + }, + codex: { + enabled: true, + }, + }); + expect(payload.status.claude).toMatchObject({ + state: 'disabled', + managedMcpServerName: 'ccs-browser', + }); + expect(payload.status.codex).toMatchObject({ + enabled: true, + serverName: 'ccs_browser', + }); + }); + + it('updates the saved browser config through the dashboard route', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser', + devtoolsPort: 9333, + }, + codex: { + enabled: false, + }, + }), + }); + + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.browser.config).toMatchObject({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser', + devtoolsPort: 9333, + }, + codex: { + enabled: false, + }, + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: true, + user_data_dir: '/tmp/ccs-browser', + devtools_port: 9333, + }, + codex: { + enabled: false, + }, + }); + }); + + it('rejects invalid DevTools ports at the route boundary', async () => { + const response = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + devtoolsPort: 0, + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.', + }); + }); +}); diff --git a/ui/src/components/compatible-cli/codex-docs-tab.tsx b/ui/src/components/compatible-cli/codex-docs-tab.tsx index 8c118e9f..8a88a652 100644 --- a/ui/src/components/compatible-cli/codex-docs-tab.tsx +++ b/ui/src/components/compatible-cli/codex-docs-tab.tsx @@ -139,6 +139,11 @@ export function CodexDocsTab({ diagnostics }: CodexDocsTabProps) { Export CLIPROXY_API_KEY before launching native Codex. +

+ CCS-managed browser tooling belongs to Settings > Browser. Do not edit + the ccs_browser entry from the generic MCP card unless you are + intentionally overriding the managed path in raw TOML. +

diff --git a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx index 3407a550..e1570ed3 100644 --- a/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx +++ b/ui/src/components/compatible-cli/codex-mcp-servers-card.tsx @@ -36,6 +36,8 @@ const EMPTY_MCP_SERVER_DRAFT: CodexMcpServerEntry = { toolTimeoutSec: null, enabledTools: [], disabledTools: [], + isCcsManaged: false, + managementSurface: null, }; function toCsv(value: string[]) { @@ -70,9 +72,16 @@ function McpServerEditor({ }: McpServerEditorProps) { const { t } = useTranslation(); const [draft, setDraft] = useState(initialDraft); + const reservedManagedBrowserDraft = isNew && draft.name.trim() === 'ccs_browser'; return ( <> + {reservedManagedBrowserDraft ? ( +
+ ccs_browser is reserved for the CCS-managed browser tooling path. + Configure it from Settings > Browser instead of creating it here. +
+ ) : null}
{saving ? : null} {/* TODO i18n: missing key codex.saveMcpServer */} @@ -244,6 +255,8 @@ export function CodexMcpServersCard({ ); const draftSeed = selectedEntry ?? EMPTY_MCP_SERVER_DRAFT; const draftKey = JSON.stringify(draftSeed); + const selectedEntryIsManagedBrowser = + selectedEntry?.isCcsManaged && selectedEntry.managementSurface === 'browser-settings'; return ( + {selectedEntryIsManagedBrowser ? ( +
+ {selectedEntry?.name} is CCS-managed. Configure browser tooling from{' '} + Settings > Browser; the generic MCP editor is read-only for this entry. +
+ ) : null} + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { + userDataDir: event.target.value, + }) + ) + } + placeholder={status.claude.recommendedUserDataDir} + /> +

+ {t('settingsPage.browserSection.claude.userDataDirHint')} +

+
+ +
+ + setClaudePortDraft(event.target.value)} + inputMode="numeric" + /> +

+ {claudePortInvalid + ? t('settingsPage.browserSection.claude.devtoolsPortInvalid') + : t('settingsPage.browserSection.claude.devtoolsPortHint')} +

+
+
+ +
+
+

+ {t('settingsPage.browserSection.readiness')} +

+

{status.claude.title}

+

{status.claude.detail}

+
+
+

+ {t('settingsPage.browserSection.nextStep')} +

+

{status.claude.nextStep}

+
+
+ +
+
+

+ {t('settingsPage.browserSection.claude.effectivePath')} +

+

+ {status.claude.effectiveUserDataDir} +

+

+ {t('settingsPage.browserSection.claude.recommendedPath')}:{' '} + {status.claude.recommendedUserDataDir} +

+
+
+

+ {t('settingsPage.browserSection.claude.managedRuntime')} +

+

{status.claude.managedMcpServerName}

+

+ {status.claude.managedMcpServerPath} +

+
+
+ + {status.claude.overrideActive ? ( + + + + {t('settingsPage.browserSection.claude.overrideMessage', { + source: status.claude.source, + })} + + + ) : null} + +
+
+
+

+ {t('settingsPage.browserSection.claude.launchGuidance')} +

+

+ {t('settingsPage.browserSection.claude.launchGuidanceHint')} +

+
+ +
+
+                  {preferredLaunchCommand}
+                
+ {status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL ? ( +

+ DevTools: {status.claude.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL} +

+ ) : null} +
+ +
+ + +
+ + + + + +
+
+ {t('settingsPage.browserSection.codex.title')} + + {t('settingsPage.browserSection.codex.description')} + +
+ + {stateLabel(status.codex.state)} + +
+
+ +
+
+ +

+ {t('settingsPage.browserSection.codex.enabledDescription')} +

+
+ + setDraft((current) => + updateCodexDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + aria-label={t('settingsPage.browserSection.codex.enabledLabel')} + /> +
+ +
+
+

+ {t('settingsPage.browserSection.readiness')} +

+

{status.codex.title}

+

{status.codex.detail}

+
+
+

+ {t('settingsPage.browserSection.nextStep')} +

+

{status.codex.nextStep}

+
+
+ +
+
+

+ {t('settingsPage.browserSection.codex.serverName')} +

+

{status.codex.serverName}

+
+
+

+ {t('settingsPage.browserSection.codex.overrideSupport')} +

+

+ {status.codex.supportsConfigOverrides + ? t('settingsPage.browserSection.codex.overrideSupported') + : t('settingsPage.browserSection.codex.overrideUnsupported')} +

+
+
+

+ {t('settingsPage.browserSection.codex.binary')} +

+

+ {status.codex.binaryPath ?? t('settingsPage.browserSection.codex.notDetected')} +

+ {status.codex.version ? ( +

{status.codex.version}

+ ) : null} +
+
+ +
+ +
+
+
+ + + + ); +} diff --git a/ui/src/pages/settings/settings-context.ts b/ui/src/pages/settings/settings-context.ts index d0329ac8..0aca0404 100644 --- a/ui/src/pages/settings/settings-context.ts +++ b/ui/src/pages/settings/settings-context.ts @@ -5,6 +5,8 @@ import { createContext, type Dispatch } from 'react'; import type { + BrowserConfig, + BrowserStatus, WebSearchConfig, GlobalEnvConfig, CliproxyServerConfig, @@ -15,6 +17,14 @@ import type { // === State === export interface SettingsState { + // Browser state + browserConfig: BrowserConfig | null; + browserStatus: BrowserStatus | null; + browserLoading: boolean; + browserStatusLoading: boolean; + browserSaving: boolean; + browserError: string | null; + browserSuccess: boolean; // WebSearch state webSearchConfig: WebSearchConfig | null; webSearchStatus: WebSearchStatus | null; @@ -43,6 +53,13 @@ export interface SettingsState { } export const initialSettingsState: SettingsState = { + browserConfig: null, + browserStatus: null, + browserLoading: true, + browserStatusLoading: true, + browserSaving: false, + browserError: null, + browserSuccess: false, webSearchConfig: null, webSearchStatus: null, webSearchLoading: true, @@ -69,6 +86,13 @@ export const initialSettingsState: SettingsState = { // === Actions === export type SettingsAction = + | { type: 'SET_BROWSER_CONFIG'; payload: BrowserConfig | null } + | { type: 'SET_BROWSER_STATUS'; payload: BrowserStatus | null } + | { type: 'SET_BROWSER_LOADING'; payload: boolean } + | { type: 'SET_BROWSER_STATUS_LOADING'; payload: boolean } + | { type: 'SET_BROWSER_SAVING'; payload: boolean } + | { type: 'SET_BROWSER_ERROR'; payload: string | null } + | { type: 'SET_BROWSER_SUCCESS'; payload: boolean } | { type: 'SET_WEBSEARCH_CONFIG'; payload: WebSearchConfig | null } | { type: 'SET_WEBSEARCH_STATUS'; payload: WebSearchStatus | null } | { type: 'SET_WEBSEARCH_LOADING'; payload: boolean } @@ -93,6 +117,20 @@ export type SettingsAction = export function settingsReducer(state: SettingsState, action: SettingsAction): SettingsState { switch (action.type) { + case 'SET_BROWSER_CONFIG': + return { ...state, browserConfig: action.payload }; + case 'SET_BROWSER_STATUS': + return { ...state, browserStatus: action.payload }; + case 'SET_BROWSER_LOADING': + return { ...state, browserLoading: action.payload }; + case 'SET_BROWSER_STATUS_LOADING': + return { ...state, browserStatusLoading: action.payload }; + case 'SET_BROWSER_SAVING': + return { ...state, browserSaving: action.payload }; + case 'SET_BROWSER_ERROR': + return { ...state, browserError: action.payload }; + case 'SET_BROWSER_SUCCESS': + return { ...state, browserSuccess: action.payload }; case 'SET_WEBSEARCH_CONFIG': return { ...state, webSearchConfig: action.payload }; case 'SET_WEBSEARCH_STATUS': diff --git a/ui/src/pages/settings/types.ts b/ui/src/pages/settings/types.ts index 1cff3d33..a8765ee7 100644 --- a/ui/src/pages/settings/types.ts +++ b/ui/src/pages/settings/types.ts @@ -3,7 +3,13 @@ * Type definitions for WebSearch, GlobalEnv, and Proxy configurations */ -import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; +import type { + BrowserSettingsConfig, + BrowserStatusPayload, + CliproxyServerConfig, + RemoteProxyStatus, + UpdateBrowserSettingsPayload, +} from '@/lib/api-client'; // === WebSearch Types === @@ -162,6 +168,7 @@ export interface OfficialChannelsStatus { // === Tab Types === export type SettingsTab = + | 'browser' | 'websearch' | 'image' | 'channels' @@ -192,3 +199,6 @@ export interface ThinkingConfig { // === Re-exports from api-client === export type { CliproxyServerConfig, RemoteProxyStatus }; +export type BrowserConfig = BrowserSettingsConfig; +export type BrowserStatus = BrowserStatusPayload; +export type BrowserSavePayload = UpdateBrowserSettingsPayload; diff --git a/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx b/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx new file mode 100644 index 00000000..616fb160 --- /dev/null +++ b/ui/tests/unit/components/compatible-cli/codex-mcp-servers-card.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; +import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card'; + +describe('CodexMcpServersCard', () => { + it('blocks creating the reserved ccs_browser entry from the generic MCP editor', async () => { + render(); + + const nameInput = screen.getByPlaceholderText('playwright'); + await userEvent.type(nameInput, 'ccs_browser'); + + expect( + screen.getAllByText( + (_, element) => + element?.textContent?.includes( + 'ccs_browser is reserved for the CCS-managed browser tooling path.' + ) ?? false + )[0] + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save MCP server' })).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/ui/lib/codex-config-browser.test.ts b/ui/tests/unit/ui/lib/codex-config-browser.test.ts new file mode 100644 index 00000000..6d851fc4 --- /dev/null +++ b/ui/tests/unit/ui/lib/codex-config-browser.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { readCodexMcpServers } from '@/lib/codex-config'; + +describe('readCodexMcpServers', () => { + it('marks the CCS browser MCP entry as managed by Browser settings', () => { + const entries = readCodexMcpServers({ + mcp_servers: { + ccs_browser: { + command: 'npx', + args: ['-y', '@playwright/mcp@0.0.70'], + enabled: true, + }, + playwright: { + command: 'npx', + args: ['-y', '@playwright/mcp@latest'], + enabled: true, + }, + }, + }); + + expect(entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'ccs_browser', + isCcsManaged: true, + managementSurface: 'browser-settings', + }), + expect.objectContaining({ + name: 'playwright', + isCcsManaged: false, + managementSurface: null, + }), + ]) + ); + }); +}); diff --git a/ui/tests/unit/ui/pages/browser-section.test.tsx b/ui/tests/unit/ui/pages/browser-section.test.tsx new file mode 100644 index 00000000..3e6ce379 --- /dev/null +++ b/ui/tests/unit/ui/pages/browser-section.test.tsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const mocks = vi.hoisted(() => ({ + useBrowserConfig: vi.fn(), + useRawConfig: vi.fn(), + fetchConfig: vi.fn(), + fetchStatus: vi.fn(), + saveConfig: vi.fn(), + fetchRawConfig: vi.fn(), +})); + +vi.mock('@/pages/settings/hooks', async () => { + const actual = + await vi.importActual('@/pages/settings/hooks'); + return { + ...actual, + useBrowserConfig: mocks.useBrowserConfig, + useRawConfig: mocks.useRawConfig, + }; +}); + +import BrowserSection from '@/pages/settings/sections/browser'; + +describe('BrowserSection', () => { + beforeEach(() => { + mocks.fetchConfig.mockReset(); + mocks.fetchStatus.mockReset(); + mocks.saveConfig.mockReset(); + mocks.fetchRawConfig.mockReset(); + + mocks.useRawConfig.mockReturnValue({ + rawConfig: 'browser:\n claude:\n enabled: true\n', + loading: false, + copied: false, + fetchRawConfig: mocks.fetchRawConfig, + copyToClipboard: vi.fn(), + }); + + mocks.useBrowserConfig.mockReturnValue({ + config: { + claude: { + enabled: true, + userDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + }, + codex: { + enabled: true, + }, + }, + status: { + claude: { + enabled: true, + source: 'config', + overrideActive: false, + state: 'ready', + title: 'Claude Browser Attach is ready.', + detail: 'CCS can reach the configured Chrome DevTools endpoint.', + nextStep: 'Launch Claude.', + effectiveUserDataDir: '/tmp/browser-profile', + recommendedUserDataDir: '/tmp/browser-profile', + devtoolsPort: 9222, + managedMcpServerName: 'ccs-browser', + managedMcpServerPath: '/tmp/ccs-browser-server.cjs', + launchCommands: { + darwin: + 'open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + linux: + 'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + win32: 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"', + }, + }, + codex: { + enabled: true, + state: 'enabled', + title: 'Codex Browser Tools are enabled.', + detail: 'CCS can inject managed Playwright MCP overrides.', + nextStep: 'Use a Codex-target launch.', + serverName: 'ccs_browser', + supportsConfigOverrides: true, + binaryPath: '/usr/local/bin/codex', + version: 'codex-cli 0.120.0', + }, + }, + loading: false, + statusLoading: false, + saving: false, + error: null, + success: false, + fetchConfig: mocks.fetchConfig, + fetchStatus: mocks.fetchStatus, + saveConfig: mocks.saveConfig, + }); + }); + + it('uses the current draft for launch guidance and disables testing until the draft is saved', async () => { + render(, { withSettingsProvider: true }); + + const pathInput = screen.getByLabelText('Chrome user-data directory'); + await userEvent.clear(pathInput); + await userEvent.type(pathInput, '/tmp/new-browser-profile'); + + const launchCommand = screen.getByText(/new-browser-profile/); + expect(launchCommand).toBeInTheDocument(); + + const testConnectionButton = screen.getByRole('button', { name: 'Test connection' }); + expect(testConnectionButton).toBeDisabled(); + }); +}); diff --git a/ui/tests/unit/ui/pages/settings/settings-page.test.tsx b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx index 651a3adb..a8253db2 100644 --- a/ui/tests/unit/ui/pages/settings/settings-page.test.tsx +++ b/ui/tests/unit/ui/pages/settings/settings-page.test.tsx @@ -31,6 +31,10 @@ vi.mock('@/pages/settings/sections/websearch', () => ({ default: () =>
WebSearch Section
, })); +vi.mock('@/pages/settings/sections/browser', () => ({ + default: () =>
Browser Section
, +})); + vi.mock('@/pages/settings/sections/channels', () => ({ default: () =>
Channels Section
, })); @@ -70,6 +74,7 @@ describe('SettingsPage raw config panel', () => { }); it('keeps the current config editor visible while raw config refreshes', async () => { + window.history.pushState({}, '', '/settings'); mocks.useRawConfig.mockReturnValue({ rawConfig: 'websearch:\n enabled: true\n', loading: true, @@ -84,4 +89,19 @@ describe('SettingsPage raw config panel', () => { expect(screen.getByLabelText('config editor')).toHaveValue('websearch:\n enabled: true\n'); expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); }); + + it('renders the Browser section when the settings tab query is browser', async () => { + window.history.pushState({}, '', '/settings?tab=browser'); + mocks.useRawConfig.mockReturnValue({ + rawConfig: 'browser:\n claude:\n enabled: false\n', + loading: false, + copied: false, + fetchRawConfig: mocks.fetchRawConfig, + copyToClipboard: mocks.copyToClipboard, + }); + + render(); + + expect(await screen.findAllByText('Browser Section')).toHaveLength(2); + }); }); From 06f6f5485fdae1790ac50eee3f0bdc4905b340b1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 18:47:48 -0400 Subject: [PATCH 56/59] feat(settings): refine browser automation tab --- ui/bun.lock | 10 + ui/package.json | 2 + ui/src/lib/i18n.ts | 8 + ui/src/lib/platform.ts | 25 + .../pages/settings/sections/browser/index.tsx | 983 +++++++++++------- ui/tests/unit/ui/lib/platform.test.ts | 32 + 6 files changed, 669 insertions(+), 391 deletions(-) create mode 100644 ui/src/lib/platform.ts create mode 100644 ui/tests/unit/ui/lib/platform.test.ts diff --git a/ui/bun.lock b/ui/bun.lock index d79f76ca..cd5c1000 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -8,6 +8,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -28,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", @@ -273,6 +275,8 @@ "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -735,6 +739,8 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -877,6 +883,10 @@ "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + + "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "msw": ["msw@2.12.4", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-rHNiVfTyKhzc0EjoXUBVGteNKBevdjOlVC6GlIRXpy+/3LHEIGRovnB5WPjcvmNODVQ1TNFnoa7wsGbd0V3epg=="], diff --git a/ui/package.json b/ui/package.json index a5a7ad53..872ab80e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,6 +22,7 @@ "@hookform/resolvers": "^5.2.2", "@nivo/core": "^0.99.0", "@nivo/sankey": "^0.99.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -42,6 +43,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.38.0", "i18next": "^25.8.13", "lucide-react": "^0.556.0", "prism-react-renderer": "^2.4.1", diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 502ea578..5329df3a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -2433,6 +2433,8 @@ const resources = { 'Configure Claude Browser Attach and Codex Browser Tools here, then use the guidance below to launch or verify each lane.', readiness: 'Readiness', nextStep: 'Next step', + technicalDetails: 'Technical Details', + diagnostics: 'Diagnostics', actions: { saveClaude: 'Save Claude settings', saveCodex: 'Save Codex settings', @@ -4846,6 +4848,8 @@ const resources = { '在这里配置 Claude Browser Attach 和 Codex Browser Tools,然后按下方指引启动或验证每条路径。', readiness: '就绪状态', nextStep: '下一步', + technicalDetails: '技术细节', + diagnostics: '诊断信息', actions: { saveClaude: '保存 Claude 设置', saveCodex: '保存 Codex 设置', @@ -7361,6 +7365,8 @@ const resources = { 'Cấu hình Claude Browser Attach và Codex Browser Tools tại đây, rồi dùng hướng dẫn bên dưới để khởi chạy hoặc kiểm tra từng luồng.', readiness: 'Trạng thái sẵn sàng', nextStep: 'Bước tiếp theo', + technicalDetails: 'Chi tiết kỹ thuật', + diagnostics: 'Chẩn đoán', actions: { saveClaude: 'Lưu cấu hình Claude', saveCodex: 'Lưu cấu hình Codex', @@ -9783,6 +9789,8 @@ const resources = { 'ここで Claude Browser Attach と Codex Browser Tools を設定し、各レーンの起動や確認は下の案内を使ってください。', readiness: '準備状況', nextStep: '次の手順', + technicalDetails: '技術的な詳細', + diagnostics: '診断情報', actions: { saveClaude: 'Claude 設定を保存', saveCodex: 'Codex 設定を保存', diff --git a/ui/src/lib/platform.ts b/ui/src/lib/platform.ts new file mode 100644 index 00000000..535abdb7 --- /dev/null +++ b/ui/src/lib/platform.ts @@ -0,0 +1,25 @@ +export type ClientPlatformKey = 'darwin' | 'linux' | 'win32'; + +type NavigatorWithUserAgentData = Pick & { + userAgentData?: { + platform?: string | null; + }; +}; + +function normalizeClientPlatform(value: string): ClientPlatformKey { + const platform = value.toLowerCase(); + if (platform.includes('mac')) return 'darwin'; + if (platform.includes('win')) return 'win32'; + return 'linux'; +} + +export function getClientPlatformKey( + nav: NavigatorWithUserAgentData = navigator as NavigatorWithUserAgentData +): ClientPlatformKey { + const userAgentDataPlatform = + typeof nav.userAgentData?.platform === 'string' ? nav.userAgentData.platform : ''; + const fallbackPlatform = + typeof nav.platform === 'string' && nav.platform.trim() ? nav.platform : nav.userAgent; + + return normalizeClientPlatform(userAgentDataPlatform || fallbackPlatform || ''); +} diff --git a/ui/src/pages/settings/sections/browser/index.tsx b/ui/src/pages/settings/sections/browser/index.tsx index 49dc8301..6c997257 100644 --- a/ui/src/pages/settings/sections/browser/index.tsx +++ b/ui/src/pages/settings/sections/browser/index.tsx @@ -1,60 +1,40 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Browser, + Gear, + CheckCircle, + WarningCircle, + XCircle, + ArrowRight, + ClipboardText, + ArrowsClockwise, + TerminalWindow, + CaretDown, + Info, +} from '@phosphor-icons/react'; import { Alert, AlertDescription } from '@/components/ui/alert'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; -import { - AlertCircle, - CheckCircle2, - Copy, - Monitor, - RefreshCw, - SearchCheck, - Wrench, -} from 'lucide-react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { getClientPlatformKey } from '@/lib/platform'; import { cn } from '@/lib/utils'; import { useBrowserConfig, useRawConfig } from '../../hooks'; import type { BrowserConfig } from '../../types'; -function getPlatformKey(): 'darwin' | 'linux' | 'win32' { - const platform = navigator.platform.toLowerCase(); - if (platform.includes('mac')) return 'darwin'; - if (platform.includes('win')) return 'win32'; - return 'linux'; -} +// --- Constants & Helpers --- function parsePortDraft(value: string): number | null { - if (!/^\d+$/.test(value.trim())) { - return null; - } - + if (!/^\d+$/.test(value.trim())) return null; const port = Number.parseInt(value.trim(), 10); - if (port < 1 || port > 65535) { - return null; - } - + if (port < 1 || port > 65535) return null; return port; } -function statusTone(state: string) { - if (state === 'ready' || state === 'enabled') { - return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'; - } - if (state === 'disabled') { - return 'border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300'; - } - return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'; -} - -function stateLabel(state: string) { - return state.replaceAll('_', ' '); -} - function buildLaunchCommand( userDataDir: string, devtoolsPort: number, @@ -70,32 +50,181 @@ function buildLaunchCommand( return `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`; } -function updateClaudeDraft( - source: BrowserConfig, - updates: Partial -): BrowserConfig { - return { - ...source, - claude: { - ...source.claude, - ...updates, - }, - }; +// --- High-End Components --- + +/** + * Double-Bezel Card Pattern + * Machined hardware look with nested enclosures + */ +function DoubleBezelCard({ + children, + className, + title, + description, + badge, + action, +}: { + children: React.ReactNode; + className?: string; + title?: string; + description?: string; + badge?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+
+ {/* Subtle highlight inner shadow */} +
+ + {(title || action) && ( +
+
+
+

{title}

+ {badge} +
+ {description &&

{description}

} +
+ {action &&
{action}
} +
+ )} +
{children}
+
+
+ ); } -function updateCodexDraft( - source: BrowserConfig, - updates: Partial -): BrowserConfig { - return { - ...source, - codex: { - ...source.codex, - ...updates, - }, - }; +/** + * Animated Status Strip + * Highlights readiness with cinematic dynamics + */ +function StatusStrip({ + state, + title, + detail, + nextStep, +}: { + state: string; + title: string; + detail: string; + nextStep: string; +}) { + const { t } = useTranslation(); + const isReady = state === 'ready' || state === 'enabled'; + const isError = !isReady && state !== 'disabled'; + + return ( +
+
+
+ {isReady ? ( + + ) : isError ? ( + + ) : ( + + )} +
+
+
+ + {t('settingsPage.browserSection.readiness')} + + {isReady && ( + + )} +
+

{title}

+

{detail}

+ {nextStep && ( +
+ +

+ + {t('settingsPage.browserSection.nextStep')}: + {' '} + {nextStep} +

+
+ )} +
+
+
+ ); } +/** + * Diagnostics Accordion + * Pushes secondary details lower in the hierarchy + */ +function DiagnosticsSection({ + title, + children, + defaultOpen = false, +}: { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( + + + + + +
+ {children} +
+
+
+ ); +} + +// --- Main Section --- + export default function BrowserSection() { const { t } = useTranslation(); const { fetchRawConfig } = useRawConfig(); @@ -132,7 +261,7 @@ export default function BrowserSection() { return buildLaunchCommand( effectiveConfig.claude.userDataDir, effectiveConfig.claude.devtoolsPort, - getPlatformKey() + getClientPlatformKey() ); }, [effectiveConfig]); @@ -168,7 +297,6 @@ export default function BrowserSection() { const saveClaudeSettings = useCallback(async () => { if (!effectiveConfig || claudePort === null) return; - const saved = await saveConfig({ claude: { enabled: effectiveConfig.claude.enabled, @@ -176,7 +304,6 @@ export default function BrowserSection() { devtoolsPort: claudePort, }, }); - if (saved) { await fetchRawConfig(); setActionMessage(null); @@ -187,13 +314,11 @@ export default function BrowserSection() { const saveCodexSettings = useCallback(async () => { if (!effectiveConfig) return; - const saved = await saveConfig({ codex: { enabled: effectiveConfig.codex.enabled, }, }); - if (saved) { await fetchRawConfig(); setActionMessage(null); @@ -208,12 +333,21 @@ export default function BrowserSection() { setActionMessage(t('settingsPage.browserSection.messages.launchCommandCopied')); }, [preferredLaunchCommand, t]); + // -- Render Helpers -- + if (loading) { return (
-
- - {t('settings.loading')} +
+ + + + + {t('settings.loading')} +
); @@ -221,357 +355,424 @@ export default function BrowserSection() { if (!config || !status || !effectiveConfig) { return ( -
- - - +
+ + + {error ?? t('settingsPage.browserSection.description')} +
+ +
-
- -
); } return ( -
-
+ {/* Toast Notification Surface */} + + {(error || success || actionMessage) && ( + + {error ? ( +
+ + {error} +
+ ) : ( +
+ + {actionMessage ?? t('commonToast.settingsSaved')} +
+ )} +
)} - > - {error && ( - - - {error} - - )} - {!error && (success || actionMessage) && ( -
- - - {actionMessage ?? t('commonToast.settingsSaved')} - -
- )} -
+ -
-
-
-
- -

{t('settingsPage.browserSection.title')}

+
+ {/* Header Section */} + +
+
+
+ +
+
+

+ {t('settingsPage.browserSection.title')} +

+

+ {t('settingsPage.browserSection.description')} +

+
-

- {t('settingsPage.browserSection.description')} -

- -
+ - - - - {t('settingsPage.browserSection.primaryTitle')}{' '} - {t('settingsPage.browserSection.primaryDescription')} - - + + + + + + {t('settingsPage.browserSection.primaryTitle')} + {' '} + {t('settingsPage.browserSection.primaryDescription')} + + + - - -
-
- {t('settingsPage.browserSection.claude.title')} - - {t('settingsPage.browserSection.claude.description')} - -
- - {stateLabel(status.claude.state)} - -
-
- -
-
- -

- {t('settingsPage.browserSection.claude.enabledDescription')} -

-
- - setDraft((current) => - updateClaudeDraft(current ?? effectiveConfig, { enabled: next }) - ) - } - aria-label={t('settingsPage.browserSection.claude.enabledLabel')} - /> -
- -
-
- - - setDraft((current) => - updateClaudeDraft(current ?? effectiveConfig, { - userDataDir: event.target.value, - }) - ) - } - placeholder={status.claude.recommendedUserDataDir} - /> -

- {t('settingsPage.browserSection.claude.userDataDirHint')} -

-
- -
- - setClaudePortDraft(event.target.value)} - inputMode="numeric" - /> -

- {claudePortInvalid - ? t('settingsPage.browserSection.claude.devtoolsPortInvalid') - : t('settingsPage.browserSection.claude.devtoolsPortHint')} -

-
-
- -
-
-

- {t('settingsPage.browserSection.readiness')} -

-

{status.claude.title}

-

{status.claude.detail}

-
-
-

- {t('settingsPage.browserSection.nextStep')} -

-

{status.claude.nextStep}

-
-
- -
-
-

- {t('settingsPage.browserSection.claude.effectivePath')} -

-

- {status.claude.effectiveUserDataDir} -

-

- {t('settingsPage.browserSection.claude.recommendedPath')}:{' '} - {status.claude.recommendedUserDataDir} -

-
-
-

- {t('settingsPage.browserSection.claude.managedRuntime')} -

-

{status.claude.managedMcpServerName}

-

- {status.claude.managedMcpServerPath} -

-
-
- - {status.claude.overrideActive ? ( - - - - {t('settingsPage.browserSection.claude.overrideMessage', { - source: status.claude.source, - })} - - - ) : null} - -
-
-
-

- {t('settingsPage.browserSection.claude.launchGuidance')} -

-

- {t('settingsPage.browserSection.claude.launchGuidanceHint')} -

+ {/* Claude Lane Card */} + + +
+ + + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + />
+
-
-                  {preferredLaunchCommand}
-                
- {status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL ? ( -

- DevTools: {status.claude.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL} -

- ) : null} -
- -
- - -
- - - - - -
-
- {t('settingsPage.browserSection.codex.title')} - - {t('settingsPage.browserSection.codex.description')} - -
- - {stateLabel(status.codex.state)} - -
-
- -
-
- -

- {t('settingsPage.browserSection.codex.enabledDescription')} -

-
- - setDraft((current) => - updateCodexDraft(current ?? effectiveConfig, { enabled: next }) - ) - } - aria-label={t('settingsPage.browserSection.codex.enabledLabel')} + } + > +
+ -
-
-
-

- {t('settingsPage.browserSection.readiness')} -

-

{status.codex.title}

-

{status.codex.detail}

-
-
-

- {t('settingsPage.browserSection.nextStep')} -

-

{status.codex.nextStep}

-
-
+
+
+
+ + + setDraft((current) => + updateClaudeDraft(current ?? effectiveConfig, { + userDataDir: event.target.value, + }) + ) + } + className="rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + placeholder={status.claude.recommendedUserDataDir} + /> +

+ {t('settingsPage.browserSection.claude.userDataDirHint')} +

+
-
-
-

- {t('settingsPage.browserSection.codex.serverName')} -

-

{status.codex.serverName}

-
-
-

- {t('settingsPage.browserSection.codex.overrideSupport')} -

-

- {status.codex.supportsConfigOverrides - ? t('settingsPage.browserSection.codex.overrideSupported') - : t('settingsPage.browserSection.codex.overrideUnsupported')} -

-
-
-

- {t('settingsPage.browserSection.codex.binary')} -

-

- {status.codex.binaryPath ?? t('settingsPage.browserSection.codex.notDetected')} -

- {status.codex.version ? ( -

{status.codex.version}

- ) : null} -
-
+
+ + setClaudePortDraft(event.target.value)} + inputMode="numeric" + className="max-w-[120px] rounded-xl border-border/70 bg-background/85 dark:border-white/[0.08] dark:bg-zinc-900/70" + /> +

+ {claudePortInvalid + ? t('settingsPage.browserSection.claude.devtoolsPortInvalid') + : t('settingsPage.browserSection.claude.devtoolsPortHint')} +

+
+
-
- +
+
+
+
+

+ {t('settingsPage.browserSection.claude.launchGuidance')} +

+

+ {t('settingsPage.browserSection.claude.launchGuidanceHint')} +

+
+ +
+ + {preferredLaunchCommand} + +
+ + {status.claude.overrideActive && ( +
+ + {t('settingsPage.browserSection.claude.overrideMessage', { + source: status.claude.source, + })} +
+ )} +
+
+ + +
+
+

+ {t('settingsPage.browserSection.claude.effectivePath')} +

+
+

+ {status.claude.effectiveUserDataDir} +

+
+

+ + {t('settingsPage.browserSection.claude.recommendedPath')}: + {' '} + {status.claude.recommendedUserDataDir} +

+
+
+

+ {t('settingsPage.browserSection.claude.managedRuntime')} +

+
+

+ {status.claude.managedMcpServerName} +

+

+ {status.claude.managedMcpServerPath} +

+
+
+
+
- - + + + + {/* Codex Lane Card */} + + +
+ + + setDraft((current) => + updateCodexDraft(current ?? effectiveConfig, { enabled: next }) + ) + } + /> +
+ +
+ } + > +
+ + + +
+
+

+ {t('settingsPage.browserSection.codex.serverName')} +

+
+

+ {status.codex.serverName} +

+
+
+
+

+ {t('settingsPage.browserSection.codex.overrideSupport')} +

+
+ {status.codex.supportsConfigOverrides ? ( + + ) : ( + + )} + {status.codex.supportsConfigOverrides + ? t('settingsPage.browserSection.codex.overrideSupported') + : t('settingsPage.browserSection.codex.overrideUnsupported')} +
+
+
+

+ {t('settingsPage.browserSection.codex.binary')} +

+
+

+ {status.codex.binaryPath ?? + t('settingsPage.browserSection.codex.notDetected')} +

+ {status.codex.version && ( +
+ {status.codex.version} +
+ )} +
+
+
+
+
+ +
); } + +function updateClaudeDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + claude: { + ...source.claude, + ...updates, + }, + }; +} + +function updateCodexDraft( + source: BrowserConfig, + updates: Partial +): BrowserConfig { + return { + ...source, + codex: { + ...source.codex, + ...updates, + }, + }; +} diff --git a/ui/tests/unit/ui/lib/platform.test.ts b/ui/tests/unit/ui/lib/platform.test.ts new file mode 100644 index 00000000..b97f923a --- /dev/null +++ b/ui/tests/unit/ui/lib/platform.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getClientPlatformKey } from '@/lib/platform'; + +describe('getClientPlatformKey', () => { + it('prefers navigator.userAgentData.platform when available', () => { + expect( + getClientPlatformKey({ + userAgentData: { platform: 'macOS' }, + platform: 'Win32', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }) + ).toBe('darwin'); + }); + + it('falls back to navigator.platform when userAgentData is unavailable', () => { + expect( + getClientPlatformKey({ + platform: 'Win32', + userAgent: 'Mozilla/5.0 (X11; Linux x86_64)', + }) + ).toBe('win32'); + }); + + it('falls back to user-agent parsing when platform is unavailable', () => { + expect( + getClientPlatformKey({ + platform: '', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }) + ).toBe('win32'); + }); +}); From 8a17410f9642911d3b74357d6bbfd16aecddad4e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 18:48:28 -0400 Subject: [PATCH 57/59] fix(browser): address platform and port review --- docs/browser-automation.md | 10 +++++ src/commands/browser-command.ts | 12 ++---- src/utils/browser/browser-settings.ts | 3 ++ src/utils/browser/browser-status.ts | 13 ++---- src/utils/browser/platform.ts | 9 ++++ .../unit/utils/browser/browser-status.test.ts | 41 +++++++++++++++++++ tests/unit/utils/browser/platform.test.ts | 17 ++++++++ 7 files changed, 87 insertions(+), 18 deletions(-) create mode 100644 src/utils/browser/platform.ts create mode 100644 tests/unit/utils/browser/platform.test.ts diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 87be8aaf..27ba99a6 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -90,6 +90,16 @@ CCS still supports environment-variable overrides for backward compatibility. If an override is active, Browser status surfaces should report that the current session is being managed externally by environment variables. +Override precedence is: + +1. `CCS_BROWSER_USER_DATA_DIR` +2. `CCS_BROWSER_PROFILE_DIR` +3. the persisted `browser.claude.user_data_dir` config value + +Config-backed Browser Attach always passes an explicit DevTools port to the runtime, even when the +effective value is the default `9222`. Metadata-based port discovery is preserved only for the +legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set. + ## Managed Runtime Files - `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach diff --git a/src/commands/browser-command.ts b/src/commands/browser-command.ts index d42a0654..d9c4ccf6 100644 --- a/src/commands/browser-command.ts +++ b/src/commands/browser-command.ts @@ -1,14 +1,9 @@ import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser'; +import { getNodePlatformKey } from '../utils/browser/platform'; import { color, dim, header, initUI, subheader } from '../utils/ui'; type HelpWriter = (line: string) => void; -function currentPlatform(): 'darwin' | 'linux' | 'win32' { - if (process.platform === 'darwin') return 'darwin'; - if (process.platform === 'win32') return 'win32'; - return 'linux'; -} - function summarizeBrowserHealth(status: BrowserStatusPayload): { label: 'ready' | 'partial' | 'action required'; exitCode: 0 | 1; @@ -63,9 +58,8 @@ function writeClaudeStatus( writeLine(` Detail: ${status.detail}`); writeLine(` Next step: ${status.nextStep}`); if (includeLaunchGuidance && status.enabled && status.state !== 'ready') { - writeLine( - ` Launch command (${currentPlatform()}): ${status.launchCommands[currentPlatform()]}` - ); + const platform = getNodePlatformKey(); + writeLine(` Launch command (${platform}): ${status.launchCommands[platform]}`); } writeLine(''); } diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index 2e0691e2..e1a9f6fe 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -74,6 +74,9 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: false, userDataDir: configUserDataDir, devtoolsPort: configPort, + // Config-backed browser attach always keeps an explicit port so launches + // stay aligned with Settings > Browser, even when the effective value is + // the default 9222. hasExplicitDevtoolsPort: true, }; } diff --git a/src/utils/browser/browser-status.ts b/src/utils/browser/browser-status.ts index 4af83b20..0ea469d2 100644 --- a/src/utils/browser/browser-status.ts +++ b/src/utils/browser/browser-status.ts @@ -2,6 +2,7 @@ import { getBrowserConfig } from '../../config/unified-config-loader'; import { getCodexBinaryInfo } from '../../targets/codex-detector'; import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse'; import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer'; +import { getNodePlatformKey } from './platform'; import { getEffectiveClaudeBrowserAttachConfig, getRecommendedBrowserUserDataDir, @@ -107,7 +108,7 @@ async function buildClaudeBrowserStatus( state: 'path_missing', title: 'Claude Browser Attach path is missing.', detail: message, - nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[platformKey()]}`, + nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[getNodePlatformKey()]}`, }; } @@ -117,7 +118,7 @@ async function buildClaudeBrowserStatus( state: 'browser_not_running', title: 'Claude Browser Attach could not find a running browser session.', detail: message, - nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[platformKey()]}`, + nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[getNodePlatformKey()]}`, }; } @@ -126,7 +127,7 @@ async function buildClaudeBrowserStatus( state: 'endpoint_unreachable', title: 'Claude Browser Attach could not reach the DevTools endpoint.', detail: message, - nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[platformKey()]}`, + nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[getNodePlatformKey()]}`, }; } } @@ -185,9 +186,3 @@ function buildLaunchCommands(userDataDir: string, devtoolsPort: number): Browser win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`, }; } - -function platformKey(): keyof BrowserLaunchCommands { - if (process.platform === 'darwin') return 'darwin'; - if (process.platform === 'win32') return 'win32'; - return 'linux'; -} diff --git a/src/utils/browser/platform.ts b/src/utils/browser/platform.ts new file mode 100644 index 00000000..9f747258 --- /dev/null +++ b/src/utils/browser/platform.ts @@ -0,0 +1,9 @@ +export type BrowserPlatformKey = 'darwin' | 'linux' | 'win32'; + +export function getNodePlatformKey( + platform: NodeJS.Platform = process.platform +): BrowserPlatformKey { + if (platform === 'darwin') return 'darwin'; + if (platform === 'win32') return 'win32'; + return 'linux'; +} diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index 1e047e5d..a15cd461 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -199,4 +199,45 @@ describe('browser status', () => { codexSpy.mockRestore(); } }); + + it('always forwards an explicit port for config-backed browser attach sessions', async () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + user_data_dir: '/tmp/config-browser', + devtools_port: 9222, + }, + codex: { + enabled: true, + }, + }; + }); + + const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({ + CCS_BROWSER_USER_DATA_DIR: '/tmp/config-browser', + CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1', + CCS_BROWSER_DEVTOOLS_PORT: '9222', + CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222', + CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/config', + }); + const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({ + path: '/usr/local/bin/codex', + needsShell: false, + version: 'codex-cli 0.120.0', + features: ['config-overrides'], + }); + + try { + await getBrowserStatus(); + + expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({ + profileDir: '/tmp/config-browser', + devtoolsPort: '9222', + }); + } finally { + runtimeSpy.mockRestore(); + codexSpy.mockRestore(); + } + }); }); diff --git a/tests/unit/utils/browser/platform.test.ts b/tests/unit/utils/browser/platform.test.ts new file mode 100644 index 00000000..2c6ab7a0 --- /dev/null +++ b/tests/unit/utils/browser/platform.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test'; +import { getNodePlatformKey } from '../../../../src/utils/browser/platform'; + +describe('browser platform helper', () => { + it('maps darwin explicitly', () => { + expect(getNodePlatformKey('darwin')).toBe('darwin'); + }); + + it('maps win32 explicitly', () => { + expect(getNodePlatformKey('win32')).toBe('win32'); + }); + + it('falls back to linux for other node platforms', () => { + expect(getNodePlatformKey('linux')).toBe('linux'); + expect(getNodePlatformKey('freebsd')).toBe('linux'); + }); +}); From 4583ffa022909c4456051c6e89be791c18933eab Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 16 Apr 2026 19:00:14 -0400 Subject: [PATCH 58/59] fix(browser): reset blank attach path to default --- src/web-server/routes/browser-routes.ts | 4 +- tests/unit/web-server/browser-routes.test.ts | 43 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/web-server/routes/browser-routes.ts b/src/web-server/routes/browser-routes.ts index 2701339f..14d8bdc3 100644 --- a/src/web-server/routes/browser-routes.ts +++ b/src/web-server/routes/browser-routes.ts @@ -93,11 +93,13 @@ router.put('/', async (req: Request, res: Response): Promise => { try { const current = getBrowserConfig(); + const nextClaudeUserDataDir = + claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim(); mutateUnifiedConfig((config) => { config.browser = { claude: { enabled: claude?.enabled ?? current.claude.enabled, - user_data_dir: claude?.userDataDir?.trim() || current.claude.user_data_dir, + user_data_dir: nextClaudeUserDataDir, devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port, }, codex: { diff --git a/tests/unit/web-server/browser-routes.test.ts b/tests/unit/web-server/browser-routes.test.ts index 17ed127b..bc6e0305 100644 --- a/tests/unit/web-server/browser-routes.test.ts +++ b/tests/unit/web-server/browser-routes.test.ts @@ -152,6 +152,49 @@ describe('browser routes', () => { }); }); + it('treats a blank user-data directory as a reset to the recommended path', async () => { + const firstResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + enabled: true, + userDataDir: '/tmp/ccs-browser-custom', + devtoolsPort: 9333, + }, + }), + }); + + expect(firstResponse.status).toBe(200); + + const resetResponse = await fetch(`${baseUrl}/api/browser`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claude: { + userDataDir: ' ', + }, + }), + }); + + expect(resetResponse.status).toBe(200); + const payload = await resetResponse.json(); + expect(payload.browser.config.claude).toMatchObject({ + enabled: true, + userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtoolsPort: 9333, + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.browser).toMatchObject({ + claude: { + enabled: true, + user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'), + devtools_port: 9333, + }, + }); + }); + it('rejects invalid DevTools ports at the route boundary', async () => { const response = await fetch(`${baseUrl}/api/browser`, { method: 'PUT', From 196a64545880a80a7f357cfee5ad8f5e6fb151b6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 17 Apr 2026 01:21:08 +0000 Subject: [PATCH 59/59] chore(release): 7.71.0-dev.13 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 52e39a8d..642ec2f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.71.0-dev.12", + "version": "7.71.0-dev.13", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",