UNPKG

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),

556 lines • 28 kB
// Where does THE PANEL live? (#766, #769) // // Every other filesystem-backed tool asks `resolveEffectiveComfyUIBase()` — a // SYNCHRONOUS reader of COMFYUI_PATH / the saved default workspace. That is the // right answer for "where do models go", but it is the WRONG question for the // sidebar panel, because the panel is not a file the user reads: it is a web // extension that the RUNNING ComfyUI serves to the browser tab. The only // custom_nodes that can possibly matter is the one belonging to the server this // orchestrator is actually talking to. // // Two real deployments broke because those two answers differ: // // - #766 (Comfy Desktop dual-path). Desktop launches ComfyUI out of its own // program directory but passes `--base-directory <Documents\ComfyUI>`, and // ComfyUI derives custom_nodes/ from THAT. The configured workspace pointed // at the program directory, so install_comfyui(action:'panel') reported `installed: false` // while a perfectly good 0.11.x panel sat in the Documents tree — and any // install would have landed in a custom_nodes the server never reads. // // - #769 (no configured workspace at all). `install_comfyui (action:"environment")` already resolves // the live root from the serving process and reports it as the local // workspace, but install_comfyui(action:'panel') asked the sync resolver, got nothing, and // refused with "no local ComfyUI (COMFYUI_PATH) is configured" about an // install it had just been told the path to. // // So: resolve LIVE-FIRST, exactly like `resolveInstallInterpreter` does for the // interpreter — the running server's own launch argv is the ground truth, and a // configured path is the fallback, not the other way round. Two guard rails keep // that honest: // // 1. A live-derived base is only ACCEPTED when it actually contains a // `custom_nodes` directory. Otherwise we would confidently point the panel // tooling at a tree that has none and report a false "not installed". // 2. Nothing here is a guess. If the server is unreachable, or its argv yields // no resolvable root, we fall straight back to the configured base and SAY // which one we used (`source`), so a wrong answer is diagnosable instead of // silent. // // Remote/cloud mode resolves to nothing at all: the live root is a path on // SOMEONE ELSE'S filesystem and must never be handed to a local `join()`. import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { getComfyUIBaseUrl, getComfyuiTargetGeneration, isLocalMode, } from "../config.js"; import { parsePyproject } from "./node-authoring.js"; import { hasUnresolvableRelativeBaseDirFlag, parseBaseDirFromArgv, } from "./output-dir.js"; import { getLiveServerSnapshot, resolveLiveServerRoot, resolveEffectiveComfyUIBase, resolveLocalWorkspaceBase, } from "./workspace-env.js"; /** * Did the RUNNING SERVER actually choose this root? * * "Reachable" is not the same thing and must never stand in for it. A * `/system_stats` response with absent or unparseable argv proves only that * something answered — it says nothing about whether COMFYUI_PATH is the tree * that server reads. On a Comfy Desktop split install those differ, so a * configured base is a plausible READ but never authority for a destructive * write, nor for certifying which panel the browser is loading. * * Only the three live-derived sources qualify. `live-observed-root` belongs here * for the same reason the other two do — it is anchored on the process the OS * reports for our port, correlated against that server's OWN argv, and accepted * only when the anchored directory really holds `main.py` and an interpreter * belonging to that install. It is an OBSERVATION of the running server, not a * guess about a configured path (#1133). */ export function isLiveDerivedBase(resolution) { return (resolution?.source === "live-base-directory" || resolution?.source === "live-argv-root" || resolution?.source === "live-observed-root"); } function customNodesState(base) { try { return statSync(join(base, "custom_nodes")).isDirectory() ? "present" : "absent"; } catch (err) { // Only these two prove absence. Everything else — permissions, IO, a share // that went away — is an unanswered question, not a negative answer. const code = err?.code; return code === "ENOENT" || code === "ENOTDIR" ? "absent" : "unknown"; } } /** * Resolve the ComfyUI root whose `custom_nodes` serves the panel, preferring the * LIVE server over any configured path. Never throws; returns `source: "none"` * when nothing local is resolvable. */ export async function resolvePanelBase() { // Remote/cloud: the serving filesystem is not ours. Do not fall back to a // configured local path — that dir is not the panel the browser loads. if (!isLocalMode()) return { source: "none" }; const configured = resolveEffectiveComfyUIBase(); const snapshot = await getLiveServerSnapshot(); // A `--base-directory` that is PRESENT but UNRESOLVABLE (relative, and the // server reported no absolute cwd) poisons EVERY derivation, so no candidate // may be tried at all (codex gate, #1133). ComfyUI derives custom_nodes from // `<server cwd>/<flag>`; without the cwd that path cannot be computed — but // the install root and the OS-observed process root both still resolve, and // both name a tree the flag has already overridden. Adopting either would // hand a DESTRUCTIVE swap a confidently-wrong directory. Fail closed. // // This also closes the same hole on the pre-existing `live-argv-root` tier, // where an ABSOLUTE main.py alongside an unresolvable relative // `--base-directory` already resolved to the install root. const baseDirPoisoned = hasUnresolvableRelativeBaseDirFlag(snapshot.argv, snapshot.cwd); /** #796 — a live candidate whose custom_nodes could not be READ (permissions, * IO, a dead share), as opposed to one that provably lacks it. */ let liveRootUnreadable; if (snapshot.reachable && !baseDirPoisoned) { // Only accept a live base we can PROVE holds custom_nodes. A root without // one is not the tree the panel lives in, and pointing the installer at it // would manufacture a false "not installed". const accept = (candidate, source) => { if (!candidate) return undefined; const state = customNodesState(candidate); if (state === "unknown") { // #796 — STILL SKIPPED: "we could not read it" is not the proof this // branch requires, and loosening a guard on an unread directory is the // wrong direction. But it is not disproof either, so it is recorded // instead of vanishing — the fallback below otherwise hands the caller a // CONFIGURED tree while silently implying the live one was disqualified // on the evidence. liveRootUnreadable ??= candidate; return undefined; } if (state === "absent") return undefined; return { base: candidate, source, overriddenConfiguredBase: configured && configured !== candidate ? configured : undefined, }; }; // `--base-directory` FIRST: when ComfyUI is launched with it, that flag — // not the main.py location — is the root it derives custom_nodes/ from. const fromFlag = accept(parseBaseDirFromArgv(snapshot.argv, snapshot.cwd), "live-base-directory"); if (fromFlag) return fromFlag; // Only NOW pay for the install-root derivation. `resolveLiveServerRoot` is // SYNCHRONOUS and its second tier shells out to the process table // (netstat + WMI on Windows), which blocks the event loop for ~1.3s on a // healthy machine and far longer on its timeout path — so it must never run // when `--base-directory` was going to win anyway (codex gate, #1133). // // It goes through the ONE canonical resolver rather than re-parsing argv // here, which is what silently dropped ComfyUI Desktop and the Windows // portable bundle: they report a RELATIVE `ComfyUI\main.py` with no cwd, so // `liveRootFromArgv` yields nothing and a Desktop install had NO // live-derived candidate at all — every destructive panel operation refused // on an uncorroborated configured path. The canonical resolver adds the tier // that exists precisely for that shape, re-anchoring the relative main.py on // the interpreter the OS reports for the process on our port. // // That does not widen what counts as corroboration: the anchor is accepted // only when the resulting directory holds `main.py` AND the observed // interpreter belongs to that install. Unresolved still yields no candidate, // and the gate still refuses. const live = resolveLiveServerRoot(snapshot.argv, snapshot.cwd, { remote: false }); const fromLive = accept(live.root, live.source === "observed-process" ? "live-observed-root" : "live-argv-root"); if (fromLive) return fromLive; } const liveProbeFailed = !snapshot.reachable; // Reached but nothing derivable: tell callers apart from "could not ask" — // the remedy for each is different (#916). const liveRootUnderivable = snapshot.reachable; // …and apart again from "reached, but a flag we cannot resolve overrides // everything we could derive", whose remedy is different a third time. const baseDirUnresolvable = snapshot.reachable && baseDirPoisoned; if (configured) { return { base: configured, source: "configured", liveProbeFailed, liveRootUnderivable, baseDirUnresolvable, liveRootUnreadable, }; } return { source: "none", liveProbeFailed, liveRootUnderivable, baseDirUnresolvable, liveRootUnreadable, }; } /* * The resolution is async (it reads /system_stats), but `PanelInstallerDeps * .comfyuiPath` is synchronous and is consulted from several places inside one * operation (detection, the shadow scan, the post-op re-read). Those MUST all * see the same answer — a base that changed halfway through would make the * "did the pack move?" proof compare two different directories. * * So the async resolution is primed ONCE at the top of each panel operation and * cached; the sync accessor serves that cache. The cache is keyed on the ComFYUI * TARGET URL, so retargeting the orchestrator at a different server can never * serve the previous one's base — and it expires on a short TTL so a restarted * server with new launch flags is picked up without a retarget. */ const PANEL_BASE_TTL_MS = 60_000; let cached; /** * How many times the cache has been deliberately CLEARED (#1222). * * The in-flight guard below compares `cached` by reference to catch "a newer * write landed while we were probing". That comparison has one blind spot, and * it is the one that bites: a probe which STARTS with an empty cache records * `undefined`, a clear leaves it `undefined`, and the two compare equal — so the * clear is invisible and the stale probe writes its pre-clear answer in * afterwards. * * A clear is an EVENT, not a value, so counting it is what makes it observable. * `undefined → undefined` is indistinguishable by reference and unmistakable by * count. */ let clearEpoch = 0; /** * Forget the resolved base, countably (#1222). * * The one way to clear it deliberately. A bare `cached = undefined` at a fourth * call site would reintroduce the whole bug silently — the clear would happen and * an in-flight probe would put its pre-clear answer straight back — so the count * lives with the assignment rather than next to it. * * NOT used by the retarget bail inside `primePanelBase`. That one is discarding * its OWN answer because the target moved, and the target/generation checks * already stop anyone acting on it. Bumping there would additionally stop a * CONCURRENT probe against the new target from caching a result that is * perfectly good — which costs a re-probe rather than correctness, so it is a * deliberate scope line and not a safety one. Stated that way because it is not * pinned by a test: mutating it to bump changes no observable answer, only how * often the next caller re-resolves, and a test asserting that would be pinning * a performance detail as though it were a contract. */ function forgetResolvedBase() { cached = undefined; clearEpoch += 1; } /** Cache key: which ComfyUI this resolution describes. Never throws. */ function targetKey() { try { return getComfyUIBaseUrl(); } catch { return ""; } } /** * The target GENERATION — config.ts bumps it on every successful retarget, * INCLUDING a round trip back to the same URL, which a URL comparison cannot * see. It is the only signal that catches the orchestrator's hello handler * kicking off the panel sync BEFORE its retarget completes: an observation * recorded in that window belongs to the outgoing target, and the generation * moves underneath it. * * Defensive because a partially-mocked config (several test suites stub only * the handful of exports they use) must not turn this into a crash. A constant * fallback is safe: record and compare both see it, so it degrades to the URL * check rather than to a wrong match. */ export function panelTargetGeneration() { return targetGeneration(); } function targetGeneration() { try { return getComfyuiTargetGeneration(); } catch { return -1; } } /** * The already-resolved panel ComfyUI base, or undefined when nothing is cached. * Sync on purpose: panel_load_workflow's local fallback must not issue HTTP * after the userdata library has just been unreachable (#1845). * * Same-URL generation bumps are ignored here on purpose. `setComfyuiTarget` * increments generation on a restart even when the address did not change, * and dropping the last-known tree in that window is what made the fallback * claim "COMFYUI_PATH not set" while install_comfyui environment still named * that workspace. Mutation callers still go through `cachedResolution()`, * which keeps the generation fence. A different URL still invalidates via * `targetKey()`. */ export function peekResolvedPanelBase() { if (!cached) return undefined; if (cached.target !== targetKey()) return undefined; if (Date.now() - cached.at > PANEL_BASE_TTL_MS) return undefined; return cached.resolution.base; } function cachedResolution() { if (!cached) return undefined; if (cached.target !== targetKey()) return undefined; // The URL is not enough here either. setComfyuiTarget bumps the generation // even for a round trip back to the SAME address, which is exactly what a // ComfyUI restart onto a different --base-directory looks like from outside: // same URL, different custom_nodes. A cache that survived that would freeze // the OLD root into a status read or — far worse — into a mutation, which // would then verify its own work in a tree nobody is serving. if (cached.generation !== targetGeneration()) return undefined; if (Date.now() - cached.at > PANEL_BASE_TTL_MS) return undefined; return cached.resolution; } /** * Resolve and cache the panel's ComfyUI base. Call at the START of any panel * operation, before the first `deps.comfyuiPath()`, so every read within that * operation agrees. Never throws. */ export async function primePanelBase(opts = {}) { const fresh = opts.force ? undefined : cachedResolution(); if (fresh) return fresh; // CAPTURE THE IDENTITY BEFORE THE AWAIT, never after. // // The probe is a network round trip, and the orchestrator can retarget during // it — its hello handler starts the panel sync and then retargets. Stamping // the result with values read AFTERWARDS would label tree A's answer with // target B, which is worse than having no answer: every downstream check // (the swap's corroboration, the stale-bundle proof, the mid-op generation // guard) compares against that label and would agree with itself all the way // to a wrong-tree mutation reported as success. const atTarget = targetKey(); const atGeneration = targetGeneration(); // The cache entry as the probe STARTS. A probe that lands after another // writer refreshed (or deliberately seeded) the cache must not overwrite // that newer entry with its older answer — the in-flight `void // primePanelBase()` a refusal fires in the background did exactly that to a // base seeded after it started (#879 test isolation surfaced it). const cacheAtStart = cached; // #1222 — and the CLEAR COUNT, because the reference comparison above cannot // see a clear that leaves the cache as empty as it found it. const clearEpochAtStart = clearEpoch; let resolution; try { resolution = await resolvePanelBase(); } catch { // A failed probe must not break panel management — fall back to the // ordinary sync answer rather than reporting "no local ComfyUI". // Asks the LOCAL-MACHINE question explicitly, having already established the mode // on the line itself. Same answer as the target-scoped resolver, but the two are // different questions and #490 is what happens when one stands in for the other. const configured = isLocalMode() ? resolveLocalWorkspaceBase() : undefined; resolution = configured ? { base: configured, source: "configured" } : { source: "none" }; } // The target moved while we were asking. This answer describes the PREVIOUS // ComfyUI, so it is not cached and not returned as a resolution anyone may // act on — callers see "nothing resolved" and fail closed, and the next // prime (against the settled target) answers properly. if (targetKey() !== atTarget || targetGeneration() !== atGeneration) { cached = undefined; return { source: "none" }; } // A NEWER cache write landed while we were asking (a later prime settled, a // test seed, a cache reset). Same rule as the retarget guard above: a probe // that STARTED earlier holds older information, so it must not clobber the // newer write — the fire-and-forget prime a capability refusal kicks off (see // resolveStaleBundleSkew) can settle seconds later, mid-way through someone // else's operation. // // MERGE NOTE (#884 branch × main): both sides fixed this independently and // agreed the newer write must win in the CACHE. They differed on what the // caller gets back — main returned this probe's older answer, this branch // serves the newer cached one. Kept the branch's shape because the other // leaves the caller holding a value the cache has already superseded, which // is the same "two sources of truth" split the guard exists to close. The // expiry fallback preserves main's behavior exactly when the newer entry has // already aged out. if (cached !== cacheAtStart) { return cachedResolution() ?? resolution; } // #1222 — the cache was deliberately CLEARED while this probe was in flight. // Same rule as above and the same reason: this answer predates the clear, so // writing it would silently undo an explicit "forget what you knew". The // reference check misses it whenever the cache was already empty when the // probe started, which is the common case — a refusal fires the background // prime precisely because nothing was primed yet. // // The ANSWER is still returned: this caller asked and this is what the probe // found. Only the shared cache is left alone, so the next caller re-resolves // rather than inheriting a resolution someone asked to be forgotten. if (clearEpoch !== clearEpochAtStart) { return resolution; } cached = { at: Date.now(), target: atTarget, generation: atGeneration, resolution }; return resolution; } /** * The primed base, or — when nothing has been primed in this window — the * ordinary sync answer. Deliberately falls back rather than returning * undefined: an unprimed caller must keep the pre-#766 behaviour, never lose * a workspace it used to see. */ export function panelBaseSync() { const fresh = cachedResolution(); if (fresh) return fresh.base; if (!isLocalMode()) return undefined; // Mode established on the line above → the remaining question is purely "where is the // local install?", so ask that one by name. return resolveLocalWorkspaceBase(); } /** The resolution behind `panelBaseSync()`, for reporting. undefined ⇒ unprimed. */ export function lastPanelBaseResolution() { return cachedResolution(); } /** * Test hook — drop the cache so the next prime re-resolves. * * Bumps the clear epoch (#1222) so a probe already in flight cannot land its * pre-clear answer afterwards. Without that, this hook cleared the cache and an * unawaited background prime — the one a capability refusal fires — repopulated * it seconds later, inside whichever test happened to be running. That produced * three flakes whose only symptom was the wrong remedy WORDING, which reads * exactly like a real regression. */ export function __resetPanelBaseCache() { diskObservation = undefined; forgetResolvedBase(); } /** * Test hook — seed a resolved base without probing a live server. Defaults to a * LIVE-DERIVED source, because that is what an ordinary serving install * produces and it is what the corroboration gates require; pass * `source: "configured"` to exercise the uncorroborated branches. */ export function __setPanelBaseForTests(base, source = "live-argv-root") { cached = { at: Date.now(), target: targetKey(), generation: targetGeneration(), resolution: base ? { base, source } : { source: "none" }, }; } /** * How long an observation's POINTER stays usable. The version it carries is * never trusted on age alone — see `verifiedPanelDiskVersion`. */ const DISK_OBSERVATION_TTL_MS = 30 * 60_000; /** pyproject `[project].name` that identifies the panel pack. Kept as a literal * rather than imported from panel-installer, which imports this module. */ const PANEL_PROJECT_NAME = "comfyui-agent-panel"; let diskObservation; /** Record a version READ FROM DISK. Called by panelStatus, never guessed. */ export function recordPanelDiskObservation(version, dir, base) { diskObservation = { version, dir, at: Date.now(), target: targetKey(), generation: targetGeneration(), base: base ?? panelBaseSync(), }; } /** Forget the on-disk observation — the pack is not there (or was removed). */ export function clearPanelDiskObservation() { diskObservation = undefined; // Drop the resolved base with it. A hello is also the moment ComfyUI may have // restarted with different launch flags — and therefore a different // custom_nodes — so keeping the previous root cached would let the very next // operation freeze the wrong tree. // // #1222 — through `forgetResolvedBase`, because an in-flight probe that // started BEFORE this hello would otherwise write the pre-restart root back in // a moment later, which is precisely the freezing this exists to prevent. That // is a PRODUCTION path, not only a test one: the clear runs on every hello. forgetResolvedBase(); } /** * The last on-disk read, if it still describes THIS ComfyUI and is recent. * * The target check matters more than the clock. The orchestrator kicks off the * panel sync on a hello and retargets ComfyUI in the same handler, so an * observation can be recorded a moment before the target changes — and a * version read from the OLD machine's disk must never be presented as evidence * about the new one. */ export function lastPanelDiskObservation() { if (!diskObservation) return undefined; if (diskObservation.target !== targetKey()) return undefined; // A URL match is not enough. The orchestrator starts the panel sync and // retargets in the same hello handler, so an observation can be recorded // against the OUTGOING target and still share a URL with the incoming one; // the generation moves on every retarget, including a round trip back to the // same address, and catches exactly that. if (diskObservation.generation !== targetGeneration()) return undefined; if (Date.now() - diskObservation.at > DISK_OBSERVATION_TTL_MS) return undefined; return diskObservation; } /** * The panel's on-disk version RIGHT NOW, re-read from the directory the last * observation pointed at — or undefined if that cannot be confirmed. * * The recorded version is deliberately NOT returned. It is a fact about the * past, and the one conclusion it feeds — "your install is fine, the browser * tab is the stale part" — is exactly the conclusion that must never be wrong: * telling someone whose install is genuinely behind to hard-refresh sends them * back into the loop this whole change exists to break. So the observation * supplies only the WHERE (which needs the async scan), and the version is read * fresh and cheaply at the moment of use. A pack that was removed, downgraded, * or replaced by something else since the scan therefore yields undefined and * the caller falls back to ordinary update guidance. */ export function verifiedPanelDiskVersion() { const observed = lastPanelDiskObservation(); if (!observed?.dir) return undefined; // The observation must still describe the tree the LIVE server is serving // from. A restart at the same address with a different `--base-directory` is // a different custom_nodes and therefore a different panel, and re-reading // the OLD directory would then certify a version nobody is running. Only a // FRESHLY resolved base counts: an expired cache falls back to the // configured path, which is exactly the disagreement (#766) this whole change // exists to fix, so it must not be accepted as a match. No fresh resolution ⇒ // no claim. const resolution = cachedResolution(); if (!resolution?.base || !observed.base || resolution.base !== observed.base) { return undefined; } // And the RUNNING SERVER must have chosen that tree. A configured fallback // only proves something answered on the URL, not that COMFYUI_PATH is what it // serves — and on a split install it is not. Certifying "your install is // fine, hard-refresh" off a dormant copy is the exact wrong direction. if (!isLiveDerivedBase(resolution)) return undefined; // And the pack must live UNDER that base — never a path left over from a tree // we are no longer looking at. if (!observed.dir.startsWith(resolution.base)) return undefined; try { const raw = readFileSync(join(observed.dir, "pyproject.toml"), "utf-8"); const parsed = parsePyproject(raw); // Still the panel, and still has a version. Anything else is not proof. if (parsed.projectName !== PANEL_PROJECT_NAME) return undefined; return parsed.version; } catch { return undefined; } } //# sourceMappingURL=panel-workspace.js.map