diff --git a/cli/README.md b/cli/README.md index 1a5c99b8f9e..6f343bf951b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -159,6 +159,99 @@ This instructs the AI to proceed without user input. echo "Implement the new feature" | kilocode --ci --timeout 600 ``` +## Proxy Configuration + +The CLI supports HTTP/HTTPS proxy configuration through environment variables. This is useful when running behind corporate proxies or when you need to route traffic through a proxy server. + +### Supported Environment Variables + +- `HTTP_PROXY` / `http_proxy`: Proxy URL for HTTP requests +- `HTTPS_PROXY` / `https_proxy`: Proxy URL for HTTPS requests +- `ALL_PROXY` / `all_proxy`: Fallback proxy for all protocols +- `NO_PROXY` / `no_proxy`: Comma-separated list of domains to bypass proxy +- `NODE_TLS_REJECT_UNAUTHORIZED`: Set to `0` to disable SSL certificate validation (use with caution) + +### Proxy URL Format + +``` +http://[username:password@]proxy-host:port +``` + +### Examples + +#### Basic Proxy Configuration + +```bash +# Set proxy for HTTP and HTTPS +export HTTP_PROXY=http://localhost:8080 +export HTTPS_PROXY=http://localhost:8080 + +# Run CLI +kilocode +``` + +#### Proxy with Authentication + +```bash +# Proxy with username and password +export HTTPS_PROXY=http://username:password@proxy.company.com:8080 + +kilocode +``` + +#### Bypass Proxy for Specific Domains + +```bash +# Set proxy +export HTTPS_PROXY=http://localhost:8080 + +# Bypass proxy for localhost and internal domains +export NO_PROXY=localhost,127.0.0.1,*.internal.company.com,192.168.0.0/16 + +kilocode +``` + +#### Self-Signed Certificates + +```bash +# Disable SSL certificate validation (use with caution in development only) +export NODE_TLS_REJECT_UNAUTHORIZED=0 +export HTTPS_PROXY=http://localhost:8080 + +kilocode +``` + +#### One-Line Command + +```bash +# Run with proxy settings in a single command +HTTP_PROXY=http://localhost:8080 HTTPS_PROXY=http://localhost:8080 NODE_TLS_REJECT_UNAUTHORIZED=0 kilocode +``` + +### NO_PROXY Patterns + +The `NO_PROXY` environment variable supports various patterns: + +- **Exact domain**: `example.com` +- **Wildcard subdomains**: `*.example.com` +- **IP addresses**: `192.168.1.1` +- **CIDR ranges**: `192.168.0.0/16` +- **Port-specific**: `example.com:8080` +- **Multiple patterns**: `localhost,127.0.0.1,*.internal.com` + +### Troubleshooting + +If proxy is not working: + +1. **Check proxy logs**: The CLI will log proxy configuration on startup +2. **Verify proxy URL**: Ensure the proxy URL is correct and accessible +3. **Test proxy**: Use `curl` to test if the proxy is working: + ```bash + curl -x http://localhost:8080 https://api.kilocode.ai + ``` +4. **Check NO_PROXY**: Ensure the target domain is not in NO_PROXY list +5. **Certificate issues**: If you see SSL errors, you may need to set `NODE_TLS_REJECT_UNAUTHORIZED=0` (development only) + ## Local Development ### DevTools diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index 4085ad9c493..f6e60060c3e 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -102,6 +102,8 @@ const __dirname = __dirname__(__filename); "get-folder-size", "google-auth-library", "gray-matter", + "http-proxy-agent", + "https-proxy-agent", "i18next", "ignore", "ink", diff --git a/cli/package.dist.json b/cli/package.dist.json index 167e121bcf4..06138312c81 100644 --- a/cli/package.dist.json +++ b/cli/package.dist.json @@ -45,6 +45,8 @@ "get-folder-size": "^5.0.0", "google-auth-library": "^9.15.1", "gray-matter": "^4.0.3", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", "i18next": "^25.0.0", "ignore": "^7.0.3", "ink": "^6.3.1", diff --git a/cli/package.json b/cli/package.json index 71056fd8d72..3e58057f7e9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -65,6 +65,8 @@ "get-folder-size": "^5.0.0", "google-auth-library": "^9.15.1", "gray-matter": "^4.0.3", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", "i18next": "^25.0.0", "ignore": "^7.0.3", "ink": "^6.3.1", diff --git a/cli/src/host/ExtensionHost.ts b/cli/src/host/ExtensionHost.ts index 720e759d1c2..0af4fbf4768 100644 --- a/cli/src/host/ExtensionHost.ts +++ b/cli/src/host/ExtensionHost.ts @@ -412,6 +412,21 @@ export class ExtensionHost extends EventEmitter { const originalResolveFilename = ModuleClass._resolveFilename const originalCompile = ModuleClass.prototype._compile + // Configure proxy BEFORE loading the extension + // This ensures the extension's HTTP clients get the proxy configuration + try { + // Load axios in the extension's require context to ensure it's the same instance + require("axios") + const { configureProxy } = await import("../utils/proxy-config.js") + + // Apply proxy configuration to all HTTP clients that will be used by the extension + logs.debug("Configuring proxy for extension context", "ExtensionHost") + configureProxy() + logs.debug("Proxy configured for extension", "ExtensionHost") + } catch (error) { + logs.warn("Failed to configure proxy for extension", "ExtensionHost", { error }) + } + // Set up module resolution interception for vscode ModuleClass._resolveFilename = function (request: string, parent: any, isMain: boolean, options?: any) { if (request === "vscode") { diff --git a/cli/src/index.ts b/cli/src/index.ts index 404daaad31e..8c73f895e83 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -4,6 +4,10 @@ import { loadEnvFile } from "./utils/env-loader.js" loadEnvFile() +// Configure proxy settings immediately after loading environment +import { configureProxy } from "./utils/proxy-config.js" +configureProxy() + import { Command } from "commander" import { existsSync } from "fs" import { spawn } from "child_process" diff --git a/cli/src/services/extension.ts b/cli/src/services/extension.ts index 8457462dde7..4f5ef67b483 100644 --- a/cli/src/services/extension.ts +++ b/cli/src/services/extension.ts @@ -156,6 +156,7 @@ export class ExtensionService extends EventEmitter { }) // Setup proper message routing to avoid IPC timeouts + // This is the ONLY handler for TUI messages - removed duplicate tuiRequest handler this.messageBridge.getTUIChannel().on("message", async (ipcMessage) => { if (ipcMessage.type === "request") { try { @@ -172,6 +173,7 @@ export class ExtensionService extends EventEmitter { /** * Handle TUI messages and return response + * This is the single point of entry for all TUI->Extension messages */ private async handleTUIMessage(data: any): Promise { try { diff --git a/cli/src/utils/proxy-config.ts b/cli/src/utils/proxy-config.ts new file mode 100644 index 00000000000..d72b2d3e780 --- /dev/null +++ b/cli/src/utils/proxy-config.ts @@ -0,0 +1,174 @@ +/** + * Proxy configuration for CLI + * Reads proxy settings from environment variables and configures axios, fetch, and undici + */ + +import axios from "axios" +import { HttpProxyAgent } from "http-proxy-agent" +import { HttpsProxyAgent } from "https-proxy-agent" +import { logs } from "../services/logs.js" +import { parseNoProxy, shouldBypassProxy } from "./proxy-matcher.js" + +export interface ProxyConfig { + httpProxy?: string + httpsProxy?: string + noProxy: string[] + rejectUnauthorized: boolean +} + +/** + * Get proxy configuration from environment variables + */ +export function getProxyConfig(): ProxyConfig { + // Read proxy environment variables (case-insensitive) + const httpProxy = + process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || undefined + + const httpsProxy = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.ALL_PROXY || + process.env.all_proxy || + undefined + + const noProxyStr = process.env.NO_PROXY || process.env.no_proxy || "" + const noProxy = parseNoProxy(noProxyStr) + + // Handle NODE_TLS_REJECT_UNAUTHORIZED for self-signed certificates + const rejectUnauthorized = process.env.NODE_TLS_REJECT_UNAUTHORIZED !== "0" + + const result: ProxyConfig = { + noProxy, + rejectUnauthorized, + } + + if (httpProxy) { + result.httpProxy = httpProxy + } + + if (httpsProxy) { + result.httpsProxy = httpsProxy + } + + return result +} + +/** + * Configure axios, fetch, and undici to use proxy settings from environment variables + * This must be called before any HTTP requests are made + */ +export function configureProxy(): void { + const config = getProxyConfig() + + // Log proxy configuration (without credentials) + if (config.httpProxy || config.httpsProxy) { + logs.info("Configuring proxy settings:", "Proxy") + if (config.httpProxy) { + logs.info(` HTTP_PROXY: ${sanitizeProxyUrl(config.httpProxy)}`, "Proxy") + } + if (config.httpsProxy) { + logs.info(` HTTPS_PROXY: ${sanitizeProxyUrl(config.httpsProxy)}`, "Proxy") + } + if (config.noProxy.length > 0) { + logs.info(` NO_PROXY: ${config.noProxy.join(", ")}`, "Proxy") + } + if (!config.rejectUnauthorized) { + logs.warn(" TLS certificate validation: DISABLED (NODE_TLS_REJECT_UNAUTHORIZED=0)", "Proxy") + } + } + + // Create proxy agents + const httpProxyAgent = config.httpProxy ? new HttpProxyAgent(config.httpProxy) : undefined + const httpsProxyAgent = config.httpsProxy + ? new HttpsProxyAgent(config.httpsProxy, { + rejectUnauthorized: config.rejectUnauthorized, + }) + : undefined + + // Configure axios defaults + axios.defaults.httpAgent = httpProxyAgent + axios.defaults.httpsAgent = httpsProxyAgent + + // Add request interceptor to handle NO_PROXY for axios + axios.interceptors.request.use( + (requestConfig) => { + const url = requestConfig.url + if (!url) { + return requestConfig + } + + // Check if this URL should bypass the proxy + if (shouldBypassProxy(url, config.noProxy)) { + // Remove proxy agents for this request + requestConfig.httpAgent = undefined + requestConfig.httpsAgent = undefined + requestConfig.proxy = false + } + + return requestConfig + }, + (error) => { + return Promise.reject(error) + }, + ) + + // Configure undici for fetch requests + configureUndiciProxy(config).catch((error) => { + logs.debug("Failed to configure undici proxy", "Proxy", { error }) + }) + + logs.info("Proxy configuration complete (axios, fetch, undici)", "Proxy") +} + +/** + * Sanitize proxy URL for logging (remove credentials) + */ +function sanitizeProxyUrl(proxyUrl: string): string { + try { + const url = new URL(proxyUrl) + if (url.username || url.password) { + return `${url.protocol}//*****:*****@${url.host}` + } + return proxyUrl + } catch { + return proxyUrl + } +} + +/** + * Configure undici proxy (async to handle dynamic import) + */ +async function configureUndiciProxy(config: ProxyConfig): Promise { + try { + // Configure undici (used by some providers like fetchWithTimeout) + const undici = await import("undici") + if (undici && undici.setGlobalDispatcher) { + const { ProxyAgent } = undici + + // Use HTTPS proxy for all requests if configured, fallback to HTTP proxy + const proxyUri = config.httpsProxy || config.httpProxy + if (proxyUri) { + const proxyAgent = new ProxyAgent({ + uri: proxyUri, + requestTls: { + rejectUnauthorized: config.rejectUnauthorized, + }, + }) + undici.setGlobalDispatcher(proxyAgent) + logs.debug("Undici proxy agent configured", "Proxy") + } + } + } catch (error) { + logs.debug("Undici not available or failed to configure", "Proxy", { error }) + } +} + +/** + * Check if proxy is configured + */ +export function isProxyConfigured(): boolean { + const config = getProxyConfig() + return !!(config.httpProxy || config.httpsProxy) +} diff --git a/cli/src/utils/proxy-matcher.ts b/cli/src/utils/proxy-matcher.ts new file mode 100644 index 00000000000..767d96713c8 --- /dev/null +++ b/cli/src/utils/proxy-matcher.ts @@ -0,0 +1,149 @@ +/** + * Utility functions for matching URLs against NO_PROXY patterns + */ + +/** + * Parse NO_PROXY environment variable into an array of patterns + */ +export function parseNoProxy(noProxy: string | undefined): string[] { + if (!noProxy) { + return [] + } + + return noProxy + .split(",") + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0) +} + +/** + * Check if a hostname matches a NO_PROXY pattern + */ +export function shouldBypassProxy(url: string, noProxyPatterns: string[]): boolean { + if (noProxyPatterns.length === 0) { + return false + } + + try { + const urlObj = new URL(url) + const hostname = urlObj.hostname.toLowerCase() + const port = urlObj.port + + for (const pattern of noProxyPatterns) { + const normalizedPattern = pattern.toLowerCase() + + // Check for wildcard pattern (*.example.com) + if (normalizedPattern.startsWith("*.")) { + const domain = normalizedPattern.slice(2) + if (hostname === domain || hostname.endsWith("." + domain)) { + return true + } + } + // Check for exact match or subdomain match + else if (normalizedPattern.startsWith(".")) { + const domain = normalizedPattern.slice(1) + if (hostname === domain || hostname.endsWith("." + domain)) { + return true + } + } + // Check for pattern with port + else if (normalizedPattern.includes(":")) { + const [patternHost, patternPort] = normalizedPattern.split(":") + if (hostname === patternHost && port === patternPort) { + return true + } + } + // Check for exact hostname match + else if (hostname === normalizedPattern) { + return true + } + // Check if hostname ends with pattern (subdomain match) + else if (hostname.endsWith("." + normalizedPattern)) { + return true + } + // Check for IP address or CIDR range + else if (isIpMatch(hostname, normalizedPattern)) { + return true + } + } + } catch { + // Invalid URL, don't bypass proxy + return false + } + + return false +} + +/** + * Check if an IP address matches a pattern (including CIDR notation) + */ +function isIpMatch(hostname: string, pattern: string): boolean { + // Simple IP exact match + if (hostname === pattern) { + return true + } + + // Check for CIDR notation (e.g., 192.168.0.0/16) + if (pattern.includes("/")) { + return matchesCidr(hostname, pattern) + } + + return false +} + +/** + * Check if an IP address matches a CIDR range + */ +function matchesCidr(ip: string, cidr: string): boolean { + try { + const parts = cidr.split("/") + if (parts.length !== 2 || !parts[0] || !parts[1]) { + return false + } + + const range = parts[0] + const bitsStr = parts[1] + const mask = parseInt(bitsStr, 10) + + if (isNaN(mask) || mask < 0 || mask > 32) { + return false + } + + const ipNum = ipToNumber(ip) + const rangeNum = ipToNumber(range) + + if (ipNum === null || rangeNum === null) { + return false + } + + const maskNum = (0xffffffff << (32 - mask)) >>> 0 + return (ipNum & maskNum) === (rangeNum & maskNum) + } catch { + return false + } +} + +/** + * Convert IPv4 address to number + */ +function ipToNumber(ip: string): number | null { + const parts = ip.split(".") + if (parts.length !== 4) { + return null + } + + let num = 0 + for (let i = 0; i < 4; i++) { + const partStr = parts[i] + if (!partStr) { + return null + } + const part = parseInt(partStr, 10) + if (isNaN(part) || part < 0 || part > 255) { + return null + } + num = (num << 8) + part + } + + return num >>> 0 +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b8b76de1e9..6cc299c94ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -684,6 +684,12 @@ importers: gray-matter: specifier: ^4.0.3 version: 4.0.3 + http-proxy-agent: + specifier: ^7.0.2 + version: 7.0.2 + https-proxy-agent: + specifier: ^7.0.5 + version: 7.0.6 i18next: specifier: ^25.0.0 version: 25.2.1(typescript@5.6.3)