Files
ccs/src/cliproxy/executor/index.ts
T

551 lines
18 KiB
TypeScript

/**
* CLIProxy Executor - Main Orchestrator
*
* Coordinates the full execution flow:
* 1. Configuration resolution and validation
* 2. Binary management and remote proxy checks
* 3. Authentication and account management
* 4. Proxy lifecycle (spawn/detect/join)
* 5. Environment setup and proxy chains
* 6. Claude CLI execution with cleanup handlers
*/
import { ChildProcess } from 'child_process';
import * as fs from 'fs';
import { fail, info, warn } from '../../utils/ui';
import {
generateConfig,
getProviderConfig,
CLIPROXY_DEFAULT_PORT,
} from '../config/config-generator';
import { configureProviderModel } from '../config/model-config';
import { supportsModelConfig } from '../model-catalog';
import { CLIProxyProvider, ExecutorConfig } from '../types';
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
import { ensureWebSearchMcpOrThrow, displayWebSearchStatus } from '../../utils/websearch-manager';
import {
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
} from '../../utils/image-analysis';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/config-loader-facade';
import { HttpsTunnelProxy } from '../proxy/https-tunnel-proxy';
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
// Import modular components
import { resolveBrowserLaunchFlags, resolveBrowserRuntime } from './browser-launch-setup';
import {
resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders,
resolveAccounts,
} from './account-resolution';
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
import {
buildClaudeEnvironment,
logEnvironment,
resolveCliproxyImageAnalysisEnv,
} from './env-resolver';
import { checkOrJoinProxy, registerProxySession } from './session-bridge';
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
import {
handleLogout,
handleImport,
resolveSkipLocalAuth,
runAntigravityGate,
ensureProviderAuthentication,
runPreflightQuotaCheck,
runAccountSafetyGuards,
ensureModelConfiguration,
ensureProviderSettingsFile,
} from './auth-coordinator';
import {
buildThinkingStartupStatus,
resolveRuntimeThinkingOverride,
} from './thinking-override-resolver';
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
import { resolveExecutorProxy } from './proxy-resolver';
import { buildProxyChain } from './proxy-chain-builder';
import { warnBrokenModels } from './model-warnings';
import { launchClaude } from './claude-launcher';
/** Local alias so internal call sites need no change */
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
port: CLIPROXY_DEFAULT_PORT,
timeout: 5000,
verbose: false,
pollInterval: 100,
};
// readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags are
// re-exported from ./arg-parser via the export block at the bottom of this file
// for backwards compatibility with external callers.
/**
* 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<ExecutorConfig> = {}
): Promise<void> {
// Filter out undefined values to prevent overwriting defaults
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
) as Partial<ExecutorConfig>;
const cfg = { ...DEFAULT_CONFIG, ...filteredConfig };
const verbose = cfg.verbose || args.includes('--verbose') || args.includes('-v');
// Validate Claude CLI exists before proceeding
if (!fs.existsSync(claudeCli)) {
console.error(fail(`Claude CLI not found at: ${claudeCli}`));
console.error(' Run "ccs doctor --fix" to reinstall or check your PATH');
process.exit(1);
}
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
}
};
// Helper: Extract unique providers from composite tiers
const compositeProviders =
cfg.isComposite && cfg.compositeTiers
? [...new Set(Object.values(cfg.compositeTiers).map((t) => t.provider))]
: [];
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
const unifiedConfig = loadOrCreateUnifiedConfig();
// Collect all providers to validate (default + composite tiers)
const allProviders = [provider, ...compositeProviders];
const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } =
await resolveExecutorProxy(args, {
unifiedConfig,
allProviders,
verbose,
cfg,
log,
});
const { browserLaunchOverride, argsWithoutBrowserFlags } =
resolveBrowserLaunchFlags(argsWithoutProxy);
// Setup first-class CCS WebSearch runtime
ensureWebSearchMcpOrThrow();
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
displayWebSearchStatus();
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
// Variables for local proxy mode
let sessionId: string | undefined;
// 2. Parse all CCS executor flags (extracted to arg-parser.ts)
const parsedFlags = parseExecutorFlags(argsWithoutProxy, {
provider,
compositeProviders,
unifiedConfig,
});
if (process.exitCode === 1) return;
// Validate cross-flag combinations (exits with code 1 on violation)
validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy);
if (process.exitCode === 1) return;
const {
forceConfig,
addAccount,
showAccounts,
useAccount,
setNickname,
extendedContextOverride,
thinkingParse,
} = parsedFlags;
const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride(
thinkingParse.value,
process.env.CCS_THINKING
);
const thinkingCfg = getThinkingConfig();
if (thinkingParse.duplicateDisplays.length > 0) {
console.warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}`
);
}
if (thinkingParse.sourceFlag === '--effort' && provider !== 'codex') {
console.warn(
warn(
'`--effort` is primarily for codex. Continuing as alias of `--thinking` for compatibility.'
)
);
}
// Handle --accounts / --use / --nickname (warnOAuthBanRisk emitted inside)
await resolveAccounts({ provider, showAccounts, useAccount, setNickname, addAccount });
// Handle --config
if (forceConfig && supportsModelConfig(provider)) {
// Block --config for composite variants (per-tier models in config.yaml)
if (cfg.isComposite) {
const variantName = cfg.profileName || provider;
console.log(
warn('Composite variants use per-tier config. Edit config.yaml to change tier models.')
);
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`);
process.exit(1);
} else {
await configureProviderModel(provider, true, cfg.customSettingsPath);
process.exit(0);
}
}
// Build auth coordination context (used for logout/import/antigravity/oauth)
const authCtx = {
provider,
compositeProviders,
parsedFlags,
cfg,
unifiedConfig,
verbose,
log,
};
// Handle --logout (early exit)
await handleLogout(authCtx);
// Handle --import (early exit, Kiro only)
await handleImport(authCtx);
// 3. Ensure OAuth completed (if provider requires it)
const remoteAuthToken = proxyConfig.authToken?.trim();
const skipLocalAuth = resolveSkipLocalAuth(remoteAuthToken, useRemoteProxy);
if (skipLocalAuth) {
log(`Using remote proxy authentication (skipping local OAuth)`);
}
// Antigravity gate (runs before OAuth check)
const { earlyReturn: agyEarlyReturn } = await runAntigravityGate(authCtx, skipLocalAuth);
if (agyEarlyReturn) return;
if (providerConfig.requiresOAuth && !skipLocalAuth) {
await ensureProviderAuthentication(authCtx);
}
// 3b. Preflight quota check (providers with quota-based rotation)
if (!skipLocalAuth) {
await runPreflightQuotaCheck(provider, compositeProviders);
}
// 3c. Account safety: enforce cross-provider isolation
if (!skipLocalAuth) {
runAccountSafetyGuards(provider, compositeProviders);
}
// 4. First-run model configuration + codex plan reconcile
if (!skipLocalAuth) {
await ensureModelConfiguration(provider, cfg, verbose);
}
// 5. Check for broken models (multi-tier for composite)
warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth });
// 6. Ensure user settings file exists
ensureProviderSettingsFile(provider);
// Local proxy mode: generate config, spawn/join proxy, track session
let proxy: ChildProcess | null = null;
let configPath: string | undefined;
if (!useRemoteProxy) {
log(`Generating config for ${provider}`);
configPath = generateConfig(provider, cfg.port);
log(`Config written: ${configPath}`);
// 6a. Check or join existing proxy
const { sessionId: existingSessionId, shouldSpawn } = await checkOrJoinProxy(
cfg.port,
cfg.timeout,
verbose
);
sessionId = existingSessionId;
// 6b. Spawn new proxy if needed
if (shouldSpawn && binaryPath) {
proxy = spawnProxy(binaryPath, configPath, verbose);
// 7. Wait for proxy readiness
await waitForProxyReadyWithSpinner(
cfg.port,
cfg.timeout,
cfg.pollInterval,
localBackend,
configPath
);
// Register session
if (proxy.pid) {
sessionId = registerProxySession(cfg.port, proxy.pid, localBackend, verbose);
}
}
}
// 8. Setup HTTPS tunnel if needed (tunnelPort used by imageAnalysisProxyTarget below)
let httpsTunnel: HttpsTunnelProxy | null = null;
let tunnelPort: number | null = null;
const useHttpsTunnel = shouldStartHttpsTunnel({
provider,
useRemoteProxy,
protocol: proxyConfig.protocol,
host: proxyConfig.host,
isComposite: cfg.isComposite,
});
if (useHttpsTunnel && 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}`);
}
} else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') {
log('HTTPS tunnel skipped for Codex; local proxy chain will connect to remote HTTPS directly');
}
const imageAnalysisProxyTarget =
useRemoteProxy && proxyConfig.host
? {
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
managementKey: proxyConfig.managementKey,
allowSelfSigned: proxyConfig.allowSelfSigned,
isRemote: true as const,
}
: {
host: '127.0.0.1',
port: cfg.port,
protocol: 'http' as const,
isRemote: false as const,
};
const imageAnalysisResolution = await resolveCliproxyImageAnalysisEnv({
profileName: cfg.profileName || provider,
provider,
profileSettingsPath: cfg.customSettingsPath,
isComposite: cfg.isComposite,
proxyTarget: imageAnalysisProxyTarget,
tunnelPort,
proxyReachable: true,
});
const imageAnalysisProvisioningFailed =
!imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1';
const imageAnalysisEnv = {
...imageAnalysisResolution.env,
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
};
const imageAnalysisWarning = imageAnalysisProvisioningFailed
? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.'
: imageAnalysisResolution.warning;
// 9. Resolve config dir + browser runtime (needed before proxy chain)
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
let toolSanitizationPort: number | null = null;
let codexReasoningProxy: CodexReasoningProxy | null = null;
let codexReasoningPort: number | null = null;
let inheritedClaudeConfigDir = cfg.claudeConfigDir;
if (!inheritedClaudeConfigDir && cfg.profileName) {
const continuityInheritance = await resolveProfileContinuityInheritance({
profileName: cfg.profileName,
profileType: 'cliproxy',
target: 'claude',
});
inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
log(
`Continuity inheritance active: profile "${cfg.profileName}" -> account "${continuityInheritance.sourceAccount}"`
);
}
}
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
// Resolve browser attach runtime and sync browser MCP (needs inheritedClaudeConfigDir)
const { browserRuntimeEnv } = await resolveBrowserRuntime(
browserLaunchOverride,
inheritedClaudeConfigDir
);
// Build initial env vars to get ANTHROPIC_BASE_URL
const initialEnvVars = buildClaudeEnvironment({
provider,
useRemoteProxy,
remoteConfig: proxyConfig.host
? {
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
}
: undefined,
httpsTunnel: httpsTunnel ?? undefined,
tunnelPort: tunnelPort ?? undefined,
localPort: cfg.port,
customSettingsPath: cfg.customSettingsPath,
thinkingOverride,
extendedContextOverride,
verbose,
isComposite: cfg.isComposite,
compositeTiers: cfg.compositeTiers,
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
});
// 9b. Build env-dependent proxy chain (tool-sanitization + codex-reasoning)
({ toolSanitizationProxy, toolSanitizationPort, codexReasoningProxy, codexReasoningPort } =
await buildProxyChain({
provider,
useRemoteProxy,
proxyConfig,
cfg,
initialEnvVars,
thinkingOverride,
thinkingCfg,
verbose,
log,
}));
// 11. Build final environment with all proxy chains
const env = buildClaudeEnvironment({
provider,
useRemoteProxy,
remoteConfig: proxyConfig.host
? {
host: proxyConfig.host,
port: proxyConfig.port,
protocol: proxyConfig.protocol,
authToken: proxyConfig.authToken,
}
: undefined,
httpsTunnel: httpsTunnel ?? undefined,
tunnelPort: tunnelPort ?? undefined,
codexReasoningProxy: codexReasoningProxy ?? undefined,
codexReasoningPort: codexReasoningPort ?? undefined,
toolSanitizationProxy: toolSanitizationProxy ?? undefined,
toolSanitizationPort: toolSanitizationPort ?? undefined,
localPort: cfg.port,
customSettingsPath: cfg.customSettingsPath,
thinkingOverride,
extendedContextOverride,
verbose,
isComposite: cfg.isComposite,
compositeTiers: cfg.compositeTiers,
compositeDefaultTier: cfg.compositeDefaultTier,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
browserRuntimeEnv,
});
if (cfg.isComposite && cfg.compositeTiers && cfg.compositeDefaultTier) {
const mode = useRemoteProxy
? proxyConfig.protocol === 'https'
? 'remote-https'
: 'remote-http'
: 'local';
const defaultTierProvider = cfg.compositeTiers[cfg.compositeDefaultTier]?.provider ?? provider;
log(
`Composite self-check: mode=${mode}, baseUrl=${env.ANTHROPIC_BASE_URL || 'unset'}, defaultTier=${cfg.compositeDefaultTier}, defaultProvider=${defaultTierProvider}`
);
}
const webSearchEnv = getWebSearchHookEnv();
if (process.env.CCS_DEBUG) {
console.error(
`[cliproxy-browser-debug] keys=${Object.keys(env)
.filter((key) => key.startsWith('CCS_BROWSER_'))
.sort()
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}`
);
}
logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning));
}
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
if (process.stderr.isTTY) {
const { thinkingLabel, sourceLabel } = buildThinkingStartupStatus(
thinkingCfg,
thinkingOverride,
thinkingSource,
thinkingParse.sourceDisplay
);
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
}
// 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup
const claudeArgs = filterCcsFlags(argsWithoutBrowserFlags);
await launchClaude({
claudeCli,
claudeArgs,
env,
cfg,
provider,
compositeProviders,
skipLocalAuth,
sessionId,
imageAnalysisMcpReady,
browserRuntimeEnv,
inheritedClaudeConfigDir,
codexReasoningProxy,
toolSanitizationProxy,
httpsTunnel,
verbose,
});
}
// Re-export utility functions for backwards compatibility
export { isPortAvailable, findAvailablePort } from './lifecycle-manager';
// Re-export arg-parser helpers (previously inlined here; external callers can
// import from index or directly from ./arg-parser)
export { readOptionValue, hasGitLabTokenLoginFlag, CCS_FLAGS, filterCcsFlags } from './arg-parser';
// Re-export account-resolution helpers for backwards compat with __testExports consumers
export { resolveRuntimeQuotaMonitorProviders as _resolveRuntimeQuotaMonitorProviders } from './account-resolution';
export const __testExports = {
resolveRuntimeQuotaMonitorProviders,
};
export default execClaudeWithCLIProxy;