comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
313 lines • 12.2 kB
JavaScript
import { execFile } from "node:child_process";
import { isIP } from "node:net";
import { platform } from "node:os";
import { promisify } from "node:util";
import { EnvHttpProxyAgent, ProxyAgent } from "undici";
import { assertHostResolvesSafe, isBlockedHost } from "./workflow-url.js";
import { logger } from "../utils/logger.js";
const execFileAsync = promisify(execFile);
const WINDOWS_INTERNET_SETTINGS = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
const WINDOWS_PROXY_QUERY_TIMEOUT_MS = 1_000;
const PROXY_DNS_CHECK_TIMEOUT_MS = 1_000;
function nonEmpty(value) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function envValue(lower, upper) {
// This is the precedence EnvHttpProxyAgent documents: lowercase wins when
// both spellings are present.
return nonEmpty(process.env[lower]) ?? nonEmpty(process.env[upper]);
}
function urlForInput(input) {
try {
if (typeof input === "string" || input instanceof URL)
return new URL(String(input));
if (input && typeof input === "object" && "url" in input) {
const value = input.url;
return typeof value === "string" ? new URL(value) : undefined;
}
}
catch {
// Let fetch produce the canonical invalid-URL error. Invalid input is never
// given a proxy dispatcher here.
}
return undefined;
}
function normalizedHost(hostname) {
return hostname.replace(/^\[|\]$/g, "").toLowerCase().replace(/\.$/, "");
}
/** Internal and non-public targets are always direct. A local ComfyUI API must
* not inherit a model-download proxy merely because the two requests share this
* process, and a corporate proxy must not become an SSRF path to private hosts. */
export function isDirectLocalHost(hostname) {
return isBlockedHost(normalizedHost(hostname));
}
/** Only a target proven public may use a configured proxy. A DNS failure is
* deliberately direct/fail-closed: the proxy must never be asked to resolve an
* unknown hostname on our behalf. Every redirect hop calls this again through
* the download fetch seam. */
async function shouldBypassProxy(url) {
const host = normalizedHost(url.hostname);
if (isDirectLocalHost(host))
return true;
if (isIP(host) !== 0)
return false;
try {
await assertHostResolvesSafe(host, PROXY_DNS_CHECK_TIMEOUT_MS);
return false;
}
catch (error) {
logger.debug("Could not prove that a model-download host is public; using a direct route.", {
host,
error: error instanceof Error ? error.message : String(error),
});
return true;
}
}
function noProxyHostAndPort(value) {
const trimmed = value.trim().toLowerCase();
if (trimmed.startsWith("[")) {
const close = trimmed.indexOf("]");
if (close >= 0) {
return { host: trimmed.slice(1, close), port: trimmed.slice(close + 1).replace(/^:/, "") || undefined };
}
}
const colon = trimmed.lastIndexOf(":");
if (colon > 0 && /^\d+$/.test(trimmed.slice(colon + 1))) {
return { host: trimmed.slice(0, colon), port: trimmed.slice(colon + 1) };
}
return { host: trimmed };
}
function matchesNoProxy(url, value) {
if (!value)
return false;
const host = normalizedHost(url.hostname);
const port = url.port || (url.protocol === "https:" ? "443" : "80");
for (const rawEntry of value.split(/[;,\s]+/)) {
const entry = rawEntry.trim().toLowerCase();
if (!entry)
continue;
if (entry === "*")
return true;
if (entry === "<local>") {
if (!host.includes("."))
return true;
continue;
}
if (entry.startsWith("<") && entry.endsWith(">"))
continue;
const parsed = noProxyHostAndPort(entry.replace(/^\.+/, ""));
if (parsed.port && parsed.port !== port)
continue;
const pattern = parsed.host.replace(/^\*\./, "");
if (host === pattern || host.endsWith(`.${pattern}`))
return true;
}
return false;
}
function normalizeProxyUrl(raw) {
const value = raw.trim();
const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `http://${value}`;
const parsed = new URL(candidate);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`unsupported proxy protocol ${parsed.protocol}`);
}
return parsed.toString();
}
export function parseProxyServer(raw) {
const out = {};
for (const part of (raw ?? "").split(";")) {
const trimmed = part.trim();
if (!trimmed)
continue;
const equals = trimmed.indexOf("=");
const scheme = equals < 0 ? undefined : trimmed.slice(0, equals).trim().toLowerCase();
const endpoint = (equals < 0 ? trimmed : trimmed.slice(equals + 1)).trim();
if (!endpoint || (scheme && scheme !== "http" && scheme !== "https"))
continue;
let normalized;
try {
normalized = normalizeProxyUrl(endpoint);
}
catch {
continue;
}
if (!scheme || scheme === "http")
out.http = normalized;
if (!scheme || scheme === "https")
out.https = normalized;
}
return out;
}
async function proxyChoiceForStatic(url, agents, noProxy) {
if (matchesNoProxy(url, noProxy) || (await shouldBypassProxy(url)))
return { route: "direct" };
const dispatcher = url.protocol === "https:" ? agents.https : agents.http;
return dispatcher ? { route: "proxied", dispatcher } : { route: "direct" };
}
function parseRegistryProxy(stdout) {
const values = { proxyEnable: false };
for (const line of stdout.split(/\r?\n/)) {
const match = /^\s*(ProxyEnable|ProxyServer|ProxyOverride|AutoConfigURL|AutoDetect)\s+REG_\w+\s+(.+?)\s*$/i.exec(line);
if (!match)
continue;
const key = match[1].toLowerCase();
const value = match[2].trim();
if (key === "proxyenable")
values.proxyEnable = /^0x0$|^0$/i.test(value) ? false : /^0x1$|^1$/i.test(value);
else if (key === "proxyserver")
values.proxyServer = value;
else if (key === "proxyoverride")
values.proxyOverride = value;
else if (key === "autoconfigurl")
values.autoConfigUrl = value;
else if (key === "autodetect")
values.autoDetect = /^0x1$|^1$/i.test(value);
}
return values;
}
async function readWindowsSystemProxy() {
if (platform() !== "win32")
return undefined;
let timeout;
const controller = new AbortController();
try {
const query = execFileAsync("reg.exe", ["query", WINDOWS_INTERNET_SETTINGS], {
windowsHide: true,
maxBuffer: 32 * 1024,
signal: controller.signal,
});
const deadline = new Promise((_, reject) => {
timeout = setTimeout(() => {
controller.abort();
reject(new Error(`registry query timed out after ${WINDOWS_PROXY_QUERY_TIMEOUT_MS}ms`));
}, WINDOWS_PROXY_QUERY_TIMEOUT_MS);
});
const result = await Promise.race([query, deadline]);
return parseRegistryProxy(String(result.stdout));
}
catch (error) {
logger.debug("Could not read the Windows WinINet proxy settings; using direct model downloads.", {
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
finally {
if (timeout)
clearTimeout(timeout);
}
}
function directState() {
return { choose: () => ({ route: "direct" }) };
}
function envProxyState() {
const httpProxy = envValue("http_proxy", "HTTP_PROXY");
const httpsProxy = envValue("https_proxy", "HTTPS_PROXY");
const noProxy = envValue("no_proxy", "NO_PROXY");
if (!httpProxy && !httpsProxy)
return undefined;
try {
const dispatcher = new EnvHttpProxyAgent({
httpProxy,
httpsProxy,
noProxy,
});
return {
choose: async (url) => {
const configuredProxy = url.protocol === "https:" ? (httpsProxy ?? httpProxy) : httpProxy;
return (matchesNoProxy(url, noProxy) ||
(await shouldBypassProxy(url)) ||
!configuredProxy)
? { route: "direct" }
: { route: "proxied", dispatcher };
},
};
}
catch (error) {
logger.warn("Ignoring invalid HTTP proxy environment settings for model downloads.", {
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
async function loadProxyState() {
const explicit = nonEmpty(process.env.COMFYUI_DOWNLOAD_PROXY);
if (explicit) {
try {
const dispatcher = new ProxyAgent(normalizeProxyUrl(explicit));
return {
choose: async (url) => (await shouldBypassProxy(url)) ? { route: "direct" } : { route: "proxied", dispatcher },
};
}
catch (error) {
logger.warn("Ignoring invalid COMFYUI_DOWNLOAD_PROXY; model downloads will use a direct route.", {
error: error instanceof Error ? error.message : String(error),
});
}
}
const env = envProxyState();
if (env)
return env;
const windows = await readWindowsSystemProxy();
if (!windows?.proxyEnable || !windows.proxyServer) {
if (windows?.autoConfigUrl || windows?.autoDetect) {
logger.debug("Windows WinINet uses PAC/auto-detect without a static proxy; model downloads stay direct.");
}
return directState();
}
const parsed = parseProxyServer(windows.proxyServer);
if (!parsed.http && !parsed.https)
return directState();
try {
const agents = {
http: parsed.http ? new ProxyAgent(parsed.http) : undefined,
https: parsed.https ? new ProxyAgent(parsed.https) : undefined,
};
return { choose: (url) => proxyChoiceForStatic(url, agents, windows.proxyOverride) };
}
catch (error) {
logger.warn("Ignoring invalid Windows WinINet proxy settings for model downloads.", {
error: error instanceof Error ? error.message : String(error),
});
return directState();
}
}
let proxyStatePromise;
async function chooseProxy(input) {
const url = urlForInput(input);
if (!url)
return { route: "direct" };
proxyStatePromise ??= loadProxyState();
return (await proxyStatePromise).choose(url);
}
/** Create a download-only fetch wrapper. It never changes undici's global
* dispatcher, so ComfyUI API calls continue to use their existing route. */
export function createDownloadFetch(onRoute) {
return async (input, init) => {
const choice = await chooseProxy(input);
onRoute?.(choice.route);
const { dispatcher: _callerDispatcher, ...withoutDispatcher } = init ?? {};
// Node's global fetch and the direct undici dependency can expose distinct
// Dispatcher type copies. The runtime contract is the same; keep the
// compatibility cast at this one boundary rather than leaking it into every
// download fetch seam.
const routedInit = choice.route === "proxied" && choice.dispatcher
? { ...withoutDispatcher, dispatcher: choice.dispatcher }
: withoutDispatcher;
return globalThis.fetch(input, routedInit);
};
}
export const downloadFetch = createDownloadFetch();
export async function downloadRouteForUrl(input) {
return (await chooseProxy(input)).route;
}
/** Test-only reset for the lazy system/config probe. No production caller should
* need to refresh it during a process lifetime. */
export const __downloadProxyTestHooks = {
reset() {
proxyStatePromise = undefined;
},
matchesNoProxy,
parseRegistryProxy,
};
//# sourceMappingURL=download-proxy.js.map