diff --git a/lib/client.js b/lib/client.js index bd06d24..e3eca38 100644 --- a/lib/client.js +++ b/lib/client.js @@ -5,16 +5,17 @@ * link:/Desktop profiles can resolve it from the host graph without a local * `node_modules` copy of the legacy monolith SDK. * - * HTTP egress follows web-fetch-http: - * - When DSH installed a process proxy policy (`proxyRouteFor` → proxied), - * reuse that dispatcher so HTTPS_PROXY / system proxy is inherited. - * - Otherwise keep the DNS-pinned undici Agent (SSRF-safe direct dial). + * HTTP egress: + * 1. DSH policy (`proxyRouteFor`) when `@deepseek-ai/dsh-http-proxy` resolves. + * 2. Else env + undici global dispatcher (DSH boot installs proxy from HTTP_PROXY). + * 3. Else DNS-pinned direct dial (SSRF-safe). */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { WebError } from '@deepseek-ai/dsh-web'; import { Agent, fetch as undiciFetch } from 'undici'; import { clampSearchResults } from './catalog.js'; +import { resolveEgressRoute } from './proxy-env.js'; import { isNonPublicIpLiteral, parseHttpEndpoint, @@ -32,7 +33,7 @@ export async function callMcpSearch(server, key, args, signal) { let runtime; const client = new Client( - { name: 'dsh-search-mcp', version: '0.2.40' }, + { name: 'dsh-search-mcp', version: '0.2.41' }, { capabilities: {}, versionNegotiation: { mode: 'auto' } }, ); try { @@ -78,21 +79,7 @@ export async function callMcpSearch(server, key, args, signal) { } } -/** - * Ask DSH's process-wide proxy policy how to send this URL. - * Soft-import so missing peer does not kill plugin boot. - */ -async function resolveProxyRoute(url) { - try { - const mod = await import('@deepseek-ai/dsh-http-proxy'); - if (typeof mod.proxyRouteFor !== 'function') return { proxied: false }; - return mod.proxyRouteFor(url); - } catch { - return { proxied: false }; - } -} - -/** Build streamable-http transport: inherit proxy when installed, else DNS-pin. */ +/** Build streamable-http transport: proxy when policy/env says so, else DNS-pin. */ async function httpRuntime(server, key, signal) { const parsed = parseHttpEndpoint(server.url); const url = new URL(parsed.url); @@ -103,11 +90,9 @@ async function httpRuntime(server, key, signal) { else if (server.authStyle === 'header') headers[server.authParam] = value; } - // Proxied hops must not pin local DNS — that would dial the origin directly - // and bypass the proxy (same rule as web-fetch-http). - const route = await resolveProxyRoute(url); - if (route.proxied && !isNonPublicIpLiteral(url.hostname)) { - return buildTransport(url, headers, signal, route.dispatcher, async () => {}); + const route = await resolveEgressRoute(url, isNonPublicIpLiteral); + if (route.proxied) { + return buildTransport(url, headers, signal, route.dispatcher, route.close ?? (async () => {})); } const validated = await validateHttpEndpoint(server.url, { signal }); @@ -126,12 +111,13 @@ function buildTransport(url, headers, signal, dispatcher, close) { if (requestUrl.origin !== expectedOrigin) { throw new Error('search-mcp URL policy: request origin changed after validation'); } - return undiciFetch(input, { + const fetchOptions = { ...init, - dispatcher, redirect: 'error', ...(signal !== undefined ? { signal: combineSignals(signal, init.signal) } : {}), - }); + }; + if (dispatcher !== undefined) fetchOptions.dispatcher = dispatcher; + return undiciFetch(input, fetchOptions); }; return { diff --git a/lib/proxy-env.js b/lib/proxy-env.js new file mode 100644 index 0000000..7d9234b --- /dev/null +++ b/lib/proxy-env.js @@ -0,0 +1,108 @@ +/** + * Proxy resolution for search-mcp HTTP egress. + * + * Primary: DSH's installed policy (`proxyRouteFor`) when the module resolves. + * Fallback: process env + undici global dispatcher — covers link: installs where + * `@deepseek-ai/dsh-http-proxy` is not reachable from the plugin source tree + * even though DSH boot already installed proxy from the same env vars. + */ + +/** First non-empty proxy URL from standard env names. */ +export function readProxyEnv() { + for (const name of [ + 'HTTPS_PROXY', 'https_proxy', + 'HTTP_PROXY', 'http_proxy', + 'ALL_PROXY', 'all_proxy', + ]) { + const value = process.env[name]; + if (typeof value === 'string' && value.trim().length > 0) return value.trim(); + } + return undefined; +} + +/** Effective NO_PROXY list (may include CIDR entries we do not interpret). */ +export function readNoProxyEnv() { + return (process.env.NO_PROXY ?? process.env.no_proxy ?? '').trim(); +} + +/** + * Suffix / host bypass — same rules as dsh-http-proxy (no CIDR matching). + * @param {URL} url + * @returns {boolean} + */ +export function bypassesEnvNoProxy(url) { + const noProxy = readNoProxyEnv(); + if (noProxy.length === 0) return false; + const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase(); + const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'; + for (const raw of noProxy.split(/[,\s]+/)) { + const entry = raw.trim().toLowerCase(); + if (entry.length === 0) continue; + if (entry === '*') return true; + const colon = entry.lastIndexOf(':'); + let candidate = entry; + let entryPort; + if (colon > 0 && /^\d+$/.test(entry.slice(colon + 1))) { + entryPort = entry.slice(colon + 1); + candidate = entry.slice(0, colon); + } + if (entryPort !== undefined && entryPort !== port) continue; + candidate = candidate.replace(/^\*?\./, '').replace(/\.$/, ''); + if (candidate.length === 0) continue; + if (host === candidate || host.endsWith(`.${candidate}`)) return true; + } + return false; +} + +/** + * Whether this URL should use a proxy based on ambient env (fallback path). + * @param {URL} url + * @param {(host: string) => boolean} isNonPublicIpLiteral + */ +export function shouldUseProcessProxy(url, isNonPublicIpLiteral) { + if (isNonPublicIpLiteral(url.hostname)) return false; + if (readProxyEnv() === undefined) return false; + return !bypassesEnvNoProxy(url); +} + +/** + * Resolve egress: DSH policy module first, then env/global dispatcher fallback. + * @param {URL} url + * @param {(host: string) => boolean} isNonPublicIpLiteral + * @returns {Promise<{ proxied: boolean, dispatcher?: import('undici').Dispatcher, close?: () => Promise | void }>} + */ +export async function resolveEgressRoute(url, isNonPublicIpLiteral) { + if (isNonPublicIpLiteral(url.hostname)) { + return { proxied: false }; + } + + try { + const mod = await import('@deepseek-ai/dsh-http-proxy'); + if (typeof mod.proxyRouteFor === 'function') { + const route = mod.proxyRouteFor(url); + if (route.proxied && route.dispatcher) { + return { proxied: true, dispatcher: route.dispatcher, close: async () => {} }; + } + } + } catch { + // link: source trees often cannot resolve this peer from D:\code\gpt\... + } + + if (!shouldUseProcessProxy(url, isNonPublicIpLiteral)) { + return { proxied: false }; + } + + const { ProxyAgent, getGlobalDispatcher } = await import('undici'); + // Prefer the dispatcher DSH installed at boot from the same env vars. + try { + const global = getGlobalDispatcher(); + if (global) { + return { proxied: true, dispatcher: global, close: async () => {} }; + } + } catch { /* undici unavailable */ } + + const proxyUrl = readProxyEnv(); + if (proxyUrl === undefined) return { proxied: false }; + const agent = new ProxyAgent(proxyUrl); + return { proxied: true, dispatcher: agent, close: () => agent.close() }; +} diff --git a/package.json b/package.json index 99df1aa..63c8a1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-search-mcp", - "version": "0.2.40", + "version": "0.2.41", "description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.", "type": "module", "main": "lib/index.js", @@ -19,7 +19,7 @@ "node": ">=20" }, "scripts": { - "check": "node --check lib/index.js && node --check lib/provider.js && node --check lib/catalog.js && node --check lib/extract.js && node --check lib/url-policy.js && node --check lib/client.js && node --check lib/client.browser.js", + "check": "node --check lib/index.js && node --check lib/provider.js && node --check lib/catalog.js && node --check lib/extract.js && node --check lib/url-policy.js && node --check lib/proxy-env.js && node --check lib/client.js && node --check lib/client.browser.js", "test": "node --test" }, "keywords": [ diff --git a/test/compatibility.test.js b/test/compatibility.test.js index 2304c2a..347914a 100644 --- a/test/compatibility.test.js +++ b/test/compatibility.test.js @@ -12,7 +12,7 @@ test('package exports resolve and peerDependencies stay open', async () => { assert.equal(pkg.exports['.'], './lib/index.js') assert.equal(pkg.exports['./client'], './lib/client.browser.js') assert.equal(pkg.engines.node, '>=20') - assert.equal(pkg.version, '0.2.40') + assert.equal(pkg.version, '0.2.41') assert.equal(pkg.dsh.client.immediately, false) assert.equal(pkg.dependencies['@modelcontextprotocol/client'], '2.0.0') assert.ok(!Object.hasOwn(pkg.dependencies, '@modelcontextprotocol/sdk')) @@ -86,16 +86,18 @@ test('known providers are CDKey-only while custom keeps advanced fields', async assert.match(client, /已知提供商不需要填写端点链接/) }) -test('HTTP transport inherits DSH proxy and pins DNS on the direct path', async () => { +test('HTTP transport inherits DSH proxy with env fallback and pins DNS on direct path', async () => { const transport = await read('lib/client.js') + const proxyEnv = await read('lib/proxy-env.js') assert.match(transport, /from '@modelcontextprotocol\/client'/) assert.match(transport, /from '@modelcontextprotocol\/client\/stdio'/) assert.doesNotMatch(transport, /from ['"]@modelcontextprotocol\/sdk/) - assert.match(transport, /@deepseek-ai\/dsh-http-proxy/) - assert.match(transport, /proxyRouteFor/) + assert.match(transport, /resolveEgressRoute/) + assert.match(proxyEnv, /proxyRouteFor/) + assert.match(proxyEnv, /readProxyEnv/) + assert.match(proxyEnv, /getGlobalDispatcher/) assert.match(transport, /parseHttpEndpoint/) assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/) - assert.match(transport, /dispatcher/) assert.match(transport, /redirect: 'error'/) assert.match(transport, /versionNegotiation:\s*\{\s*mode:\s*['"]auto['"]/) }) diff --git a/test/proxy-env.test.js b/test/proxy-env.test.js new file mode 100644 index 0000000..9b9083d --- /dev/null +++ b/test/proxy-env.test.js @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + bypassesEnvNoProxy, + readProxyEnv, + shouldUseProcessProxy, +} from '../lib/proxy-env.js'; + +const nonPublic = (host) => host === '127.0.0.1' || host === '10.0.0.1'; + +test('readProxyEnv picks HTTPS_PROXY over HTTP_PROXY', () => { + const prev = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + HTTP_PROXY: process.env.HTTP_PROXY, + }; + process.env.HTTPS_PROXY = 'http://proxy.example:8080'; + process.env.HTTP_PROXY = 'http://other:8080'; + try { + assert.equal(readProxyEnv(), 'http://proxy.example:8080'); + } finally { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test('bypassesEnvNoProxy matches suffix entries', () => { + const prev = process.env.NO_PROXY; + process.env.NO_PROXY = 'localhost,.zte.com.cn'; + try { + assert.equal(bypassesEnvNoProxy(new URL('https://api.zte.com.cn/v1')), true); + assert.equal(bypassesEnvNoProxy(new URL('https://dashscope.aliyuncs.com/mcp')), false); + } finally { + if (prev === undefined) delete process.env.NO_PROXY; + else process.env.NO_PROXY = prev; + } +}); + +test('shouldUseProcessProxy respects NO_PROXY bypass', () => { + const prev = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + NO_PROXY: process.env.NO_PROXY, + }; + process.env.HTTPS_PROXY = 'http://proxy.zte.com.cn:80'; + process.env.NO_PROXY = '.zte.com.cn'; + try { + assert.equal( + shouldUseProcessProxy(new URL('https://dashscope.aliyuncs.com/mcp'), nonPublic), + true, + ); + assert.equal( + shouldUseProcessProxy(new URL('https://llm.zte.com.cn/v1'), nonPublic), + false, + ); + assert.equal( + shouldUseProcessProxy(new URL('https://127.0.0.1/mcp'), nonPublic), + false, + ); + } finally { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +});