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 committed 2025-12-19 01:15:33 -05:00
1 parent f4a50d006c
commit bd1ff2f059
1 file changed
+132 -27
+132 -27
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,11 +161,65 @@ 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)
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',
});
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<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');
}
}
}
// 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'); const spinner = new ProgressIndicator('Preparing CLIProxy');
spinner.start(); spinner.start();
let binaryPath: string;
try { try {
binaryPath = await ensureCLIProxyBinary(verbose); binaryPath = await ensureCLIProxyBinary(verbose);
spinner.succeed('CLIProxy binary ready'); spinner.succeed('CLIProxy binary ready');
@@ -154,27 +227,36 @@ export async function execClaudeWithCLIProxy(
spinner.fail('Failed to prepare CLIProxy'); spinner.fail('Failed to prepare CLIProxy');
throw error; throw error;
} }
}
// 2. Handle special flags // 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped)
const forceAuth = args.includes('--auth'); const forceAuth = argsWithoutProxy.includes('--auth');
const forceHeadless = args.includes('--headless'); const forceHeadless = argsWithoutProxy.includes('--headless');
const forceLogout = args.includes('--logout'); const forceLogout = argsWithoutProxy.includes('--logout');
const forceConfig = args.includes('--config'); const forceConfig = argsWithoutProxy.includes('--config');
const addAccount = args.includes('--add'); const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = args.includes('--accounts'); 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,6 +388,10 @@ 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);
// Local proxy mode: generate config, spawn proxy, track session
let proxy: ChildProcess | null = null;
if (!useRemoteProxy) {
// 6. Generate config file // 6. Generate config file
log(`Generating config for ${provider}`); log(`Generating config for ${provider}`);
const configPath = generateConfig(provider, cfg.port); const configPath = generateConfig(provider, cfg.port);
@@ -317,8 +403,6 @@ export async function execClaudeWithCLIProxy(
// Check if there's an existing healthy proxy we can reuse // Check if there's an existing healthy proxy we can reuse
const existingProxy = getExistingProxy(cfg.port); const existingProxy = getExistingProxy(cfg.port);
let proxy: ChildProcess | null = null;
let sessionId: string;
if (existingProxy) { if (existingProxy) {
// Reuse existing proxy - another CCS session started it // Reuse existing proxy - another CCS session started it
@@ -351,7 +435,9 @@ export async function execClaudeWithCLIProxy(
// Non-CLIProxy process blocking the port - warn user // Non-CLIProxy process blocking the port - warn user
console.error(''); console.error('');
console.error( console.error(
warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`) warn(
`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`
)
); );
console.error(''); console.error('');
console.error('To fix this, close the blocking application or run:'); console.error('To fix this, close the blocking application or run:');
@@ -363,11 +449,12 @@ export async function execClaudeWithCLIProxy(
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy) // 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
// Use detached mode so proxy persists after terminal closes // Use detached mode so proxy persists after terminal closes
const configPath = generateConfig(provider, cfg.port);
const proxyArgs = ['--config', configPath]; const proxyArgs = ['--config', configPath];
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`); log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
proxy = spawn(binaryPath, proxyArgs, { proxy = spawn(binaryPath as string, proxyArgs, {
stdio: ['ignore', 'ignore', 'ignore'], stdio: ['ignore', 'ignore', 'ignore'],
detached: true, // Persist after parent terminal closes detached: true, // Persist after parent terminal closes
env: { env: {
@@ -418,10 +505,18 @@ export async function execClaudeWithCLIProxy(
sessionId = registerSession(cfg.port, proxy.pid as number); sessionId = registerSession(cfg.port, proxy.pid as number);
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`); 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
if (sessionId) {
unregisterSession(sessionId); unregisterSession(sessionId);
log(`Session ${sessionId} unregistered, proxy persists for other sessions or future use`); 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)
if (sessionId) {
unregisterSession(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)
if (sessionId) {
unregisterSession(sessionId); unregisterSession(sessionId);
}
claude.kill('SIGTERM'); claude.kill('SIGTERM');
}; };