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),
165 lines • 8.94 kB
JavaScript
// Panel-`hello` ComfyUI-target retarget policy (#303 zombie-tab guard + #756
// restart-window fix + the codex-gate trust model), extracted from the
// orchestrator so the decision is unit-testable. Pure decision logic — the
// probe is injected, so no I/O lives here.
//
// Background: a panel tab's hello carries the ComfyUI URL the browser was
// SERVED from (window.location), and the orchestrator retargets to it — that
// is the auto-point-at-whatever-ComfyUI-the-user-has-open mechanism, and the
// ONLY refresh of the connected target after a ComfyUI restart (a connected
// panel never re-hellos). #303 added a liveness veto: probe the hello URL's
// /system_stats first and drop the retarget when it doesn't answer, because a
// STALE browser tab on a DEAD instance kept dragging the target to a corpse.
// #756: the veto was UNCONDITIONAL — it also dropped the correction back to a
// LOCAL target when that hello raced the local ComfyUI's own restart window,
// pinning a stale (possibly REMOTE) target forever.
//
// TRUST MODEL (codex gate on #756): hello.comfyui_url is page-JS-writable —
// a stale, confused, or malicious tab can CLAIM any instance. The ONLY
// unspoofable proof of which ComfyUI a real browser tab fronts is the
// server-observed WebSocket handshake Origin (ui-bridge's serverOrigin: the
// browser sets it and forbids page scripts from overriding it). So the
// decision is two-tiered:
//
// TRUSTED (handshake origin canonically matches the claimed URL): the tab
// provably fronts what it names. All #303/#756 semantics apply — the
// same-target no-probe shortcut, the RunPod-proxy skip (booting pods answer
// late), the healthy-current veto, and the #756 both-dead recovery.
//
// UNTRUSTED (origin mismatches, or absent — a non-browser/relay client):
// NOTHING is taken on the claim's word. No no-probe shortcut (the
// same-target comparison keys on the observed origin, never the bare
// claim), no proxy skip, and NO both-dead recovery — applying a
// wrong-host retarget to an unverifiable claim is the wrong-workflow
// hazard the gate closed. The claim may still apply on EVIDENCE: probe
// the claimed instance, and a live, answering one earns the retarget; a
// dead one fails closed.
//
// Locality is always classified from the target URL's host (durable), never
// from a probe reading (transient).
/**
* Canonical form for same-target comparisons — scheme-aware but
* DEFAULT-PORT-INSENSITIVE: strip only the scheme's actual default (:443 for
* https, :80 for http); http://h:443 and http://h are NOT the same endpoint.
* Trailing slashes stripped. Unparseable input is returned slash-stripped.
* This is the single implementation the orchestrator's target dedupe and this
* module's same-target check share, so the two can never drift apart.
*/
export function canonComfyuiTargetUrl(u) {
try {
const p = new URL(u);
if ((p.protocol === "https:" && p.port === "443") || (p.protocol === "http:" && p.port === "80"))
p.port = "";
return p.toString().replace(/\/+$/, "");
}
catch {
return u.replace(/\/+$/, "");
}
}
/** Loopback family canonicalization, mirroring loopbackFamily in
* orchestrator/panel-tools.ts (kept local so this leaf module doesn't import
* the heavyweight panel-tools unit): every IPv4-family loopback literal
* (127.0.0.1 / the 0.0.0.0 wildcard) → 127.0.0.1; every IPv6-family literal
* (::1 / the :: wildcard) → ::1. The families stay DISTINCT — a v4 and a v6
* instance at the same port are different instances — and `localhost` is
* deliberately NOT canonicalized: a DNS-ambiguous name can't be pinned to a
* family, so it never aliases a concrete loopback literal. */
function loopbackFamilyKey(host) {
const h = host.toLowerCase().replace(/^\[|\]$/g, "");
if (h === "127.0.0.1" || h === "0.0.0.0")
return "127.0.0.1";
if (h === "::1" || h === "::" || h === "0000:0000:0000:0000:0000:0000:0000:0000")
return "::1";
return null;
}
/**
* Canonical scheme://host:port key of a ComfyUI URL for instance-identity
* comparison — path ignored (the WebSocket handshake Origin carries none; a
* basePath on the claim is a mount on the proven instance), default ports made
* explicit, loopback hosts family-canonicalized. null when unparseable.
* Mirrors httpOriginOf in orchestrator/panel-tools.ts.
*/
export function comfyuiOriginKey(rawUrl) {
try {
const u = new URL(rawUrl);
const port = u.port || (u.protocol === "https:" ? "443" : "80");
const host = u.hostname.toLowerCase();
return `${u.protocol}//${loopbackFamilyKey(host) ?? host}:${port}`;
}
catch {
return null;
}
}
/**
* Decide whether a panel hello's `comfyui_url` should be applied as the new
* ComfyUI target.
*
* `observedOrigin` is the SERVER-OBSERVED WebSocket handshake Origin of the
* tab that sent the hello (ui-bridge's tabServerOrigin) — the ONLY unspoofable
* corroboration of the claim. Undefined for non-browser/relay clients, whose
* claims are therefore always probed, never trusted.
*
* `probe` answers whether a given /system_stats URL responds (the caller
* supplies the timeout); it must never throw — a probe failure reads as
* unreachable.
*/
export async function judgeHelloRetarget(opts) {
const { helloUrl, currentUrl, probe } = opts;
const observedOrigin = opts.observedOrigin?.trim() || undefined;
if (typeof helloUrl !== "string")
return { apply: false, reason: "not-a-url", trusted: false };
const base = helloUrl.trim().replace(/\/+$/, "");
if (!base)
return { apply: false, reason: "not-a-url", trusted: false };
// TRUST: the claim is page-JS-writable; only the browser-set handshake
// Origin (unforgeable by page JS) proves which instance the tab fronts.
// Compared canonically at origin granularity — the Origin carries no path.
const claimKey = comfyuiOriginKey(base);
const originKey = observedOrigin ? comfyuiOriginKey(observedOrigin) : null;
const trusted = claimKey !== null && originKey !== null && claimKey === originKey;
if (!trusted) {
// Nothing is taken on the claim's word: no no-probe shortcut (the
// same-target comparison keys on the observed origin, never the bare
// claim — and a trusted same-target claim implies the observed origin
// fronts the current target, so no trusted case is lost here), no RunPod
// skip, and NO #756 both-dead recovery. The claim may apply on EVIDENCE
// only: a live, answering instance earns the retarget; a dead or
// unreachable one fails closed (#303's zombie-tab hazard in its general
// form — a wrong-host retarget is a wrong-workflow hazard regardless of
// adversary framing).
if (await probe(`${base}/system_stats`)) {
return { apply: true, reason: "healthy-untrusted", base, trusted };
}
return { apply: false, reason: "vetoed-untrusted", base, trusted };
}
// TRUSTED claim: the tab provably fronts the instance it names.
// Same-target hello — the corroborated claim IS the current target, so the
// observed origin fronts it too: a no-op retarget that must not stall on
// probes during the target's own restart window.
if (canonComfyuiTargetUrl(base) === canonComfyuiTargetUrl(currentUrl)) {
return { apply: true, reason: "same-target", base, trusted };
}
// RunPod proxies skip the probe: a booting pod answers late — readiness is
// the connector's job (#303). Trust-gated: the tab fronts the pod it names.
if (/\.proxy\.runpod\.net/i.test(base)) {
return { apply: true, reason: "runpod-proxy", base, trusted };
}
if (await probe(`${base}/system_stats`)) {
return { apply: true, reason: "healthy", base, trusted };
}
// The claimed instance doesn't answer. Veto ONLY while the current target
// is healthy (#303's zombie-tab guard: a stale tab on a dead instance must
// not steal a GOOD target). When the current target doesn't answer EITHER,
// both reads are dead — trust the live tab's corroborated hello: applying
// keys locality on the claim's durable host (never on a transient probe)
// and is the self-healing direction. #756: vetoing the LOCAL hello during
// the ComfyUI restart window pinned a stale REMOTE target forever — a
// connected panel never re-hellos — leaving every tool misclassified as
// remote.
const currentBase = currentUrl.trim().replace(/\/+$/, "");
if (currentBase && (await probe(`${currentBase}/system_stats`))) {
return { apply: false, reason: "vetoed-unreachable", base, trusted };
}
return { apply: true, reason: "current-also-unreachable", base, trusted };
}
//# sourceMappingURL=hello-retarget.js.map