mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 02:16:43 +00:00
fix(cliproxy): use adaptive thinking for Opus 4.7 + add 'max' level
1388 lines
48 KiB
TypeScript
1388 lines
48 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, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
|
import {
|
|
ensureCLIProxyBinary,
|
|
getConfiguredBackend,
|
|
getPlusBackendUnavailableMessage,
|
|
} 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 { configureProviderModel, getCurrentModel } from '../model-config';
|
|
import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility';
|
|
import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver';
|
|
import {
|
|
supportsModelConfig,
|
|
isModelBroken,
|
|
getModelIssueUrl,
|
|
findModel,
|
|
getSuggestedReplacementModel,
|
|
} 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 { formatAccountDisplayName } from '../accounts/email-account-identity';
|
|
import {
|
|
ensureWebSearchMcpOrThrow,
|
|
displayWebSearchStatus,
|
|
appendThirdPartyWebSearchToolArgs,
|
|
createWebSearchTraceContext,
|
|
} from '../../utils/websearch-manager';
|
|
import {
|
|
ensureImageAnalysisMcpOrThrow,
|
|
syncImageAnalysisMcpToConfigDir,
|
|
appendThirdPartyImageAnalysisToolArgs,
|
|
} from '../../utils/image-analysis';
|
|
import {
|
|
appendBrowserToolArgs,
|
|
type BrowserLaunchOverride,
|
|
ensureBrowserMcpOrThrow,
|
|
getBlockedBrowserOverrideWarning,
|
|
getEffectiveClaudeBrowserAttachConfig,
|
|
resolveBrowserExposure,
|
|
resolveBrowserLaunchFlagResolution,
|
|
resolveOptionalBrowserAttachRuntime,
|
|
syncBrowserMcpToConfigDir,
|
|
} from '../../utils/browser';
|
|
import {
|
|
getBrowserConfig,
|
|
loadOrCreateUnifiedConfig,
|
|
getThinkingConfig,
|
|
} from '../../config/unified-config-loader';
|
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
|
import {
|
|
isKiroAuthMethod,
|
|
isKiroIDCFlow,
|
|
KiroAuthMethod,
|
|
KiroIDCFlow,
|
|
normalizeKiroAuthMethod,
|
|
normalizeKiroIDCFlow,
|
|
} from '../auth/auth-types';
|
|
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
|
|
|
// Import modular components
|
|
import { waitForProxyReadyWithSpinner, spawnProxy } from './lifecycle-manager';
|
|
import {
|
|
buildClaudeEnvironment,
|
|
logEnvironment,
|
|
resolveCliproxyImageAnalysisEnv,
|
|
} 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,
|
|
};
|
|
|
|
export function readOptionValue(
|
|
args: string[],
|
|
flag: string
|
|
): { present: boolean; value?: string; missingValue: boolean } {
|
|
const inlinePrefix = `${flag}=`;
|
|
const inlineArg = args.find((arg) => arg.startsWith(inlinePrefix));
|
|
if (inlineArg !== undefined) {
|
|
const value = inlineArg.slice(inlinePrefix.length).trim();
|
|
return {
|
|
present: true,
|
|
value: value.length > 0 ? value : undefined,
|
|
missingValue: value.length === 0,
|
|
};
|
|
}
|
|
|
|
const index = args.indexOf(flag);
|
|
if (index === -1) {
|
|
return { present: false, missingValue: false };
|
|
}
|
|
|
|
const next = args[index + 1];
|
|
if (!next || next.startsWith('-')) {
|
|
return { present: true, missingValue: true };
|
|
}
|
|
|
|
return { present: true, value: next.trim(), missingValue: false };
|
|
}
|
|
|
|
export function hasGitLabTokenLoginFlag(args: string[]): boolean {
|
|
return args.includes('--gitlab-token-login') || args.includes('--token-login');
|
|
}
|
|
|
|
function getGitLabTokenLoginFlagName(args: string[]): '--gitlab-token-login' | '--token-login' {
|
|
return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login';
|
|
}
|
|
|
|
/**
|
|
* 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 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,
|
|
management_key: cliproxyServerConfig.remote.management_key,
|
|
timeout: cliproxyServerConfig.remote.timeout,
|
|
}
|
|
: undefined,
|
|
local: cliproxyServerConfig?.local
|
|
? {
|
|
port: cliproxyServerConfig.local.port,
|
|
auto_start: cliproxyServerConfig.local.auto_start,
|
|
}
|
|
: undefined,
|
|
});
|
|
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
|
let argsWithoutBrowserFlags = argsWithoutProxy;
|
|
try {
|
|
const browserLaunchFlags = resolveBrowserLaunchFlagResolution(argsWithoutProxy);
|
|
browserLaunchOverride = browserLaunchFlags.override;
|
|
argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
|
|
} catch (error) {
|
|
console.error(fail((error as Error).message));
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
const browserConfig = getBrowserConfig();
|
|
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(browserConfig);
|
|
const claudeBrowserExposure = resolveBrowserExposure(
|
|
{
|
|
enabled: browserAttachConfig.enabled,
|
|
policy: browserConfig.claude.policy,
|
|
},
|
|
browserLaunchOverride
|
|
);
|
|
const blockedBrowserOverrideWarning = getBlockedBrowserOverrideWarning(
|
|
'Claude Browser Attach',
|
|
claudeBrowserExposure
|
|
);
|
|
if (blockedBrowserOverrideWarning) {
|
|
console.error(warn(blockedBrowserOverrideWarning));
|
|
}
|
|
|
|
// 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 first-class CCS WebSearch runtime
|
|
ensureWebSearchMcpOrThrow();
|
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
|
const browserAttachRuntime =
|
|
browserAttachConfig.enabled && claudeBrowserExposure.exposeForLaunch
|
|
? await resolveOptionalBrowserAttachRuntime(browserAttachConfig)
|
|
: undefined;
|
|
const browserRuntimeEnv = browserAttachRuntime?.runtimeEnv;
|
|
if (browserAttachRuntime?.warning) {
|
|
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
|
|
}
|
|
if (browserRuntimeEnv) {
|
|
ensureBrowserMcpOrThrow();
|
|
}
|
|
displayWebSearchStatus();
|
|
|
|
const providerConfig = getProviderConfig(provider);
|
|
log(`Provider: ${providerConfig.displayName}`);
|
|
warnOAuthBanRisk(provider);
|
|
|
|
// Check remote proxy if configured
|
|
let useRemoteProxy = false;
|
|
let localBackend: CLIProxyBackend = 'original';
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!useRemoteProxy) {
|
|
localBackend = getConfiguredBackend({ warnOnFallback: true });
|
|
|
|
for (const p of allProviders) {
|
|
if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
|
|
console.error('');
|
|
console.error(fail(getPlusBackendUnavailableMessage(p)));
|
|
console.error('');
|
|
throw new Error(`Provider ${p} is temporarily unavailable on local CLIProxy`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, { skipAutoUpdate: true });
|
|
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 gitlabTokenLogin = hasGitLabTokenLoginFlag(argsWithoutProxy);
|
|
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 kiroMethodValue = readOptionValue(argsWithoutProxy, '--kiro-auth-method');
|
|
if (kiroMethodValue.present) {
|
|
const rawMethod = kiroMethodValue.value;
|
|
if (kiroMethodValue.missingValue || !rawMethod) {
|
|
console.error(fail('--kiro-auth-method requires a value'));
|
|
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
|
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, idc');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
|
|
}
|
|
|
|
let kiroIDCStartUrl: string | undefined;
|
|
const kiroIDCStartUrlValue = readOptionValue(argsWithoutProxy, '--kiro-idc-start-url');
|
|
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
|
|
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
|
|
} else if (kiroIDCStartUrlValue.present) {
|
|
console.error(fail('--kiro-idc-start-url requires a value'));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let kiroIDCRegion: string | undefined;
|
|
const kiroIDCRegionValue = readOptionValue(argsWithoutProxy, '--kiro-idc-region');
|
|
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
|
|
kiroIDCRegion = kiroIDCRegionValue.value;
|
|
} else if (kiroIDCRegionValue.present) {
|
|
console.error(fail('--kiro-idc-region requires a value'));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
let kiroIDCFlow: KiroIDCFlow | undefined;
|
|
const kiroIDCFlowValue = readOptionValue(argsWithoutProxy, '--kiro-idc-flow');
|
|
if (kiroIDCFlowValue.present) {
|
|
const rawFlow = kiroIDCFlowValue.value;
|
|
if (kiroIDCFlowValue.missingValue || !rawFlow) {
|
|
console.error(fail('--kiro-idc-flow requires a value'));
|
|
console.error(' Supported values: authcode, device');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const normalized = rawFlow.trim().toLowerCase();
|
|
if (!isKiroIDCFlow(normalized)) {
|
|
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
|
|
console.error(' Supported values: authcode, device');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
|
|
}
|
|
|
|
let gitlabBaseUrl: string | undefined;
|
|
const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url');
|
|
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
|
|
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
|
|
} else if (gitlabBaseUrlValue.present) {
|
|
console.error(fail('--gitlab-url requires a value'));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
|
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) &&
|
|
provider !== 'kiro' &&
|
|
!compositeProviders.includes('kiro')
|
|
) {
|
|
console.error(
|
|
fail(
|
|
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
|
|
)
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (!kiroAuthMethod && (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)) {
|
|
kiroAuthMethod = 'idc';
|
|
}
|
|
|
|
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
|
|
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
|
|
console.error(
|
|
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (
|
|
kiroAuthMethod &&
|
|
kiroAuthMethod !== 'idc' &&
|
|
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
|
|
) {
|
|
console.error(
|
|
fail(
|
|
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
|
|
)
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
|
|
const flagName = gitlabTokenLogin
|
|
? getGitLabTokenLoginFlagName(argsWithoutProxy)
|
|
: '--gitlab-url';
|
|
console.error(fail(`${flagName} is only valid for ccs gitlab`));
|
|
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, max, 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)} ${formatAccountDisplayName(acct)}${defaultMark}`);
|
|
}
|
|
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" 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) {
|
|
const displayName = formatAccountDisplayName(acct);
|
|
const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName;
|
|
console.error(` - ${label}`);
|
|
}
|
|
}
|
|
process.exit(1);
|
|
}
|
|
setDefaultAccount(provider, account.id);
|
|
touchAccount(provider, account.id);
|
|
const switchedLabel = account.nickname
|
|
? `${account.nickname} (${formatAccountDisplayName(account)})`
|
|
: formatAccountDisplayName(account);
|
|
console.log(ok(`Switched to account: ${switchedLabel}`));
|
|
}
|
|
|
|
// 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 } : {}),
|
|
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
|
|
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
|
|
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
|
|
...(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 } : {}),
|
|
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
|
|
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
|
|
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
|
|
...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}),
|
|
...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}),
|
|
...(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 } : {}),
|
|
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
|
|
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
|
|
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
|
|
...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}),
|
|
...(gitlabBaseUrl ? { gitlabBaseUrl } : {}),
|
|
...(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);
|
|
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
|
|
console.error('');
|
|
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
|
if (replacementModel) {
|
|
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
|
|
} else {
|
|
console.error(' Tool calls will fail. Consider changing the model in config.yaml.');
|
|
}
|
|
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);
|
|
|
|
if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) {
|
|
await reconcileCodexModelForActivePlan({
|
|
currentModel: getCurrentModel(provider, cfg.customSettingsPath),
|
|
verbose,
|
|
});
|
|
}
|
|
|
|
// 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
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
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. Setup tool sanitization proxy
|
|
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
|
let toolSanitizationPort: 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);
|
|
if (
|
|
browserRuntimeEnv &&
|
|
inheritedClaudeConfigDir &&
|
|
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
|
) {
|
|
throw new Error(
|
|
'Browser MCP is enabled, but CCS could not sync the browser MCP config into the inherited Claude instance.'
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
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,
|
|
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-specific flags before passing to Claude CLI
|
|
const ccsFlags = [
|
|
'--auth',
|
|
'--paste-callback',
|
|
'--port-forward',
|
|
'--headless',
|
|
'--logout',
|
|
'--config',
|
|
'--add',
|
|
'--accounts',
|
|
'--use',
|
|
'--nickname',
|
|
'--kiro-auth-method',
|
|
'--kiro-idc-start-url',
|
|
'--kiro-idc-region',
|
|
'--kiro-idc-flow',
|
|
'--thinking',
|
|
'--effort',
|
|
'--1m',
|
|
'--no-1m',
|
|
'--incognito',
|
|
'--no-incognito',
|
|
'--import',
|
|
'--accept-agr-risk',
|
|
'--accept-antigravity-risk',
|
|
'--settings',
|
|
...PROXY_CLI_FLAGS,
|
|
];
|
|
const claudeArgs = argsWithoutBrowserFlags.filter((arg, idx) => {
|
|
if (ccsFlags.includes(arg)) return false;
|
|
if (arg.startsWith('--kiro-auth-method=')) return false;
|
|
if (arg.startsWith('--kiro-idc-start-url=')) return false;
|
|
if (arg.startsWith('--kiro-idc-region=')) return false;
|
|
if (arg.startsWith('--kiro-idc-flow=')) 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 (
|
|
argsWithoutBrowserFlags[idx - 1] === '--use' ||
|
|
argsWithoutBrowserFlags[idx - 1] === '--nickname' ||
|
|
argsWithoutBrowserFlags[idx - 1] === '--kiro-auth-method' ||
|
|
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-start-url' ||
|
|
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-region' ||
|
|
argsWithoutBrowserFlags[idx - 1] === '--kiro-idc-flow' ||
|
|
argsWithoutBrowserFlags[idx - 1] === '--thinking' ||
|
|
argsWithoutBrowserFlags[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;
|
|
const imageAnalysisArgs = imageAnalysisMcpReady
|
|
? appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
|
: claudeArgs;
|
|
const browserArgs = browserRuntimeEnv
|
|
? appendBrowserToolArgs(imageAnalysisArgs)
|
|
: imageAnalysisArgs;
|
|
const launchArgs = [
|
|
'--settings',
|
|
settingsPath,
|
|
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
|
];
|
|
const traceEnv = createWebSearchTraceContext({
|
|
launcher: 'cliproxy.executor',
|
|
args: launchArgs,
|
|
profile: cfg.profileName || provider,
|
|
profileType: 'cliproxy',
|
|
settingsPath,
|
|
claudeConfigDir: inheritedClaudeConfigDir,
|
|
});
|
|
const tracedEnv = { ...env, ...traceEnv };
|
|
if (needsShell) {
|
|
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
|
|
claude = spawn(cmdString, {
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
shell: getWindowsEscapedCommandShell(),
|
|
env: tracedEnv,
|
|
});
|
|
} else {
|
|
claude = spawn(claudeCli, launchArgs, {
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
env: tracedEnv,
|
|
});
|
|
}
|
|
|
|
// 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;
|