From 7a9ee09f49a74918ea9035fb5a3ace8d682be8cb Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 10 Jul 2026 18:41:04 +0800 Subject: [PATCH] fix(core): honor NO_PROXY for model requests (#6640) --- docs/users/integration-vscode.md | 4 +- package-lock.json | 24 +-- packages/cli/package.json | 2 +- packages/cli/src/utils/apiPreconnect.ts | 2 +- packages/core/package.json | 2 +- .../runtime-fetch-options.no-proxy.test.ts | 155 ++++++++++++++++++ .../src/utils/runtimeFetchOptions.test.ts | 31 ++-- .../core/src/utils/runtimeFetchOptions.ts | 24 +-- packages/vscode-ide-companion/package.json | 4 +- 9 files changed, 204 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts diff --git a/docs/users/integration-vscode.md b/docs/users/integration-vscode.md index eb08b667858..5c1468fd2de 100644 --- a/docs/users/integration-vscode.md +++ b/docs/users/integration-vscode.md @@ -18,7 +18,7 @@ ### Requirements -- VS Code 1.85.0 or higher +- VS Code 1.96.0 or higher ### Installation @@ -28,7 +28,7 @@ Download and install the extension from the [Visual Studio Code Extension Market ### Extension not installing -- Ensure you have VS Code 1.85.0 or higher +- Ensure you have VS Code 1.96.0 or higher - Check that VS Code has permission to install extensions - Try installing directly from the Marketplace website diff --git a/package-lock.json b/package-lock.json index a02e6d82f2e..b57fad68398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24215,7 +24215,7 @@ "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.19", - "undici": "^6.27.0", + "undici": "^7.28.0", "update-notifier": "^7.3.1", "wrap-ansi": "^10.0.0", "ws": "^8.18.0", @@ -24362,12 +24362,12 @@ } }, "packages/cli/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "packages/cli/node_modules/wrap-ansi": { @@ -24466,7 +24466,7 @@ "simple-git": "^3.36.0", "strip-ansi": "^7.1.0", "tar": "^7.5.19", - "undici": "^6.27.0", + "undici": "^7.28.0", "uuid": "^9.0.1", "web-tree-sitter": "^0.24.7", "ws": "^8.18.0", @@ -24580,12 +24580,12 @@ } }, "packages/core/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { - "node": ">=18.17" + "node": ">=20.18.1" } }, "packages/mobile-mcp": { @@ -27317,7 +27317,7 @@ "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.96.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.9.2", @@ -27332,7 +27332,7 @@ "vitest": "^3.2.4" }, "engines": { - "vscode": "^1.85.0" + "vscode": "^1.96.0" } }, "packages/vscode-ide-companion/node_modules/@types/node": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 31855e4e0be..e4a8997060a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,7 +83,7 @@ "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.19", - "undici": "^6.27.0", + "undici": "^7.28.0", "update-notifier": "^7.3.1", "wrap-ansi": "^10.0.0", "ws": "^8.18.0", diff --git a/packages/cli/src/utils/apiPreconnect.ts b/packages/cli/src/utils/apiPreconnect.ts index 4baff09bd54..ebeb3c595ca 100644 --- a/packages/cli/src/utils/apiPreconnect.ts +++ b/packages/cli/src/utils/apiPreconnect.ts @@ -197,7 +197,7 @@ export function preconnectApi( // Fire HEAD request to warm connection (fire-and-forget). // Use undici's own fetch (not Node's built-in fetch) so the dispatcher // and fetch come from the same undici version — Node's bundled undici - // may differ in major version from the bundled one (e.g. v8 vs v6), + // may differ in major version from the packaged one (e.g. v8 vs v7), // causing handler-interface mismatches like `invalid onError method`. undiciFetch(targetUrl, { method: 'HEAD', diff --git a/packages/core/package.json b/packages/core/package.json index 0d35a4eb313..17012e6442b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -69,7 +69,7 @@ "simple-git": "^3.36.0", "strip-ansi": "^7.1.0", "tar": "^7.5.19", - "undici": "^6.27.0", + "undici": "^7.28.0", "uuid": "^9.0.1", "web-tree-sitter": "^0.24.7", "ws": "^8.18.0", diff --git a/packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts b/packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts new file mode 100644 index 00000000000..b6805550d2c --- /dev/null +++ b/packages/core/src/utils/runtime-fetch-options.no-proxy.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import { connect as netConnect, type AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetch as undiciFetch, type Dispatcher } from 'undici'; +import { + getOrCreateSharedDispatcher, + resetDispatcherCache, +} from './runtimeFetchOptions.js'; + +describe('shared proxy dispatcher NO_PROXY behavior', () => { + const savedEnv = { + NO_PROXY: process.env['NO_PROXY'], + no_proxy: process.env['no_proxy'], + }; + + beforeEach(() => { + delete process.env['NO_PROXY']; + delete process.env['no_proxy']; + resetDispatcherCache(); + vi.spyOn(process, 'emitWarning').mockImplementation(() => {}); + }); + + afterEach(() => { + if (savedEnv.NO_PROXY === undefined) { + delete process.env['NO_PROXY']; + } else { + process.env['NO_PROXY'] = savedEnv.NO_PROXY; + } + if (savedEnv.no_proxy === undefined) { + delete process.env['no_proxy']; + } else { + process.env['no_proxy'] = savedEnv.no_proxy; + } + resetDispatcherCache(); + vi.restoreAllMocks(); + }); + + it.each(['NO_PROXY', 'no_proxy'] as const)( + 'bypasses the explicit proxy when %s matches the target', + async (noProxyKey) => { + let originRequests = 0; + let proxyRequests = 0; + const origin = createServer((_request, response) => { + originRequests += 1; + response.end('direct'); + }); + const proxy = createServer((_request, response) => { + proxyRequests += 1; + response.writeHead(502).end(); + }); + proxy.on('connect', (_request, socket) => { + proxyRequests += 1; + socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'); + }); + + origin.listen(0, '127.0.0.1'); + proxy.listen(0, '127.0.0.1'); + await Promise.all([once(origin, 'listening'), once(proxy, 'listening')]); + + const originPort = (origin.address() as AddressInfo).port; + const proxyPort = (proxy.address() as AddressInfo).port; + process.env[noProxyKey] = '127.0.0.1'; + let dispatcher: Dispatcher | undefined; + + try { + dispatcher = getOrCreateSharedDispatcher( + `http://127.0.0.1:${proxyPort}`, + ); + const response = await undiciFetch( + `http://127.0.0.1:${originPort}/v1/chat/completions`, + { + dispatcher, + signal: AbortSignal.timeout(2_000), + }, + ); + + expect(await response.text()).toBe('direct'); + expect(originRequests).toBe(1); + expect(proxyRequests).toBe(0); + } finally { + await dispatcher?.close(); + const closed = Promise.all([ + once(origin, 'close'), + once(proxy, 'close'), + ]); + origin.close(); + proxy.close(); + await closed; + } + }, + ); + + it('still routes through the proxy when NO_PROXY does not match the target', async () => { + let originRequests = 0; + const proxyConnects: string[] = []; + const origin = createServer((_request, response) => { + originRequests += 1; + response.end('via-proxy'); + }); + const proxy = createServer((_request, response) => { + response.writeHead(405).end(); + }); + proxy.on('connect', (request, clientSocket, head) => { + proxyConnects.push(request.url ?? ''); + const [host, port] = (request.url ?? '').split(':'); + const upstream = netConnect(Number(port), host, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + upstream.write(head); + upstream.pipe(clientSocket); + clientSocket.pipe(upstream); + }); + upstream.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => upstream.destroy()); + clientSocket.on('close', () => upstream.destroy()); + upstream.on('close', () => clientSocket.destroy()); + }); + + origin.listen(0, '127.0.0.1'); + proxy.listen(0, '127.0.0.1'); + await Promise.all([once(origin, 'listening'), once(proxy, 'listening')]); + + const originPort = (origin.address() as AddressInfo).port; + const proxyPort = (proxy.address() as AddressInfo).port; + process.env['NO_PROXY'] = 'example.com'; + let dispatcher: Dispatcher | undefined; + + try { + dispatcher = getOrCreateSharedDispatcher(`http://127.0.0.1:${proxyPort}`); + const response = await undiciFetch( + `http://127.0.0.1:${originPort}/v1/chat/completions`, + { + dispatcher, + signal: AbortSignal.timeout(2_000), + }, + ); + + expect(await response.text()).toBe('via-proxy'); + expect(originRequests).toBe(1); + expect(proxyConnects).toEqual([`127.0.0.1:${originPort}`]); + } finally { + await dispatcher?.close(); + const closed = Promise.all([once(origin, 'close'), once(proxy, 'close')]); + origin.close(); + proxy.close(); + await closed; + } + }); +}); diff --git a/packages/core/src/utils/runtimeFetchOptions.test.ts b/packages/core/src/utils/runtimeFetchOptions.test.ts index 158486eed3a..f872a5c7bdd 100644 --- a/packages/core/src/utils/runtimeFetchOptions.test.ts +++ b/packages/core/src/utils/runtimeFetchOptions.test.ts @@ -34,15 +34,14 @@ vi.mock('undici', () => { } } - class MockProxyAgent { + class MockEnvHttpProxyAgent { options: UndiciOptions; - uri: string; constructor(options: UndiciOptions) { this.options = options; - this.uri = (options as { uri?: string }).uri || ''; + const httpProxy = (options as { httpProxy?: string }).httpProxy || ''; // Simulate failure for specifically invalid proxy URLs - // Note: Real ProxyAgent accepts credential URLs — only syntactically invalid URIs fail - if (this.uri === 'http://invalid-proxy') { + // Note: Real EnvHttpProxyAgent accepts credential URLs — only syntactically invalid URIs fail + if (httpProxy === 'http://invalid-proxy') { throw new Error('Invalid proxy URL: http://user:secret@proxy.local'); } } @@ -50,7 +49,7 @@ vi.mock('undici', () => { return { Agent: MockAgent, - ProxyAgent: MockProxyAgent, + EnvHttpProxyAgent: MockEnvHttpProxyAgent, fetch: mockUndiciFetch, }; }); @@ -108,7 +107,7 @@ describe('buildRuntimeFetchOptions (node runtime)', () => { expect((result as { fetch?: unknown }).fetch).toBe(mockUndiciFetch); }); - it('uses ProxyAgent with disabled timeouts when proxy is set', () => { + it('uses EnvHttpProxyAgent with disabled timeouts when proxy is set', () => { const result = buildRuntimeFetchOptions('openai', 'http://proxy.local'); expect(result).toBeDefined(); @@ -118,13 +117,14 @@ describe('buildRuntimeFetchOptions (node runtime)', () => { result as { fetchOptions?: { dispatcher?: { options?: UndiciOptions } } } ).fetchOptions?.dispatcher; expect(dispatcher?.options).toMatchObject({ - uri: 'http://proxy.local', + httpProxy: 'http://proxy.local', + httpsProxy: 'http://proxy.local', headersTimeout: 0, bodyTimeout: 0, }); }); - it('returns fetchOptions with ProxyAgent for Anthropic with proxy', () => { + it('returns fetchOptions with EnvHttpProxyAgent for Anthropic with proxy', () => { const result = buildRuntimeFetchOptions('anthropic', 'http://proxy.local'); expect(result).toBeDefined(); @@ -134,7 +134,8 @@ describe('buildRuntimeFetchOptions (node runtime)', () => { result as { fetchOptions?: { dispatcher?: { options?: UndiciOptions } } } ).fetchOptions?.dispatcher; expect(dispatcher?.options).toMatchObject({ - uri: 'http://proxy.local', + httpProxy: 'http://proxy.local', + httpsProxy: 'http://proxy.local', headersTimeout: 0, bodyTimeout: 0, }); @@ -142,7 +143,7 @@ describe('buildRuntimeFetchOptions (node runtime)', () => { it('pins fetch to undici when proxy is set so dispatcher and fetch share a version', () => { // Regression for `invalid onError method`: Node's built-in fetch (newer - // undici) cannot accept a ProxyAgent built from a different undici major. + // undici) cannot accept a dispatcher built from a different undici major. // The function must hand back the bundled undici's fetch alongside the // dispatcher. const openaiResult = buildRuntimeFetchOptions( @@ -281,7 +282,7 @@ describe('getOrCreateSharedDispatcher', () => { expect(d1).not.toBe(d2); }); - it('shares the same ProxyAgent dispatcher with buildRuntimeFetchOptions when proxy is set', () => { + it('shares the same EnvHttpProxyAgent dispatcher with buildRuntimeFetchOptions when proxy is set', () => { const shared = getOrCreateSharedDispatcher('http://proxy.local'); const result = buildRuntimeFetchOptions('openai', 'http://proxy.local'); const sdkDispatcher = ( @@ -347,7 +348,7 @@ describe('TLS verification opt-out (insecure)', () => { expect(options?.['connect']).toBeUndefined(); }); - it('uses requestTls/proxyTls (not connect) for the ProxyAgent', () => { + it('configures TLS opt-out for direct and proxied requests', () => { process.env['QWEN_TLS_INSECURE'] = '1'; const dispatcher = getOrCreateSharedDispatcher( 'http://proxy.local', @@ -358,7 +359,9 @@ describe('TLS verification opt-out (insecure)', () => { expect(dispatcher.options['proxyTls']).toEqual({ rejectUnauthorized: false, }); - expect(dispatcher.options['connect']).toBeUndefined(); + expect(dispatcher.options['connect']).toEqual({ + rejectUnauthorized: false, + }); }); it('keeps secure and insecure dispatchers in separate cache entries', () => { diff --git a/packages/core/src/utils/runtimeFetchOptions.ts b/packages/core/src/utils/runtimeFetchOptions.ts index 39427e70a6e..b88482881b6 100644 --- a/packages/core/src/utils/runtimeFetchOptions.ts +++ b/packages/core/src/utils/runtimeFetchOptions.ts @@ -6,7 +6,7 @@ import { Agent, - ProxyAgent, + EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher, } from 'undici'; @@ -73,8 +73,8 @@ export type OpenAIRuntimeFetchOptions = // Optional fetch override. When a custom dispatcher is being passed, // we pin this to the bundled undici's fetch so the dispatcher and // fetch share a single undici version — otherwise Node's built-in - // fetch (newer undici) rejects a ProxyAgent from the bundled undici - // (e.g. v6) with `invalid onError method`. + // fetch (newer undici) rejects a dispatcher from the bundled undici + // with errors such as `invalid onError method`. // eslint-disable-next-line @typescript-eslint/no-explicit-any fetch?: any; } @@ -203,8 +203,8 @@ const NO_DISPATCHER_FALLBACK = { * The dispatcher is cached so that preconnect and subsequent SDK requests * share the same connection pool, enabling TCP+TLS connection reuse. * - * @param proxyUrl - Proxy URL used to create a cached ProxyAgent - * @returns A cached undici ProxyAgent dispatcher + * @param proxyUrl - Proxy URL used to create a cached proxy dispatcher + * @returns A cached undici dispatcher that honors NO_PROXY */ export function getOrCreateSharedDispatcher( proxyUrl: string, @@ -219,16 +219,18 @@ export function getOrCreateSharedDispatcher( return cached; } - const dispatcher = new ProxyAgent({ - uri: proxyUrl, + const dispatcher = new EnvHttpProxyAgent({ + httpProxy: proxyUrl, + httpsProxy: proxyUrl, headersTimeout: 0, bodyTimeout: 0, keepAliveTimeout: 60_000, - // For a ProxyAgent the upstream (origin) TLS handshake is governed by - // `requestTls`, not `connect`; `proxyTls` covers an HTTPS proxy whose own - // certificate is self-signed. Disable verification on both when opted in. + // EnvHttpProxyAgent can dispatch either directly or through a proxy. + // `connect` covers a direct NO_PROXY connection, `requestTls` covers the + // origin through a proxy, and `proxyTls` covers an HTTPS proxy itself. ...(insecure ? { + connect: { rejectUnauthorized: false }, requestTls: { rejectUnauthorized: false }, proxyTls: { rejectUnauthorized: false }, } @@ -668,7 +670,7 @@ function buildFetchOptionsWithDispatcher( const dispatcher = getOrCreateSharedDispatcher(proxyUrl, insecure); // Pin fetch to undici's own implementation so the dispatcher and fetch // come from the same undici version. Node's bundled undici may differ in - // major version from the project's bundled one (e.g. v8 vs v6), which + // major version from the packaged one (e.g. v8 vs v7), which // breaks dispatcher handler-interface checks (`invalid onError method`). // The no-proxy branch above also pins undiciFetch for consistency. return { fetchOptions: { dispatcher }, fetch: undiciFetch }; diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 421bbe38703..7ba07267178 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -11,7 +11,7 @@ "directory": "packages/vscode-ide-companion" }, "engines": { - "vscode": "^1.85.0" + "vscode": "^1.96.0" }, "license": "LICENSE", "preview": true, @@ -275,7 +275,7 @@ "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", - "@types/vscode": "^1.85.0", + "@types/vscode": "^1.96.0", "@typescript-eslint/eslint-plugin": "^8.31.1", "@typescript-eslint/parser": "^8.31.1", "@vscode/vsce": "^3.9.2",