mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
- add safer error responses for settings/auth routes to avoid leaking internals - guard sensitive AGY settings endpoints to localhost when dashboard auth is off - validate start-route bodies and reject OAuth start in remote mode for consistency - preserve cliproxy.kiro_no_incognito and token_refresh during config merges - enforce AGY acknowledgement in remote auth-token run/auth command paths
1037 lines
36 KiB
TypeScript
1037 lines
36 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 { spawn, ChildProcess } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { ProgressIndicator } from '../../utils/progress-indicator';
|
|
import { ok, fail, info, warn } from '../../utils/ui';
|
|
import { getCcsDir } from '../../utils/config-manager';
|
|
import { escapeShellArg } from '../../utils/shell-executor';
|
|
import { ensureCLIProxyBinary } from '../binary-manager';
|
|
import {
|
|
generateConfig,
|
|
getProviderConfig,
|
|
ensureProviderSettings,
|
|
getProviderSettingsPath,
|
|
CLIPROXY_DEFAULT_PORT,
|
|
validatePort,
|
|
} 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 { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from '../model-catalog';
|
|
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
|
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
|
import {
|
|
findAccountByQuery,
|
|
getProviderAccounts,
|
|
setDefaultAccount,
|
|
touchAccount,
|
|
renameAccount,
|
|
getDefaultAccount,
|
|
} from '../account-manager';
|
|
import {
|
|
ensureMcpWebSearch,
|
|
installWebSearchHook,
|
|
displayWebSearchStatus,
|
|
} from '../../utils/websearch-manager';
|
|
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
|
import { installImageAnalyzerHook } from '../../utils/hooks';
|
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
|
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
|
|
|
|
// Import modular components
|
|
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
|
import { buildClaudeEnvironment, logEnvironment } from './env-resolver';
|
|
import {
|
|
isNetworkError,
|
|
handleNetworkError,
|
|
handleTokenExpiration,
|
|
handleQuotaCheck,
|
|
} from './retry-handler';
|
|
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
|
import { parseThinkingOverride } from './thinking-arg-parser';
|
|
import {
|
|
warnCrossProviderDuplicates,
|
|
warnOAuthBanRisk,
|
|
cleanupStaleAutoPauses,
|
|
enforceProviderIsolation,
|
|
restoreAutoPausedAccounts,
|
|
} from '../account-safety';
|
|
import {
|
|
ensureCliAntigravityResponsibility,
|
|
hasAntigravityRiskAcceptanceFlag,
|
|
ANTIGRAVITY_ACCEPT_RISK_FLAGS,
|
|
} from '../antigravity-responsibility';
|
|
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
|
import {
|
|
buildThinkingStartupStatus,
|
|
resolveRuntimeThinkingOverride,
|
|
shouldDisableCodexReasoning,
|
|
} from './thinking-override-resolver';
|
|
|
|
/** Default executor configuration */
|
|
const DEFAULT_CONFIG: ExecutorConfig = {
|
|
port: CLIPROXY_DEFAULT_PORT,
|
|
timeout: 5000,
|
|
verbose: false,
|
|
pollInterval: 100,
|
|
};
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// 0a. Runtime backend/provider validation
|
|
const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND;
|
|
|
|
// Collect all providers to validate (default + composite tiers)
|
|
const allProviders = [provider, ...compositeProviders];
|
|
for (const p of allProviders) {
|
|
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
|
|
console.error('');
|
|
console.error(fail(`${p} 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 ' + p + ' --backend=plus');
|
|
console.error('');
|
|
throw new Error(`Provider ${p} 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,
|
|
});
|
|
|
|
// Port resolution and validation
|
|
if (cfg.port && cfg.port !== CLIPROXY_DEFAULT_PORT) {
|
|
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
|
cfg.port = proxyConfig.port;
|
|
}
|
|
} else if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
|
cfg.port = proxyConfig.port;
|
|
}
|
|
cfg.port = validatePort(cfg.port);
|
|
|
|
log(`Proxy mode: ${proxyConfig.mode}`);
|
|
if (proxyConfig.mode === 'remote') {
|
|
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
|
|
}
|
|
|
|
// Setup WebSearch hooks
|
|
ensureMcpWebSearch();
|
|
installWebSearchHook();
|
|
displayWebSearchStatus();
|
|
|
|
// Sync image analyzer hook from npm package to ~/.ccs/hooks/
|
|
installImageAnalyzerHook();
|
|
|
|
const providerConfig = getProviderConfig(provider);
|
|
log(`Provider: ${providerConfig.displayName}`);
|
|
warnOAuthBanRisk(provider);
|
|
|
|
// Check remote proxy if configured
|
|
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 {
|
|
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');
|
|
spinner.start();
|
|
|
|
try {
|
|
binaryPath = await ensureCLIProxyBinary(verbose);
|
|
spinner.succeed('CLIProxy binary ready');
|
|
} catch (error) {
|
|
spinner.fail('Failed to prepare CLIProxy');
|
|
const err = error as Error;
|
|
|
|
if (isNetworkError(err)) {
|
|
handleNetworkError(err);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// 2. Handle special flags (simplified flag parsing - full implementation continues below)
|
|
const forceAuth = argsWithoutProxy.includes('--auth');
|
|
const pasteCallback = argsWithoutProxy.includes('--paste-callback');
|
|
const portForward = argsWithoutProxy.includes('--port-forward');
|
|
const forceHeadless = argsWithoutProxy.includes('--headless');
|
|
|
|
if (pasteCallback && portForward) {
|
|
console.error(fail('Cannot use --paste-callback with --port-forward'));
|
|
console.error(' --paste-callback: Manually paste OAuth redirect URL');
|
|
console.error(' --port-forward: Use SSH port forwarding for callback');
|
|
process.exit(1);
|
|
}
|
|
|
|
const forceLogout = argsWithoutProxy.includes('--logout');
|
|
const forceConfig = argsWithoutProxy.includes('--config');
|
|
const addAccount = argsWithoutProxy.includes('--add');
|
|
const showAccounts = argsWithoutProxy.includes('--accounts');
|
|
const forceImport = argsWithoutProxy.includes('--import');
|
|
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy);
|
|
|
|
const incognitoFlag = argsWithoutProxy.includes('--incognito');
|
|
const noIncognitoFlag = argsWithoutProxy.includes('--no-incognito');
|
|
const kiroNoIncognitoConfig =
|
|
provider === 'kiro' ? (unifiedConfig.cliproxy?.kiro_no_incognito ?? true) : false;
|
|
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 --kiro-auth-method flag
|
|
let kiroAuthMethod: KiroAuthMethod | undefined;
|
|
const kiroMethodIdx = argsWithoutProxy.indexOf('--kiro-auth-method');
|
|
if (kiroMethodIdx !== -1) {
|
|
const rawMethod = argsWithoutProxy[kiroMethodIdx + 1];
|
|
if (!rawMethod || rawMethod.startsWith('-')) {
|
|
console.error(fail('--kiro-auth-method requires a value'));
|
|
console.error(' Supported values: aws, aws-authcode, google, github');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const normalized = rawMethod.trim().toLowerCase();
|
|
if (!isKiroAuthMethod(normalized)) {
|
|
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
|
|
console.error(' Supported values: aws, aws-authcode, google, github');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
|
|
}
|
|
|
|
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
|
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Parse --thinking / --effort flags (aliases; first occurrence wins)
|
|
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
|
|
if (thinkingParse.error) {
|
|
const { flag } = thinkingParse.error;
|
|
console.error(fail(`${flag} requires a value`));
|
|
|
|
if (provider === 'codex') {
|
|
console.error(' Codex examples: --effort xhigh, --effort high, --effort medium');
|
|
console.error(' Alias: --thinking xhigh (same behavior)');
|
|
} else {
|
|
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
|
|
console.error(' Levels: minimal, low, medium, high, xhigh, auto');
|
|
}
|
|
|
|
process.exit(1);
|
|
}
|
|
|
|
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.'
|
|
)
|
|
);
|
|
}
|
|
|
|
// Parse --1m / --no-1m flags for extended context (1M token window)
|
|
let extendedContextOverride: boolean | undefined;
|
|
const has1mFlag =
|
|
argsWithoutProxy.includes('--1m') || argsWithoutProxy.some((arg) => arg.startsWith('--1m='));
|
|
const hasNo1mFlag =
|
|
argsWithoutProxy.includes('--no-1m') ||
|
|
argsWithoutProxy.some((arg) => arg.startsWith('--no-1m='));
|
|
|
|
if (has1mFlag && hasNo1mFlag) {
|
|
console.error(fail('Cannot use both --1m and --no-1m flags'));
|
|
process.exit(1);
|
|
} else if (has1mFlag) {
|
|
extendedContextOverride = true;
|
|
} else if (hasNo1mFlag) {
|
|
extendedContextOverride = false;
|
|
}
|
|
// undefined = auto behavior (Gemini: on, others: off)
|
|
|
|
// Handle --accounts
|
|
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 <nickname>" to switch accounts`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// Handle --use
|
|
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);
|
|
}
|
|
setDefaultAccount(provider, account.id);
|
|
touchAccount(provider, account.id);
|
|
console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`));
|
|
}
|
|
|
|
// Handle --nickname (rename account)
|
|
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
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Handle --logout
|
|
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 (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);
|
|
}
|
|
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,
|
|
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
|
|
...(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)
|
|
const remoteAuthToken = proxyConfig.authToken?.trim();
|
|
const skipLocalAuth = useRemoteProxy && !!remoteAuthToken;
|
|
if (skipLocalAuth) {
|
|
log(`Using remote proxy authentication (skipping local OAuth)`);
|
|
}
|
|
|
|
if (provider === 'agy' && forceAuth && skipLocalAuth) {
|
|
const acknowledged = await ensureCliAntigravityResponsibility({
|
|
context: 'oauth',
|
|
acceptedByFlag: acceptAgyRisk,
|
|
});
|
|
if (!acknowledged) {
|
|
throw new Error(
|
|
`Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
|
|
);
|
|
}
|
|
console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.'));
|
|
return;
|
|
}
|
|
|
|
if (provider === 'agy' && !forceAuth) {
|
|
const requiresAuthNow = providerConfig.requiresOAuth && !isAuthenticated(provider);
|
|
if (skipLocalAuth || !requiresAuthNow) {
|
|
const acknowledged = await ensureCliAntigravityResponsibility({
|
|
context: 'run',
|
|
acceptedByFlag: acceptAgyRisk,
|
|
});
|
|
if (!acknowledged) {
|
|
console.error(
|
|
fail(
|
|
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
|
|
)
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (providerConfig.requiresOAuth && !skipLocalAuth) {
|
|
log(`Checking authentication for ${provider}`);
|
|
|
|
// Multi-provider auth check for composite variants
|
|
if (compositeProviders.length > 0) {
|
|
// Handle forceAuth for composite providers
|
|
if (forceAuth) {
|
|
const { triggerOAuth } = await import('../auth-handler');
|
|
const failures: string[] = [];
|
|
for (const p of compositeProviders) {
|
|
const authSuccess = await triggerOAuth(p, {
|
|
verbose,
|
|
add: addAccount,
|
|
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
|
|
...(kiroAuthMethod && p === 'kiro' ? { kiroMethod: kiroAuthMethod } : {}),
|
|
...(forceHeadless ? { headless: true } : {}),
|
|
...(setNickname ? { nickname: setNickname } : {}),
|
|
...(noIncognito ? { noIncognito: true } : {}),
|
|
...(pasteCallback ? { pasteCallback: true } : {}),
|
|
...(portForward ? { portForward: true } : {}),
|
|
});
|
|
if (!authSuccess) {
|
|
failures.push(p);
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
const succeeded = compositeProviders.filter((p) => !failures.includes(p));
|
|
console.error(fail(`Auth failed for: ${failures.join(', ')}`));
|
|
if (succeeded.length > 0) {
|
|
console.error(info(`Succeeded: ${succeeded.join(', ')}`));
|
|
}
|
|
process.exit(1);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
// Check for unauthenticated providers
|
|
const unauthenticatedProviders: string[] = [];
|
|
for (const p of compositeProviders) {
|
|
if (!isAuthenticated(p)) {
|
|
unauthenticatedProviders.push(p);
|
|
}
|
|
}
|
|
if (unauthenticatedProviders.length > 0) {
|
|
console.error(fail('Composite variant requires authentication for multiple providers:'));
|
|
for (const p of unauthenticatedProviders) {
|
|
console.error(` - ${p} (run "ccs ${p} --auth")`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
} else if (forceAuth || !isAuthenticated(provider)) {
|
|
const { triggerOAuth } = await import('../auth-handler');
|
|
const authSuccess = await triggerOAuth(provider, {
|
|
verbose,
|
|
add: addAccount,
|
|
...(acceptAgyRisk ? { acceptAgyRisk: true } : {}),
|
|
...(kiroAuthMethod ? { kiroMethod: kiroAuthMethod } : {}),
|
|
...(forceHeadless ? { headless: true } : {}),
|
|
...(setNickname ? { nickname: setNickname } : {}),
|
|
...(noIncognito ? { noIncognito: true } : {}),
|
|
...(pasteCallback ? { pasteCallback: true } : {}),
|
|
...(portForward ? { portForward: true } : {}),
|
|
});
|
|
if (!authSuccess) {
|
|
throw new Error(`Authentication required for ${providerConfig.displayName}`);
|
|
}
|
|
if (forceAuth) {
|
|
process.exit(0);
|
|
}
|
|
} else {
|
|
log(`${provider} already authenticated`);
|
|
}
|
|
|
|
// 3a. Proactive token refresh (multi-provider for composite)
|
|
if (compositeProviders.length > 0) {
|
|
for (const p of compositeProviders) {
|
|
await handleTokenExpiration(p, verbose);
|
|
}
|
|
} else {
|
|
await handleTokenExpiration(provider, verbose);
|
|
}
|
|
|
|
// 3a-1. Update lastUsedAt
|
|
const usedAccount = getDefaultAccount(provider);
|
|
if (usedAccount) {
|
|
touchAccount(provider, usedAccount.id);
|
|
}
|
|
}
|
|
|
|
// 3b. Preflight quota check (providers with quota-based rotation)
|
|
if (!skipLocalAuth) {
|
|
// Multi-tier quota check for composite variants (check if any tier uses a managed provider)
|
|
if (compositeProviders.length > 0) {
|
|
const managedQuotaProviders = ['agy', 'claude'] as const;
|
|
for (const managedProvider of managedQuotaProviders) {
|
|
if (compositeProviders.includes(managedProvider)) {
|
|
await handleQuotaCheck(managedProvider);
|
|
}
|
|
}
|
|
} else {
|
|
await handleQuotaCheck(provider);
|
|
}
|
|
}
|
|
|
|
// 3c. Account safety: enforce cross-provider isolation
|
|
if (!skipLocalAuth) {
|
|
cleanupStaleAutoPauses();
|
|
const isolated = enforceProviderIsolation(provider);
|
|
if (isolated === 0) {
|
|
// No enforcement — still warn about duplicates for awareness
|
|
warnCrossProviderDuplicates(provider);
|
|
} else {
|
|
// 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs
|
|
process.on('exit', () => {
|
|
restoreAutoPausedAccounts(provider);
|
|
});
|
|
}
|
|
}
|
|
|
|
// 4. First-run model configuration
|
|
if (!cfg.isComposite && supportsModelConfig(provider) && !skipLocalAuth) {
|
|
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
|
}
|
|
|
|
// 5. Check for broken models (multi-tier for composite)
|
|
if (compositeProviders.length > 0 && cfg.compositeTiers) {
|
|
// Check all tier models in composite variant
|
|
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
|
for (const tier of tiers) {
|
|
const tierConfig = cfg.compositeTiers[tier];
|
|
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
|
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
|
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
|
console.error('');
|
|
console.error(
|
|
warn(
|
|
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
|
|
)
|
|
);
|
|
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
|
if (issueUrl) {
|
|
console.error(` Tracking: ${issueUrl}`);
|
|
}
|
|
console.error('');
|
|
}
|
|
}
|
|
} else {
|
|
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
|
|
ensureProviderSettings(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,
|
|
backend,
|
|
configPath
|
|
);
|
|
|
|
// Register session
|
|
if (proxy.pid) {
|
|
sessionId = registerProxySession(cfg.port, proxy.pid, backend, verbose);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 8. Setup HTTPS tunnel if needed
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
// 9. Setup tool sanitization proxy
|
|
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
|
let toolSanitizationPort: number | null = null;
|
|
|
|
// 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,
|
|
});
|
|
|
|
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
|
try {
|
|
toolSanitizationProxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
|
|
verbose,
|
|
warnOnSanitize: true,
|
|
});
|
|
toolSanitizationPort = await toolSanitizationProxy.start();
|
|
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
toolSanitizationProxy = null;
|
|
toolSanitizationPort = null;
|
|
if (verbose) {
|
|
console.error(warn(`Tool sanitization proxy disabled: ${err.message}`));
|
|
}
|
|
}
|
|
}
|
|
|
|
const postSanitizationBaseUrl = toolSanitizationPort
|
|
? `http://127.0.0.1:${toolSanitizationPort}`
|
|
: initialEnvVars.ANTHROPIC_BASE_URL;
|
|
|
|
// 10. Setup Codex reasoning proxy (single-provider Codex only)
|
|
let codexReasoningProxy: CodexReasoningProxy | null = null;
|
|
let codexReasoningPort: number | null = null;
|
|
|
|
// Composite variants require root model-routed endpoints, never provider-pinned codex endpoints.
|
|
if (provider === 'codex' && !cfg.isComposite) {
|
|
if (!postSanitizationBaseUrl) {
|
|
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';
|
|
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
|
|
const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride);
|
|
codexReasoningProxy = new CodexReasoningProxy({
|
|
upstreamBaseUrl: postSanitizationBaseUrl,
|
|
verbose,
|
|
defaultEffort: 'medium',
|
|
disableEffort: codexThinkingOff,
|
|
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
|
|
modelMap: {
|
|
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
|
|
opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
|
sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
|
haikuModel: initialEnvVars.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}`));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
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();
|
|
logEnvironment(env, webSearchEnv, verbose);
|
|
|
|
// 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-specific flags before passing to Claude CLI
|
|
const ccsFlags = [
|
|
'--auth',
|
|
'--paste-callback',
|
|
'--port-forward',
|
|
'--headless',
|
|
'--logout',
|
|
'--config',
|
|
'--add',
|
|
'--accounts',
|
|
'--use',
|
|
'--nickname',
|
|
'--kiro-auth-method',
|
|
'--thinking',
|
|
'--effort',
|
|
'--1m',
|
|
'--no-1m',
|
|
'--incognito',
|
|
'--no-incognito',
|
|
'--import',
|
|
'--accept-agr-risk',
|
|
'--accept-antigravity-risk',
|
|
'--settings',
|
|
...PROXY_CLI_FLAGS,
|
|
];
|
|
const claudeArgs = argsWithoutProxy.filter((arg, idx) => {
|
|
if (ccsFlags.includes(arg)) return false;
|
|
if (arg.startsWith('--thinking=')) return false;
|
|
if (arg.startsWith('--effort=')) return false;
|
|
if (arg.startsWith('--1m=') || arg.startsWith('--no-1m=')) return false;
|
|
if (
|
|
argsWithoutProxy[idx - 1] === '--use' ||
|
|
argsWithoutProxy[idx - 1] === '--nickname' ||
|
|
argsWithoutProxy[idx - 1] === '--kiro-auth-method' ||
|
|
argsWithoutProxy[idx - 1] === '--thinking' ||
|
|
argsWithoutProxy[idx - 1] === '--effort'
|
|
)
|
|
return false;
|
|
return true;
|
|
});
|
|
|
|
const isWindows = process.platform === 'win32';
|
|
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
|
|
|
const settingsPath = cfg.customSettingsPath
|
|
? cfg.customSettingsPath.replace(/^~/, os.homedir())
|
|
: getProviderSettingsPath(provider);
|
|
|
|
let claude: ChildProcess;
|
|
if (needsShell) {
|
|
const cmdString = [claudeCli, '--settings', settingsPath, ...claudeArgs]
|
|
.map(escapeShellArg)
|
|
.join(' ');
|
|
claude = spawn(cmdString, {
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
shell: true,
|
|
env,
|
|
});
|
|
} else {
|
|
claude = spawn(claudeCli, ['--settings', settingsPath, ...claudeArgs], {
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
env,
|
|
});
|
|
}
|
|
|
|
// 12b. Start runtime quota monitor (adaptive polling during session)
|
|
if (!skipLocalAuth) {
|
|
const { startQuotaMonitor } = await import('../quota-manager');
|
|
const monitorAccount = getDefaultAccount(provider);
|
|
if (monitorAccount) {
|
|
startQuotaMonitor(provider, monitorAccount.id);
|
|
}
|
|
}
|
|
|
|
// 13. Setup cleanup handlers
|
|
setupCleanupHandlers(
|
|
claude,
|
|
sessionId,
|
|
cfg.port,
|
|
codexReasoningProxy,
|
|
toolSanitizationProxy,
|
|
httpsTunnel,
|
|
verbose
|
|
);
|
|
}
|
|
|
|
// Re-export utility functions for backwards compatibility
|
|
export { isPortAvailable, findAvailablePort } from './lifecycle-manager';
|
|
|
|
export default execClaudeWithCLIProxy;
|