diff --git a/src/cliproxy/proxy-config-resolver.ts b/src/cliproxy/proxy-config-resolver.ts new file mode 100644 index 00000000..b0af403f --- /dev/null +++ b/src/cliproxy/proxy-config-resolver.ts @@ -0,0 +1,279 @@ +/** + * Proxy Config Resolver + * + * Resolves proxy configuration from multiple sources with priority: + * CLI flags > Environment variables > config.yaml > defaults + * + * Supports both local (spawn CLIProxyAPI) and remote (connect to external) modes. + */ + +import { ResolvedProxyConfig } from './types'; +import { CLIPROXY_DEFAULT_PORT } from './config-generator'; + +/** CLI flags for proxy configuration */ +export const PROXY_CLI_FLAGS = [ + '--proxy-host', + '--proxy-port', + '--proxy-protocol', + '--proxy-auth-token', + '--local-proxy', + '--remote-only', +] as const; + +/** Environment variable names for proxy configuration */ +export const PROXY_ENV_VARS = { + host: 'CCS_PROXY_HOST', + port: 'CCS_PROXY_PORT', + protocol: 'CCS_PROXY_PROTOCOL', + authToken: 'CCS_PROXY_AUTH_TOKEN', + fallbackEnabled: 'CCS_PROXY_FALLBACK_ENABLED', +} as const; + +/** Parsed CLI proxy flags */ +interface ParsedProxyFlags { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + localProxy: boolean; + remoteOnly: boolean; +} + +/** Proxy config from environment variables */ +interface EnvProxyConfig { + host?: string; + port?: number; + protocol?: 'http' | 'https'; + authToken?: string; + fallbackEnabled?: boolean; +} + +/** + * Parse proxy-related CLI flags from argv. + * Returns parsed flags and remaining args (with proxy flags removed). + */ +export function parseProxyFlags(args: string[]): { + flags: ParsedProxyFlags; + remainingArgs: string[]; +} { + const flags: ParsedProxyFlags = { + localProxy: false, + remoteOnly: false, + }; + const remainingArgs: string[] = []; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === '--proxy-host' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.host = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--proxy-port' && args[i + 1] && !args[i + 1].startsWith('-')) { + const port = parseInt(args[i + 1], 10); + if (!isNaN(port) && port > 0 && port <= 65535) { + flags.port = port; + } + i += 2; + continue; + } + + if (arg === '--proxy-protocol' && args[i + 1] && !args[i + 1].startsWith('-')) { + const proto = args[i + 1].toLowerCase(); + if (proto === 'http' || proto === 'https') { + flags.protocol = proto; + } + i += 2; + continue; + } + + if (arg === '--proxy-auth-token' && args[i + 1] && !args[i + 1].startsWith('-')) { + flags.authToken = args[i + 1]; + i += 2; + continue; + } + + if (arg === '--local-proxy') { + flags.localProxy = true; + i += 1; + continue; + } + + if (arg === '--remote-only') { + flags.remoteOnly = true; + i += 1; + continue; + } + + // Not a proxy flag - keep in remaining args + remainingArgs.push(arg); + i += 1; + } + + return { flags, remainingArgs }; +} + +/** + * Get proxy configuration from environment variables. + */ +export function getProxyEnvVars(): EnvProxyConfig { + const config: EnvProxyConfig = {}; + + const host = process.env[PROXY_ENV_VARS.host]; + if (host) { + config.host = host; + } + + const port = process.env[PROXY_ENV_VARS.port]; + if (port) { + const portNum = parseInt(port, 10); + if (!isNaN(portNum) && portNum > 0 && portNum <= 65535) { + config.port = portNum; + } + } + + const protocol = process.env[PROXY_ENV_VARS.protocol]; + if (protocol) { + const proto = protocol.toLowerCase(); + if (proto === 'http' || proto === 'https') { + config.protocol = proto; + } + } + + const authToken = process.env[PROXY_ENV_VARS.authToken]; + if (authToken) { + config.authToken = authToken; + } + + const fallback = process.env[PROXY_ENV_VARS.fallbackEnabled]; + if (fallback !== undefined) { + // Accept: '1', 'true', 'yes' as enabled; '0', 'false', 'no' as disabled + const lower = fallback.toLowerCase(); + if (lower === '1' || lower === 'true' || lower === 'yes') { + config.fallbackEnabled = true; + } else if (lower === '0' || lower === 'false' || lower === 'no') { + config.fallbackEnabled = false; + } + } + + return config; +} + +/** + * Default proxy configuration values. + */ +const DEFAULT_PROXY_CONFIG: ResolvedProxyConfig = { + mode: 'local', + port: CLIPROXY_DEFAULT_PORT, + protocol: 'http', + fallbackEnabled: true, + autoStartLocal: true, + remoteOnly: false, + forceLocal: false, +}; + +/** + * Resolve proxy configuration with priority: CLI > ENV > config.yaml > defaults. + * + * @param cliArgs - Raw CLI arguments + * @param configYamlProxy - Proxy section from config.yaml (optional, Phase 1) + * @returns Resolved configuration and remaining args (without proxy flags) + */ +export function resolveProxyConfig( + cliArgs: string[], + + _configYamlProxy?: { + remote?: { + enabled?: boolean; + host?: string; + port?: number; + protocol?: 'http' | 'https'; + auth_token?: string; + fallback_enabled?: boolean; + }; + local?: { + port?: number; + auto_start?: boolean; + }; + } +): { config: ResolvedProxyConfig; remainingArgs: string[] } { + // 1. Parse CLI flags (highest priority) + const { flags: cliFlags, remainingArgs } = parseProxyFlags(cliArgs); + + // 2. Get environment variables + const envConfig = getProxyEnvVars(); + + // 3. config.yaml proxy section (passed as parameter - Phase 1 provides this) + // For now, we use empty object if not provided; Phase 1 integrates unified config loading + const yamlConfig = _configYamlProxy || {}; + + // 4. Build resolved config with priority merge + const resolved: ResolvedProxyConfig = { + ...DEFAULT_PROXY_CONFIG, + }; + + // Determine mode: remote if host is specified anywhere (unless --local-proxy) + const hasRemoteHost = + cliFlags.host || envConfig.host || yamlConfig.remote?.host || yamlConfig.remote?.enabled; + + // --local-proxy forces local mode regardless of remote config + if (cliFlags.localProxy) { + resolved.mode = 'local'; + resolved.forceLocal = true; + } else if (hasRemoteHost) { + resolved.mode = 'remote'; + } + + // Merge host: CLI > ENV > config.yaml + resolved.host = cliFlags.host ?? envConfig.host ?? yamlConfig.remote?.host; + + // Merge port: CLI > ENV > config.yaml (remote or local) > default + resolved.port = + cliFlags.port ?? + envConfig.port ?? + (resolved.mode === 'remote' ? yamlConfig.remote?.port : yamlConfig.local?.port) ?? + DEFAULT_PROXY_CONFIG.port; + + // Merge protocol: CLI > ENV > config.yaml > default + resolved.protocol = + cliFlags.protocol ?? envConfig.protocol ?? yamlConfig.remote?.protocol ?? 'http'; + + // Merge auth token: CLI > ENV > config.yaml + resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token; + + // Merge fallback enabled: ENV > config.yaml > default + resolved.fallbackEnabled = + envConfig.fallbackEnabled ?? yamlConfig.remote?.fallback_enabled ?? true; + + // --remote-only from CLI + resolved.remoteOnly = cliFlags.remoteOnly; + + // If --remote-only, disable fallback + if (resolved.remoteOnly) { + resolved.fallbackEnabled = false; + } + + // Auto-start local from config.yaml > default + resolved.autoStartLocal = yamlConfig.local?.auto_start ?? true; + + return { config: resolved, remainingArgs }; +} + +/** + * Check if args contain any proxy flags. + * Used for quick filtering before full parse. + */ +export function hasProxyFlags(args: string[]): boolean { + return args.some( + (arg) => + arg === '--proxy-host' || + arg === '--proxy-port' || + arg === '--proxy-protocol' || + arg === '--proxy-auth-token' || + arg === '--local-proxy' || + arg === '--remote-only' + ); +} diff --git a/tests/unit/cliproxy/proxy-config-resolver.test.js b/tests/unit/cliproxy/proxy-config-resolver.test.js new file mode 100644 index 00000000..2a7d0851 --- /dev/null +++ b/tests/unit/cliproxy/proxy-config-resolver.test.js @@ -0,0 +1,274 @@ +/** + * Unit tests for proxy-config-resolver module + */ +const { describe, it, expect, beforeEach, afterEach } = require('bun:test'); + +// Import from compiled dist +const { + parseProxyFlags, + getProxyEnvVars, + resolveProxyConfig, + hasProxyFlags, + PROXY_CLI_FLAGS, + PROXY_ENV_VARS, +} = require('../../../dist/cliproxy/proxy-config-resolver'); + +describe('proxy-config-resolver', () => { + describe('PROXY_CLI_FLAGS', () => { + it('should define all expected proxy flags', () => { + expect(PROXY_CLI_FLAGS).toContain('--proxy-host'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-port'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-protocol'); + expect(PROXY_CLI_FLAGS).toContain('--proxy-auth-token'); + expect(PROXY_CLI_FLAGS).toContain('--local-proxy'); + expect(PROXY_CLI_FLAGS).toContain('--remote-only'); + }); + }); + + describe('PROXY_ENV_VARS', () => { + it('should define all expected environment variable names', () => { + expect(PROXY_ENV_VARS.host).toBe('CCS_PROXY_HOST'); + expect(PROXY_ENV_VARS.port).toBe('CCS_PROXY_PORT'); + expect(PROXY_ENV_VARS.protocol).toBe('CCS_PROXY_PROTOCOL'); + expect(PROXY_ENV_VARS.authToken).toBe('CCS_PROXY_AUTH_TOKEN'); + expect(PROXY_ENV_VARS.fallbackEnabled).toBe('CCS_PROXY_FALLBACK_ENABLED'); + }); + }); + + describe('parseProxyFlags', () => { + it('should parse --proxy-host flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-host', '192.168.1.100']); + expect(flags.host).toBe('192.168.1.100'); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-port flag', () => { + const { flags, remainingArgs } = parseProxyFlags(['--proxy-port', '9000']); + expect(flags.port).toBe(9000); + expect(remainingArgs).toEqual([]); + }); + + it('should parse --proxy-protocol flag', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'https']); + expect(flags.protocol).toBe('https'); + }); + + it('should parse --proxy-auth-token flag', () => { + const { flags } = parseProxyFlags(['--proxy-auth-token', 'secret123']); + expect(flags.authToken).toBe('secret123'); + }); + + it('should parse --local-proxy boolean flag', () => { + const { flags } = parseProxyFlags(['--local-proxy']); + expect(flags.localProxy).toBe(true); + }); + + it('should parse --remote-only boolean flag', () => { + const { flags } = parseProxyFlags(['--remote-only']); + expect(flags.remoteOnly).toBe(true); + }); + + it('should preserve non-proxy args in remainingArgs', () => { + const { flags, remainingArgs } = parseProxyFlags([ + '--verbose', + '--proxy-host', + 'localhost', + '--some-other-flag', + ]); + expect(flags.host).toBe('localhost'); + expect(remainingArgs).toEqual(['--verbose', '--some-other-flag']); + }); + + it('should handle mixed proxy and non-proxy args', () => { + const { flags, remainingArgs } = parseProxyFlags([ + 'arg1', + '--proxy-port', + '8080', + 'arg2', + '--local-proxy', + 'arg3', + ]); + expect(flags.port).toBe(8080); + expect(flags.localProxy).toBe(true); + expect(remainingArgs).toEqual(['arg1', 'arg2', 'arg3']); + }); + + it('should ignore invalid port values', () => { + const { flags } = parseProxyFlags(['--proxy-port', 'invalid']); + expect(flags.port).toBeUndefined(); + }); + + it('should ignore out-of-range port values', () => { + const { flags: flags1 } = parseProxyFlags(['--proxy-port', '0']); + expect(flags1.port).toBeUndefined(); + + const { flags: flags2 } = parseProxyFlags(['--proxy-port', '70000']); + expect(flags2.port).toBeUndefined(); + }); + + it('should normalize protocol to lowercase', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'HTTPS']); + expect(flags.protocol).toBe('https'); + }); + + it('should ignore invalid protocol values', () => { + const { flags } = parseProxyFlags(['--proxy-protocol', 'ftp']); + expect(flags.protocol).toBeUndefined(); + }); + }); + + describe('getProxyEnvVars', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear proxy env vars + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + // Restore original env + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return empty config when no env vars set', () => { + const config = getProxyEnvVars(); + expect(config.host).toBeUndefined(); + expect(config.port).toBeUndefined(); + expect(config.protocol).toBeUndefined(); + expect(config.authToken).toBeUndefined(); + expect(config.fallbackEnabled).toBeUndefined(); + }); + + it('should read CCS_PROXY_HOST', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const config = getProxyEnvVars(); + expect(config.host).toBe('remote.example.com'); + }); + + it('should read and parse CCS_PROXY_PORT', () => { + process.env.CCS_PROXY_PORT = '9000'; + const config = getProxyEnvVars(); + expect(config.port).toBe(9000); + }); + + it('should read CCS_PROXY_PROTOCOL', () => { + process.env.CCS_PROXY_PROTOCOL = 'https'; + const config = getProxyEnvVars(); + expect(config.protocol).toBe('https'); + }); + + it('should read CCS_PROXY_AUTH_TOKEN', () => { + process.env.CCS_PROXY_AUTH_TOKEN = 'my-secret-token'; + const config = getProxyEnvVars(); + expect(config.authToken).toBe('my-secret-token'); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as true', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '1'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'true'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'yes'; + expect(getProxyEnvVars().fallbackEnabled).toBe(true); + }); + + it('should parse CCS_PROXY_FALLBACK_ENABLED as false', () => { + process.env.CCS_PROXY_FALLBACK_ENABLED = '0'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'false'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + + process.env.CCS_PROXY_FALLBACK_ENABLED = 'no'; + expect(getProxyEnvVars().fallbackEnabled).toBe(false); + }); + }); + + describe('resolveProxyConfig', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.CCS_PROXY_HOST; + delete process.env.CCS_PROXY_PORT; + delete process.env.CCS_PROXY_PROTOCOL; + delete process.env.CCS_PROXY_AUTH_TOKEN; + delete process.env.CCS_PROXY_FALLBACK_ENABLED; + }); + + afterEach(() => { + Object.keys(process.env).forEach((key) => { + if (key.startsWith('CCS_PROXY_')) { + delete process.env[key]; + } + }); + Object.assign(process.env, originalEnv); + }); + + it('should return local mode by default', () => { + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('local'); + expect(config.port).toBe(8317); // Default CLIProxy port + expect(config.fallbackEnabled).toBe(true); + }); + + it('should enable remote mode when --proxy-host is provided', () => { + const { config } = resolveProxyConfig(['--proxy-host', '192.168.1.100']); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('192.168.1.100'); + }); + + it('should enable remote mode when CCS_PROXY_HOST env is set', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig([]); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('remote.example.com'); + }); + + it('should prioritize CLI flags over ENV vars', () => { + process.env.CCS_PROXY_HOST = 'env-host'; + process.env.CCS_PROXY_PORT = '9000'; + const { config } = resolveProxyConfig(['--proxy-host', 'cli-host', '--proxy-port', '8080']); + expect(config.host).toBe('cli-host'); + expect(config.port).toBe(8080); + }); + + it('should force local mode with --local-proxy', () => { + process.env.CCS_PROXY_HOST = 'remote.example.com'; + const { config } = resolveProxyConfig(['--local-proxy']); + expect(config.mode).toBe('local'); + expect(config.forceLocal).toBe(true); + }); + + it('should set remoteOnly and disable fallback with --remote-only', () => { + const { config } = resolveProxyConfig(['--proxy-host', 'remote', '--remote-only']); + expect(config.remoteOnly).toBe(true); + expect(config.fallbackEnabled).toBe(false); + }); + }); + + describe('hasProxyFlags', () => { + it('should return true when proxy flags are present', () => { + expect(hasProxyFlags(['--proxy-host', 'localhost'])).toBe(true); + expect(hasProxyFlags(['--proxy-port', '8080'])).toBe(true); + expect(hasProxyFlags(['--local-proxy'])).toBe(true); + expect(hasProxyFlags(['--remote-only'])).toBe(true); + }); + + it('should return false when no proxy flags are present', () => { + expect(hasProxyFlags([])).toBe(false); + expect(hasProxyFlags(['--verbose', '--help'])).toBe(false); + expect(hasProxyFlags(['gemini', 'some-task'])).toBe(false); + }); + }); +});