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),
5,801 lines • 312 kB
JavaScript
import { execFileSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { basename, isAbsolute, join, relative, resolve } from "node:path";
import { config, getComfyUIBaseUrl, getComfyuiTargetGeneration, isRemoteMode, } from "../config.js";
import { comfyuiFetch, describeComfyBodyTimeout, raceAbort, } from "../comfyui/fetch.js";
import { resetObjectInfoCache } from "../comfyui/client.js";
import { progressEnabled, reportDownloadProgress } from "./download-progress.js";
import { parsePyproject } from "./node-authoring.js";
import { getNodePackDetails } from "./registry-client.js";
import { cacheManagerApi, dialectRecheckSuppressed, getCachedManagerApi, managerApiCacheStamp, managerApiEpoch, resetManagerApiCache, setManagerApiCacheForTests, suppressDialectRecheck, } from "./manager-api-cache.js";
import { detectLocalWriteTargetMismatch, resolveEffectiveComfyUIBase, resolveEffectiveComfyUICodeBase, resolveCustomNodesScanBaseLiveStrict, resolveInstallInterpreter, } from "./workspace-env.js";
import { assertComfyCliOk, getComfyCliVersion, isComfyCliUsable, isSupportedComfyCliVersion, resolveComfyCliExecutable, runComfyCliSync, } from "./comfy-cli.js";
import { withPanelMutationLock, withPanelPinGuard } from "./panel-pin-guard.js";
import { ambiguousBareNameRefusal, ambiguousBareNameWarning, bareNameAmbiguity, isFromSourceSpec, registryVersionAmbiguity, registryVersionRefusal, rekeyedRegistryVersionAmbiguity, rekeyedRegistryVersionRefusal, rekeyedRepoAmbiguity, rekeyedRepoRefusal, rekeyedRepoWarning, } from "./manager-bare-name-collisions.js";
import { ComfyUIError, ProcessControlError, ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { managerBodyClause } from "./manager-error-body.js";
import { MANAGER_VERSION_ROUTES, parseManagerMajor } from "./manager-version.js";
// ---------------------------------------------------------------------------
// Custom-node management — ports `comfy-cli node install|update|reinstall|fix|
// show|uv-sync` to MCP tools.
//
// Strategy (hybrid, confirmed with maintainer):
// 1. Prefer the ComfyUI-Manager HTTP API (works against remote instances).
// TWO API GENERATIONS exist in the wild (issue #116), auto-detected per
// target by detectManagerApi():
// • v4 lineage (pip `comfyui_manager` ≥4.x, the `manager-v4` branch —
// what the RunPod image bakes): unified queue — POST one task
// envelope to /v2/manager/queue/task, then /v2/manager/queue/start,
// then poll /v2/manager/queue/status until the queue drains.
// • RELEASED Manager 3.x (`main`, the registry default): same queue
// engine under /manager/queue/* with PER-OPERATION routes
// (install/uninstall/update/fix/disable/install_model/update-all)
// and different body shapes — see legacyTaskRequest().
// 2. Fall back to the cm-cli.py subprocess (against the resolved code
// workspace) for
// anything the HTTP API can't do, or when the user forces it (local
// installs only — cm-cli needs the filesystem).
//
// API contract verified against the current Comfy-Org/ComfyUI-Manager (the
// `glob` server, codegen'd from openapi.yaml). Every operation now flows through
// ONE endpoint:
// POST /v2/manager/queue/task body: { ui_id, client_id, kind, params }
// where `kind` is an OperationType (install | uninstall | update | fix |
// enable | disable | update-comfyui | install-model) and `params` is the
// matching Pydantic model:
// install → InstallPackParams { id, version, selected_version, repository?,
// pip?, mode, channel, skip_post_install? }
// (do_install only reads `id` + `selected_version` → resolve_node_spec)
// update → UpdatePackParams { node_name, node_ver? }
// fix → FixPackParams { node_name, node_ver }
// uninstall→ UninstallPackParams { node_name, is_unknown? }
// disable → DisablePackParams { node_name, is_unknown? }
// enable → EnablePackParams { cnr_id }
// Dedicated (non-task) routes still exist for: /v2/manager/queue/update_all,
// /v2/manager/queue/start, /v2/manager/queue/status, /v2/customnode/installed.
// There is NO `reinstall` kind — reinstall is modeled as uninstall + install.
//
// NOTE: modern ComfyUI-Manager ships as the pip package `comfyui_manager`
// (site-packages), NOT a custom_nodes/ checkout, so it does not provide
// cm-cli.py. The cm-cli fallback therefore only works on legacy layouts; the
// HTTP API above covers every operation and is the primary path.
// ---------------------------------------------------------------------------
/** client_id reported to ComfyUI-Manager's task queue for our requests. */
const MANAGER_CLIENT_ID = "comfyui-mcp";
export class NodeManagementError extends ComfyUIError {
constructor(message, details) {
super(message, "NODE_MANAGEMENT_ERROR", details);
this.name = "NodeManagementError";
}
}
class GitRefProbeError extends NodeManagementError {
constructor(operation, cause) {
const message = cause instanceof Error ? cause.message : String(cause);
super(`Git ref probe failed during ${operation}: ${message}`, {
kind: "git-ref-probe",
operation,
});
this.name = "GitRefProbeError";
}
}
// ---------------------------------------------------------------------------
// Manager HTTP helper (local to this unit — do NOT extract to a shared client)
// ---------------------------------------------------------------------------
function managerBaseUrl() {
return getComfyUIBaseUrl();
}
/**
* #1089 — a Manager 403 already SAYS why; we were dropping it.
*
* ComfyUI-Manager gates privileged routes behind its own security level and
* answers with a machine-readable body (glob/manager_server.py):
*
* def security_403_response(flag_token=None):
* if not manager_migration.has_system_user_api():
* return web.json_response({"error": "comfyui_outdated"}, status=403)
* if flag_token is not None:
* return web.json_response({"error": flag_token}, status=403)
* return web.json_response({"error": "security_level"}, status=403)
*
* Three causes, three different remedies — and the caller saw only
* "ComfyUI-Manager API 403 Forbidden for /v2/manager/queue/update_all". The
* reporter had to ask which permission was even involved.
*
* install_comfyui (action:"update_all") specifically needs is_allowed_security_level("middle"), which passes
* only for security_level in weak / normal / normal-. That is a Manager SETTING,
* not a credential and not a consequence of being remote.
*
* NAMES NO REMEDY IT CANNOT SUPPORT. An unrecognised token is reported as the
* flag Manager named, with no invented advice — a specific install flag denied it
* and only that flag can say more. Guessing here would be the same defect as the
* status blaming the filename in a userdata 400.
*/
export function explainManagerForbidden(status, body) {
if (status !== 403)
return "";
let reason = null;
try {
const parsed = JSON.parse(body);
const e = parsed?.error;
if (typeof e === "string" && e.trim())
reason = e.trim();
}
catch {
// Not JSON — Manager always sends JSON here, so an unparseable body means
// something else answered (a proxy, an auth gate). Say nothing rather than
// attribute a Manager reason to a response Manager may not have sent.
return "";
}
if (reason === "security_level") {
return (" — ComfyUI-Manager REFUSED this on its own security level, not on credentials and not" +
" because the server is remote. Privileged Manager routes (its update-all, install and" +
" snapshot-removal endpoints)" +
" require Manager config security_level to be weak, normal or normal-; anything stricter" +
" refuses. Change it in ComfyUI-Manager settings (or its config.ini) and restart ComfyUI." +
" Loosening it decides who may reach that server, so it is a deliberate choice: the" +
" alternative is to run the update from a shell on the machine itself.");
}
if (reason === "comfyui_outdated") {
return (" — ComfyUI-Manager reports this ComfyUI as too OLD for the route: it predates the" +
" system-user API that Manager checks first, before it ever consults security_level." +
" Update ComfyUI itself; changing Manager settings will not clear this.");
}
if (reason) {
return (` — ComfyUI-Manager denied this via a specific install flag it names as "${reason}".` +
" That flag, not the general security level, is what governs this route; consult" +
" ComfyUI-Manager for what it controls.");
}
return "";
}
/**
* A Manager capability probe can be answered by the authentication layer in
* front of ComfyUI. Treating a 401/407 as an absent Manager loses the one fact
* the caller can act on, especially when the browser is authenticated but the
* MCP process is not carrying that browser session.
*/
export function explainManagerAuthenticationRequired(status) {
if (status !== 401 && status !== 407)
return "";
const gate = status === 407 ? "proxy authentication layer" : "ComfyUI or an authentication proxy";
const credentialHint = "Configure the gateway credentials for this MCP process (COMFYUI_AUTH_TOKEN / " +
"COMFYUI_AUTH_HEADER, or the CF-Access client-id/client-secret pair); browser cookies " +
"and panel login sessions are not forwarded automatically.";
return (` — the ${gate} rejected the Manager probe with HTTP ${status}. This is an ` +
"authentication failure, not evidence that ComfyUI-Manager is missing. " +
credentialHint);
}
/** Same 120s ceiling as fetch.ts, minted locally so tests that mock
* `comfyui/fetch.js` without `defaultComfyTimeoutSignal` still load. */
export function managerBodyTimeoutSeconds() {
const raw = Number(process.env.COMFYUI_MCP_HTTP_TIMEOUT_S);
return Number.isFinite(raw) && raw > 0 ? raw : 120;
}
function managerBodyTimeoutSignal() {
const seconds = managerBodyTimeoutSeconds();
return AbortSignal.timeout(Math.round(seconds * 1000));
}
function isManagerTimeoutAbort(err) {
return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
}
async function managerFetch(path, options = {}) {
const { method = "GET", body, base = managerBaseUrl(), soft = false, onSoftFailure, onSoftTransportFailure, onSoftResponse, } = options;
const url = `${base}${path}`;
logger.debug("Manager API request", { url, method });
const headers = {};
if (body !== undefined)
headers["Content-Type"] = "application/json";
// Mint the ceiling HERE and race only the body reads. Do NOT pass it into
// comfyuiFetch: that helper rewrites a timeout into describeComfyTimeout only
// when init.signal is undefined, so threading a signal through would silently
// drop the transport-timeout diagnosis for every Manager call (#2773).
// Local AbortSignal — do not import defaultComfyTimeoutSignal from fetch.js;
// tests mock that module without this export.
const budget = managerBodyTimeoutSignal();
let res;
try {
res = await comfyuiFetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
catch (err) {
if (soft) {
onSoftFailure?.(undefined);
onSoftTransportFailure?.();
return undefined;
}
throw new NodeManagementError(`ComfyUI-Manager API unreachable at ${url}. Is ComfyUI running with ComfyUI-Manager installed? (${err instanceof Error ? err.message : String(err)})`);
}
const readBody = () => raceAbort(budget, () => res.text());
if (!res.ok) {
// unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the
// HTTP status is reported either way, so an unreadable body costs detail in the
// text, never a wrong conclusion. Verified there is no branch on this value.
const text = await readBody().catch(() => "");
if (soft) {
onSoftFailure?.(res.status);
// A soft probe may ignore an absent route or a transient server error, but
// authentication is actionable evidence. Returning undefined here made
// detectManagerApi() fall through to its false "Manager is missing"
// conclusion when an authenticated reverse proxy answered 401 (#2085).
const auth = explainManagerAuthenticationRequired(res.status);
if (auth) {
throw new NodeManagementError(`ComfyUI-Manager probe at ${url} was rejected` + auth + managerBodyClause(text), { url, status: res.status, body: text });
}
return undefined;
}
throw new NodeManagementError(`ComfyUI-Manager API ${res.status} ${res.statusText} for ${path}` +
explainManagerForbidden(res.status, text) +
explainManagerAuthenticationRequired(res.status) +
// #1397 — SHOW THE BODY. It was already being captured into `details` below
// and only surfaced for 403, so a task-queue failure reported a bare status
// line while the serialized exception naming the cause sat one layer down.
// Bounded and truncation-marked; see manager-error-body.ts for why a
// passthrough is not an option here.
managerBodyClause(text), { url, status: res.status, body: text });
}
// Some endpoints return empty bodies (e.g. queue ops). Parse defensively.
// Headers already arrived, so comfyuiFetch's describeComfyTimeout rewrite
// cannot run. A stall here used to escape as a bare TimeoutError (#2773).
let raw;
try {
raw = await readBody();
}
catch (err) {
if (isManagerTimeoutAbort(err)) {
if (soft) {
onSoftFailure?.(undefined);
onSoftTransportFailure?.();
return undefined;
}
const timeout = describeComfyBodyTimeout(url, method);
throw new NodeManagementError(timeout.message, {
url,
kind: "manager-body-timeout",
});
}
throw err;
}
if (!raw.trim()) {
if (soft)
onSoftResponse?.(undefined);
return undefined;
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
parsed = raw;
}
if (soft)
onSoftResponse?.(parsed);
return parsed;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** The HTTP status carried on a NodeManagementError from a non-2xx Manager
* response (managerFetch stores { url, status, body } in `details`). */
function errorStatus(err) {
if (err instanceof NodeManagementError && err.details && typeof err.details === "object") {
const s = err.details.status;
return typeof s === "number" ? s : undefined;
}
return undefined;
}
/** Does a fetched body look like ComfyUI's SPA catchall page (an HTML document)
* rather than a real Manager response? The frontend catchall answers an
* UNREGISTERED GET path with 200 + a page of HTML, so a 200 alone is not proof a
* route exists. A genuine queue-control GET returns an empty (or small non-HTML)
* body. */
function looksLikeHtml(body) {
return typeof body === "string" && /^\s*<(?:!doctype\b|html\b|!--)/i.test(body);
}
/**
* POST to a Manager queue CONTROL route (e.g. `${prefix}/start`), negotiating the
* HTTP method on a 405.
*
* Some legacy Manager 3.x builds register `/manager/queue/start` (and peers) as
* GET-only, so our POST comes back HTTP 405 Method Not Allowed (#551). On a
* Manager route a 405 means "wrong method for THIS endpoint on THIS build" — NOT
* "the Manager is unreachable" and NOT "this is a different Manager generation".
* So retry the SAME path once with GET before giving up; a GET-only build then
* starts its queue instead of failing the whole install.
*
* The GET retry is itself guarded against ComfyUI's frontend catchall: an
* UNREGISTERED start path 405s the POST but 200s the GET with a page of HTML, and
* that HTML must NOT be mistaken for a successful queue start (codex review). If
* the GET yields a catchall HTML page, the route genuinely doesn't accept our
* request — surface the original method error. Any non-405 error, or a
* non-2xx GET, propagates unchanged.
*/
async function managerQueueControl(path, base) {
try {
await managerFetch(path, { method: "POST", base });
}
catch (err) {
if (errorStatus(err) === 405) {
// GET-only legacy build — negotiate the method rather than declare failure.
logger.debug("Manager queue control 405 on POST; retrying as GET", { path });
const body = await managerFetch(path, { base }); // throws on a non-2xx GET
if (looksLikeHtml(body))
throw err; // catchall page — not a real GET-only start
return;
}
throw err;
}
}
// ---------------------------------------------------------------------------
// Manager API generation detection (issue #116)
//
// The /v2/manager/* routes exist ONLY on the Manager v4 lineage (the
// `manager-v4` branch / pip package, ≥4.x — what the RunPod image bakes).
// The RELEASED ComfyUI-Manager (3.x `main`, what ComfyUI-Manager installs by
// default) serves the same queue engine under /manager/* with PER-OPERATION
// routes and different body shapes. Probe once per target and adapt.
// ---------------------------------------------------------------------------
// Three dialects in the wild (issue #116 found the second; #235 the third):
// "v2" pip comfyui_manager (Manager v4) in its NORMAL mode — the
// unified POST /v2/manager/queue/task envelope.
// "v2-batch" pip comfyui_manager started with --enable-manager-legacy-ui
// (what e.g. the yanwk/comfyui-boot images hardcode): the pip
// package loads its BUNDLED 3.x server under the /v2 prefix.
// GET /v2/manager/queue/status exists, but POST .../task does NOT
// — mutations go through POST /v2/manager/queue/batch with 3.x
// body shapes ({install:[body], uninstall:[body], ...}). And
// because ComfyUI's frontend catchall matches every path for GET,
// an unregistered POST returns 405 (never 404) — the exact
// symptom of #235.
// "legacy" the 3.x custom-node Manager: per-operation /manager/queue/*.
//
// The detected dialect is cached by ./manager-api-cache.ts — URL-keyed AND
// TTL-bounded, and invalidated by the restart lifecycle, so a ComfyUI restart at
// the SAME URL (a Manager 3.x→4.x upgrade, dropping --enable-manager-legacy-ui)
// can no longer pin the pre-restart dialect forever (#646).
/** @internal — test hook so suites can pin/clear the detected generation. */
export function resetManagerApiCacheForTests(api) {
setManagerApiCacheForTests(managerBaseUrl(), api);
}
/**
* Re-classify the cached Manager dialect to "v2-batch" after the unified
* /v2/manager/queue/task route answered 405 AND a batch retry then SUCCEEDED.
* The /v2 queue surface is real (it answered a status during detection), so the
* build is the bundled 3.x server under /v2 whose `is_legacy_manager_ui` probe
* didn't identify it (#464). Pin the corrected dialect so subsequent operations
* skip the dead task route. Caller must only invoke this once the batch enqueue
* has actually succeeded, so a transient/proxy 405 can't poison the cache.
*
* `startEpoch` is the invalidation epoch from before the operation began: if
* ComfyUI restarted while the enqueue was in flight, this conclusion describes
* the OLD server and must not be pinned over the fresh invalidation (#646).
*/
function demoteManagerApiToV2Batch(base, startEpoch) {
// Guarded on the invalidation epoch ONLY (not the write counter): this verdict
// is backed by an enqueue that actually SUCCEEDED, so it legitimately
// supersedes a probe-derived classification that landed during the operation.
cacheManagerApi(base, "v2-batch", { epoch: startEpoch });
}
/** A real queue/status payload — guards detection against servers that answer
* 200 with HTML/junk for unknown GETs (SPA fallbacks, index catchalls). Accepts
* any of the queue-count fields the Manager reports: `total_count`/`is_processing`
* (both generations) plus `pending_count` (the v4-style field, issue #555) — so a
* v4 status shape is recognized authoritatively rather than falling through to a
* legacy conclusion. HTML/SPA bodies carry none of these, so they still fail. */
function looksLikeQueueStatus(s) {
if (!s || typeof s !== "object")
return false;
const q = s;
return (typeof q.total_count === "number" ||
typeof q.is_processing === "boolean" ||
typeof q.pending_count === "number" ||
typeof q.done_count === "number");
}
function managerQueueStatusKind(status) {
if (status === 404)
return "not-found";
if (status === 405)
return "method-not-allowed";
if (status >= 500)
return "server-error";
return "http-error";
}
/**
* Some local ComfyUI Desktop builds leave the Manager queue/status endpoints
* registered as successful routes but return no body (or an explicit Manager
* unavailable error). That is different from the frontend HTML catchall: the
* former is a stable, positive answer that no queue/status surface is
* available, while the latter is merely an unrecognized route response.
*/
function looksLikeManagerUnavailableResponse(body) {
if (looksLikeHtml(body))
return false;
const text = typeof body === "string"
? body
: body && typeof body === "object"
? Object.values(body)
.filter((value) => typeof value === "string")
.join(" ")
: "";
return /(?:comfyui[-\s]?manager|manager).{0,80}(?:not\s+reachable|unavailable|not\s+enabled|not\s+installed)/i.test(text);
}
function managerQueueResponseKind(body) {
if (looksLikeQueueStatus(body))
return "queue";
// A successful JSON `null` is the same absence of queue evidence as a 200
// with no body. Treat both as empty so a warm cached dialect cannot poll
// until timeout and then invite a re-issue (#1129).
if (body === undefined || body === null)
return "empty";
if (looksLikeManagerUnavailableResponse(body))
return "unavailable";
return "malformed";
}
/** Internal marker carried by the all-soft queue-generation probe. */
const MANAGER_QUEUE_DETECTION_FAILURE = "manager-queue-detection";
/**
* Probe both queue/status dialects while preserving their HTTP status. This is
* intentionally separate from `managerFetch(..., { soft: true })`, whose
* contract is to erase non-authentication failures for ordinary callers.
*
* `absent` is deliberately narrow: BOTH routes must answer HTTP 404 on the
* SAME captured base. Everything else that is not a recognizable queue payload
* is `unreadable`, including network errors, 5xx, auth/permission failures,
* malformed 2xx bodies, and a mixed 404/non-404 result.
*/
export async function probeManagerQueueAvailability(base = managerBaseUrl()) {
const statuses = [];
for (const path of ["/v2/manager/queue/status", "/manager/queue/status"]) {
let res;
try {
res = await comfyuiFetch(`${base}${path}`, { method: "GET" });
}
catch {
return "unreadable";
}
statuses.push(res.status);
if (res.ok) {
let raw;
try {
raw = await res.text();
}
catch {
return "unreadable";
}
if (!raw)
continue;
try {
if (looksLikeQueueStatus(JSON.parse(raw)))
return "available";
}
catch {
// A malformed response is not absence; continue so the other dialect
// still gets a chance to establish a real queue surface.
}
}
}
return statuses.length === 2 && statuses.every((status) => status === 404)
? "absent"
: "unreadable";
}
/**
* Repeat the queue/status probe with the response class preserved. The public
* availability helper intentionally exposes only its conservative three-state
* result; the install fallback needs the extra distinction between a stable
* empty/unavailable response and a timeout or malformed catchall.
*/
async function probeManagerQueueStatusKinds(base) {
const kinds = [];
for (const path of ["/v2/manager/queue/status", "/manager/queue/status"]) {
let kind = "hard-error";
try {
await managerFetch(path, {
base,
soft: true,
onSoftFailure: (status) => {
kind = status === undefined ? "transport" : managerQueueStatusKind(status);
},
onSoftTransportFailure: () => {
kind = "transport";
},
onSoftResponse: (body) => {
kind = managerQueueResponseKind(body);
},
});
}
catch {
// Authentication and other hard probe failures must not become local
// clone authorization, even though managerFetch had already observed a
// status before throwing.
kind = "hard-error";
}
kinds.push(kind);
}
return kinds;
}
function isManagerUnavailableQueueKinds(kinds) {
if (!Array.isArray(kinds) || kinds.length !== 2)
return false;
const allowed = new Set(["not-found", "empty", "unavailable"]);
if (!kinds.every((kind) => allowed.has(kind))) {
return false;
}
// Both 404s have their own already-shipped, stronger absence contract. This
// branch is for the new unavailable surface and therefore needs at least one
// positive empty/unavailable response rather than re-labelling 404 absence.
return kinds.some((kind) => kind === "empty" || kind === "unavailable");
}
async function probeManagerMajor(base) {
// v4's /v2/manager/version is the strongest signal; check it first.
const v4 = parseManagerMajor(await managerFetch("/v2/manager/version", { base, soft: true }));
if (v4 !== undefined)
return v4;
return parseManagerMajor(await managerFetch("/manager/version", { base, soft: true }));
}
async function probeManagerVersionEvidence(base) {
let refusedStatus;
let refused = false;
// Same response vocabulary the queue probes use — these two surfaces answer the
// same question about the same host, and a second private taxonomy is how the
// two drift into disagreeing about what "absent" means.
const kinds = [];
// ONE budget for the whole diagnosis, not one per route.
//
// managerFetch now races its own body reads (#2773). raceAbort still wraps
// these two probes so a late inner settlement cannot hold the diagnosis open
// after this budget fires — this is the diagnosis a caller reached after
// everything else had already failed.
const budget = managerBodyTimeoutSignal();
for (const path of MANAGER_VERSION_ROUTES) {
let kind = "hard-error";
// This route's own status. Recorded per-iteration, and promoted to
// `refusedStatus` ONLY from the catch below: a 404 on the first route must not
// be mistaken for the status that refused us on the second.
let statusSeen;
try {
const major = parseManagerMajor(await raceAbort(budget, () => managerFetch(path, {
base,
soft: true,
onSoftFailure: (status) => {
statusSeen = status;
kind = status === undefined ? "transport" : managerQueueStatusKind(status);
},
onSoftTransportFailure: () => {
kind = "transport";
},
// A 2xx that is not a version string — ComfyUI's SPA catchall, most of
// all — is malformed, never absence.
onSoftResponse: (body) => {
kind = body === undefined || body === null ? "empty" : "malformed";
},
})));
if (major !== undefined)
return { kind: "version", major };
}
catch {
// Swallow the throw — it must not replace the reader's queue-detection
// error with a secondary probe's — but classify it before moving on.
//
// NOT every throw here is an authentication refusal (codex gate round 3).
// Soft mode throws for 401/407, and a body-read rejection on a 2xx is not
// an auth status — a `refused = true` in every catch would answer a broken
// pipe with "configure your gateway credentials". managerFetch runs
// onSoftFailure BEFORE the auth throw, and never runs it on a 2xx — so a
// status is here exactly when the response was a real, non-ok answer we
// can classify.
if (statusSeen !== undefined && explainManagerAuthenticationRequired(statusSeen) !== "") {
refused = true;
refusedStatus ??= statusSeen;
}
// Anything else keeps `kind` at its "hard-error" seed and lands in
// `unreadable`, which claims nothing about whether Manager is there.
}
kinds.push(kind);
}
if (refused)
return { kind: "refused", status: refusedStatus };
return kinds.every((k) => k === "not-found") ? { kind: "absent" } : { kind: "unreadable", kinds };
}
/** The one sentence in this failure that is allowed to speak with certainty. */
const MANAGER_QUEUE_UNREACHABLE_LEDE = "ComfyUI-Manager's queue API is not reachable (neither /v2/manager/queue/status nor " +
"/manager/queue/status answered with a queue status)";
/**
* Choose the diagnosis the observations actually support (#2754).
*
* The old message had one arm and made the strongest available claim from the
* weakest available evidence. There are four states here because the host really
* can be in four of them, and three of them have fixes that the fourth's advice
* would send the reader away from.
*/
function managerQueueDetectionMessage(base, queueStatusKinds, version) {
if (version.kind === "version") {
const lede = `ComfyUI-Manager IS answering on ${base} — its version route reports generation ` +
`${version.major}.x — so this is NOT a missing or disabled Manager, and neither ` +
"installing the pip comfyui_manager package nor adding --enable-manager will fix it. ";
// The queue side deserves the same care as the version side (codex gate round
// 6). Two 404s mean the queue routes are genuinely not registered; a 503, a
// 405 or a timeout means we could not READ them, and a Manager that is merely
// busy or briefly sick must not be told its queue module failed to register.
return queueStatusKinds.every((kind) => kind === "not-found")
? lede +
"It serves NO queue API: both /v2/manager/queue/status and /manager/queue/status " +
"answered 404. What fits that is a Manager whose queue routes specifically are " +
"absent — a partial or older Manager server build, a queue module that failed to " +
"register, or a proxy in front of ComfyUI forwarding only some /manager routes. " +
"Check the ComfyUI log around Manager's startup, and retry once it serves a queue " +
"surface."
: lede +
"Its queue routes did not answer with a queue status either, but they did not say " +
`they are absent: /v2/manager/queue/status and /manager/queue/status came back ` +
`"${queueStatusKinds.join(", ")}". That is a queue surface we could not READ — a ` +
"5xx, a wrong-method reply, a timeout — not a Manager that lacks one, so this may " +
"well clear on its own. Check that ComfyUI is healthy and retry before concluding " +
"anything about Manager's queue API.";
}
if (version.kind === "refused") {
return (`${MANAGER_QUEUE_UNREACHABLE_LEDE}, and the version routes that would establish ` +
"whether Manager is present at all were REJECTED" +
(version.status !== undefined
? explainManagerAuthenticationRequired(version.status)
: " — an authentication failure, not evidence that ComfyUI-Manager is missing.") +
` Whether ComfyUI-Manager is installed on ${base} is therefore UNKNOWN from here; ` +
"clear the credential/proxy rejection and retry before concluding anything about " +
"Manager itself.");
}
// Absence is claimable only when all four probes were TOLD there is no such
// route. A 404 is a server answering; a 503, a proxy error page, a timeout or an
// HTML catchall is a route we could not read, and this branch used to spend
// those as absence too (codex gate round 2).
const provenAbsent = version.kind === "absent" && queueStatusKinds.every((kind) => kind === "not-found");
if (!provenAbsent) {
const seen = version.kind === "unreadable"
? version.kinds.join(", ")
: queueStatusKinds.join(", ");
return (`${MANAGER_QUEUE_UNREACHABLE_LEDE}, and whether ComfyUI-Manager is present could not ` +
`be established either: the probes came back "${seen}" rather than a clean 404. An ` +
"unreadable response — a 5xx, a proxy or gateway error page, a timeout, an HTML " +
"catchall — is NOT evidence that ComfyUI-Manager is absent, so do not reinstall or " +
"migrate Manager on the strength of this message. Check that the ComfyUI at " +
`${base} is healthy and reachable, then retry.`);
}
return (`${MANAGER_QUEUE_UNREACHABLE_LEDE}, and neither /v2/manager/version nor /manager/version ` +
`answered with a version either — every probe 404'd, so nothing at ${base} is serving ` +
"ComfyUI-Manager routes at all. Is ComfyUI-Manager installed and enabled on the " +
"connected ComfyUI? The pip comfyui_manager package only activates when ComfyUI is " +
"started with --enable-manager. NOTE: a custom_nodes/ComfyUI-Manager clone can import " +
"cleanly and still register no routes — ComfyUI logs `Blocked by policy: <path>` and " +
"skips the clone entirely when --enable-manager is set and the pip package shadows it " +
"(with --disable-manager-ui on top, that leaves no Manager routes at all), and the " +
"clone starts in CLI-only mode, serving nothing, when its .enable-cli-only-mode marker " +
"file is present.");
}
/**
* Given that the /v2 queue surface answered, decide between the two pip-Manager
* (v4) dialects. Both normal-v4 and legacy-UI mode register
* GET /v2/manager/is_legacy_manager_ui and answer truthfully; a missing route
* (older pip) means the normal v4 server → unified task dialect.
*/
async function resolveV2SubDialect(base) {
const legacyUi = await managerFetch("/v2/manager/is_legacy_manager_ui", { base, soft: true });
return legacyUi && typeof legacyUi === "object" && legacyUi.is_legacy_manager_ui === true
? "v2-batch"
: "v2";
}
/**
* In-flight detection, shared by every caller that misses the cache for the same
* base under the same epoch. Without this, N operations arriving just after the
* window lapses each fire their own probe round — a thundering herd against a
* server that is often exactly the one that just restarted (#646 review).
*
* The EPOCH is part of the join key: a detection that started before an
* invalidation is probing the pre-restart server, so a caller arriving after the
* reset must start its own rather than inherit that reading.
*/
let detectInflight = null;
/** How many times a detection will re-probe when it is invalidated mid-probe
* before giving the caller its latest reading anyway. A restart storm must not
* spin here; three readings is far past any real restart. */
const DETECT_INVALIDATION_RETRIES = 2;
export async function detectManagerApi(base = managerBaseUrl()) {
for (let attempt = 0;; attempt++) {
const cached = getCachedManagerApi(base);
if (cached !== undefined)
return cached;
const epochAtStart = managerApiEpoch();
let entry = detectInflight;
let created = false;
if (!(entry && entry.base === base && entry.epoch === epochAtStart)) {
entry = { base, epoch: epochAtStart, promise: probeManagerApi(base) };
detectInflight = entry;
created = true;
}
let api;
try {
api = await entry.promise;
}
finally {
// Only the caller that STARTED this probe clears the slot, and only if it
// is still ours — a joiner finishing early must not free the slot out from
// under a probe others could still share.
if (created && detectInflight === entry)
detectInflight = null;
}
// The commit was already dropped if something invalidated the cache while we
// probed — but the VALUE is just as stale, and returning it would route the
// caller's mutation at the server that just went away (#646 review). Probe
// again against whatever is there now.
if (managerApiEpoch() === epochAtStart)
return api;
if (attempt >= DETECT_INVALIDATION_RETRIES) {
// Every reading so far described a server that was already gone. Handing
// the last one back would send a mutation on a dialect we KNOW is stale, so
// refuse instead — nothing has been sent at this point, which makes this the
// one moment where failing is completely free.
throw new NodeManagementError("ComfyUI-Manager's API generation could not be determined: the connected ComfyUI kept " +
"being restarted (or retargeted) while comfyui-mcp was probing it, so every reading " +
"described a server that was already gone. NOTHING was sent to the Manager. Wait for " +
"ComfyUI to settle, then retry.");
}
logger.debug("Manager dialect detection invalidated mid-probe — re-probing", {
base,
dropped: api,
attempt,
});
}
}
async function probeManagerApi(base) {
// Stamp the cache state BEFORE probing: if ComfyUI is restarted — or another
// probe commits a fresher verdict — while these probes are in flight, this
// conclusion describes a server that may be gone and must not be pinned over
// the newer state (#646).
const stamp = managerApiCacheStamp();
const queueStatusCodes = [undefined, undefined];
const queueStatusKinds = ["hard-error", "hard-error"];
const v2 = await managerFetch("/v2/manager/queue/status", {
base,
soft: true,
onSoftFailure: (status) => {
queueStatusCodes[0] = status;
queueStatusKinds[0] =
status === undefined ? "transport" : managerQueueStatusKind(status);
},
onSoftTransportFailure: () => {
queueStatusKinds[0] = "transport";
},
onSoftResponse: (body) => {
queueStatusKinds[0] = managerQueueResponseKind(body);
},
});
if (looksLikeQueueStatus(v2)) {
const api = await resolveV2SubDialect(base);
cacheManagerApi(base, api, stamp);
return api;
}
const legacy = await managerFetch("/manager/queue/status", {
base,
soft: true,
onSoftFailure: (status) => {
queueStatusCodes[1] = status;
queueStatusKinds[1] =
status === undefined ? "transport" : managerQueueStatusKind(status);
},
onSoftTransportFailure: () => {
queueStatusKinds[1] = "transport";
},
onSoftResponse: (body) => {
queueStatusKinds[1] = managerQueueResponseKind(body);
},
});
if (looksLikeQueueStatus(legacy)) {
// /manager/queue/status answering ALMOST always means the released 3.x
// custom-node Manager — but do NOT brand it "legacy 3.x" (and speak 3.x
// grammar, and tell the user to upgrade) on the route shape alone (#555). A
// v4 host reached only through a proxy/back-compat shim could also answer
// here; CONFIRM the generation with the authoritative version string first.
// A 405/route-shape is NEVER the version signal — /v2/manager/version ("V4.x")
// vs /manager/version ("V3.x") is.
const major = await probeManagerMajor(base);
if (major !== undefined && major >= 4) {
// Version says v4 — but only speak v4 if its queue surface ACTUALLY
// validates now (re-probe): routing to v2 when /v2/manager/queue/status is
// unreachable would enqueue work we then can't poll → a false timeout and a
// duplicate on retry (codex review). If the v4 surface still doesn't answer,
// keep the proven, WORKING legacy classification (we hold a validated
// /manager queue endpoint) rather than route to a dead v4 surface.
const v2Retry = await managerFetch("/v2/manager/queue/status", {
base,
soft: true,
});
if (looksLikeQueueStatus(v2Retry)) {
const api = await resolveV2SubDialect(base);
cacheManagerApi(base, api, stamp);
return api;
}
}
cacheManagerApi(base, "legacy", stamp);
return "legacy";
}
// #2754 — the queue routes answered nothing. That is evidence about the QUEUE
// API, and the sentence below used to spend it on a much bigger claim: "is
// ComfyUI-Manager installed and enabled?", plus a --enable-manager pointer. The
// reporter's Manager was installed AND had imported cleanly (ComfyUI appends
// " (IMPORT FAILED)" to its `N seconds: <path>` line, and theirs had no suffix),
// so the one instruction we gave was the one thing that could not help.
//
// Same defect class as #2085, where an authenticating proxy's 401 was translated
// into "Manager is missing". The fix is the same shape: consult the AUTHORITATIVE
// version routes before absence may be claimed. They are the right witness here
// because they are disjoint from the queue surface — /v2/manager/version is
// registered only by v4 and /manager/version only by 3.x — so an answer on either
// proves Manager is serving HTTP on this base even when its queue routes 404.
const versionEvidence = await probeManagerVersionEvidence(base);
const managerVersionMajor = versionEvidence.kind === "version" ? versionEvidence.major : undefined;
throw new NodeManagementError(managerQueueDetectionMessage(base, queueStatusKinds, versionEvidence), {
kind: MANAGER_QUEUE_DETECTION_FAILURE,
base,
queueStatusCodes,
queueStatusKinds,
// The evidence the message was earned on, so a report carries it too.
// `managerVersionMajor` is undefined unless a version actually parsed;
// `managerVersionEvidence` is what distinguishes "the version routes said
// nothing" from "the version routes refused to answer".
managerVersionMajor,
managerVersionEvidence: versionEvidence.kind,
});
}
/** Queue route prefix for a dialect ("v2-batch" serves status/start under the
* /v2 prefix too — only the mutation routes differ). */
function managerQueuePrefixFor(api) {
return api === "legacy" ? "/manager/queue" : "/v2/manager/queue";
}
/** Prefix for non-queue Manager routes. The two pip-Manager dialects both
* serve their custom-node/config surface under /v2; only the standalone 3.x
* Manager uses unprefixed paths. */
export function managerApiPrefixFor(api) {
return api === "legacy" ? "" : "/v2";
}
/** Appended to every legacy-Manager operation failure so users know they're on
* the partial feature set and how to get off it. */
const MANAGER_UPGRADE_HINT = "NOTE: this ComfyUI runs the LEGACY ComfyUI-Manager 3.x. comfyui-mcp is optimized " +
"for Manager v4+ — on 3.x some operations degrade or are unavailable (notably " +
"arbitrary-URL model downloads, which 3.x whitelist-gates). RECOVERY — migrate to " +
"Manager v4 (one time, on the ComfyUI host): " +
"1) in ComfyUI's python env run `pip install -U comfyui_manager`; " +
"2) disable the old custom_nodes/ComfyUI-Manager clone so it no longer shadows the " +
"pip package (rename it to ComfyUI-Manager.disabled, or delete it); " +
"3) restart ComfyUI with --enable-manager, then retry the operation. " +
"See https://comfyui-mcp.artokun.io/docs/troubleshooting";
/** Appended to v2-batch (pip Manager in legacy-UI mode) failures. */
const MANAGER_LEGACY_UI_HINT = "NOTE: this ComfyUI runs Manager v4 (pip comfyui_manager) in LEGACY-UI mode " +
"(--enable-manager-legacy-ui), which serves the older 3.x API — some operations " +
"degrade (notably arbitrary-URL model downloads, which the 3.x code whitelist-gates). " +
"For the full v4 API, start ComfyUI without --enable-manager-legacy-ui (for " +
"yanwk/comfyui-boot images that flag is hardcoded in the entrypoint). " +
"See https://comfyui-mcp.artokun.io/docs/troubleshooting";
/**
* #1326 — what an install-model failure on legacy Manager is allowed to CLAIM.
*
* The sentence below used to be appended to every `install-model` failure on 3.x,
* unconditionally: "Arbitrary-URL model installs REQUIRE Manager v4+". For the URL
* whitelist that is right, and it is the common case. For a security_level refusal, a
* 404 URL, a full disk or a Manager bug it is a confident answer to a question nobody
* asked — and it sends the reader through a Manager migration that will not fix it.
*
* The reporter of #1326 saw exactly the ambiguous form: a bare
* `ComfyUI-Manager API 500 Internal Server Error for /manager/queue/install_model`,
* with our v4 sentence attached. Manager's own response body — the one thing that
* would have said which it was — was captured in `details.body` and never shown.
*
* So: three states, not two (#796). Say WHICH when the evidence decides it, offer the
* candidates when it does not, and show Manager's own words either way.
*/
function whitelistVerdict(details) {
const d = details;
const status = typeof d?.status === "number" ? d.status : undefined;
const body = typeof d?.body === "string" ? d.body : "";
// A security_level refusal is a DIFFERENT fault with a different fix (a Manager
// config setting, not a migration), and explainManagerForbidden already spells it
// out. Offering the whitelist beside it would give two causes for one failure.
//
// Keyed on the marker, not on the bare 403: a proxy or auth gate in front of ComfyUI
// also answers 403, and that is not Manager speaking at all.
if (status === 403 && /"error"\s*:\s*"security_level"/.test(body))
return "excluded";
// Manager 3.x names the model-list check when it rejects one. Only these positively
// establish it — anything else stays "unknown" rather than defaulting to the guess.
//
// Not applied to an HTML page: a proxy error or a framework traceback can contain the
// words "model list" without being Manager's rejection, and "proven" is the one branch
// that speaks with certainty. Manager answers JSON/plain text here.
if (/^\s*<|<html/i.test(body))
return "unknown";
if (/model[- _]?list|whitelist|not (?:in|found in) the model list|invalid model/i.test(body))
return "proven";
return "unknown";
}
/**
* Strip credentials out of text that is about to be shown to a user.
*
* Redacting only the values from `details.url` is NOT enough here, and getting that
* wrong would have leaked a token. `details.url` is the Manager ENDPOINT
* (`…/manager/queue/install_model`) — the MODEL url, which is the one carrying a
* Hugging Face / CivitAI token, travels in the POST BODY. Manager echoes that url back
* when the download fails ("failed fetching <url>"), so the secret arrives through a
* path an endpoint-only redaction never sees.
*
* So every url found in the text has its query VALUES redacted, whatever its origin,
* plus a few token shapes that travel outside a query string. Keys are kept: the reader
* still needs to see that a token was present.
*/
function redactCredentialsForDisplay(text, endpointUrl) {
let out = text;
const redactUrlValues = (raw) => {
let url;
try {
url = new URL(raw);
}
catch {
return; // not a parseable url — nothing known to redact
}
for (const value of url.searchParams.values()) {
// Short values are structural (page=2, type=vae), not secrets.
if (!value || value.length < 12)
continue;
for (const form of new Set([value, encodeURIComponent(value)])) {
out = out.split(form).join("«redacted»");
}
}
};
if (endpointUrl)
redactUrlValues(endpointUrl);
for (const found of text.match(/https?:\/\/[^\s"'<>)\\]+/gi) ?? [])
redactUrlValues(found);
// Token shapes that are not query values (an Authorization header echoed into a
// traceback, a bare token in a message).
out = out
.replace(/\b(hf_|sk-|ghp_|gho_|github_pat_)[A-Za-z0-9_-]{8,}/g, "«redacted»")
.replace(/\b([Bb]earer\s+)[A-Za-z0-9._~+/-]{12,}=*/g, "$1«redacted»");
return out;
}
/** Manager's own response body, bounded and stripped of credentials. This is the
* evidence #1326 lost. */
function managerBodyExcerpt(details) {
const d = details;
const raw = typeof d?.body === "string" ? d.body.trim() : "";
if (!raw)
return "";
const body = redactCredentialsForDisplay(raw, typeof d?.url === "string" ? d.url : undefined);
const MAX = 600;
const clipped = body.length > MAX ? `${body.slice(0, MAX)}… (truncated)` : body;
return ` ComfyUI-Manager said: ${clipped}`;
}
/** A bounded, credential-stripped per-task result from Manager queue history. */
function managerTaskResultExcerpt(result) {
const raw = typeof result === "string" ? result.replace(/\s+/g, " ").trim() : "";
if (!raw)
return "";
const body = redactCredentialsForDisplay(raw);
const MAX = 600;
return body.length > MAX ? `${body.slice(0, MAX)}… (truncated)` : body;
}
function safeManagerTaskDetails(record, result) {
return {
uiId: record.uiId,
kind: record.kind,
statusStr: record.statusStr,
...(result ? { result } : {}),
};
}
/**
* #2490 — a drained Manager queue is not a successful repair. On v4 the
* queue-wide counts are identical for a task that succeeded and one that
* errored; the per-task history record is the structured verdict.
*
* `unified_fix` fails with `not found: <id>@<ver>` and the worker logs
* `An error occurred while fixing '…'`. Those two strings are Manager's own
* proven-negative literals, so they are treated as failure even when
* `status_str` is omitted — inferring failure from any other prose is still
* refused (#1999).
*/
function managerTaskLooksFailed(record) {
if (!record)
return undefined;
const status = record.statusStr?.trim().toLowerCase();
const excerpt = managerTaskResultExcerpt(record.result);
if (status === "failed" || status === "error") {
return { status, excerpt };
}
const result = typeof record.result === "string" ? record.result : "";
if (/\bnot found\s*:/i.test(result) || /an error occurred while fixing/i.test(result)) {
return { status: status || "error", excerpt };
}
return undefined;
}
/** Wrap a legacy-Manager failure with the upgrade guidance (keeps details). */
function annotateLegacyError(err, kind, hint = MANAGER_UPGRADE_HINT) {
const base = err instanceof Error ? err.message : String(err);
const details = err instanceof NodeManagementError ? err.details : undefined;
let extra = "";
if (kind === "install-model") {
switch (whitelistVerdict(details)) {
case "proven":
extra =
" Arbitrary-URL model installs REQUIRE Manager v4+ (3.x only accepts whitelisted catalog models).";
break;
case "excluded":
// Deliberately silent: the 403 explanation is the answer, and adding the
// whitelist sentence beside it would offer a second, wrong cause.
break;
case "unknown":
// Still LEADS with the whitelist and keeps the upgrade path: on 3.x that is the
// usual cause and the migration below is the fix, which is real value (#553) and
// must not be hedged away. What changes is only the certainty — Manager did not
// say so here, and three other faults fail identically. Naming them costs a
// sentence; omitting them cost the reporter of #1326 a diagnosis.
extra =
" On Manager 3.x the usual cause is its model-list whitelist: arbitrary-URL model" +
" installs REQUIRE Manager v4+ (3.x only accepts whitelisted catalog models), and" +
" the recovery below is that upgrade. Manager did NOT say so explicitly here," +
" though — a security_level refusal, an unreachable URL, or no space on the host" +
" fail the same way. If the upgrade does not resolve it, the ComfyUI server log" +
" names which.";
break;
}
extra += managerBodyExcerpt(details);
}
return new NodeManagementError(`${base}${extra}\n${hint}`, details);
}
/** #1326 — the cause-attribution above is the whole user-visible product of a failed
* install-model, and it is reached only through a live Manager. Exposed so it can be
* asserted against real error shapes rather than through a mocked HTTP stack. */
export const __managerCauseTestHooks = {
annotateLegacyError,
whitelistVerdict,
managerBodyExcerpt,
NodeManagementError,
};
/**
* Translate one unified-task call into the legacy per-operation route + body.
* Body shapes verified against ComfyUI-Manager 3.41 glob/manager_server.py:
* install/uninstall/update/fix/disable key on `version` ('unknown' → treat
* `files[0]` as a git URL / folder name), and install (unlike v4) accepts a
* REAL git URL natively via { version:'unknown', files:[url] }.
*/
function legacyTaskRequest(kind, params, uiId) {
const nodeName = (params.node_name ?? params.id);
const version = params.node_ver || params.selected_version || "latest";
switch (kind) {
case "install":
return {
path: "/manager/queue/install",
body: {
ui_id: uiId,
id: params.id,
version: params.version ?? version,
selected_version: params.selected_version ?? version,
...(params.files ? { files: params.files } : {}),
...(params.repository ? { repository: params.repository } : {}),
...(params.pip ? { pip: params.pip } : {}),
channel: params.channel ?? "default",
mode: params.mode ?? "cache",
...(params.skip_post_install !== undefined
? { skip_post_install: params.skip_post_install }
: {}),
},
};
case "enable":
// Legacy has no enable route — the UI re-installs with skip_post_install.
return {
path: "/manager/queue/install",
body: {
ui_id: uiId,
id: nodeName,
version,
selected_version: version,
skip_post_install: true,
channel: "default",
mode: "cache",
},
};
case "uninstall":
return {
path: "/manager/queue/uninstall",
body: { ui_id: uiId, id: nodeName, version },
};
case "update":
return {
path: "/manager/queue/update",
body: { ui_id: uiId, id: nodeName, version },
};
case "fix":
return {
path: "/manager/queue/fix",
body: { ui_id: uiId, id: nodeName, version },
};
case "disable":
return {
path: "/manager/queue/disable",
body: { ui_id: uiId, id: nodeName, version },
};
case "install-model":
// Same model-item body — but 3.x validates it against the model-list
// whitelist, so arbitrary-URL installs are rejected (v4-only feature).
return { path: "/manager/queue/install_model", body: { ...params, ui_id: uiId } };
}
}
/**
* Tunable timing for queue polling. Defaults are production values; tests
* shrink these via setQueueTimingForTests to keep the suite fast.
*/
const queueTiming = {
pollIntervalMs: 1500,
// The worker thread is spawned asynchronously by /manager/queue/start, so a
// poll taken immediately afterward can read is_processing=false /
// in_progress_count=0 while the queued item is still pending (total_count
// counts queued items). Give the worker a grace window to spin up before
// treating an idle-looking status as "done".
startupGraceMs: 8000,
// Budget for node installs/updates/fixes: bounded by how long `pip install`
// and a git clone plausibly take.
timeoutMs: 600_000,
/**
* Budget for an `install-model` task (#817). A model download is NOT the same
* shape of work as a node install: 600s is a hard guarantee of failure for any
* multi-GB file on a normal link (a 15 GB file at 25 MB/s needs 600s just for
* the bytes, before any CDN slowness), and #817 is exactly that — the queue
* timing out at 600s every time.
*
* We cannot make the REMOTE transfer resumable — ComfyUI-Manager fetches
* server-side and exposes no per-task progress or byte count — so unlike the
* local path (#470) there is nothing here to bound an attempt AGAINST. All we
* can honestly do is stop declaring failure while the host is still working.
* The value is deliberately generous rather than tuned; the timeout's job is to
* stop us waiting forever, not to police the download.
*/
modelTimeoutMs: 4 * 60 * 60_000,
};
/** Per-kind queue budget. Only `install-model` gets the large one — a node
* install that hangs for four hours is a real failure worth reporting. */
function queueTimeoutFor(kind) {
if (kind !== "install-model")
return queueTiming.timeoutMs;
const raw = process.env.COMFYUI_MANAGER_DOWNLOAD_TIMEOUT_S;
if (raw !== undefined && raw.trim() !== "") {
const n = Number(raw);
if (Number.isFinite(n) && n > 0) {
return Math.min(24 * 60 * 60_000, Math.max(60_000, Math.round(n) * 1000));
}
logger.warn(`Ignoring COMFYUI_MANAGER_DOWNLOAD_TIMEOUT_S="${raw}" — not a positive number of seconds.`);
}
return queueTiming.modelTimeoutMs;
}
/** @internal — test hook to shrink polling timings; not part of the tool API. */
export function setQueueTimingForTests(overrides) {
Object.assign(queueTiming, overrides);
}
/**
* Extract counts from a queue/status payload. Returns undefined when required
* fields are missing or the counts are incoherent (negative pending — the
* #639 stale-3.x signature), so callers report "could not verify" instead of
* guessing.
*/
function countsFromStatus(status) {
if (!status || typeof status !== "object")
return undefined;
const s = status;
const inProgress = typeof s.in_progress_count === "number" ? s.in_progress_count : undefined;
if (inProgress === undefined)
return undefined;
let pending;
if (typeof s.pending_count === "number") {
pending = s.pending_count; // v4 reports it directly
}
else {
// 3.x: total_count = done + in_progress + queued exactly.
if (typeof s.total_count !== "number" || typeof s.done_count !== "number") {
return undefined;
}
pending = s.total_count - s.done_count - inProgress;
if (pending < 0)
return undefined; // incoherent counts — cannot verify anything
}
return {
pending,
inProgress,
processing: typeof s.is_processing === "boolean" ? s.is_processing : inProgress > 0,
};
}
/**
* Read the Manager queue counts ONCE, in the detected dialect — the measuring
* stick for the pin-write cancellation path (#689), which must report what a
* queue reset actually dropped rather than claim the panel was saved.
*
* These are SHARED, queue-wide counts: they can never say whether OUR task is
* among the pending ones (that needs fetchManagerClientQueueCounts /
* fetchManagerTaskHistoryEntry on v4).
*
* Returns undefined whenever the state cannot be PROVEN: detection failed,
* the status endpoint did not answer with queue counts, required fields are
* missing, or the counts are incoherent. The caller reports "could not
* verify" and keeps the pending-op marker rather than guessing in either
* direction.
*/
export async function fetchManagerQueueCounts(base = managerBaseUrl()) {
let api;
try {
api = await detectManagerApi(base);
}
catch {
return undefined;
}
const status = await managerFetch(`${managerQueuePrefixFor(api)}/status`, {
base,
soft: true,
});
return countsFromStatus(status);
}
/**
* v4-only: queue counts for tasks THIS orchestrator enqueued (every enqueue
* carries client_id "comfyui-mcp"). This is what separates "the update-all is
* still queued" from "UNRELATED tasks are queued" on a shared Manager — the
* distinction a proven cancel needs (#689 round 3).
*
* Returns undefined on any non-v4 dialect: the 3.x and legacy-UI (v2-batch)
* status handlers IGNORE the client_id parameter and answer GLOBAL counts,
* which would impersonate our tasks. Also undefined when unreadable or
* incoherent.
*/
export async function fetchManagerClientQueueCounts(base = managerBaseUrl()) {
let api;
try {
api = await detectManagerApi(base);
}
catch {
return undefined;
}
if (api !== "v2")
return undefined;
const status = await managerFetch(`/v2/manager/queue/status?client_id=${encodeURIComponent(MANAGER_CLIENT_ID)}`, { base, soft: true });
return countsFromStatus(status);
}
/**
* v4-only: look up ONE completed task in the Manager's queue history by exact
* ui_id (GET /v2/manager/queue/history?ui_id=…). Returns the entry when found,
* null when the Manager provably has NO such completed task ({}), and
* undefined when the answer cannot be trusted — non-v4 dialect (the legacy-UI
* history route knows only the file-based ?id= form, so it would not answer
* the question asked) or an unreadable response.
*/
export async function fetchManagerTaskHistoryEntry(uiId, base = managerBaseUrl()) {
let api;
try {
api = await detectManagerApi(base);
}
catch {
return undefined;
}
if (api !== "v2")
return undefined;
const res = await managerFetch(`/v2/manager/queue/history?ui_id=${encodeURIComponent(uiId)}`, { base, soft: true });
if (!res || typeof res !== "object" || !("history" in res))
return undefined;
const history = res.history;
if (!history || typeof history !== "object" || Array.isArray(history))
return undefined;
const h = history;
if (typeof h.ui_id !== "string") {
// {"history": {}} is the provable "no such completed task". Anything else
// shaped wrong is not an answer we can act on.
return Object.keys(h).length === 0 ? null : undefined;
}
// STRICT id check: an entry for a DIFFERENT task (a rewriting proxy, a
// Manager variant that ignores ui_id and answers with something else's
// history) must never prove anything about OUR task — least of all clear
// its marker as already-drained (#689 round 4).
if (h.ui_id !== uiId)
return undefined;
const status = h.status;
const statusStr = status && typeof status === "object" &&
typeof status.status_str === "string"
? status.status_str
: undefined;
return {
uiId: h.ui_id,
kind: typeof h.kind === "string" ? h.kind : undefined,
result: typeof h.result === "string" ? h.result : undefined,
statusStr,
};
}
/**
* Kick off the Manager queue worker and poll until it drains.
* Returns the final queue status.
*
* `api` is REQUIRED and must be the dialect the caller ACTUALLY enqueued with.
* Re-deriving the prefix from the cache here could start and poll a different
* generation's queue than the one holding our task (after a self-heal retry, or
* when a concurrent detection lands mid-operation), reporting a false failure for
* work that is really running — and inviting a caller retry that double-executes
* it (#646).
*/
async function runManagerQueue(api, base,
/** Selects the queue budget — a model download gets a far larger one than a
* node install (#817). Omitted ⇒ the node-install budget. */
kind) {
const prefix = managerQueuePrefixFor(api);
const timeoutMs = queueTimeoutFor(kind);
// queue/start returns 200 (worker started) or 201 (already running) — both
// are 2xx, so managerFetch accepts either. Same on both generations. Some
// legacy 3.x builds expose start as GET-only, so negotiate POST→GET on a 405
// (#551) rather than failing the queue on a method mismatch.
await managerQueueControl(`${prefix}/start`, base);
const start = Date.now();
let lastStatus;
while (Date.now() - start < timeoutMs) {
await sleep(queueTiming.pollIntervalMs);
let emptyStatusResponse = false;
const status = await managerFetch(`${prefix}/status`, {
base,
soft: true,
onSoftResponse: (body) => {
// managerFetch preserves JSON null, so it must be fail-closed exactly
// like an empty successful body. Otherwise a stale warm-cache install
// waits for the full queue budget instead of being classified as an
// ambiguous outcome immediately.
emptyStatusResponse = body === undefined || body === null;
},
});
if (emptyStatusResponse) {
await reclassifyAfterEmptyQueueStatus(api, kind, base);
}
if (status) {
lastStatus = status;
// Manager defines total_count = done + in_progress + queued. The queue
// is fully drained only once nothing is processing AND every item that
// was ever queued has completed (done_count >= total_count, which also
// implies in_progress_count === 0 and no queued items remain).
const drained = !status.is_processing && status.done_count >= status.total_count;
if (drained && Date.now() - start >= queueTiming.startupGraceMs) {
return status;
}
}
}
// TIMED OUT. Be precise about what that does and does NOT mean (#817): we
// stopped WAITING; the ComfyUI host's queue worker was never told to stop and,
// for a model download, is very likely still streaming. Reporting this as a
// plain failure is what led #817's reporter to re-issue — starting a SECOND
// server-side fetch of the same file, which is how the destination ended up
// truncated and failing safetensors shape checks. There is no Manager API to
// recall a queued task, so the only honest remedy is: look, don't re-issue.
throw new NodeManagementError(`ComfyUI-Manager queue did not finish within ${Math.round(timeoutMs / 1000)}s. ` +
(kind === "install-model"
? `This is the WAIT giving up, NOT proof the download failed — ComfyUI-Manager fetches the file ` +
`server-side and there is no API to stop or inspect an individual task, so the host is probably ` +
`still downloading. Do NOT re-issue the download: a second server-side fetch writes the SAME ` +
`destination file concurrently, and the interleaved result is a corrupt model (that is the ` +
`"shape is invalid for input of size N" failure). Instead, wait and check list_local_models on ` +
`the connected server until the file appears and its size stops changing. If your files are ` +
`genuinely larger than this budget, raise COMFYUI_MANAGER_DOWNLOAD_TIMEOUT_S (seconds), or ` +
`download to a LOCAL ComfyUI, where the transfer is streamed here — resumable, retried ` +
`automatically, and size-verified before anything is reported as landed.`
: `The Manager worker may still be running the task on the host; check its state before retrying.`), lastStatus);
}
/**
* A warm dialect cache can send an enqueue and queue-start request to a local
* ComfyUI whose Manager surface has since disappeared. Those routes may answer
* 2xx with empty bodies, so there is no enqueue HTTP error for the existing
* dialect self-heal to see. An empty queue/status response is the first
* production-path signal that the cached classification may be stale: clear it
* and classify the live server before waiting for the queue timeout.
*
* If a different usable dialect is found, the earlier empty enqueue is
* ambiguous and must not be re-sent. If both dialects remain empty/unavailable,
* detectManagerApi() preserves the queue-status evidence that the git-install
* fallback is allowed to re-check. Every other probe failure remains fail-closed.
*/
async function reclassifyAfterEmptyQueueStatus(used, kind, base) {
// Do not discard a newer classification another operation may have committed
// after this operation captured `used`. If the cache is already absent, the
// detector below will perform the needed fresh probe without another epoch
// bump; if it names another dialect, it is already the reclassification we
// need to observe.
if (getCachedManagerApi(base) === used) {
resetManagerApiCache(`Manager queue/status returned an empty successful response on the cached "${used}" dialect`);
}
let fresh;
try {
fresh = await detectManagerApi(base);
}
catch {
// Re-detection can itself conclude that both queue surfaces are empty or
// unavailable. That is useful classification evidence, but it is NOT proof
// that the enqueue already observed by this operation did not submit a task.
// Keep the post-enqueue ambiguity distinct so the local git fallback cannot
// race an accepted Manager task.
}
throw new NodeManagementError(`ComfyUI-Manager queue/status returned an empty successful response while the cached ` +
`"${used}" dialect was in use. The live server ` +
(fresh ? `reclassified as "${fresh}"` : `could not be reclassified`) +
`, but the ${kind ?? "Manager"} enqueue may already have submitted a task. Its ` +
`outcome is UNKNOWN; it was NOT retried automatically and no local fallback is ` +
`authorized. Verify whether it took effect before reissuing it.`, { kind: "manager-queue-empty-status", base, used, fresh });
}
function assertManagerEnqueueResponse(response, options, kind, api, path, base) {
const empty = response === undefined || response === null;
if (empty && api === "v2" && options.allowEmptyV2Enqueue) {
options.onEmptyV2Enqueue?.();
return;
}
if (!options.rejectEmptyEnqueue ||
!empty)
return;
throw new NodeManagementError(`ComfyUI-Manager returned a successful but empty response for the ${kind} enqueue ` +
`on the "${api}" dialect. The task may have been submitted, so its outcome is ` +
`UNKNOWN; the queue was not started and no local fallback is authorized. ` +
`Verify whether it took effect before reissuing it.`, {
kind: "manager-enqueue-empty-success",
operation: kind,
api,
path,
base,
});
}
/**
* Enqueue one operation on ComfyUI-Manager's unified task queue and drain it.
* Wraps the caller's per-kind `params` in the QueueTaskItem envelope the v2
* endpoint validates ({ ui_id, client_id, kind, params }) and returns the final
* queue status. `ui_id` is also threaded into params (Manager's models carry an
* optional ui_id for correlation).
*/
async function queueManagerTask(kind, params, base = managerBaseUrl(), options = {}) {
return (await queueManagerTaskTracked(kind, params, base, options)).status;
}
/**
* #1999 — the same enqueue+drain, but it also HANDS BACK the `ui_id` the task
* was enqueued under. A drained queue's counts are queue-wide and, on Manager
* v4, identical for a task that succeeded and one that errored (measured: an
* idle v4 answers `total_count:0` with a `done_count` that is the CUMULATIVE
* session history length). The ui_id is the only handle that can ask Manager
* what OUR task actually did — see fetchManagerTaskHistoryEntry.
*
* Callers that don't correlate keep using queueManagerTask above; nothing about
* the enqueue, drain, dialect self-heal or timeout behaviour differs.
*/
async function queueManagerTaskTracked(kind, params, base = managerBaseUrl(), options = {}) {
// Normalize to a resolver so every attempt builds its body for the dialect it
// is about to speak — a few operations (git installs) have dialect-specific
// params, and a self-heal retry must rebuild them, not just re-route them.
const resolve = typeof params === "function" ? params : () => params;
// The id is minted PER ATTEMPT, exactly as before. A self-heal retry is a
// genuinely second enqueue, so it gets its own id; the one recorded here is
// therefore the id of the attempt that LANDED (a failed attempt throws, so it
// never reaches the drain, and a v2→v2-batch downgrade inside a single attempt
// reuses that attempt's id).
let uiId = "";
// This is a mutation transaction, so freeze its target before the first await.
// A later setComfyuiTarget() selects where NEW calls go; it must not redirect a
// retry, queue start, or verification of a request the user already made.
const used = await enqueueWithDialectSelfHeal(kind, (api, epoch) => {
uiId = randomUUID();
return enqueueManagerTask(api, kind, resolve, uiId, base, epoch, options);
}, base);
// Thread the kind so a model download gets the model budget, not the node one (#817).
const status = await runManagerQueue(used, base, kind);
return { status, uiId };
}
/**
* Run ONE enqueue against the detected Manager dialect, healing a stale
* classification (#646). `enqueue` must only ENQUEUE — never drain — because a
* re-send is sound only while nothing has been queued yet; it is handed the
* dialect to speak and the invalidation epoch the operation started under.
*
* EVERY operation routed through here is a MUTATION (install, uninstall, update,
* fix, enable/disable, install-model, update-all, Manager self-update) and Manager
* has no idempotency key, so a re-sent request is a genuinely second operation.
* The enqueue is therefore re-sent ONLY when the failure PROVES nothing ran — a
* 404/405 route rejection — and only when a fresh probe shows the dialect really
* changed. It is called at most twice, never recursively.
*
* On an AMBIGUOUS failure that also indicates a dialect change (a 400: possibly a
* validation rejection, possibly a handler that already acted) the classification
* is still refreshed, but the operation is NOT re-sent — the caller gets an
* actionable error and decides. See AMBIGUOUS_MISMATCH_STATUSES.
*/
async function enqueueWithDialectSelfHeal(label, enqueue, base = managerBaseUrl()) {
const api = await detectManagerApi(base);
// Epoch snapshot taken AFTER detection, i.e. spanning exactly the enqueue whose
// outcome a conclusion would be based on. Taking it earlier would also cover the
// detection — and since a detection invalidated mid-probe now re-probes and
// returns a POST-restart dialect, that stale epoch would then veto a perfectly
// good conclusion drawn from it (the #464 v2→v2-batch demotion would never
// re-pin, leaving every later op to 405 on /queue/task first — #646 review).
const startEpoch = managerApiEpoch();
try {
return (await enqueue(api, startEpoch)) ?? api;
}
catch (err) {
const fresh = await redetectAfterDialectMismatch(api, label, err, base);
if (fresh === undefined)
throw err;
if (!ROUTE_MISMATCH_STATUSES.has(errorStatus(err))) {
// The dialect DID change, but this failure doesn't prove the request went
// unexecuted, so re-sending it could run the mutation twice. Refuse, and
// hand the user everything needed to decide (#646 review).
throw dialectChangedError(label, api, fresh, err);
}
// A route-level rejection: no handler ran, so nothing was enqueued —
// re-enqueue ONCE in the dialect the live server actually speaks.
return (await enqueue(fresh, managerApiEpoch())) ?? fresh;
}
}
/**
* Enqueue a task without draining the Manager queue. This is the small shared
* seam for tools that deliberately batch several mutations before starting the
* worker. It retains the same cached-dialect self-heal as queueManagerTask:
* only a 404/405 route rejection is re-sent, never an ambiguous handler error.
*/
export async function enqueueManagerTaskForExternal(kind, params, base = managerBaseUrl()) {
return enqueueWithDialectSelfHeal(kind, (api, epoch) => enqueueManagerTask(api, kind, typeof params === "function" ? params : () => params, randomUUID(), base, epoch), base);
}
/** Start the queue that received an externally enqueued task. */
export async function startManagerQueueForExternal(api, base = managerBaseUrl()) {
await managerQueueControl(`${managerQueuePrefixFor(api)}/start`, base);
}
/**
* The classification was stale AND the failure can't be proven pre-execution, so
* the operation was deliberately not re-sent. Say exactly that: which dialect we
* spoke, which one the server actually speaks now, that the classification is
* already corrected, and that the previous attempt's effect is UNKNOWN — so the
* user verifies before reissuing rather than blindly repeating a mutation.
*/
function dialectChangedError(label, was, now, cause) {
const base = cause instanceof Error ? cause.message : String(cause);
return new NodeManagementError(`${base}\nThe ComfyUI-Manager API dialect CHANGED under this connection: the request ` +
`was sent as "${was}", but the live server now answers as "${now}" (ComfyUI was ` +
`restarted with a different Manager generation at the same URL). comfyui-mcp has ` +
`re-detected the dialect, so the next call routes correctly. The "${label}" request ` +
`was NOT retried automatically: this failure does not prove the server left it ` +
`unexecuted, and Manager offers no idempotency key, so an automatic retry could run ` +
`it TWICE. VERIFY whether it took effect (install_custom_node (action:"list") / list_local_models, ` +
`or the ComfyUI server log), then reissue it if it did not.`, cause instanceof NodeManagementError ? cause.details : undefined);
}
/**
* ROUTE-level rejections: the request never reached a handler, so the operation
* PROVABLY did not run and re-sending it cannot double-execute anything. On a
* Manager route this is exactly 404/405 — ComfyUI's frontend catchall answers an
* unregistered POST with 405 (never 404), and both mean "no such route on this
* build", never "the Manager is unreachable". These are the ONLY statuses that
* may trigger an automatic re-enqueue.
*/
const ROUTE_MISMATCH_STATUSES = new Set([404, 405]);
/**
* A 400 is AMBIGUOUS. It can mean a stale dialect — a v4 Manager rejecting a
* 3.x-shaped install_model body is the #646 report itself — but it can equally
* come from a handler that already did the work and failed afterwards, or from a
* proxy on the response path. Manager exposes no idempotency key, so a re-sent
* mutation is a genuinely SECOND operation; there is no way to make that safe.
*
* So a 400 refreshes the CLASSIFICATION but never re-sends the request: if the
* re-probe shows the dialect really did change, the caller gets an actionable
* error naming both dialects and is left to decide whether to reissue. Refusing
* is always correct here; guessing is not.
*
* Everything else (403 security_level gating, 5xx, transport failures, queue-drain
* timeouts) is not a dialect signal at all and triggers neither.
*/
const AMBIGUOUS_MISMATCH_STATUSES = new Set([400]);
/**
* Decide whether a failed enqueue was the CACHED DIALECT being stale, and if so
* return the dialect the live server actually speaks now (undefined = no dialect
* news; the caller surfaces its original failure).
*
* A restart at the same URL can swap the Manager generation underneath a cached
* classification (#646). The explicit restart-lifecycle invalidation and the TTL
* cover the common paths; this is the per-call backstop so ONE stale entry heals
* itself instead of failing every subsequent Manager operation. It only reports
* the change — whether the operation may be re-sent is the caller's decision,
* made from the STATUS (see ROUTE_MISMATCH_STATUSES vs AMBIGUOUS_*).
*
* Three guards keep this cheap and non-looping:
* • only the two mismatch-capable status classes get here at all; 403/5xx and
* transport failures never cost a probe;
* • a verdict of "unchanged" (or an unreachable Manager) ARMS A COOLDOWN, so an
* endpoint that fails the same way on every call cannot buy a probe per call —
* without it, each re-check resets the entry the previous one repopulated;
* • the cooldown is cleared by any resetManagerApiCache(), i.e. by every real
* lifecycle event, so it can never delay reacting to an actual restart.
*/
/**
* In-flight "reset + re-detect" transaction, shared per base URL. The re-check is
* NOT just a detection: it resets the cache first, which bumps the epoch — so
* without sharing, N concurrent failures would each reset (invalidating each
* other's in-flight probe, since the detector joins only on a matching epoch) and
* each run a full probe round. One transaction serves them all (#646 review).
*/
let recheckInflight = null;
async function sharedDialectRecheck(base, label, status, used) {
const joined = recheckInflight;
if (joined?.base === base)
return joined.promise;
const run = async () => {
resetManagerApiCache(`manager ${label} enqueue failed ${status} on the "${used}" dialect`);
const epoch = managerApiEpoch();
try {
return { fresh: await detectManagerApi(base), epoch };
}
catch {
// Manager isn't answering at all — that's not a dialect mismatch; the
// caller surfaces its own (more informative) failure. Don't re-probe on
// every subsequent call while it stays down, unless a lifecycle event has
// since told us something new.
if (managerApiEpoch() === epoch) {
suppressDialectRecheck(base, "manager unreachable during re-check");
}
return { fresh: undefined, epoch };
}
};
const entry = { base, promise: run() };
recheckInflight = entry;
try {
return await entry.promise;
}
finally {
if (recheckInflight === entry)
recheckInflight = null;
}
}
async function redetectAfterDialectMismatch(used, label, err, base = managerBaseUrl()) {
const status = errorStatus(err);
if (status === undefined ||
!(ROUTE_MISMATCH_STATUSES.has(status) || AMBIGUOUS_MISMATCH_STATUSES.has(status))) {
return undefined;
}
if (dialectRecheckSuppressed(base)) {
logger.debug("Skipping Manager dialect re-check (cooldown)", { op: label, status });
return undefined;
}
const { fresh, epoch: epochAfterReset } = await sharedDialectRecheck(base, label, status, used);
if (fresh === undefined)
return undefined;
if (fresh === used) {
// Nothing changed, so this failure was never about the dialect. Arm the
// cooldown — but only if no lifecycle event landed since the re-check began,
// otherwise a stale continuation would suppress re-checks against a server
// we now know nothing about (#646 review).
if (managerApiEpoch() === epochAfterReset) {
suppressDialectRecheck(base, `dialect confirmed unchanged ("${used}") after a ${status}`);
}
return undefined;
}
logger.info("Manager API dialect changed under a cached classification", {
op: label,
was: used,
now: fresh,
status,
// A route-level rejection proves nothing ran, so the caller re-sends once;
// anything else is refused rather than risking a double-execute.
resend: ROUTE_MISMATCH_STATUSES.has(status),
});
return fresh;
}
/**
* ENQUEUE one operation in the given dialect, WITHOUT draining the queue.
*
* The split matters twice over. For #424, it lets a caller tell an ENQUEUE
* failure apart from a later queue-control/drain failure: a 405 raised by
* `${prefix}/start` inside runManagerQueue says nothing about whether the
* OPERATION's own route exists, so it must never be read as "this build doesn't
* register that route". For #646, it is what makes the dialect self-heal sound:
* queueManagerTask retries this step and only this step, and a retry is only safe
* while nothing has been queued yet. Draining happens once, afterwards.
*
* Returns the dialect the enqueue ACTUALLY spoke, so the drain polls the queue
* that holds the task.
*/
async function enqueueManagerTask(api, kind, resolveParams, uiId, base, startEpoch, options = {}) {
if (api === "legacy") {
const response = await enqueueLegacyTask(kind, resolveParams("legacy"), uiId, base);
assertManagerEnqueueResponse(response, options, kind, api, "/manager/queue/install", base);
return "legacy";
}
if (api === "v2-batch") {
const response = await enqueueV2BatchTask(kind, resolveParams("v2-batch"), uiId, base);
assertManagerEnqueueResponse(response, options, kind, api, "/v2/manager/queue/batch", base);
return "v2-batch";
}
// v2 unified task envelope (normal-mode pip Manager v4).
try {
const response = await managerFetch("/v2/manager/queue/task", {
method: "POST",
base,
body: {
ui_id: uiId,
client_id: MANAGER_CLIENT_ID,
kind,
params: { ...resolveParams("v2"), ui_id: uiId },
},
});
assertManagerEnqueueResponse(response, options, kind, api, "/v2/manager/queue/task", base);
}
catch (err) {
if (errorStatus(err) === 405) {
// Detection chose the unified /v2 task dialect (the /v2 queue surface
// answered a real status during detectManagerApi), yet THIS build 405s a
// POST to /v2/manager/queue/task. That is the bundled 3.x server served
// under /v2 — a legacy-UI pip Manager whose `is_legacy_manager_ui` probe
// did NOT answer truthfully, so resolveV2SubDialect defaulted to "v2"
// (#464: panel_update_node → "Manager …/queue/task: HTTP 405" on a
// legacy-dialect Manager). A 405 on a Manager route is a method/route
// signal, never "unreachable" — mirror the queue/start POST→GET
// negotiation (#551/#586) by retrying via the v2-batch envelope instead
// of surfacing the raw 405.
logger.debug("Manager /v2/manager/queue/task 405 — retrying via the v2-batch envelope", { kind });
// Rebuild the body for the dialect we are downgrading TO: this build runs
// the 3.x handlers under /v2, so a dialect-specific body (a git install)
// must be its 3.x shape, not the v2 one we just tried.
const response = await enqueueV2BatchTask(kind, resolveParams("v2-batch"), uiId, base);
assertManagerEnqueueResponse(response, options, kind, "v2-batch", kind === "install-model" ? "/v2/manager/queue/install_model" : "/v2/manager/queue/batch", base);
// Pin the corrected dialect ONLY after the batch enqueue actually
// succeeded, so a transient/proxy 405 on a genuine v4 host (task 405 but
// batch also unavailable) never poisons the cache: enqueueV2BatchTask
// throws on a batch failure, leaving the cache as "v2" so the next op
// re-probes the unified task route (codex review).
demoteManagerApiToV2Batch(base, startEpoch);
return "v2-batch";
}
throw err;
}
return "v2";
}
/**
* Enqueue one operation on the released Manager 3.x per-operation routes
* (issue #116 — the unified /v2 task route 405s there). Does NOT drain.
*/
async function enqueueLegacyTask(kind, params, uiId, base) {
const { path, body } = legacyTaskRequest(kind, params, uiId);
try {
return await managerFetch(path, { method: "POST", body, base });
}
catch (err) {
throw annotateLegacyError(err, kind);
}
}
/**
* Enqueue one operation on the v2-batch dialect — pip Manager in legacy-UI mode
* (issue #235): the /v2 prefix serves the BUNDLED 3.x server, which has no
* unified task route (a POST there 405s via the frontend catchall). Mutations
* take 3.x body shapes wrapped in the batch envelope {<op>: [body, ...]}; the
* batch runs its items synchronously and reports failures as {failed: [id, ...]}.
* install-model keeps its dedicated route (same path as v4, 3.x whitelist
* semantics). Does NOT drain.
*/
async function enqueueV2BatchTask(kind, params, uiId, base) {
const { path, body } = legacyTaskRequest(kind, params, uiId);
try {
if (kind === "install-model") {
return await managerFetch("/v2/manager/queue/install_model", { method: "POST", body, base });
}
else {
// legacyTaskRequest's path is "/manager/queue/<op>"; the batch key is
// that trailing op ("enable" maps to an install body → key "install").
const op = path.split("/").pop();
const res = await managerFetch("/v2/manager/queue/batch", {
method: "POST",
body: { [op]: [body] },
base,
});
const failed = Array.isArray(res?.failed) ? res.failed : [];
if (failed.length && (body.id === undefined || failed.includes(body.id))) {
throw new NodeManagementError(`ComfyUI-Manager batch reported the ${op} of "${String(body.id ?? "?")}" as failed ` +
"(check the ComfyUI server log for the underlying error — security_level " +
"gating is a common cause).");
}
return res;
}
}
catch (err) {
throw annotateLegacyError(err, kind, MANAGER_LEGACY_UI_HINT);
}
}
// ---------------------------------------------------------------------------
// Official comfy-cli subprocess helper
// ---------------------------------------------------------------------------
const COMFY_CLI_TIMEOUT = 600_000;
// A real custom-node repo clones well under this; with prompts disabled a
// missing/private repo fails in ~1s rather than blocking the whole timeout.
const GIT_CLONE_TIMEOUT = 180_000;
/** Env for git network ops that must NEVER block on an interactive credential
* prompt. Without this, `git clone`/`fetch` of a missing or private repo waits
* on a stdin username/password prompt and hangs until the timeout (observed as a
* multi-minute "thinking" stall on a bad URL). GIT_ASKPASS=echo + the prompt
* flags make git fail fast instead. GITHUB_TOKEN is passed through when set. */
export function nonInteractiveGitEnv() {
return {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "echo",
GCM_INTERACTIVE: "never",
...(config.githubToken ? { GITHUB_TOKEN: config.githubToken } : {}),
};
}
/**
* Run an official `comfy node` subcommand. Returns normalized JSON data.
* Throws ProcessControlError when no local workspace is available (remote
* mode). `workspace` is the CALL-SCOPED local ComfyUI root, captured by the
* caller BEFORE its first await — a retarget mid-operation must not send the
* CLI at a different install than the pre/post checks described.
*/
function runCmCli(args, workspace) {
// THE CHOKE-POINT (codex gate P0). An earlier fix put this guard in
// `comfyCliUnavailableReason`, which install/enable/disable/uninstall consult —
// but `update`, `reinstall`, `fix all` and dependency sync call the CLI without
// going through it, so a stale local COMFYUI_PATH was still mutable in remote
// mode by four other routes. Every comfy-cli invocation passes through HERE, so
// this is where the refusal belongs. Nothing has run yet at this point, which is
// what makes refusing the right shape.
const resolvedWorkspace = workspace ?? resolveEffectiveComfyUIBase();
if (isRemoteMode()) {
throw new ProcessControlError("This session targets a REMOTE ComfyUI (--comfyui-url), and comfy-cli only acts on a " +
"LOCAL install" +
(workspace ?? config.comfyuiPath ?? config.comfyuiCodePath
? ` — running it would modify ${workspace ?? config.comfyuiPath ?? config.comfyuiCodePath}, which is NOT the ` +
`server you are connected to`
: "") +
". Nothing was run. Use the ComfyUI-Manager HTTP path, which addresses the server you " +
"are actually connected to, or run comfy-cli on the machine the install lives on.");
}
const ws = resolvedWorkspace;
if (!ws) {
throw new ProcessControlError("This operation requires a local ComfyUI install. Set COMFYUI_PATH, " +
"a default workspace (workspace action 'set_default'), or use the Manager HTTP API. " +
"On --base-directory deployments this is the data/base directory the runtime scans, " +
"not COMFYUI_CODE_PATH.");
}
logger.info("Running comfy-cli", { args: ["node", ...args].join(" ") });
try {
const envelope = assertComfyCliOk(runComfyCliSync(["node", ...args], {
workspace: ws,
timeoutMs: COMFY_CLI_TIMEOUT,
env: config.githubToken ? { GITHUB_TOKEN: config.githubToken } : undefined,
}));
return JSON.stringify(envelope.data ?? {}, null, 2);
}
catch (error) {
const processError = error;
if (processError.code === "ENOENT") {
throw new ProcessControlError("The comfy-cli executable could not be started. Check COMFY_CLI_PATH and PATH.");
}
throw new NodeManagementError(`comfy-cli node ${args[0]} failed: ${processError.message}`, {
stdout: processError.stdout?.toString() ?? "",
stderr: processError.stderr?.toString() ?? "",
});
}
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Normalize the /customnode/installed response into InstalledNode[].
* Manager returns an object keyed by module name (see manager_core
* get_installed_node_packs), each value carrying { ver, cnr_id, aux_id, enabled }.
* Older/variant builds may return an array; handle both.
*/
function parseInstalled(raw) {
if (!raw || typeof raw !== "object")
return [];
const toNode = (module, v, moduleKey) => ({
module,
...(moduleKey ? { moduleKey } : {}),
cnrId: typeof v.cnr_id === "string" && v.cnr_id.length > 0 ? v.cnr_id : undefined,
auxId: typeof v.aux_id === "string" && v.aux_id.length > 0 ? v.aux_id : undefined,
version: typeof v.ver === "string" ? v.ver : undefined,
// `enabled` is reported as a boolean on current builds; an explicit
// is_disabled flag is honored too. When NEITHER is present the state is
// UNKNOWN — left undefined, never defaulted to a definite value.
enabled: typeof v.enabled === "boolean"
? v.enabled
: typeof v.is_disabled === "boolean"
? !v.is_disabled
: undefined,
});
if (Array.isArray(raw)) {
return raw
.filter((entry) => Boolean(entry && typeof entry === "object"))
.map((entry) => {
const module = (typeof entry.title === "string" && entry.title) ||
(typeof entry.module === "string" && entry.module) ||
(typeof entry.cnr_id === "string" && entry.cnr_id) ||
"unknown";
// #2714 — `module` above may be a human TITLE, so it is NOT safe to read as
// a directory name. Carry the key the payload actually stated separately;
// `module`'s own precedence is left exactly as it was, because it is what
// Manager is sent back as `node_name` and what this list displays.
return toNode(module, entry, typeof entry.module === "string" ? entry.module : undefined);
});
}
return Object.entries(raw)
.filter(([, v]) => Boolean(v && typeof v === "object"))
.map(([module, v]) => toNode(module, v, module));
}
function stripUrlSuffix(value) {
return value.replace(/[?#].*$/, "").replace(/\/+$/, "");
}
/**
* Is this version string the "newest, whatever that is" SENTINEL (#1254)?
*
* Exported for the registry path's own translation of the same word, so the two
* cannot drift into disagreeing about what "latest" means. Case- and
* whitespace-insensitive because it arrives from a tool argument, not from git.
*/
export function isLatestSentinel(version) {
return typeof version === "string" && version.trim().toLowerCase() === "latest";
}
/**
* #1470 — is this the CHANNEL word "nightly" rather than a git ref?
*
* The word is overloaded in our own surface, which is what made the bug: for a Manager
* install it names the git-HEAD channel — one of our paths even MINTS it, rewriting an
* absent/"latest" version to "nightly" because the Manager rejects a registry "latest" for
* a repository-style entry — while for a from-source git install `version` is documented as
* a git ref. A user who types `version:"nightly"` may mean either.
*
* So this does NOT decide the meaning on its own (codex): collapsing it to "no ref" would
* silently install the DEFAULT branch of a repository that genuinely has a `nightly` branch,
* and a quietly-wrong version is worse than the loud failure being fixed. It is used only to
* choose what happens when the checkout FAILS — see the clone path, which keeps the clone at
* HEAD and says so instead of deleting it.
*/
export function isGitHeadChannel(version) {
return typeof version === "string" && version.trim().toLowerCase() === "nightly";
}
/**
* #1470 — what to do with a ref the freshly-cloned repository does NOT have.
*
* Decided from a rev-parse BEFORE the checkout, never by catching its failure (codex r2).
* Catching was wrong twice over: it swallowed unrelated failures — a corrupt repo, a
* permissions error — as "no such branch", and it claimed the clone was left at a usable
* HEAD after a checkout that had just failed in an unknown way. Asking first means a real
* checkout error still throws, and the skip path never runs a command at all, so the clone
* is exactly as `git clone` left it.
*
* Only a ref that came from `version` may be skipped. An explicit `ref:` is the caller
* naming a git ref, and quietly installing something else because the name happens to spell
* a channel word would be the wrong-version bug in a new place.
*/
export function checkoutPlanForMissingRef(opts) {
return opts.fromVersion && isGitHeadChannel(opts.ref) ? "skip-at-head" : "fail";
}
/**
* Which git ref an install should check out, or undefined for "leave the clone
* where it landed" (#1254).
*
* "latest" IS NOT A GIT REF. It is this tool's own word for "whatever is
* newest", and `version` defaults to it — so a plain
* install_custom_node(source:"git", version:"latest") fell straight through to
* `git checkout --detach --end-of-options latest` and failed. Worse than a
* failed command: the checkout failure discards the clone as a husk, so a clone
* that had already succeeded left a partially installed node behind.
*
* A fresh clone is ALREADY on the default branch, which is exactly what "latest"
* means for a git source, so the answer is to check out nothing. That also
* restores the shallow clone — the full history is fetched only because a
* concrete ref might need it.
*
* AN EXPLICIT ref is honoured LITERALLY, sentinel word or not. `ref` and a
* `#ref` in the URL are the caller naming a ref, and repositories do carry tags
* called `latest`; ignoring one would check out a different commit than the one
* asked for — a wrong answer, where the bug this fixes was only a failure.
*
* A separate exported function rather than an expression inline, so the rule can
* be tested as behaviour instead of asserted against source text.
*/
export function gitRefForInstall(opts) {
// `??` already collapses parseGitUrl's `null` "no fragment" into undefined, so
// there is no separate null check here — one would read as load-bearing and
// never fire.
const explicit = opts.ref ?? opts.urlRef ?? undefined;
if (explicit !== undefined)
return explicit;
// "latest" is collapsed because it is NEVER a ref anyone means (#1254). "nightly" is
// deliberately NOT (#1470): it is overloaded — this tool's git-HEAD channel AND a plausible
// branch name — so it is offered to git as a ref, and the clone path decides what a FAILED
// checkout of it means. Collapsing it here would silently install the default branch of a
// repository that genuinely has one (codex).
return isLatestSentinel(opts.version) ? undefined : opts.version;
}
/**
* #1470 — does the cloned repository actually have this ref? Asked BEFORE the checkout so a
* missing ref is distinguishable from a checkout that failed for any other reason.
*
* A missing ref is proven with `show-ref`'s status 1. Any other failure is a probe error:
* a missing directory, unreadable/corrupt repository, or failed fetch must not be mistaken
* for a missing channel ref.
*/
function gitFetchAllTags(nodeDir, cwd) {
// The SAME fetch runGitCheckout performs, hoisted so the existence probe below sees
// everything a checkout would (codex r3). A clone does not necessarily bring down every
// tag, so probing first would report an orphan/unreachable `nightly` TAG as missing and
// silently leave the clone at HEAD — while the original checkout would have fetched it and
// succeeded. That is the silent-wrong-version failure this whole change exists to avoid,
// reintroduced by the probe meant to prevent it.
//
try {
execFileSync("git", ["-C", nodeDir, "fetch", "--all", "--tags"], {
cwd,
encoding: "utf-8",
timeout: GIT_CLONE_TIMEOUT,
env: nonInteractiveGitEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
}
catch (err) {
throw new GitRefProbeError("git fetch --all --tags", err);
}
}
function gitShowRefExists(nodeDir, refPath, cwd) {
try {
// refPath is always constructed with a refs/* prefix; no user string is
// passed as an option, and --verify requires an exact ref path.
execFileSync("git", ["-C", nodeDir, "show-ref", "--verify", "--quiet", refPath], {
cwd,
encoding: "utf-8",
timeout: GIT_CLONE_TIMEOUT,
env: nonInteractiveGitEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
return true;
}
catch (err) {
const status = err?.status;
if (status === 1)
return false;
throw new GitRefProbeError(`git show-ref --verify ${refPath}`, err);
}
}
function gitRemoteRefFor(nodeDir, ref, cwd) {
let output;
try {
output = execFileSync("git", ["-C", nodeDir, "for-each-ref", "--format=%(refname)", "refs/remotes"], {
cwd,
encoding: "utf-8",
timeout: GIT_CLONE_TIMEOUT,
env: nonInteractiveGitEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
}
catch (err) {
throw new GitRefProbeError("git for-each-ref refs/remotes", err);
}
const canonicalRemoteRef = `refs/remotes/origin/${ref}`;
const matches = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line === canonicalRemoteRef);
return matches[0];
}
function resolveGitHeadRef(nodeDir, ref, cwd) {
for (const prefix of ["refs/heads/", "refs/tags/"]) {
const refPath = `${prefix}${ref}`;
if (gitShowRefExists(nodeDir, refPath, cwd))
return refPath;
}
return gitRemoteRefFor(nodeDir, ref, cwd);
}
function validateGitRef(ref) {
if (ref.length === 0) {
throw new ValidationError("Git ref must be a non-empty string.");
}
if (ref.startsWith("-")) {
throw new ValidationError("Git ref cannot start with '-'.");
}
if (/[\x00-\x1F\x7F]/.test(ref)) {
throw new ValidationError("Git ref cannot contain ASCII control characters.");
}
if (/\s/.test(ref)) {
throw new ValidationError("Git ref cannot contain whitespace.");
}
if (/[~^:?*[\\]/.test(ref)) {
throw new ValidationError("Git ref contains characters that are not valid in git refs.");
}
if (ref.startsWith("/") ||
ref.endsWith("/") ||
ref.includes("//") ||
ref.includes("..") ||
ref.includes("@{") ||
ref === "@" ||
ref.endsWith(".") ||
ref.endsWith(".lock")) {
throw new ValidationError("Git ref is not a valid git ref name.");
}
return ref;
}
function stripGitUrlRef(value, patterns) {
const normalized = stripUrlSuffix(value);
for (const pattern of patterns) {
const match = normalized.match(pattern.re);
if (match) {
const ref = decodeURIComponent(match[2]);
if (pattern.rejectSlashRef && ref.includes("/")) {
throw new ValidationError("Ambiguous git tree URL contains a path after the ref. Pass the repository URL and explicit `ref` instead.");
}
return { baseUrl: match[1], ref: validateGitRef(ref) };
}
}
return undefined;
}
export function parseGitUrl(url) {
const input = url.trim();
const withoutSuffix = stripUrlSuffix(input);
// npm/pip style: repo@ref or repo.git@ref. Avoid treating the user part of
// scp-like SSH URLs (git@github.com:owner/repo.git) as a ref.
const atRef = withoutSuffix.match(/^(.+)@([^@/]+)$/);
if (atRef && (!/^[^@]+@[^/:]+:/.test(withoutSuffix) || atRef[1].includes("@"))) {
return { baseUrl: atRef[1], ref: validateGitRef(decodeURIComponent(atRef[2])) };
}
const matched = stripGitUrlRef(withoutSuffix, [
{ re: /^(.+?)\/-\/tree\/(.+)$/, rejectSlashRef: true },
{ re: /^(.+?)\/-\/commit\/(.+)$/ },
{ re: /^(.+?)\/tree\/(.+)$/, rejectSlashRef: true },
{ re: /^(.+?)\/commit\/(.+)$/ },
{ re: /^(.+?)\/releases\/tag\/(.+)$/ },
{ re: /^(.+?)\/src\/([^/]+)(?:\/.*)?$/ },
{ re: /^(.+?)\/commits\/(.+)$/ },
]);
if (matched)
return matched;
return { baseUrl: input, ref: null };
}
function looksLikeGitUrl(id) {
return /^(https?:\/\/|git@|git\+)/i.test(id) || id.endsWith(".git");
}
function gitCheckoutDir(baseUrl) {
const pathPart = baseUrl.includes(":") && !baseUrl.includes("://")
? baseUrl.slice(baseUrl.lastIndexOf(":") + 1)
: baseUrl;
const clean = stripUrlSuffix(pathPart);
return basename(clean).replace(/\.git$/i, "");
}
function runGitCheckout(baseUrl, ref, basePath, opts = {}) {
// basePath is the CALL-SCOPED local ComfyUI root (apply_manifest threads an
// adopted saved-default/live root WITHOUT mutating global config); fall back to
// the shared data/base resolver. Either may be unset in remote mode → clear error.
const comfyuiBase = basePath ?? resolveEffectiveComfyUIBase();
if (!comfyuiBase) {
throw new ProcessControlError("Checking out a custom-node git ref requires a local ComfyUI install, " +
"but no ComfyUI path is set.");
}
// SECURITY: this is also reached by the forced-cm-cli git path, NOT just the
// clone fallback, so validate here too before baseUrl / the derived dir reach
// git or the filesystem (option injection + path traversal). Mirrors
// cloneCustomNodeFallback's checks.
assertSafeGitUrl(baseUrl);
const repoName = gitCheckoutDir(baseUrl);
assertSafeRepoName(repoName);
const customNodesRoot = resolve(comfyuiBase, "custom_nodes");
const nodeDir = resolve(customNodesRoot, repoName);
const rel = relative(customNodesRoot, nodeDir);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
throw new ValidationError(`Refusing to check out: resolved path "${nodeDir}" escapes ${customNodesRoot}.`);
}
// #1470 — `nightly` is overloaded: a version-derived value can mean the
// repository's default HEAD, while an explicit ref must remain literal. The
// direct-clone fallback already used this probe; keep comfy-cli on the same
// provenance-aware path so it does not unconditionally checkout a missing
// channel word after `comfy node install`.
let checkoutRef = ref;
if (opts.refFromVersion === true && isGitHeadChannel(ref)) {
gitFetchAllTags(nodeDir, comfyuiBase);
const resolved = resolveGitHeadRef(nodeDir, ref, comfyuiBase);
if (!resolved && checkoutPlanForMissingRef({ ref, fromVersion: true }) === "skip-at-head") {
return (`"${ref}" is not a branch or tag in ${baseUrl}, so the clone was left at the ` +
`repository's default HEAD — which is what "nightly" means as a channel here. ` +
`If you meant a ref by that name, this repository does not have one; pass ` +
`ref:<branch-or-tag> for an exact checkout.`);
}
checkoutRef = resolved ?? ref;
}
logger.info("Checking out custom-node git ref", {
repository: baseUrl,
ref: checkoutRef,
nodeDir,
});
try {
execFileSync("git", ["-C", nodeDir, "fetch", "--all", "--tags"], {
cwd: comfyuiBase,
encoding: "utf-8",
timeout: GIT_CLONE_TIMEOUT,
env: nonInteractiveGitEnv(),
});
// `--end-of-options` is not a portable `git checkout` argument.
// checkoutRef has already passed validateGitRef(), which rejects leading
// dashes and control/whitespace characters before it reaches argv.
execFileSync("git", ["-C", nodeDir, "checkout", "--detach", checkoutRef], {
cwd: comfyuiBase,
encoding: "utf-8",
timeout: GIT_CLONE_TIMEOUT,
env: nonInteractiveGitEnv(),
});
}
catch (err) {
const e = err;
throw new NodeManagementError(`Failed to check out git ref "${checkoutRef}" for custom node "${baseUrl}": ${e.message}`, {
stdout: e.stdout ? e.stdout.toString() : "",
stderr: e.stderr ? e.stderr.toString() : "",
});
}
return undefined;
}
/**
* Does an installed node match the wanted id/url? Mirrors manifest.ts's private
* nodeAlreadyInstalled, but kept local to this unit. Normalizes (lowercase) and
* matches an installed node's module/cnrId/auxId (and the basename of each — aux
* ids are typically "owner/repo") against the wanted value and, when the wanted
* value is a git URL, its derived repo name. This is how we VERIFY a Manager
* install actually landed on disk (Manager marks the queue "done" even when it
* resolved nothing).
*/
function nodeInstalledMatches(idOrUrl, installed) {
return findInstalledNode(idOrUrl, installed) !== undefined;
}
/**
* The installed-list entry matching the wanted id/url, if any — the same
* matching nodeInstalledMatches applies, but returning the node so callers can
* also inspect its enabled flag / version (disable/uninstall post-state
* verification, #775).
*/
function findInstalledNode(idOrUrl, installed) {
const wanted = idOrUrl.trim().toLowerCase();
const repoName = looksLikeGitUrl(idOrUrl)
? gitCheckoutDir(parseGitUrl(idOrUrl).baseUrl).toLowerCase()
: wanted;
return installed.find((node) => {
const candidates = [];
for (const v of [node.module, node.cnrId, node.auxId]) {
if (!v)
continue;
const norm = v.trim().toLowerCase();
candidates.push(norm);
candidates.push(basename(norm));
}
return candidates.includes(wanted) || candidates.includes(repoName);
});
}
// ---------------------------------------------------------------------------
// On-disk presence evidence (#797)
//
// The Manager installed-list is keyed on what the MANAGER tracks. A pack
// installed as a Comfy Registry zip (no .git) or copied into custom_nodes by
// hand is present on disk while the list says nothing about it — and a gate
// that only asks the list has no basis for asserting "not installed locally".
// The disk check below answers the SAME question the list was asked ("is this
// pack here?") against the filesystem, keeping the three outcomes distinct:
// found / scanned-and-not-there / could-not-look.
// ---------------------------------------------------------------------------
/** Directory names a pack could plausibly be installed under for `id`. */
function packDirNameCandidates(id) {
const wanted = id.trim().toLowerCase();
// A path-shaped enable/disable id must never be reduced to its basename:
// doing that would let `../pack` vouch for `custom_nodes/pack` and could
// hand an untrusted id to a local mutation path. Git URLs are the one
// deliberate exception because their checkout directory is derived from
// the URL after it has already passed git-URL validation.
const names = new Set();
if (!/[/\\]/.test(wanted))
names.add(wanted);
if (looksLikeGitUrl(id)) {
names.add(gitCheckoutDir(parseGitUrl(id).baseUrl).toLowerCase());
}
return names;
}
/**
* Look for a pack on disk under <comfyuiBase>/custom_nodes. A directory counts
* when its name matches (a Manager-disabled "<name>.disabled" suffix is still
* the pack — disabled is not uninstalled) or its pyproject.toml [project].name
* matches (the registry-id identity a zip install keeps even when the folder
* was renamed). An unrelated pack's unreadable pyproject simply does not vouch
* for that directory; it never turns the whole scan into a failure.
*/
export function findPackOnDisk(id, comfyuiBase) {
const customNodes = join(comfyuiBase, "custom_nodes");
let entries;
try {
if (!existsSync(customNodes)) {
return {
state: "unreadable",
reason: `${customNodes} does not exist (is ${comfyuiBase} a ComfyUI install root?)`,
};
}
entries = readdirSync(customNodes, { withFileTypes: true });
}
catch (err) {
return {
state: "unreadable",
reason: `could not enumerate ${customNodes}: ${err instanceof Error ? err.message : String(err)}`,
};
}
const wanted = packDirNameCandidates(id);
for (const entry of entries) {
if (!(entry.isDirectory() || entry.isSymbolicLink()))
continue;
if (entry.name.startsWith("."))
continue;
const dirName = entry.name.toLowerCase().replace(/\.disabled$/, "");
let pyprojectName;
const pyproject = join(customNodes, entry.name, "pyproject.toml");
try {
if (existsSync(pyproject)) {
pyprojectName = parsePyproject(readFileSync(pyproject, "utf-8"))
.projectName?.trim()
.toLowerCase();
}
}
catch {
// An unreadable/corrupt pyproject means THIS directory cannot vouch by
// identity — it does not make the whole scan unreadable.
}
if (wanted.has(dirName) || (pyprojectName !== undefined && wanted.has(pyprojectName))) {
return { state: "found", dir: join(customNodes, entry.name) };
}
}
return { state: "not-found", scanned: customNodes };
}
/**
* Validate the id before it can participate in a local filesystem mutation or
* become a comfy-cli argument. Installed-pack ids are single directory names;
* URLs, separators, option-like names, and control characters are not valid
* targets for this recovery path.
*/
function assertSafeLocalPackId(id) {
const trimmed = id.trim();
if (trimmed.length === 0 ||
trimmed !== id ||
trimmed === "." ||
trimmed === ".." ||
trimmed.startsWith("-") ||
/[/\\]/.test(trimmed) ||
/[\x00-\x1F\x7F]/.test(trimmed)) {
throw new ValidationError(`Refusing to enable local custom node "${id}": expected a single installed ` +
`pack id without path separators, control characters, or a leading '-'.`);
}
return trimmed;
}
/**
* Find a real directory named `<pack>.disabled` under the selected
* custom_nodes root. This intentionally does not follow symlink entries and
* refuses duplicate active/disabled identities; otherwise a rename could
* enable a different pack than the caller named or make ComfyUI load an
* arbitrary external tree after restart.
*/
function findDisabledPackOnDisk(id, comfyuiBase) {
const customNodes = join(comfyuiBase, "custom_nodes");
let entries;
try {
if (!existsSync(customNodes))
return undefined;
entries = readdirSync(customNodes, { withFileTypes: true });
}
catch {
return undefined;
}
const wanted = new Set([id.toLowerCase()]);
let disabled;
let activeDir;
for (const entry of entries) {
const name = entry.name;
if (name.startsWith("."))
continue;
const lower = name.toLowerCase();
const isDisabled = lower.endsWith(".disabled");
const canonical = isDisabled ? name.slice(0, -".disabled".length) : name;
const canonicalLower = canonical.toLowerCase();
if (!wanted.has(canonicalLower) &&
!(isManagerSelfTarget(id) && isManagerSelfTarget(canonical))) {
continue;
}
if (entry.isSymbolicLink()) {
if (isDisabled) {
throw new NodeManagementError(`Refusing to enable "${id}": ${join(customNodes, name)} is a symlink; ` +
`the local recovery path only renames a real custom-node directory.`);
}
throw new NodeManagementError(`Refusing to enable "${id}": the active destination ${join(customNodes, name)} ` +
`is a symlink. Remove the collision before retrying.`);
}
const dir = join(customNodes, name);
if (isDisabled) {
if (!entry.isDirectory()) {
throw new NodeManagementError(`Refusing to enable "${id}": ${dir} is not a real directory.`);
}
if (disabled) {
throw new NodeManagementError(`Refusing to enable "${id}": multiple matching .disabled directories ` +
`exist under ${customNodes}.`);
}
disabled = {
disabledDir: dir,
enabledDir: join(customNodes, canonical),
cliId: canonical,
};
}
else {
// Any existing active-name entry is a collision, including a file. A
// POSIX rename could otherwise replace it, and a symlink could redirect
// what ComfyUI loads after restart.
activeDir = dir;
}
}
if (disabled && activeDir) {
throw new NodeManagementError(`Refusing to enable "${id}": both ${activeDir} and ${disabled.disabledDir} ` +
`exist. Remove the duplicate before retrying.`);
}
return disabled;
}
function verifyLocalEnable(id, comfyuiBase, enabledDir) {
const disabled = findDisabledPackOnDisk(id, comfyuiBase);
if (disabled) {
throw new NodeManagementError(`The local enable of "${id}" did NOT take effect: ${disabled.disabledDir} ` +
`is still disabled.`);
}
const after = findPackOnDisk(id, comfyuiBase);
if (after.state !== "found" ||
resolve(after.dir) !== resolve(enabledDir) ||
basename(after.dir).toLowerCase().endsWith(".disabled")) {
throw new NodeManagementError(`The local enable of "${id}" could NOT be verified under ${join(comfyuiBase, "custom_nodes")}.`);
}
}
function revalidateLocalEnableTarget(id, comfyuiBase, expected) {
const current = findDisabledPackOnDisk(id, comfyuiBase);
if (!current ||
resolve(current.disabledDir) !== resolve(expected.disabledDir) ||
resolve(current.enabledDir) !== resolve(expected.enabledDir)) {
throw new NodeManagementError(`Refusing to enable "${id}": the validated source or destination changed ` +
`before the local rename. NOTHING was changed by this tool.`);
}
return current;
}
/** Return undefined on a verified rollback, otherwise a safe refusal reason. */
function rollbackLocalEnable(id, comfyuiBase, disabled) {
try {
// Do not move a destination that appeared or changed while verification
// was running. A rollback is only safe when the original source is absent
// and the exact destination is still the directory we created.
if (findDisabledPackOnDisk(id, comfyuiBase)) {
return "the original .disabled source is still present or a collision appeared";
}
const active = findPackOnDisk(id, comfyuiBase);
if (active.state !== "found" ||
resolve(active.dir) !== resolve(disabled.enabledDir) ||
basename(active.dir).toLowerCase().endsWith(".disabled")) {
return "the destination no longer matches the directory this call renamed";
}
renameSync(disabled.enabledDir, disabled.disabledDir);
const restored = findDisabledPackOnDisk(id, comfyuiBase);
if (!restored ||
resolve(restored.disabledDir) !== resolve(disabled.disabledDir) ||
resolve(restored.enabledDir) !== resolve(disabled.enabledDir)) {
return "the rollback rename landed but its .disabled post-state could not be verified";
}
return undefined;
}
catch (err) {
return `rollback failed: ${err instanceof Error ? err.message : String(err)}`;
}
}
function enablePackFromDisk(id, comfyuiBase, disabled) {
const target = revalidateLocalEnableTarget(id, comfyuiBase, disabled);
try {
renameSync(target.disabledDir, target.enabledDir);
}
catch (err) {
throw new NodeManagementError(`Could not enable "${id}" by renaming ${target.disabledDir}: ` +
`${err instanceof Error ? err.message : String(err)}. NOTHING was changed by this tool.`);
}
try {
verifyLocalEnable(id, comfyuiBase, target.enabledDir);
}
catch (err) {
const rollbackError = rollbackLocalEnable(id, comfyuiBase, target);
const verification = err instanceof Error ? err.message : String(err);
throw new NodeManagementError(`The local enable of "${id}" could NOT be verified: ${verification}. ` +
(rollbackError === undefined
? "The rename was rolled back."
: `The rename could NOT be rolled back safely: ${rollbackError}.`));
}
return {
mechanism: "filesystem",
message: `Enabled "${id}" by renaming its validated local directory to ` +
`${target.enabledDir} (verified on disk). A ComfyUI restart is required ` +
`for the change to take effect.`,
details: { from: target.disabledDir, to: target.enabledDir },
};
}
function enablePackViaComfyCliFromDisk(id, comfyuiBase, disabled) {
const out = runCmCli(["enable", disabled.cliId], comfyuiBase);
try {
verifyLocalEnable(id, comfyuiBase, disabled.enabledDir);
}
catch (err) {
if (err instanceof NodeManagementError) {
return {
mechanism: "comfy-cli",
message: err.message + " comfy-cli reported success, but the local post-state disagrees.",
details: out.trim(),
};
}
throw err;
}
return {
mechanism: "comfy-cli",
message: `Enabled "${id}" via official comfy-cli (verified on disk at ` +
`${disabled.enabledDir}). A ComfyUI restart is required for the change to take effect.`,
details: out.trim(),
};
}
export function capturePackPresenceContext(diskRoot, options = {}) {
const remote = isRemoteMode();
return {
remote,
diskRoot: remote || (diskRoot === undefined && options.allowConfiguredFallback === false)
? undefined
: (diskRoot ?? resolveEffectiveComfyUIBase()),
};
}
/**
* Answer "is this pack present?" across BOTH sources of truth, keeping
* "could not determine" distinct from "determined absent" (#797, #796's
* defect class):
* 1. ComfyUI-Manager's installed list (works remotely, covers tracked packs);
* 2. the on-disk custom_nodes scan — LOCAL sessions only. In remote mode the
* disk we could read is not the server's, so it is never consulted.
* The Manager list matching always wins; disk evidence is what keeps a
* Manager-untracked zip install from reading as "not installed".
*
* `ctx` is the operation's entry-captured context (capturePackPresenceContext).
* When omitted it is captured here — still before this call's first await, so
* single-shot callers stay correct.
*/
export async function resolvePackPresence(id, base, ctx) {
const context = ctx ?? capturePackPresenceContext();
const diskBase = context.remote ? undefined : context.diskRoot;
let installed;
let listError;
try {
installed = await listInstalledNodesAt(base);
}
catch (err) {
listError = err instanceof Error ? err.message : String(err);
}
if (installed) {
const node = findInstalledNode(id, installed);
if (node)
return { state: "manager-listed", node };
}
const disk = diskBase ? findPackOnDisk(id, diskBase) : undefined;
if (disk?.state === "found") {
return {
state: "on-disk",
dir: disk.dir,
managerListReadable: installed !== undefined,
listError,
};
}
if (!installed) {
// The Manager list could not be read — "absent" is off the table no matter
// what the disk says: a pack Manager tracks but the disk missed (an
// adopted/extra-paths root) would be falsely condemned.
const diskNote = disk?.state === "not-found"
? ` The disk check found no matching pack under ${disk.scanned} either, but with the Manager list unreadable that still does not prove absence.`
: disk?.state === "unreadable"
? ` The disk check was inconclusive too (${disk.reason}).`
: " No local disk check was possible in this session.";
return {
state: "unverifiable",
reason: `ComfyUI-Manager's installed-pack list could not be read (${listError}).` + diskNote,
};
}
if (disk?.state === "not-found") {
return { state: "absent", evidence: "manager+disk", scanned: disk.scanned };
}
if (disk?.state === "unreadable") {
return {
state: "unverifiable",
reason: `"${id}" is not in ComfyUI-Manager's installed-pack list, and the on-disk ` +
`check could not answer (${disk.reason}).`,
};
}
// Remote session: only the Manager list could be consulted at all.
return { state: "absent", evidence: "manager-only" };
}
/**
* #2530 — clone succeeded, but the dependency interpreter is unproven. Never
* recommend `python -m pip install` against the interpreter we just refused;
* that is the Stability Matrix base-python command that PEP 668 already rejected.
*/
function depsSkippedUnknownInterpreterWarning(reason, packId) {
return (`Python dependencies were NOT installed. ${reason} ` +
`The pack was cloned but this is a PARTIAL install: the dependency interpreter is unknown. ` +
`Do not run pip against an untrusted interpreter. ` +
`Use install_custom_node(action:"fix", id:"${packId}") so ComfyUI-Manager can restore/repair ` +
`dependencies in the connected environment, or set COMFYUI_PYTHON to the interpreter ` +
`ComfyUI actually imports from and retry.`);
}
function isExternallyManagedPipError(text) {
return (/^\s*error: externally-managed-environment/im.test(text) ||
/interpreter at .+ is externally managed/i.test(text));
}
function depsPipFailedWarning(err, python, packId) {
const e = err;
const output = `${e.stderr?.toString() ?? ""}\n${e.stdout?.toString() ?? ""}\n${e.message}`;
if (isExternallyManagedPipError(output)) {
return (`Python dependencies (requirements.txt) failed to install: "${python}" is EXTERNALLY MANAGED ` +
`(PEP 668) and must not be modified. Do not re-run pip against that interpreter. ` +
`Use install_custom_node(action:"fix", id:"${packId}") so ComfyUI-Manager can restore/repair ` +
`the connected environment.`);
}
return (`Python dependencies (requirements.txt) failed to install (${e.message}); ` +
`install them manually with "${python} -m pip install -r requirements.txt".`);
}
/**
* Resolve the ComfyUI venv python for installing a cloned node's deps. Prefers
* the install's own `.venv` (Windows Scripts/ or POSIX bin/), falling back to a
* bare "python" on PATH. `basePath` is the CALL-SCOPED install root (apply_manifest
* threads an adopted saved-default/live root without mutating global config); when
* omitted it falls back to the code checkout (pip/venv live under main.py), then
* the pack/data root for a conventional single-root install. Passing the pack
* root still matters as that last fallback: otherwise a cloned node's
* requirements.txt / install.py would run under a BARE system python for an
* adopted workspace (#463). The code root wins on a split install so Desktop
* `--base-directory` pack writes do not pick the data tree's missing .venv.
*/
async function resolveVenvPython(basePath) {
return resolveInstallInterpreter(resolveEffectiveComfyUICodeBase() ?? basePath);
}
/**
* Validate a git URL before it is handed to `git clone` as an argument.
* Rejects an arg-injection vector (a URL parsed as a git option) and anything
* that isn't a recognized git URL shape.
*/
function assertSafeGitUrl(gitId) {
if (gitId.startsWith("-")) {
throw new ValidationError(`Refusing to clone git URL "${gitId}": it starts with '-' and would be ` +
`interpreted as a git option.`);
}
if (/[\x00-\x1F\x7F]/.test(gitId)) {
throw new ValidationError("Git URL cannot contain ASCII control characters.");
}
if (!looksLikeGitUrl(gitId)) {
throw new ValidationError(`Refusing to clone "${gitId}": not a recognized git URL (expected ` +
`https://, ssh://, git@…, git+…, or a .git URL).`);
}
}
/**
* Validate the repo name derived from a git URL before it is used as a
* filesystem path segment under custom_nodes. Rejects empty, '.'/'..', names
* starting with '-', and names containing path separators or control chars —
* any of which could escape custom_nodes or be parsed as a git option.
*/
export function assertSafeRepoName(repoName) {
if (repoName.length === 0 ||
repoName === "." ||
repoName === ".." ||
repoName.startsWith("-") ||
/[/\\]/.test(repoName) ||
/[\x00-\x1F\x7F]/.test(repoName)) {
throw new ValidationError(`Refusing to use "${repoName}" as a custom_nodes directory name (empty, ` +
`'.'/'..', starts with '-', or contains a path separator/control char).`);
}
}
/**
* Direct-clone fallback for an unregistered git repo the Manager can't resolve.
* Clones into custom_nodes/<repoName>, checks out a ref if given, then makes a
* best-effort attempt at installing python deps (requirements.txt + install.py).
* Dep failures DON'T fail the install (clone succeeded) — they're surfaced as
* warnings. A clone failure throws NodeManagementError.
*/
/**
* Filename that is an install/VCS marker, not pack code. A Comfy Registry zip
* that extracted nothing still leaves `.tracking`; a timed-out git clone leaves
* `.git`. ComfyUI loads DIRECTORIES, so either husk is imported on every start
* and fails — forever, silently (#900, #1816). Hidden files and `__pycache__`
* are the same class: they are not `__init__.py` / `pyproject.toml` / `*.py`.
*/
function isPackMarkerName(name) {
const n = name.toLowerCase();
return n.startsWith(".") || n === "__pycache__";
}
function readdirEntryName(entry) {
return typeof entry === "string" ? entry : entry.name;
}
function isLoadablePackFileName(name) {
const n = name.toLowerCase();
return n === "pyproject.toml" || n === "__init__.py" || n.endsWith(".py");
}
/**
* Does this directory hold something ComfyUI could actually load?
*
* A clone that git created and then abandoned leaves a directory containing only
* `.git`. A Comfy Registry zip that downloaded nothing leaves only `.tracking`.
* ComfyUI loads DIRECTORIES, so it will try to import that on every start and
* fail — forever, silently, long after whoever ran the install has forgotten
* about it (#900, #1816). "The directory exists" was never the question.
*
* An empty listing is inconclusive (callers/tests may only have enumerated
* `custom_nodes/`): fall through to the two files every loadable pack has.
* Unreadable is not a finding either way.
*/
function looksLikeAPack(dir) {
try {
const names = readdirSync(dir).map((entry) => readdirEntryName(entry));
if (names.length === 0) {
return existsSync(join(dir, "__init__.py")) || existsSync(join(dir, "pyproject.toml"));
}
return names.some((name) => !isPackMarkerName(name) && isLoadablePackFileName(name));
}
catch {
return true;
}
}
/**
* The alias identities a matched Manager entry offers, reduced to values that
* could actually BE a directory under custom_nodes.
*
* `aux_id` is "owner/repo" — the repo half is what Manager checks the pack out
* as, and `packDirNameCandidates` deliberately drops any path-shaped id, so the
* raw value would silently vouch for nothing. Take its basename.
*
* It reads `moduleKey`, NEVER `module`: the array payload puts a human `title` in
* the latter whenever the entry has one, so a Manager record of
* `{title:"Friendly Label", cnr_id:"owner/repo"}` would send this scan looking for
* a directory called "Friendly Label" — and an unrelated pack that happens to be
* named that would then certify an install that never happened (gate round 3).
*
* NO NAME-SHAPE HEURISTIC BEYOND THAT. Round 1 of the gate rejected values with
* whitespace here on the reasoning that ComfyUI imports a pack directory as a
* Python module name so it cannot contain a space. That reasoning is FALSE —
* ComfyUI loads packs through `importlib.util.spec_from_file_location`, which
* takes any string as the module name — and the filter it justified would have
* dropped a real `custom_nodes/Foo Bar` and cloned a duplicate beside it (round 2).
* The contamination it was actually defending against is the title-in-`module`
* problem above, which `moduleKey` removes outright — so the heuristic bought
* nothing that reading the right field does not already give.
*/
function packDirAliases(node) {
const aliases = [];
for (const raw of [node.moduleKey, node.cnrId, node.auxId]) {
if (typeof raw !== "string")
continue;
const name = basename(raw.trim().replace(/[\\/]+$/, ""));
if (name.length === 0 || name === "." || name === "..")
continue;
if (/[\x00-\x1F\x7F]/.test(name))
continue;
aliases.push(name);
}
return aliases;
}
function diskCorroboratesManagerInstall(gitId, node, diskRoot) {
const identities = [gitId, ...packDirAliases(node)];
let husk;
let scanned;
let unreadable = false;
for (const identity of identities) {
const found = findPackOnDisk(identity, diskRoot);
if (found.state === "found") {
if (looksLikeAPack(found.dir))
return { state: "corroborated", dir: found.dir };
husk ??= found.dir;
continue;
}
if (found.state === "not-found")
scanned ??= found.scanned;
else
unreadable = true;
}
// A husk is a POSITIVE observation of a real directory. A scan that could not
// answer cannot explain it away, so it is reported first — and it only ever
// produces a refusal, never a write.
if (husk !== undefined)
return { state: "husk", dir: husk };
// UNREADABLE OUTRANKS ABSENT, and this ordering is the whole point (codex gate,
// round 1): "absent" is the one verdict here that retracts a success AND
// authorizes a filesystem write, so it may only be reached when EVERY identity
// was conclusively scanned. One `not-found` beside one unanswerable scan is not
// proof of absence — it is the #796/#797 fold this file exists to avoid.
if (unreadable)
return { state: "unreadable" };
if (scanned !== undefined)
return { state: "absent", scanned };
return { state: "unreadable" };
}
/**
* Registry-zip post-verify: the target directory exists but holds no pack code.
* Look up the registry entry's repository so the error can name the git-clone
* fallback that actually works; a lookup failure must not hide the empty dir.
*/
async function refuseEmptyRegistryPack(id, dir, status, createdThisCall) {
let leftover = "";
if (createdThisCall) {
try {
rmSync(dir, { recursive: true, force: true });
}
catch (rmErr) {
leftover =
` NOTE: the empty pack directory at ${dir} could NOT be removed ` +
`(${rmErr instanceof Error ? rmErr.message : String(rmErr)}). Delete it by hand ` +
`before cloning.`;
}
}
let cloneHint = `search_custom_nodes (action:"details", id:"${id}") reports the repository URL; ` +
`then install_custom_node (action:"install", source:"git") with that URL`;
try {
const details = await getNodePackDetails(id);
const repo = typeof details.repository === "string" ? details.repository.trim() : "";
if (repo) {
cloneHint =
`install_custom_node (action:"install", id:"${repo}", source:"git") ` +
`— or \`git clone --depth 1 ${repo}\` into custom_nodes/ after removing any empty leftover`;
}
}
catch {
// The empty directory is the finding; the URL is additive.
}
const createdNote = createdThisCall
? leftover ||
` The empty directory at ${dir} was removed so a git clone can use that path.`
: ` This call did not create ${dir}, so it was left untouched. Delete it by hand before retrying.`;
return new NodeManagementError(`"${id}" was queued as a Comfy Registry zip install, but ${dir} holds no loadable pack ` +
`— only install markers (e.g. .tracking), with no __init__.py, pyproject.toml, or *.py. ` +
`NOT reporting this as installed: ComfyUI cannot import an empty directory.` +
createdNote +
` The registry zip is empty or broken; install from the pack's git repository instead: ` +
`${cloneHint}. Then restart ComfyUI to load it.`, status);
}
/**
* Kill a TIMED-OUT git clone's whole process tree, not just the direct child.
*
* execFileSync's timeout signals only the `git` it spawned. Git's descendants —
* `git-remote-https`, `index-pack` — keep running, and on Windows they hold open
* locks on `.git/objects/pack/tmp_pack_*`, so the removal below fails EBUSY and
* the husk stays put (#900 recurrence, observed 2026-08-05: `spawnSync git
* ETIMEDOUT` with the pack tmpfiles still locked). Mirrors the house pattern
* (orchestrator/index.ts killProcessTreeCrossPlatform): Windows `taskkill /T /F`
* (tree + force); POSIX a best-effort children sweep around the direct signal.
*/
function killGitProcessTree(pid) {
if (process.platform === "win32") {
// No graceful tree-signal exists on Windows; /T /F is the reliable path.
execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
return;
}
try {
execFileSync("pkill", ["-TERM", "-P", String(pid)], { stdio: "ignore" });
}
catch {
// no children / pkill absent — the direct signal below still applies
}
process.kill(pid, "SIGTERM");
}
/** Sync sleep for the settle window after a process-tree kill: this cleanup runs
* inside a synchronous catch, so it may not await. Same Atomics.wait idiom as
* download-progress.ts / lora-catalog.ts. */
function sleepSyncMs(ms) {
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
catch {
// Atomics.wait disallowed here — the retry below just runs a touch early.
}
}
/**
* Remove a clone directory THIS call created, after the clone failed.
*
* Returns a sentence to append to the error — a leftover nobody was told about
* is how the husk in #900 survived a month.
*
* `settlingProcesses` says a process-tree kill just ran (the clone timed out):
* the OS releases the killed processes' file handles asynchronously, so a
* removal that would EBUSY on the spot succeeds a moment later. Retry briefly
* before concluding the directory cannot be removed.
*
* `alreadyPresent` is a PRECONDITION, not live logic: every call site today sits
* inside `if (!alreadyPresent)`, so it is always false here and the guard below
* cannot fire. It is kept, and named, because the rule it encodes is the one that
* would do real damage if a later caller broke it — an existing pack is the
* user's, and a failed operation must never take it away. Being explicit about
* its unreachability is deliberate: an unreachable check that reads like live
* protection is worse than one that says what it is.
*/
function discardFailedClone(nodeDir, alreadyPresent, settlingProcesses = false) {
if (alreadyPresent)
return ""; // precondition — see above; unreachable today
if (!existsSync(nodeDir))
return ""; // git cleaned up after itself
const attempts = settlingProcesses ? 4 : 1;
let lastErr;
for (let attempt = 0; attempt < attempts; attempt++) {
if (attempt > 0)
sleepSyncMs(250 * attempt);
try {
rmSync(nodeDir, { recursive: true, force: true });
return "";
}
catch (rmErr) {
lastErr = rmErr;
}
}
return (` NOTE: the partial clone at ${nodeDir} could NOT be removed ` +
`(${lastErr instanceof Error ? lastErr.message : String(lastErr)}). ComfyUI will try to ` +
`import it on every start and log an error — delete that directory by hand.`);
}
/**
* Did ComfyUI-Manager reject the route outright, before queueing anything?
*
* A legacy 3.x git-URL route can be absent, and a Manager-native install can be
* refused before queueing by its `security_level` / `allow_git_url_install`
* policy. Both arrive as a status on the POST itself:
*
* 404 — the route is not registered on this build. Git installs use
* `/manager/queue/install`, `/v2/manager/queue/task`, or the legacy-UI
* `/v2/manager/queue/batch` envelope.
*
* 403 is admitted only when Manager's authoritative JSON error shape names its
* own policy gate, or when one of the exact legacy Manager policy responses is
* present. A bare 403 may be an authentication/permission layer and stays
* fail-closed. 405 is deliberately
* NOT here. On this API it is the DIALECT-MISMATCH signal
* (ComfyUI's frontend catchall answering a route registered under the other
* generation), and the self-heal retry in #646 already owns it — diverting to a
* clone would pre-empt a Manager install that is about to succeed on the right
* route.
*
* What matters is not which one, but that the response describes the REQUEST
* rather than a task: nothing was queued, so nothing is running, so a direct
* clone afterwards cannot race a Manager install writing to the same directory.
* That is the whole warrant for continuing — and it is why 5xx is excluded, where
* a task may well have been accepted before the handler fell over.
*
* Returns 404, or the explicitly Manager-owned policy 403, or undefined when the
* failure is anything else (including 401, bare 403, 407, a transport error, a
* timeout, or a 500) and must keep propagating.
*/
const LEGACY_MANAGER_SECURITY_ERROR = "A security error has occurred. Please check the terminal logs";
/**
* Keep the old plain-text Manager response working without treating arbitrary
* response text as proof of Manager ownership. JSON is the current authoritative
* Manager shape; the two exact strings are retained for the legacy response and
* the existing local compatibility fixture.
*/
function isManagerOwnedGitPolicy403(body) {
if (typeof body !== "string")
return false;
const text = body.trim();
if (text === LEGACY_MANAGER_SECURITY_ERROR ||
text === "gated by security_level") {
return true;
}
try {
const parsed = JSON.parse(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return false;
const reason = parsed.error;
return reason === "security_level" || reason === "allow_git_url_install";
}
catch {
return false;
}
}
function managerEnqueueRefusal(err) {
if (!(err instanceof NodeManagementError))
return undefined;
const details = err.details;
const status = details?.status;
const url = details?.url;
if (typeof status !== "number")
return undefined;
if (typeof url !== "string" ||
!/\/manager\/queue\/(?:task|install|batch)(?:[?#]|$)/.test(url)) {
return undefined;
}
if (status === 404)
return status;
if (status === 403 &&
isManagerOwnedGitPolicy403(details?.body)) {
return status;
}
return undefined;
}
function isManagerQueueDetectionFailure(err) {
if (!(err instanceof NodeManagementError) || !err.details || typeof err.details !== "object") {
return false;
}
return err.details.kind === MANAGER_QUEUE_DETECTION_FAILURE;
}
function initialManagerQueueAbsenceWasProven(err) {
if (!(err instanceof NodeManagementError) || !err.details || typeof err.details !== "object") {
return false;
}
const codes = err.details.queueStatusCodes;
return (Array.isArray(codes) &&
codes.length === 2 &&
codes.every((status) => status === 404));
}
/**
* #2754 — did the SAME detection failure also observe ComfyUI-Manager serving?
*
* The queue-detection error now carries what the version routes said, and only
* ONE of those readings leaves the word "absent" standing. `version` means a
* Manager answered with its generation; `refused` means an authentication layer
* stopped us before anything could be established; `unreadable` means the message
* in the same object already says presence is UNKNOWN. Reporting `manager_absent: true` off the
* back of an error object that says either one is a claim refuted by its own
* details (codex gate round 5).
*
* Read for LABELLING only. Whether the git fallback is allowed to run stays with
* managerAbsenceAllowsGitFallback and its fresh re-probe — the queue routes really
* did 404 twice, so nothing was queued and the clone is still safe. This corrects
* what we CALL that clone, not whether it happens.
*/
function managerAbsenceUnprovenDuringDetection(err) {
if (!(err instanceof NodeManagementError) || !err.details || typeof err.details !== "object") {
return false;
}
const evidence = err.details.managerVersionEvidence;
// `absent` is the ONLY reading that leaves "Manager is absent" standing, and an
// error that predates this field (undefined) keeps the old behaviour. Everything
// else contradicts it: `version` and `refused` positively, and `unreadable`
// because the diagnostic in the very same object says presence is UNKNOWN
// (codex gate round 7).
return evidence !== undefined && evidence !== "absent";
}
/**
* A direct clone is safe after either a Manager-native policy refusal whose body
* explicitly proves no task was queued, a direct enqueue route-level 404, a
* detection failure followed by a fresh probe proving BOTH queue dialects are
* 404 on the same captured ComfyUI base, or the new local Manager-unavailable
* surface: both dialects repeatedly answer with an empty/explicitly-unavailable
* response before any enqueue route is attempted.
*
* A bare 401/403/407, timeout, 5xx, 405, malformed/HTML response, or unrelated
* reachability error remains fail-closed. This keeps a response that could be a
* live Manager or an accepted background task from being raced by the clone.
*/
async function managerAbsenceAllowsGitFallback(err, base) {
const status = errorStatus(err);
const preQueueRefusal = managerEnqueueRefusal(err);
// The enqueue route's own 404 is already a pre-queue refusal. Preserve the
// legacy contract that authorized a direct clone on that route-level fact;
// the fresh status probes are required only for the new detection-failure
// path, where the enqueue error carries no route-level refusal.
if (preQueueRefusal === 403 || preQueueRefusal === 404)
return true;
if (status !== undefined)
return false;
if (status === undefined &&
!isManagerQueueDetectionFailure(err)) {
return false;
}
if (initialManagerQueueAbsenceWasProven(err)) {
try {
return (await probeManagerQueueAvailability(base)) === "absent";
}
catch {
return false;
}
}
const details = err instanceof NodeManagementError && err.details && typeof err.details === "object"
? err.details
: undefined;
const initialKinds = details?.queueStatusKinds;
if (!isManagerUnavailableQueueKinds(initialKinds))
return false;
// Re-read the same two endpoints. A single empty response can be a transient
// proxy/server failure; two identical no-queue observations are the minimum
// evidence this pre-enqueue fallback can safely act on. The direct probe keeps
// 405/5xx/auth/transport/malformed responses distinct instead of collapsing
// them into the public "unreadable" state.
try {
const freshKinds = await probeManagerQueueStatusKinds(base);
return (isManagerUnavailableQueueKinds(freshKinds) &&
freshKinds.every((kind, index) => kind === initialKinds[index]));
}
catch {
return false;
}
}
/**
* #1765 (recurrence on 0.52.35) — the write was aimed at the wrong ComfyUI.
*
* The reporter ran six installs on one machine, pointed the MCP at
* `http://127.0.0.1:8189`, and got a `git-clone` SUCCESS whose `nodeDir` named a
* different install entirely; the connected server never received the pack. The
* refusal below is what replaces that success. It names both roots, says which
* selector produced each, and gives the one-step remedy — because the failure
* this class produces is not an error the user can see, it is a report of work
* that was done somewhere else.
*
* Deliberately a REFUSAL and not a retarget: on a `--base-directory` split
* install the configured root IS the pack root (#1715/#1770/#1766), so silently
* preferring the live `main.py` root would trade this bug for that one. A
* `--base-directory` the server actually reports is honored FIRST, so that
* deployment never reaches this refusal in the first place.
*
* A `COMFYUI_PATH` that disagrees with the running server IS refused, and that
* is deliberate — see `detectLocalWriteTargetMismatch` for why the env var
* cannot be read as the user naming this install (the panel orchestrator
* forwards an auto-detected path under exactly that name).
*/
function wrongInstallRefusal(m, what,
/** The target CAPTURED for this operation — not `getComfyUIBaseUrl()` read at
* throw time, which a panel retarget mid-call would have already moved. */
target) {
const chosenBy = m.baseSource === "comfyui-path"
? "COMFYUI_PATH"
: m.baseSource === "saved-default-workspace"
? 'the SAVED DEFAULT WORKSPACE (workspace action:"set_default")'
: "AUTO-DETECTION of ComfyUI installs on this machine";
const provenBy = m.liveSource === "base-directory"
? "its own --base-directory"
: m.liveSource === "argv"
? "its own /system_stats launch argv"
: "the OS process listening on that port, anchored on the interpreter it runs";
return (`${what} would write into ${m.base}, but this session is connected to ` +
`${target}, which runs from ${m.liveRoot} (established from ${provenBy}). ` +
`Those are two DIFFERENT ComfyUI installs, so NOTHING was written: the pack would have ` +
`landed in a custom_nodes/ the connected server never scans, and this call would have ` +
`reported success. ${m.base} was chosen by ${chosenBy} — configuration, which describes ` +
`this MACHINE; the root above is what the running server says about ITSELF. To install ` +
`into the connected server, set COMFYUI_PATH=${m.liveRoot} and retry. If ${m.base} really ` +
`is where you want packs for this server, launch it with --base-directory ${m.base} — ` +
`that is the flag that makes a data root authoritative, and it is honored here.`);
}
async function cloneCustomNodeFallback(gitId, repoName, gitRef, managerStatus, basePath,
/**
* #1129 — why we are here, when it was NOT "the pack is unregistered". The
* refusal messages below name that reason by default, and stating it for a
* policy refusal would be a wrong explanation for a correct action; the caller
* that knows better passes its own.
*/
opts) {
const because = opts?.managerRefusalNote ?? `"${repoName}" is not in the ComfyUI-Manager registry`;
// basePath is the CALL-SCOPED local ComfyUI root. When the caller has already
// established that no safe root exists, do not re-read the saved-default
// resolver and recreate the wrong-machine clone hazard.
const comfyuiBase = basePath ??
(opts?.allowSharedWorkspaceFallback === false ? undefined : resolveEffectiveComfyUIBase());
// Same hazard as the CLI paths (codex gate P0): the guard below catches "no
// path", but the dangerous case is HAVING one while connected elsewhere. A
// clone into a stale local tree would report a successful install of a pack the
// connected server will never see.
if (isRemoteMode()) {
throw new ProcessControlError(`${because}. Cloning it would write to a ` +
`LOCAL install` +
(comfyuiBase ? ` (${comfyuiBase})` : "") +
` — but this session targets a REMOTE ComfyUI (--comfyui-url), so that is not the ` +
`server you are connected to. Nothing was cloned. Install it on the ComfyUI host, or ` +
`pass a registered pack id the Manager can resolve there.`);
}
if (!comfyuiBase) {
throw new ProcessControlError(`${because}, and cloning it ` +
`requires a local ComfyUI install, but no ComfyUI path is set. ` +
`Install it on the ComfyUI host, or pass a registered pack id.`);
}
// SECURITY: validate before either value becomes a `git clone` arg or a path
// segment. gitId is checked for option-injection; repoName for path traversal.
assertSafeGitUrl(gitId);
assertSafeRepoName(repoName);
// Resolve the target and ASSERT it stays inside <comfyuiBase>/custom_nodes,
// mirroring manifest.ts's isWithinRoot containment check (defense in depth on
// top of the repoName validation above).
const customNodesRoot = resolve(comfyuiBase, "custom_nodes");
const nodeDir = resolve(customNodesRoot, repoName);
const rel = relative(customNodesRoot, nodeDir);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
throw new ValidationError(`Refusing to clone: resolved path "${nodeDir}" escapes ${customNodesRoot}.`);
}
const warnings = [];
let alreadyPresent = existsSync(nodeDir);
// A directory that is already there but holds nothing but git metadata is NOT
// a pack — it is the husk an earlier failed install left behind (#900). Without
// this check the clone below is skipped ("the directory exists") and the call
// reports a successful install of nothing. It is also not ours to delete: this
// call did not create it, and a directory we did not create is the user's —
// so REFUSE, name it, and leave it untouched.
if (alreadyPresent && !looksLikeAPack(nodeDir)) {
throw new NodeManagementError(`${because}, and custom_nodes/${repoName} already exists but holds nothing but ` +
`git metadata — the husk of an install that did not complete, which ComfyUI ` +
`cannot import and logs an error for on every start. This call did not create ` +
`it, so it was left untouched. Delete ${nodeDir} by hand and retry the install.`);
}
// #2523 — Manager v4 resolves a from-source install by BARE REPO NAME and
// clones the channel/registry URL, so artokun/comfyui-teskors-utils can land
// as teskor-hub/comfyui-teskors-utils in the same folder. A name hit is not
// the requested origin. A verified disk remote is authoritative: stale
// Manager metadata must never delete a checkout that already has the right
// origin (or any local changes in it). If the disk remote is wrong, replace
// it; if the disk remote cannot answer, the Manager aux_id may still prove an
// alias replacement is needed.
const diskOrigin = alreadyPresent ? readGitRemoteOrigin(nodeDir) : undefined;
const diskOriginDiffers = gitOriginsDiffer(gitId, diskOrigin);
const managerOriginDiffers = gitOriginsDiffer(gitId, opts?.replaceOrigin);
if (alreadyPresent &&
(diskOriginDiffers || (!diskOrigin && managerOriginDiffers))) {
const landed = opts?.replaceOrigin ?? diskOrigin ?? "a different origin";
try {
rmSync(nodeDir, { recursive: true, force: true });
}
catch (err) {
throw new NodeManagementError(`ComfyUI-Manager resolved "${landed}" instead of the requested "${gitId}", ` +
`but custom_nodes/${repoName} could not be replaced ` +
`(${err instanceof Error ? err.message : String(err)}). ` +
`Delete ${nodeDir} by hand and retry the install.`);
}
alreadyPresent = false;
warnings.push(`ComfyUI-Manager resolved "${landed}" instead of the requested "${gitId}"; ` +
`replaced custom_nodes/${repoName} with a direct clone of the requested origin.`);
}
if (!alreadyPresent) {
// A concrete ref needs the full history reachable; otherwise shallow-clone.
// `--end-of-options` ensures gitId/nodeDir are never parsed as git options.
const cloneArgs = gitRef
? ["clone", "--end-of-options", gitId, nodeDir]
: ["clone", "--depth", "1", "--end-of-options", gitId, nodeDir];
logger.info("Cloning unregistered custom node", { gitId, nodeDir, gitRef });
try {
execFileSync("git", cloneArgs, {
cwd: comfyuiBase,
encoding: "utf-8",
timeout: GIT_CLONE_TIMEOUT,
env: nonInteractiveGitEnv(),
});
}
catch (err) {
const e = err;
// A TIMEOUT killed only the direct `git` child; its descendants
// (git-remote-https, index-pack) may still be running and holding locks on
// the partial pack, which is exactly how the #900 husk survived its own
// cleanup (EBUSY on .git/objects/pack/tmp_pack_*). Kill the tree first —
// best-effort: if the kill itself fails, the removal below still runs and
// still discloses what it could not remove.
const timedOut = e.code === "ETIMEDOUT";
if (timedOut && typeof e.pid === "number") {
try {
killGitProcessTree(e.pid);
}
catch {
// best-effort — see above
}
}
const leftover = discardFailedClone(nodeDir, alreadyPresent, timedOut);
throw new NodeManagementError(`Failed to clone "${gitId}" into custom_nodes/${repoName}: ${e.message}${leftover}`, {
stdout: e.stdout ? e.stdout.toString() : "",
stderr: e.stderr ? e.stderr.toString() : "",
});
}
if (gitRef) {
// #1470 — ASK WHETHER THE REF EXISTS, then act. "nightly" is overloaded in this
// tool's own surface: it names the git-HEAD channel for a Manager install (one of
// our paths mints it), and `version` is documented as a git ref for a from-source
// install. A repository that HAS a `nightly` branch must get it; one that does not
// is not a caller error, it is the other reading of the same word — and the clone
// already sits at HEAD, which is what that reading asks for.
// The checkout is part of producing the pack, so its failure leaves the
// same husk as a failed clone — and the clone directory is ours either way.
try {
const checkoutWarning = runGitCheckout(gitId, gitRef, comfyuiBase, {
refFromVersion: opts?.refFromVersion === true,
});
if (checkoutWarning)
warnings.push(checkoutWarning);
}
catch (err) {
const leftover = discardFailedClone(nodeDir, alreadyPresent);
const detail = err instanceof GitRefProbeError
? `Could not determine whether "${gitRef}" is available: ${err.message}`
: `Cloned "${gitId}" but could not check out "${gitRef}": ` +
`${err instanceof Error ? err.message : String(err)}`;
throw new NodeManagementError(`${detail}${leftover}`);
}
}
}
// VERIFY the clone produced a PACK, not merely a directory (#900).
//
// `existsSync(nodeDir)` passed for a directory containing nothing but `.git` —
// which is exactly what a clone killed by the timeout leaves behind. One such
// husk sat in a user's custom_nodes for over a month, and ComfyUI logged an
// import failure for it on EVERY boot, because ComfyUI loads directories and
// does not care what we think we installed.
//
// Worse than useless: to any later reader — including our own disk-presence
// checks — it is indistinguishable from a pack the user installed on purpose
// that is now broken.
if (!existsSync(nodeDir)) {
throw new NodeManagementError(`Clone of "${gitId}" reported success but ${nodeDir} is missing.`);
}
if (!alreadyPresent && !looksLikeAPack(nodeDir)) {
const leftover = discardFailedClone(nodeDir, alreadyPresent);
throw new NodeManagementError(`Clone of "${gitId}" reported success but produced no loadable pack in ` +
`custom_nodes/${repoName} — the directory holds nothing but git metadata, which ` +
`ComfyUI cannot import and would log an error for on every start.${leftover}`);
}
// Best-effort python deps. Don't fail the install if these don't.
// #2530 — never pip (or recommend pip) with an interpreter the environment
// probe would mark untrusted. resolveInstallInterpreter now shares that
// verdict; when it refuses, this is a PARTIAL install and Manager repair is
// the next step, not a copy-paste of the same unsafe python -m pip command.
const requirements = join(nodeDir, "requirements.txt");
const installScript = join(nodeDir, "install.py");
if (existsSync(requirements) || existsSync(installScript)) {
const resolved = await resolveVenvPython(comfyuiBase);
if (!resolved.python) {
warnings.push(depsSkippedUnknownInterpreterWarning(resolved.reason, repoName));
}
else if (existsSync(requirements)) {
const python = resolved.python;
try {
execFileSync(python, ["-m", "pip", "install", "-r", requirements], {
cwd: nodeDir,
encoding: "utf-8",
timeout: COMFY_CLI_TIMEOUT,
});
}
catch (err) {
warnings.push(depsPipFailedWarning(err, python, repoName));
}
}
if (resolved.python && existsSync(installScript)) {
const python = resolved.python;
try {
execFileSync(python, [installScript], {
cwd: nodeDir,
encoding: "utf-8",
timeout: COMFY_CLI_TIMEOUT,
});
}
catch (err) {
const e = err;
warnings.push(`install.py failed to run (${e.message}); the node may need manual setup.`);
}
}
}
const base = alreadyPresent
? `"${repoName}" already exists in custom_nodes (${repoName}) — left it in place.`
: `${because} — cloned it directly into custom_nodes (${repoName}).`;
const warn = warnings.length ? ` ${warnings.join(" ")}` : "";
return {
mechanism: "git-clone",
message: `${base}${warn} RESTART ComfyUI to load it.`,
details: { nodeDir, warnings, managerStatus },
};
}
function notifyLocalFallbackSelected(opts) {
const binding = opts.localFallbackBinding;
if (!binding)
return;
try {
opts.onLocalFallback?.(binding);
}
catch (err) {
// This is an observational hook used by apply_manifest; a reporting
// callback must never prevent the selected local fallback from running.
logger.debug("Ignoring local fallback phase-hook failure", {
error: err instanceof Error ? err.message : String(err),
});
}
}
function notifyLocalFallbackSettled(opts, state) {
const binding = opts.localFallbackBinding;
if (!binding)
return;
try {
opts.onLocalFallbackSettled?.(binding, state);
}
catch (err) {
// This is an observational hook used by apply_manifest; a reporting
// callback must never change the install result.
logger.debug("Ignoring local fallback settle-hook failure", {
error: err instanceof Error ? err.message : String(err),
});
}
}
/**
* Installing/updating/reinstalling/repairing a pack changes which node classes
* this ComfyUI can register, so the orchestrator's memoized /object_info snapshot
* (getObjectInfo) is now stale — create_workflow's validate and node_info
* actions, and get_history (action:"diagnose"),
* would keep reporting the just-installed types as `missing_node_type` in their
* top-level summaries until some later reboot bumped the epoch (#444, the residual
* of the #235/#247/#352/#364 staleness cluster). Drop the cache the moment a
* mutation succeeds so the next read refetches the live registry. Applied via a
* single choke point (rather than at every early return) so every install branch —
* cm-cli, Manager HTTP, and the git clone fallback — is covered.
*/
async function withObjectInfoInvalidation(op) {
const result = await op();
resetObjectInfoCache();
return result;
}
// NOTE on `async`: these wrappers are declared async purely so the PIN GUARD's
// throw surfaces as a REJECTED PROMISE rather than a synchronous exception.
// Callers rely on that — apply_manifest does `installCustomNode(...).then().catch()`
// and documents that it never rejects, which a sync throw would walk straight past.
export async function installCustomNode(opts) {
// PIN GUARD (see panel-pin-guard.ts). The panel is an ordinary node pack, so
// these generic mutations are a second door into the same ComfyUI-Manager
// operation that install_comfyui(action:'panel') drives. Guarding here — where the TARGET is
// known — covers every caller, including a bulk "all". The check and the
// mutation are atomic under the panel mutation lock (withPanelPinGuard).
const op = () => withPanelPinGuard("install", opts.id, () => withObjectInfoInvalidation(() => installCustomNodeImpl(opts)));
return op();
}
/**
* Why the comfy-cli subprocess path cannot run against `workspace`, or
* undefined when it can. Probed BEFORE anything is submitted (#808): an
* explicit useCmCli on a host without a usable CLI must fall back to Manager
* HTTP, not die with NODE_MANAGEMENT_ERROR after the tool description promised
* a fallback. `workspace` is the caller's captured local pack/data root —
* COMFYUI_PATH, an adopted live `--base-directory`, or the saved default
* workspace, which the CLI layer supports natively.
*/
function comfyCliUnavailableReason(workspace) {
// REMOTE MODE FIRST (codex gate P0). comfy-cli runs against a LOCAL install,
// while every check around these calls — the pre-state, the post-verify — talks
// to the Manager on the REMOTE server. With a stale local COMFYUI_PATH the two
// describe different machines: the CLI uninstalls or disables a pack here, the
// verification reports on a pack over there, and the local destructive action is
// never disclosed at all.
//
// `workspace` is non-empty in exactly that case, which is why this cannot be
// folded into the check below: having a path is what makes it dangerous.
if (isRemoteMode()) {
return ("this session targets a REMOTE ComfyUI (--comfyui-url), and comfy-cli only acts on a " +
"LOCAL install" +
(workspace
? ` — running it would modify ${workspace}, which is NOT the server you are connected to`
: "") +
". Run it on the machine the install lives on");
}
if (!workspace) {
return "no local ComfyUI install is available (COMFYUI_PATH unset and no saved default workspace), which the comfy-cli subprocess needs";
}
const executable = resolveComfyCliExecutable({ workspace });
if (!executable) {
return "comfy-cli was not found on PATH or in the workspace's .venv (install comfy-cli>=1.11.1, set COMFY_CLI_PATH, or install it into the workspace venv)";
}
const version = getComfyCliVersion({ workspace });
if (!isSupportedComfyCliVersion(version)) {
return `comfy-cli >=1.11.1 is required; found ${version ?? "an unrecognized version"}`;
}
return undefined;
}
async function resolveInstallLocalWorkspace(opts) {
if (opts.comfyuiPath !== undefined)
return opts.comfyuiPath;
if (opts.localCloneFallback === "verified-only")
return undefined;
// An unset COMFYUI_PATH is not permission to use the saved default. The
// connected local server must identify a usable custom_nodes scan root, or
// this operation remains Manager-only. This resolver never throws for an
// unavailable/ambiguous live root; the Manager attempt still proceeds.
if (!process.env.COMFYUI_PATH && !isRemoteMode()) {
try {
return await resolveCustomNodesScanBaseLiveStrict({ requireLive: true });
}
catch {
return undefined;
}
}
return resolveEffectiveComfyUIBase();
}
function assertInstallTargetStable(expectedGeneration, managerBase) {
const actualGeneration = getComfyuiTargetGeneration();
if (actualGeneration === expectedGeneration)
return;
throw new ProcessControlError("ComfyUI's target changed while this custom-node install was being prepared. " +
"The Manager request and any local clone were refused; retry after the target settles.", {
kind: "target-generation-changed",
managerBase,
expectedGeneration,
actualGeneration,
});
}
/**
* Serialize only a local install writer. The callback must be the complete
* filesystem transaction, including any existence check that decides whether
* it will create the destination. Manager probing, enqueueing, draining, and
* Manager-only fallback decisions stay outside this boundary.
*/
function withLocalInstallMutationLock(expectedGeneration, managerBase, op) {
return withPanelMutationLock(async () => {
// Another local writer may have held the lock while the target changed.
// Refuse before touching the captured filesystem target in that case.
assertInstallTargetStable(expectedGeneration, managerBase);
return op();
});
}
async function installCustomNodeImpl(opts) {
const { id, version, mode = "remote", channel = "default" } = opts;
const parsedGit = parseGitUrl(id);
const gitId = parsedGit.baseUrl;
const gitRefCandidate = gitRefForInstall({
ref: opts.ref,
urlRef: parsedGit.ref,
version,
});
const source = opts.source && opts.source !== "auto"
? opts.source
: looksLikeGitUrl(gitId)
? "git"
: "registry";
const gitRef = source === "git" && gitRefCandidate
? validateGitRef(gitRefCandidate)
: gitRefCandidate;
// #1470 — provenance, not spelling. `gitRefForInstall` prefers an explicit ref/urlRef, so
// the candidate came from `version` exactly when neither was supplied. Only that case may
// fall back to HEAD for a repo that lacks the ref; an explicit `ref:` is the caller naming
// a git ref and must fail loudly if it is absent.
const refFromVersion = opts.ref === undefined && parsedGit.ref == null;
// SECURITY: validate a git URL ONCE, up front, before it can reach ANY install
// path — cm-cli (`cm-cli install <url>`), the Manager queue, or the clone
// fallback. Rejects option-injection (leading "-") / non-git / control chars.
// The repo-name + custom_nodes-containment checks live where the on-disk dir is
// actually used (runGitCheckout, cloneCustomNodeFallback).
if (source === "git")
assertSafeGitUrl(gitId);
// Keep the mutation, its post-queue verification, AND any local filesystem
// work on the target selected for this invocation. The Manager base is
// captured before the live-root lookup so a panel retarget cannot move the
// HTTP half of this operation while the filesystem target is being verified.
// With COMFYUI_PATH unset, a live custom_nodes root is required; a saved
// default is machine-local configuration, not proof of what this panel target
// scans.
const managerBase = opts.managerBase ?? managerBaseUrl();
const targetGeneration = opts.targetGeneration ?? getComfyuiTargetGeneration();
const cliWorkspace = source === "git" || opts.useCmCli
? await resolveInstallLocalWorkspace(opts)
: opts.comfyuiPath ?? resolveEffectiveComfyUIBase();
assertInstallTargetStable(targetGeneration, managerBase);
const presenceCtx = capturePackPresenceContext(cliWorkspace, {
allowConfiguredFallback: cliWorkspace !== undefined,
});
// #1765 — and is that root the install this session is actually CONNECTED to?
// Pinned to the very cliWorkspace the write will use, so a panel retarget
// mid-call cannot judge one install's root against another's server.
//
// Taken only when a LOCAL write is reachable for this call: a registry install
// goes through ComfyUI-Manager, which targets the connected server by
// construction and has no local destination to misdirect.
const localWriteMismatch = source === "git" || opts.useCmCli === true
? await detectLocalWriteTargetMismatch(cliWorkspace)
: undefined;
assertInstallTargetStable(targetGeneration, managerBase);
// THE PRE-STATE, OBSERVED BEFORE THE OPERATION (codex gate P0). The "already
// installed" verdict below used to be inferred from POST-op disk state alone:
// a pack that was absent before the call and installed as a registry ZIP —
// which Manager does not track — creates a directory the post-check then reads
// as "it was already there", telling the user nothing happened when their pack
// had just been installed. A pre-state cannot be recovered afterwards; it has
// to be taken now.
const diskBefore = presenceCtx.diskRoot
? findPackOnDisk(id, presenceCtx.diskRoot)
: undefined;
// #808 — a requested comfy-cli that is not usable is a FALLBACK, not a fatal
// error: the probe runs before anything is submitted, so dropping to Manager
// HTTP cannot double-run anything, and the git path below keeps its own
// direct-clone fallback for an unregistered repo. The note is prepended to
// whichever result the HTTP path produces, so the mechanism switch is
// disclosed rather than silent.
let cliFallbackNote;
if (opts.useCmCli) {
const cliProblem = comfyCliUnavailableReason(cliWorkspace);
if (cliProblem === undefined) {
// #1765 — comfy-cli writes into `--workspace`, so the wrong-install hazard
// is identical here to the direct clone below.
if (localWriteMismatch) {
throw new ProcessControlError(wrongInstallRefusal(localWriteMismatch, 'install_custom_node (action:"install", comfy-cli)', managerBase));
}
// cm-cli install accepts registry ids and git urls alike.
const installId = source === "git" ? gitId : id;
const { out, checkoutWarning } = await withLocalInstallMutationLock(targetGeneration, managerBase, async () => {
// The lock is intentionally acquired only after the CLI availability
// probe and Manager-only fallback decision. The helper re-checks the
// target after waiting, so a retarget while another local writer held
// the lock cannot send this mutation to an obsolete install.
const out = runCmCli(["install", installId, "--mode", mode, "--channel", channel], cliWorkspace);
let checkoutWarning;
if (source === "git" && gitRef) {
checkoutWarning = runGitCheckout(gitId, gitRef, cliWorkspace, { refFromVersion });
}
return { out, checkoutWarning };
});
return {
mechanism: "comfy-cli",
message: `Installed "${id}" via official comfy-cli.${checkoutWarning ? ` ${checkoutWarning}` : ""}`,
details: out.trim(),
};
}
logger.info("comfy-cli requested (useCmCli) but unavailable — falling back to Manager HTTP", {
reason: cliProblem,
});
cliFallbackNote =
`comfy-cli was requested (useCmCli) but is not usable here: ${cliProblem}. ` +
`NOTHING was run through comfy-cli — the install below used ComfyUI-Manager instead.`;
}
const withCliNote = (result) => cliFallbackNote
? { ...result, message: `${cliFallbackNote} ${result.message}` }
: result;
if (source === "git") {
const repoName = gitCheckoutDir(gitId);
// A git install's PARAMS are dialect-specific, not just its route, so they
// are built per-attempt from the dialect actually being spoken. Precomputing
// them from the cached dialect would let a self-heal retry (#646) resend
// 3.x-shaped params to a v4 server (or the reverse) and install the wrong
// thing — the retry has to rebuild the body, not just re-route it.
// #1129 — A REFUSED ENQUEUE IS NOT THE END OF THE ROAD.
//
// The 3.x git-URL install is gated by security_level + allow_git_url_install,
// and a host that does not serve the route answers the same way. Both come
// back as a status on the POST, which throws — so the direct-clone fallback
// twenty lines below, which needs no Manager at all and is exactly what the
// user would do by hand, was unreachable in the one case it exists for. A
// reporter on legacy 3.x got "ComfyUI-Manager not reachable", then a 404 with
// "A security error has occurred", and was left with no install path on a
// machine whose custom_nodes directory was sitting right there.
//
// Only a PRE-QUEUE refusal diverts (see managerEnqueueRefusal): the response
// describes the request, so nothing is running and the clone cannot race a
// Manager task writing to the same directory. Everything else rethrows.
// An empty successful enqueue is not a pre-queue refusal: Manager may
// have accepted the task and simply omitted its acknowledgement body.
// Start and drain the queue first, then the exact installed-pack/disk
// verification below decides whether a local clone is still needed.
let refusedBy;
let emptyV2Enqueue = false;
const enqueue = async () => await queueManagerTask("install", (api) => api !== "v2"
? // 3.x SEMANTICS (both the custom-node Manager AND pip Manager in
// legacy-UI mode, whose /v2 batch runs the same 3.x handlers — codex
// review on #235): a REAL git URL installs natively via
// { version:'unknown', files:[url] }, cloning it as an unregistered
// pack. (Gated server-side by security_level + allow_git_url_install
// config — a rejection surfaces as a 403/404 from the queue route.)
{
id: repoName,
version: "unknown",
selected_version: "unknown",
files: [gitId],
channel: opts.channel ?? "default",
mode: opts.mode ?? "cache",
}
: // Manager v4: REGISTRY-FIRST, CLONE FALLBACK. The v4 backend resolves
// an install by the pack's REPO NAME / CNR id — NOT a full git URL
// (do_install splits `${id}@${selected_version}` and looks the result
// up in its DB; a full URL matches nothing and the queue silently
// marks the task "done"). So we mirror the frontend UI: id = repo
// name, selected_version = ref or "nightly" (the git-HEAD channel for
// unclaimed packs), channel "dev", mode "cache".
{
id: repoName,
version: gitRef ?? "nightly",
selected_version: gitRef ?? "nightly",
channel: opts.channel ?? "dev",
mode: opts.mode ?? "cache",
}, managerBase,
// Manager v4 may accept the task while omitting its acknowledgement
// body. Direct installs can safely drain and verify before cloning;
// apply_manifest opts out so a late completion cannot start a local
// write after that budgeted call has returned UNKNOWN/pending.
{
rejectEmptyEnqueue: true,
allowEmptyV2Enqueue: opts.allowEmptyV2Enqueue !== false,
onEmptyV2Enqueue: () => {
emptyV2Enqueue = true;
},
});
let status;
try {
status = await enqueue();
}
catch (err) {
refusedBy = managerEnqueueRefusal(err);
// A remote target keeps the original error: the clone would write to a
// LOCAL tree that is not the server we are connected to, so "the Manager
// refused" is the honest and complete answer there.
assertInstallTargetStable(targetGeneration, managerBase);
const absenceAllows = await managerAbsenceAllowsGitFallback(err, managerBase);
assertInstallTargetStable(targetGeneration, managerBase);
if (isRemoteMode() || !absenceAllows) {
throw err;
}
// BEFORE the log line, which says "cloning directly" and would otherwise be
// recording an action that then does not happen.
if (localWriteMismatch) {
throw new ProcessControlError(wrongInstallRefusal(localWriteMismatch, 'install_custom_node (action:"install", git clone)', managerBase));
}
const localFallback = !isRemoteMode();
if (localFallback)
notifyLocalFallbackSelected(opts);
const managerUnavailable = refusedBy === undefined &&
isManagerQueueDetectionFailure(err) &&
// Two 404s no longer settle this on their own: if the same detection saw
// Manager answer its version route (or get auth-refused), "absent" is a
// claim its own error object refutes (#2754).
(!initialManagerQueueAbsenceWasProven(err) ||
managerAbsenceUnprovenDuringDetection(err));
logger.info("Manager refused, was unavailable, or was proven absent — cloning directly", {
status: refusedBy,
gitId,
});
const clone = () => cloneCustomNodeFallback(gitId, repoName, gitRef, refusedBy === 403 || refusedBy === 404
? { manager_refused: refusedBy }
: managerUnavailable
? { manager_unavailable: true }
: { manager_absent: true }, cliWorkspace, {
refFromVersion,
allowSharedWorkspaceFallback: cliWorkspace !== undefined,
managerRefusalNote: refusedBy === 403
? `ComfyUI-Manager REFUSED the git-URL install (HTTP 403) via its ` +
`security_level / allow_git_url_install policy gate. Nothing was queued there.`
: refusedBy === 404
? `ComfyUI-Manager's git-install route returned HTTP 404 before queueing. ` +
`Nothing was queued there.`
: managerUnavailable
? `ComfyUI-Manager's queue/status surface was unavailable: both dialect ` +
`probes repeatedly returned no queue status before any install task was ` +
`submitted. Nothing was queued there.`
: `ComfyUI-Manager is confirmed absent: both queue/status dialects returned HTTP 404 ` +
`on the connected ComfyUI. Nothing was queued there.`,
});
// A remote target never writes locally. In particular, an accepted
// Manager task that resolves no pack reaches cloneCustomNodeFallback only
// to receive its authoritative remote-target refusal; do not make that
// refusal wait behind an unrelated local writer.
let cloned;
try {
cloned = localFallback
? await withLocalInstallMutationLock(targetGeneration, managerBase, clone)
: await clone();
assertInstallTargetStable(targetGeneration, managerBase);
}
catch (err) {
if (localFallback)
notifyLocalFallbackSettled(opts, "failed");
throw err;
}
if (localFallback)
notifyLocalFallbackSettled(opts, "applied");
return withCliNote(cloned);
}
assertInstallTargetStable(targetGeneration, managerBase);
// VERIFY: the Manager's installed-pack list is the FIRST witness — but it is
// Manager's own bookkeeping, not the filesystem, so #2714 crosses it with a
// disk scan before any success wording (see diskCorroboratesManagerInstall).
// Three outcomes, and only one of them writes: for ordinary direct installs,
// a scan that finds the pack ABSENT falls through to the same direct clone an
// unregistered pack takes; apply_manifest disables that fallback after an
// empty v4 acknowledgement because the Manager task may still have landed.
// A HUSK throws; a scan that could not answer (remote, no proven scan root, an
// unreadable custom_nodes) keeps the Manager result and says the disk was not
// checked. "Could not look" is never folded into "not there".
const installed = await listInstalledNodesAt(managerBase).catch((err) => {
if (emptyV2Enqueue) {
const listError = err instanceof Error ? err.message : String(err);
throw new NodeManagementError(`ComfyUI-Manager accepted the git install with an empty v4 response and the queue ` +
`drained, but its installed-pack list could not be read (${listError}). ` +
`The result is UNKNOWN; no local fallback is authorized because that could ` +
`duplicate a Manager task whose post-state cannot be verified. Verify the pack ` +
`state before reissuing the install.`, {
kind: "manager-enqueue-empty-unverified",
base: managerBase,
repoName,
listError,
});
}
return [];
});
assertInstallTargetStable(targetGeneration, managerBase);
const listedNode = findInstalledNode(gitId, installed);
// A registry/CNR identity can intentionally point at a differently named
// source checkout (the #2714 aux-id alias test); only a bare from-source
// Manager entry's aux_id is origin evidence for this pack alias case.
const originMismatch = listedNode !== undefined &&
!listedNode.cnrId &&
!gitOriginMatchesRequested(gitId, listedNode.auxId);
const rejectEmptyV2Fallback = (verification, extra = {}) => {
throw new NodeManagementError(`ComfyUI-Manager accepted the git install with an empty v4 response and the queue ` +
`drained, but ${verification}. The result is UNKNOWN; no local fallback is ` +
`authorized because that could duplicate a Manager task that completed without a ` +
`visible matching post-state. Verify the pack state before reissuing the install.`, {
kind: "manager-enqueue-empty-unverified",
base: managerBase,
repoName,
...extra,
});
};
if (emptyV2Enqueue &&
opts.allowLocalFallbackAfterEmptyV2Enqueue === false &&
(listedNode === undefined || originMismatch)) {
rejectEmptyV2Fallback(originMismatch
? `it listed a different source origin for "${repoName}"`
: `it did not provide a matching installed-pack record for "${repoName}"`);
}
/** #2714 — why the Manager's own "installed" verdict was not taken. */
let uncorroboratedNote;
if (listedNode && !originMismatch) {
// The disk is only consulted when it can answer ABOUT THIS SERVER: in
// remote mode the tree we could read is not the host's, and with no local
// root captured there is nothing to scan. In both cases the list is the
// only witness there is, and saying so is the honest report — not silence.
const corroboration = presenceCtx.remote || !presenceCtx.diskRoot
? undefined
: diskCorroboratesManagerInstall(gitId, listedNode, presenceCtx.diskRoot);
if (corroboration?.state === "absent") {
if (emptyV2Enqueue &&
opts.allowLocalFallbackAfterEmptyV2Enqueue === false) {
rejectEmptyV2Fallback(`it still lists "${repoName}", but no matching pack exists under ${corroboration.scanned}`, { scanned: corroboration.scanned });
}
uncorroboratedNote =
`ComfyUI-Manager drained the install task and still lists "${repoName}" in its ` +
`installed-pack list, but NO matching pack exists under ${corroboration.scanned} ` +
`— so that list is describing its own bookkeeping, not this filesystem, and a ` +
`drained queue proves nothing landed (#2714). Nothing was installed by that route.`;
}
else if (corroboration?.state === "husk") {
// Same class as #900/#1816, reached from the git route: a directory the
// install left behind that ComfyUI cannot import. This call is not the
// proven author of it, so it is named and left untouched rather than
// deleted, and no success is claimed over it.
throw new NodeManagementError(`ComfyUI-Manager drained the install task and lists "${repoName}" as installed, but ` +
`${corroboration.dir} holds nothing ComfyUI can import — only metadata/marker files, ` +
`the husk of an install that did not complete. ComfyUI loads DIRECTORIES, so it will ` +
`fail to import that on every start. NOT reporting success: nothing usable was ` +
`installed. Delete ${corroboration.dir} by hand and retry the install.`, status);
}
else {
return withCliNote({
mechanism: "manager-http",
message: `Installed "${repoName}" via ComfyUI-Manager` +
(corroboration?.state === "corroborated"
? `, verified present on disk at ${corroboration.dir}`
: `. Its presence on disk was NOT verified from here (${presenceCtx.remote
? "this session targets a REMOTE ComfyUI, so the local filesystem is not the host's"
: !presenceCtx.diskRoot
? "no local custom_nodes scan root could be established for the connected server"
: "the local custom_nodes scan could not answer"}), so ComfyUI-Manager's installed-pack list is the only witness`) +
`. Restart may be required to load new nodes.`,
details: status,
});
}
}
const replaceOrigin = originMismatch ? listedNode?.auxId : undefined;
if (localWriteMismatch) {
throw new ProcessControlError(wrongInstallRefusal(localWriteMismatch, 'install_custom_node (action:"install", git clone)', managerBase));
}
const localFallback = !isRemoteMode();
if (localFallback)
notifyLocalFallbackSelected(opts);
const clone = () => cloneCustomNodeFallback(gitId, repoName, gitRef, status, cliWorkspace, {
refFromVersion,
allowSharedWorkspaceFallback: cliWorkspace !== undefined,
...(replaceOrigin
? {
replaceOrigin,
managerRefusalNote: `ComfyUI-Manager installed "${replaceOrigin}" instead of the requested ` +
`origin ${gitOwnerRepo(gitId) ?? gitId}`,
}
: {}),
// #2714 — the default reason ("not in the ComfyUI-Manager registry") is the
// wrong explanation when Manager DID list the pack; state what was actually
// observed instead, so a subsequent refusal from here is not misattributed.
...(uncorroboratedNote && !replaceOrigin
? { managerRefusalNote: uncorroboratedNote }
: {}),
});
// An accepted remote Manager task that resolves no pack still reaches this
// helper only for its authoritative remote-target refusal; it is not a
// local write and must not wait on the local writer lock.
let cloned;
try {
cloned = localFallback
? await withLocalInstallMutationLock(targetGeneration, managerBase, clone)
: await clone();
assertInstallTargetStable(targetGeneration, managerBase);
}
catch (err) {
if (localFallback)
notifyLocalFallbackSettled(opts, "failed");
throw err;
}
if (localFallback)
notifyLocalFallbackSettled(opts, "applied");
return withCliNote(cloned);
}
// Registry (plain CNR id). Keep the prior defaults channel "default" /
// mode "remote" (overridable via opts) — forcing "dev"/"cache" risks resolving
// a different build or failing for default-only packs. The UI-style
// "dev"/"cache" is used ONLY for the git registry-first lookup above. Then
// VERIFY the pack actually landed — a non-URL id can't be cloned, so an absent
// pack is a hard error rather than a silent no-op.
const status = await queueManagerTask("install", {
id,
version: version ?? "latest",
selected_version: version ?? "latest",
channel,
mode,
}, managerBase);
assertInstallTargetStable(targetGeneration, managerBase);
// #797: verify across BOTH sources (Manager list + local disk) and keep
// "could not determine" distinct from "determined absent" — an unreadable
// Manager list collapsed to [] used to read as "not found in the registry".
const presence = await resolvePackPresence(id, managerBase, presenceCtx);
assertInstallTargetStable(targetGeneration, managerBase);
switch (presence.state) {
case "manager-listed":
return withCliNote({
mechanism: "manager-http",
message: `Installed "${id}" via ComfyUI-Manager. Restart may be required to load new nodes.`,
details: status,
});
case "on-disk":
// #1816 — a registry zip that extracted nothing still creates the target
// directory (Manager writes `.tracking` first). Directory-exists is not
// "installed": ComfyUI cannot import a marker-only folder. Fail before
// any success wording, including the unreadable-list / already-there
// branches, so a retry over the husk cannot report success either.
if (!looksLikeAPack(presence.dir)) {
throw await refuseEmptyRegistryPack(id, presence.dir, status, diskBefore?.state === "not-found");
}
if (!presence.managerListReadable) {
// Present on disk, but the Manager list could not be read — claiming
// "already installed, nothing new happened" would assert an outcome
// nobody observed.
return withCliNote({
mechanism: "manager-http",
message: `"${id}" is present on disk at ${presence.dir}. Whether the queued install ` +
`changed anything could NOT be verified: ComfyUI-Manager's installed-pack ` +
`list could not be read (${presence.listError}). NOT claiming a fresh install ` +
`— check install_custom_node (action:"list").`,
details: status,
});
}
// The Manager install resolved to nothing and the pack IS on disk. WHICH of
// those two stories it is depends entirely on the PRE-state, which is why
// it is read here and not inferred.
if (diskBefore?.state === "not-found") {
// It was NOT there before and it is now, AND the directory holds pack
// files (the empty-husk gate above already ran). Reporting "already
// installed" here hid a successful install behind a no-op.
// STATES THE OBSERVATION, NOT A CAUSE (codex gate P1, #1816). `diskBefore`
// is a filesystem snapshot with nothing binding it to this operation, so
// under two concurrent agents on one rig — which is in scope — the pack
// could have been created by the OTHER agent between our pre-check and
// our post-check. "It was absent before and is present now" is exactly
// what we saw; "this call installed it" is an inference the evidence
// does not support. The user's next action (restart to load it) is
// identical either way, so the weaker claim costs them nothing.
return withCliNote({
mechanism: "manager-http",
message: `"${id}" is now present on disk at ${presence.dir}, and was NOT there before ` +
`this call, though ComfyUI-Manager does not track it (a Comfy Registry zip ` +
`install is not in its installed-pack list). If another agent is working on ` +
`this ComfyUI, it may have been the one that put it there. Restart ComfyUI ` +
`to load it.`,
details: status,
});
}
if (diskBefore?.state === "found") {
return withCliNote({
mechanism: "manager-http",
message: `"${id}" is present on disk at ${presence.dir}, but ComfyUI-Manager does not ` +
`track it (a Comfy Registry zip install or a manual copy), and it was ALREADY ` +
`there before this call — so the queued registry install resolved to nothing ` +
`new. Restart ComfyUI if it was just added.`,
details: status,
});
}
// No usable pre-state (no disk root, or the pre-check was unreadable). The
// pack is on disk NOW; whether this call put it there is undetermined, and
// saying either would be a guess dressed as a finding.
return withCliNote({
mechanism: "manager-http",
message: `"${id}" is present on disk at ${presence.dir}, and ComfyUI-Manager does not ` +
`track it (a Comfy Registry zip install or a manual copy). Whether THIS call ` +
`installed it could not be determined — the pack's state before the call was ` +
`${diskBefore?.state === "unreadable" ? `unreadable (${diskBefore.reason})` : "not observable in this session"}. ` +
`Restart ComfyUI if it was just added.`,
details: status,
});
case "absent":
throw new NodeManagementError(presence.evidence === "manager+disk"
? `"${id}" was queued but is not present afterward — it is in neither the ` +
`ComfyUI-Manager installed-pack list NOR on disk under ${presence.scanned}. ` +
`It was not found in the ComfyUI-Manager registry. Check the pack id, or pass ` +
`a git URL to clone it directly.`
: `"${id}" was queued but is not present afterward — it was not found in the ` +
`ComfyUI-Manager registry. Check the pack id, or pass a git URL to clone ` +
`it directly.`, status);
case "unverifiable":
throw new NodeManagementError(`"${id}" was queued, but whether it landed could NOT be verified: ${presence.reason} ` +
`NOT reporting success on an unverified install. Check install_custom_node (action:"list"), ` +
`then retry if it is genuinely absent.`, status);
}
}
/**
* Post-op presence gate for update/reinstall (#730) — the same fail-closed
* check install got in #232, reusing the same helpers (listInstalledNodesAt +
* nodeInstalledMatches). A drained Manager queue proves NOTHING about whether
* work happened: for an id that resolves nowhere the enqueue is a silent no-op
* and the drain passes trivially (total_count 0 while done_count increments),
* which is how update/reinstall reported "Queued + updated/reinstalled" for
* packs that do not exist. So re-read the installed list afterward —
* /customnode/installed reflects the on-disk custom_nodes — and require the
* pack to resolve SOMEWHERE before claiming success.
*
* A pack that IS installed but not in the registry (git-cloned) still matches —
* nodeInstalledMatches covers the module/auxId spellings — so that path is
* unaffected; only an id that resolves NOWHERE fails.
*/
/**
* Thrown by assertPackPresentAfterOp ONLY when absence was actually observed
* (every consulted source agrees the pack is gone). An unverifiable post-state
* throws a plain NodeManagementError instead, so a caller wrapping failures —
* the reinstall path, which CAN truthfully say "left the pack REMOVED" for an
* observed absence but not for an unreadable one — can tell them apart
* (codex gate round 10).
*/
export class PackAbsentAfterOpError extends NodeManagementError {
constructor(message, details) {
super(message, details);
this.name = "PackAbsentAfterOpError";
}
}
async function assertPackPresentAfterOp(id, op, base, status, ctx) {
// #771 — SAY WHAT WE ACTUALLY CHECKED; #797 — CHECK THE DISK TOO. This gate
// consults up to TWO sources: the Manager's installed list AND (in a local
// session) the on-disk custom_nodes scan. The Manager list alone never sees a
// Comfy Registry zip install or a manual copy, so a list-only miss is NOT
// proof the pack "is not installed locally" — for the sidebar panel installed
// as a zip, that assertion was flatly false while install_comfyui(action:'panel', panel_action:'status')
// reported the same pack at a concrete directory and version.
//
// The three outcomes stay distinct (this fold is #796's recurring defect):
// present (either source), absent (BOTH consulted sources agree), and
// unverifiable (a source that could not answer — an unreadable list or an
// unenumerable directory is never treated as absence).
const presence = await resolvePackPresence(id, base, ctx);
switch (presence.state) {
case "manager-listed":
return;
case "on-disk":
if (!presence.managerListReadable) {
// The pack IS on disk, but Manager's list could not be read — so whether
// Manager tracked it, and whether the queued op did anything, are both
// UNKNOWN. Asserting "resolved to nothing" would be a guess.
throw new NodeManagementError(`"${id}" was queued for ${op}. The pack IS present on disk at ${presence.dir}, ` +
`but ComfyUI-Manager's installed-pack list could not be read (${presence.listError}), ` +
`so whether the ${op} took effect could NOT be verified. NOT reporting success ` +
`on an unverified ${op}. Check that ComfyUI-Manager is reachable, then retry.`, status);
}
// The pack IS installed — the op still resolved to nothing, because
// Manager can only update/reinstall packs IT tracks. Refuse, but with the
// truth: where the pack is and why Manager could not touch it.
throw new NodeManagementError(`"${id}" was queued for ${op}, which resolved to NOTHING: the pack is present ` +
`on disk at ${presence.dir} but ComfyUI-Manager does not track it (a Comfy ` +
`Registry zip install or a manual copy), and Manager can only ${op} packs in ` +
`its own installed list. ` +
`The pack was NOT ${op === "update" ? "updated" : "reinstalled"}. To move it, reinstall it from its ` +
`registry id or repository URL (install_custom_node action:"install" / action:"reinstall"), ` +
`or — when ${presence.dir} is a git checkout — pull it there directly.`, status);
case "absent":
if (presence.evidence === "manager+disk") {
throw new PackAbsentAfterOpError(`"${id}" was queued for ${op} but is not present afterward — it is in neither ` +
`ComfyUI-Manager's installed-pack list NOR on disk under ${presence.scanned} — ` +
`so the ${op} resolved to nothing. Check the pack id with install_custom_node (action:"list"), ` +
`or find the right id with search_custom_nodes.`, status);
}
// Remote session: only the Manager list could be consulted. Say exactly that.
throw new PackAbsentAfterOpError(`"${id}" was queued for ${op} but is not present afterward in ` +
`ComfyUI-Manager's installed-pack list, so the ${op} resolved to nothing. NOTE: this ` +
`check reads ComfyUI-Manager's registry/installed list ONLY — it does not ` +
`inspect custom_nodes, so a pack that IS on disk but unknown to the Manager ` +
`(a Comfy Registry zip install, or a manual copy) reaches here too. Check ` +
`the pack id with install_custom_node (action:"list"), and for the sidebar panel use ` +
`install_comfyui(action:'panel', panel_action:'status'), which reads the directory itself.`, status);
case "unverifiable":
throw new NodeManagementError(`"${id}" was queued for ${op}, but whether it landed could NOT be verified: ` +
`${presence.reason} NOT reporting success on an unverified ${op}. Check that ` +
`ComfyUI-Manager is reachable on the host, then retry.`, status);
}
}
/**
* Pre-op resolution for update/reinstall (#730's gate moved BEFORE the queue,
* codex gate round 8): the queued task must name the pack's MANAGER module
* name — a caller's registry id can differ from it ("comfyui-impact-pack" vs
* "ComfyUI-Impact-Pack"), and the legacy routes resolve by module, so an
* update enqueued under the registry id no-ops while the post-check still
* matches the pre-existing record by cnr_id and reports "updated". Resolving
* first also means a target that resolves nowhere is refused with NOTHING
* queued rather than after a meaningless queue cycle. Returns the tracked
* entry; throws the truthful refusal otherwise.
*/
async function resolveTrackedForOp(id, op, base, ctx) {
const presence = await resolvePackPresence(id, base, ctx);
switch (presence.state) {
case "manager-listed":
return presence.node;
case "on-disk":
throw new NodeManagementError(presence.managerListReadable
? `"${id}" is present on disk at ${presence.dir} but ComfyUI-Manager does not ` +
`track it (a Comfy Registry zip install or a manual copy), so Manager cannot ` +
`${op} it and NOTHING was queued. To move it, reinstall it from its registry id ` +
`or repository URL (install_custom_node action:"install" / action:"reinstall"), or — when ` +
`${presence.dir} is a git checkout — pull it there directly.`
: `"${id}" is present on disk at ${presence.dir}, but ComfyUI-Manager's ` +
`installed-pack list could not be read (${presence.listError}), so whether ` +
`Manager can ${op} it could NOT be determined and NOTHING was queued. Check ` +
`that ComfyUI-Manager is reachable, then retry.`);
case "absent":
throw new NodeManagementError(presence.evidence === "manager+disk"
? `"${id}" is not installed — it is in neither ComfyUI-Manager's installed-pack ` +
`list NOR on disk under ${presence.scanned} — so there is nothing to ${op} ` +
`and NOTHING was queued. Check the pack id with install_custom_node (action:"list"), or find ` +
`the right id with search_custom_nodes.`
: `"${id}" is not in ComfyUI-Manager's installed-pack list, so there is nothing ` +
`to ${op} and NOTHING was queued. NOTE: this check reads the Manager list only ` +
`(remote session — no disk check possible). Check the pack id with ` +
`install_custom_node (action:"list").`);
case "unverifiable":
throw new NodeManagementError(`Whether "${id}" is installed could not be determined (${presence.reason}), so ` +
`the ${op} was NOT queued — a ${op} queued blind would drain silently whether ` +
`or not it did anything. Check that ComfyUI-Manager is reachable, then retry.`);
}
}
/** Does this update target refer to ComfyUI-Manager itself (any spelling)? */
function isManagerSelfTarget(id) {
const norm = basename(id.trim().toLowerCase()).replace(/[_\s]+/g, "-");
return norm === "comfyui-manager" || norm === "comfy-manager";
}
/**
* Actionable terminal error for a Manager build that does not register its own
* self-update route. This is the ONLY outcome of that case — see updateManagerSelf
* for why there is deliberately no local-git fallback.
*/
function managerSelfUpdateUnsupported(api, cause) {
const dialect = api === "legacy"
? "the LEGACY ComfyUI-Manager 3.x queue API"
: api === "v2-batch"
? "this Manager's legacy-UI (3.x-under-/v2) queue API"
: "this Manager's queue API";
return new NodeManagementError(`Updating ComfyUI-Manager ITSELF is not supported by ${dialect} on this build — ` +
`its self-update route answered HTTP 405, i.e. the route is not registered. ` +
`NOTHING WAS UPDATED. Update ComfyUI-Manager on the ComfyUI HOST instead — for a ` +
`custom_nodes checkout: \`git -C custom_nodes/ComfyUI-Manager pull\`; for the pip ` +
`package: \`pip install -U comfyui_manager\` — then restart ComfyUI. ` +
`(comfyui-mcp will not git-pull a checkout on ITS OWN machine for you here: it cannot ` +
`prove that checkout is the one the connected server actually loaded — a loopback URL ` +
`can be a container or an SSH port-forward — and pulling the wrong copy would report a ` +
`fix that never reached your ComfyUI.) ` +
`See https://comfyui-mcp.artokun.io/docs/troubleshooting`, cause instanceof NodeManagementError ? cause.details : undefined);
}
/**
* Update ComfyUI-Manager ITSELF (#424).
*
* The released Manager 3.x DOES support this through its ordinary per-operation
* route — POST /manager/queue/update with { id: "comfyui-manager", version } —
* verified against ComfyUI-Manager 3.41 glob/manager_server.py + manager_core.py:
* `unified_update` has NO comfyui-manager guard (unlike install/enable/disable/
* uninstall, which explicitly refuse it), and /manager/queue/update_all itself
* enqueues 'comfyui-manager' as an update task. The original #424 HTTP 405 came
* from posting the v4-only unified envelope path (…/queue/task) at a 3.x server,
* where an UNREGISTERED POST is answered 405 by ComfyUI's frontend catchall —
* not from any per-pack refusal. So: route the self-update at the real endpoint
* for the detected dialect first.
*
* When THAT route also answers 405 (genuinely unregistered on this build) the ONLY
* outcome is the explicit "not supported here, update it on the host via X" error.
*
* There is deliberately NO local-git fallback. The previous implementation pulled
* the resolved code workspace's custom_nodes/ComfyUI-Manager checkout, which is only ever
* correct if that checkout is the one the CONNECTED server actually loaded — and
* that cannot be established over HTTP. Successive review rounds killed one
* wrong-target hole after another (remote host; --force-remote over a forwarded
* loopback port; a second local instance on another port; finally a loopback
* container or SSH forward whose reported install root string coincides with a
* DIFFERENT host path — /system_stats argv reports a path in the SERVER's
* filesystem namespace, which we cannot compare to ours). Each hole ends the same
* way: git-pull a checkout the running Manager never loads, then report it as the
* fix. shouldDispatchDownloadToManager (model-resolver.ts) hits the same wall and
* settles for an existsSync probe only because ITS failure mode is a stray
* directory; here it would be a fabricated success, which this issue exists to
* eliminate. Refusing with the exact host-side command is always correct; guessing
* is not. (The primary path above is the real #424 fix — released Manager 3.x
* registers the route, so this branch is for builds that genuinely lack it.)
*/
async function updateManagerSelf(id) {
// Keep this self-update on the ComfyUI instance selected at invocation. A
// panel retarget during the dialect recheck must not update another instance.
const base = managerBaseUrl();
const api = await detectManagerApi(base);
// ONLY the enqueue is inspected for the 405 signal. A 405 raised later, while
// draining (e.g. `${prefix}/start`), says nothing about whether the update route
// exists, so it must propagate as itself rather than be reported as "unsupported"
// (codex review) — hence enqueue and drain are called separately here.
//
// The enqueue goes through the dialect self-heal (#646) first: a 405 can ALSO
// mean we spoke a dialect the server no longer runs (a Manager upgrade + restart
// at the same URL), and telling that user "your build doesn't support this" would
// be flatly wrong. Only a 405 that survives a re-probe — the route really is
// unregistered on the live dialect — reaches the unsupported verdict.
let used;
// The verdict must name the dialect of the attempt that ACTUALLY 405'd — after a
// re-probe that can differ from the dialect detected on entry, and the message is
// dialect-specific ("the LEGACY 3.x queue API" vs the legacy-UI one).
let lastTried = api;
try {
used = await enqueueWithDialectSelfHeal("update", (dialect, epoch) => {
lastTried = dialect;
return enqueueManagerTask(dialect, "update", () => ({ node_name: id }), randomUUID(), base, epoch);
}, base);
}
catch (err) {
if (errorStatus(err) !== 405)
throw err;
// 405 = the update route is not registered on this build (#424).
logger.debug("Manager self-update route 405 — reporting unsupported", { api: lastTried });
throw managerSelfUpdateUnsupported(lastTried, err);
}
const status = await runManagerQueue(used, base);
return {
mechanism: "manager-http",
message: `Queued ComfyUI-Manager's own update via ComfyUI-Manager (${id}) and the queue drained. ` +
`Manager marks a task done even when the underlying git/registry update was a no-op or ` +
`failed, and a Manager self-update only takes effect after a RESTART — restart ComfyUI, ` +
`then confirm the Manager version actually changed.`,
details: status,
};
}
/**
* ENQUEUE update-all on its dedicated route in the detected dialect, WITHOUT
* draining the queue. Shared by updateCustomNode({id:"all"}) (which drains) and
* the update-all tool's fire-and-forget path (queueUpdateAllCustomNodes), so
* both inherit dialect selection, cache invalidation, and the 404/405-only
* re-send discipline from enqueueWithDialectSelfHeal — a 400 re-detects but is
* never re-sent, so update-all can never run twice (#656).
*
* Returns the dialect the enqueue ACTUALLY spoke (so the caller starts/polls
* the queue holding the task), the route's raw response body, and the ui_id
* of the attempt that LANDED (a self-heal retry mints a fresh one — only the
* last one's tasks exist, so only it correlates with v4 queue history, #689).
*/
async function enqueueUpdateAll(mode, base) {
let response;
let uiId = "";
const used = await enqueueWithDialectSelfHeal("update-all", async (api) => {
// The v4 backend reads UpdateAllQueryParams from the QUERY STRING
// (manager_server.py), NOT the JSON body — a body-only request leaves mode
// defaulting to 'remote' and drops client_id/ui_id. Send them as URL query
// params. A fresh ui_id per attempt keeps the two attempts distinguishable.
const attemptUiId = randomUUID();
if (api === "legacy") {
// 3.x reads {mode} from the JSON BODY (and ignores client_id/ui_id).
response = await managerFetch("/manager/queue/update_all", {
method: "POST",
body: { mode, ui_id: attemptUiId },
base,
});
}
else {
const query = new URLSearchParams({
mode,
client_id: MANAGER_CLIENT_ID,
ui_id: attemptUiId,
}).toString();
response = await managerFetch(`/v2/manager/queue/update_all?${query}`, {
method: "POST",
base,
});
}
// Only reached when the enqueue did NOT throw — this attempt's id is the
// one whose tasks are actually on the queue.
uiId = attemptUiId;
}, base);
return { used, response, uiId };
}
export async function updateCustomNode(opts) {
// PIN GUARD — covers id="comfyui-agent-panel", the repo-name and git-URL
// spellings, and id="all" (a bulk update moves the panel too). The check and
// the mutation are atomic under the panel mutation lock: update-all waits on
// the Manager queue, and a pin written in that window must block the NEXT
// op, not land inside this one (withPanelPinGuard).
return withPanelPinGuard("update", opts.id, () => withObjectInfoInvalidation(() => updateCustomNodeImpl(opts)));
}
async function updateCustomNodeImpl(opts) {
const { id, mode = "remote", channel = "default" } = opts;
const all = id.trim().toLowerCase() === "all";
if (opts.useCmCli) {
const out = runCmCli(["update", id, "--mode", mode, "--channel", channel]);
return {
mechanism: "comfy-cli",
message: all
? "Updated all installed node packs via official comfy-cli."
: `Updated "${id}" via official comfy-cli.`,
details: out.trim(),
};
}
// Updating ComfyUI-Manager ITSELF is its own routing problem (#424): it has a
// real endpoint on every dialect, but a build that doesn't register it answers
// 405 (ComfyUI's catchall) and the in-panel 3.x→v4 upgrade path deadlocks.
// updateManagerSelf routes at the right endpoint for the detected dialect, and
// on a 405 there reports an explicit "not supported here, run X on the host".
if (!all && isManagerSelfTarget(id))
return updateManagerSelf(id);
let status;
/** #1999 — set only on the single-pack branch; see NodeOpResult.taskUiId. The
* update-all branch deliberately leaves it undefined: v4 derives a SEPARATE
* per-pack task id (`${uiId}_${pack}`) there, so the bare enqueue id would
* never resolve in queue history and handing it out would invite a lookup
* that answers "no such task" about a task that really ran. */
let taskUiId;
// Freeze the target BEFORE the first await (queueManagerTask does the same):
// a mid-op retarget must not split the enqueue and the post-op verification
// across two servers. The base travels back on the result so callers (the
// #724 panel fallback's dialect probe) verify against the SAME Manager.
const base = managerBaseUrl();
// Captured with the target, before any await: the pre-resolve AND the
// post-drain gate must read the SAME disk context (codex gate round 11).
const presenceCtx = capturePackPresenceContext();
if (all) {
// update-all keeps its own dedicated route (so it does NOT go through
// queueManagerTask), but it is just as dialect-dependent — route it through
// the same enqueue-only self-heal so a stale classification can't wedge it
// either (#646). Enqueue here, drain once below.
const { used } = await enqueueUpdateAll(mode, base);
status = await runManagerQueue(used, base);
}
else {
// Resolve the target BEFORE queueing (codex gate round 8): the update body
// must name the pack's MANAGER module name — a caller's registry id can
// differ from it, a legacy route resolves by module, and a no-op update
// would still pass a post-check that matches by cnr_id. A target that
// resolves nowhere is refused with NOTHING queued.
const tracked = await resolveTrackedForOp(id, "update", base, presenceCtx);
// Single-pack update → unified task; UpdatePackParams uses node_name/node_ver.
const queued = await queueManagerTaskTracked("update", { node_name: tracked.module }, base);
status = queued.status;
taskUiId = queued.uiId;
// VERIFY (#730): the drain passes trivially for an id Manager never
// enqueued (total_count 0 — the "Queued + updated" lie in the issue).
// Require the pack to resolve SOMEWHERE post-op before claiming success.
await assertPackPresentAfterOp(id, "update", base, status, presenceCtx);
}
return {
mechanism: "manager-http",
message: all
? "Queued update-all with ComfyUI-Manager. Completion and the sidebar panel's on-disk version are not verified yet."
: `Queued + updated "${id}" via ComfyUI-Manager.`,
details: status,
managerBase: base,
taskUiId,
};
}
/**
* The update-all MCP tool's path (#656): enqueue update-all in the DETECTED
* dialect and kick the queue worker, then return WITHOUT draining — the tool
* reports "queued + started" and the updates run asynchronously (unlike
* updateCustomNode({id:"all"}), which drains the queue). Routed through the
* same detectManagerApi + enqueue-with-self-heal machinery as every other
* Manager mutation, so it inherits dialect selection, cache invalidation, and
* the no-double-execute discipline rather than assuming the legacy 3.x route.
*
* No object_info invalidation here: the queued updates only take effect after
* a ComfyUI RESTART, and the restart lifecycle already drops that cache.
*/
export async function queueUpdateAllCustomNodes() {
// One pinned target for the whole operation — detection, the enqueue, any
// self-heal retry, and the queue start all stay on the instance selected at
// invocation, even if the panel retargets mid-call.
const base = managerBaseUrl();
const { used, response, uiId } = await enqueueUpdateAll("remote", base);
const prefix = managerQueuePrefixFor(used);
let queueStarted = true;
try {
await managerQueueControl(`${prefix}/start`, base);
}
catch (err) {
// The update-all IS queued — a start failure must not fail the whole call;
// report it so the user can kick the queue manually.
queueStarted = false;
logger.warn("Queued update-all but failed to start the queue worker", {
error: err instanceof Error ? err.message : String(err),
});
}
return {
endpoint: `${prefix}/update_all`,
queueStarted,
managerResponse: response,
base,
uiId,
};
}
export async function reinstallCustomNode(opts) {
// PIN GUARD — same door as install/update, and check + mutation are likewise
// atomic under the panel mutation lock (withPanelPinGuard).
return withPanelPinGuard("reinstall", opts.id, () => withObjectInfoInvalidation(() => reinstallCustomNodeImpl(opts)));
}
async function reinstallCustomNodeImpl(opts) {
const { id, version, mode = "remote", channel = "default" } = opts;
if (opts.useCmCli) {
const out = runCmCli(["reinstall", id, "--mode", mode, "--channel", channel]);
return {
mechanism: "comfy-cli",
message: `Reinstalled "${id}" via official comfy-cli.`,
details: out.trim(),
};
}
// The unified queue has no `reinstall` kind, so model it as uninstall + a
// fresh install of the same target. Each is its own drained queue cycle, but
// the TWO cycles are one logical operation and must share ONE pinned target:
// captured once, up front, so a panel retarget after the uninstall drains
// cannot send the install to a different ComfyUI — which would leave the
// user's server uninstalled while reporting success for another.
const base = managerBaseUrl();
const presenceCtx = capturePackPresenceContext();
// Resolve BEFORE the uninstall half (codex gate round 8): it must name the
// Manager module name, and a nowhere-resolving id is refused with NOTHING
// queued — previously both cycles no-op'd and the post-check could still
// match a pre-existing record by cnr_id.
const tracked = await resolveTrackedForOp(id, "reinstall", base, presenceCtx);
await queueManagerTask("uninstall", { node_name: tracked.module }, base);
// The install half must name an identity the registry can actually RESOLVE
// (codex gate round 9): when the caller passed the module/folder spelling,
// installing it back under that spelling resolves nowhere — the pack would
// be removed and not restored. The tracked record's CNR id is the stable
// identity; a git/aux pack without one keeps the caller's spelling (the v4
// backend also resolves repo names), as before.
const reinstallId = tracked.cnrId ?? id;
const status = await queueManagerTask("install", {
id: reinstallId,
version: version ?? "latest",
selected_version: version ?? "latest",
channel,
mode,
}, base);
// VERIFY (#730): same queue-drain trust hole as update — for an id that
// resolves nowhere BOTH cycles no-op and both drains pass trivially. Require
// the pack to be present afterward before claiming a reinstall. An OBSERVED
// post-op absence gets the reinstall-specific truth (the uninstall half
// already ran — the pack is REMOVED, not untouched); an UNVERIFIABLE
// post-state keeps the gate's own "could not verify" message.
try {
await assertPackPresentAfterOp(id, "reinstall", base, status, presenceCtx);
}
catch (err) {
// "Left the pack REMOVED" is only true when absence was OBSERVED — an
// unverifiable post-state (unreadable list, inconclusive disk) establishes
// neither removal nor a failed install, and keeps its own message
// (codex gate round 10).
if (err instanceof PackAbsentAfterOpError) {
throw new NodeManagementError(`The reinstall of "${id}" left the pack REMOVED: the uninstall half drained, ` +
`but the install half did not restore it. Install it again with ` +
`install_custom_node ("${reinstallId}"). Underlying detail: ${err.message}`, err.details);
}
throw err;
}
return {
mechanism: "manager-http",
message: `Queued + reinstalled "${id}" (uninstall + install) via ComfyUI-Manager. A restart may be required.`,
details: status,
};
}
export async function fixCustomNode(opts) {
// `fix` reinstalls the pack's dependencies and can pull the pack itself, and
// it accepts "all" — same door, same guard, same lock (withPanelPinGuard).
return withPanelPinGuard("fix", opts.id, () => withObjectInfoInvalidation(() => fixCustomNodeImpl(opts)));
}
async function fixCustomNodeImpl(opts) {
const { id, mode = "remote", channel = "default" } = opts;
const all = id.trim().toLowerCase() === "all";
// The HTTP API has no "fix all"; cm-cli supports it. Route accordingly.
if (opts.useCmCli || all) {
const out = runCmCli(["fix", id, "--mode", mode, "--channel", channel]);
return {
mechanism: "comfy-cli",
message: all
? "Repaired all installed node packs via official comfy-cli."
: `Repaired "${id}" via official comfy-cli.`,
details: out.trim(),
};
}
// FixPackParams requires node_ver; "" lets Manager resolve the installed
// version (do_fix looks the pack up by name).
// Pin the target before the first await so the enqueue, drain, and per-task
// history lookup stay on the same Manager even if the panel retargets.
const base = managerBaseUrl();
const queued = await queueManagerTaskTracked("fix", { node_name: id, node_ver: "" }, base);
const status = queued.status;
// #2490 — a drained queue / done_count is not a repair. Manager v4 records
// the real verdict on the per-task history row (status_str + result); a
// not-found / error there used to still print "Queued + repaired".
let taskRecord;
try {
taskRecord = await fetchManagerTaskHistoryEntry(queued.uiId, base);
}
catch {
taskRecord = undefined;
}
const failure = managerTaskLooksFailed(taskRecord);
if (failure) {
throw new NodeManagementError(`ComfyUI-Manager recorded the fix of "${id}" as ${failure.status}. ` +
`The queue drained, but that only proves the task finished running; it does not prove ` +
`the pack was repaired.` +
(failure.excerpt ? ` Manager result: ${failure.excerpt}` : ""), {
queueStatus: status,
managerTask: taskRecord ? safeManagerTaskDetails(taskRecord, failure.excerpt) : undefined,
uiId: queued.uiId,
});
}
return {
mechanism: "manager-http",
message: `Queued + repaired "${id}" via ComfyUI-Manager.`,
details: status,
managerBase: base,
taskUiId: queued.uiId,
};
}
async function readEnabledVerdict(id, expectEnabled, base) {
let installed;
let listError;
try {
installed = await listInstalledNodesAt(base);
}
catch (err) {
listError = err instanceof Error ? err.message : String(err);
}
if (!installed) {
return {
state: "inconclusive",
reason: `ComfyUI-Manager's installed-pack list could not be read (${listError})`,
};
}
const node = findInstalledNode(id, installed);
if (!node) {
return {
state: "inconclusive",
reason: `"${id}" no longer appears in ComfyUI-Manager's installed-pack list`,
};
}
if (node.enabled === undefined) {
return {
state: "inconclusive",
reason: `ComfyUI-Manager did not report an enabled/disabled flag for "${id}"`,
};
}
if (node.enabled === !expectEnabled) {
return { state: "not-applied", reported: node.enabled ? "enabled" : "disabled" };
}
return { state: "applied" };
}
async function setCustomNodeEnabled(opts, enable) {
const { id } = opts;
const op = enable ? "enable" : "disable";
const base = managerBaseUrl();
// Pinned with the target: the CLI must run against the SAME local install
// the pre/post checks describe, even if the configured workspace is retargeted
// during an await below (codex gate round 5).
const cliWorkspace = resolveEffectiveComfyUIBase();
const presenceCtx = capturePackPresenceContext(cliWorkspace);
// A disabled Manager cannot answer its own installed-list or queue routes.
// For local enable, only permit the recovery path for a validated real
// `<pack>.disabled` child of this operation's captured custom_nodes root.
// Remote sessions never inspect or mutate local disk.
const localPackId = !presenceCtx.remote ? assertSafeLocalPackId(id) : id;
const localRecoveryAllowed = !presenceCtx.remote && opts.useCmCli !== false && isManagerSelfTarget(localPackId);
const disabledOnDisk = enable && !presenceCtx.remote && opts.useCmCli !== false && presenceCtx.diskRoot
? findDisabledPackOnDisk(localPackId, presenceCtx.diskRoot)
: undefined;
const managerDisabledOnDisk = localRecoveryAllowed &&
disabledOnDisk &&
isManagerSelfTarget(disabledOnDisk.cliId)
? disabledOnDisk
: undefined;
// CLI availability probe FIRST (#808 fallback discipline), but NO subprocess
// runs yet: the presence pre-check below comes before either mechanism,
// because a CLI run of an already-satisfied/no-such-pack request reports a
// pre-existing state as a fresh transition exactly like a queued no-op.
const cliProblem = opts.useCmCli ? comfyCliUnavailableReason(cliWorkspace) : undefined;
const useCli = opts.useCmCli === true && cliProblem === undefined;
let cliFallbackNote;
if (opts.useCmCli && !useCli) {
logger.info(`comfy-cli requested (useCmCli) but unavailable — falling back to Manager HTTP for ${op}`, {
reason: cliProblem,
});
cliFallbackNote =
`comfy-cli was requested (useCmCli) but is not usable here: ${cliProblem}. ` +
`NOTHING was run through comfy-cli — ComfyUI-Manager was used for what follows instead.`;
}
// Honor an explicitly requested usable comfy-cli BEFORE the Manager list
// precheck. This is the only safe early escape: the directory was found by
// enumerating the captured custom_nodes root, its name is validated, and the
// CLI is given the canonical directory name rather than the raw caller id.
if (enable && useCli && managerDisabledOnDisk && presenceCtx.diskRoot) {
return enablePackViaComfyCliFromDisk(localPackId, presenceCtx.diskRoot, managerDisabledOnDisk);
}
// Pre-op validation: a disable/enable for an id Manager never heard of is a
// silent no-op (queue or CLI alike), and the LEGACY dialects key the body on
// the pack's REAL installed version (node-bisect's controller does the same
// — "never send 'unknown' for an installed pack"). Both need the installed
// list read up front.
const presence = await resolvePackPresence(id, base, presenceCtx);
if (enable &&
disabledOnDisk &&
!managerDisabledOnDisk &&
(presence.state === "on-disk" || presence.state === "unverifiable")) {
throw new NodeManagementError(`Refusing local recovery for "${id}": only the validated ` +
`ComfyUI-Manager identity may be enabled from a .disabled directory ` +
`without a readable Manager installed-pack list. NOTHING was changed.`);
}
if (presence.state === "on-disk" &&
!(enable && managerDisabledOnDisk) &&
// A READABLE list that doesn't track the pack: refuse — neither mechanism
// can verify the op through it. An UNREADABLE list with the pack on disk
// only blocks the HTTP path; comfy-cli works on the local install directly,
// so it proceeds below with the uncertainty disclosed.
(presence.managerListReadable || !useCli)) {
throw new NodeManagementError(presence.managerListReadable
? `"${id}" is present on disk at ${presence.dir} but ComfyUI-Manager does not ` +
`track it (a Comfy Registry zip install or a manual copy), so Manager cannot ` +
`${op} it and NOTHING was queued. To ${op} it yourself, ${enable
? `remove any ".disabled" suffix from that directory's name`
: `rename that directory with a ".disabled" suffix (Manager's own convention)`}, then restart ComfyUI.`
: `"${id}" is present on disk at ${presence.dir}, but ComfyUI-Manager's ` +
`installed-pack list could not be read (${presence.listError}), so whether ` +
`Manager can ${op} it could NOT be determined and NOTHING was queued. Check ` +
`that ComfyUI-Manager is reachable, then retry.`);
}
// Manager-unreadable or Manager-untracked local disabled packs can still be
// enabled safely without queueing blind: the source and destination are
// validated siblings under the captured custom_nodes root, and the rename is
// followed by an exact on-disk postcondition check.
if (enable &&
managerDisabledOnDisk &&
presenceCtx.diskRoot &&
(presence.state === "on-disk" || presence.state === "unverifiable")) {
return enablePackFromDisk(localPackId, presenceCtx.diskRoot, managerDisabledOnDisk);
}
if (presence.state === "absent") {
throw new NodeManagementError(presence.evidence === "manager+disk"
? `"${id}" is not installed — it is in neither ComfyUI-Manager's installed-pack ` +
`list NOR on disk under ${presence.scanned} — so there is nothing to ${op} ` +
`and NOTHING was queued. Check the id with install_custom_node (action:"list").`
: `"${id}" is not in ComfyUI-Manager's installed-pack list, so there is ` +
`nothing to ${op} and NOTHING was queued. NOTE: this check reads the Manager ` +
`list only (remote session — no disk check possible). Check the id with ` +
`install_custom_node (action:"list").`);
}
if (presence.state === "unverifiable" && !useCli) {
// The HTTP path cannot validate or verify without the Manager list. The
// comfy-cli path CAN still act (it works on the local install directly) —
// it proceeds below, disclosing that the pre-state was never established.
throw new NodeManagementError(`Whether "${id}" is installed could not be determined (${presence.reason}), so ` +
`the ${op} was NOT queued — a ${op} queued blind would drain silently whether ` +
`or not it did anything. Check that ComfyUI-Manager is reachable, then retry.`);
}
// Already in the requested state: running anything would be a no-op whose
// post-op read-back would "verify" a state that predates the call. Say so
// and do nothing. (An UNREPORTED enabled flag is not this case: proceed and
// let the post-op verdict be inconclusive if it stays unreported.)
if (presence.state === "manager-listed" && presence.node?.enabled === enable) {
return {
mechanism: useCli ? "comfy-cli" : "manager-http",
message: `"${id}" is already ${enable ? "enabled" : "disabled"} in ComfyUI-Manager's ` +
`installed-pack list — NOTHING was run.` +
(cliFallbackNote ? ` ${cliFallbackNote}` : ""),
};
}
if (useCli) {
const preStateReason = presence.state === "unverifiable"
? presence.reason
: presence.state === "on-disk"
? `ComfyUI-Manager's installed-pack list could not be read (${presence.listError}); the pack is present on disk at ${presence.dir}`
: undefined;
const out = runCmCli([op, id], cliWorkspace);
// comfy-cli's exit is its OWN claim — verify against the Manager list
// when it can be read rather than reporting the claim as the state.
const verdict = await readEnabledVerdict(id, enable, base);
if (verdict.state === "not-applied") {
return {
mechanism: "comfy-cli",
message: `comfy-cli reported the ${op} of "${id}" successful, but ComfyUI-Manager ` +
`still reports the pack as ${verdict.reported} — the ${op} did NOT take ` +
`effect. Check the ComfyUI server log for the underlying error.`,
details: out.trim(),
};
}
return {
mechanism: "comfy-cli",
message: (verdict.state === "applied"
? `${enable ? "Enabled" : "Disabled"} "${id}" via official comfy-cli ` +
`(verified against ComfyUI-Manager's installed-pack list)`
: `${enable ? "Enabled" : "Disabled"} "${id}" via official comfy-cli ` +
`(comfy-cli's own report — the post-state could not be independently ` +
`verified: ${verdict.reason})`) +
(preStateReason
? `. NOTE: the pre-operation state was never established (${preStateReason}), so this is not a verified transition`
: "") +
`. A ComfyUI restart is required for the change to take effect.`,
details: out.trim(),
};
}
if (presence.state !== "manager-listed") {
// Unreachable by construction (absent/on-disk threw; unverifiable without a
// usable CLI threw; unverifiable WITH one returned inside the CLI branch) —
// a guard, not a load-bearing check.
throw new NodeManagementError(`Whether "${id}" is installed could not be determined, so the ${op} was NOT queued.`);
}
const tracked = presence.node;
const status = await queueManagerTask(op,
// The body is dialect-specific: v2 keys enable on cnr_id / disable on
// node_name; the legacy 3.x bodies key on node_name + the REAL installed
// version (legacyTaskRequest maps node_ver → version). node_name is the
// MANAGER'S module/folder name (tracked.module), never the caller's id —
// a registry id ("comfyui-impact-pack") and the module name
// ("ComfyUI-Impact-Pack") can differ, and the 3.x routes resolve by module
// (codex gate round 7).
(api) => api === "v2"
? enable
? { cnr_id: tracked.cnrId ?? id, node_name: tracked.module }
: { node_name: tracked.module, is_unknown: false }
: { node_name: tracked.module, node_ver: tracked.version ?? "unknown" }, base);
const verdict = await readEnabledVerdict(id, enable, base);
const result = verdict.state === "applied"
? {
mechanism: "manager-http",
message: `${enable ? "Enabled" : "Disabled"} "${id}" via ComfyUI-Manager (verified ` +
`against its installed-pack list). A ComfyUI restart is required for the ` +
`change to take effect.`,
details: status,
}
: verdict.state === "not-applied"
? {
mechanism: "manager-http",
message: `The ${op} of "${id}" was queued and the queue drained, but ComfyUI-Manager ` +
`still reports the pack as ${verdict.reported} — the ${op} did NOT take ` +
`effect. Check the ComfyUI server log for the underlying error.`,
details: status,
}
: {
mechanism: "manager-http",
message: `The ${op} of "${id}" was queued and the queue drained, but the result ` +
`could NOT be verified: ${verdict.reason}. NOT claiming it took effect — ` +
`check install_custom_node (action:"list").`,
details: status,
};
return cliFallbackNote
? { ...result, message: `${cliFallbackNote} ${result.message}` }
: result;
}
/**
* Disable an installed pack WITHOUT removing it — the reversible cleanup step
* (#775). Verified against the Manager installed list afterwards; a restart is
* required for the change to take effect.
*/
export async function disableCustomNode(opts) {
// PIN GUARD — disabling the panel kills the agent's own UI; a pinned panel
// must not be moved through this door either (withPanelPinGuard).
return withPanelPinGuard("disable", opts.id, () => withObjectInfoInvalidation(() => setCustomNodeEnabled(opts, false)));
}
/** Re-enable a pack previously disabled with install_custom_node (action:"disable") (#775). */
export async function enableCustomNode(opts) {
return withPanelPinGuard("enable", opts.id, () => withObjectInfoInvalidation(() => setCustomNodeEnabled(opts, true)));
}
/**
* Uninstall an installed pack (#775). Irreversible through this tool — prefer
* disableCustomNode for a cleanup audit. The pack must be one ComfyUI-Manager
* TRACKS: a pre-queue check refuses an id that resolves nowhere (nothing would
* be uninstalled, and a drained queue would look exactly like success), naming
* the on-disk directory when the pack is present but unmanaged so the caller
* can remove it themselves. Post-op, the installed list is re-read and the
* pack must be GONE before anything claims "uninstalled".
*/
export async function uninstallCustomNode(opts) {
// PIN GUARD — same door as install/update (withPanelPinGuard).
return withPanelPinGuard("uninstall", opts.id, () => withObjectInfoInvalidation(() => uninstallCustomNodeImpl(opts)));
}
/**
* Disk root for uninstall presence / postcondition checks (#2485).
*
* comfy-cli still runs against the configured workspace. Disk verification must
* use the custom_nodes tree the LIVE server actually scans — on ComfyUI Desktop
* `--base-directory` is commonly a different data root than COMFYUI_PATH, and
* scanning the configured workspace reports "custom_nodes does not exist"
* while the pack may still sit under the live scan root.
*
* Fall back to the configured workspace only when the live server cannot prove
* a scan root. An explicit unavailable `--base-directory` is contradictory
* evidence and must not fall back — that would recreate the false inconclusive.
*/
async function resolveUninstallPresenceDiskRoot(configuredWorkspace) {
if (isRemoteMode())
return undefined;
try {
const liveScan = await resolveCustomNodesScanBaseLiveStrict({ requireLive: true });
if (liveScan)
return liveScan;
}
catch {
return undefined;
}
return configuredWorkspace;
}
async function uninstallCustomNodeImpl(opts) {
const { id } = opts;
const base = managerBaseUrl();
// Pinned with the target, same as in setCustomNodeEnabled (codex gate round 5).
const cliWorkspace = resolveEffectiveComfyUIBase();
const presenceDiskRoot = await resolveUninstallPresenceDiskRoot(cliWorkspace);
const presenceCtx = capturePackPresenceContext(presenceDiskRoot, {
allowConfiguredFallback: presenceDiskRoot !== undefined,
});
// CLI availability probe FIRST (#808 fallback discipline) — but nothing runs
// until the presence pre-check below has answered what there is to remove.
const cliProblem = opts.useCmCli ? comfyCliUnavailableReason(cliWorkspace) : undefined;
const useCli = opts.useCmCli === true && cliProblem === undefined;
let cliFallbackNote;
if (opts.useCmCli && !useCli) {
logger.info("comfy-cli requested (useCmCli) but unavailable — falling back to Manager HTTP for uninstall", {
reason: cliProblem,
});
cliFallbackNote =
`comfy-cli was requested (useCmCli) but is not usable here: ${cliProblem}. ` +
`NOTHING was run through comfy-cli — ComfyUI-Manager was used for what follows instead.`;
}
// Pre-op: an uninstall of an id that resolves nowhere is a silent no-op —
// via Manager (drained queue) or comfy-cli (exit 0) alike — and reading
// absence afterwards would "verify" a state that predated the call. Refuse
// with the reason that is actually true.
const presence = await resolvePackPresence(id, base, presenceCtx);
if (presence.state === "on-disk" &&
// A READABLE list that doesn't track the pack: refuse. An UNREADABLE list
// with the pack on disk only blocks the HTTP path — comfy-cli works on the
// local install directly, and its postcondition is verified on disk below.
(presence.managerListReadable || !useCli)) {
throw new NodeManagementError(presence.managerListReadable
? `"${id}" is present on disk at ${presence.dir} but ComfyUI-Manager does not track ` +
`it (a Comfy Registry zip install or a manual copy), so Manager cannot uninstall ` +
`it and NOTHING was queued. To remove it, delete or move that directory yourself ` +
`(moving it into a custom_nodes/.disabled folder keeps it reversible), then ` +
`restart ComfyUI.`
: `"${id}" is present on disk at ${presence.dir}, but ComfyUI-Manager's ` +
`installed-pack list could not be read (${presence.listError}), so whether ` +
`Manager can uninstall it could NOT be determined and NOTHING was queued. ` +
`Check that ComfyUI-Manager is reachable, then retry.`);
}
if (presence.state === "absent") {
throw new NodeManagementError(presence.evidence === "manager+disk"
? `"${id}" is not installed — it is in neither ComfyUI-Manager's installed-pack ` +
`list NOR on disk under ${presence.scanned} — so there is nothing to uninstall ` +
`and NOTHING was queued. Check the id with install_custom_node (action:"list").`
: `"${id}" is not in ComfyUI-Manager's installed-pack list, so there is nothing ` +
`for Manager to uninstall and NOTHING was queued. NOTE: this check reads the ` +
`Manager list only (remote session — no disk check possible); a pack present on ` +
`disk but unknown to Manager must be removed on the ComfyUI host. Check the id ` +
`with install_custom_node (action:"list").`);
}
if (presence.state === "unverifiable" && !useCli) {
// The HTTP path cannot validate or verify without the Manager list. The
// comfy-cli path can still act on the local install — it proceeds below,
// with the postcondition checked on disk instead.
throw new NodeManagementError(`Whether "${id}" is installed could not be determined (${presence.reason}), so the ` +
`uninstall was NOT queued — an uninstall queued blind would drain silently whether ` +
`or not it did anything. Check that ComfyUI-Manager is reachable, then retry.`);
}
if (useCli) {
const out = runCmCli(["uninstall", id], cliWorkspace);
// comfy-cli's exit is its OWN claim. "Uninstalled" requires BOTH an
// observed pre-op presence (manager-listed, or on-disk) AND observed
// post-op absence — absence alone may predate the call (codex gate rounds
// 8-9). A pack still discoverable afterwards means the uninstall did NOT
// take effect — disclose it.
const preObserved = presence.state === "manager-listed" || presence.state === "on-disk";
const preUnknownNote = presence.state === "unverifiable"
? `whether the pack was installed beforehand could NOT be established (${presence.reason})`
: undefined;
// The disk postcondition applies to EVERY CLI uninstall (a CLI session is
// always a local one), and for a pack that was never Manager-tracked
// (on-disk pre-state) it is the ONLY meaningful one. Checked against the
// ENTRY-captured presence disk root (the live scan root when proven), never
// a recomputed one: a retarget during the awaits must not verify against a
// different install (codex gate round 6).
const diskAfter = presenceCtx.diskRoot
? findPackOnDisk(id, presenceCtx.diskRoot)
: undefined;
if (diskAfter?.state === "found") {
return {
mechanism: "comfy-cli",
message: `comfy-cli reported the uninstall of "${id}" successful, but the pack ` +
`directory still exists at ${diskAfter.dir} — the uninstall did NOT take ` +
`effect. Check the ComfyUI server log for the underlying error.`,
details: out.trim(),
};
}
let installed;
let listError;
try {
installed = await listInstalledNodesAt(base);
}
catch (err) {
listError = err instanceof Error ? err.message : String(err);
}
if (installed && findInstalledNode(id, installed)) {
return {
mechanism: "comfy-cli",
message: `comfy-cli reported the uninstall of "${id}" successful, but the pack is ` +
`STILL in ComfyUI-Manager's installed-pack list — the uninstall did NOT ` +
`take effect. Check the ComfyUI server log for the underlying error.`,
details: out.trim(),
};
}
const listGone = installed !== undefined; // readable AND not in it
const diskGone = diskAfter?.state === "not-found";
if (preObserved && (listGone || diskGone)) {
const evidence = [];
if (listGone)
evidence.push("the pack is absent from ComfyUI-Manager's installed-pack list");
if (diskGone) {
evidence.push(`no matching directory remains under ${diskAfter.scanned}`);
}
return {
mechanism: "comfy-cli",
message: `Uninstalled "${id}" via official comfy-cli (verified: ${evidence.join(" and ")}). ` +
`A ComfyUI restart is required to unload it fully.`,
details: out.trim(),
};
}
// Either the pre-state was never observed (absence may predate the call)
// or nothing could be read post-op — disclose, never claim the removal.
return {
mechanism: "comfy-cli",
message: `comfy-cli reported the uninstall of "${id}" successful, but the removal ` +
`could NOT be verified: ` +
(preUnknownNote ??
`ComfyUI-Manager's installed-pack list could not be read (${listError})` +
(diskAfter?.state === "unreadable"
? ` and the disk check was inconclusive (${diskAfter.reason})`
: "")) +
`. NOT claiming an uninstall happened; if the pack was never there, nothing ` +
`needed removing — otherwise check install_custom_node (action:"list") / custom_nodes yourself.`,
details: out.trim(),
};
}
const withCliNote = (result) => cliFallbackNote
? { ...result, message: `${cliFallbackNote} ${result.message}` }
: result;
if (presence.state !== "manager-listed") {
// Unreachable by construction (absent/on-disk threw; unverifiable without a
// usable CLI threw; unverifiable WITH one returned inside the CLI branch) —
// a guard, not a load-bearing check.
throw new NodeManagementError(`Whether "${id}" is installed could not be determined, so the uninstall was NOT queued.`);
}
const status = await queueManagerTask("uninstall",
// Legacy 3.x keys the body on node_name + the REAL installed version
// (legacyTaskRequest maps node_ver → version); v2 needs only node_name.
// node_name is the Manager's module name (presence.node.module), which can
// differ from the caller's registry-id spelling (codex gate round 7).
(api) => api === "v2"
? { node_name: presence.node?.module ?? id }
: {
node_name: presence.node?.module ?? id,
node_ver: presence.node?.version ?? "unknown",
}, base);
// Post-op: the pack must be GONE from the installed list. A drained queue is
// not proof (#639); "still there" is disclosed, never reported as removed.
let installed;
let listError;
try {
installed = await listInstalledNodesAt(base);
}
catch (err) {
listError = err instanceof Error ? err.message : String(err);
}
if (!installed) {
return withCliNote({
mechanism: "manager-http",
message: `The uninstall of "${id}" was queued and the queue drained, but the result could ` +
`NOT be verified: ComfyUI-Manager's installed-pack list could not be read ` +
`(${listError}). NOT claiming the pack was uninstalled — check install_custom_node (action:"list").`,
details: status,
});
}
if (findInstalledNode(id, installed)) {
return withCliNote({
mechanism: "manager-http",
message: `The uninstall of "${id}" was queued and the queue drained, but the pack is STILL ` +
`in ComfyUI-Manager's installed-pack list — the uninstall did NOT take effect. ` +
`Check the ComfyUI server log for the underlying error.`,
details: status,
});
}
// MANAGER'S LIST IS NOT THE DISK (codex gate P0). A partial uninstall can drop
// the tracking entry and leave `custom_nodes/<pack>` in place — and ComfyUI
// loads directories, not Manager's bookkeeping, so the pack comes back on the
// next restart while we have already called it uninstalled. A destructive
// postcondition deserves the strongest evidence available, not the most
// convenient, and this branch has the disk check the rest of this file uses.
const diskAfter = presenceCtx.diskRoot ? findPackOnDisk(id, presenceCtx.diskRoot) : undefined;
// `findPackOnDisk` accepts a matching SYMLINK without following it, so a
// dangling link would be reported as a pack that survived the uninstall
// (codex gate P2). ComfyUI cannot load a dead link, so calling that an
// incomplete removal is a false alarm on a destructive operation — the one
// place a spurious warning does real damage, because it sends the user to
// delete something by hand. `existsSync` follows the link, which is exactly
// the question: is there anything at the other end?
const diskAfterReal = diskAfter?.state === "found" && existsSync(diskAfter.dir);
if (diskAfterReal) {
return withCliNote({
mechanism: "manager-http",
message: `The uninstall of "${id}" removed it from ComfyUI-Manager's installed-pack list, ` +
`but the pack directory is STILL on disk at ${diskAfter.dir}. ComfyUI loads ` +
`directories, not Manager's list, so it will load again on the next restart — ` +
`this is NOT a completed uninstall. Remove the directory manually, or re-run the ` +
`uninstall, and check the ComfyUI server log for the underlying error.`,
details: status,
});
}
const diskNote = diskAfter?.state === "found"
? ` and gone from disk (a dangling symlink remains at ${diskAfter.dir}, which ComfyUI ` +
`cannot load — remove it at your leisure)`
: diskAfter?.state === "not-found"
? " and gone from disk"
: diskAfter?.state === "unreadable"
? `; the disk check was inconclusive (${diskAfter.reason}), so the directory may ` +
`still be present`
: "; no disk check was possible in this session";
return withCliNote({
mechanism: "manager-http",
message: `Uninstalled "${id}" via ComfyUI-Manager (verified absent from its installed-pack ` +
`list afterwards${diskNote}). A ComfyUI restart is required to unload it fully.`,
details: status,
});
}
/**
* #789 — panel_search_nodes can return a REPOSITORY URL as a pack's `id` (the
* Manager's legacy/repository-style entries). Feeding that URL back as `id`
* with the default version "latest" asks the Manager to resolve
* `<RepoName>@latest` as a REGISTRY install, which it rejects ("not available
* node: ComfyUI-Impact-Pack@8.28.3") — while the same URL passed as
* `repository` with version "nightly" installs fine. So when the effective
* target is a git URL:
* - carry it as `repository` (the from-source install path), and
* - translate an absent or "latest" version to "nightly" (git HEAD — the only
* channel a from-source install can resolve).
* An explicit non-"latest" version is the caller's own choice and passes
* through untouched; the rewrite is always disclosed in `note`.
*
* `id` AND `repository` together name TWO targets (the schema presents them as
* alternatives); that is a conflict to refuse, not a precedence to pick.
*/
/**
* The route that still works when the Manager has no route at all (#789, recurrence).
*
* Rerouting a git URL to a from-source "nightly" install fixed the ORIGINAL report,
* where the pack was registered and only the version spec was wrong. It cannot fix
* the recurrence, because that pack is not in the registry:
*
* Node 'ComfyUI-MiniMaxH3-FirstBlockCache@nightly' not found
* — ManagerChannel.dev, ManagerDatabaseSource.remote
*
* Manager v4's do_install resolves a pack by its registry ID, never by URL (the
* 3.x `files:[url]` clone path does not exist there), so a repository the resolved
* LIST does not carry has no Manager route. The panel cannot clone either — it is
* browser JS.
*
* `install_custom_node` can: it tries the Manager first and then clones the URL
* directly into custom_nodes, verifying the result is a real pack. That is the
* one tool that finishes this job, and the reporter had to find it by hand after
* being left at "not found". Naming it here costs a sentence.
*
* Stated up front rather than on the failure: the failure text comes back from
* the panel and matching on it would be brittle, and an agent that reads this
* when the install is queued needs one fewer round trip than one that reads it
* after the queue drains.
*
* #1539 GATE ROUND 2 — THIS CONTRADICTED THE NOTE IT SHIPS ALONGSIDE. It said a
* not-found means "the pack is not in the Manager's registry at all ... NO Manager
* route, by any spelling", and then the channel note appended right after it said the
* miss rules out ONE channel and to retry with another. Both reached the caller in the
* same reply, and the first one sends a pack that IS remotely installable — the
* reporter's own, which is listed in `default` — down the local-only clone path. That
* is the same over-claim this branch retracted from the tool description, surviving one
* layer down because the description test could not see a runtime string.
*
* WHAT A NOT-FOUND ACTUALLY MEANS, read out of V4.2.2 rather than inferred. The lookup
* resolves against ONE list, chosen by channel, and which file that is depends on how
* the Manager was installed:
*
* * `get_data_by_mode` takes an OFFLINE branch when `network_mode == 'offline'` OR
* `is_manager_pip_package()` — the latter being simply "the Manager is not inside a
* custom_nodes tree", i.e. every pip/Desktop install. That branch reads the cache
* file for that exact channel URL, else the snapshot BUNDLED in the package, and
* never fetches. So on a pip host `mode` is inert and a channel name only reaches a
* real list if a cache for its URL happens to exist.
* * Measured on this rig: the only cache present is for `DEFAULT_CHANNEL`
* (`ltdrdata/ComfyUI-Manager`), written at startup by `default_cache_update()` —
* while the channel NAME "default" normalizes through `channels.list` to
* `Comfy-Org/ComfyUI-Manager`, which hashes to a different cache file that does not
* exist. So a pip host serves the bundled snapshot (3587 packs), and the reporter's
* pack is NOT in it (checked directly).
* * A Manager cloned into custom_nodes takes the public branch and FETCHES the
* channel URL, where `default` carries 5891 packs including the reporter's.
*
* So the channel fix is a strict improvement on a cloned Manager and a NO-OP on a pip
* one (there, old `dev` and new `default` both land on the same bundled file) — it
* cannot make any host worse. The earlier note attributed the pip fallback to "a host
* configured to a non-default channel URL"; that was wrong, the trigger is the pip
* packaging, not the configuration.
*
* The text below therefore stops declaring the registry empty of the pack, names both
* causes it could actually be, and puts the retry that CAN succeed before the local
* clone that ends the road.
*/
function unregisteredPackEscapeHatch() {
return (`If this comes back "not found" / "not available node", that is one LIST saying no, ` +
`not the Manager's whole registry: v4 resolves against a single channel's node list, ` +
`and on a pip-installed Manager it reads a bundled snapshot rather than fetching. So ` +
`try an explicit \`channel\` first (the pack may simply live on another one), and if ` +
`the plausible channels are ruled out, use install_custom_node (source:"git"): it ` +
`clones the URL directly into custom_nodes and verifies a real pack landed. That one ` +
`requires a LOCAL ComfyUI, since the clone writes to its filesystem — which need not ` +
`be the ComfyUI this panel drives.`);
}
/**
* #1539 GATE ROUND 3 — THE ROUTING PREDICATE HAD TO MATCH THE PANEL'S, AND DID NOT.
*
* Whether this function reroutes decides the version rewrite, the channel, and every
* note attached to them — but the PANEL decides, independently, whether its own
* `buildInstallRequest` takes the git branch (and so whether `channel || "dev"` fires).
* When the two predicates disagree, the orchestrator sends a payload normalized for one
* route down the other one.
*
* They disagreed. `looksLikeGitUrl` above recognizes `https?://`, `git@`, `git+` and a
* `.git` suffix. The panel's recognizes those PLUS `ssh://`, `git://`, and a bare
* `author/repo` shorthand (#301) — and `author/repo` is the form this tool's own
* description tells callers to pass in `id`. So
* `panel_install_node({id:"Wenaka2004/comfyui-anima-ipadapter"})` — the reporter's own
* pack, in the documented spelling — fell through here with no channel, and the panel
* then routed it as git and asked `dev`. The channel fix reached the URL spelling and
* missed the shorthand.
*
* Mirrored here rather than by widening `looksLikeGitUrl`, which is shared with the
* HEADLESS `install_custom_node` clone path: widening it there would newly treat a
* slash-bearing registry id as a URL to clone, which is a different tool's behaviour and
* not this fix's business.
*/
function looksLikeOwnerRepoShorthand(s) {
return /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(s);
}
/** Exactly the panel's `looksLikeGitUrl` (web/js/lib/manager-install.js). */
function panelRoutesAsGitInstall(s) {
return (/^(https?|ssh|git):\/\//i.test(s) ||
/^git\+/i.test(s) ||
/^git@/i.test(s) ||
s.endsWith(".git") ||
looksLikeOwnerRepoShorthand(s));
}
/** Exactly the panel's `installGitUrl` expansion: a bare shorthand becomes clonable. */
function panelGitInstallUrl(s) {
return looksLikeOwnerRepoShorthand(s) ? `https://github.com/${s}` : s;
}
export function normalizeGitUrlInstallArgs(args) {
if (args.id && args.repository) {
return {
conflict: `panel_install_node was given BOTH id ("${args.id}") and repository ` +
`("${args.repository}") — those are two different ways to name the target and ` +
`they may not name the same pack. Pass exactly one: the registry id, or the ` +
`git repository URL.`,
};
}
const gitSpelling = args.repository && panelRoutesAsGitInstall(args.repository)
? args.repository
: args.id && panelRoutesAsGitInstall(args.id)
? args.id
: undefined;
if (!gitSpelling)
return {};
// Expanded the way the panel expands it, so `repository` is a fetchable URL on the
// 3.x dialects that put it straight into Manager's `files` clone list.
const gitTarget = panelGitInstallUrl(gitSpelling);
const version = args.version?.trim();
// The URL travels as `repository` ONLY: a URL that arrived as `id` is not
// forwarded as both (routing off the id path is the whole point), and
// id+repository together were refused above.
const out = { repository: gitTarget };
// #1254 — the same sentinel test the git path uses, so "latest" cannot come to
// mean one thing here and another there. This site already trimmed; the helper
// trims too, which makes " Latest " behave like "latest" on both paths.
if (version === undefined || version.length === 0 || isLatestSentinel(version)) {
out.version = "nightly";
out.note =
`"${gitTarget}" is a git repository URL, so this was queued as a from-source ` +
`install with version "nightly" (git HEAD): ComfyUI-Manager cannot resolve a ` +
`registry "latest" for a repository-style entry and rejects it ("not available ` +
`node: <repo>@<version>"). Pass an explicit version/ref to override. ` +
unregisteredPackEscapeHatch();
}
else {
out.version = args.version;
}
return out;
}
/**
* #1539 — the Manager channel a git-URL ("nightly") install asks for when the
* caller named none.
*
* WHY THIS EXISTS AT ALL. The panel's `buildInstallRequest` defaults the v4 git-URL
* payload to `channel: "dev"` while defaulting the registry-ID payload — and BOTH
* of the 3.x payload shapes — to `"default"`. Nothing justifies the difference, and
* because the panel spells it `channel || "dev"`, only a NON-EMPTY channel sent from
* here can displace it. So this is the field that fixes it on panels already in the
* field, including the version the report was filed from.
*
* WHAT "dev" COSTS, MEASURED. Manager's channels are separate lists, not a
* superset/subset pair. Fetched 2026-08-14 from the channel URLs the live Manager
* itself reports (`GET /v2/manager/channel_url_list`):
*
* default .../ComfyUI-Manager/main/custom-node-list.json 5887 packs
* dev .../ComfyUI-Manager/main/node_db/dev/custom-node-list.json 1210 packs
* overlap 3 packs
*
* So asking `dev` hides ~99.9% of what `default` lists — including packs
* `panel_search_nodes` had just returned, because that search reads
* `extension-node-map.json`, which is NOT channel-scoped (measured: the live
* `getmappings?mode=cache` and `getmappings?mode=cache&channel=dev` returned
* byte-identical bodies). Search answered from one file, the install resolved
* against a different one, and the reporter was told their pack did not exist.
*
* WHY THE CHANNEL DECIDES ANYTHING. Read out of ComfyUI-Manager V4.2.2's own source
* (`glob/manager_core.py`), not inferred: `install_by_id` for a `nightly` spec does
* `custom_nodes = await self.get_custom_nodes(channel, mode)` → `load_nightly` →
* `get_data_by_mode(mode, 'custom-node-list.json', channel_url)`, keys that map by
* the BARE REPO NAME (`repo_name = y.split('/')[-1]`), and on a hit clones
* `the_node['repository']` — the CHANNEL's URL. The `repository` field we send is
* never read on that path; it is stored in the task params and nothing more.
*
* A MISS IS NOT AUTOMATICALLY THE REPORTED FAILURE (#1616 gate round 6). For a
* `nightly` spec, v4 first tries `self.cnr_map.get(node_id)` and clones that entry's
* `repository` — the COMFY REGISTRY's repo for an id equal to the bare name — and only
* when the registry lacks the id too does it return the reported
* `Node '<name>@nightly' not found in [ManagerChannel.<ch>, ManagerDatabaseSource.<mode>]`,
* naming the channel THIS argument chose. So a channel that carries nothing under the
* name still resolves to SOMETHING, which is why `bareNameAmbiguity` treats a miss on a
* contested name as an ambiguity rather than an exemption.
*
* WHAT THIS DOES NOT CLAIM. Not that `default` always resolves — and gate round 2
* corrected WHY. On a pip Manager v4 `manager_util.is_manager_pip_package()` is true,
* which sends `get_data_by_mode` down its offline branch unconditionally — it reads the
* cache file for that exact channel URL, else the snapshot bundled in the package, and
* never fetches, so `mode` is inert there. The earlier wording blamed "a host configured
* to a non-default channel URL"; that is not the trigger. `is_manager_pip_package()` is
* simply "the Manager is not inside a custom_nodes tree", so it is true of every
* pip/Desktop install regardless of configuration, and the caches a stock host actually
* holds do not line up with the channel NAMES: `default_cache_update()` writes the cache
* for `DEFAULT_CHANNEL` (`ltdrdata/ComfyUI-Manager`) at startup, while the name
* "default" normalizes through `channels.list` to `Comfy-Org/ComfyUI-Manager` — a
* different URL, so a different cache file, which does not exist. Measured on this rig:
* the cache directory holds exactly one node list, hashed from the ltdrdata URL, and the
* bundled snapshot it therefore falls back to (3587 packs) does NOT contain the
* reporter's pack.
*
* So the honest split is by PACKAGING, not by configuration: on a Manager cloned into
* custom_nodes this reaches the real 5891-pack list and fixes the report; on a pip
* Manager old `dev` and new `default` both land on the same bundled file, so the change
* is a no-op there rather than an improvement. It cannot make any host worse. Reaching
* the pip case would need the Manager to be asked for the channel the USER configured,
* which v4.2.2 gives no way to name (`channel: null` makes `load_nightly` return `{}`
* outright), so it is out of reach from here and is not claimed to be fixed.
*
* What IS established is narrower and still worth shipping: `dev` is the wrong
* question for every host, `default` is the right one for a host on the stock
* channel, and the registry-ID path has been asking `default` all along.
*/
export const GIT_INSTALL_DEFAULT_CHANNEL = "default";
/**
* #1539 review — NO SINGLE DEFAULT IS CORRECT, so the one taken is DISCLOSED.
*
* `default` and `dev` are near-disjoint (5887 vs 1210 packs, 3 shared), which cuts
* both ways: whichever this call picks, the other one is a list it did not search.
* Review proposed resolving that by trying the picked channel and retrying the other
* on a not-found — a not-found being a proven negative that installed nothing, which
* it is (`install_by_id` returns before `to_path` is even computed, and four live
* probes left `custom_nodes` untouched).
*
* MEASURED, AND IT DOES NOT HOLD. The premise is true about the FIRST attempt and
* false about the SECOND. v4 resolves a from-source install by the BARE REPO NAME —
* `load_nightly` keys its map on `y.split('/')[-1]` — and then clones
* `the_node['repository']`, the URL recorded in THAT channel's entry, not the URL the
* caller passed. Comparing the two channels on that key: 38 bare names appear in
* both, and for 35 of them the two channels point at DIFFERENT authors' repositories
* (`comfyui-birefnet` is hieuck's on default and mohammadaboulela's on dev;
* `comfyui-lmstudio`, `comfyui-kittentts`, `ComfyUI-Image-Selector` and 31 more are
* the same story). An automatic second attempt would therefore clone, silently, a
* repository the caller never named — arbitrary code from an unintended author. That
* is the wrong-target bug this project already has a standing finding about, only
* automated. So the fallback position applies: do not silently pick between two
* candidates, NAME them.
*
* Refusing outright was rejected as the larger harm — it breaks every existing bare
* git-URL call — so the choice is taken, disclosed, and made reversible in one
* argument. Stated at DISPATCH rather than on the failure, for the reason
* `unregisteredPackEscapeHatch` already gives: the failure text comes back from the
* panel, matching on it would be brittle, and a caller who reads this when the
* install is queued needs one fewer round trip than one who reads it after.
*
* HOW BIG THE REGRESSION ACTUALLY IS, since review costed it as all 1207 dev-only
* packs. On a pip Manager v4 `get_data_by_mode` reads the cache file for that exact
* channel URL, else the snapshot bundled in the package — so a channel name only
* reaches its real list when it is the channel the user CONFIGURED (nothing else
* writes that cache). The bundled snapshot is a stale `default`-flavoured list: 3587
* packs, holding 3482 of default's 5891 and exactly 1 of dev's 1223 dev-only entries.
* So on a stock v4 the old `dev` never reached the dev list either — it landed on
* that same bundled file. The population that genuinely regresses is users whose
* Manager is CONFIGURED to the dev channel, and the note below hands them the exact
* argument that restores them.
*/
function gitInstallChannelNote(channel) {
return (`CHANNEL: this asked ComfyUI-Manager's "${channel}" channel, because the call named ` +
`none — saying so rather than picking silently, since Manager's channels are ` +
`near-disjoint lists (default ~5900 packs, dev ~1200, sharing 3). So a "not found" ` +
`from this install rules the pack out of "${channel}" ONLY; it says NOTHING about ` +
`dev/recent/legacy/forked/tutorial. If that happens, retry with an explicit channel ` +
`(e.g. channel:"dev") before concluding the pack does not exist. ` +
`NOT retried for you on purpose: by the same bare-name resolution described above, ` +
`an automatic second attempt could install a repository you did not name.`);
}
/** `owner/repo` out of a git URL, or undefined when it does not parse that way. */
function gitUrlOwnerRepo(url) {
const m = /[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/*$/.exec(url.trim());
return m ? `${m[1]}/${m[2]}` : undefined;
}
/**
* `owner/repo` from a git URL, an `aux_id`, or a shorthand `owner/repo` string.
* Manager reports git packs as `aux_id: "teskor-hub/comfyui-teskors-utils"` while
* a manifest names `https://github.com/artokun/comfyui-teskors-utils` — those must
* compare as different origins, not as the same bare repo name (#2523).
*/
function gitOwnerRepo(value) {
if (!value)
return undefined;
const trimmed = value.trim().replace(/\.git$/i, "").replace(/\/+$/, "");
if (!trimmed)
return undefined;
const hosted = /(?:github\.com|gitlab\.com|bitbucket\.org)[/:]([^/]+)\/([^/#?]+)/i.exec(trimmed);
if (hosted)
return `${hosted[1]}/${hosted[2]}`.toLowerCase();
const short = /^([^/]+)\/([^/]+)$/.exec(trimmed);
return short ? `${short[1]}/${short[2]}`.toLowerCase() : undefined;
}
/** True when both values name an owner/repo and those identities differ. */
function gitOriginsDiffer(wanted, other) {
const a = gitOwnerRepo(wanted);
const b = gitOwnerRepo(other);
return Boolean(a && b && a !== b);
}
/**
* A Manager installed-list hit for a git URL is only the requested origin when
* `aux_id` names the same owner/repo. Missing aux_id cannot prove a mismatch,
* so it is treated as a match (the clone fallback still checks `git remote`).
*/
function gitOriginMatchesRequested(gitId, auxId) {
return !gitOriginsDiffer(gitId, auxId);
}
function readGitRemoteOrigin(nodeDir) {
try {
const out = execFileSync("git", ["-C", nodeDir, "remote", "get-url", "origin"], {
encoding: "utf-8",
timeout: GIT_CLONE_TIMEOUT,
env: nonInteractiveGitEnv(),
});
const origin = typeof out === "string" ? out.trim() : String(out).trim();
return origin || undefined;
}
catch {
return undefined;
}
}
/**
* #1539 gate round 2 — THE FIRST ATTEMPT CAN ALSO HIT THE WRONG AUTHOR, and until now
* this only said so as a reason not to RETRY.
*
* The gate's finding, restated: because v4 resolves by bare repo name against the
* channel's list and clones the URL in THAT entry, a caller who passes
* `mohammadaboulela/ComfyUI-BiRefNet` with no channel now resolves the same bare name
* in `default` and gets `hieuck/ComfyUI-BiRefNet` — a repository they never named, and
* it reports success. That is real, and it is disclosed here rather than left to the
* retry paragraph.
*
* WHAT THE GATE HAD BACKWARDS, measured rather than argued. The hazard is not created
* by this change; it is PRE-EXISTING and SYMMETRIC — before it, the same call with no
* channel asked `dev`, so a caller passing hieuck's URL got mohammadaboulela's. What
* changed is which way it points, and the direction is now the safe one. Of the 35 bare
* names listed in both channels under different authors, `extension-node-map.json` —
* the channel-independent file `panel_search_nodes` reads, and where the reporter got
* their URL — carries the DEFAULT author's repo for 33 and the DEV author's repo for
* ZERO (2 in neither; fetched live 2026-08-15). So every colliding URL this tool can
* actually hand a caller resolved to the WRONG author under the old `dev` default and
* resolves to the RIGHT one now. The residue is a caller who hand-types a dev-only
* author's URL, which search never returns.
*
* Stated on EVERY git-URL install, not just the defaulted-channel one: bare-name
* resolution is a property of the v4 from-source route, not of who picked the channel,
* so a caller who passed `channel:"dev"` themselves faces exactly the same substitution.
* It names the owner from the caller's own URL so the check is concrete — the standing
* rule being that two candidates are NAMED, never silently picked between.
*
* #1616 — THE COUNT IN THIS TEXT WAS AN UNDERCOUNT, and is corrected here. "35" came from
* comparing bare names case-SENSITIVELY. Manager's own lookup is not: `get_custom_nodes`
* returns a `NormalizedKeyDict` whose `get` keys on `key.strip().lower()`, so
* `comfyui_tagger` and `ComfyUI_tagger` are one name. Counted the way the lookup counts:
* 111 names resolve to different repositories across the six published channels, 60 of
* them between `default` and `dev` alone. This note is now the FALLBACK disclosure — the
* measured 111 are refused before dispatch by `bareNameAmbiguity` when the channel was
* defaulted, and this text covers everything that snapshot has not seen.
*/
function gitInstallSubstitutionNote(channel, url) {
const owner = gitUrlOwnerRepo(url);
const bare = url.trim().replace(/\.git$/, "").replace(/\/+$/, "").split("/").pop();
return (`WHAT GETS CLONED IS NOT NECESSARILY THE URL YOU PASSED. Manager v4 resolves a ` +
`from-source install by the BARE REPO NAME ("${bare}") against the "${channel}" ` +
`channel's list, then clones the URL recorded in THAT entry — 111 bare names resolve ` +
`to DIFFERENT repositories depending on the channel asked. So if "${channel}" lists ` +
`that name under anyone other than ${owner ? `the ${owner} you passed` : "the author you passed"}, ` +
`this installs THEIR repository and still reports success. Confirm with ` +
`panel_list_nodes that what landed is the repo you meant before you restart or ` +
`report success.`);
}
/**
* The FINAL dispatch fields for the panel's `nodes_install` command, after
* #789 normalization. Built here — not by `??`-merging with the raw args at
* the call site — because a rerouted git URL must DROP `id`, and a
* `norm.id ?? args.id` merge silently restores it (codex gate round 4).
*/
/**
* #1539 recurrence — Manager v4 (`v2` dialect) ignores `repository` and looks
* the pack up by bare name in its registry. Queueing a git URL there is the
* unlisted-URL failure the reporter still hits. Legacy 3.x / v2-batch still
* clone `files:[url]`.
*/
export function v4GitUrlQueueRefusal(url) {
return (`Refusing to queue "${url}" as a Manager v4 install. Manager v4 ignores the supplied ` +
`repository and resolves by bare name against its registry, so a Git URL is not a ` +
`successful v4 from-source install. Use a registry id from panel_search_nodes; or ` +
`install_custom_node(source:'git') only when its local target is the same ComfyUI as ` +
`this panel. If the panel drives another machine, install the repository on the ` +
`ComfyUI host instead.`);
}
/** True when this dialect would send a git URL through v4's registry lookup. */
export function managerDialectQueuesGitUrlAsRegistryLookup(api) {
return api === "v2";
}
export function nodesInstallCommandArgs(args) {
const norm = normalizeGitUrlInstallArgs(args);
if (norm.conflict)
return { conflict: norm.conflict };
const rerouted = norm.repository !== undefined;
// #1539 — only the git-URL route is touched, and only when the caller named no
// channel. A caller who passed one keeps it (including on this route: naming a
// channel is the one way to reach a pack that really does live in `dev`), and
// the registry-ID route is left exactly as it was, because the panel already
// defaults THAT one to "default".
//
// Blank counts as unset. The panel's `channel || "dev"` treats "" as absent and
// substitutes `dev`, so forwarding an empty string would silently land back on
// the channel this fix exists to stop asking for.
const explicitChannel = args.channel?.trim() ? args.channel : undefined;
// DISCLOSE only what THIS function chose. A caller who named a channel made the
// choice themselves and does not need it read back to them, and `norm.note` rides
// only the "latest"→nightly branch — so an explicit version with no channel would
// otherwise dispatch a defaulted channel with nothing said about it.
const defaultedChannel = rerouted && explicitChannel === undefined;
const effectiveChannel = explicitChannel ?? GIT_INSTALL_DEFAULT_CHANNEL;
// #1616 — WHERE THE DISCLOSURE BECOMES A REFUSAL. `gitInstallSubstitutionNote` below
// warns that v4's bare-name resolution CAN hand back another author's repository; for
// the names where that is not hypothetical but measured, this refuses instead. It fires
// only on the channel THIS code defaulted to, because that is the case where the tool
// would be picking between two candidate repositories on the caller's behalf — the one
// thing the standing rule says to refuse. A caller who named the channel made the choice
// themselves and gets the collision NAMED instead (`ambiguousBareNameWarning`), which
// also leaves a stale snapshot escapable in one argument rather than bricking the tool.
//
// GATE ROUND 4 — AND IT ONLY APPLIES WHERE A CHANNEL IS ACTUALLY READ. Manager gates
// the whole channel lookup on the version: `install_by_id` calls `get_custom_nodes`
// only for "nightly"/"unknown" and otherwise falls through to `cnr_install`, which
// resolves the bare name as a COMFY REGISTRY ID and reads neither the channel nor the
// `repository` passed. `bareNameAmbiguity`'s exemption — "this channel carries exactly
// your repo, so there is nothing to pick between" — is therefore not just unhelpful
// there, it is wrong: measured, `{repository:"hieuck/ComfyUI-BiRefNet",
// version:"1.0.0"}` was allowed through on that exemption while registry id
// `comfyui-birefnet` is viperyl's. Split the two routes so each is judged by the thing
// that actually decides it.
//
// GATE ROUND 5 — AND THIS REFUSAL TAKES NO CHANNEL BYPASS, unlike the one below it.
// Every reason the from-source refusal yields to an explicit `channel` is absent here:
// * the channel is not merely unhelpful on this route, Manager never reads it, so
// naming one expresses nothing at all about which repository lands;
// * the 3.x escape does not apply either — the panel's 3.x shapes HARDCODE
// `version:"unknown"` with `files:[url]` (manager-install.js, verified on the panel's
// origin/main), so a 3.x host never takes the registry route no matter what version
// the caller passed;
// * and the remedy costs the caller nothing on either generation: dropping `version`
// gives v4 the channel-resolved from-source install and gives 3.x the same clone it
// would have done anyway.
// So a bypass here would only ever hand back a repository the caller did not name.
const registryAmbiguity = rerouted && norm.repository && norm.version
? registryVersionAmbiguity(norm.repository, norm.version)
: undefined;
if (registryAmbiguity)
return { conflict: registryVersionRefusal(registryAmbiguity) };
// #1624 — THE SAME REGISTRY-ROUTE HAZARD FOR A REPO THAT NO CHANNEL CONTESTS. Above is
// scoped to the contested names; a repository filed under a Comfy Registry id that is
// not its own name loses the id race just as completely, and needs no second channel to
// do it. Same route, same reason it takes no channel bypass — Manager reads no channel
// here — so it is refused in the same place rather than warned about later.
const rekeyedVersionAmbiguity = rerouted && norm.repository && norm.version
? rekeyedRegistryVersionAmbiguity(norm.repository, norm.version)
: undefined;
if (rekeyedVersionAmbiguity) {
return { conflict: rekeyedRegistryVersionRefusal(rekeyedVersionAmbiguity) };
}
// Past that return, the request is necessarily on the from-source route — a contested
// name with a registry version has already been refused — so the channel-based
// reasoning below is only ever applied where a channel is actually consulted.
const ambiguity = rerouted && norm.repository ? bareNameAmbiguity(norm.repository, effectiveChannel) : undefined;
if (ambiguity && defaultedChannel)
return { conflict: ambiguousBareNameRefusal(ambiguity) };
// #1624 — AND THE FROM-SOURCE ROUTE FOR THE SAME REPOSITORIES. Consulted only when the
// contested-name table said nothing, because when it DOES fire it already reasons about
// re-keying (`REKEYED_REPOS`) for that name and a second message would say it twice.
//
// GATE FINDING — AND ONLY ON THE ROUTE THAT READS A CHANNEL. The two returns above do
// NOT leave "necessarily from-source" true for this table the way they do for the
// contested one. `registryVersionAmbiguity` covers every contested name a channel can
// answer to, but `rekeyedRegistryVersionAmbiguity` requires a `registryTarget`, and 12
// of the 98 re-keyed records have none — the registry holds nothing under their bare
// name. For those, an explicit version fell through to a refusal that told the caller
// the channel's list was what would be cloned, when on that route Manager calls
// `cnr_install(bare, version)`, reads no channel, finds no such id and fails. Refusing
// was directionally defensible and the remedy it named was even correct, which is how a
// fabricated mechanism survives review — so the check is scoped to the route it
// describes, and Manager's own "not found" (which substitutes nobody's code) stands.
const rekeyed = rerouted && norm.repository && !ambiguity && isFromSourceSpec(norm.version)
? rekeyedRepoAmbiguity(norm.repository, effectiveChannel)
: undefined;
if (rekeyed && defaultedChannel)
return { conflict: rekeyedRepoRefusal(rekeyed) };
// The substitution warning rides EVERY git-URL install (gate round 2), because
// bare-name resolution is a property of the v4 from-source route rather than of
// who chose the channel — an explicit `channel:"dev"` substitutes just as readily.
const note = [
norm.note,
rerouted && norm.repository ? gitInstallSubstitutionNote(effectiveChannel, norm.repository) : undefined,
ambiguity ? ambiguousBareNameWarning(ambiguity) : undefined,
rekeyed ? rekeyedRepoWarning(rekeyed) : undefined,
defaultedChannel ? gitInstallChannelNote(GIT_INSTALL_DEFAULT_CHANNEL) : undefined,
]
.filter((s) => typeof s === "string" && s.length > 0)
.join(" ") || undefined;
return {
id: rerouted ? norm.id : args.id,
repository: rerouted ? norm.repository : args.repository,
version: rerouted ? norm.version : args.version,
channel: rerouted ? effectiveChannel : args.channel,
mode: args.mode,
...(note ? { note } : {}),
};
}
async function listInstalledNodesAt(base, opts = {}) {
const { mode = "default" } = opts;
// `list` is read-only, so an explicitly requested CLI is a preference here,
// not a reason to reject an otherwise useful inventory when the installed
// executable is absent or its version probe is unrecognized. Keep the remote
// branch on runCmCli so its existing no-local-CLI refusal remains intact.
const useCli = opts.useCmCli === true && (isRemoteMode() || isComfyCliUsable());
if (useCli) {
// cm-cli `show installed` prints a formatted table — return raw lines as
// pseudo-nodes since structured data is HTTP-only. `enabled` is left
// undefined: the table does not report it, so claiming a value would be
// inventing state.
const out = runCmCli(["show", "installed"]);
const lines = out
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("#"));
return lines.map((line) => ({
module: line,
version: undefined,
}));
}
// Both pip-Manager modes serve /v2/customnode/installed (the legacy-UI
// module registers it too); only the 3.x custom-node Manager lacks /v2.
const prefix = (await detectManagerApi(base)) === "legacy" ? "" : "/v2";
const raw = await managerFetch(`${prefix}/customnode/installed?mode=${encodeURIComponent(mode)}`, { base });
// A 200 with an empty or non-JSON body parses to undefined / a raw string in
// managerFetch, and an ERROR ENVELOPE parses to an object whose values are
// scalars ({"error": "…"}) or an object keyed by "error"/"detail"
// ({"error": {"message": …}}, FastAPI's {"detail": …}) — parseInstalled
// would silently turn ALL of these into an empty or junk list, which
// downstream gates read as "the pack is absent". An unreadable payload is
// NOT an empty list: refuse to answer from it. ({} is a legitimate
// "nothing installed"; a module literally named "error" is the pathological
// case accepted as collateral.)
let readable = false;
if (Array.isArray(raw)) {
// parseInstalled only understands entry OBJECTS — a bare string element
// (["temporary failure"]) would be silently dropped, reading as empty.
readable = raw.every((el) => el !== null && typeof el === "object" && !Array.isArray(el));
}
else if (raw !== undefined && raw !== null && typeof raw === "object") {
const rec = raw;
readable =
!("error" in rec) &&
!("detail" in rec) &&
Object.values(rec).every((v) => v !== null && typeof v === "object" && !Array.isArray(v));
}
if (!readable) {
throw new NodeManagementError(`ComfyUI-Manager's installed-pack list returned an unreadable payload ` +
`(expected a JSON object of pack records or an array, got ${raw === undefined ? "an empty body" : `a ${typeof raw}`}) — treating the list as UNREADABLE, not as empty.`);
}
return parseInstalled(raw);
}
export async function listInstalledNodes(opts = {}, base = managerBaseUrl()) {
return listInstalledNodesAt(base, opts);
}
/**
* Reconcile installed-node Python dependencies. comfy-cli exposes this as
* `node uv-sync`, but ComfyUI-Manager has no `uv-sync` subcommand or HTTP
* endpoint; the equivalent reconciliation is cm-cli `restore-dependencies`,
* which reinstalls each installed pack's requirements. Subprocess-only.
*/
export async function syncNodeDependencies() {
const out = runCmCli(["restore-dependencies"]);
return {
mechanism: "comfy-cli",
message: "Reconciled installed-node Python dependencies via official comfy-cli node restore-dependencies.",
details: out.trim(),
};
}
// Remote (Manager-dispatched) downloads run server-side: the queue reports
// "done" at dispatch/aria2-handoff while the host still streams gigabytes, so
// tray progress can't come from bytes we never see (issue #143). Instead, poll
// the server's /models/<category> listing — verified live: an in-progress file
// is NOT listed; it appears when the download completes.
const REMOTE_LANDING_POLL_MS = 5_000;
const REMOTE_LANDING_TIMEOUT_MS = 4 * 60 * 60 * 1000; // generous: multi-GB on slow links
/**
* Background tray reporter for a server-side model download. Writes an
* indeterminate "downloading" row immediately (and as a heartbeat, so the
* orchestrator's 60s dead-writer sweep doesn't prune long downloads), then a
* terminal "done" when the filename appears in /models/<category>, or "error"
* if it never shows within the timeout. Fire-and-forget: the tool call returns
* at dispatch; this keeps the tray honest afterwards. No-op outside the panel
* (progress channel disabled).
*
* `base` is the ComfyUI the download was DISPATCHED to: the file lands there,
* so the poll must stay there even if the panel retargets mid-download —
* following the new target would watch a server the file never lands on and
* eventually report a false "error".
*/
export function watchRemoteModelLanding(category, filename, url, base = getComfyUIBaseUrl()) {
if (!progressEnabled())
return;
const id = createHash("sha256").update(url).digest("hex").slice(0, 16);
const row = { id, name: filename, downloaded: 0, total: 0, bytes_per_sec: 0 };
reportDownloadProgress({ ...row, status: "downloading" }, true);
const started = Date.now();
const timer = setInterval(() => {
void (async () => {
let listed = false;
try {
const res = await comfyuiFetch(`${base}/models/${encodeURIComponent(category)}`);
if (res.ok) {
const names = (await res.json());
// Entries are relative paths ("file.safetensors" or "sub/file.safetensors").
listed =
Array.isArray(names) &&
names.some((n) => typeof n === "string" &&
(n === filename || n.endsWith(`/${filename}`) || n.endsWith(`\\${filename}`)));
}
}
catch {
// Server briefly unreachable (e.g. mid-reboot) — keep waiting.
}
if (listed) {
clearInterval(timer);
reportDownloadProgress({ ...row, status: "done" }, true);
}
else if (Date.now() - started > REMOTE_LANDING_TIMEOUT_MS) {
clearInterval(timer);
reportDownloadProgress({ ...row, status: "error" }, true);
}
else {
reportDownloadProgress({ ...row, status: "downloading" }, true);
}
})();
}, REMOTE_LANDING_POLL_MS);
timer.unref?.();
}
/**
* Install a model on the connected ComfyUI host via ComfyUI-Manager's unified
* task queue (`install-model` kind). Wraps the same queue task + drain
* (start → poll status) flow that custom-node installs use, so it works against
* a REMOTE ComfyUI where the MCP has no local filesystem. The Manager backend
* fetches `url` server-side into the target model directory.
*/
export async function installModelViaManager(params) {
// Build the ModelMetadata params exactly as Manager 4.2.2 expects. ALL of
// name/type/url/filename/save_path are load-bearing (verified live against a
// RunPod pod): `name` and `save_path` were the two missing fields that made
// the task dispatch but silently install nothing.
const name = params.name && params.name.trim().length > 0 ? params.name : params.filename;
const save_path = params.save_path && params.save_path.trim().length > 0
? params.save_path
: "default";
const taskParams = {
name,
url: params.url,
filename: params.filename,
type: params.type,
save_path,
};
// One pinned target for the whole operation — the dispatch, its self-heal
// retry/drain, AND the landing watcher (the file lands on the server the task
// was dispatched to, so that is the only server worth polling).
const base = managerBaseUrl();
const queued = await queueManagerTaskTracked("install-model", taskParams, base);
const status = queued.status;
let taskRecord;
try {
taskRecord = await fetchManagerTaskHistoryEntry(queued.uiId, base);
}
catch {
taskRecord = undefined;
}
const taskStatus = taskRecord?.statusStr?.trim().toLowerCase();
if (taskRecord && (taskStatus === "failed" || taskStatus === "error")) {
const excerpt = managerTaskResultExcerpt(taskRecord?.result);
throw new NodeManagementError(`ComfyUI-Manager recorded the server-side model download task as ${taskStatus}. ` +
`The queue drained, but that only proves the task finished running; it does not prove ` +
`the model file landed or is valid.` +
(excerpt ? ` Manager result: ${excerpt}` : ""), { queueStatus: status, managerTask: safeManagerTaskDetails(taskRecord, excerpt), uiId: queued.uiId });
}
// NOTE: the Manager queue reports the task "done" once it DRAINS, and with
// an aria2 sidecar (COMFYUI_MANAGER_ARIA2_SERVER) it drains at HANDOFF,
// while the host is still streaming gigabytes. On v4, queue/history can tell
// us that OUR task already failed; older or unreadable history cannot. A
// clean drain (or unknown history) still does NOT prove the file landed —
// phrase the result as "dispatched", not "installed", and leave final
// verification to a list on the pod.
//
// DEPLOYMENT: remote model install requires the pod's ComfyUI-Manager to be
// in network_mode=personal_cloud (or loopback) with permissive security; a
// stricter security_level rejects the server-side download.
if (params.trayCategory) {
watchRemoteModelLanding(params.trayCategory, params.filename, params.url, base);
}
return {
mechanism: "manager-http",
message: `Dispatched model "${name}" (${params.filename}) to install into ` +
`${params.type} on the connected ComfyUI via ComfyUI-Manager. The queue ` +
`drained, but that only means the download was HANDED OFF (with an aria2 ` +
`sidecar the host keeps streaming after the queue drains), and Manager ` +
`history did not prove the file landed — so success is ` +
`NOT guaranteed. The file appears in the server's ` +
`/models/<category> listing only once the download COMPLETES (in-progress ` +
`files are hidden), and Manager may store it under its own type dir ` +
`(e.g. unet/, clip/) which ComfyUI aliases to diffusion_models/` +
`text_encoders — an empty canonical folder mid-download is NORMAL, not a ` +
`failure. Wait and re-check before re-dispatching (duplicates waste ` +
`bandwidth); a restart may be required for loaders to see the file. If ` +
`nothing ever lands, confirm Manager runs with ` +
`network_mode=personal_cloud (or loopback) and a permissive security ` +
`level so server-side downloads aren't blocked.`,
details: status,
};
}
//# sourceMappingURL=node-management.js.map