diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index acb10a43..92080547 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -5,7 +5,16 @@ */ import { CLIProxyProvider } from '../types'; -import { AccountInfo } from '../account-manager'; +import type { AccountInfo } from '../account-manager'; +import { + buildProviderMap, + CLIPROXY_PROVIDER_IDS, + getOAuthCallbackPort, + getCLIProxyCallbackProviderName, + getCLIProxyAuthUrlProviderName, + getProviderAuthFilePrefixes, + getProviderTokenTypeValues, +} from '../provider-capabilities'; /** * Kiro authentication methods supported by CLIProxyAPIPlus. @@ -90,17 +99,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 +224,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/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 abcf3ff8..207ac0fa 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -11,6 +11,11 @@ */ import { CLIProxyProvider } from '../../types'; +import { getProviderAccounts } from '../../account-manager'; +import { + getTokenRefreshOwnership, + isRefreshDelegatedToCLIProxy, +} from '../../provider-capabilities'; import { refreshGeminiToken } from '../gemini-token-refresh'; /** Token refresh result */ @@ -22,64 +27,64 @@ 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', -]; +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 */ export function isRefreshDelegated(provider: CLIProxyProvider): boolean { - return CLIPROXY_DELEGATED_REFRESH.includes(provider); + return isRefreshDelegatedToCLIProxy(provider); } /** * 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 { - switch (provider) { - case 'gemini': - return await refreshGeminiTokenWrapper(); + const normalizedAccountId = accountId.trim(); + if (!normalizedAccountId) { + return { + success: false, + error: 'Account ID is required for token refresh', + }; + } - case 'codex': - case 'agy': - case 'qwen': - case 'iflow': - case 'kiro': - case 'ghcp': - case 'kimi': + 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(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 'claude': + 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 { - success: false, - error: `Unknown provider: ${provider}`, - }; + return assertNever(ownership); } } @@ -87,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 26f9a6ae..f33edd21 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -16,24 +16,41 @@ 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; +/** 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 : DEFAULT_HTTP_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 4ddd4f27..c582cfb7 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -1,11 +1,23 @@ import type { CLIProxyProvider } from './types'; export type OAuthFlowType = 'authorization_code' | 'device_code'; +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. */ + 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. @@ -16,56 +28,110 @@ export interface ProviderCapabilities { export const PROVIDER_CAPABILITIES: Record = { gemini: { displayName: 'Google Gemini', + description: 'Gemini Pro/Flash models', oauthFlow: 'authorization_code', callbackPort: 8085, + callbackProviderName: 'gemini', + authUrlProviderName: 'gemini-cli', + refreshOwnership: 'ccs', + authFilePrefixes: ['gemini-', 'google-'], + tokenTypeValues: ['gemini'], aliases: ['gemini-cli'], }, codex: { - displayName: 'Codex', + displayName: 'OpenAI Codex', + description: 'GPT-4 and codex models', oauthFlow: 'authorization_code', callbackPort: 1455, + callbackProviderName: 'codex', + authUrlProviderName: 'codex', + refreshOwnership: 'cliproxy', + authFilePrefixes: ['codex-', 'openai-'], + tokenTypeValues: ['codex'], aliases: [], }, agy: { - displayName: 'AntiGravity', + displayName: 'Antigravity', + description: 'Antigravity AI models', oauthFlow: 'authorization_code', callbackPort: 51121, + callbackProviderName: 'antigravity', + authUrlProviderName: 'antigravity', + refreshOwnership: 'cliproxy', + authFilePrefixes: ['antigravity-', 'agy-'], + tokenTypeValues: ['antigravity'], aliases: ['antigravity'], }, qwen: { - displayName: 'Qwen', + displayName: 'Alibaba Qwen', + description: 'Qwen Code models', oauthFlow: 'device_code', callbackPort: null, + callbackProviderName: 'qwen', + authUrlProviderName: 'qwen', + refreshOwnership: 'cliproxy', + authFilePrefixes: ['qwen-'], + tokenTypeValues: ['qwen'], aliases: [], }, iflow: { displayName: 'iFlow', + description: 'iFlow AI models', oauthFlow: 'authorization_code', callbackPort: 11451, + callbackProviderName: 'iflow', + authUrlProviderName: 'iflow', + refreshOwnership: 'cliproxy', + authFilePrefixes: ['iflow-'], + tokenTypeValues: ['iflow'], aliases: [], }, kiro: { displayName: 'Kiro (AWS)', + description: 'AWS CodeWhisperer models', oauthFlow: 'device_code', callbackPort: null, + callbackProviderName: 'kiro', + authUrlProviderName: 'kiro', + refreshOwnership: 'cliproxy', + authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'], + tokenTypeValues: ['kiro', 'codewhisperer'], aliases: ['codewhisperer'], }, ghcp: { displayName: 'GitHub Copilot (OAuth)', + description: 'GitHub Copilot via OAuth', oauthFlow: 'device_code', callbackPort: null, + callbackProviderName: 'copilot', + authUrlProviderName: 'github', + refreshOwnership: 'cliproxy', + authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'], + tokenTypeValues: ['github-copilot', 'copilot'], aliases: ['github-copilot', 'copilot'], }, claude: { - displayName: 'Claude', + displayName: 'Claude (Anthropic)', + description: 'Claude Opus/Sonnet models', oauthFlow: 'authorization_code', callbackPort: 54545, + callbackProviderName: 'anthropic', + authUrlProviderName: 'anthropic', + refreshOwnership: 'unsupported', + authFilePrefixes: ['claude-', 'anthropic-'], + tokenTypeValues: ['claude', 'anthropic'], aliases: ['anthropic'], }, kimi: { displayName: 'Kimi (Moonshot)', + description: 'Moonshot AI K2/K2.5 models', oauthFlow: 'device_code', callbackPort: null, + callbackProviderName: 'kimi', + authUrlProviderName: 'kimi', + refreshOwnership: 'cliproxy', + authFilePrefixes: ['kimi-'], + tokenTypeValues: ['kimi'], aliases: ['moonshot'], }, }; @@ -74,18 +140,53 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze( Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] ); +export function buildProviderMap( + 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 = (() => { - 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); @@ -99,6 +200,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 @@ -113,6 +218,30 @@ export function getOAuthCallbackPort(provider: CLIProxyProvider): number | null return PROVIDER_CAPABILITIES[provider].callbackPort; } +export function getCLIProxyCallbackProviderName(provider: CLIProxyProvider): string { + return PROVIDER_CAPABILITIES[provider].callbackProviderName; +} + +export function getCLIProxyAuthUrlProviderName(provider: CLIProxyProvider): string { + return PROVIDER_CAPABILITIES[provider].authUrlProviderName; +} + +export function getTokenRefreshOwnership(provider: CLIProxyProvider): TokenRefreshOwnership { + return PROVIDER_CAPABILITIES[provider].refreshOwnership; +} + +export function isRefreshDelegatedToCLIProxy(provider: CLIProxyProvider): boolean { + return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy'; +} + +export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] { + return PROVIDER_CAPABILITIES[provider].authFilePrefixes; +} + +export function getProviderTokenTypeValues(provider: CLIProxyProvider): readonly string[] { + return PROVIDER_CAPABILITIES[provider].tokenTypeValues; +} + export function mapExternalProviderName(providerName: string): CLIProxyProvider | null { const normalized = providerName.toLowerCase(); return PROVIDER_ALIAS_MAP.get(normalized) ?? null; 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..5317774c 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 { @@ -226,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) { @@ -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, }, }; 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/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 }); 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..c8fe0d1d --- /dev/null +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -0,0 +1,58 @@ +/** + * 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_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS, + getProviderDescription as getBackendProviderDescription, + getProviderDisplayName as getBackendProviderDisplayName, + 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, + PROVIDER_METADATA as UI_PROVIDER_METADATA, +} 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', () => { + 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); + }); + + 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'))); + }); + + 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/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', () => { 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 6fcce60f..a92125b0 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -9,11 +9,9 @@ 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'; - -/** CLIProxyAPI default port */ -const CLIPROXY_DEFAULT_PORT = 8317; +import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; interface AuthTokensResponse { apiKey: { value: string; isCustom: boolean }; @@ -26,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); @@ -42,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(); }, @@ -62,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) @@ -91,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(); @@ -132,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 @@ -266,6 +273,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel {/* Iframe */}