Fallback to env proxy when dsh-http-proxy cannot resolve on link installs.

Use process HTTP_PROXY/HTTPS_PROXY and undici global dispatcher when the policy module import fails, so symlinked desktop plugins still egress through the corporate proxy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-10 14:24:33 +08:00
parent 26a8c2ecee
commit 4b4a1e4460
5 changed files with 197 additions and 35 deletions

View file

@ -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 {