From bd8daac094404bf834d77f5c4bebd9470d88c55f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:06:33 +0700 Subject: [PATCH 01/13] refactor(cliproxy): centralize provider auth capability metadata - move callback/auth-url names, token type values, and prefixes - derive auth maps from capabilities instead of hardcoded records - route refresh delegation through capability ownership metadata --- src/cliproxy/auth/auth-types.ts | 100 ++++++++---------- .../auth/provider-refreshers/index.ts | 68 +++++------- src/cliproxy/provider-capabilities.ts | 80 ++++++++++++++ 3 files changed, 150 insertions(+), 98 deletions(-) diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index acb10a43..90b086d8 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -5,7 +5,27 @@ */ import { CLIProxyProvider } from '../types'; -import { AccountInfo } from '../account-manager'; +import type { AccountInfo } from '../account-manager'; +import { + CLIPROXY_PROVIDER_IDS, + getOAuthCallbackPort, + getCLIProxyCallbackProviderName, + getCLIProxyAuthUrlProviderName, + getProviderAuthFilePrefixes, + getProviderTokenTypeValues, +} from '../provider-capabilities'; + +function buildProviderMap( + valueFor: (provider: CLIProxyProvider) => T +): Record { + return CLIPROXY_PROVIDER_IDS.reduce( + (acc, provider) => { + acc[provider] = valueFor(provider); + return acc; + }, + {} as Record + ); +} /** * Kiro authentication methods supported by CLIProxyAPIPlus. @@ -90,17 +110,17 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' * - GHCP: Device Code Flow (polling-based, NO callback port needed) * - Kimi: Device Code Flow (polling-based, NO callback port needed) */ -export const OAUTH_CALLBACK_PORTS: Partial> = { - gemini: 8085, - codex: 1455, - agy: 51121, - iflow: 11451, - claude: 54545, - // kiro: Device Code Flow - no callback port - // qwen: Device Code Flow - no callback port - // ghcp: Device Code Flow - no callback port - // kimi: Device Code Flow - no callback port -}; +export const OAUTH_CALLBACK_PORTS: Partial> = + CLIPROXY_PROVIDER_IDS.reduce( + (acc, provider) => { + const callbackPort = getOAuthCallbackPort(provider); + if (callbackPort !== null) { + acc[provider] = callbackPort; + } + return acc; + }, + {} as Partial> + ); /** * Auth status for a provider @@ -215,66 +235,34 @@ export const OAUTH_CONFIGS: Record = { * CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json") * Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format */ -export const PROVIDER_AUTH_PREFIXES: Record = { - gemini: ['gemini-', 'google-'], - codex: ['codex-', 'openai-'], - agy: ['antigravity-', 'agy-'], - qwen: ['qwen-'], - iflow: ['iflow-'], - kiro: ['kiro-', 'aws-', 'codewhisperer-'], - ghcp: ['github-copilot-', 'copilot-', 'gh-'], - claude: ['claude-', 'anthropic-'], - kimi: ['kimi-'], -}; +export const PROVIDER_AUTH_PREFIXES: Record = buildProviderMap( + (provider) => [...getProviderAuthFilePrefixes(provider)] +); /** * Provider type values inside token JSON files * CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"}) */ -export const PROVIDER_TYPE_VALUES: Record = { - gemini: ['gemini'], - codex: ['codex'], - agy: ['antigravity'], - qwen: ['qwen'], - iflow: ['iflow'], - kiro: ['kiro', 'codewhisperer'], - ghcp: ['github-copilot', 'copilot'], - claude: ['claude', 'anthropic'], - kimi: ['kimi'], -}; +export const PROVIDER_TYPE_VALUES: Record = buildProviderMap( + (provider) => [...getProviderTokenTypeValues(provider)] +); /** * Maps CCS provider names to CLIProxyAPI callback provider names * Used when submitting OAuth callbacks to CLIProxyAPI management endpoint */ -export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record = { - gemini: 'gemini', - codex: 'codex', - agy: 'antigravity', - kiro: 'kiro', - ghcp: 'copilot', - claude: 'anthropic', - qwen: 'qwen', - iflow: 'iflow', - kimi: 'kimi', -}; +export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record = buildProviderMap( + (provider) => getCLIProxyCallbackProviderName(provider) +); /** * Maps CCS provider names to CLIProxyAPI auth-url endpoint prefixes. * Used for GET /v0/management/${prefix}-auth-url endpoints. * These differ from callback names for some providers (e.g., gemini-cli vs gemini). */ -export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record = { - gemini: 'gemini-cli', - codex: 'codex', - agy: 'antigravity', - kiro: 'kiro', - ghcp: 'github', - claude: 'anthropic', - qwen: 'qwen', - iflow: 'iflow', - kimi: 'kimi', -}; +export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record = buildProviderMap( + (provider) => getCLIProxyAuthUrlProviderName(provider) +); /** * Get OAuth config for provider diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index abcf3ff8..3e7c763c 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -11,6 +11,10 @@ */ import { CLIProxyProvider } from '../../types'; +import { + getTokenRefreshOwnership, + isRefreshDelegatedToCLIProxy, +} from '../../provider-capabilities'; import { refreshGeminiToken } from '../gemini-token-refresh'; /** Token refresh result */ @@ -22,26 +26,11 @@ export interface ProviderRefreshResult { delegated?: boolean; } -/** - * Providers where CLIProxyAPIPlus owns token refresh. - * CLIProxyAPIPlus runs background refresh automatically (e.g. kiro: every 1 min). - * CCS should not attempt to refresh these — just trust CLIProxy. - */ -const CLIPROXY_DELEGATED_REFRESH: CLIProxyProvider[] = [ - 'codex', - 'agy', - 'kiro', - 'ghcp', - 'qwen', - 'iflow', - 'kimi', -]; - /** * Check if a provider's token refresh is delegated to CLIProxy */ export function isRefreshDelegated(provider: CLIProxyProvider): boolean { - return CLIPROXY_DELEGATED_REFRESH.includes(provider); + return isRefreshDelegatedToCLIProxy(provider); } /** @@ -54,33 +43,28 @@ export async function refreshToken( provider: CLIProxyProvider, _accountId: string ): Promise { - switch (provider) { - case 'gemini': - return await refreshGeminiTokenWrapper(); - - case 'codex': - case 'agy': - case 'qwen': - case 'iflow': - case 'kiro': - case 'ghcp': - case 'kimi': - // CLIProxyAPIPlus handles refresh for these providers automatically. - // No action needed from CCS — report success with delegated flag. - return { success: true, delegated: true }; - - case 'claude': - return { - success: false, - error: `Token refresh not yet implemented for ${provider}`, - }; - - default: - return { - success: false, - error: `Unknown provider: ${provider}`, - }; + if (provider === 'gemini') { + return await refreshGeminiTokenWrapper(); } + + if (isRefreshDelegated(provider)) { + // CLIProxyAPIPlus handles refresh for these providers automatically. + // No action needed from CCS — report success with delegated flag. + return { success: true, delegated: true }; + } + + const ownership = getTokenRefreshOwnership(provider); + if (ownership === 'unsupported') { + return { + success: false, + error: `Token refresh not yet implemented for ${provider}`, + }; + } + + return { + success: false, + error: `Unknown provider: ${provider}`, + }; } /** diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 4ddd4f27..5d575569 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -1,11 +1,22 @@ import type { CLIProxyProvider } from './types'; export type OAuthFlowType = 'authorization_code' | 'device_code'; +export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported'; export interface ProviderCapabilities { displayName: string; oauthFlow: OAuthFlowType; callbackPort: number | null; + /** Provider name expected by CLIProxyAPI callback endpoint payload. */ + callbackProviderName: string; + /** Provider name prefix used by CLIProxyAPI auth URL endpoint. */ + authUrlProviderName: string; + /** Who owns token refresh logic for this provider. */ + refreshOwnership: TokenRefreshOwnership; + /** Filename prefixes used to identify auth tokens for this provider. */ + authFilePrefixes: readonly string[]; + /** Token JSON "type" values accepted for this provider. */ + tokenTypeValues: readonly string[]; /** * Alternative provider names used by CLIProxyAPI or stats endpoints. * These aliases normalize external names to canonical CCS provider IDs. @@ -18,54 +29,99 @@ export const PROVIDER_CAPABILITIES: Record Date: Wed, 18 Feb 2026 03:06:44 +0700 Subject: [PATCH 02/13] refactor(cliproxy): use shared default port in management paths - replace 8317 literals with CLIPROXY_DEFAULT_PORT constant - keep http/https fallback behavior unchanged in runtime checks --- src/cliproxy/management-api-client.ts | 6 ++---- src/web-server/routes/cliproxy-stats-routes.ts | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index 26f9a6ae..f89713e1 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -16,13 +16,11 @@ import type { RemoteModelInfo, GetModelDefinitionsResponse, } from './management-api-types'; +import { CLIPROXY_DEFAULT_PORT } from './config/port-manager'; /** Default timeout for management operations (longer than health check) */ const DEFAULT_TIMEOUT_MS = 5000; -/** Default port for HTTP protocol */ -const DEFAULT_HTTP_PORT = 8317; - /** Default port for HTTPS protocol */ const DEFAULT_HTTPS_PORT = 443; @@ -33,7 +31,7 @@ function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) { return port; } - return protocol === 'https' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT; + return protocol === 'https' ? DEFAULT_HTTPS_PORT : CLIPROXY_DEFAULT_PORT; } /** diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 4fd41249..e6c6e475 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -43,6 +43,7 @@ import { DEFAULT_BACKEND, } from '../../cliproxy/platform-detector'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; const router = Router(); @@ -208,7 +209,7 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise // Proxy running but no session lock - legacy/untracked instance res.json({ running: true, - port: 8317, // Default port + port: CLIPROXY_DEFAULT_PORT, sessionCount: 0, // Unknown sessions // No pid/startedAt since we don't have session lock }); From 688f3e3889843931cde2e34fd56ec51df454b0a6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:06:55 +0700 Subject: [PATCH 03/13] refactor(commands): share cliproxy default port for setup and help - source setup wizard local port default from port-manager - render help text defaults from shared constant --- src/commands/help-command.ts | 5 +++-- src/commands/setup-command.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 3a4c0340..868d1060 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui'; import { isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir, getCcsDirSource } from '../utils/config-manager'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; // Get version from package.json (same as version-command.ts) const VERSION = JSON.parse( @@ -345,7 +346,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // CLI Proxy configuration flags (new) printSubSection('CLI Proxy Configuration', [ ['--proxy-host ', 'Remote proxy hostname/IP'], - ['--proxy-port ', 'Proxy port (default: 8317)'], + ['--proxy-port ', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`], ['--proxy-protocol ', 'Protocol: http or https (default: http)'], ['--proxy-auth-token ', 'Auth token for remote proxy'], ['--proxy-timeout ', 'Connection timeout in ms (default: 2000)'], @@ -421,7 +422,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); console.log(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`); console.log(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`); console.log(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`); - console.log(` ${dim('Port: 8317 (default)')}`); + console.log(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`); console.log(''); // Shared Data diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index bd17af07..24ea9235 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -24,6 +24,7 @@ import { } from '../config/unified-config-loader'; import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types'; import { getCcsDir } from '../utils/config-manager'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; /** Custom error for user cancellation (Ctrl+C) */ class UserCancelledError extends Error { @@ -318,7 +319,7 @@ async function runSetupWizard(force: boolean = false): Promise { auto_start: false, }, local: { - port: 8317, + port: CLIPROXY_DEFAULT_PORT, auto_start: false, // Disable local auto-start when using remote }, }; @@ -341,7 +342,7 @@ async function runSetupWizard(force: boolean = false): Promise { auth_token: '', }, local: { - port: 8317, + port: CLIPROXY_DEFAULT_PORT, auto_start: true, }, }; From 63f422179ed34678a8abc0f763a2a3e3525bac16 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:07:05 +0700 Subject: [PATCH 04/13] refactor(ui): centralize default ports and add parity test - add ui default port constants for cliproxy and cursor - wire preset utils to shared ui default port constants - add backend/ui sync test to prevent port drift --- .../backend-ui-default-ports-sync.test.ts | 23 +++++++++++++++++++ ui/src/lib/default-ports.ts | 9 ++++++++ ui/src/lib/preset-utils.ts | 6 +++-- 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts create mode 100644 ui/src/lib/default-ports.ts diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts new file mode 100644 index 00000000..751d7624 --- /dev/null +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -0,0 +1,23 @@ +/** + * Default Port Sync Test + * + * Keeps backend and UI default ports in sync while allowing independent modules. + */ + +import { describe, expect, test } from 'bun:test'; +import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; +import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../src/cursor/cursor-models'; +import { + CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT, + DEFAULT_CURSOR_PORT as UI_CURSOR_DEFAULT_PORT, +} from '../../../ui/src/lib/default-ports'; + +describe('Default Port Sync', () => { + test('CLIProxy default port is synced between backend and UI', () => { + expect(UI_CLIPROXY_DEFAULT_PORT).toBe(BACKEND_CLIPROXY_DEFAULT_PORT); + }); + + test('Cursor default port is synced between backend and UI', () => { + expect(UI_CURSOR_DEFAULT_PORT).toBe(BACKEND_CURSOR_DEFAULT_PORT); + }); +}); diff --git a/ui/src/lib/default-ports.ts b/ui/src/lib/default-ports.ts new file mode 100644 index 00000000..e16a097f --- /dev/null +++ b/ui/src/lib/default-ports.ts @@ -0,0 +1,9 @@ +/** + * UI-side default ports. + * + * Keep UI defaults explicit to preserve frontend decoupling from backend build internals. + * Sync is enforced by backend/UI parity tests in `tests/unit/cliproxy`. + */ + +export const CLIPROXY_DEFAULT_PORT = 8317; +export const DEFAULT_CURSOR_PORT = 20129; diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index ebdb724d..bf2de02b 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -4,9 +4,11 @@ */ import { MODEL_CATALOGS } from './model-catalogs'; +import { CLIPROXY_DEFAULT_PORT } from './default-ports'; +export { CLIPROXY_DEFAULT_PORT } from './default-ports'; /** CLIProxy port - should match the backend configuration */ -export const CLIPROXY_PORT = 8317; +export const CLIPROXY_PORT = CLIPROXY_DEFAULT_PORT; /** Default fallback API key if fetch fails */ const DEFAULT_API_KEY = 'ccs-internal-managed'; @@ -53,7 +55,7 @@ export async function applyDefaultPreset( // Fetch effective API key (respects user customization) const effectiveApiKey = await fetchEffectiveApiKey(); - const effectivePort = port ?? CLIPROXY_PORT; + const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; const settings = { env: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, From feb556dc90dab593e79aaf6145dd5b9a38eb6831 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:07:23 +0700 Subject: [PATCH 05/13] refactor(ui): unify api base path for cursor and copilot hooks - add withApiBase helper and use it across fetch calls - remove duplicated API base literals in cursor/copilot hooks - expose cursor default port from shared ui defaults module --- ui/src/hooks/use-copilot.ts | 25 ++++++++++++------------- ui/src/hooks/use-cursor.ts | 23 ++++++++++++----------- ui/src/lib/api-client.ts | 8 ++++++-- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/ui/src/hooks/use-copilot.ts b/ui/src/hooks/use-copilot.ts index 2aed765e..06e8c84d 100644 --- a/ui/src/hooks/use-copilot.ts +++ b/ui/src/hooks/use-copilot.ts @@ -6,8 +6,7 @@ import { useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; - -const API_BASE = '/api'; +import { withApiBase } from '@/lib/api-client'; // Types export interface CopilotStatus { @@ -80,31 +79,31 @@ export interface CopilotRawSettings { // API functions async function fetchCopilotStatus(): Promise { - const res = await fetch(`${API_BASE}/copilot/status`); + const res = await fetch(withApiBase('/copilot/status')); if (!res.ok) throw new Error('Failed to fetch copilot status'); return res.json(); } async function fetchCopilotConfig(): Promise { - const res = await fetch(`${API_BASE}/copilot/config`); + const res = await fetch(withApiBase('/copilot/config')); if (!res.ok) throw new Error('Failed to fetch copilot config'); return res.json(); } async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> { - const res = await fetch(`${API_BASE}/copilot/models`); + const res = await fetch(withApiBase('/copilot/models')); if (!res.ok) throw new Error('Failed to fetch copilot models'); return res.json(); } async function fetchCopilotRawSettings(): Promise { - const res = await fetch(`${API_BASE}/copilot/settings/raw`); + const res = await fetch(withApiBase('/copilot/settings/raw')); if (!res.ok) throw new Error('Failed to fetch copilot raw settings'); return res.json(); } async function updateCopilotConfig(config: Partial): Promise<{ success: boolean }> { - const res = await fetch(`${API_BASE}/copilot/config`, { + const res = await fetch(withApiBase('/copilot/config'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), @@ -117,7 +116,7 @@ async function saveCopilotRawSettings(data: { settings: CopilotRawSettings['settings']; expectedMtime?: number; }): Promise<{ success: boolean; mtime: number }> { - const res = await fetch(`${API_BASE}/copilot/settings/raw`, { + const res = await fetch(withApiBase('/copilot/settings/raw'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -135,31 +134,31 @@ export interface CopilotAuthResult { } async function startCopilotAuth(): Promise { - const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' }); + const res = await fetch(withApiBase('/copilot/auth/start'), { method: 'POST' }); if (!res.ok) throw new Error('Failed to start auth'); return res.json(); } async function startCopilotDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> { - const res = await fetch(`${API_BASE}/copilot/daemon/start`, { method: 'POST' }); + const res = await fetch(withApiBase('/copilot/daemon/start'), { method: 'POST' }); if (!res.ok) throw new Error('Failed to start daemon'); return res.json(); } async function stopCopilotDaemon(): Promise<{ success: boolean; error?: string }> { - const res = await fetch(`${API_BASE}/copilot/daemon/stop`, { method: 'POST' }); + const res = await fetch(withApiBase('/copilot/daemon/stop'), { method: 'POST' }); if (!res.ok) throw new Error('Failed to stop daemon'); return res.json(); } async function fetchCopilotInfo(): Promise { - const res = await fetch(`${API_BASE}/copilot/info`); + const res = await fetch(withApiBase('/copilot/info')); if (!res.ok) throw new Error('Failed to fetch copilot info'); return res.json(); } async function installCopilotApi(version?: string): Promise { - const res = await fetch(`${API_BASE}/copilot/install`, { + const res = await fetch(withApiBase('/copilot/install'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(version ? { version } : {}), diff --git a/ui/src/hooks/use-cursor.ts b/ui/src/hooks/use-cursor.ts index 20ed9be8..e86d8782 100644 --- a/ui/src/hooks/use-cursor.ts +++ b/ui/src/hooks/use-cursor.ts @@ -6,8 +6,9 @@ import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { withApiBase } from '@/lib/api-client'; -const API_BASE = '/api'; +export { DEFAULT_CURSOR_PORT } from '@/lib/default-ports'; export interface CursorStatus { enabled: boolean; @@ -59,25 +60,25 @@ interface CursorAuthResult { } async function fetchCursorStatus(): Promise { - const res = await fetch(`${API_BASE}/cursor/status`); + const res = await fetch(withApiBase('/cursor/status')); if (!res.ok) throw new Error('Failed to fetch cursor status'); return res.json(); } async function fetchCursorConfig(): Promise { - const res = await fetch(`${API_BASE}/cursor/settings`); + const res = await fetch(withApiBase('/cursor/settings')); if (!res.ok) throw new Error('Failed to fetch cursor config'); return res.json(); } async function fetchCursorModels(): Promise { - const res = await fetch(`${API_BASE}/cursor/models`); + const res = await fetch(withApiBase('/cursor/models')); if (!res.ok) throw new Error('Failed to fetch cursor models'); return res.json(); } async function fetchCursorRawSettings(): Promise { - const res = await fetch(`${API_BASE}/cursor/settings/raw`); + const res = await fetch(withApiBase('/cursor/settings/raw')); if (!res.ok) throw new Error('Failed to fetch cursor raw settings'); return res.json(); } @@ -85,7 +86,7 @@ async function fetchCursorRawSettings(): Promise { async function updateCursorConfig( updates: Partial ): Promise<{ success: boolean; cursor: CursorConfig }> { - const res = await fetch(`${API_BASE}/cursor/settings`, { + const res = await fetch(withApiBase('/cursor/settings'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), @@ -98,7 +99,7 @@ async function saveCursorRawSettings(data: { settings: CursorRawSettings['settings']; expectedMtime?: number; }): Promise<{ success: boolean; mtime: number }> { - const res = await fetch(`${API_BASE}/cursor/settings/raw`, { + const res = await fetch(withApiBase('/cursor/settings/raw'), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -109,7 +110,7 @@ async function saveCursorRawSettings(data: { } async function autoDetectCursorAuth(): Promise { - const res = await fetch(`${API_BASE}/cursor/auth/auto-detect`, { method: 'POST' }); + const res = await fetch(withApiBase('/cursor/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'); @@ -121,7 +122,7 @@ async function importCursorAuthManual(data: { accessToken: string; machineId: string; }): Promise { - const res = await fetch(`${API_BASE}/cursor/auth/import`, { + const res = await fetch(withApiBase('/cursor/auth/import'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), @@ -134,13 +135,13 @@ async function importCursorAuthManual(data: { } async function startCursorDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> { - const res = await fetch(`${API_BASE}/cursor/daemon/start`, { method: 'POST' }); + const res = await fetch(withApiBase('/cursor/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(`${API_BASE}/cursor/daemon/stop`, { method: 'POST' }); + const res = await fetch(withApiBase('/cursor/daemon/stop'), { method: 'POST' }); if (!res.ok) throw new Error('Failed to stop cursor daemon'); return res.json(); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 9f01191f..7b28b20c 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -5,10 +5,14 @@ import type { CLIProxyProvider } from './provider-config'; -const BASE_URL = '/api'; +export const API_BASE_URL = '/api'; + +export function withApiBase(path: string): string { + return `${API_BASE_URL}${path}`; +} async function request(url: string, options?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${url}`, { + const res = await fetch(withApiBase(url), { headers: { 'Content-Type': 'application/json' }, ...options, }); From a53e6cbd2505c2dab5c5e8fd940b75af5584d600 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:07:42 +0700 Subject: [PATCH 06/13] refactor(ui): centralize provider metadata for setup wizard - define provider display names and descriptions in provider-config - consume shared metadata helpers in setup wizard constants --- ui/src/components/setup/wizard/constants.ts | 23 +++----- ui/src/lib/provider-config.ts | 63 ++++++++++++++++++--- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 26addd28..9fba4e91 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -5,20 +5,11 @@ */ import type { ProviderOption } from './types'; -import type { CLIProxyProvider } from '@/lib/provider-config'; - -/** Provider display info for wizard - ordered by recommendation */ -const PROVIDER_INFO: Record = { - agy: { name: 'Antigravity', description: 'Antigravity AI models' }, - claude: { name: 'Claude (Anthropic)', description: 'Claude Opus/Sonnet models' }, - gemini: { name: 'Google Gemini', description: 'Gemini Pro/Flash models' }, - codex: { name: 'OpenAI Codex', description: 'GPT-4 and codex models' }, - qwen: { name: 'Alibaba Qwen', description: 'Qwen Code models' }, - iflow: { name: 'iFlow', description: 'iFlow AI models' }, - kiro: { name: 'Kiro (AWS)', description: 'AWS CodeWhisperer models' }, - ghcp: { name: 'GitHub Copilot (OAuth)', description: 'GitHub Copilot via OAuth' }, - kimi: { name: 'Kimi (Moonshot)', description: 'Moonshot AI K2/K2.5 models' }, -}; +import { + type CLIProxyProvider, + getProviderDescription, + getProviderDisplayName, +} from '@/lib/provider-config'; /** Wizard display order - most recommended first */ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ @@ -35,8 +26,8 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({ id, - name: PROVIDER_INFO[id].name, - description: PROVIDER_INFO[id].description, + name: getProviderDisplayName(id), + description: getProviderDescription(id) || '', })); export const ALL_STEPS = ['provider', 'auth', 'variant', 'success']; diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index cfcf4dbb..eea4f08b 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -30,6 +30,50 @@ export function isValidProvider(provider: string): provider is CLIProxyProvider return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider); } +interface ProviderMetadata { + displayName: string; + description: string; +} + +export const PROVIDER_METADATA: Record = { + agy: { + displayName: 'Antigravity', + description: 'Antigravity AI models', + }, + claude: { + displayName: 'Claude (Anthropic)', + description: 'Claude Opus/Sonnet models', + }, + gemini: { + displayName: 'Google Gemini', + description: 'Gemini Pro/Flash models', + }, + codex: { + displayName: 'OpenAI Codex', + description: 'GPT-4 and codex models', + }, + qwen: { + displayName: 'Alibaba Qwen', + description: 'Qwen Code models', + }, + iflow: { + displayName: 'iFlow', + description: 'iFlow AI models', + }, + kiro: { + displayName: 'Kiro (AWS)', + description: 'AWS CodeWhisperer models', + }, + ghcp: { + displayName: 'GitHub Copilot (OAuth)', + description: 'GitHub Copilot via OAuth', + }, + kimi: { + displayName: 'Kimi (Moonshot)', + description: 'Moonshot AI K2/K2.5 models', + }, +}; + // Map provider names to asset filenames (only providers with actual logos) export const PROVIDER_ASSETS: Record = { gemini: '/assets/providers/gemini-color.svg', @@ -59,16 +103,10 @@ export const PROVIDER_COLORS: Record = { // Provider display names const PROVIDER_NAMES: Record = { - gemini: 'Gemini', - agy: 'Antigravity', - codex: 'Codex', + ...Object.fromEntries( + CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName]) + ), vertex: 'Vertex AI', - iflow: 'iFlow', - qwen: 'Qwen', - kiro: 'Kiro (AWS)', - ghcp: 'GitHub Copilot (OAuth)', - claude: 'Claude (Anthropic)', - kimi: 'Kimi (Moonshot)', }; // Map provider to display name @@ -76,6 +114,13 @@ export function getProviderDisplayName(provider: string): string { return PROVIDER_NAMES[provider.toLowerCase()] || provider; } +/** Map provider to user-facing short description */ +export function getProviderDescription(provider: string): string | undefined { + const normalized = provider.toLowerCase(); + if (!isValidProvider(normalized)) return undefined; + return PROVIDER_METADATA[normalized].description; +} + /** * Providers that use Device Code OAuth flow instead of Authorization Code flow. * Device Code flow requires displaying a user code for manual entry at provider's website. From 5788ddc3b7747df898571470318d5df774603b95 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:07:59 +0700 Subject: [PATCH 07/13] refactor(ui): use shared default proxy port in settings cards - replace inline 8317 defaults with CLIPROXY_DEFAULT_PORT - keep HTTPS fallback behavior at 443 unchanged --- ui/src/pages/settings/sections/proxy/index.tsx | 3 ++- ui/src/pages/settings/sections/proxy/local-proxy-card.tsx | 3 ++- ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index da4bf7f2..e81062c1 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -24,6 +24,7 @@ import { LocalProxyCard } from './local-proxy-card'; import { RemoteProxyCard } from './remote-proxy-card'; import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { api } from '@/lib/api-client'; +import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; /** LocalStorage key for debug mode preference */ const DEBUG_MODE_KEY = 'ccs_debug_mode'; @@ -165,7 +166,7 @@ export default function ProxySection() { const portInput = config.remote.port !== undefined ? config.remote.port.toString() : ''; const authTokenInput = config.remote.auth_token ?? ''; const managementKeyInput = config.remote.management_key ?? ''; - const localPortInput = (config.local.port ?? 8317).toString(); + const localPortInput = (config.local.port ?? CLIPROXY_DEFAULT_PORT).toString(); const displayHost = editedHost ?? hostInput; const displayPort = editedPort ?? portInput; diff --git a/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx index ff2ddb24..e40fe168 100644 --- a/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx +++ b/ui/src/pages/settings/sections/proxy/local-proxy-card.tsx @@ -5,6 +5,7 @@ import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; +import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import type { CliproxyServerConfig } from '../../types'; interface LocalProxyCardProps { @@ -38,7 +39,7 @@ export function LocalProxyCard({ value={displayLocalPort} onChange={(e) => setEditedLocalPort(e.target.value)} onBlur={onSaveLocalPort} - placeholder="8317" + placeholder={`${CLIPROXY_DEFAULT_PORT}`} className="font-mono max-w-32" disabled={saving} /> diff --git a/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx index ad03b65d..60146bac 100644 --- a/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx +++ b/ui/src/pages/settings/sections/proxy/remote-proxy-card.tsx @@ -13,6 +13,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Cloud, RefreshCw, Wifi, WifiOff, CheckCircle2 } from 'lucide-react'; +import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import type { CliproxyServerConfig, RemoteProxyStatus } from '../../types'; interface RemoteProxyCardProps { @@ -59,7 +60,8 @@ export function RemoteProxyCard({ const remoteConfig = config.remote; // HTTP defaults to 8317 (CLIProxyAPI default), HTTPS to 443 (standard SSL) - const getDefaultPort = (protocol: 'http' | 'https') => (protocol === 'https' ? 443 : 8317); + const getDefaultPort = (protocol: 'http' | 'https') => + protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT; return (
From 70116cb3a15694532e57d370245176b905c8f375 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:08:17 +0700 Subject: [PATCH 08/13] refactor(ui): replace remaining hardcoded port defaults - use shared cliproxy default in control panel embed - use shared cursor default port in cursor page fallbacks --- ui/src/components/cliproxy/control-panel-embed.tsx | 4 +--- ui/src/pages/cursor.tsx | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 6fcce60f..e29ea502 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -11,9 +11,7 @@ import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-r import { useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; import type { CliproxyServerConfig } from '@/lib/api-client'; - -/** CLIProxyAPI default port */ -const CLIPROXY_DEFAULT_PORT = 8317; +import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; interface AuthTokensResponse { apiKey: { value: string; isCustom: boolean }; diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index 0997dddd..f66dd584 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -23,7 +23,7 @@ import { XCircle, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useCursor } from '@/hooks/use-cursor'; +import { DEFAULT_CURSOR_PORT, useCursor } from '@/hooks/use-cursor'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -77,7 +77,7 @@ function buildConfigDraft(config?: { haiku_model?: string; }): CursorConfigDraft { return { - port: String(config?.port ?? 20129), + port: String(config?.port ?? DEFAULT_CURSOR_PORT), auto_start: config?.auto_start ?? false, ghost_mode: config?.ghost_mode ?? true, model: config?.model?.trim() || 'gpt-5.3-codex', @@ -767,7 +767,7 @@ export function CursorPage() {
Port - {status?.port ?? config?.port ?? 20129} + {status?.port ?? config?.port ?? DEFAULT_CURSOR_PORT}
From 5a786263bc2501684593c2c1aa0b56b1d20a56ed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:15:03 +0700 Subject: [PATCH 09/13] fix(ui): replace stale BASE_URL references in api client - use withApiBase for config yaml, auth file, and error log endpoints - resolve UI build/typecheck failure in CI validate job --- ui/src/lib/api-client.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 7b28b20c..f3550685 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -490,12 +490,12 @@ export const api = { // Config YAML for Config tab getConfigYaml: async (): Promise => { - const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`); + const res = await fetch(withApiBase('/cliproxy/config.yaml')); if (!res.ok) throw new Error('Failed to load config'); return res.text(); }, saveConfigYaml: async (content: string): Promise => { - const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`, { + const res = await fetch(withApiBase('/cliproxy/config.yaml'), { method: 'PUT', headers: { 'Content-Type': 'application/yaml' }, body: content, @@ -510,7 +510,7 @@ export const api = { getAuthFiles: () => request<{ files: AuthFile[] }>('/cliproxy/auth-files'), getAuthFile: async (name: string): Promise => { const res = await fetch( - `${BASE_URL}/cliproxy/auth-files/download?name=${encodeURIComponent(name)}` + withApiBase(`/cliproxy/auth-files/download?name=${encodeURIComponent(name)}`) ); if (!res.ok) throw new Error('Failed to load auth file'); return res.text(); @@ -592,7 +592,7 @@ export const api = { list: () => request<{ files: CliproxyErrorLog[] }>('/cliproxy/error-logs'), /** Get content of a specific error log */ getContent: async (name: string): Promise => { - const res = await fetch(`${BASE_URL}/cliproxy/error-logs/${encodeURIComponent(name)}`); + const res = await fetch(withApiBase(`/cliproxy/error-logs/${encodeURIComponent(name)}`)); if (!res.ok) throw new Error('Failed to load error log'); return res.text(); }, From 39593c161b4cbbfc6a5a125f6ef100922b73ef9b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:24:28 +0700 Subject: [PATCH 10/13] refactor(cliproxy): address review feedback on parity and refresh flow - make refresh ownership handling exhaustive in provider refresher - extend backend/ui sync test to provider IDs and device-code providers - remove DEFAULT_CURSOR_PORT re-export from use-cursor hook - import cursor default port directly from shared defaults module --- .../auth/provider-refreshers/index.ts | 34 ++++++++++--------- .../backend-ui-default-ports-sync.test.ts | 20 +++++++++++ ui/src/hooks/use-cursor.ts | 2 -- ui/src/pages/cursor.tsx | 3 +- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 3e7c763c..07ed2bd2 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -26,6 +26,10 @@ export interface ProviderRefreshResult { delegated?: boolean; } +function assertNever(value: never): never { + throw new Error(`Unhandled token refresh ownership: ${String(value)}`); +} + /** * Check if a provider's token refresh is delegated to CLIProxy */ @@ -47,24 +51,22 @@ export async function refreshToken( return await refreshGeminiTokenWrapper(); } - if (isRefreshDelegated(provider)) { - // CLIProxyAPIPlus handles refresh for these providers automatically. - // No action needed from CCS — report success with delegated flag. - return { success: true, delegated: true }; - } - const ownership = getTokenRefreshOwnership(provider); - if (ownership === 'unsupported') { - return { - success: false, - error: `Token refresh not yet implemented for ${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}`, + }; + default: + return assertNever(ownership); } - - return { - success: false, - error: `Unknown provider: ${provider}`, - }; } /** diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts index 751d7624..8e9bee7c 100644 --- a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -7,10 +7,22 @@ import { describe, expect, test } from 'bun:test'; import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../src/cursor/cursor-models'; +import { + CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS, + getProvidersByOAuthFlow, +} from '../../../src/cliproxy/provider-capabilities'; import { CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT, DEFAULT_CURSOR_PORT as UI_CURSOR_DEFAULT_PORT, } from '../../../ui/src/lib/default-ports'; +import { + CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, + DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS, +} from '../../../ui/src/lib/provider-config'; + +function sorted(values: readonly string[]): string[] { + return [...values].sort((a, b) => a.localeCompare(b)); +} describe('Default Port Sync', () => { test('CLIProxy default port is synced between backend and UI', () => { @@ -20,4 +32,12 @@ describe('Default Port Sync', () => { test('Cursor default port is synced between backend and UI', () => { expect(UI_CURSOR_DEFAULT_PORT).toBe(BACKEND_CURSOR_DEFAULT_PORT); }); + + test('CLIProxy provider IDs are synced between backend and UI', () => { + expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS)); + }); + + test('Device code providers are synced between backend and UI', () => { + expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code'))); + }); }); diff --git a/ui/src/hooks/use-cursor.ts b/ui/src/hooks/use-cursor.ts index e86d8782..2beca36f 100644 --- a/ui/src/hooks/use-cursor.ts +++ b/ui/src/hooks/use-cursor.ts @@ -8,8 +8,6 @@ import { useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { withApiBase } from '@/lib/api-client'; -export { DEFAULT_CURSOR_PORT } from '@/lib/default-ports'; - export interface CursorStatus { enabled: boolean; authenticated: boolean; diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index f66dd584..ec2da394 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -23,7 +23,8 @@ import { XCircle, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { DEFAULT_CURSOR_PORT, useCursor } from '@/hooks/use-cursor'; +import { useCursor } from '@/hooks/use-cursor'; +import { DEFAULT_CURSOR_PORT } from '@/lib/default-ports'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; From 7e527af777f1ca8ea36570f97aada469c77299ee Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 03:35:08 +0700 Subject: [PATCH 11/13] refactor(cliproxy): resolve remaining review parity and cleanup nits - export buildProviderMap from provider capabilities and reuse in auth-types - add provider descriptions to backend capabilities and sync display names - remove ui CLIPROXY_PORT alias and use CLIPROXY_DEFAULT_PORT directly - return stable string from getProviderDescription - extend backend/ui parity tests for display name and description values --- src/cliproxy/auth/auth-types.ts | 13 +------ src/cliproxy/provider-capabilities.ts | 34 ++++++++++++++++--- .../backend-ui-default-ports-sync.test.ts | 15 ++++++++ .../cliproxy/provider-editor/index.tsx | 6 ++-- ui/src/components/setup/wizard/constants.ts | 2 +- ui/src/lib/preset-utils.ts | 5 +-- ui/src/lib/provider-config.ts | 4 +-- 7 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 90b086d8..92080547 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -7,6 +7,7 @@ import { CLIProxyProvider } from '../types'; import type { AccountInfo } from '../account-manager'; import { + buildProviderMap, CLIPROXY_PROVIDER_IDS, getOAuthCallbackPort, getCLIProxyCallbackProviderName, @@ -15,18 +16,6 @@ import { getProviderTokenTypeValues, } from '../provider-capabilities'; -function buildProviderMap( - valueFor: (provider: CLIProxyProvider) => T -): Record { - return CLIPROXY_PROVIDER_IDS.reduce( - (acc, provider) => { - acc[provider] = valueFor(provider); - return acc; - }, - {} as Record - ); -} - /** * Kiro authentication methods supported by CLIProxyAPIPlus. * - aws: AWS Builder ID via Device Code flow diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 5d575569..04049bcf 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -5,6 +5,7 @@ export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported'; export interface ProviderCapabilities { displayName: string; + description: string; oauthFlow: OAuthFlowType; callbackPort: number | null; /** Provider name expected by CLIProxyAPI callback endpoint payload. */ @@ -27,6 +28,7 @@ export interface ProviderCapabilities { export const PROVIDER_CAPABILITIES: Record = { gemini: { displayName: 'Google Gemini', + description: 'Gemini Pro/Flash models', oauthFlow: 'authorization_code', callbackPort: 8085, callbackProviderName: 'gemini', @@ -37,7 +39,8 @@ export const PROVIDER_CAPABILITIES: Record( + valueFor: (provider: CLIProxyProvider) => T +): Record { + return CLIPROXY_PROVIDER_IDS.reduce( + (acc, provider) => { + acc[provider] = valueFor(provider); + return acc; + }, + {} as Record + ); +} + const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS); const PROVIDER_ALIAS_MAP: ReadonlyMap = (() => { @@ -155,6 +177,10 @@ export function getProviderDisplayName(provider: CLIProxyProvider): string { return PROVIDER_CAPABILITIES[provider].displayName; } +export function getProviderDescription(provider: CLIProxyProvider): string { + return PROVIDER_CAPABILITIES[provider].description; +} + export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvider[] { return CLIPROXY_PROVIDER_IDS.filter( (provider) => PROVIDER_CAPABILITIES[provider].oauthFlow === flowType diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts index 8e9bee7c..c8fe0d1d 100644 --- a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -9,6 +9,8 @@ import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../../.. import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../src/cursor/cursor-models'; import { CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS, + getProviderDescription as getBackendProviderDescription, + getProviderDisplayName as getBackendProviderDisplayName, getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; import { @@ -18,6 +20,7 @@ import { import { CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS, + PROVIDER_METADATA as UI_PROVIDER_METADATA, } from '../../../ui/src/lib/provider-config'; function sorted(values: readonly string[]): string[] { @@ -40,4 +43,16 @@ describe('Default Port Sync', () => { test('Device code providers are synced between backend and UI', () => { expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code'))); }); + + test('Provider display names are synced between backend and UI', () => { + for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) { + expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider)); + } + }); + + test('Provider descriptions are synced between backend and UI', () => { + for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) { + expect(UI_PROVIDER_METADATA[provider].description).toBe(getBackendProviderDescription(provider)); + } + }); }); diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 59658788..ab19c98a 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -16,7 +16,7 @@ import { useCreatePreset, useDeletePreset, } from '@/hooks/use-cliproxy'; -import { CLIPROXY_PORT } from '@/lib/preset-utils'; +import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { usePrivacy } from '@/contexts/privacy-context'; import { useProviderEditor } from './use-provider-editor'; import { CustomPresetDialog } from './custom-preset-dialog'; @@ -117,7 +117,7 @@ export function ProviderEditor({ const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed'; const handleApplyPreset = (updates: Record) => { - const effectivePort = port ?? CLIPROXY_PORT; + const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, ANTHROPIC_AUTH_TOKEN: effectiveApiKey, @@ -127,7 +127,7 @@ export function ProviderEditor({ }; const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => { - const effectivePort = port ?? CLIPROXY_PORT; + const effectivePort = port ?? CLIPROXY_DEFAULT_PORT; updateEnvValues({ ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`, ANTHROPIC_AUTH_TOKEN: effectiveApiKey, diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 9fba4e91..9f5014ed 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -27,7 +27,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({ id, name: getProviderDisplayName(id), - description: getProviderDescription(id) || '', + description: getProviderDescription(id), })); export const ALL_STEPS = ['provider', 'auth', 'variant', 'success']; diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index bf2de02b..1121d5ab 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -7,9 +7,6 @@ import { MODEL_CATALOGS } from './model-catalogs'; import { CLIPROXY_DEFAULT_PORT } from './default-ports'; export { CLIPROXY_DEFAULT_PORT } from './default-ports'; -/** CLIProxy port - should match the backend configuration */ -export const CLIPROXY_PORT = CLIPROXY_DEFAULT_PORT; - /** Default fallback API key if fetch fails */ const DEFAULT_API_KEY = 'ccs-internal-managed'; @@ -33,7 +30,7 @@ async function fetchEffectiveApiKey(): Promise { * Uses the first model's presetMapping or falls back to using defaultModel for all tiers * * @param provider - The provider ID (e.g., 'gemini', 'codex', 'agy') - * @param port - Optional custom port (defaults to CLIPROXY_PORT) + * @param port - Optional custom port (defaults to CLIPROXY_DEFAULT_PORT) * @returns Object with success status and applied preset name */ export async function applyDefaultPreset( diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index eea4f08b..9a3fdc18 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -115,9 +115,9 @@ export function getProviderDisplayName(provider: string): string { } /** Map provider to user-facing short description */ -export function getProviderDescription(provider: string): string | undefined { +export function getProviderDescription(provider: string): string { const normalized = provider.toLowerCase(); - if (!isValidProvider(normalized)) return undefined; + if (!isValidProvider(normalized)) return ''; return PROVIDER_METADATA[normalized].description; } From a71496cc3d7db499780c2b257d74bb6cc101f450 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 04:18:39 +0700 Subject: [PATCH 12/13] fix(cliproxy): harden provider alias and refresh edge cases - guard provider alias generation against ambiguous collisions - add account-scoped Gemini token refresh with strict account checks - warn and fallback on invalid management remote ports - align remaining HTTP remote defaults and extend cliproxy tests --- src/cliproxy/auth/gemini-token-refresh.ts | 88 ++++++++++++------- .../auth/provider-refreshers/index.ts | 29 ++++-- src/cliproxy/management-api-client.ts | 23 ++++- src/cliproxy/provider-capabilities.ts | 39 ++++++-- src/commands/setup-command.ts | 2 +- src/config/unified-config-types.ts | 2 +- .../cliproxy/management-api-client.test.ts | 14 ++- .../cliproxy/provider-capabilities.test.ts | 22 ++++- 8 files changed, 167 insertions(+), 52 deletions(-) diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index 2117a3be..c08799b8 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -108,42 +108,53 @@ function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken { * Read Gemini token from CLIProxy auth directory * Returns credentials with source path, or null if no valid token found */ -function readCliproxyGeminiCreds(): GeminiCredsWithSource | null { +function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null { const authDir = getProviderAuthDir('gemini'); if (!fs.existsSync(authDir)) return null; - // Try to find default account's token file - const defaultAccount = getDefaultAccount('gemini'); let tokenPath: string | null = null; + const normalizedAccountId = accountId?.trim(); + const accounts = getProviderAccounts('gemini'); - if (defaultAccount) { - tokenPath = path.join(authDir, defaultAccount.tokenFile); - if (!fs.existsSync(tokenPath)) tokenPath = null; + // 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); } - // Fallback: find any gemini token file by prefix or type - if (!tokenPath) { - const accounts = getProviderAccounts('gemini'); - if (accounts.length > 0) { + 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; + // 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; } - } catch { - // Directory read failed - continue to return null - return null; } } @@ -172,13 +183,19 @@ function readCliproxyGeminiCreds(): GeminiCredsWithSource | null { * Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json * Returns credentials with source path for correct write-back */ -function readGeminiCreds(): GeminiCredsWithSource | null { +function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null { // 1. Try CLIProxy auth directory first (CCS-managed tokens) - const cliproxyResult = readCliproxyGeminiCreds(); + 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)) { @@ -249,8 +266,8 @@ function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | /** * Check if Gemini token is expired or expiring soon */ -export function isGeminiTokenExpiringSoon(): boolean { - const result = readGeminiCreds(); +export function isGeminiTokenExpiringSoon(accountId?: string): boolean { + const result = readGeminiCreds(accountId); if (!result || !result.creds.access_token) { return true; // No token = needs auth } @@ -263,14 +280,15 @@ export function isGeminiTokenExpiringSoon(): boolean { /** * 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(): Promise<{ +export async function refreshGeminiToken(accountId?: string): Promise<{ success: boolean; error?: string; expiresAt?: number; }> { - const result = readGeminiCreds(); + const result = readGeminiCreds(accountId); if (!result || !result.creds.refresh_token) { return { success: false, error: 'No refresh token available' }; } @@ -334,19 +352,23 @@ export async function refreshGeminiToken(): Promise<{ /** * 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): Promise<{ +export async function ensureGeminiTokenValid( + verbose = false, + accountId?: string +): Promise<{ valid: boolean; refreshed: boolean; error?: string; }> { - const result = readGeminiCreds(); + const result = readGeminiCreds(accountId); if (!result || !result.creds.access_token) { return { valid: false, refreshed: false, error: 'No Gemini credentials found' }; } - if (!isGeminiTokenExpiringSoon()) { + if (!isGeminiTokenExpiringSoon(accountId)) { return { valid: true, refreshed: false }; } @@ -355,7 +377,7 @@ export async function ensureGeminiTokenValid(verbose = false): Promise<{ console.log('[i] Gemini token expired or expiring soon, refreshing...'); } - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { if (verbose) { console.log('[OK] Gemini token refreshed successfully'); diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 07ed2bd2..207ac0fa 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -11,6 +11,7 @@ */ import { CLIProxyProvider } from '../../types'; +import { getProviderAccounts } from '../../account-manager'; import { getTokenRefreshOwnership, isRefreshDelegatedToCLIProxy, @@ -40,15 +41,33 @@ export function isRefreshDelegated(provider: CLIProxyProvider): boolean { /** * Refresh token for a specific provider and account * @param provider Provider to refresh - * @param _accountId Account ID (currently unused, multi-account not yet implemented) + * @param accountId Account ID used to refresh the correct provider token * @returns Refresh result with success status and optional error */ export async function refreshToken( provider: CLIProxyProvider, - _accountId: string + accountId: string ): Promise { + const normalizedAccountId = accountId.trim(); + if (!normalizedAccountId) { + return { + success: false, + error: 'Account ID is required for token refresh', + }; + } + + const hasAccount = getProviderAccounts(provider).some( + (account) => account.id === normalizedAccountId + ); + if (!hasAccount) { + return { + success: false, + error: `Account not found for ${provider}: ${normalizedAccountId}`, + }; + } + if (provider === 'gemini') { - return await refreshGeminiTokenWrapper(); + return await refreshGeminiTokenWrapper(normalizedAccountId); } const ownership = getTokenRefreshOwnership(provider); @@ -73,8 +92,8 @@ export async function refreshToken( * Wrapper for Gemini token refresh * Converts gemini-token-refresh.ts format to provider-refreshers format */ -async function refreshGeminiTokenWrapper(): Promise { - const result = await refreshGeminiToken(); +async function refreshGeminiTokenWrapper(accountId: string): Promise { + const result = await refreshGeminiToken(accountId); if (!result.success) { return { diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index f89713e1..f33edd21 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -24,14 +24,33 @@ const DEFAULT_TIMEOUT_MS = 5000; /** Default port for HTTPS protocol */ const DEFAULT_HTTPS_PORT = 443; +/** Avoid duplicate warnings for repeated invalid port inputs */ +const WARNED_INVALID_PORTS = new Set(); + +function isValidPort(port: number | undefined): port is number { + return port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535; +} + /** * Get effective port based on config and protocol. */ function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number { - if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) { + if (isValidPort(port)) { return port; } - return protocol === 'https' ? DEFAULT_HTTPS_PORT : CLIPROXY_DEFAULT_PORT; + + const fallbackPort = protocol === 'https' ? DEFAULT_HTTPS_PORT : CLIPROXY_DEFAULT_PORT; + if (port !== undefined) { + const warningKey = `${protocol}:${String(port)}`; + if (!WARNED_INVALID_PORTS.has(warningKey)) { + WARNED_INVALID_PORTS.add(warningKey); + console.warn( + `[management-api-client] Invalid port "${String(port)}", using default ${fallbackPort}` + ); + } + } + + return fallbackPort; } /** diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 04049bcf..c582cfb7 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -154,16 +154,39 @@ export function buildProviderMap( const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS); -const PROVIDER_ALIAS_MAP: ReadonlyMap = (() => { - const entries: Array<[string, CLIProxyProvider]> = []; - for (const provider of CLIPROXY_PROVIDER_IDS) { - entries.push([provider, provider]); - for (const alias of PROVIDER_CAPABILITIES[provider].aliases) { - entries.push([alias.toLowerCase(), provider]); +export function buildProviderAliasMap( + capabilities: Record = PROVIDER_CAPABILITIES +): ReadonlyMap { + const aliasMap = new Map(); + const providers = Object.keys(capabilities) as CLIProxyProvider[]; + + const registerAlias = (alias: string, provider: CLIProxyProvider): void => { + const normalized = alias.trim().toLowerCase(); + if (!normalized) { + return; + } + + const existingProvider = aliasMap.get(normalized); + if (existingProvider && existingProvider !== provider) { + throw new Error( + `Provider alias collision for "${normalized}": ${existingProvider} and ${provider}` + ); + } + + aliasMap.set(normalized, provider); + }; + + for (const provider of providers) { + registerAlias(provider, provider); + for (const alias of capabilities[provider].aliases) { + registerAlias(alias, provider); } } - return new Map(entries); -})(); + + return aliasMap; +} + +const PROVIDER_ALIAS_MAP: ReadonlyMap = buildProviderAliasMap(); export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider { return PROVIDER_ID_SET.has(provider as CLIProxyProvider); diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index 24ea9235..5317774c 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -227,7 +227,7 @@ async function configureRemoteProxy(rl: readline.Interface): Promise<{ ])) as 'http' | 'https'; // Port (optional) - with validation - const defaultPort = protocol === 'https' ? '443' : '80'; + const defaultPort = protocol === 'https' ? '443' : String(CLIPROXY_DEFAULT_PORT); const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`); let port: number | undefined; if (portStr) { diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 5e5e0d2d..1b1d1d9c 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -328,7 +328,7 @@ export interface ProxyRemoteConfig { * Remote proxy port. * Optional - defaults based on protocol: * - HTTPS: 443 - * - HTTP: 80 + * - HTTP: 8317 * When empty/undefined, uses protocol default. */ port?: number; diff --git a/tests/unit/cliproxy/management-api-client.test.ts b/tests/unit/cliproxy/management-api-client.test.ts index e3b6b7b4..58edbdb2 100644 --- a/tests/unit/cliproxy/management-api-client.test.ts +++ b/tests/unit/cliproxy/management-api-client.test.ts @@ -1,7 +1,7 @@ /** * Unit tests for management-api-client module */ -import { describe, it, expect, beforeEach, mock } from 'bun:test'; +import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test'; import { ManagementApiClient } from '../../../src/cliproxy/management-api-client'; import type { ManagementClientConfig, @@ -76,6 +76,18 @@ describe('management-api-client', () => { const client = new ManagementApiClient(configNoPort); expect(client.getBaseUrl()).toBe('https://localhost'); }); + + it('should warn and fall back when configured port is invalid', () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + + const client = new ManagementApiClient({ ...config, port: 99999 }); + expect(client.getBaseUrl()).toBe('http://localhost:8317'); + expect(warnSpy).toHaveBeenCalledWith( + '[management-api-client] Invalid port "99999", using default 8317' + ); + + warnSpy.mockRestore(); + }); }); describe('error code mapping', () => { diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index 13f3e8f7..bbc8ae8b 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'bun:test'; import { + buildProviderAliasMap, CLIPROXY_PROVIDER_IDS, getOAuthCallbackPort, getOAuthFlowType, + PROVIDER_CAPABILITIES, getProviderDisplayName, getProvidersByOAuthFlow, isCLIProxyProvider, @@ -68,7 +70,25 @@ describe('provider-capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); expect(getOAuthCallbackPort('gemini')).toBe(8085); - expect(getProviderDisplayName('agy')).toBe('AntiGravity'); + expect(getProviderDisplayName('agy')).toBe('Antigravity'); + }); + + it('throws when provider aliases collide across providers', () => { + const capabilitiesWithCollision = { + ...PROVIDER_CAPABILITIES, + gemini: { + ...PROVIDER_CAPABILITIES.gemini, + aliases: ['shared-alias'], + }, + codex: { + ...PROVIDER_CAPABILITIES.codex, + aliases: ['shared-alias'], + }, + }; + + expect(() => + buildProviderAliasMap(capabilitiesWithCollision as typeof PROVIDER_CAPABILITIES) + ).toThrow(/shared-alias/i); }); it('keeps diagnostics flow metadata in sync with provider capabilities', () => { From c18adc90c33b45577422de4929edb9034e854941 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 04:18:57 +0700 Subject: [PATCH 13/13] fix(ui): harden cliproxy panel and proxy edge handling - make iframe auto-login/reload flow race-safe and lint-compliant - normalize API base joins and replace conflict sentinels with typed errors - tighten proxy port validation and defer cursor preset apply until models load - add ui api-client unit coverage for path and conflict helpers --- tests/unit/ui-api-client.test.ts | 32 +++++++ .../cliproxy/control-panel-embed.tsx | 94 ++++++++++--------- .../config-form/use-copilot-config-form.ts | 3 +- ui/src/hooks/use-copilot.ts | 4 +- ui/src/hooks/use-cursor.ts | 4 +- ui/src/lib/api-client.ts | 76 ++++++++++++++- ui/src/pages/cursor.tsx | 19 +++- .../pages/settings/sections/proxy/index.tsx | 37 ++++++-- .../sections/proxy/local-proxy-card.tsx | 5 +- 9 files changed, 210 insertions(+), 64 deletions(-) create mode 100644 tests/unit/ui-api-client.test.ts diff --git a/tests/unit/ui-api-client.test.ts b/tests/unit/ui-api-client.test.ts new file mode 100644 index 00000000..c5edc307 --- /dev/null +++ b/tests/unit/ui-api-client.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'bun:test'; +import { + API_BASE_URL, + API_CONFLICT_ERROR_CODE, + ApiConflictError, + isApiConflictError, + withApiBase, +} from '../../ui/src/lib/api-client'; + +describe('ui api-client helpers', () => { + it('normalizes relative paths with API base prefix', () => { + expect(withApiBase('/cliproxy/status')).toBe('/api/cliproxy/status'); + expect(withApiBase('cliproxy/status')).toBe('/api/cliproxy/status'); + }); + + it('preserves paths that already include API base', () => { + expect(withApiBase('/api/cliproxy/status')).toBe('/api/cliproxy/status'); + expect(withApiBase('/api')).toBe('/api'); + }); + + it('handles empty and absolute URLs safely', () => { + expect(withApiBase('')).toBe(API_BASE_URL); + expect(withApiBase('https://example.com/api')).toBe('https://example.com/api'); + }); + + it('identifies typed API conflict errors', () => { + const conflict = new ApiConflictError('conflict'); + expect(conflict.code).toBe(API_CONFLICT_ERROR_CODE); + expect(isApiConflictError(conflict)).toBe(true); + expect(isApiConflictError(new Error('plain'))).toBe(false); + }); +}); diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index e29ea502..a92125b0 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -9,7 +9,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; -import { api } from '@/lib/api-client'; +import { api, withApiBase } from '@/lib/api-client'; import type { CliproxyServerConfig } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; @@ -24,7 +24,8 @@ interface ControlPanelEmbedProps { export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) { const iframeRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); + const [loadedUrl, setLoadedUrl] = useState(null); + const [iframeRevision, setIframeRevision] = useState(0); const [error, setError] = useState(null); const [isConnected, setIsConnected] = useState(false); const [showLoginHint, setShowLoginHint] = useState(true); @@ -40,7 +41,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const { data: authTokens } = useQuery({ queryKey: ['auth-tokens-raw'], queryFn: async () => { - const response = await fetch('/api/settings/auth/tokens/raw'); + const response = await fetch(withApiBase('/settings/auth/tokens/raw')); if (!response.ok) throw new Error('Failed to fetch auth tokens'); return response.json(); }, @@ -60,8 +61,8 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel if (remote?.enabled && remote?.host) { const protocol = remote.protocol || 'http'; - // Use port from config, or default based on protocol (443 for https, 80 for http) - const remotePort = remote.port || (protocol === 'https' ? 443 : 80); + // Use port from config, or default based on protocol (443 for https, 8317 for http) + const remotePort = remote.port || (protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT); // Only include port in URL if it's non-standard const portSuffix = (protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80) @@ -89,6 +90,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel }; }, [cliproxyConfig, authTokens, port]); + const iframeLoaded = loadedUrl === managementUrl; + const isLoading = !iframeLoaded; + // Check if CLIProxy is running useEffect(() => { const controller = new AbortController(); @@ -130,48 +134,53 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel return () => controller.abort(); }, [checkUrl, isRemote, displayHost]); - // Handle iframe load - attempt to auto-login via postMessage - const handleIframeLoad = useCallback(() => { - setIsLoading(false); - - // Try to inject credentials via postMessage - // The management.html needs to listen for this message - // If it doesn't support it, user will see the login page - if (iframeRef.current?.contentWindow && authToken) { - try { - // Derive apiBase from checkUrl (remove trailing slash) - const apiBase = checkUrl.replace(/\/$/, ''); - - // Security: Validate iframe src matches target origin before sending credentials - const iframeSrc = iframeRef.current.src; - if (!iframeSrc.startsWith(apiBase)) { - console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); - return; - } - - // Send credentials to iframe - iframeRef.current.contentWindow.postMessage( - { - type: 'ccs-auto-login', - apiBase, - managementKey: authToken, - }, - apiBase - ); - } catch (e) { - // Cross-origin restriction - expected if not same origin - console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e); - } + const postAutoLoginCredentials = useCallback(() => { + // Auto-login can only run when iframe has loaded and authToken is available. + if (!iframeLoaded || !iframeRef.current?.contentWindow || !authToken) { + return; } - }, [checkUrl, authToken]); + + try { + // Derive apiBase from checkUrl (remove trailing slash) + const apiBase = checkUrl.replace(/\/$/, ''); + + // Security: Validate iframe src matches target origin before sending credentials + const iframeSrc = iframeRef.current.src; + if (!iframeSrc.startsWith(apiBase)) { + console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); + return; + } + + // Send credentials to iframe + iframeRef.current.contentWindow.postMessage( + { + type: 'ccs-auto-login', + apiBase, + managementKey: authToken, + }, + apiBase + ); + } catch (e) { + // Cross-origin restriction - expected if not same origin + console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e); + } + }, [authToken, checkUrl, iframeLoaded]); + + // Retry auto-login when token/checkUrl arrive after iframe onLoad. + useEffect(() => { + postAutoLoginCredentials(); + }, [postAutoLoginCredentials]); + + // Handle iframe load - mark ready then let effect post credentials. + const handleIframeLoad = useCallback(() => { + setLoadedUrl(managementUrl); + }, [managementUrl]); const handleRefresh = () => { - setIsLoading(true); + setLoadedUrl(null); + setIframeRevision((value) => value + 1); setError(null); setIsConnected(false); - if (iframeRef.current) { - iframeRef.current.src = managementUrl; - } }; // Show error state if CLIProxy is not running @@ -264,6 +273,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel {/* Iframe */}