mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 10:16:34 +00:00
refactor(cliproxy/executor): extract proxy-resolver from index.ts
Phase 03 of #1162. Splits proxy + binary resolution out of the orchestrator into a focused module: - src/cliproxy/executor/proxy-resolver.ts (196 LOC): ResolvedProxy + ResolveExecutorProxyContext interfaces, resolveExecutorProxy function. Encapsulates proxy config resolution, port mutation, remote reachability check, fallback prompt, local backend selection, and binary acquisition. - src/cliproxy/executor/__tests__/proxy-resolver.test.ts: 10 unit tests covering local/remote/fallback paths. index.ts: 1168 -> 1045 LOC (-123). Removes 9 now-unused imports. Browser flag handling intentionally left in index.ts for Phase 04. Behavior unchanged; full suite passes 1824/1824. Refs #1162
This commit is contained in:
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for proxy-resolver.ts (Phase 03 extraction)
|
||||||
|
*
|
||||||
|
* Tests cover the proxy resolution + remote reachability + binary acquisition
|
||||||
|
* logic extracted from executor/index.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||||
|
import type { ResolveExecutorProxyContext } from '../proxy-resolver';
|
||||||
|
import type { ExecutorConfig } from '../../types';
|
||||||
|
import type { UnifiedConfig } from '../../../config/schemas/unified-config';
|
||||||
|
|
||||||
|
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy');
|
||||||
|
const mockGetConfiguredBackend = jest.fn().mockReturnValue('original');
|
||||||
|
const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable');
|
||||||
|
|
||||||
|
jest.mock('../../binary-manager', () => ({
|
||||||
|
ensureCLIProxyBinary: mockEnsureCLIProxyBinary,
|
||||||
|
getConfiguredBackend: mockGetConfiguredBackend,
|
||||||
|
getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCheckRemoteProxy = jest.fn();
|
||||||
|
jest.mock('../../services/remote-proxy-client', () => ({
|
||||||
|
checkRemoteProxy: mockCheckRemoteProxy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../retry-handler', () => ({
|
||||||
|
isNetworkError: jest.fn().mockReturnValue(false),
|
||||||
|
handleNetworkError: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockResolveProxyConfig = jest.fn();
|
||||||
|
jest.mock('../../proxy/proxy-config-resolver', () => ({
|
||||||
|
resolveProxyConfig: mockResolveProxyConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../config/config-generator', () => ({
|
||||||
|
CLIPROXY_DEFAULT_PORT: 8317,
|
||||||
|
validatePort: jest.fn((port: number | undefined) => port ?? 8317),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { resolveExecutorProxy } = await import('../proxy-resolver');
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeMinimalUnifiedConfig(): UnifiedConfig {
|
||||||
|
return {
|
||||||
|
cliproxy_server: undefined,
|
||||||
|
} as unknown as UnifiedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBaseCfg(): ExecutorConfig {
|
||||||
|
return {
|
||||||
|
port: 8317,
|
||||||
|
timeout: 5000,
|
||||||
|
verbose: false,
|
||||||
|
pollInterval: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContext(
|
||||||
|
overrides: Partial<ResolveExecutorProxyContext> = {}
|
||||||
|
): ResolveExecutorProxyContext {
|
||||||
|
return {
|
||||||
|
unifiedConfig: makeMinimalUnifiedConfig(),
|
||||||
|
allProviders: ['gemini'],
|
||||||
|
verbose: false,
|
||||||
|
cfg: makeBaseCfg(),
|
||||||
|
log: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock resolveProxyConfig to return a local-mode config */
|
||||||
|
function mockLocalProxyConfig(remainingArgs: string[] = []): void {
|
||||||
|
mockResolveProxyConfig.mockReturnValue({
|
||||||
|
config: {
|
||||||
|
mode: 'local',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
fallbackEnabled: false,
|
||||||
|
autoStartLocal: false,
|
||||||
|
remoteOnly: false,
|
||||||
|
forceLocal: true,
|
||||||
|
},
|
||||||
|
remainingArgs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock resolveProxyConfig to return a remote-mode config */
|
||||||
|
function mockRemoteProxyConfig(remainingArgs: string[] = []): void {
|
||||||
|
mockResolveProxyConfig.mockReturnValue({
|
||||||
|
config: {
|
||||||
|
mode: 'remote',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
fallbackEnabled: false,
|
||||||
|
autoStartLocal: false,
|
||||||
|
remoteOnly: false,
|
||||||
|
forceLocal: false,
|
||||||
|
},
|
||||||
|
remainingArgs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
|
||||||
|
mockGetConfiguredBackend.mockReturnValue('original');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveExecutorProxy — local mode', () => {
|
||||||
|
it('returns useRemoteProxy=false and correct binary for local mode', async () => {
|
||||||
|
mockLocalProxyConfig(['--verbose']);
|
||||||
|
const result = await resolveExecutorProxy(['--verbose'], makeContext());
|
||||||
|
|
||||||
|
expect(result.useRemoteProxy).toBe(false);
|
||||||
|
expect(result.localBackend).toBe('original');
|
||||||
|
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
|
||||||
|
expect(result.argsWithoutProxy).toEqual(['--verbose']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips proxy flags and passes remainingArgs through', async () => {
|
||||||
|
mockLocalProxyConfig(['clean-arg']);
|
||||||
|
const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext());
|
||||||
|
|
||||||
|
expect(result.argsWithoutProxy).toEqual(['clean-arg']);
|
||||||
|
expect(result.useRemoteProxy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call checkRemoteProxy in local mode', async () => {
|
||||||
|
mockLocalProxyConfig();
|
||||||
|
await resolveExecutorProxy([], makeContext());
|
||||||
|
|
||||||
|
expect(mockCheckRemoteProxy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveExecutorProxy — remote mode reachable', () => {
|
||||||
|
it('returns useRemoteProxy=true when remote proxy is reachable', async () => {
|
||||||
|
mockRemoteProxyConfig();
|
||||||
|
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined });
|
||||||
|
|
||||||
|
const result = await resolveExecutorProxy([], makeContext());
|
||||||
|
|
||||||
|
expect(result.useRemoteProxy).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips binary acquisition when remote proxy is reachable', async () => {
|
||||||
|
mockRemoteProxyConfig();
|
||||||
|
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined });
|
||||||
|
|
||||||
|
const result = await resolveExecutorProxy([], makeContext());
|
||||||
|
|
||||||
|
expect(result.binaryPath).toBeUndefined();
|
||||||
|
expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveExecutorProxy — remote mode unreachable', () => {
|
||||||
|
it('throws expected message when remoteOnly=true and remote is unreachable', async () => {
|
||||||
|
mockResolveProxyConfig.mockReturnValue({
|
||||||
|
config: {
|
||||||
|
mode: 'remote',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
fallbackEnabled: false,
|
||||||
|
autoStartLocal: false,
|
||||||
|
remoteOnly: true,
|
||||||
|
forceLocal: false,
|
||||||
|
},
|
||||||
|
remainingArgs: [],
|
||||||
|
});
|
||||||
|
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' });
|
||||||
|
|
||||||
|
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
|
||||||
|
'Remote proxy unreachable and --remote-only specified'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when fallback disabled and remote is unreachable', async () => {
|
||||||
|
mockResolveProxyConfig.mockReturnValue({
|
||||||
|
config: {
|
||||||
|
mode: 'remote',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
fallbackEnabled: false,
|
||||||
|
autoStartLocal: false,
|
||||||
|
remoteOnly: false,
|
||||||
|
forceLocal: false,
|
||||||
|
},
|
||||||
|
remainingArgs: [],
|
||||||
|
});
|
||||||
|
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
|
||||||
|
|
||||||
|
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
|
||||||
|
'Remote proxy unreachable and fallback disabled'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to local and acquires binary when autoStartLocal=true', async () => {
|
||||||
|
mockResolveProxyConfig.mockReturnValue({
|
||||||
|
config: {
|
||||||
|
mode: 'remote',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 8317,
|
||||||
|
protocol: 'http',
|
||||||
|
fallbackEnabled: true,
|
||||||
|
autoStartLocal: true,
|
||||||
|
remoteOnly: false,
|
||||||
|
forceLocal: false,
|
||||||
|
},
|
||||||
|
remainingArgs: [],
|
||||||
|
});
|
||||||
|
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
|
||||||
|
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
|
||||||
|
|
||||||
|
const result = await resolveExecutorProxy([], makeContext());
|
||||||
|
|
||||||
|
expect(result.useRemoteProxy).toBe(false);
|
||||||
|
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
|
||||||
|
expect(mockEnsureCLIProxyBinary).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveExecutorProxy — proxyConfig propagated in result', () => {
|
||||||
|
it('returns the resolved proxyConfig object', async () => {
|
||||||
|
mockLocalProxyConfig();
|
||||||
|
|
||||||
|
const result = await resolveExecutorProxy([], makeContext());
|
||||||
|
|
||||||
|
expect(result.proxyConfig).toBeDefined();
|
||||||
|
expect(result.proxyConfig.mode).toBe('local');
|
||||||
|
expect(result.proxyConfig.port).toBe(8317);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns mutated cfg with validated port', async () => {
|
||||||
|
mockLocalProxyConfig();
|
||||||
|
const ctx = makeContext();
|
||||||
|
|
||||||
|
const result = await resolveExecutorProxy([], ctx);
|
||||||
|
|
||||||
|
// cfg is mutated in place and also returned
|
||||||
|
expect(result.cfg).toBe(ctx.cfg);
|
||||||
|
expect(result.cfg.port).toBe(8317);
|
||||||
|
});
|
||||||
|
});
|
||||||
+12
-135
@@ -14,29 +14,20 @@ import { spawn, ChildProcess } from 'child_process';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { ProgressIndicator } from '../../utils/progress-indicator';
|
|
||||||
import { ok, fail, info, warn } from '../../utils/ui';
|
import { ok, fail, info, warn } from '../../utils/ui';
|
||||||
import { getCcsDir } from '../../utils/config-manager';
|
import { getCcsDir } from '../../utils/config-manager';
|
||||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||||
import {
|
|
||||||
ensureCLIProxyBinary,
|
|
||||||
getConfiguredBackend,
|
|
||||||
getPlusBackendUnavailableMessage,
|
|
||||||
} from '../binary-manager';
|
|
||||||
import {
|
import {
|
||||||
generateConfig,
|
generateConfig,
|
||||||
getProviderConfig,
|
getProviderConfig,
|
||||||
ensureProviderSettings,
|
ensureProviderSettings,
|
||||||
getProviderSettingsPath,
|
getProviderSettingsPath,
|
||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
validatePort,
|
|
||||||
} from '../config/config-generator';
|
} from '../config/config-generator';
|
||||||
import { checkRemoteProxy } from '../services/remote-proxy-client';
|
|
||||||
import { isAuthenticated } from '../auth/auth-handler';
|
import { isAuthenticated } from '../auth/auth-handler';
|
||||||
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types';
|
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||||
import { configureProviderModel, getCurrentModel } from '../config/model-config';
|
import { configureProviderModel, getCurrentModel } from '../config/model-config';
|
||||||
import { reconcileCodexModelForActivePlan } from '../ai-providers/codex-plan-compatibility';
|
import { reconcileCodexModelForActivePlan } from '../ai-providers/codex-plan-compatibility';
|
||||||
import { resolveProxyConfig } from '../proxy/proxy-config-resolver';
|
|
||||||
import {
|
import {
|
||||||
supportsModelConfig,
|
supportsModelConfig,
|
||||||
isModelBroken,
|
isModelBroken,
|
||||||
@@ -92,12 +83,7 @@ import {
|
|||||||
logEnvironment,
|
logEnvironment,
|
||||||
resolveCliproxyImageAnalysisEnv,
|
resolveCliproxyImageAnalysisEnv,
|
||||||
} from './env-resolver';
|
} from './env-resolver';
|
||||||
import {
|
import { handleTokenExpiration, handleQuotaCheck } from './retry-handler';
|
||||||
isNetworkError,
|
|
||||||
handleNetworkError,
|
|
||||||
handleTokenExpiration,
|
|
||||||
handleQuotaCheck,
|
|
||||||
} from './retry-handler';
|
|
||||||
import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager';
|
import { MANAGED_QUOTA_PROVIDERS, type ManagedQuotaProvider } from '../quota/quota-manager';
|
||||||
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
||||||
import {
|
import {
|
||||||
@@ -119,6 +105,7 @@ import {
|
|||||||
} from './thinking-override-resolver';
|
} from './thinking-override-resolver';
|
||||||
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
||||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||||
|
import { resolveExecutorProxy } from './proxy-resolver';
|
||||||
|
|
||||||
function resolveRuntimeQuotaMonitorProviders(
|
function resolveRuntimeQuotaMonitorProviders(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
@@ -197,26 +184,15 @@ export async function execClaudeWithCLIProxy(
|
|||||||
// Collect all providers to validate (default + composite tiers)
|
// Collect all providers to validate (default + composite tiers)
|
||||||
const allProviders = [provider, ...compositeProviders];
|
const allProviders = [provider, ...compositeProviders];
|
||||||
|
|
||||||
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
|
const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } =
|
||||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
|
await resolveExecutorProxy(args, {
|
||||||
remote: cliproxyServerConfig?.remote
|
unifiedConfig,
|
||||||
? {
|
allProviders,
|
||||||
enabled: cliproxyServerConfig.remote.enabled,
|
verbose,
|
||||||
host: cliproxyServerConfig.remote.host,
|
cfg,
|
||||||
port: cliproxyServerConfig.remote.port,
|
log,
|
||||||
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 browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||||
let argsWithoutBrowserFlags = argsWithoutProxy;
|
let argsWithoutBrowserFlags = argsWithoutProxy;
|
||||||
try {
|
try {
|
||||||
@@ -245,21 +221,6 @@ export async function execClaudeWithCLIProxy(
|
|||||||
console.error(warn(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
|
// Setup first-class CCS WebSearch runtime
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||||
@@ -280,93 +241,9 @@ export async function execClaudeWithCLIProxy(
|
|||||||
log(`Provider: ${providerConfig.displayName}`);
|
log(`Provider: ${providerConfig.displayName}`);
|
||||||
warnOAuthBanRisk(provider);
|
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({ notifyOnPlus: 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} requires local CLIProxy Plus backend`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variables for local proxy mode
|
// Variables for local proxy mode
|
||||||
let binaryPath: string | undefined;
|
|
||||||
let sessionId: 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. Parse all CCS executor flags (extracted to arg-parser.ts)
|
// 2. Parse all CCS executor flags (extracted to arg-parser.ts)
|
||||||
const parsedFlags = parseExecutorFlags(argsWithoutProxy, {
|
const parsedFlags = parseExecutorFlags(argsWithoutProxy, {
|
||||||
provider,
|
provider,
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Proxy Resolver — Concern D
|
||||||
|
*
|
||||||
|
* Handles proxy configuration resolution, remote proxy reachability check,
|
||||||
|
* local-backend selection, and CLIProxy binary acquisition.
|
||||||
|
*
|
||||||
|
* Extracted from executor/index.ts to isolate the proxy-resolution concern.
|
||||||
|
* All log messages, error messages, and exit semantics are byte-identical to
|
||||||
|
* the original implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ProgressIndicator } from '../../utils/progress-indicator';
|
||||||
|
import { ok, fail, info, warn } from '../../utils/ui';
|
||||||
|
import {
|
||||||
|
ensureCLIProxyBinary,
|
||||||
|
getConfiguredBackend,
|
||||||
|
getPlusBackendUnavailableMessage,
|
||||||
|
} from '../binary-manager';
|
||||||
|
import { checkRemoteProxy } from '../services/remote-proxy-client';
|
||||||
|
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types';
|
||||||
|
import { resolveProxyConfig } from '../proxy/proxy-config-resolver';
|
||||||
|
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../config/config-generator';
|
||||||
|
import type { ResolvedProxyConfig } from '../types';
|
||||||
|
import type { UnifiedConfig } from '../../config/schemas/unified-config';
|
||||||
|
import { isNetworkError, handleNetworkError } from './retry-handler';
|
||||||
|
|
||||||
|
/** Result returned from resolveExecutorProxy */
|
||||||
|
export interface ResolvedProxy {
|
||||||
|
/** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */
|
||||||
|
proxyConfig: ResolvedProxyConfig;
|
||||||
|
/** Whether to use the remote proxy (vs spawning a local one) */
|
||||||
|
useRemoteProxy: boolean;
|
||||||
|
/** Which local backend binary to use ('original' | 'plus') */
|
||||||
|
localBackend: CLIProxyBackend;
|
||||||
|
/** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */
|
||||||
|
binaryPath: string | undefined;
|
||||||
|
/** Args after proxy-related flags are stripped out */
|
||||||
|
argsWithoutProxy: string[];
|
||||||
|
/** Mutated executor config (port resolved and validated) */
|
||||||
|
cfg: ExecutorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dependencies injected by the orchestrator */
|
||||||
|
export interface ResolveExecutorProxyContext {
|
||||||
|
unifiedConfig: UnifiedConfig;
|
||||||
|
allProviders: CLIProxyProvider[];
|
||||||
|
verbose: boolean;
|
||||||
|
cfg: ExecutorConfig;
|
||||||
|
log: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves proxy configuration, checks remote reachability, selects the local
|
||||||
|
* backend, and ensures the CLIProxy binary is present when running locally.
|
||||||
|
*
|
||||||
|
* Mutates `context.cfg.port` in-place (same as original orchestrator behaviour).
|
||||||
|
*/
|
||||||
|
export async function resolveExecutorProxy(
|
||||||
|
args: string[],
|
||||||
|
context: ResolveExecutorProxyContext
|
||||||
|
): Promise<ResolvedProxy> {
|
||||||
|
const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context;
|
||||||
|
|
||||||
|
// Resolve proxy config from CLI flags > ENV > config.yaml > defaults
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Port resolution and validation (mutates cfg in-place)
|
||||||
|
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})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check remote proxy reachability
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local backend selection (only when not using remote proxy)
|
||||||
|
if (!useRemoteProxy) {
|
||||||
|
localBackend = getConfiguredBackend({ notifyOnPlus: 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} requires local CLIProxy Plus backend`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary acquisition — skipped when using remote proxy
|
||||||
|
let binaryPath: string | undefined;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
proxyConfig,
|
||||||
|
useRemoteProxy,
|
||||||
|
localBackend,
|
||||||
|
binaryPath,
|
||||||
|
argsWithoutProxy,
|
||||||
|
cfg,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user