diff --git a/README.md b/README.md index bb69dc9c..0a6620c7 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou | OAuth Providers | [docs.ccs.kaitran.ca/providers/oauth-providers](https://docs.ccs.kaitran.ca/providers/oauth-providers) | | Multi-Account Claude | [docs.ccs.kaitran.ca/providers/claude-accounts](https://docs.ccs.kaitran.ca/providers/claude-accounts) | | API Profiles | [docs.ccs.kaitran.ca/providers/api-profiles](https://docs.ccs.kaitran.ca/providers/api-profiles) | +| Remote Proxy | [docs.ccs.kaitran.ca/features/remote-proxy](https://docs.ccs.kaitran.ca/features/remote-proxy) | | CLI Reference | [docs.ccs.kaitran.ca/reference/cli-commands](https://docs.ccs.kaitran.ca/reference/cli-commands) | | Architecture | [docs.ccs.kaitran.ca/reference/architecture](https://docs.ccs.kaitran.ca/reference/architecture) | | Troubleshooting | [docs.ccs.kaitran.ca/reference/troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) | diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 1bad1d16..9cd4277f 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -21,14 +21,17 @@ import { ensureCLIProxyBinary } from './binary-manager'; import { generateConfig, getEffectiveEnvVars, + getRemoteEnvVars, getProviderConfig, ensureProviderSettings, CLIPROXY_DEFAULT_PORT, getCliproxyWritablePath, } from './config-generator'; +import { checkRemoteProxy } from './remote-proxy-client'; import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, ExecutorConfig } from './types'; import { configureProviderModel, getCurrentModel } from './model-config'; +import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { @@ -126,6 +129,22 @@ export async function execClaudeWithCLIProxy( } }; + // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) + // This filters proxy flags from args and returns resolved config + const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args); + + // Use resolved port from proxy config (overrides ExecutorConfig) + if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { + cfg.port = proxyConfig.port; + } + + log(`Proxy mode: ${proxyConfig.mode}`); + if (proxyConfig.mode === 'remote') { + log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); + } + + // Note: proxyConfig is available for Phase 4 (remote mode integration) + // Ensure MCP web-search is configured for third-party profiles // WebSearch is a server-side tool executed by Anthropic's API // Third-party providers don't have access, so we use MCP fallback @@ -142,39 +161,102 @@ export async function execClaudeWithCLIProxy( const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); - // 1. Ensure binary exists (downloads if needed) - const spinner = new ProgressIndicator('Preparing CLIProxy'); - spinner.start(); + // Check remote proxy if configured (before binary download) + let useRemoteProxy = false; + if (proxyConfig.mode === 'remote' && proxyConfig.host) { + const status = await checkRemoteProxy({ + host: proxyConfig.host, + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + timeout: 2000, + allowSelfSigned: proxyConfig.protocol === 'https', + }); - let binaryPath: string; - try { - binaryPath = await ensureCLIProxyBinary(verbose); - spinner.succeed('CLIProxy binary ready'); - } catch (error) { - spinner.fail('Failed to prepare CLIProxy'); - throw error; + if (status.reachable) { + useRemoteProxy = true; + console.log( + ok( + `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)` + ) + ); + } else { + console.error(warn(`Remote proxy unreachable: ${status.error}`)); + + if (proxyConfig.remoteOnly) { + throw new Error('Remote proxy unreachable and --remote-only specified'); + } + + if (proxyConfig.fallbackEnabled) { + if (proxyConfig.autoStartLocal) { + console.log(info('Falling back to local proxy...')); + } else { + // Prompt user for fallback (only in TTY) + if (process.stdin.isTTY) { + const readline = await import('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question('Start local proxy instead? [Y/n] ', resolve); + }); + rl.close(); + if (answer.toLowerCase() === 'n') { + throw new Error('Remote proxy unreachable and user declined fallback'); + } + } + console.log(info('Starting local proxy...')); + } + } else { + throw new Error('Remote proxy unreachable and fallback disabled'); + } + } } - // 2. Handle special flags - const forceAuth = args.includes('--auth'); - const forceHeadless = args.includes('--headless'); - const forceLogout = args.includes('--logout'); - const forceConfig = args.includes('--config'); - const addAccount = args.includes('--add'); - const showAccounts = args.includes('--accounts'); + // Variables for local proxy mode + let binaryPath: string | undefined; + let sessionId: string | undefined; + + // 1. Ensure binary exists (downloads if needed) - SKIP for remote mode + if (!useRemoteProxy) { + const spinner = new ProgressIndicator('Preparing CLIProxy'); + spinner.start(); + + try { + binaryPath = await ensureCLIProxyBinary(verbose); + spinner.succeed('CLIProxy binary ready'); + } catch (error) { + spinner.fail('Failed to prepare CLIProxy'); + throw error; + } + } + + // 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped) + const forceAuth = argsWithoutProxy.includes('--auth'); + const forceHeadless = argsWithoutProxy.includes('--headless'); + const forceLogout = argsWithoutProxy.includes('--logout'); + const forceConfig = argsWithoutProxy.includes('--config'); + const addAccount = argsWithoutProxy.includes('--add'); + const showAccounts = argsWithoutProxy.includes('--accounts'); // Parse --use flag let useAccount: string | undefined; - const useIdx = args.indexOf('--use'); - if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) { - useAccount = args[useIdx + 1]; + const useIdx = argsWithoutProxy.indexOf('--use'); + if ( + useIdx !== -1 && + argsWithoutProxy[useIdx + 1] && + !argsWithoutProxy[useIdx + 1].startsWith('-') + ) { + useAccount = argsWithoutProxy[useIdx + 1]; } // Parse --nickname flag let setNickname: string | undefined; - const nicknameIdx = args.indexOf('--nickname'); - if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) { - setNickname = args[nicknameIdx + 1]; + const nicknameIdx = argsWithoutProxy.indexOf('--nickname'); + if ( + nicknameIdx !== -1 && + argsWithoutProxy[nicknameIdx + 1] && + !argsWithoutProxy[nicknameIdx + 1].startsWith('-') + ) { + setNickname = argsWithoutProxy[nicknameIdx + 1]; } // Handle --accounts: list accounts and exit @@ -306,122 +388,135 @@ export async function execClaudeWithCLIProxy( // 6. Ensure user settings file exists (creates from defaults if not) ensureProviderSettings(provider); - // 6. Generate config file - log(`Generating config for ${provider}`); - const configPath = generateConfig(provider, cfg.port); - log(`Config written: ${configPath}`); - - // 6a. Pre-flight check: handle existing proxy or port conflicts - // Clean up orphaned sessions first (from crashed proxies) - cleanupOrphanedSessions(cfg.port); - - // Check if there's an existing healthy proxy we can reuse - const existingProxy = getExistingProxy(cfg.port); + // Local proxy mode: generate config, spawn proxy, track session let proxy: ChildProcess | null = null; - let sessionId: string; - if (existingProxy) { - // Reuse existing proxy - another CCS session started it - log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); - sessionId = registerSession(cfg.port, existingProxy.pid); - console.log( - info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) - ); - } else { - // No existing proxy - check if port is free - const portProcess = await getPortProcess(cfg.port); - if (portProcess) { - if (isCLIProxyProcess(portProcess)) { - // CLIProxy on port but no session lock - likely orphaned/zombie - // Only kill if no active sessions registered - if (!hasActiveSessions()) { - log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); - const killed = killProcessOnPort(cfg.port, verbose); - if (killed) { - console.log(info(`Cleaned up zombie CLIProxy process`)); - // Wait a bit for port to be released - await new Promise((r) => setTimeout(r, 500)); + if (!useRemoteProxy) { + // 6. Generate config file + log(`Generating config for ${provider}`); + const configPath = generateConfig(provider, cfg.port); + log(`Config written: ${configPath}`); + + // 6a. Pre-flight check: handle existing proxy or port conflicts + // Clean up orphaned sessions first (from crashed proxies) + cleanupOrphanedSessions(cfg.port); + + // Check if there's an existing healthy proxy we can reuse + const existingProxy = getExistingProxy(cfg.port); + + if (existingProxy) { + // Reuse existing proxy - another CCS session started it + log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); + sessionId = registerSession(cfg.port, existingProxy.pid); + console.log( + info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`) + ); + } else { + // No existing proxy - check if port is free + const portProcess = await getPortProcess(cfg.port); + if (portProcess) { + if (isCLIProxyProcess(portProcess)) { + // CLIProxy on port but no session lock - likely orphaned/zombie + // Only kill if no active sessions registered + if (!hasActiveSessions()) { + log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); + const killed = killProcessOnPort(cfg.port, verbose); + if (killed) { + console.log(info(`Cleaned up zombie CLIProxy process`)); + // Wait a bit for port to be released + await new Promise((r) => setTimeout(r, 500)); + } + } else { + // Active sessions exist but getExistingProxy returned null - something's wrong + // Try to connect anyway + log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); } } else { - // Active sessions exist but getExistingProxy returned null - something's wrong - // Try to connect anyway - log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); + // Non-CLIProxy process blocking the port - warn user + console.error(''); + console.error( + warn( + `Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})` + ) + ); + console.error(''); + console.error('To fix this, close the blocking application or run:'); + console.error(` ${getPortCheckCommand(cfg.port)}`); + console.error(''); + throw new Error(`Port ${cfg.port} is in use by another application`); } - } else { - // Non-CLIProxy process blocking the port - warn user - console.error(''); - console.error( - warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) - ); - console.error(''); - console.error('To fix this, close the blocking application or run:'); - console.error(` ${getPortCheckCommand(cfg.port)}`); - console.error(''); - throw new Error(`Port ${cfg.port} is in use by another application`); } + + // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) + // Use detached mode so proxy persists after terminal closes + const configPath = generateConfig(provider, cfg.port); + const proxyArgs = ['--config', configPath]; + + log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); + + proxy = spawn(binaryPath as string, proxyArgs, { + stdio: ['ignore', 'ignore', 'ignore'], + detached: true, // Persist after parent terminal closes + env: { + ...process.env, + WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ + }, + }); + + // Unref so parent process can exit independently + proxy.unref(); + + // Handle proxy errors (only fires if spawn itself fails) + proxy.on('error', (error) => { + console.error(fail(`CLIProxy spawn error: ${error.message}`)); + }); + + // 7. Wait for proxy readiness via TCP polling + const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); + readySpinner.start(); + + try { + await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); + readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); + } catch (error) { + readySpinner.fail('CLIProxy startup failed'); + proxy.kill('SIGTERM'); + + const err = error as Error; + console.error(''); + console.error(fail('CLIProxy failed to start')); + console.error(''); + console.error('Possible causes:'); + console.error(` 1. Port ${cfg.port} already in use`); + console.error(' 2. Binary crashed on startup'); + console.error(' 3. Invalid configuration'); + console.error(''); + console.error('Troubleshooting:'); + console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); + console.error(' - Run with --verbose for detailed logs'); + console.error(` - View config: ${getCatCommand(configPath)}`); + console.error(' - Try: ccs doctor --fix'); + console.error(''); + + throw new Error(`CLIProxy startup failed: ${err.message}`); + } + + // Register this session with the new proxy + sessionId = registerSession(cfg.port, proxy.pid as number); + log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); } - - // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) - // Use detached mode so proxy persists after terminal closes - const proxyArgs = ['--config', configPath]; - - log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); - - proxy = spawn(binaryPath, proxyArgs, { - stdio: ['ignore', 'ignore', 'ignore'], - detached: true, // Persist after parent terminal closes - env: { - ...process.env, - WRITABLE_PATH: getCliproxyWritablePath(), // Logs stored in ~/.ccs/cliproxy/logs/ - }, - }); - - // Unref so parent process can exit independently - proxy.unref(); - - // Handle proxy errors (only fires if spawn itself fails) - proxy.on('error', (error) => { - console.error(fail(`CLIProxy spawn error: ${error.message}`)); - }); - - // 7. Wait for proxy readiness via TCP polling - const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`); - readySpinner.start(); - - try { - await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval); - readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`); - } catch (error) { - readySpinner.fail('CLIProxy startup failed'); - proxy.kill('SIGTERM'); - - const err = error as Error; - console.error(''); - console.error(fail('CLIProxy failed to start')); - console.error(''); - console.error('Possible causes:'); - console.error(` 1. Port ${cfg.port} already in use`); - console.error(' 2. Binary crashed on startup'); - console.error(' 3. Invalid configuration'); - console.error(''); - console.error('Troubleshooting:'); - console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`); - console.error(' - Run with --verbose for detailed logs'); - console.error(` - View config: ${getCatCommand(configPath)}`); - console.error(' - Try: ccs doctor --fix'); - console.error(''); - - throw new Error(`CLIProxy startup failed: ${err.message}`); - } - - // Register this session with the new proxy - sessionId = registerSession(cfg.port, proxy.pid as number); - log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); } // 7. Execute Claude CLI with proxied environment - // Uses custom settings path (for variants), user settings, or bundled defaults - const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); + // Use remote or local env vars based on mode + const envVars = useRemoteProxy + ? getRemoteEnvVars(provider, { + host: proxyConfig.host ?? 'localhost', + port: proxyConfig.port, + protocol: proxyConfig.protocol, + authToken: proxyConfig.authToken, + }) + : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, @@ -437,6 +532,7 @@ export async function execClaudeWithCLIProxy( } // Filter out CCS-specific flags before passing to Claude CLI + // Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig() const ccsFlags = [ '--auth', '--headless', @@ -446,12 +542,15 @@ export async function execClaudeWithCLIProxy( '--accounts', '--use', '--nickname', + // Proxy flags are handled by resolveProxyConfig, but list for documentation + ...PROXY_CLI_FLAGS, ]; - const claudeArgs = args.filter((arg, idx) => { + const claudeArgs = argsWithoutProxy.filter((arg, idx) => { // Filter out CCS flags if (ccsFlags.includes(arg)) return false; // Filter out value after --use or --nickname - if (args[idx - 1] === '--use' || args[idx - 1] === '--nickname') return false; + if (argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname') + return false; return true; }); @@ -475,14 +574,16 @@ export async function execClaudeWithCLIProxy( }); } - // 8. Cleanup: unregister session when Claude exits + // 8. Cleanup: unregister session when Claude exits (local mode only) // Proxy persists by default - use 'ccs cliproxy stop' to kill manually claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); - // Unregister this session (proxy keeps running for persistence) - unregisterSession(sessionId); - log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); + // Unregister this session (proxy keeps running for persistence) - only for local mode + if (sessionId) { + unregisterSession(sessionId); + log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); + } if (signal) { process.kill(process.pid, signal as NodeJS.Signals); @@ -494,8 +595,10 @@ export async function execClaudeWithCLIProxy( claude.on('error', (error) => { console.error(fail(`Claude CLI error: ${error}`)); - // Unregister session, proxy keeps running - unregisterSession(sessionId); + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId); + } process.exit(1); }); @@ -503,8 +606,10 @@ export async function execClaudeWithCLIProxy( const cleanup = () => { log('Parent signal received, cleaning up'); - // Unregister session, proxy keeps running - unregisterSession(sessionId); + // Unregister session, proxy keeps running (local mode only) + if (sessionId) { + unregisterSession(sessionId); + } claude.kill('SIGTERM'); }; diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index df134a36..9cf354d6 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -479,3 +479,49 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { mode: 0o600, }); } + +/** + * Get environment variables for remote proxy mode. + * Uses the remote proxy's provider endpoint as the base URL. + * + * @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow) + * @param remoteConfig Remote proxy connection details + * @returns Environment variables for Claude CLI + */ +export function getRemoteEnvVars( + provider: CLIProxyProvider, + remoteConfig: { host: string; port: number; protocol: 'http' | 'https'; authToken?: string } +): Record { + const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}:${remoteConfig.port}/api/provider/${provider}`; + const models = getModelMapping(provider); + + // Get global env vars (DISABLE_TELEMETRY, etc.) + const globalEnv = getGlobalEnvVars(); + + // Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.) + const baseEnvVars = getEnvVarsFromConfig(provider); + + // Filter out core env vars from base config to avoid conflicts + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel, + ...additionalEnvVars + } = baseEnvVars; + + const env: Record = { + ...globalEnv, + ...additionalEnvVars, + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY, + ANTHROPIC_MODEL: models.claudeModel, + ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel, + }; + + return env; +} diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts new file mode 100644 index 00000000..b0af403f --- /dev/null +++ b/src/cliproxy/proxy-config-resolver.ts @@ -0,0 +1,279 @@ +/** + * Proxy Config Resolver + * + * Resolves proxy configuration from multiple sources with priority: + * CLI flags > Environment variables > config.yaml > defaults + * + * Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes. + */ + +import { ResolvedProxyConfig } from './types'; +import { CLIPROXY_DEFAULT_PORT } from './config-generator'; + +/** CLI flags for proxy configuration */ +export const PROXY_CLI_FLAGS = [ + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--local-proxy', + '--remote-only', +] as const; + +/** Environment variable names for proxy configuration */ +export const PROXY_ENV_VARS = { + host: 'CCS_PROXY_HOST', + port: 'CCS_PROXY_PORT', + protocol: 'CCS_PROXY_PROTOCOL', + authToken: 'CCS_PROXY_AUTH_TOKEN', + fallbackEnabled: 'CCS_PROXY_FALLBACK_ENABLED', +} as const; + +/** Parsed CLI proxy flags */ +interface ParsedProxyFlags { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + localProxy: boolean; + remoteOnly: boolean; +} + +/** Proxy config from environment variables */ +interface EnvProxyConfig { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + fallbackEnabled?: boolean; +} + +/** + * Parse proxy-related CLI flags from argv. + * Returns parsed flags and remaining args (with proxy flags removed). + */ +export function parseProxyFlags(args: string[]): { + flags: ParsedProxyFlags; + remainingArgs: string[]; +} { + const flags: ParsedProxyFlags = { + localProxy: false, + remoteOnly: false, + }; + const remainingArgs: string[] = []; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === '--proxy-host' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.host = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--proxy-port' && args[i + 1] && !args[i + 1].startsWith('-')) { + const port = parseInt(args[i + 1], 10); + if (!isNaN(port) && port > 0 && port <= 65535) { + flags.port = port; + } + i += 2; + continue; + } + + if (arg === '--proxy-protocol' && args[i + 1] && !args[i + 1].startsWith('-')) { + const proto = args[i + 1].toLowerCase(); + if (proto === 'http' || proto === 'https') { + flags.protocol = proto; + } + i += 2; + continue; + } + + if (arg === '--proxy-auth-token' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.authToken = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--local-proxy') { + flags.localProxy = true; + i += 1; + continue; + } + + if (arg === '--remote-only') { + flags.remoteOnly = true; + i += 1; + continue; + } + + // Not a proxy flag - keep in remaining args + remainingArgs.push(arg); + i += 1; + } + + return { flags, remainingArgs }; +} + +/** + * Get proxy configuration from environment variables. + */ +export function getProxyEnvVars(): EnvProxyConfig { + const config: EnvProxyConfig = {}; + + const host = process.env[PROXY_ENV_VARS.host]; + if (host) { + config.host = host; + } + + const port = process.env[PROXY_ENV_VARS.port]; + if (port) { + const portNum = parseInt(port, 10); + if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) { + config.port = portNum; + } + } + + const protocol = process.env[PROXY_ENV_VARS.protocol]; + if (protocol) { + const proto = protocol.toLowerCase(); + if (proto === 'http' || proto === 'https') { + config.protocol = proto; + } + } + + const authToken = process.env[PROXY_ENV_VARS.authToken]; + if (authToken) { + config.authToken = authToken; + } + + const fallback = process.env[PROXY_ENV_VARS.fallbackEnabled]; + if (fallback !== undefined) { + // Accept: '1', 'true', 'yes' as enabled; '0', 'false', 'no' as disabled + const lower = fallback.toLowerCase(); + if (lower === '1' || lower === 'true' || lower === 'yes') { + config.fallbackEnabled = true; + } else if (lower === '0' || lower === 'false' || lower === 'no') { + config.fallbackEnabled = false; + } + } + + return config; +} + +/** + * Default proxy configuration values. + */ +const DEFAULT_PROXY_CONFIG: ResolvedProxyConfig = { + mode: 'local', + port: CLIPROXY_DEFAULT_PORT, + protocol: 'http', + fallbackEnabled: true, + autoStartLocal: true, + remoteOnly: false, + forceLocal: false, +}; + +/** + * Resolve proxy configuration with priority: CLI > ENV > config.yaml > defaults. + * + * @param cliArgs - Raw CLI arguments + * @param configYamlProxy - Proxy section from config.yaml (optional, Phase 1) + * @returns Resolved configuration and remaining args (without proxy flags) + */ +export function resolveProxyConfig( + cliArgs: string[], + + _configYamlProxy?: { + remote?: { + enabled?: boolean; + host?: string; + port?: number; + protocol?: 'http' | 'https'; + auth_token?: string; + fallback_enabled?: boolean; + }; + local?: { + port?: number; + auto_start?: boolean; + }; + } +): { config: ResolvedProxyConfig; remainingArgs: string[] } { + // 1. Parse CLI flags (highest priority) + const { flags: cliFlags, remainingArgs } = parseProxyFlags(cliArgs); + + // 2. Get environment variables + const envConfig = getProxyEnvVars(); + + // 3. config.yaml proxy section (passed as parameter - Phase 1 provides this) + // For now, we use empty object if not provided; Phase 1 integrates unified config loading + const yamlConfig = _configYamlProxy || {}; + + // 4. Build resolved config with priority merge + const resolved: ResolvedProxyConfig = { + ...DEFAULT_PROXY_CONFIG, + }; + + // Determine mode: remote if host is specified anywhere (unless --local-proxy) + const hasRemoteHost = + cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled; + + // --local-proxy forces local mode regardless of remote config + if (cliFlags.localProxy) { + resolved.mode = 'local'; + resolved.forceLocal = true; + } else if (hasRemoteHost) { + resolved.mode = 'remote'; + } + + // Merge host: CLI > ENV > config.yaml + resolved.host = cliFlags.host ?? envConfig.host ?? yamlConfig.remote?.host; + + // Merge port: CLI > ENV > config.yaml (remote or local) > default + resolved.port = + cliFlags.port ?? + envConfig.port ?? + (resolved.mode === 'remote' ? yamlConfig.remote?.port : yamlConfig.local?.port) ?? + DEFAULT_PROXY_CONFIG.port; + + // Merge protocol: CLI > ENV > config.yaml > default + resolved.protocol = + cliFlags.protocol ?? envConfig.protocol ?? yamlConfig.remote?.protocol ?? 'http'; + + // Merge auth token: CLI > ENV > config.yaml + resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token; + + // Merge fallback enabled: ENV > config.yaml > default + resolved.fallbackEnabled = + envConfig.fallbackEnabled ?? yamlConfig.remote?.fallback_enabled ?? true; + + // --remote-only from CLI + resolved.remoteOnly = cliFlags.remoteOnly; + + // If --remote-only, disable fallback + if (resolved.remoteOnly) { + resolved.fallbackEnabled = false; + } + + // Auto-start local from config.yaml > default + resolved.autoStartLocal = yamlConfig.local?.auto_start ?? true; + + return { config: resolved, remainingArgs }; +} + +/** + * Check if args contain any proxy flags. + * Used for quick filtering before full parse. + */ +export function hasProxyFlags(args: string[]): boolean { + return args.some( + (arg) => + arg === '--proxy-host' || + arg === '--proxy-port' || + arg === '--proxy-protocol' || + arg === '--proxy-auth-token' || + arg === '--local-proxy' || + arg === '--remote-only' + ); +} diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts new file mode 100644 index 00000000..c4c8a321 --- /dev/null +++ b/src/cliproxy/remote-proxy-client.ts @@ -0,0 +1,236 @@ +/** + * Remote Proxy Client for CLIProxyAPI + * + * HTTP client for health checks and connection testing against remote CLIProxyAPI instances. + * Uses native fetch API with aggressive timeout for CLI responsiveness. + */ + +import * as https from 'https'; + +/** Error codes for remote proxy status */ +export type RemoteProxyErrorCode = 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; + +/** Status returned from remote proxy health check */ +export interface RemoteProxyStatus { + /** Whether the remote proxy is reachable */ + reachable: boolean; + /** Latency in milliseconds (only set if reachable) */ + latencyMs?: number; + /** Error message (only set if not reachable) */ + error?: string; + /** Error code for programmatic handling */ + errorCode?: RemoteProxyErrorCode; +} + +/** Configuration for remote proxy client */ +export interface RemoteProxyClientConfig { + /** Remote proxy host (IP or hostname) */ + host: string; + /** Remote proxy port */ + port: number; + /** Protocol to use (http or https) */ + protocol: 'http' | 'https'; + /** Optional auth token for Authorization header */ + authToken?: string; + /** Request timeout in ms (default: 2000) */ + timeout?: number; + /** Allow self-signed certificates (default: false) */ + allowSelfSigned?: boolean; +} + +/** Default timeout for remote proxy requests (aggressive for CLI UX) */ +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Map error to RemoteProxyErrorCode + */ +function mapErrorToCode(error: Error, statusCode?: number): RemoteProxyErrorCode { + const message = error.message.toLowerCase(); + const code = (error as NodeJS.ErrnoException).code?.toLowerCase(); + + // Connection refused + if (code === 'econnrefused' || message.includes('connection refused')) { + return 'CONNECTION_REFUSED'; + } + + // Timeout + if ( + code === 'etimedout' || + code === 'timeout' || + message.includes('timeout') || + message.includes('aborted') + ) { + return 'TIMEOUT'; + } + + // Auth failed (401/403) + if (statusCode === 401 || statusCode === 403) { + return 'AUTH_FAILED'; + } + + return 'UNKNOWN'; +} + +/** + * Get human-readable error message from error code + */ +function getErrorMessage(errorCode: RemoteProxyErrorCode, rawError?: string): string { + switch (errorCode) { + case 'CONNECTION_REFUSED': + return 'Connection refused - is the proxy running?'; + case 'TIMEOUT': + return 'Connection timed out'; + case 'AUTH_FAILED': + return 'Authentication failed - check auth token'; + default: + return rawError || 'Unknown error'; + } +} + +/** + * Create a custom HTTPS agent for self-signed certificate support + */ +function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined { + if (!allowSelfSigned) return undefined; + + return new https.Agent({ + rejectUnauthorized: false, + }); +} + +/** + * Check health of remote CLIProxyAPI instance + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function checkRemoteProxy( + config: RemoteProxyClientConfig +): Promise { + const { host, port, protocol, authToken, allowSelfSigned = false } = config; + const timeout = config.timeout ?? DEFAULT_TIMEOUT_MS; + + const url = `${protocol}://${host}:${port}/health`; + const startTime = Date.now(); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + // Build request options + const headers: Record = { + Accept: 'application/json', + }; + + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + // For HTTPS with self-signed certs, we need to use native https module + // Bun's fetch doesn't support custom agents + let response: Response; + + if (protocol === 'https' && allowSelfSigned) { + // Warn about security implications + console.error('[!] Allowing self-signed certificate - not recommended for production'); + + // Use native https module for self-signed cert support + response = await new Promise((resolve, reject) => { + const agent = createHttpsAgent(true); + const reqTimeout = setTimeout(() => { + reject(new Error('Request timeout')); + }, timeout); + + const req = https.request( + url, + { + method: 'GET', + headers, + agent, + timeout, + }, + (res) => { + clearTimeout(reqTimeout); + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + resolve( + new Response(data, { + status: res.statusCode || 500, + statusText: res.statusMessage, + }) + ); + }); + } + ); + + req.on('error', (err) => { + clearTimeout(reqTimeout); + reject(err); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + + req.end(); + }); + } else { + // Standard fetch for HTTP or HTTPS without self-signed + response = await fetch(url, { + signal: controller.signal, + headers, + }); + } + + clearTimeout(timeoutId); + + const latencyMs = Date.now() - startTime; + + // Check for auth failure + if (response.status === 401 || response.status === 403) { + return { + reachable: false, + error: getErrorMessage('AUTH_FAILED'), + errorCode: 'AUTH_FAILED', + }; + } + + // 200 OK = healthy + if (response.ok) { + return { + reachable: true, + latencyMs, + }; + } + + // Non-200 but connected + return { + reachable: false, + error: `Unexpected status: ${response.status}`, + errorCode: 'UNKNOWN', + }; + } catch (error) { + const err = error as Error; + const errorCode = mapErrorToCode(err); + + return { + reachable: false, + error: getErrorMessage(errorCode, err.message), + errorCode, + }; + } +} + +/** + * Test connection to remote CLIProxyAPI (alias for dashboard use) + * + * This is an alias for checkRemoteProxy() for semantic clarity in UI contexts. + * + * @param config Remote proxy client configuration + * @returns RemoteProxyStatus with reachability and latency + */ +export async function testConnection(config: RemoteProxyClientConfig): Promise { + return checkRemoteProxy(config); +} diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index 925842d0..9fc1c1a9 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -187,3 +187,28 @@ export interface ProviderConfig { /** Whether OAuth is required */ requiresOAuth: boolean; } + +/** + * Resolved proxy configuration after merging CLI > ENV > config.yaml > defaults. + * Used by executor to determine local vs remote proxy mode. + */ +export interface ResolvedProxyConfig { + /** Proxy mode: 'local' spawns CLIProxyAPI locally, 'remote' connects to external server */ + mode: 'local' | 'remote'; + /** Remote proxy hostname/IP (only for remote mode) */ + host?: string; + /** Proxy port (default: 8317) */ + port: number; + /** Protocol for remote connection (default: http) */ + protocol: 'http' | 'https'; + /** Auth token for remote proxy authentication */ + authToken?: string; + /** Enable fallback to local when remote unreachable (default: true) */ + fallbackEnabled: boolean; + /** Auto-start local proxy if not running (default: true) */ + autoStartLocal: boolean; + /** --remote-only flag: fail if remote unreachable, no fallback */ + remoteOnly: boolean; + /** --local-proxy flag: force local mode, ignore remote config */ + forceLocal: boolean; +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 618dfd23..4490126b 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -241,6 +241,25 @@ Claude Code Profile & Model Switcher`.trim(); ['ccs cliproxy --latest', 'Update to latest version'], ]); + // CLI Proxy configuration flags (new) + printSubSection('CLI Proxy Configuration', [ + ['--proxy-host ', 'Remote proxy hostname/IP'], + ['--proxy-port ', 'Proxy port (default: 8317)'], + ['--proxy-protocol ', 'Protocol: http or https (default: http)'], + ['--proxy-auth-token ', 'Auth token for remote proxy'], + ['--local-proxy', 'Force local mode, ignore remote config'], + ['--remote-only', 'Fail if remote unreachable (no fallback)'], + ]); + + // CLI Proxy env vars + printSubSection('CLI Proxy Environment Variables', [ + ['CCS_PROXY_HOST', 'Remote proxy hostname'], + ['CCS_PROXY_PORT', 'Proxy port'], + ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], + ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], + ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], + ]); + // CLI Proxy paths console.log(subheader('CLI Proxy:')); console.log(` Binary: ${color('~/.ccs/cliproxy/bin/cli-proxy-api', 'path')}`); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 80e0ad64..98c72da1 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -16,6 +16,7 @@ import { UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, DEFAULT_GLOBAL_ENV, + DEFAULT_CLIPROXY_SERVER_CONFIG, GlobalEnvConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -177,6 +178,35 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { enabled: partial.global_env?.enabled ?? true, env: partial.global_env?.env ?? { ...DEFAULT_GLOBAL_ENV }, }, + // CLIProxy server config - remote/local CLIProxyAPI settings + cliproxy_server: { + remote: { + enabled: + partial.cliproxy_server?.remote?.enabled ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.enabled, + host: partial.cliproxy_server?.remote?.host ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.host, + port: partial.cliproxy_server?.remote?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.remote.port, + protocol: + partial.cliproxy_server?.remote?.protocol ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.protocol, + auth_token: + partial.cliproxy_server?.remote?.auth_token ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token, + }, + fallback: { + enabled: + partial.cliproxy_server?.fallback?.enabled ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.enabled, + auto_start: + partial.cliproxy_server?.fallback?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.fallback.auto_start, + }, + local: { + port: partial.cliproxy_server?.local?.port ?? DEFAULT_CLIPROXY_SERVER_CONFIG.local.port, + auto_start: + partial.cliproxy_server?.local?.auto_start ?? + DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start, + }, + }, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 0379c9ea..fcffd26d 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -14,8 +14,9 @@ * Version 2 = YAML unified format * Version 3 = WebSearch config with model configuration for Gemini/OpenCode * Version 4 = Copilot API integration (GitHub Copilot proxy) + * Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI) */ -export const UNIFIED_CONFIG_VERSION = 4; +export const UNIFIED_CONFIG_VERSION = 5; /** * Account configuration (formerly in profiles.json). @@ -185,6 +186,56 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Remote proxy configuration. + * Connect to a remote CLIProxyAPI instance instead of spawning local binary. + */ +export interface ProxyRemoteConfig { + /** Enable remote proxy mode (default: false = local mode) */ + enabled: boolean; + /** Remote proxy hostname or IP (empty = not configured) */ + host: string; + /** Remote proxy port (default: 8317) */ + port: number; + /** Protocol for remote connection */ + protocol: 'http' | 'https'; + /** Auth token for remote proxy (optional, sent as header) */ + auth_token: string; +} + +/** + * Fallback configuration when remote proxy is unreachable. + */ +export interface ProxyFallbackConfig { + /** Enable fallback to local proxy (default: true) */ + enabled: boolean; + /** Auto-start local proxy without prompting (default: false = prompt user) */ + auto_start: boolean; +} + +/** + * Local proxy configuration. + */ +export interface ProxyLocalConfig { + /** Local proxy port (default: 8317) */ + port: number; + /** Auto-start local binary (default: true) */ + auto_start: boolean; +} + +/** + * CLIProxy server configuration section. + * Controls whether CCS uses local or remote CLIProxyAPI instance. + */ +export interface CliproxyServerConfig { + /** Remote proxy settings */ + remote: ProxyRemoteConfig; + /** Fallback behavior when remote is unreachable */ + fallback: ProxyFallbackConfig; + /** Local proxy settings */ + local: ProxyLocalConfig; +} + /** * Global environment variables configuration. * These env vars are injected into ALL non-Claude subscription profiles. @@ -242,7 +293,7 @@ export interface WebSearchConfig { * Stored in ~/.ccs/config.yaml */ export interface UnifiedConfig { - /** Config version (4 for copilot support) */ + /** Config version (5 for remote proxy support) */ version: number; /** Default profile name to use when none specified */ default?: string; @@ -260,6 +311,8 @@ export interface UnifiedConfig { global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; + /** CLIProxy server configuration for remote/local mode */ + cliproxy_server?: CliproxyServerConfig; } /** @@ -289,6 +342,28 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { model: 'gpt-4.1', // Free tier compatible }; +/** + * Default CLIProxy server configuration. + * Local mode by default - remote must be explicitly enabled. + */ +export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { + remote: { + enabled: false, + host: '', + port: 8317, + protocol: 'http', + auth_token: '', + }, + fallback: { + enabled: true, + auto_start: false, + }, + local: { + port: 8317, + auto_start: true, + }, +}; + /** * Create an empty unified config with defaults. */ @@ -336,6 +411,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { env: { ...DEFAULT_GLOBAL_ENV }, }, copilot: { ...DEFAULT_COPILOT_CONFIG }, + cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, }; } diff --git a/src/web-server/index.ts b/src/web-server/index.ts index e9c4ba3a..a4de7f80 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -51,6 +51,10 @@ export async function startServer(options: ServerOptions): Promise { + try { + const config = await loadOrCreateUnifiedConfig(); + res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to load proxy config:', error); + res.status(500).json({ error: 'Failed to load proxy config' }); + } +}); + +/** + * PUT /api/cliproxy-server - Update proxy configuration + */ +router.put('/', async (req: Request, res: Response) => { + try { + const config = await loadOrCreateUnifiedConfig(); + const updates = req.body as Partial; + + // Deep merge with defaults and current config + config.cliproxy_server = { + remote: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.remote, + ...config.cliproxy_server?.remote, + ...updates.remote, + }, + fallback: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.fallback, + ...config.cliproxy_server?.fallback, + ...updates.fallback, + }, + local: { + ...DEFAULT_CLIPROXY_SERVER_CONFIG.local, + ...config.cliproxy_server?.local, + ...updates.local, + }, + }; + + await saveUnifiedConfig(config); + res.json(config.cliproxy_server); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to save proxy config:', error); + res.status(500).json({ error: 'Failed to save proxy config' }); + } +}); + +/** + * POST /api/cliproxy-server/test - Test remote proxy connection + */ +router.post('/test', async (req: Request, res: Response) => { + try { + const { host, port, protocol, authToken, allowSelfSigned } = req.body; + + if (!host || !port) { + res.status(400).json({ error: 'Host and port are required' }); + return; + } + + const status = await testConnection({ + host, + port: typeof port === 'number' ? port : parseInt(port, 10), + protocol: protocol || 'http', + authToken, + allowSelfSigned: allowSelfSigned || false, + timeout: 5000, + }); + + res.json(status); + } catch (error) { + console.error('[cliproxy-server-routes] Failed to test connection:', error); + res.status(500).json({ error: 'Failed to test connection' }); + } +}); + +export default router; diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js new file mode 100644 index 00000000..2a7d0851 --- /dev/null +++ b/tests/unit/cliproxy/proxy-config-resolver.test.js @@ -0,0 +1,274 @@ +/** + * Unit tests for proxy-config-resolver module + */ +const { describe, it, expect, beforeEach, afterEach } = require('bun:test'); + +// Import from compiled dist +const { + parseProxyFlags, + getProxyEnvVars, + resolveProxyConfig, + hasProxyFlags, + PROXY_CLI_FLAGS, + PROXY_ENV_VARS, +} = require('../../../dist/cliproxy/proxy-config-resolver'); + +describe('proxy-config-resolver', () => { + describe('PROXY_CLI_FLAGS', () => { + it('should define all expected proxy flags', () => { + expect(PROXY_CLI_FLAGS).toContain('--proxy-host'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-port'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-protocol'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-auth-token'); + expect(PROXY_CLI_FLAGS).toContain('--local-proxy'); + expect(PROXY_CLI_FLAGS).toContain('--remote-only'); + }); + }); + + describe('PROXY_ENV_VARS', () => { + it('should define all expected environment variable names', () => { + expect(PROXY_ENV_VARS.host).toBe('CCS_PROXY_HOST'); + expect(PROXY_ENV_VARS.port).toBe('CCS_PROXY_PORT'); + expect(PROXY_ENV_VARS.protocol).toBe('CCS_PROXY_PROTOCOL'); + expect(PROXY_ENV_VARS.authToken).toBe('CCS_PROXY_AUTH_TOKEN'); + expect(PROXY_ENV_VARS.fallbackEnabled).toBe('CCS_PROXY_FALLBACK_ENABLED'); + }); + }); + + describe('parseProxyFlags', () => { + it('should parse --proxy-host flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-host', '192.168.1.100']); + expect(flags.host).toBe('192.168.1.100'); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-port flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-port', '9000']); + expect(flags.port).toBe(9000); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-protocol flag', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'https']); + expect(flags.protocol).toBe('https'); + }); + + it('should parse --proxy-auth-token flag', () => { + const { flags } = parseProxyFlags(['--proxy-auth-token', 'secret123']); + expect(flags.authToken).toBe('secret123'); + }); + + it('should parse --local-proxy boolean flag', () => { + const { flags } = parseProxyFlags(['--local-proxy']); + expect(flags.localProxy).toBe(true); + }); + + it('should parse --remote-only boolean flag', () => { + const { flags } = parseProxyFlags(['--remote-only']); + expect(flags.remoteOnly).toBe(true); + }); + + it('should preserve non-proxy args in remainingArgs', () => { + const { flags, remainingArgs } = parseProxyFlags([ + '--verbose', + '--proxy-host', + 'localhost', + '--some-other-flag', + ]); + expect(flags.host).toBe('localhost'); + expect(remainingArgs).toEqual(['--verbose', '--some-other-flag']); + }); + + it('should handle mixed proxy and non-proxy args', () => { + const { flags, remainingArgs } = parseProxyFlags([ + 'arg1', + '--proxy-port', + '8080', + 'arg2', + '--local-proxy', + 'arg3', + ]); + expect(flags.port).toBe(8080); + expect(flags.localProxy).toBe(true); + expect(remainingArgs).toEqual(['arg1', 'arg2', 'arg3']); + }); + + it('should ignore invalid port values', () => { + const { flags } = parseProxyFlags(['--proxy-port', 'invalid']); + expect(flags.port).toBeUndefined(); + }); + + it('should ignore out-of-range port values', () => { + const { flags: flags1 } = parseProxyFlags(['--proxy-port', '0']); + expect(flags1.port).toBeUndefined(); + + const { flags: flags2 } = parseProxyFlags(['--proxy-port', '70000']); + expect(flags2.port).toBeUndefined(); + }); + + it('should normalize protocol to lowercase', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'HTTPS']); + expect(flags.protocol).toBe('https'); + }); + + it('should ignore invalid protocol values', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'ftp']); + expect(flags.protocol).toBeUndefined(); + }); + }); + + describe('getProxyEnvVars', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear proxy env vars + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + // Restore original env + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return empty config when no env vars set', () => { + const config = getProxyEnvVars(); + expect(config.host).toBeUndefined(); + expect(config.port).toBeUndefined(); + expect(config.protocol).toBeUndefined(); + expect(config.authToken).toBeUndefined(); + expect(config.fallbackEnabled).toBeUndefined(); + }); + + it('should read CCS_PROXY_HOST', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const config = getProxyEnvVars(); + expect(config.host).toBe('remote.example.com'); + }); + + it('should read and parse CCS_PROXY_PORT', () => { + process.env.CCS_PROXY_PORT = '9000'; + const config = getProxyEnvVars(); + expect(config.port).toBe(9000); + }); + + it('should read CCS_PROXY_PROTOCOL', () => { + process.env.CCS_PROXY_PROTOCOL = 'https'; + const config = getProxyEnvVars(); + expect(config.protocol).toBe('https'); + }); + + it('should read CCS_PROXY_AUTH_TOKEN', () => { + process.env.CCS_PROXY_AUTH_TOKEN = 'my-secret-token'; + const config = getProxyEnvVars(); + expect(config.authToken).toBe('my-secret-token'); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as true', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '1'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'true'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'yes'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as false', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '0'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'false'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'no'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + }); + }); + + describe('resolveProxyConfig', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return local mode by default', () => { + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('local'); + expect(config.port).toBe(8317); // Default CLIProxy port + expect(config.fallbackEnabled).toBe(true); + }); + + it('should enable remote mode when --proxy-host is provided', () => { + const { config } = resolveProxyConfig(['--proxy-host', '192.168.1.100']); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('192.168.1.100'); + }); + + it('should enable remote mode when CCS_PROXY_HOST env is set', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('remote.example.com'); + }); + + it('should prioritize CLI flags over ENV vars', () => { + process.env.CCS_PROXY_HOST = 'env-host'; + process.env.CCS_PROXY_PORT = '9000'; + const { config } = resolveProxyConfig(['--proxy-host', 'cli-host', '--proxy-port', '8080']); + expect(config.host).toBe('cli-host'); + expect(config.port).toBe(8080); + }); + + it('should force local mode with --local-proxy', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig(['--local-proxy']); + expect(config.mode).toBe('local'); + expect(config.forceLocal).toBe(true); + }); + + it('should set remoteOnly and disable fallback with --remote-only', () => { + const { config } = resolveProxyConfig(['--proxy-host', 'remote', '--remote-only']); + expect(config.remoteOnly).toBe(true); + expect(config.fallbackEnabled).toBe(false); + }); + }); + + describe('hasProxyFlags', () => { + it('should return true when proxy flags are present', () => { + expect(hasProxyFlags(['--proxy-host', 'localhost'])).toBe(true); + expect(hasProxyFlags(['--proxy-port', '8080'])).toBe(true); + expect(hasProxyFlags(['--local-proxy'])).toBe(true); + expect(hasProxyFlags(['--remote-only'])).toBe(true); + }); + + it('should return false when no proxy flags are present', () => { + expect(hasProxyFlags([])).toBe(false); + expect(hasProxyFlags(['--verbose', '--help'])).toBe(false); + expect(hasProxyFlags(['gemini', 'some-task'])).toBe(false); + }); + }); +}); diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts new file mode 100644 index 00000000..61022c11 --- /dev/null +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for remote-proxy-client module + */ +import { describe, it, expect } from 'bun:test'; +import type { RemoteProxyClientConfig, RemoteProxyStatus } from '../../../src/cliproxy/remote-proxy-client'; + +// We test the module's type exports and error handling logic +// Actual HTTP calls are not mocked in this unit test - use integration tests for that + +describe('remote-proxy-client', () => { + describe('type exports', () => { + it('should export RemoteProxyClientConfig interface', () => { + // Type-level test - ensure the interface shape is correct + const config: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + authToken: 'test-token', + timeout: 2000, + allowSelfSigned: false, + }; + expect(config.host).toBe('localhost'); + expect(config.port).toBe(8317); + expect(config.protocol).toBe('http'); + }); + + it('should export RemoteProxyStatus interface', () => { + // Success case + const successStatus: RemoteProxyStatus = { + reachable: true, + latencyMs: 50, + }; + expect(successStatus.reachable).toBe(true); + expect(successStatus.latencyMs).toBe(50); + + // Error case + const errorStatus: RemoteProxyStatus = { + reachable: false, + error: 'Connection refused', + errorCode: 'CONNECTION_REFUSED', + }; + expect(errorStatus.reachable).toBe(false); + expect(errorStatus.error).toBe('Connection refused'); + expect(errorStatus.errorCode).toBe('CONNECTION_REFUSED'); + }); + }); + + describe('RemoteProxyErrorCode', () => { + it('should define expected error codes', () => { + const validCodes = ['CONNECTION_REFUSED', 'TIMEOUT', 'AUTH_FAILED', 'UNKNOWN']; + + // Type-level test - ensure error codes can be used + const status1: RemoteProxyStatus = { reachable: false, errorCode: 'CONNECTION_REFUSED' }; + const status2: RemoteProxyStatus = { reachable: false, errorCode: 'TIMEOUT' }; + const status3: RemoteProxyStatus = { reachable: false, errorCode: 'AUTH_FAILED' }; + const status4: RemoteProxyStatus = { reachable: false, errorCode: 'UNKNOWN' }; + + expect(validCodes).toContain(status1.errorCode); + expect(validCodes).toContain(status2.errorCode); + expect(validCodes).toContain(status3.errorCode); + expect(validCodes).toContain(status4.errorCode); + }); + }); + + describe('config validation', () => { + it('should require host and port', () => { + const minimalConfig: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + }; + expect(minimalConfig.host).toBeDefined(); + expect(minimalConfig.port).toBeDefined(); + expect(minimalConfig.protocol).toBeDefined(); + }); + + it('should allow optional fields', () => { + const config: RemoteProxyClientConfig = { + host: '127.0.0.1', + port: 8317, + protocol: 'https', + authToken: 'secret', + timeout: 5000, + allowSelfSigned: true, + }; + expect(config.authToken).toBe('secret'); + expect(config.timeout).toBe(5000); + expect(config.allowSelfSigned).toBe(true); + }); + + it('should accept http and https protocols', () => { + const httpConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'http', + }; + const httpsConfig: RemoteProxyClientConfig = { + host: 'localhost', + port: 8317, + protocol: 'https', + }; + expect(httpConfig.protocol).toBe('http'); + expect(httpsConfig.protocol).toBe('https'); + }); + }); + + describe('health check URL construction', () => { + it('should construct correct health check URL pattern', () => { + const config: RemoteProxyClientConfig = { + host: '192.168.1.100', + port: 8317, + protocol: 'http', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('http://192.168.1.100:8317/health'); + }); + + it('should construct HTTPS URL when protocol is https', () => { + const config: RemoteProxyClientConfig = { + host: 'secure.example.com', + port: 443, + protocol: 'https', + }; + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/health`; + expect(expectedUrl).toBe('https://secure.example.com:443/health'); + }); + }); +}); diff --git a/ui/src/components/proxy-status-widget.tsx b/ui/src/components/proxy-status-widget.tsx index d3d4cd1d..bfa0a2c3 100644 --- a/ui/src/components/proxy-status-widget.tsx +++ b/ui/src/components/proxy-status-widget.tsx @@ -123,7 +123,8 @@ export function ProxyStatusWidget() { size="sm" className={cn( 'h-7 text-xs gap-1 flex-1', - hasUpdate && 'bg-amber-600 hover:bg-amber-700 text-white' + hasUpdate && + 'bg-sidebar-accent hover:bg-sidebar-accent/90 text-sidebar-accent-foreground' )} onClick={handleRestart} disabled={isActioning} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 73815560..4e0f6d21 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -154,6 +154,42 @@ export interface CreatePreset { haiku?: string; } +/** Remote proxy status from health check */ +export interface RemoteProxyStatus { + reachable: boolean; + latencyMs?: number; + error?: string; + errorCode?: 'CONNECTION_REFUSED' | 'TIMEOUT' | 'AUTH_FAILED' | 'UNKNOWN'; +} + +/** Remote proxy configuration */ +export interface ProxyRemoteConfig { + enabled: boolean; + host: string; + port: number; + protocol: 'http' | 'https'; + auth_token: string; +} + +/** Fallback configuration */ +export interface ProxyFallbackConfig { + enabled: boolean; + auto_start: boolean; +} + +/** Local proxy configuration */ +export interface ProxyLocalConfig { + port: number; + auto_start: boolean; +} + +/** CLIProxy server configuration */ +export interface CliproxyServerConfig { + remote: ProxyRemoteConfig; + fallback: ProxyFallbackConfig; + local: ProxyLocalConfig; +} + /** CLIProxy process status from session tracker */ export interface ProxyProcessStatus { running: boolean; @@ -353,4 +389,27 @@ export const api = { method: 'DELETE', }), }, + /** CLIProxy server configuration API */ + cliproxyServer: { + /** Get cliproxy server configuration */ + get: () => request('/cliproxy-server'), + /** Update cliproxy server configuration */ + update: (config: Partial) => + request('/cliproxy-server', { + method: 'PUT', + body: JSON.stringify(config), + }), + /** Test remote proxy connection */ + test: (params: { + host: string; + port: number; + protocol: 'http' | 'https'; + authToken?: string; + allowSelfSigned?: boolean; + }) => + request('/cliproxy-server/test', { + method: 'POST', + body: JSON.stringify(params), + }), + }, }; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 00f39dee..1fdb9138 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,6 +1,6 @@ /** - * Settings Page - WebSearch & Global Env Configuration - * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + * Settings Page - WebSearch, Global Env & Proxy Configuration + * Supports Gemini CLI and Grok CLI providers + Global Environment Variables + Proxy Settings */ import { useState, useEffect } from 'react'; @@ -12,6 +12,13 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Globe, RefreshCw, @@ -28,8 +35,15 @@ import { Settings2, Plus, Trash2, + Server, + Laptop, + Cloud, + Wifi, + WifiOff, } from 'lucide-react'; import { CodeEditor } from '@/components/code-editor'; +import { api } from '@/lib/api-client'; +import type { CliproxyServerConfig, RemoteProxyStatus } from '@/lib/api-client'; interface ProviderConfig { enabled?: boolean; @@ -71,8 +85,10 @@ interface GlobalEnvConfig { export function SettingsPage() { const [searchParams] = useSearchParams(); - const initialTab = searchParams.get('tab') === 'globalenv' ? 'globalenv' : 'websearch'; - const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv'>(initialTab); + const tabParam = searchParams.get('tab'); + const initialTab = + tabParam === 'globalenv' ? 'globalenv' : tabParam === 'proxy' ? 'proxy' : 'websearch'; + const [activeTab, setActiveTab] = useState<'websearch' | 'globalenv' | 'proxy'>(initialTab); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -100,6 +116,14 @@ export function SettingsPage() { // New env var inputs const [newEnvKey, setNewEnvKey] = useState(''); const [newEnvValue, setNewEnvValue] = useState(''); + // Proxy state + const [proxyConfig, setCliproxyServerConfig] = useState(null); + const [proxyLoading, setProxyLoading] = useState(true); + const [proxySaving, setProxySaving] = useState(false); + const [proxyError, setProxyError] = useState(null); + const [proxySuccess, setProxySuccess] = useState(false); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); // Load config and status on mount useEffect(() => { @@ -107,6 +131,7 @@ export function SettingsPage() { fetchStatus(); fetchRawConfig(); fetchGlobalEnvConfig(); + fetchCliproxyServerConfig(); }, []); // Sync local model inputs when config changes @@ -179,6 +204,19 @@ export function SettingsPage() { } }; + const fetchCliproxyServerConfig = async () => { + try { + setProxyLoading(true); + setProxyError(null); + const data = await api.cliproxyServer.get(); + setCliproxyServerConfig(data); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setProxyLoading(false); + } + }; + const copyToClipboard = async () => { if (!rawConfig) return; try { @@ -387,6 +425,68 @@ export function SettingsPage() { saveGlobalEnvConfig({ env: newEnv }); }; + // Proxy functions + const saveCliproxyServerConfig = async (updates: Partial) => { + if (!proxyConfig) return; + + // Optimistic update + const optimisticConfig = { + remote: { ...proxyConfig.remote, ...updates.remote }, + fallback: { ...proxyConfig.fallback, ...updates.fallback }, + local: { ...proxyConfig.local, ...updates.local }, + }; + setCliproxyServerConfig(optimisticConfig); + setTestResult(null); // Clear previous test result on config change + + try { + setProxySaving(true); + setProxyError(null); + + const data = await api.cliproxyServer.update(updates); + setCliproxyServerConfig(data); + setProxySuccess(true); + setTimeout(() => setProxySuccess(false), 1500); + // Silently refresh raw config + fetch('/api/config/raw') + .then((r) => (r.ok ? r.text() : null)) + .then((text) => text && setRawConfig(text)) + .catch(() => {}); + } catch (err) { + setCliproxyServerConfig(proxyConfig); + setProxyError((err as Error).message); + } finally { + setProxySaving(false); + } + }; + + const handleTestConnection = async () => { + if (!proxyConfig) return; + + const { host, port, protocol, auth_token } = proxyConfig.remote; + if (!host || !port) { + setProxyError('Host and port are required'); + return; + } + + try { + setTesting(true); + setProxyError(null); + setTestResult(null); + + const result = await api.cliproxyServer.test({ + host, + port, + protocol, + authToken: auth_token || undefined, + }); + setTestResult(result); + } catch (err) { + setProxyError((err as Error).message); + } finally { + setTesting(false); + } + }; + if (loading) { return (
@@ -408,7 +508,7 @@ export function SettingsPage() {
setActiveTab(v as 'websearch' | 'globalenv')} + onValueChange={(v) => setActiveTab(v as 'websearch' | 'globalenv' | 'proxy')} > @@ -419,6 +519,10 @@ export function SettingsPage() { Global Env + + + Proxy +
@@ -455,7 +559,7 @@ export function SettingsPage() { fetchRawConfig={fetchRawConfig} loading={loading} /> - ) : ( + ) : activeTab === 'globalenv' ? ( + ) : ( + )}
@@ -1163,3 +1281,416 @@ function GlobalEnvContent({ ); } + +// Proxy Tab Content Component +interface ProxyContentProps { + config: CliproxyServerConfig | null; + loading: boolean; + saving: boolean; + error: string | null; + success: boolean; + testResult: RemoteProxyStatus | null; + testing: boolean; + saveCliproxyServerConfig: (updates: Partial) => void; + handleTestConnection: () => void; + fetchCliproxyServerConfig: () => void; + fetchRawConfig: () => void; +} + +function ProxyContent({ + config, + loading, + saving, + error, + success, + testResult, + testing, + saveCliproxyServerConfig, + handleTestConnection, + fetchCliproxyServerConfig, + fetchRawConfig, +}: ProxyContentProps) { + // Memoized default config to avoid recreation + const defaultRemote = { + enabled: false, + host: '', + port: 8317, + protocol: 'http' as const, + auth_token: '', + }; + const defaultFallback = { enabled: true, auto_start: true }; + const defaultLocal = { port: 8317, auto_start: true }; + + // Sync local state with config (using refs to avoid lint warnings) + const hostInput = config?.remote.host ?? ''; + const portInput = (config?.remote.port ?? 8317).toString(); + const authTokenInput = config?.remote.auth_token ?? ''; + const localPortInput = (config?.local.port ?? 8317).toString(); + + // Track edited values separately + const [editedHost, setEditedHost] = useState(null); + const [editedPort, setEditedPort] = useState(null); + const [editedAuthToken, setEditedAuthToken] = useState(null); + const [editedLocalPort, setEditedLocalPort] = useState(null); + + // Get display values (edited or from config) + const displayHost = editedHost ?? hostInput; + const displayPort = editedPort ?? portInput; + const displayAuthToken = editedAuthToken ?? authTokenInput; + const displayLocalPort = editedLocalPort ?? localPortInput; + + if (loading) { + return ( +
+
+ + Loading... +
+
+ ); + } + + const isRemoteMode = config?.remote.enabled ?? false; + const remoteConfig = config?.remote ?? defaultRemote; + const fallbackConfig = config?.fallback ?? defaultFallback; + const localConfig = config?.local ?? defaultLocal; + + // Save functions for blur events + const saveHost = () => { + const value = editedHost ?? displayHost; + if (value !== config?.remote.host) { + saveCliproxyServerConfig({ remote: { ...remoteConfig, host: value } }); + } + setEditedHost(null); + }; + + const savePort = () => { + const port = parseInt(editedPort ?? displayPort, 10); + if (!isNaN(port) && port !== config?.remote.port) { + saveCliproxyServerConfig({ remote: { ...remoteConfig, port } }); + } + setEditedPort(null); + }; + + const saveAuthToken = () => { + const value = editedAuthToken ?? displayAuthToken; + if (value !== config?.remote.auth_token) { + saveCliproxyServerConfig({ remote: { ...remoteConfig, auth_token: value } }); + } + setEditedAuthToken(null); + }; + + const saveLocalPort = () => { + const port = parseInt(editedLocalPort ?? displayLocalPort, 10); + if (!isNaN(port) && port !== config?.local.port) { + saveCliproxyServerConfig({ local: { ...localConfig, port } }); + } + setEditedLocalPort(null); + }; + + return ( + <> + {/* Toast-style alerts */} +
+ {error && ( + + + {error} + + )} + {success && ( +
+ + Saved +
+ )} +
+ + {/* Scrollable Content */} + +
+ {/* Description */} +

