/** * CLIProxy Executor - Spawn/Kill Pattern for CLIProxyAPI * * Mirrors GLMT architecture: * 1. Ensure binary exists (Phase 1) * 2. Generate config for provider * 3. Spawn CLIProxyAPI binary * 4. Poll port for readiness (no stdout signal) * 5. Execute Claude CLI with proxied environment * 6. Kill proxy on Claude exit * * Key difference from GLMT: Uses TCP port polling instead of PROXY_READY signal */ import { spawn, ChildProcess } from 'child_process'; import * as net from 'net'; import { ProgressIndicator } from '../utils/progress-indicator'; import { ok, fail, info, warn } from '../utils/ui'; import { escapeShellArg } from '../utils/shell-executor'; import { ensureCLIProxyBinary, getInstalledCliproxyVersion } from './binary-manager'; import { generateConfig, getEffectiveEnvVars, getRemoteEnvVars, getProviderConfig, ensureProviderSettings, CLIPROXY_DEFAULT_PORT, getCliproxyWritablePath, validatePort, applyThinkingConfig, } from './config-generator'; import { checkRemoteProxy } from './remote-proxy-client'; import { isAuthenticated } from './auth-handler'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from './types'; import { DEFAULT_BACKEND } from './platform-detector'; 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 { CodexReasoningProxy } from './codex-reasoning-proxy'; import { findAccountByQuery, getProviderAccounts, setDefaultAccount, touchAccount, renameAccount, getDefaultAccount, } from './account-manager'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; import { ensureMcpWebSearch, installWebSearchHook, displayWebSearchStatus, } from '../utils/websearch-manager'; import { registerSession, unregisterSession, cleanupOrphanedSessions, stopProxy, } from './session-tracker'; import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector'; import { withStartupLock } from './startup-lock'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { preflightCheck } from './quota-manager'; import { HttpsTunnelProxy } from './https-tunnel-proxy'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { port: CLIPROXY_DEFAULT_PORT, timeout: 5000, verbose: false, pollInterval: 100, }; /** * Wait for TCP port to become available * Uses polling since CLIProxyAPI doesn't emit PROXY_READY signal */ async function waitForProxyReady( port: number, timeout: number = 5000, pollInterval: number = 100 ): Promise { const start = Date.now(); while (Date.now() - start < timeout) { try { await new Promise((resolve, reject) => { const socket = net.createConnection({ port, host: '127.0.0.1' }, () => { socket.destroy(); resolve(); }); socket.on('error', (err) => { socket.destroy(); reject(err); }); // Individual connection timeout socket.setTimeout(1000, () => { socket.destroy(); reject(new Error('Connection timeout')); }); }); return; // Connection successful - proxy is ready } catch { // Connection failed, wait and retry await new Promise((r) => setTimeout(r, pollInterval)); } } throw new Error(`CLIProxy not ready after ${timeout}ms on port ${port}`); } /** * Execute Claude CLI with CLIProxy (main entry point) * * @param claudeCli Path to Claude CLI executable * @param provider CLIProxy provider (gemini, codex, agy, qwen) * @param args Arguments to pass to Claude CLI * @param config Optional executor configuration */ export async function execClaudeWithCLIProxy( claudeCli: string, provider: CLIProxyProvider, args: string[], config: Partial = {} ): Promise { // Filter out undefined values to prevent overwriting defaults const filteredConfig = Object.fromEntries( Object.entries(config).filter(([, v]) => v !== undefined) ) as Partial; const cfg = { ...DEFAULT_CONFIG, ...filteredConfig }; const verbose = cfg.verbose || args.includes('--verbose') || args.includes('-v'); const log = (msg: string) => { if (verbose) { console.error(`[cliproxy] ${msg}`); } }; // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) // This filters proxy flags from args and returns resolved config const unifiedConfig = loadOrCreateUnifiedConfig(); // 0a. Runtime backend/provider validation - block kiro/ghcp if backend=original const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND; if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(provider)) { console.error(''); console.error(fail(`${provider} requires CLIProxyAPIPlus backend`)); console.error(''); console.error('To use this provider, either:'); console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml'); console.error(' 2. Use --backend=plus flag: ccs ' + provider + ' --backend=plus'); console.error(''); throw new Error(`Provider ${provider} requires Plus backend`); } const cliproxyServerConfig = unifiedConfig.cliproxy_server; const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { remote: cliproxyServerConfig?.remote ? { enabled: cliproxyServerConfig.remote.enabled, host: cliproxyServerConfig.remote.host, port: cliproxyServerConfig.remote.port, protocol: cliproxyServerConfig.remote.protocol, auth_token: cliproxyServerConfig.remote.auth_token, timeout: cliproxyServerConfig.remote.timeout, } : undefined, local: cliproxyServerConfig?.local ? { port: cliproxyServerConfig.local.port, auto_start: cliproxyServerConfig.local.auto_start, } : undefined, }); // Use resolved port from proxy config (overrides ExecutorConfig) // Priority: CLI flags > Variant port (cfg.port) > config.yaml > default // cfg.port is set if variant has dedicated port; proxyConfig.port comes from CLI/ENV/config.yaml if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) { // Variant port already set via config param - keep it (highest priority after CLI flags) // CLI flags would have been parsed in resolveProxyConfig and set proxyConfig.port // So if proxyConfig.port differs from default AND cfg.port differs, CLI wins if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { cfg.port = proxyConfig.port; // CLI/ENV override variant port } // else: keep cfg.port (variant port) } else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) { cfg.port = proxyConfig.port; } // Final port validation - ensure valid port after all resolution cfg.port = validatePort(cfg.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 ensureMcpWebSearch(); // Install WebSearch hook for Gemini CLI + MCP fallback // Hook intercepts WebSearch, tries Gemini CLI first, falls back to MCP installWebSearchHook(); // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); // Validate provider const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); // 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: proxyConfig.timeout ?? 2000, allowSelfSigned: proxyConfig.allowSelfSigned ?? false, }); 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'); } } } // 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'); // Kiro-specific: --import to import token from Kiro IDE directly const forceImport = argsWithoutProxy.includes('--import'); // Kiro-specific: browser mode for OAuth // Default to normal browser (noIncognito=true) for reliability - incognito often fails on Linux // --incognito flag opts into incognito mode, --no-incognito is legacy (now default) const incognitoFlag = argsWithoutProxy.includes('--incognito'); const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito'); // Config setting (defaults to true = normal browser) const kiroNoIncognitoConfig = provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false; // --incognito flag overrides everything to use incognito const noIncognito = incognitoFlag ? false : noIncognitoFlag || kiroNoIncognitoConfig; // Parse --use flag let useAccount: string | undefined; 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 = argsWithoutProxy.indexOf('--nickname'); if ( nicknameIdx !== -1 && argsWithoutProxy[nicknameIdx + 1] && !argsWithoutProxy[nicknameIdx + 1].startsWith('-') ) { setNickname = argsWithoutProxy[nicknameIdx + 1]; } // Parse --thinking flag for thinking budget control // Supports: level names (low, medium, high, xhigh, auto), numeric budget, or 'off'/'none' // Accepts both --thinking=value and --thinking value formats let thinkingOverride: string | number | undefined; // Check for --thinking=value format first const thinkingEqArg = argsWithoutProxy.find((arg) => arg.startsWith('--thinking=')); if (thinkingEqArg) { const val = thinkingEqArg.substring('--thinking='.length); // Handle empty value after equals (--thinking=) if (!val || val.trim() === '') { console.error(fail('--thinking requires a value')); console.error(' Examples: --thinking=low, --thinking=8192, --thinking=off'); console.error(' Levels: minimal, low, medium, high, xhigh, auto'); process.exit(1); } // Parse as number if numeric, otherwise keep as string (level name) const numVal = parseInt(val, 10); thinkingOverride = !isNaN(numVal) ? numVal : val; // W2: Warn if multiple --thinking flags found (check both formats) const allThinkingFlags = argsWithoutProxy.filter( (arg) => arg === '--thinking' || arg.startsWith('--thinking=') ); if (allThinkingFlags.length > 1) { console.warn( `[!] Multiple --thinking flags detected. Using first occurrence: ${thinkingEqArg}` ); } } else { // Fall back to --thinking value format const thinkingIdx = argsWithoutProxy.indexOf('--thinking'); if (thinkingIdx !== -1) { const nextArg = argsWithoutProxy[thinkingIdx + 1]; // U1: Check if --thinking has a value (not missing or another flag) if (!nextArg || nextArg.startsWith('-')) { console.error(fail('--thinking requires a value')); console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); console.error(' Levels: minimal, low, medium, high, xhigh, auto'); process.exit(1); } const val = nextArg; // Parse as number if numeric, otherwise keep as string (level name) const numVal = parseInt(val, 10); thinkingOverride = !isNaN(numVal) ? numVal : val; // W2: Warn if multiple --thinking flags found (check both formats) const allThinkingFlags = argsWithoutProxy.filter( (arg) => arg === '--thinking' || arg.startsWith('--thinking=') ); if (allThinkingFlags.length > 1) { console.warn( `[!] Multiple --thinking flags detected. Using first occurrence: --thinking ${val}` ); } } } // Handle --accounts: list accounts and exit if (showAccounts) { const accounts = getProviderAccounts(provider); if (accounts.length === 0) { console.log(info(`No accounts registered for ${providerConfig.displayName}`)); console.log(` Run "ccs ${provider} --auth" to add an account`); } else { console.log(`\n${providerConfig.displayName} Accounts:\n`); for (const acct of accounts) { const defaultMark = acct.isDefault ? ' (default)' : ''; const nickname = acct.nickname ? `[${acct.nickname}]` : ''; console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`); } console.log(`\n Use "ccs ${provider} --use " to switch accounts`); } process.exit(0); } // Handle --use: switch to specified account if (useAccount) { const account = findAccountByQuery(provider, useAccount); if (!account) { console.error(fail(`Account not found: "${useAccount}"`)); const accounts = getProviderAccounts(provider); if (accounts.length > 0) { console.error(` Available accounts:`); for (const acct of accounts) { console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`); } } process.exit(1); } // Set as default for this and future sessions setDefaultAccount(provider, account.id); touchAccount(provider, account.id); console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`)); } // Handle --nickname: rename account and exit (unless used with --auth --add) if (setNickname && !addAccount) { const defaultAccount = getDefaultAccount(provider); if (!defaultAccount) { console.error(fail(`No account found for ${providerConfig.displayName}`)); console.error(` Run "ccs ${provider} --auth" to add an account first`); process.exit(1); } try { const success = renameAccount(provider, defaultAccount.id, setNickname); if (success) { console.log(ok(`Renamed account to: ${setNickname}`)); } else { console.error(fail('Failed to rename account')); process.exit(1); } } catch (err) { console.error(fail(err instanceof Error ? err.message : 'Failed to rename account')); process.exit(1); } process.exit(0); } // Handle --config: configure model selection and exit // Pass customSettingsPath for CLIProxy variants to save to correct file if (forceConfig && supportsModelConfig(provider)) { await configureProviderModel(provider, true, cfg.customSettingsPath); process.exit(0); } // Handle --logout: clear auth and exit if (forceLogout) { const { clearAuth } = await import('./auth-handler'); if (clearAuth(provider)) { console.log(ok(`Logged out from ${providerConfig.displayName}`)); } else { console.log(info(`No authentication found for ${providerConfig.displayName}`)); } process.exit(0); } // Handle --import: import token from Kiro IDE directly (Kiro only) if (forceImport) { if (provider !== 'kiro') { console.error(fail('--import is only available for Kiro')); console.error(` Run "ccs ${provider} --auth" to authenticate`); process.exit(1); } // Validate flag conflicts if (forceAuth) { console.error(fail('Cannot use --import with --auth')); console.error(' --import: Import existing token from Kiro IDE'); console.error(' --auth: Trigger new OAuth flow in browser'); process.exit(1); } if (forceLogout) { console.error(fail('Cannot use --import with --logout')); process.exit(1); } const { triggerOAuth } = await import('./auth-handler'); const authSuccess = await triggerOAuth(provider, { verbose, import: true, ...(setNickname ? { nickname: setNickname } : {}), }); if (!authSuccess) { console.error(fail('Failed to import Kiro token from IDE')); console.error(' Make sure you are logged into Kiro IDE first'); process.exit(1); } process.exit(0); } // 3. Ensure OAuth completed (if provider requires it) // Skip local OAuth check when using remote proxy with auth token // The remote proxy has its own OAuth sessions and handles authentication // Note: Trim authToken to reject whitespace-only values const remoteAuthToken = proxyConfig.authToken?.trim(); const skipLocalAuth = useRemoteProxy && !!remoteAuthToken; if (skipLocalAuth) { log(`Using remote proxy authentication (skipping local OAuth)`); } if (providerConfig.requiresOAuth && !skipLocalAuth) { log(`Checking authentication for ${provider}`); if (forceAuth || !isAuthenticated(provider)) { // Pass headless only if explicitly set; otherwise let auth-handler auto-detect const { triggerOAuth } = await import('./auth-handler'); const authSuccess = await triggerOAuth(provider, { verbose, add: addAccount, ...(forceHeadless ? { headless: true } : {}), ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), }); if (!authSuccess) { throw new Error(`Authentication required for ${providerConfig.displayName}`); } // If --auth was explicitly passed, exit after auth (don't start Claude) if (forceAuth) { process.exit(0); } } else { log(`${provider} already authenticated`); } // 3a. Proactive token refresh - prevent UND_ERR_SOCKET from expired tokens // CLIProxyAPI may fail mid-request if token expires during use const { ensureTokenValid } = await import('./auth/token-manager'); const tokenResult = await ensureTokenValid(provider, verbose); if (!tokenResult.valid) { // Token expired and refresh failed - trigger re-auth console.error(warn('OAuth token expired and refresh failed')); if (tokenResult.error) { console.error(` ${tokenResult.error}`); } console.error(` Run "ccs ${provider} --auth" to re-authenticate`); process.exit(1); } if (tokenResult.refreshed) { log('Token was refreshed proactively'); } // 3a-1. Update lastUsedAt for the account being used // This ensures dashboard shows accurate "Last used" timestamps const usedAccount = getDefaultAccount(provider); if (usedAccount) { touchAccount(provider, usedAccount.id); } } // 3b. Preflight quota check - auto-switch to account with quota before launch // Uses quota-manager for caching, tier priority, and cooldown support // Skip for remote proxy - quota is managed on the remote server if (provider === 'agy' && !skipLocalAuth) { const preflight = await preflightCheck(provider); if (!preflight.proceed) { console.error(fail(`Cannot start session: ${preflight.reason}`)); process.exit(1); } if (preflight.switchedFrom) { console.log(info(`Auto-switched to ${preflight.accountId}`)); console.log(` Reason: ${preflight.reason}`); if (preflight.quotaPercent !== undefined && preflight.quotaPercent !== null) { console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`); } else { console.log(` New account quota: N/A (fetch unavailable)`); } } } // 4. First-run model configuration (interactive) // For supported providers, prompt user to select model on first run // Pass customSettingsPath for CLIProxy variants // Skip for remote proxy - model is configured on the remote server if (supportsModelConfig(provider) && !skipLocalAuth) { await configureProviderModel(provider, false, cfg.customSettingsPath); // false = only if not configured } // 5. Check for known broken models and warn user // Show warning for both local and remote modes - user should be aware of model issues const currentModel = getCurrentModel(provider, cfg.customSettingsPath); if (currentModel && isModelBroken(provider, currentModel)) { const modelEntry = findModel(provider, currentModel); const issueUrl = getModelIssueUrl(provider, currentModel); console.error(''); console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); if (issueUrl) { console.error(` Tracking: ${issueUrl}`); } if (skipLocalAuth) { console.error(' Note: Model may be overridden by remote proxy configuration.'); } else { console.error(` Run "ccs ${provider} --config" to change model.`); } console.error(''); } // 6. Ensure user settings file exists (creates from defaults if not) ensureProviderSettings(provider); // Local proxy mode: generate config, spawn proxy, track session let proxy: ChildProcess | null = null; 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 using unified detection with startup lock // This prevents race conditions when multiple CCS processes start simultaneously cleanupOrphanedSessions(cfg.port); // Use startup lock to coordinate with other CCS processes await withStartupLock(async () => { // Detect running proxy using multiple methods (HTTP, session-lock, port-process) let proxyStatus = await detectRunningProxy(cfg.port); log(`Proxy detection: ${JSON.stringify(proxyStatus)}`); // Check for version mismatch - restart proxy if installed version differs from running if (proxyStatus.running && proxyStatus.verified && proxyStatus.version) { const installedVersion = getInstalledCliproxyVersion(); if (installedVersion !== proxyStatus.version) { console.log( warn( `Version mismatch: running v${proxyStatus.version}, installed v${installedVersion}. Restarting proxy...` ) ); log(`Stopping outdated proxy (PID: ${proxyStatus.pid ?? 'unknown'})...`); const stopResult = await stopProxy(cfg.port); if (stopResult.stopped) { log(`Stopped outdated proxy successfully`); } else { log(`Stop proxy result: ${stopResult.error ?? 'unknown error'}`); } // Wait for port to be released await new Promise((r) => setTimeout(r, 500)); // Re-detect proxy status (should now be not running) proxyStatus = await detectRunningProxy(cfg.port); log(`Re-detection after version mismatch restart: ${JSON.stringify(proxyStatus)}`); } } if (proxyStatus.running && proxyStatus.verified) { // Healthy proxy found - join it if (proxyStatus.pid) { sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; } if (sessionId) { console.log(info(`Joined existing CLIProxy on port ${cfg.port} (${proxyStatus.method})`)); } else { // Failed to register session - proxy is running but we can't track it // This happens when port-process detection fails (permissions, platform issues) console.log( info(`Using existing CLIProxy on port ${cfg.port} (session tracking unavailable)`) ); log(`PID=${proxyStatus.pid ?? 'unknown'}, session registration skipped`); } return; // Exit lock early, skip spawning } if (proxyStatus.running && !proxyStatus.verified) { // Proxy detected but not ready yet (another process is starting it) log(`Proxy starting up (detected via ${proxyStatus.method}), waiting...`); const becameHealthy = await waitForProxyHealthy(cfg.port, cfg.timeout); if (becameHealthy) { if (proxyStatus.pid) { sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.pid, verbose) ?? undefined; } console.log(info(`Joined CLIProxy after startup wait`)); return; // Exit lock early } // Proxy didn't become healthy - kill and respawn if (proxyStatus.pid) { log(`Proxy PID ${proxyStatus.pid} not responding, killing...`); killProcessOnPort(cfg.port, verbose); await new Promise((r) => setTimeout(r, 500)); } } if (proxyStatus.blocked && proxyStatus.blocker) { // Port blocked by non-CLIProxy process // Last resort: try HTTP health check (handles Windows PID-XXXXX case) const isActuallyOurs = await waitForProxyHealthy(cfg.port, 1000); if (isActuallyOurs) { sessionId = reclaimOrphanedProxy(cfg.port, proxyStatus.blocker.pid, verbose) ?? undefined; console.log(info(`Reclaimed CLIProxy with unrecognized process name`)); return; } // Truly blocked by another application console.error(''); console.error( warn( `Port ${cfg.port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.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 const proxyArgs = ['--config', configPath]; log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); proxy = spawn(binaryPath as string, proxyArgs, { stdio: ['ignore', 'ignore', 'ignore'], detached: true, env: { ...process.env, WRITABLE_PATH: getCliproxyWritablePath(), }, }); proxy.unref(); 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) { const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; readySpinner.fail(`${backendLabel} startup failed`); proxy.kill('SIGTERM'); const err = error as Error; console.error(''); console.error(fail(`${backendLabel} 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, including version and backend const installedVersion = getInstalledCliproxyVersion(); sessionId = registerSession(cfg.port, proxy.pid as number, installedVersion, backend); log( `Registered session ${sessionId} with new proxy (PID ${proxy.pid}, version ${installedVersion})` ); }); } // 7. Execute Claude CLI with proxied environment // Use remote or local env vars based on mode // When remote is configured (even if using local), pass config for URL rewriting const remoteRewriteConfig = proxyConfig.mode === 'remote' && proxyConfig.host ? { host: proxyConfig.host, port: proxyConfig.port, protocol: proxyConfig.protocol, authToken: proxyConfig.authToken, } : undefined; // For HTTPS remote, we need a local HTTP tunnel since Claude Code doesn't support // HTTPS in ANTHROPIC_BASE_URL directly (undici limitation) let httpsTunnel: HttpsTunnelProxy | null = null; let tunnelPort: number | null = null; if (useRemoteProxy && proxyConfig.protocol === 'https' && proxyConfig.host) { try { httpsTunnel = new HttpsTunnelProxy({ remoteHost: proxyConfig.host, remotePort: proxyConfig.port, authToken: proxyConfig.authToken, verbose, allowSelfSigned: proxyConfig.allowSelfSigned ?? false, }); tunnelPort = await httpsTunnel.start(); log( `HTTPS tunnel started on port ${tunnelPort} → https://${proxyConfig.host}:${proxyConfig.port}` ); } catch (error) { const err = error as Error; console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`)); throw new Error(`HTTPS tunnel startup failed: ${err.message}`); } } // Build env vars - use tunnel port for HTTPS remote, direct URL otherwise const envVars = useRemoteProxy ? httpsTunnel && tunnelPort ? // HTTPS remote via local tunnel - use HTTP to tunnel getRemoteEnvVars( provider, { host: '127.0.0.1', port: tunnelPort, protocol: 'http', // Tunnel speaks HTTP locally authToken: proxyConfig.authToken, }, cfg.customSettingsPath ) : // HTTP remote - direct connection getRemoteEnvVars( provider, { host: proxyConfig.host ?? 'localhost', port: proxyConfig.port, protocol: proxyConfig.protocol, authToken: proxyConfig.authToken, }, cfg.customSettingsPath ) : getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig); // Apply thinking configuration to model (auto tier-based or manual override) // This adds thinking suffix like model(high) or model(8192) for CLIProxyAPIPlus applyThinkingConfig(envVars, provider, thinkingOverride); // Codex-only: inject OpenAI reasoning effort based on tier model mapping. // Maps by request.model: // - OPUS/default model → xhigh // - SONNET model → high // - HAIKU model → medium // - Unknown → medium let codexReasoningProxy: CodexReasoningProxy | null = null; let codexReasoningPort: number | null = null; if (provider === 'codex') { if (!envVars.ANTHROPIC_BASE_URL) { log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled'); } else { try { const traceEnabled = process.env.CCS_CODEX_REASONING_TRACE === '1' || process.env.CCS_CODEX_REASONING_TRACE === 'true'; // For remote proxy mode, strip /api/provider/codex prefix from paths // because remote CLIProxyAPI uses root paths (/v1/messages), not provider-prefixed const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined; codexReasoningProxy = new CodexReasoningProxy({ upstreamBaseUrl: envVars.ANTHROPIC_BASE_URL, verbose, defaultEffort: 'medium', traceFilePath: traceEnabled ? `${process.env.HOME || process.cwd()}/.ccs/codex-reasoning-proxy.log` : '', modelMap: { defaultModel: envVars.ANTHROPIC_MODEL, opusModel: envVars.ANTHROPIC_DEFAULT_OPUS_MODEL, sonnetModel: envVars.ANTHROPIC_DEFAULT_SONNET_MODEL, haikuModel: envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL, }, stripPathPrefix, }); codexReasoningPort = await codexReasoningProxy.start(); log( `Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex` ); } catch (error) { const err = error as Error; codexReasoningProxy = null; codexReasoningPort = null; if (verbose) { console.error(warn(`Codex reasoning proxy disabled: ${err.message}`)); } } } } const effectiveEnvVars = codexReasoningProxy && codexReasoningPort ? { ...envVars, ANTHROPIC_BASE_URL: `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`, } : envVars; const webSearchEnv = getWebSearchHookEnv(); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; log(`Claude env: ANTHROPIC_BASE_URL=${effectiveEnvVars.ANTHROPIC_BASE_URL}`); log(`Claude env: ANTHROPIC_MODEL=${effectiveEnvVars.ANTHROPIC_MODEL}`); if (Object.keys(webSearchEnv).length > 0) { log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`); } // Log global env vars for visibility if ( effectiveEnvVars.DISABLE_TELEMETRY || effectiveEnvVars.DISABLE_ERROR_REPORTING || effectiveEnvVars.DISABLE_BUG_COMMAND ) { log(`Claude env: Global env applied (telemetry/reporting disabled)`); } // Filter out CCS-specific flags before passing to Claude CLI // Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig() const ccsFlags = [ '--auth', '--headless', '--logout', '--config', '--add', '--accounts', '--use', '--nickname', '--thinking', '--incognito', '--no-incognito', '--import', // Proxy flags are handled by resolveProxyConfig, but list for documentation ...PROXY_CLI_FLAGS, ]; const claudeArgs = argsWithoutProxy.filter((arg, idx) => { // Filter out CCS flags if (ccsFlags.includes(arg)) return false; // Filter out --thinking=value format if (arg.startsWith('--thinking=')) return false; // Filter out value after --use, --nickname, or --thinking if ( argsWithoutProxy[idx - 1] === '--use' || argsWithoutProxy[idx - 1] === '--nickname' || argsWithoutProxy[idx - 1] === '--thinking' ) return false; return true; }); const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); let claude: ChildProcess; if (needsShell) { const cmdString = [claudeCli, ...claudeArgs].map(escapeShellArg).join(' '); claude = spawn(cmdString, { stdio: 'inherit', windowsHide: true, shell: true, env, }); } else { claude = spawn(claudeCli, claudeArgs, { stdio: 'inherit', windowsHide: true, env, }); } // 8. Cleanup: unregister session when Claude exits (local mode only) // Proxy persists by default - use 'ccs cliproxy stop' to kill manually // Capture port for cleanup (avoids closure issues) const sessionPort = cfg.port; claude.on('exit', (code, signal) => { log(`Claude exited: code=${code}, signal=${signal}`); if (codexReasoningProxy) { codexReasoningProxy.stop(); } if (httpsTunnel) { httpsTunnel.stop(); } // Unregister this session (proxy keeps running for persistence) - only for local mode if (sessionId) { unregisterSession(sessionId, sessionPort); log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); } if (signal) { process.kill(process.pid, signal as NodeJS.Signals); } else { process.exit(code || 0); } }); claude.on('error', (error) => { console.error(fail(`Claude CLI error: ${error}`)); if (codexReasoningProxy) { codexReasoningProxy.stop(); } if (httpsTunnel) { httpsTunnel.stop(); } // Unregister session, proxy keeps running (local mode only) if (sessionId) { unregisterSession(sessionId, sessionPort); } process.exit(1); }); // Handle parent process termination (SIGTERM, SIGINT) const cleanup = () => { log('Parent signal received, cleaning up'); if (codexReasoningProxy) { codexReasoningProxy.stop(); } if (httpsTunnel) { httpsTunnel.stop(); } // Unregister session, proxy keeps running (local mode only) if (sessionId) { unregisterSession(sessionId, sessionPort); } claude.kill('SIGTERM'); }; process.once('SIGTERM', cleanup); process.once('SIGINT', cleanup); } /** * Check if a port is available */ export async function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); server.once('error', () => { resolve(false); }); server.once('listening', () => { server.close(); resolve(true); }); server.listen(port, '127.0.0.1'); }); } /** * Find an available port in range */ export async function findAvailablePort( startPort: number = CLIPROXY_DEFAULT_PORT, range: number = 10 ): Promise { for (let port = startPort; port < startPort + range; port++) { if (await isPortAvailable(port)) { return port; } } throw new Error(`No available port found in range ${startPort}-${startPort + range - 1}`); } export default execClaudeWithCLIProxy;