diff --git a/src/cliproxy/executor/__tests__/executor-option-value.test.ts b/src/cliproxy/executor/__tests__/executor-option-value.test.ts index 9315f3f7..45e1eba0 100644 --- a/src/cliproxy/executor/__tests__/executor-option-value.test.ts +++ b/src/cliproxy/executor/__tests__/executor-option-value.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as http from 'http'; import { execClaudeWithCLIProxy, hasGitLabTokenLoginFlag, readOptionValue } from '../index'; describe('readOptionValue', () => { @@ -57,18 +58,84 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); fs.chmodSync(fakeClaudePath, 0o755); originalCcsHome = process.env.CCS_HOME; + process.exitCode = 0; process.env.CCS_HOME = tmpHome; }); + async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return fs.existsSync(filePath); + } + afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } + process.exitCode = 0; fs.rmSync(tmpHome, { recursive: true, force: true }); }); + it('validates conflicting browser launch flags before remote proxy checks', async () => { + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--browser', + '--no-browser', + ], + {} + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + '[X] Use either `--browser` or `--no-browser`, not both.' + ); + expect(requestCount).toBe(0); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it('exits cleanly when conflicting browser launch flags are provided', async () => { const exitSpy = jest .spyOn(process, 'exit') @@ -87,4 +154,69 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { errorSpy.mockRestore(); } }); + + it('does not treat a stale global exitCode as a current parse failure', async () => { + const markerPath = path.join(tmpHome, 'fake-claude-launched'); + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`, + { mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + process.exitCode = 1; + + await execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--print', + 'hello', + ], + {} + ); + + expect(await waitForFile(markerPath)).toBe(true); + expect(requestCount).toBeGreaterThan(0); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); }); diff --git a/src/cliproxy/executor/__tests__/proxy-resolver.test.ts b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts index 86af7137..d67f4797 100644 --- a/src/cliproxy/executor/__tests__/proxy-resolver.test.ts +++ b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts @@ -5,7 +5,7 @@ * logic extracted from executor/index.ts. */ -import { beforeEach, describe, expect, it, jest } from 'bun:test'; +import { afterEach, 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'; @@ -15,11 +15,29 @@ import type { UnifiedConfig } from '../../../config/schemas/unified-config'; const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy'); const mockGetConfiguredBackend = jest.fn().mockReturnValue('original'); const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable'); +const mockInstallCliproxyVersion = jest.fn().mockResolvedValue(undefined); +const mockFetchLatestCliproxyVersion = jest.fn().mockResolvedValue('test-version'); +const mockCheckCliproxyUpdate = jest.fn().mockResolvedValue({ available: false }); jest.mock('../../binary-manager', () => ({ ensureCLIProxyBinary: mockEnsureCLIProxyBinary, getConfiguredBackend: mockGetConfiguredBackend, getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage, + getStoredConfiguredBackend: mockGetConfiguredBackend, + getCLIProxyPath: jest.fn().mockReturnValue('/usr/local/bin/cliproxy'), + getInstalledCliproxyVersion: jest.fn().mockReturnValue('test-version'), + isCLIProxyInstalled: jest.fn().mockReturnValue(true), + resolveLocalBackend: mockGetConfiguredBackend, + syncPlusFallbackStateIfNeeded: jest.fn(), + installCliproxyVersion: mockInstallCliproxyVersion, + fetchLatestCliproxyVersion: mockFetchLatestCliproxyVersion, + checkCliproxyUpdate: mockCheckCliproxyUpdate, + getPinnedVersion: jest.fn().mockReturnValue(null), + savePinnedVersion: jest.fn(), + clearPinnedVersion: jest.fn(), + isVersionPinned: jest.fn().mockReturnValue(false), + getVersionPinPath: jest.fn().mockReturnValue('/tmp/cliproxy-version-pin'), + BinaryManager: class {}, })); const mockCheckRemoteProxy = jest.fn(); @@ -30,21 +48,28 @@ jest.mock('../../services/remote-proxy-client', () => ({ 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), + handleTokenExpiration: jest.fn(), + handleQuotaCheck: jest.fn(), + PROVIDER_ERROR_PATTERNS: [], + detectFailedTier: jest.fn().mockReturnValue(null), + isProviderError: jest.fn().mockReturnValue(false), })); // ── Import after mocks ──────────────────────────────────────────────────────── -const { resolveExecutorProxy } = await import('../proxy-resolver'); +const { resolveExecutorProxy, resolveExecutorProxyConfig } = await import('../proxy-resolver'); + +const PROXY_ENV_KEYS = [ + 'CCS_PROXY_HOST', + 'CCS_PROXY_PORT', + 'CCS_PROXY_PROTOCOL', + 'CCS_PROXY_AUTH_TOKEN', + 'CCS_PROXY_TIMEOUT', + 'CCS_PROXY_FALLBACK_ENABLED', + 'CCS_ALLOW_SELF_SIGNED', +] as const; + +let proxyEnvSnapshot: Record = {}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -76,51 +101,37 @@ function makeContext( }; } -/** 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, - }); +async function resolveProxyForTest(args: string[], context = makeContext()) { + const resolvedConfig = resolveExecutorProxyConfig(args, context); + return resolveExecutorProxy(resolvedConfig, context); } // ── Tests ───────────────────────────────────────────────────────────────────── beforeEach(() => { jest.clearAllMocks(); + proxyEnvSnapshot = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of PROXY_ENV_KEYS) { + delete process.env[key]; + } mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy'); mockGetConfiguredBackend.mockReturnValue('original'); }); +afterEach(() => { + for (const key of PROXY_ENV_KEYS) { + const value = proxyEnvSnapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + describe('resolveExecutorProxy — local mode', () => { it('returns useRemoteProxy=false and correct binary for local mode', async () => { - mockLocalProxyConfig(['--verbose']); - const result = await resolveExecutorProxy(['--verbose'], makeContext()); + const result = await resolveProxyForTest(['--local-proxy', '--verbose']); expect(result.useRemoteProxy).toBe(false); expect(result.localBackend).toBe('original'); @@ -129,16 +140,14 @@ describe('resolveExecutorProxy — local mode', () => { }); it('strips proxy flags and passes remainingArgs through', async () => { - mockLocalProxyConfig(['clean-arg']); - const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext()); + const result = await resolveProxyForTest(['--local-proxy', 'clean-arg']); expect(result.argsWithoutProxy).toEqual(['clean-arg']); expect(result.useRemoteProxy).toBe(false); }); it('does not call checkRemoteProxy in local mode', async () => { - mockLocalProxyConfig(); - await resolveExecutorProxy([], makeContext()); + await resolveProxyForTest(['--local-proxy']); expect(mockCheckRemoteProxy).not.toHaveBeenCalled(); }); @@ -146,19 +155,17 @@ describe('resolveExecutorProxy — local mode', () => { 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()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); 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()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.binaryPath).toBeUndefined(); expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled(); @@ -167,65 +174,27 @@ describe('resolveExecutorProxy — remote mode reachable', () => { 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' - ); + await expect( + resolveProxyForTest(['--proxy-host', '192.168.1.100', '--remote-only']) + ).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: [], - }); + process.env.CCS_PROXY_FALLBACK_ENABLED = '0'; mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' }); - await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow( + await expect(resolveProxyForTest(['--proxy-host', '192.168.1.100'])).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()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.useRemoteProxy).toBe(false); expect(result.binaryPath).toBe('/usr/local/bin/cliproxy'); @@ -235,9 +204,7 @@ describe('resolveExecutorProxy — remote mode unreachable', () => { describe('resolveExecutorProxy — proxyConfig propagated in result', () => { it('returns the resolved proxyConfig object', async () => { - mockLocalProxyConfig(); - - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--local-proxy']); expect(result.proxyConfig).toBeDefined(); expect(result.proxyConfig.mode).toBe('local'); @@ -245,10 +212,9 @@ describe('resolveExecutorProxy — proxyConfig propagated in result', () => { }); it('returns mutated cfg with validated port', async () => { - mockLocalProxyConfig(); const ctx = makeContext(); - const result = await resolveExecutorProxy([], ctx); + const result = await resolveProxyForTest(['--local-proxy'], ctx); // cfg is mutated in place and also returned expect(result.cfg).toBe(ctx.cfg); diff --git a/src/cliproxy/executor/arg-parser.ts b/src/cliproxy/executor/arg-parser.ts index ea581f98..d1a74bc2 100644 --- a/src/cliproxy/executor/arg-parser.ts +++ b/src/cliproxy/executor/arg-parser.ts @@ -8,7 +8,9 @@ * - parseExecutorFlags() — flag extraction block (lines ~411-639 in original) * - validateFlagCombinations() — cross-flag guard block (lines ~531-585) * - * IMPORTANT: process.exit semantics are kept identical to original index.ts. + * IMPORTANT: process.exit semantics are kept identical to original index.ts, + * with explicit parseFailed/validation return state for callers that must not + * depend on ambient process.exitCode. * All console.error messages are byte-identical. */ @@ -153,6 +155,7 @@ export function filterCcsFlags(args: string[]): string[] { /** Result of parsing CCS executor flags from args. */ export interface ParsedExecutorFlags { + parseFailed?: boolean; forceAuth: boolean; pasteCallback: boolean; portForward: boolean; @@ -181,7 +184,7 @@ export interface ParsedExecutorFlags { /** * Parse all CCS executor flags from args. * - * Exits with code 1 (process.exitCode = 1 + return) on invalid flag values. + * Exits with code 1 (process.exitCode = 1 + parseFailed return) on invalid flag values. * Exits with process.exit(1) on conflicting flag combinations — identical to * the original index.ts behavior. * @@ -247,7 +250,7 @@ export function parseExecutorFlags( console.error(fail('--kiro-auth-method requires a value')); console.error(' Supported values: aws, aws-authcode, google, github, idc'); process.exitCode = 1; - // Caller must check process.exitCode = 1 and bail — matching original return behavior + // Caller must check parseFailed and bail — matching original return behavior return buildPartialFlags({ forceAuth, pasteCallback, @@ -272,6 +275,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } const normalized = rawMethod.trim().toLowerCase(); @@ -303,6 +307,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } kiroAuthMethod = normalizeKiroAuthMethod(normalized); @@ -339,6 +344,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -373,6 +379,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -408,6 +415,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } const normalized = rawFlow.trim().toLowerCase(); @@ -439,6 +447,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } kiroIDCFlow = normalizeKiroIDCFlow(normalized); @@ -475,6 +484,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -538,6 +548,7 @@ export function parseExecutorFlags( gitlabBaseUrl, extendedContextOverride, thinkingParse, + parseFailed: false, }; } @@ -550,8 +561,8 @@ function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags { /** * Validate flag combinations that are mutually exclusive or provider-scoped. - * Calls process.exit(1) on any violation — identical to original index.ts. - * Call AFTER parseExecutorFlags() and only if process.exitCode is still 0. + * Sets process.exitCode=1 and returns false on any violation. + * Call AFTER parseExecutorFlags() and only if parseFailed is false. * * @param parsed Result of parseExecutorFlags() * @param context Provider context (provider string + compositeProviders list) @@ -561,7 +572,7 @@ export function validateFlagCombinations( parsed: ParsedExecutorFlags, context: { provider: string; compositeProviders: string[] }, args: string[] -): void { +): boolean { const { provider, compositeProviders } = context; const { kiroAuthMethod, @@ -575,7 +586,7 @@ export function validateFlagCombinations( if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { console.error(fail('--kiro-auth-method is only valid for ccs kiro')); process.exitCode = 1; - return; + return false; } if ( @@ -589,7 +600,7 @@ export function validateFlagCombinations( ) ); process.exitCode = 1; - return; + return false; } if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) { @@ -598,7 +609,7 @@ export function validateFlagCombinations( ' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start' ); process.exitCode = 1; - return; + return false; } if ( @@ -612,13 +623,15 @@ export function validateFlagCombinations( ) ); process.exitCode = 1; - return; + return false; } if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url'; console.error(fail(`${flagName} is only valid for ccs gitlab`)); process.exitCode = 1; - return; + return false; } + + return true; } diff --git a/src/cliproxy/executor/browser-launch-setup.ts b/src/cliproxy/executor/browser-launch-setup.ts index 94405a33..c33f3bdc 100644 --- a/src/cliproxy/executor/browser-launch-setup.ts +++ b/src/cliproxy/executor/browser-launch-setup.ts @@ -9,7 +9,7 @@ * 4. Browser MCP ensure + sync-to-config-dir */ -import { warn } from '../../utils/ui'; +import { fail, warn } from '../../utils/ui'; import { type BrowserLaunchOverride, ensureBrowserMcpOrThrow, @@ -41,6 +41,7 @@ export interface BrowserLaunchSetupResult { export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { browserLaunchOverride: BrowserLaunchOverride | undefined; argsWithoutBrowserFlags: string[]; + parseFailed: boolean; } { let browserLaunchOverride: BrowserLaunchOverride | undefined; let argsWithoutBrowserFlags = argsWithoutProxy; @@ -49,9 +50,10 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { browserLaunchOverride = browserLaunchFlags.override; argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; } catch (error) { - console.error(warn((error as Error).message)); + console.error(fail((error as Error).message)); + process.exitCode = 1; process.exit(1); - return { browserLaunchOverride: undefined, argsWithoutBrowserFlags }; + return { browserLaunchOverride: undefined, argsWithoutBrowserFlags, parseFailed: true }; } const browserConfig = getBrowserConfig(); @@ -71,7 +73,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { console.error(warn(blockedBrowserOverrideWarning)); } - return { browserLaunchOverride, argsWithoutBrowserFlags }; + return { browserLaunchOverride, argsWithoutBrowserFlags, parseFailed: false }; } /** diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index d88ff83e..0a5ee7fa 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -63,7 +63,7 @@ import { } from './thinking-override-resolver'; import { shouldStartHttpsTunnel } from './https-tunnel-policy'; import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser'; -import { resolveExecutorProxy } from './proxy-resolver'; +import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolver'; import { buildProxyChain } from './proxy-chain-builder'; import { warnBrokenModels } from './model-warnings'; import { launchClaude } from './claude-launcher'; @@ -129,8 +129,23 @@ export async function execClaudeWithCLIProxy( // Collect all providers to validate (default + composite tiers) const allProviders = [provider, ...compositeProviders]; + const proxyResolution = resolveExecutorProxyConfig(args, { + unifiedConfig, + allProviders, + verbose, + cfg, + log, + }); + + const { + browserLaunchOverride, + argsWithoutBrowserFlags, + parseFailed: browserLaunchParseFailed, + } = resolveBrowserLaunchFlags(proxyResolution.argsWithoutProxy); + if (browserLaunchParseFailed) return; + const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } = - await resolveExecutorProxy(args, { + await resolveExecutorProxy(proxyResolution, { unifiedConfig, allProviders, verbose, @@ -138,9 +153,6 @@ export async function execClaudeWithCLIProxy( log, }); - const { browserLaunchOverride, argsWithoutBrowserFlags } = - resolveBrowserLaunchFlags(argsWithoutProxy); - // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); @@ -158,11 +170,15 @@ export async function execClaudeWithCLIProxy( compositeProviders, unifiedConfig, }); - if (process.exitCode === 1) return; + if (parsedFlags.parseFailed) return; - // Validate cross-flag combinations (exits with code 1 on violation) - validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy); - if (process.exitCode === 1) return; + // Validate cross-flag combinations (reports failure without relying on ambient exitCode) + const flagCombinationsValid = validateFlagCombinations( + parsedFlags, + { provider, compositeProviders }, + argsWithoutProxy + ); + if (!flagCombinationsValid) return; const { forceConfig, diff --git a/src/cliproxy/executor/proxy-resolver.ts b/src/cliproxy/executor/proxy-resolver.ts index eae39abb..bade6f32 100644 --- a/src/cliproxy/executor/proxy-resolver.ts +++ b/src/cliproxy/executor/proxy-resolver.ts @@ -24,20 +24,23 @@ 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 { +export interface ResolvedExecutorProxyConfig { /** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */ proxyConfig: ResolvedProxyConfig; + /** Args after proxy-related flags are stripped out */ + argsWithoutProxy: string[]; + /** Mutated executor config (port resolved and validated) */ + cfg: ExecutorConfig; +} + +/** Result returned from resolveExecutorProxy */ +export interface ResolvedProxy extends ResolvedExecutorProxyConfig { /** 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 */ @@ -50,16 +53,15 @@ export interface ResolveExecutorProxyContext { } /** - * Resolves proxy configuration, checks remote reachability, selects the local - * backend, and ensures the CLIProxy binary is present when running locally. + * Resolves side-effect-free proxy configuration and strips proxy flags. * * Mutates `context.cfg.port` in-place (same as original orchestrator behaviour). */ -export async function resolveExecutorProxy( +export function resolveExecutorProxyConfig( args: string[], context: ResolveExecutorProxyContext -): Promise { - const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context; +): ResolvedExecutorProxyConfig { + const { unifiedConfig, cfg, log } = context; // Resolve proxy config from CLI flags > ENV > config.yaml > defaults const cliproxyServerConfig = unifiedConfig.cliproxy_server; @@ -98,6 +100,20 @@ export async function resolveExecutorProxy( log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); } + return { proxyConfig, argsWithoutProxy, cfg }; +} + +/** + * Resolves proxy configuration, checks remote reachability, selects the local + * backend, and ensures the CLIProxy binary is present when running locally. + */ +export async function resolveExecutorProxy( + resolvedConfig: ResolvedExecutorProxyConfig, + context: ResolveExecutorProxyContext +): Promise { + const { allProviders, verbose: _verbose } = context; + const { proxyConfig, argsWithoutProxy, cfg } = resolvedConfig; + // Check remote proxy reachability let useRemoteProxy = false; let localBackend: CLIProxyBackend = 'original';