diff --git a/.env.development b/.env.development index 81b3be797..2614ca2cc 100644 --- a/.env.development +++ b/.env.development @@ -11,3 +11,8 @@ VITE_USE_LOCAL_PROXY=false VITE_STACK_PROJECT_ID=dummy_project_id VITE_STACK_PUBLISHABLE_CLIENT_KEY=dummy_publishable_key VITE_STACK_SECRET_SERVER_KEY=dummy_secret_server_key + +# HTTP proxy settings for local development +# HTTP_PROXY=http://127.0.0.1:9090 +# HTTPS_PROXY=https://127.0.0.1:9090 +# NO_PROXY=localhost,127.0.0.1 \ No newline at end of file diff --git a/electron/main/index.ts b/electron/main/index.ts index b2001bbf0..8b3ab4392 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -52,6 +52,7 @@ import { getEmailFolderPath, getEnvPath, maskProxyUrl, + readEnvValueWithPriority, readGlobalEnvKey, removeEnvKey, updateEnvBlock, @@ -138,11 +139,31 @@ app.commandLine.appendSwitch('enable-features', 'MemoryPressureReduction'); app.commandLine.appendSwitch('renderer-process-limit', '8'); // ==================== Proxy configuration ==================== -// Read proxy from global .env file on startup -proxyUrl = readGlobalEnvKey('HTTP_PROXY'); +// Read proxy from multiple sources with priority: +// 1. Process environment (inline: SET HTTP_PROXY=... && eigent.exe) +// 2. .env.development (development mode) +// 3. Global ~/.eigent/.env file +// Check both HTTP_PROXY and HTTPS_PROXY (with lowercase variants) +const httpProxy = + readEnvValueWithPriority('HTTP_PROXY') || + readEnvValueWithPriority('http_proxy'); +const httpsProxy = + readEnvValueWithPriority('HTTPS_PROXY') || + readEnvValueWithPriority('https_proxy'); + +// Prefer HTTPS proxy if available, fallback to HTTP proxy +proxyUrl = httpsProxy || httpProxy; + if (proxyUrl) { log.info(`[PROXY] Applying proxy configuration: ${maskProxyUrl(proxyUrl)}`); app.commandLine.appendSwitch('proxy-server', proxyUrl); + + // Log which proxy type is being used + if (httpsProxy) { + log.info('[PROXY] Using HTTPS_PROXY configuration'); + } else if (httpProxy) { + log.info('[PROXY] Using HTTP_PROXY configuration'); + } } else { log.info('[PROXY] No proxy configured'); } @@ -1159,17 +1180,39 @@ function registerIpcHandlers() { log.error('global env-remove error:', error); } + // Also remove from .env.development file (remove from anywhere in file, not just MCP block) + const DEV_ENV_PATH = path.join(process.cwd(), '.env.development'); + try { + if (fs.existsSync(DEV_ENV_PATH)) { + let devContent = fs.readFileSync(DEV_ENV_PATH, 'utf-8'); + let devLines = devContent.split(/\r?\n/); + // Remove key from anywhere in the file (not limited to MCP block) + devLines = devLines.filter((line) => !line.trim().startsWith(key + '=')); + fs.writeFileSync(DEV_ENV_PATH, devLines.join('\n'), 'utf-8'); + log.info(`env-remove: removed ${key} from .env.development`); + } + } catch (error) { + log.error('.env.development env-remove error:', error); + } + return { success: true }; }); // ==================== read global env handler ==================== - const ALLOWED_GLOBAL_ENV_KEYS = new Set(['HTTP_PROXY', 'HTTPS_PROXY']); + const ALLOWED_GLOBAL_ENV_KEYS = new Set([ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + ]); ipcMain.handle('read-global-env', async (_event, key: string) => { if (!ALLOWED_GLOBAL_ENV_KEYS.has(key)) { log.warn(`[ENV] Blocked read of disallowed global env key: ${key}`); return { value: null }; } - return { value: readGlobalEnvKey(key) }; + return { value: readEnvValueWithPriority(key) }; }); // ==================== new window handler ==================== diff --git a/electron/main/init.ts b/electron/main/init.ts index 57fb26c87..b60ce45ed 100644 --- a/electron/main/init.ts +++ b/electron/main/init.ts @@ -22,7 +22,7 @@ import os from 'os'; import path from 'path'; import { promisify } from 'util'; import { PromiseReturnType } from './install-deps'; -import { maskProxyUrl, readGlobalEnvKey } from './utils/envUtil'; +import { maskProxyUrl, readEnvValueWithPriority } from './utils/envUtil'; import { ensureTerminalVenvAtUserPath, findNodejsWheelBinPath, @@ -42,7 +42,10 @@ const execAsync = promisify(exec); const DEFAULT_SERVER_URL = 'https://dev.eigent.ai/api'; -function readEnvValue(filePath: string, key: string): string | undefined { +export function readEnvValue( + filePath: string, + key: string +): string | undefined { try { if (!fs.existsSync(filePath)) return undefined; const content = fs.readFileSync(filePath, 'utf-8'); @@ -236,22 +239,22 @@ export async function startBackend( const uvEnv = getUvEnv(currentVersion); const globalEnvPath = path.join(os.homedir(), '.eigent', '.env'); - // Load proxy configuration from global .env file - const proxyUrl = readGlobalEnvKey('HTTP_PROXY'); - // Build proxy env vars if configured - const proxyEnv = proxyUrl - ? { - HTTP_PROXY: proxyUrl, - HTTPS_PROXY: proxyUrl, - http_proxy: proxyUrl, - https_proxy: proxyUrl, - } - : {}; + const proxyEnv = { + HTTP_PROXY: readEnvValueWithPriority('HTTP_PROXY'), + HTTPS_PROXY: readEnvValueWithPriority('HTTPS_PROXY'), + http_proxy: readEnvValueWithPriority('http_proxy'), + https_proxy: readEnvValueWithPriority('https_proxy'), + // Ensure local connections bypass proxy + NO_PROXY: + readEnvValueWithPriority('NO_PROXY') || 'localhost,127.0.0.1,.local', + no_proxy: + readEnvValueWithPriority('no_proxy') || 'localhost,127.0.0.1,.local', + }; - if (proxyUrl) { + if (proxyEnv.HTTP_PROXY || proxyEnv.HTTPS_PROXY) { log.info( - `[BACKEND] Proxy configured for backend: ${maskProxyUrl(proxyUrl)}` + `[BACKEND] Proxy configured for backend: ${maskProxyUrl((proxyEnv.HTTP_PROXY || proxyEnv.HTTPS_PROXY) as string)}` ); } diff --git a/electron/main/utils/envUtil.ts b/electron/main/utils/envUtil.ts index 565c8e017..c7e89b593 100644 --- a/electron/main/utils/envUtil.ts +++ b/electron/main/utils/envUtil.ts @@ -15,6 +15,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { readEnvValue } from '../init'; export const ENV_START = '# === MCP INTEGRATION ENV START ==='; export const ENV_END = '# === MCP INTEGRATION ENV END ==='; @@ -116,6 +117,34 @@ export function readGlobalEnvKey(key: string): string | null { return null; } +/** + * Read environment variable value with priority system. + * + * Priority order (highest to lowest): + * 1. Process environment variables (inline/system) + * 2. .env.development file (development mode only) + * 3. Global ~/.eigent/.env file + * + * @param key - The environment variable key to read + * @returns The value if found, null otherwise + */ +export function readEnvValueWithPriority(key: string): string | null { + // Priority 1: Process environment variables (highest priority) + if (process.env[key]) { + return process.env[key]!; + } + + // Priority 2: .env.development file (development mode only) + if (process.env.NODE_ENV === 'development') { + const devEnvPath = path.join(process.cwd(), '.env.development'); + const value = readEnvValue(devEnvPath, key); + if (value) return value; + } + + // Priority 3: Global ~/.eigent/.env file + return readGlobalEnvKey(key); +} + /** * Mask credentials in a proxy URL for safe logging. * e.g. "http://user:pass@host:port" → "http://***:***@host:port" diff --git a/electron/main/utils/process.ts b/electron/main/utils/process.ts index 70fd11921..96d865a5c 100644 --- a/electron/main/utils/process.ts +++ b/electron/main/utils/process.ts @@ -18,6 +18,7 @@ import log from 'electron-log'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { maskProxyUrl, readEnvValueWithPriority } from './envUtil'; export function getResourcePath() { return path.join(app.getAppPath(), 'resources'); @@ -33,6 +34,69 @@ export function getBackendPath() { } } +/** + * Get proxy environment variables with priority: + * 1. Process environment variables (inline/system) + * 2. .env.development file (development mode only) + * 3. Global ~/.eigent/.env config + * + * Returns an object with HTTP_PROXY, HTTPS_PROXY, NO_PROXY and lowercase variants + * if a proxy is configured, or an empty object if not. + * Supports separate HTTP and HTTPS proxy configurations. + */ +function getProxyEnvVars(): Record { + // Check both uppercase and lowercase variants + const httpProxy = + readEnvValueWithPriority('HTTP_PROXY') || + readEnvValueWithPriority('http_proxy'); + + const httpsProxy = + readEnvValueWithPriority('HTTPS_PROXY') || + readEnvValueWithPriority('https_proxy'); + + // Return empty object if no proxy configured + if (!httpProxy && !httpsProxy) { + return {}; + } + + // Log configured proxies + if (httpProxy) { + log.info( + `[INSTALL SCRIPT] HTTP Proxy configured: ${maskProxyUrl(httpProxy)}` + ); + } + if (httpsProxy) { + log.info( + `[INSTALL SCRIPT] HTTPS Proxy configured: ${maskProxyUrl(httpsProxy)}` + ); + } + + // Get NO_PROXY configuration (with default for local connections) + const noProxy = readEnvValueWithPriority('NO_PROXY') || + readEnvValueWithPriority('no_proxy') || + 'localhost,127.0.0.1,.local'; + + // Return all variants (some tools need uppercase, others lowercase) + // Filter out undefined values + const result: Record = {}; + + if (httpProxy) { + result.HTTP_PROXY = httpProxy; + result.http_proxy = httpProxy; + } + + if (httpsProxy) { + result.HTTPS_PROXY = httpsProxy; + result.https_proxy = httpsProxy; + } + + // Always set NO_PROXY when proxy is configured to avoid issues with local connections + result.NO_PROXY = noProxy; + result.no_proxy = noProxy; + + return result; +} + export function runInstallScript(scriptPath: string): Promise { return new Promise((resolve, reject) => { const installScriptPath = path.join( @@ -42,8 +106,15 @@ export function runInstallScript(scriptPath: string): Promise { ); log.info(`Running script at: ${installScriptPath}`); + // Get proxy configuration from global .env file + const proxyEnv = getProxyEnvVars(); + const nodeProcess = spawn(process.execPath, [installScriptPath], { - env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + env: { + ...process.env, + ...proxyEnv, + ELECTRON_RUN_AS_NODE: '1', + }, }); let stderrOutput = ''; diff --git a/resources/scripts/download.js b/resources/scripts/download.js index b527c6c8f..5b61b1e5c 100644 --- a/resources/scripts/download.js +++ b/resources/scripts/download.js @@ -12,13 +12,250 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -/* global console, setTimeout, clearTimeout, require */ +/* global console, process */ // @ts-check import fs from 'fs'; +import http from 'http'; import https from 'https'; +import tls from 'tls'; +import { URL } from 'url'; /** - * Downloads a file from a URL with redirect handling + * Check if the target URL should bypass proxy based on NO_PROXY env var. + * @param {string} targetUrl - The target URL + * @returns {boolean} True if proxy should be bypassed + */ +function shouldBypassProxy(targetUrl) { + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (!noProxy) return false; + + try { + const targetHost = new URL(targetUrl).hostname.toLowerCase(); + const noProxyList = noProxy.split(',').map((s) => s.trim().toLowerCase()); + + for (const pattern of noProxyList) { + if (!pattern) continue; + if (pattern === '*') return true; + if (targetHost === pattern) return true; + if (pattern.startsWith('.') && targetHost.endsWith(pattern)) return true; + if (targetHost.endsWith('.' + pattern)) return true; + } + } catch (error) { + console.warn(`Warning: Failed to parse NO_PROXY: ${error.message}`); + } + return false; +} + +/** + * Get proxy URL from environment variables. + * @param {string} targetUrl - The target URL to determine which proxy to use + * @returns {string | null} The proxy URL or null if not configured + */ +function getProxyUrl(targetUrl) { + // Check NO_PROXY first + if (shouldBypassProxy(targetUrl)) { + return null; + } + + const isHttps = targetUrl.startsWith('https://'); + + // Priority order for proxy env vars (check both uppercase and lowercase) + const envVars = isHttps + ? ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'] + : ['HTTP_PROXY', 'http_proxy']; + + for (const envVar of envVars) { + const value = process.env[envVar]; + if (value && value.trim()) { + return value.trim(); + } + } + + return null; +} + +/** + * Mask credentials in a proxy URL for safe logging. + * @param {string} url - The URL to mask + * @returns {string} The masked URL + */ +function maskProxyUrl(url) { + try { + const parsed = new URL(url); + if (parsed.username || parsed.password) { + parsed.username = parsed.username ? '***' : ''; + parsed.password = parsed.password ? '***' : ''; + return parsed.toString(); + } + } catch (error) { + // Not a valid URL, return as-is + console.warn(`Warning: Failed to parse proxy URL: ${error.message}`); + } + return url; +} + +/** + * Make an HTTP GET request with optional proxy support. + * For HTTPS URLs through HTTP proxy, uses CONNECT tunnel with TLS. + * @param {string} url - The URL to request + * @param {(response: http.IncomingMessage) => void} callback - Response callback + * @param {(error: Error) => void} onError - Error callback + */ +function makeRequest(url, callback, onError) { + const proxyUrl = getProxyUrl(url); + const isHttps = url.startsWith('https://'); + const targetUrl = new URL(url); + const targetPort = parseInt(targetUrl.port, 10) || (isHttps ? 443 : 80); + + if (!proxyUrl) { + // Direct connection (no proxy) + const httpModule = isHttps ? https : http; + const req = httpModule.get(url, callback); + req.on('error', onError); + return; + } + + console.log(`Using proxy: ${maskProxyUrl(proxyUrl)}`); + + const proxy = new URL(proxyUrl); + const proxyPort = parseInt(proxy.port, 10) || 80; + + // Build proxy auth header if credentials provided + const proxyAuthHeader = + proxy.username || proxy.password + ? { + 'Proxy-Authorization': `Basic ${Buffer.from( + `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent(proxy.password || '')}` + ).toString('base64')}`, + } + : {}; + + if (isHttps) { + // HTTPS through HTTP proxy: Use CONNECT tunnel + const connectReq = http.request({ + host: proxy.hostname, + port: proxyPort, + method: 'CONNECT', + path: `${targetUrl.hostname}:${targetPort}`, + headers: { + Host: `${targetUrl.hostname}:${targetPort}`, + ...proxyAuthHeader, + }, + }); + + // Track resources for cleanup + let tlsSocket = null; + let httpsReq = null; + + // Cleanup function to destroy all connections + const cleanup = () => { + if (httpsReq && !httpsReq.destroyed) { + httpsReq.destroy(); + } + if (tlsSocket && !tlsSocket.destroyed) { + tlsSocket.destroy(); + } + if (connectReq && !connectReq.destroyed) { + connectReq.destroy(); + } + }; + + connectReq.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + cleanup(); + onError( + new Error( + `Proxy CONNECT failed with status ${res.statusCode}: ${res.statusMessage}` + ) + ); + return; + } + + // Upgrade socket to TLS + tlsSocket = tls.connect( + { + host: targetUrl.hostname, + port: targetPort, + socket: socket, + servername: targetUrl.hostname, // SNI + }, + () => { + // Make HTTPS request over TLS socket + httpsReq = https.request( + { + hostname: targetUrl.hostname, + port: targetPort, + path: targetUrl.pathname + targetUrl.search, + method: 'GET', + headers: { Host: targetUrl.host }, + agent: false, + // Use createConnection to provide the pre-established TLS socket + createConnection: () => tlsSocket, + }, + (response) => { + // Cleanup connections when response ends + response.on('end', cleanup); + response.on('close', cleanup); + callback(response); + } + ); + + httpsReq.on('error', (err) => { + cleanup(); + onError(new Error(`HTTPS request error: ${err.message}`)); + }); + + httpsReq.end(); + } + ); + + tlsSocket.on('error', (err) => { + cleanup(); + onError(new Error(`TLS connection error: ${err.message}`)); + }); + }); + + connectReq.on('error', (err) => { + cleanup(); + onError(new Error(`Proxy connection error: ${err.message}`)); + }); + + connectReq.setTimeout(30000, () => { + cleanup(); + onError(new Error('Proxy connection timeout after 30 seconds')); + }); + + connectReq.end(); + } else { + // HTTP through HTTP proxy: Use proxy as target with full URL as path + const req = http.request( + { + host: proxy.hostname, + port: proxyPort, + path: url, // Full URL for HTTP proxy + method: 'GET', + headers: { + Host: targetUrl.host, + ...proxyAuthHeader, + }, + }, + callback + ); + req.on('error', (err) => { + onError(new Error(`HTTP proxy request error: ${err.message}`)); + }); + req.end(); + } +} + +/** + * Downloads a file from a URL with redirect handling and proxy support. + * Proxy is automatically detected from environment variables: + * - HTTPS_PROXY / https_proxy (for HTTPS URLs) + * - HTTP_PROXY / http_proxy (for HTTP URLs, or as fallback for HTTPS) + * - NO_PROXY / no_proxy (to bypass proxy for specific hosts) + * * @param {string} url The URL to download from * @param {string} destinationPath The path to save the file to * @returns {Promise} Promise that resolves when download is complete @@ -26,9 +263,7 @@ import https from 'https'; export async function downloadWithRedirects(url, destinationPath) { return new Promise((resolve, reject) => { const timeoutMs = 10 * 60 * 1000; // 10 minutes - const timeout = setTimeout(() => { - reject(new Error(`timeout(${timeoutMs / 1000} seconds)`)); - }, timeoutMs); + let timeoutId = null; // Use flag to prevent multiple resolve/reject calls let settled = false; @@ -36,7 +271,7 @@ export async function downloadWithRedirects(url, destinationPath) { const safeReject = (error) => { if (!settled) { settled = true; - clearTimeout(timeout); + if (timeoutId) clearTimeout(timeoutId); reject(error); } }; @@ -44,17 +279,19 @@ export async function downloadWithRedirects(url, destinationPath) { const safeResolve = () => { if (!settled) { settled = true; - clearTimeout(timeout); + if (timeoutId) clearTimeout(timeoutId); resolve(); } }; - const request = (url) => { - // Support both http and https - const httpModule = url.startsWith('https://') ? https : require('http'); + timeoutId = setTimeout(() => { + safeReject(new Error(`Download timeout after ${timeoutMs / 1000} seconds`)); + }, timeoutMs); - httpModule - .get(url, (response) => { + const request = (requestUrl) => { + makeRequest( + requestUrl, + (response) => { const statusCode = response.statusCode || 0; // Handle redirects (301, 302, 307, 308) @@ -63,15 +300,28 @@ export async function downloadWithRedirects(url, destinationPath) { statusCode <= 308 && response.headers.location ) { - const redirectUrl = response.headers.location; + let redirectUrl = response.headers.location; + + // Handle relative redirects + if (redirectUrl.startsWith('/')) { + try { + const originalUrl = new URL(requestUrl); + redirectUrl = `${originalUrl.protocol}//${originalUrl.host}${redirectUrl}`; + } catch (error) { + safeReject(new Error(`Failed to parse redirect URL: ${error.message}`)); + return; + } + } + console.log(`Following redirect to: ${redirectUrl}`); request(redirectUrl); return; } + if (statusCode !== 200) { safeReject( new Error( - `Download failed: ${statusCode} ${response.statusMessage || 'Unknown error'}` + `Download failed with status ${statusCode}: ${response.statusMessage || 'Unknown error'}` ) ); return; @@ -80,7 +330,8 @@ export async function downloadWithRedirects(url, destinationPath) { const file = fs.createWriteStream(destinationPath); let downloadedBytes = 0; const expectedBytes = parseInt( - response.headers['content-length'] || '0' + response.headers['content-length'] || '0', + 10 ); const startTime = Date.now(); let lastProgressTime = Date.now(); @@ -131,8 +382,10 @@ export async function downloadWithRedirects(url, destinationPath) { if (fs.existsSync(destinationPath)) { fs.unlinkSync(destinationPath); } - } catch (err) { - console.error('Failed to delete incomplete file:', err); + } catch (cleanupError) { + console.warn( + `Warning: Failed to delete incomplete file: ${cleanupError.message}` + ); } safeReject( new Error( @@ -155,9 +408,9 @@ export async function downloadWithRedirects(url, destinationPath) { } else { safeReject(new Error('Downloaded file does not exist')); } - } catch (err) { + } catch (verifyError) { safeReject( - new Error(`Failed to verify download: ${err.message}`) + new Error(`Failed to verify download: ${verifyError.message}`) ); } }); @@ -168,16 +421,18 @@ export async function downloadWithRedirects(url, destinationPath) { if (fs.existsSync(destinationPath)) { fs.unlinkSync(destinationPath); } - } catch (deleteErr) { - console.error('Failed to delete file after error:', deleteErr); + } catch (cleanupError) { + console.warn( + `Warning: Failed to delete file after error: ${cleanupError.message}` + ); } - safeReject(err); + safeReject(new Error(`File write error: ${err.message}`)); }); - }) - .on('error', (err) => { - safeReject(err); - }); + }, + safeReject + ); }; + request(url); }); } diff --git a/src/pages/Setting/General.tsx b/src/pages/Setting/General.tsx index 435133d41..02cd199c5 100644 --- a/src/pages/Setting/General.tsx +++ b/src/pages/Setting/General.tsx @@ -77,6 +77,7 @@ export default function SettingGeneral() { // Proxy configuration state const [proxyUrl, setProxyUrl] = useState(''); + const [loadedProxyUrl, setLoadedProxyUrl] = useState(''); // Track the initially loaded value const [isProxySaving, setIsProxySaving] = useState(false); const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false); @@ -158,16 +159,20 @@ export default function SettingGeneral() { ]; useEffect(() => { - // Load proxy configuration from global env + // Load proxy configuration from env (with priority system) const loadProxyConfig = async () => { if (window.electronAPI?.readGlobalEnv) { try { - const result = await window.electronAPI.readGlobalEnv('HTTP_PROXY'); - if (result?.value) { - setProxyUrl(result.value); - } + const result = + (await window.electronAPI.readGlobalEnv('HTTPS_PROXY')) || + (await window.electronAPI.readGlobalEnv('HTTP_PROXY')); + const value = result?.value || ''; + setProxyUrl(value); + setLoadedProxyUrl(value); // Remember the loaded value } catch (_error) { console.log('No proxy configured'); + setProxyUrl(''); + setLoadedProxyUrl(''); } } }; @@ -229,6 +234,9 @@ export default function SettingGeneral() { } }; + // Check if proxy value has changed from loaded value + const hasProxyChanged = proxyUrl.trim() !== loadedProxyUrl.trim(); + if (!chatStore) { return
Loading...
; } @@ -372,22 +380,24 @@ export default function SettingGeneral() { size="default" note={proxyNeedsRestart ? t('setting.proxy-restart-hint') : undefined} trailingButton={ - + proxyNeedsRestart ? ( + + ) : hasProxyChanged ? ( + + ) : undefined } />