+ Configure local or remote CLIProxyAPI connection for proxy-based profiles +

+ + {/* Mode Toggle - Card based selection */} +
+

Connection Mode

+
+ {/* Local Mode Card */} + + + {/* Remote Mode Card */} + +
+
+ + {/* Remote Settings - Show when remote mode is enabled */} + {isRemoteMode && ( +
+

+ + Remote Server Configuration +

+ + {/* Host */} +
+ + setEditedHost(e.target.value)} + onBlur={saveHost} + placeholder="192.168.1.100 or proxy.example.com" + className="font-mono" + disabled={saving} + /> +
+ + {/* Port and Protocol */} +
+
+ + setEditedPort(e.target.value)} + onBlur={savePort} + placeholder="8317" + className="font-mono" + disabled={saving} + /> +
+
+ + +
+
+ + {/* Auth Token */} +
+ + setEditedAuthToken(e.target.value)} + onBlur={saveAuthToken} + placeholder="Bearer token for authentication" + className="font-mono" + disabled={saving} + /> +
+ + {/* Test Connection */} +
+ + + {/* Test Result */} + {testResult && ( +
+
+ {testResult.reachable ? ( + <> + + + Connected ({testResult.latencyMs}ms) + + + ) : ( + <> + + + {testResult.error || 'Connection failed'} + + + )} +
+
+ )} +
+
+ )} + + {/* Fallback Settings */} +
+

Fallback Settings

+
+ {/* Enable Fallback */} +
+
+

Enable fallback to local

+

+ Use local proxy if remote is unreachable +

+
+ + saveCliproxyServerConfig({ fallback: { ...fallbackConfig, enabled: checked } }) + } + disabled={saving || !isRemoteMode} + /> +
+ + {/* Auto-start on fallback */} +
+
+

Auto-start local proxy

+

+ Automatically start local proxy on fallback +

+
+ + saveCliproxyServerConfig({ + fallback: { ...fallbackConfig, auto_start: checked }, + }) + } + disabled={saving || !isRemoteMode || !config?.fallback.enabled} + /> +
+
+
+ + {/* Local Proxy Settings */} +
+

Local Proxy

+
+ {/* Port */} +
+ + setEditedLocalPort(e.target.value)} + onBlur={saveLocalPort} + placeholder="8317" + className="font-mono max-w-32" + disabled={saving} + /> +
+ + {/* Auto-start */} +
+
+

Auto-start

+

+ Start local proxy automatically when needed +

+
+ + saveCliproxyServerConfig({ local: { ...localConfig, auto_start: checked } }) + } + disabled={saving} + /> +
+
+
+
+
+ + {/* Footer */} +
+ +
+ + ); +}