diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c2d131991..3589948e9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -108,6 +108,7 @@ import { shouldDefaultToNodePty } from '../utils/shell-utils.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { type ToolName } from '../utils/tool-utils.js'; import { getErrorMessage } from '../utils/errors.js'; +import { normalizeProxyUrl } from '../utils/proxyUtils.js'; // Local config modules import type { FileFilteringOptions } from './constants.js'; @@ -747,8 +748,9 @@ export class Config { initializeTelemetry(this); } - if (this.getProxy()) { - setGlobalDispatcher(new ProxyAgent(this.getProxy() as string)); + const proxyUrl = this.getProxy(); + if (proxyUrl) { + setGlobalDispatcher(new ProxyAgent(proxyUrl)); } this.geminiClient = new GeminiClient(this); this.chatRecordingService = this.chatRecordingEnabled @@ -1717,7 +1719,7 @@ export class Config { } getProxy(): string | undefined { - return this.proxy; + return normalizeProxyUrl(this.proxy); } getWorkingDir(): string { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 66359a865..83ab203ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -217,6 +217,7 @@ export * from './utils/pathReader.js'; export * from './utils/paths.js'; export * from './utils/projectSummary.js'; export * from './utils/promptIdContext.js'; +export * from './utils/proxyUtils.js'; export * from './utils/quotaErrorDetection.js'; export * from './utils/readManyFiles.js'; export * from './utils/request-tokenizer/supportedImageFormats.js'; diff --git a/packages/core/src/utils/proxyUtils.test.ts b/packages/core/src/utils/proxyUtils.test.ts new file mode 100644 index 000000000..4f971aec5 --- /dev/null +++ b/packages/core/src/utils/proxyUtils.test.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeProxyUrl } from './proxyUtils.js'; + +describe('normalizeProxyUrl', () => { + it('should return undefined for undefined input', () => { + expect(normalizeProxyUrl(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(normalizeProxyUrl('')).toBeUndefined(); + }); + + it('should return undefined for whitespace-only string', () => { + expect(normalizeProxyUrl(' ')).toBeUndefined(); + }); + + it('should add http:// prefix to proxy URL without protocol', () => { + expect(normalizeProxyUrl('127.0.0.1:7860')).toBe('http://127.0.0.1:7860'); + }); + + it('should add http:// prefix to proxy URL with port only', () => { + expect(normalizeProxyUrl('localhost:8080')).toBe('http://localhost:8080'); + }); + + it('should not modify URL that already has http:// prefix', () => { + expect(normalizeProxyUrl('http://127.0.0.1:7860')).toBe( + 'http://127.0.0.1:7860', + ); + }); + + it('should not modify URL that already has https:// prefix', () => { + expect(normalizeProxyUrl('https://proxy.example.com:443')).toBe( + 'https://proxy.example.com:443', + ); + }); + + it('should handle HTTP:// prefix (case insensitive)', () => { + expect(normalizeProxyUrl('HTTP://127.0.0.1:7860')).toBe( + 'HTTP://127.0.0.1:7860', + ); + }); + + it('should handle HTTPS:// prefix (case insensitive)', () => { + expect(normalizeProxyUrl('HTTPS://proxy.example.com:443')).toBe( + 'HTTPS://proxy.example.com:443', + ); + }); + + it('should handle proxy URL with authentication', () => { + expect(normalizeProxyUrl('user:pass@proxy.example.com:8080')).toBe( + 'http://user:pass@proxy.example.com:8080', + ); + }); + + it('should handle proxy URL with authentication and http:// prefix', () => { + expect(normalizeProxyUrl('http://user:pass@proxy.example.com:8080')).toBe( + 'http://user:pass@proxy.example.com:8080', + ); + }); + + it('should trim whitespace from proxy URL', () => { + expect(normalizeProxyUrl(' 127.0.0.1:7860 ')).toBe( + 'http://127.0.0.1:7860', + ); + }); + + it('should handle IPv6 addresses', () => { + expect(normalizeProxyUrl('[::1]:8080')).toBe('http://[::1]:8080'); + }); + + it('should handle IPv6 addresses with http:// prefix', () => { + expect(normalizeProxyUrl('http://[::1]:8080')).toBe('http://[::1]:8080'); + }); + + // SOCKS proxy tests - should throw error since undici doesn't support SOCKS + it('should throw error for socks:// proxy URL', () => { + expect(() => normalizeProxyUrl('socks://proxy.example.com:1080')).toThrow( + 'SOCKS proxy is not supported', + ); + }); + + it('should throw error for socks4:// proxy URL', () => { + expect(() => normalizeProxyUrl('socks4://proxy.example.com:1080')).toThrow( + 'SOCKS proxy is not supported', + ); + }); + + it('should throw error for socks5:// proxy URL', () => { + expect(() => normalizeProxyUrl('socks5://proxy.example.com:1080')).toThrow( + 'SOCKS proxy is not supported', + ); + }); + + it('should throw error for SOCKS5:// proxy URL (case insensitive)', () => { + expect(() => normalizeProxyUrl('SOCKS5://proxy.example.com:1080')).toThrow( + 'SOCKS proxy is not supported', + ); + }); +}); diff --git a/packages/core/src/utils/proxyUtils.ts b/packages/core/src/utils/proxyUtils.ts new file mode 100644 index 000000000..b989494c5 --- /dev/null +++ b/packages/core/src/utils/proxyUtils.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Normalizes a proxy URL to ensure it has a valid protocol prefix. + * + * Many proxy tools and environment variables provide proxy addresses without + * a protocol prefix (e.g., "127.0.0.1:7860" instead of "http://127.0.0.1:7860"). + * This function adds the "http://" prefix if missing, since HTTP proxies are + * the most common default. + * + * Note: Only HTTP and HTTPS proxies are supported. SOCKS proxies (socks://, + * socks4://, socks5://) are NOT supported because the underlying undici library + * does not support them. See: https://github.com/nodejs/undici/issues/2224 + * + * @param proxyUrl - The proxy URL to normalize + * @returns The normalized proxy URL with protocol prefix, or undefined if input is undefined/empty + * @throws Error if a SOCKS proxy URL is provided + */ +export function normalizeProxyUrl( + proxyUrl: string | undefined, +): string | undefined { + if (!proxyUrl) { + return undefined; + } + + const trimmed = proxyUrl.trim(); + if (!trimmed) { + return undefined; + } + + // Check if the URL already has a protocol prefix + // Only support http and https protocols (undici limitation) + if (/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + + // Reject SOCKS proxies - undici does not support them + if (/^socks[45]?:\/\//i.test(trimmed)) { + throw new Error( + `SOCKS proxy is not supported. The underlying HTTP client (undici) only supports HTTP and HTTPS proxies. ` + + `Please use an HTTP/HTTPS proxy instead, or set up a SOCKS-to-HTTP proxy converter. ` + + `See: https://github.com/nodejs/undici/issues/2224`, + ); + } + + // Add http:// prefix for proxy URLs without protocol + // HTTP is the default for most proxy configurations + return `http://${trimmed}`; +}