feat(cliproxy): integrate remote proxy mode in executor

- resolve proxy config with CLI flags, ENV vars, and config.yaml

- check remote proxy health before skipping local binary spawn

- implement TTY-aware fallback prompting when remote unreachable

- support --remote-only flag to disable fallback

- use remote or local env vars based on mode
This commit is contained in:
kaitranntt
2025-12-19 01:15:33 -05:00
parent f4a50d006c
commit bd1ff2f059
+244 -139
View File
@@ -21,14 +21,17 @@ import { ensureCLIProxyBinary } from './binary-manager';
import { import {
generateConfig, generateConfig,
getEffectiveEnvVars, getEffectiveEnvVars,
getRemoteEnvVars,
getProviderConfig, getProviderConfig,
ensureProviderSettings, ensureProviderSettings,
CLIPROXY_DEFAULT_PORT, CLIPROXY_DEFAULT_PORT,
getCliproxyWritablePath, getCliproxyWritablePath,
} from './config-generator'; } from './config-generator';
import { checkRemoteProxy } from './remote-proxy-client';
import { isAuthenticated } from './auth-handler'; import { isAuthenticated } from './auth-handler';
import { CLIProxyProvider, ExecutorConfig } from './types'; import { CLIProxyProvider, ExecutorConfig } from './types';
import { configureProviderModel, getCurrentModel } from './model-config'; import { configureProviderModel, getCurrentModel } from './model-config';
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
import { 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 // Ensure MCP web-search is configured for third-party profiles
// WebSearch is a server-side tool executed by Anthropic's API // WebSearch is a server-side tool executed by Anthropic's API
// Third-party providers don't have access, so we use MCP fallback // Third-party providers don't have access, so we use MCP fallback
@@ -142,39 +161,102 @@ export async function execClaudeWithCLIProxy(
const providerConfig = getProviderConfig(provider); const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`); log(`Provider: ${providerConfig.displayName}`);
// 1. Ensure binary exists (downloads if needed) // Check remote proxy if configured (before binary download)
const spinner = new ProgressIndicator('Preparing CLIProxy'); let useRemoteProxy = false;
spinner.start(); 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; if (status.reachable) {
try { useRemoteProxy = true;
binaryPath = await ensureCLIProxyBinary(verbose); console.log(
spinner.succeed('CLIProxy binary ready'); ok(
} catch (error) { `Connected to remote proxy at ${proxyConfig.host}:${proxyConfig.port} (${status.latencyMs}ms)`
spinner.fail('Failed to prepare CLIProxy'); )
throw error; );
} 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<string>((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 // Variables for local proxy mode
const forceAuth = args.includes('--auth'); let binaryPath: string | undefined;
const forceHeadless = args.includes('--headless'); let sessionId: string | undefined;
const forceLogout = args.includes('--logout');
const forceConfig = args.includes('--config'); // 1. Ensure binary exists (downloads if needed) - SKIP for remote mode
const addAccount = args.includes('--add'); if (!useRemoteProxy) {
const showAccounts = args.includes('--accounts'); 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 <account> flag // Parse --use <account> flag
let useAccount: string | undefined; let useAccount: string | undefined;
const useIdx = args.indexOf('--use'); const useIdx = argsWithoutProxy.indexOf('--use');
if (useIdx !== -1 && args[useIdx + 1] && !args[useIdx + 1].startsWith('-')) { if (
useAccount = args[useIdx + 1]; useIdx !== -1 &&
argsWithoutProxy[useIdx + 1] &&
!argsWithoutProxy[useIdx + 1].startsWith('-')
) {
useAccount = argsWithoutProxy[useIdx + 1];
} }
// Parse --nickname <name> flag // Parse --nickname <name> flag
let setNickname: string | undefined; let setNickname: string | undefined;
const nicknameIdx = args.indexOf('--nickname'); const nicknameIdx = argsWithoutProxy.indexOf('--nickname');
if (nicknameIdx !== -1 && args[nicknameIdx + 1] && !args[nicknameIdx + 1].startsWith('-')) { if (
setNickname = args[nicknameIdx + 1]; nicknameIdx !== -1 &&
argsWithoutProxy[nicknameIdx + 1] &&
!argsWithoutProxy[nicknameIdx + 1].startsWith('-')
) {
setNickname = argsWithoutProxy[nicknameIdx + 1];
} }
// Handle --accounts: list accounts and exit // 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) // 6. Ensure user settings file exists (creates from defaults if not)
ensureProviderSettings(provider); ensureProviderSettings(provider);
// 6. Generate config file // Local proxy mode: generate config, spawn proxy, track session
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);
let proxy: ChildProcess | null = null; let proxy: ChildProcess | null = null;
let sessionId: string;
if (existingProxy) { if (!useRemoteProxy) {
// Reuse existing proxy - another CCS session started it // 6. Generate config file
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`); log(`Generating config for ${provider}`);
sessionId = registerSession(cfg.port, existingProxy.pid); const configPath = generateConfig(provider, cfg.port);
console.log( log(`Config written: ${configPath}`);
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
); // 6a. Pre-flight check: handle existing proxy or port conflicts
} else { // Clean up orphaned sessions first (from crashed proxies)
// No existing proxy - check if port is free cleanupOrphanedSessions(cfg.port);
const portProcess = await getPortProcess(cfg.port);
if (portProcess) { // Check if there's an existing healthy proxy we can reuse
if (isCLIProxyProcess(portProcess)) { const existingProxy = getExistingProxy(cfg.port);
// CLIProxy on port but no session lock - likely orphaned/zombie
// Only kill if no active sessions registered if (existingProxy) {
if (!hasActiveSessions()) { // Reuse existing proxy - another CCS session started it
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`); log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
const killed = killProcessOnPort(cfg.port, verbose); sessionId = registerSession(cfg.port, existingProxy.pid);
if (killed) { console.log(
console.log(info(`Cleaned up zombie CLIProxy process`)); info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
// Wait a bit for port to be released );
await new Promise((r) => setTimeout(r, 500)); } 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 { } else {
// Active sessions exist but getExistingProxy returned null - something's wrong // Non-CLIProxy process blocking the port - warn user
// Try to connect anyway console.error('');
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`); 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 // 7. Execute Claude CLI with proxied environment
// Uses custom settings path (for variants), user settings, or bundled defaults // Use remote or local env vars based on mode
const envVars = getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath); 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 webSearchEnv = getWebSearchHookEnv();
const env = { const env = {
...process.env, ...process.env,
@@ -437,6 +532,7 @@ export async function execClaudeWithCLIProxy(
} }
// Filter out CCS-specific flags before passing to Claude CLI // Filter out CCS-specific flags before passing to Claude CLI
// Note: Proxy flags (--proxy-host, etc.) already stripped by resolveProxyConfig()
const ccsFlags = [ const ccsFlags = [
'--auth', '--auth',
'--headless', '--headless',
@@ -446,12 +542,15 @@ export async function execClaudeWithCLIProxy(
'--accounts', '--accounts',
'--use', '--use',
'--nickname', '--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 // Filter out CCS flags
if (ccsFlags.includes(arg)) return false; if (ccsFlags.includes(arg)) return false;
// Filter out value after --use or --nickname // 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; 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 // Proxy persists by default - use 'ccs cliproxy stop' to kill manually
claude.on('exit', (code, signal) => { claude.on('exit', (code, signal) => {
log(`Claude exited: code=${code}, signal=${signal}`); log(`Claude exited: code=${code}, signal=${signal}`);
// Unregister this session (proxy keeps running for persistence) // Unregister this session (proxy keeps running for persistence) - only for local mode
unregisterSession(sessionId); if (sessionId) {
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); unregisterSession(sessionId);
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`);
}
if (signal) { if (signal) {
process.kill(process.pid, signal as NodeJS.Signals); process.kill(process.pid, signal as NodeJS.Signals);
@@ -494,8 +595,10 @@ export async function execClaudeWithCLIProxy(
claude.on('error', (error) => { claude.on('error', (error) => {
console.error(fail(`Claude CLI error: ${error}`)); console.error(fail(`Claude CLI error: ${error}`));
// Unregister session, proxy keeps running // Unregister session, proxy keeps running (local mode only)
unregisterSession(sessionId); if (sessionId) {
unregisterSession(sessionId);
}
process.exit(1); process.exit(1);
}); });
@@ -503,8 +606,10 @@ export async function execClaudeWithCLIProxy(
const cleanup = () => { const cleanup = () => {
log('Parent signal received, cleaning up'); log('Parent signal received, cleaning up');
// Unregister session, proxy keeps running // Unregister session, proxy keeps running (local mode only)
unregisterSession(sessionId); if (sessionId) {
unregisterSession(sessionId);
}
claude.kill('SIGTERM'); claude.kill('SIGTERM');
}; };