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),
3,891 lines • 211 kB
JavaScript
import { createHash, randomBytes } from "node:crypto";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { readdir, stat, mkdir, readFile, lstat, realpath } from "node:fs/promises";
import { dirname, join, basename, normalize, resolve, relative, sep, isAbsolute, extname } from "node:path";
import { config, getComfyUIBaseUrl, getComfyuiTargetGeneration, isRemoteMode, } from "../config.js";
import { getClient, getLogs, getSystemStats, comfyApiFetch } from "../comfyui/client.js";
import { isComfyTransportFailure } from "../comfyui/fetch.js";
import { PanelComfyUIReadRelayError, requestPanelComfyUIRead, } from "./panel-image-relay.js";
import { directoryForWidget } from "./missing-models.js";
import { getExtraModelRoots, getLaunchStateExtraModelRoots, getLiveExtraModelRoots, } from "./extra-paths.js";
import { resolveEffectiveComfyUIBase, resolveLiveServerRoot, } from "./workspace-env.js";
import { installModelViaManager } from "./node-management.js";
import { ModelError, ValidationError, unreachableHostMessage } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { downloadCacheIdentity, downloadWithCache, findResumablePartial, probeRemoteModelPayload, } from "./download-cache.js";
import { supportsCloudDownload } from "./storage/index.js";
import { reportDownloadProgress } from "./download-progress.js";
import { modelNotFoundMessage } from "./model-root-scope.js";
import { resolveModelsDirWithBases, parseModelsDirFromArgv, parseExtraModelPathsConfigsFromArgvRaw, hasUnresolvableRelativeModelDirFlag, isLiveAuthoritativeModelsDir, modelsDirNamedByServer, } from "./output-dir.js";
import { applyDownloadAuth, redactUrlForLogs, } from "./download-auth.js";
import { compareSemver } from "./self-update.js";
import { getUiBridge, minPanelVersionForCmd, SEMVER_RE } from "./ui-bridge.js";
import { verifiedPanelDiskVersion } from "./panel-workspace.js";
export const MODEL_SUBDIRS = [
"checkpoints",
"loras",
"vae",
"upscale_models",
"controlnet",
"embeddings",
"clip",
"diffusers",
"diffusion_models",
"gligen",
"hypernetworks",
"photomaker",
"style_models",
"text_encoders",
"unet",
];
/**
* Registered extra_model_paths categories that are NOT model-weight folders and
* must NEVER be treated as a valid download destination — even when a symlink
* under models/ resolves into one (#633 symlink allowance). The critical entry is
* `custom_nodes`: ComfyUI IMPORTS Python from it at startup, so allowing a model
* download (arbitrary bare filename) to land there would turn a file fetch into
* arbitrary CODE execution. extra_model_paths configs routinely register
* `custom_nodes` alongside model folders (see extra-paths.ts), so the symlink
* allowance filters these out and honors only genuine model roots — a download can
* reach a model dir on another drive (the #633 intent) but never a code directory.
* Compared case-insensitively.
*/
const NON_MODEL_EXTRA_CATEGORIES = new Set(["custom_nodes"]);
/**
* Map our internal model category (a MODEL_SUBDIRS value, i.e. the literal
* ComfyUI models/ folder name) to a key that ComfyUI-Manager's
* `model_dir_name_map` understands. When an install-model task is sent with
* `save_path: "default"`, Manager resolves the destination folder by looking
* `type` up in this map; an unmapped value resolves to None and the install is
* a SILENT no-op (the model never lands). So every category we route to Manager
* with a default save_path must map to a real key here. Categories with NO
* Manager key (diffusers, hypernetworks, photomaker, style_models) are handled
* by sending an explicit save_path (the folder name) instead — see
* managerModelDestination().
*/
const MANAGER_MODEL_TYPE_MAP = {
checkpoints: "checkpoints",
loras: "lora",
vae: "vae",
upscale_models: "upscale",
controlnet: "controlnet",
embeddings: "embeddings",
clip: "clip",
diffusion_models: "diffusion_model",
gligen: "gligen",
text_encoders: "text_encoders",
unet: "unet",
};
/**
* Resolve the ComfyUI-Manager install-model { type, save_path } pair for a
* target model directory. `category` is our internal model folder (a
* MODEL_SUBDIRS value, or the first path segment of a target subfolder).
* `relPath` is the full relative path under models/ when a NESTED destination
* is wanted (e.g. "loras/pusa"); omit/equal-to-category for a top-level folder.
*
* Contract (verified against ComfyUI-Manager 4.2.2 do_install_model):
* - `save_path` is ALWAYS sent. Manager's get_model_dir does
* `if data["save_path"] != "default": <use save_path verbatim>` else it
* resolves the folder from `type` via model_dir_name_map. A missing/None
* save_path makes get_model_dir bail (→ None) and nothing installs.
* - For a nested target we send the explicit relPath (Manager writes there
* verbatim, so the type-map is bypassed).
* - For a top-level category that HAS a Manager type-map key we send
* "default" and the mapped type.
* - For a top-level category with NO Manager key we send the category folder
* as save_path so Manager writes into models/<category> directly.
*/
export function managerModelDestination(category, relPath) {
const type = MANAGER_MODEL_TYPE_MAP[category] ?? category;
if (relPath && relPath !== category) {
return { type, save_path: relPath };
}
if (MANAGER_MODEL_TYPE_MAP[category]) {
return { type, save_path: "default" };
}
return { type, save_path: category };
}
/**
* Resolve the local ComfyUI base directory for filesystem operations. Prefers
* COMFYUI_PATH / auto-detection (config.comfyuiPath); when that's unset and we're
* NOT targeting a remote ComfyUI, falls back to the saved default workspace (set
* via workspace action:"set_default") so local downloads and model lookups work without
* COMFYUI_PATH — matching what install_comfyui (action:"environment") / workspace action:"get" already report.
* Never falls back to a local workspace in remote mode (that dir isn't the remote
* target). Returns undefined when no usable local path exists.
*/
/** A stable per-PROCESS tiebreak (0..999) folded into every attempt epoch so two
* DIFFERENT processes that start an attempt in the very same millisecond still get
* DISTINCT epochs (codex finding). Equal epochs would collide on the per-attempt
* filename (a real clobber) AND defeat supersession (the predicate needs strictly
* greater). Live processes on one host have distinct pids, but a random nonce avoids
* even a pid-reuse tie; the space only needs to separate the rare same-URL,
* same-target, same-ms double-start. */
const ATTEMPT_TIEBREAK = randomBytes(2).readUInt16BE(0) % 1000;
/** Strictly-monotonic attempt epoch (panel#489). Wall-clock milliseconds DOMINATE the
* ordering (× 1000), so a retry — which starts later in real time — always outranks the
* attempt it replaced, even across a reconnect respawn in a different process. The
* low-order tiebreak makes concurrent same-ms attempts in different processes distinct
* (no filename clobber, decisive supersession). Within THIS process the value never
* repeats or goes backward — two same-ms retries (or a small local clock rollback) still
* get increasing generations; a tie would let a stale FAILED slip through. (Cross-process
* ordering still assumes the wall clock does not jump backward by more than a retry gap —
* a severe NTP step is out of scope, as it is for any wall-clock generation.) */
let lastAttemptEpoch = 0;
function nextAttemptEpoch() {
const base = Date.now() * 1000 + ATTEMPT_TIEBREAK;
lastAttemptEpoch = Math.max(base, lastAttemptEpoch + 1);
return lastAttemptEpoch;
}
function resolveComfyUIBase() {
return resolveEffectiveComfyUIBase();
}
function getModelsRoot() {
const base = resolveComfyUIBase();
if (!base) {
throw new ModelError("No local ComfyUI path configured. Set the COMFYUI_PATH environment variable, " +
"or save a default workspace with workspace (action:\"set_default\").");
}
return join(base, "models");
}
export async function searchHuggingFaceModels(query, options = {}) {
const { filter, limit = 10 } = options;
const params = new URLSearchParams({
search: query,
limit: String(limit),
});
if (filter)
params.set("filter", filter);
const headers = {};
// Captured ONCE: the credential getters resolve from the canonical store on
// every access, so testing one read and interpolating another can send a
// different token — or `Bearer undefined` — if the store is rewritten in
// between (codex gate, round 6, finding 3).
const hfToken = config.huggingfaceToken;
if (hfToken) {
headers["Authorization"] = `Bearer ${hfToken}`;
}
const url = applyHfEndpoint(`https://huggingface.co/api/models?${params}`);
logger.debug("HuggingFace API request", { url });
// Third-party API: bound the wait so a stalled response cannot wedge the
// turn. Same class as #1026 — an unbounded metadata call has no limit at
// all and hangs until the caller gives up.
//
// #1136: an unreachable HuggingFace is the failure most easily mistaken for
// "no such model" — this call backs model SEARCH, and its caller renders a
// zero-length array as "nothing found". Only errors thrown by fetch() itself
// take this path; an HTTP status is a real answer and keeps its own wording.
const res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) }).catch((err) => {
const { message, code } = unreachableHostMessage(err, url, "the HuggingFace model search", {
remedy: "If huggingface.co is blocked in your region, set HF_ENDPOINT to a reachable mirror " +
"(e.g. https://hf-mirror.com) and retry.",
});
throw new ModelError(message, { url, code });
});
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 body = await res.text().catch(() => "");
throw new ModelError(`HuggingFace API ${res.status}: ${res.statusText}`, { url, status: res.status, body });
}
const data = (await res.json());
return data.map((m) => ({
id: String(m.id ?? m._id ?? ""),
modelId: String(m.modelId ?? m.id ?? ""),
author: String(m.author ?? ""),
tags: Array.isArray(m.tags) ? m.tags.map(String) : [],
downloads: Number(m.downloads ?? 0),
likes: Number(m.likes ?? 0),
lastModified: String(m.lastModified ?? ""),
}));
}
/**
* Discover the GGUF model-folder categories a running ComfyUI has registered, by
* asking `GET /models` (which returns every key in `folder_names_and_paths`) and
* keeping the `*_gguf` ones our static MODEL_SUBDIRS list doesn't already cover.
*
* This is what fixes #526: core `/models/<dir>` only lists extensions in
* `supported_pt_extensions` (no `.gguf`), so a static scan of MODEL_SUBDIRS misses
* every GGUF model. But those files are not hidden — ComfyUI-GGUF (and every
* derivative) registers its own categories (`unet_gguf`, `clip_gguf`) with a
* `.gguf` filter, and ComfyUI serves them over REST like any other category.
* Listing them surfaces GGUF models authoritatively: it reflects the LIVE server,
* honours extra_model_paths, works remotely, and needs no filesystem scan or folder
* guessing — each model is typed by the category ComfyUI itself registered.
*
* Why only `*_gguf` and not "every non-core category `/models` reports": `/models`
* exposes category NAMES but not their extension sets, so there is no safe way to
* tell a weight-bearing custom category from an extension-BLIND one (ComfyUI's own
* `custom_nodes` / `datasets` register with an empty set and would list every file
* under them — source code, arbitrary data — flooding the output). The `_gguf`
* suffix is a reliable, self-describing signal for GGUF model folders (the subject
* of #526) and cannot flood. Surfacing other loader categories (e.g. TensorRT's
* `.engine`) is a separate enhancement that needs per-category extension knowledge.
*
* Best-effort: returns [] when `/models` is unavailable (older server / error), in
* which case the caller still lists the core categories and, offline, the filesystem
* fallback still finds `.gguf` files on disk under their real folders.
*/
/** A `*_gguf` registry category (ComfyUI-GGUF's `unet_gguf`/`clip_gguf`, …). */
function isGgufCategory(dir) {
return /_gguf$/i.test(dir);
}
/**
* The physical folders a KNOWN `*_gguf` view is backed by — used ONLY to recognise when
* the SAME file is surfaced both via the view and via one of the real folders it
* aliases. ComfyUI-GGUF registers `unet_gguf` over the `diffusion_models` folders
* (`unet/` + `diffusion_models/`) and `clip_gguf` over the `text_encoders` folders
* (`text_encoders/` + `clip/`); that is the complete set for standard ComfyUI-GGUF.
* Never used for a model's reported `type`.
*/
const GGUF_BACKING_DIRS = {
unet_gguf: ["unet", "diffusion_models"],
clip_gguf: ["text_encoders", "clip"],
};
/** Reverse of GGUF_BACKING_DIRS: core folder → ComfyUI-GGUF views that alias it. */
function ggufViewsAliasing(category) {
const lower = category.toLowerCase();
const views = [];
for (const [view, backing] of Object.entries(GGUF_BACKING_DIRS)) {
if (backing.some((dir) => dir.toLowerCase() === lower))
views.push(view);
}
return views;
}
function isGgufFilename(filename) {
return extname(filename).toLowerCase() === ".gguf";
}
/**
* The real folder(s) a scanned category resolves to, for de-dup identity. A KNOWN
* `*_gguf` view resolves to the physical folders it aliases (so the same file surfaced
* via the view and via its backing core folder collapses to one). EVERY other category
* — a core category, OR an UNKNOWN/unmapped `*_gguf` — resolves to ITSELF. We must NOT
* assume an unknown `<x>_gguf` aliases a same-named `<x>` folder: a genuinely distinct
* `<x>_gguf/model.gguf` would then be wrongly collapsed against a real `<x>/model.gguf`
* and dropped. Lookup is case-insensitive.
*
* This intentionally targets only the duplication THIS fix can introduce. The
* pre-existing overlap between ComfyUI's own back-compat categories (e.g.
* `diffusion_models` also listing `unet/`) is left exactly as on main: its provenance
* is erased over REST, so collapsing it would risk discarding distinct same-name files.
*/
function identityFoldersFor(category) {
const lower = category.toLowerCase();
return GGUF_BACKING_DIRS[lower] ?? [lower];
}
/**
* Identity keys for a file within a scanned category — `<backing-folder>/<relname>`.
* The RELATIVE name (which may include subfolders) is used, not just the basename: a
* `*_gguf` view and its backing core folder report the SAME relative path for the same
* file, so keying on it collapses that alias duplicate while keeping genuinely distinct
* files (same basename in a different subfolder, or in a different real folder)
* separate. The folder segment is lowercased (folder/category names are effectively
* case-insensitive); the relative NAME is used verbatim — its case and path separators
* matter (`Model.gguf` ≠ `model.gguf`, and a literal `a\b` ≠ nested `a/b` on POSIX).
* That's safe because a single call de-dups within ONE source (all HTTP or all
* filesystem), so the same file always arrives with identical spelling. `add` returns
* false (recording nothing) when the file is already present under any of its backing
* folders, so callers skip the duplicate; otherwise it records every key and returns
* true.
*/
function makeModelDeduper() {
const seen = new Set();
return {
add(category, name) {
const keys = identityFoldersFor(category).map((folder) => `${folder}/${name}`);
if (keys.some((k) => seen.has(k)))
return false;
for (const k of keys)
seen.add(k);
return true;
},
};
}
const MODELS_TRANSPORT_CODES = new Set([
"EHOSTUNREACH",
"ENETUNREACH",
"ECONNREFUSED",
"ENOTFOUND",
"EAI_AGAIN",
"ECONNRESET",
]);
function isModelsTransportFailure(err) {
if (isComfyTransportFailure(err))
return true;
const seen = new Set();
let current = err;
while (current instanceof Error && !seen.has(current)) {
const code = "code" in current ? current.code : undefined;
if (typeof code === "string" && MODELS_TRANSPORT_CODES.has(code))
return true;
if (/\b(EHOSTUNREACH|ENETUNREACH|ECONNREFUSED)\b/.test(current.message))
return true;
seen.add(current);
current = "cause" in current ? current.cause : undefined;
}
return false;
}
function modelsReadOperationFor(path) {
if (path === "/models")
return "models";
const match = /^\/models\/([A-Za-z0-9][A-Za-z0-9._-]{0,63})$/.exec(path);
if (!match)
return undefined;
return `models/${match[1]}`;
}
function panelModelsResponse(read) {
const headers = new Headers();
if (read.contentType)
headers.set("content-type", read.contentType);
return new Response(read.body, { status: 200, headers });
}
function jsonModelsResponse(payload) {
const body = JSON.stringify(payload);
return new Response(body, {
status: 200,
headers: { "content-type": "application/json" },
});
}
function isObjectInfoDocument(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isUnsupportedModelsPanelRead(error) {
const parts = [];
if (error instanceof Error)
parts.push(error.message);
if (error instanceof PanelComfyUIReadRelayError && error.reason)
parts.push(error.reason);
return /operation must be one of/i.test(parts.join("\n"));
}
function objectInfoDirsMatching(category) {
const lower = category.toLowerCase();
if (lower === "unet" || lower === "diffusion_models") {
return new Set(["unet", "diffusion_models"]);
}
if (lower === "clip" || lower === "text_encoders") {
return new Set(["clip", "text_encoders"]);
}
return new Set([lower]);
}
/**
* object_info exposes the legacy loader aliases (`unet`/`clip`), while the
* current model directories are `diffusion_models`/`text_encoders`. This is
* deliberately used only for object_info fallback results: an actual REST
* response keeps the category the server returned.
*/
function canonicalObjectInfoCategory(category) {
const lower = category.toLowerCase();
if (lower === "unet" || lower === "diffusion_models")
return "diffusion_models";
if (lower === "clip" || lower === "text_encoders")
return "text_encoders";
return category;
}
function walkObjectInfoCombos(info, visit) {
if (!info || typeof info !== "object" || Array.isArray(info))
return;
for (const [classType, def] of Object.entries(info)) {
if (!def || typeof def !== "object" || Array.isArray(def))
continue;
const input = "input" in def ? def.input : undefined;
if (!input || typeof input !== "object" || Array.isArray(input))
continue;
const groups = [];
if ("required" in input)
groups.push(input.required);
if ("optional" in input)
groups.push(input.optional);
for (const group of groups) {
if (!group || typeof group !== "object" || Array.isArray(group))
continue;
for (const [widget, spec] of Object.entries(group)) {
const options = comboOptionStrings(spec);
if (!options)
continue;
visit(classType, widget, options);
}
}
}
}
function modelNamesFromObjectInfo(info, category) {
const wanted = objectInfoDirsMatching(category);
const names = new Set();
let matched = false;
walkObjectInfoCombos(info, (classType, widget, options) => {
const dir = directoryForWidget(widget, classType);
if (!dir || !wanted.has(dir.toLowerCase()))
return;
matched = true;
for (const name of options) {
if (WEIGHT_EXTS.has(extname(name).toLowerCase()))
names.add(name);
}
});
return matched ? [...names] : undefined;
}
function modelCategoriesFromObjectInfo(info) {
const cats = new Set();
walkObjectInfoCombos(info, (classType, widget) => {
const dir = directoryForWidget(widget, classType);
if (dir)
cats.add(dir);
});
return [...cats];
}
async function panelObjectInfo(memo) {
if (memo.objectInfoFailed)
return undefined;
if (memo.objectInfo !== undefined)
return memo.objectInfo;
try {
const relayed = await requestPanelComfyUIRead("object_info");
if (!relayed) {
memo.objectInfoFailed = true;
return undefined;
}
const parsed = JSON.parse(relayed.body);
// A `null`, an array or a scalar is not an object_info document. Without this
// the memo would cache it (`null !== undefined`), the `info === undefined`
// guard at the call site would pass, and the walkers would be handed a
// non-document — reporting "no categories" as though the panel had answered.
if (!isObjectInfoDocument(parsed)) {
memo.objectInfoFailed = true;
return undefined;
}
memo.objectInfo = parsed;
return memo.objectInfo;
}
catch {
memo.objectInfoFailed = true;
return undefined;
}
}
async function fetchModelsViaPanel(path, transportErr, memo) {
const operation = modelsReadOperationFor(path);
if (!operation)
throw transportErr;
if (!memo.modelsOpUnsupported) {
try {
const relayed = await requestPanelComfyUIRead(operation);
if (relayed)
return panelModelsResponse(relayed);
}
catch (panelErr) {
if (!isUnsupportedModelsPanelRead(panelErr))
throw panelErr;
memo.modelsOpUnsupported = true;
}
}
const info = await panelObjectInfo(memo);
if (info === undefined)
throw transportErr;
if (path === "/models")
return jsonModelsResponse(modelCategoriesFromObjectInfo(info));
const category = path.slice("/models/".length);
const names = modelNamesFromObjectInfo(info, category);
// A valid object_info document that has no loader combo for this category
// did not answer the category. Preserve the original transport failure so
// the caller records it as unanswered instead of manufacturing HTTP 200 [].
if (names === undefined)
throw transportErr;
(memo.objectInfoFallbackCategories ??= new Map()).set(category, canonicalObjectInfoCategory(category));
return jsonModelsResponse(names);
}
/**
* Headless `/models` first; on transport failure, the #2283 panel read relay.
* Current panels still allowlist only history/system_stats/logs/object_info
* (plus workflow_templates on newer builds), so a rejected `models/<folder>`
* recovers from panel `object_info` loader combos (#2511 recurrence).
*/
async function fetchModelsRoute(path, memo = {}) {
try {
return await comfyApiFetch(path);
}
catch (err) {
if (!isModelsTransportFailure(err))
throw err;
return fetchModelsViaPanel(path, err, memo);
}
}
/**
* Weight-file extensions. The gate that makes discovering the WHOLE registry
* safe (#962).
*
* #526 deliberately scoped discovery to `*_gguf` because `/models` also lists
* extension-BLIND registries — `custom_nodes`, `datasets` and friends register
* an EMPTY extension set, so `/models/<that>` returns every file in the folder
* and folding one in would flood the listing with arbitrary files. That reason
* is sound, and a denylist of category NAMES cannot answer it: the set is open,
* since any custom node can register its own.
*
* Filtering the FILES instead closes it for good. A discovered category
* contributes only recognisable weights, so an extension-blind registry full of
* .txt/.json/.py contributes nothing — while a `.safetensors` under a key
* MODEL_SUBDIRS never heard of finally becomes visible, which is the whole of
* #962.
*/
const WEIGHT_EXTS = new Set([
".safetensors",
".ckpt",
".pt",
".pth",
".bin",
".gguf",
".sft",
".onnx",
".engine", // TensorRT
]);
async function discoverExtraCategories(client, memo) {
try {
const res = await fetchModelsRoute("/models", memo);
if (!res.ok)
return [];
const json = (await res.json());
if (!Array.isArray(json))
return [];
const core = new Set(MODEL_SUBDIRS);
// EVERY registered category we don't already scan, not just `*_gguf` (#962).
//
// MODEL_SUBDIRS is a hardcoded list of 15 folder names. Anything ComfyUI
// registers outside it — a custom node's own folder, an extra_model_paths
// entry, a fork's renamed weights dir — was invisible to this tool BY
// CONSTRUCTION. A reporter had a remote server whose UNETLoader listed and
// loaded `krastBf16_v3.safetensors` while `list_local_models` answered "no
// diffusion_models models found" AND "no unet models found": the weights
// were registered under a key neither name covers, and a hardcoded list can
// never find them.
//
// `/models` is ComfyUI's own answer to "what folders do I serve?", so
// discovery replaces the guess rather than supplementing it.
return [
...new Set(json.filter((c) => typeof c === "string" && c.trim() !== "" && !core.has(c))),
];
}
catch (err) {
logger.debug("HTTP /models category discovery failed", { err });
return [];
}
}
/**
* Why a category's body would not parse — stated from what was OBSERVED (#1015).
*
* The previous wording covered every parse failure with one sentence: "returned
* a non-JSON body instead of JSON (a proxy, login page, or a server still
* starting answers this way)". For an HTML body that is a fair reading. For an
* EMPTY body it is three guesses stacked on a fact we already hold exactly — the
* server answered, the status was fine, and it sent zero bytes. None of the
* three offered causes produces an empty body, so the one case where the
* evidence is unambiguous got the vaguest description.
*
* The reporter's ask was precisely this: "return the raw endpoint/status so
* callers can distinguish an empty category from a transport/parse failure."
* The status is now carried in every branch, because a parse failure at 200 and
* a parse failure at 502 are different problems.
*
* Note what this does NOT claim. An empty body is not read as "the category is
* empty" — that is the #918 conflation this whole coverage type exists to
* prevent. An unparsable answer leaves the category UNANSWERED regardless of
* why; this only makes the why accurate.
*/
export function describeUnparsableBody(status, body) {
const at = `HTTP ${status}`;
if (body.trim() === "") {
return (`ComfyUI answered ${at} with an EMPTY body (${body.length} byte${body.length === 1 ? "" : "s"}) — ` +
`it reported nothing about this category either way`);
}
if (/^\s*</.test(body)) {
return (`ComfyUI returned HTML instead of JSON ` +
`(${at} — a proxy, a login page, or a server still starting answers this way)`);
}
// Neither empty nor markup: quote a bounded excerpt so the cause is
// diagnosable without dumping a large body into the tool result. Collapse
// whitespace so a multi-line payload stays on one line.
const excerpt = body.trim().replace(/\s+/g, " ").slice(0, 80);
return (`ComfyUI answered ${at} with a body that is not JSON ` +
`(${body.length} bytes, starts: ${JSON.stringify(excerpt)})`);
}
function captureModelListingTarget() {
return {
generation: getComfyuiTargetGeneration(),
baseUrl: getComfyUIBaseUrl(),
remote: isRemoteMode(),
localPath: config.comfyuiPath,
};
}
function targetMatchesWitness(witness) {
return (getComfyuiTargetGeneration() === witness.generation &&
getComfyUIBaseUrl() === witness.baseUrl &&
isRemoteMode() === witness.remote &&
config.comfyuiPath === witness.localPath);
}
/**
* A model listing is one answer about one ComfyUI target. If that target is
* retargeted while an HTTP/filesystem await is in flight, none of the names or
* paths collected so far can be attributed to the target the caller asked
* about. Clear every source-derived field and make the caller retry instead of
* returning a mixed or stale inventory. The generation check is intentional:
* an A -> B -> A round trip has the same final URL and mode but is still stale.
*/
function refuseStaleModelListing(witness, coverage) {
if (targetMatchesWitness(witness))
return false;
if (!coverage.localPathRecovered && !coverage.targetChanged) {
const currentBaseUrl = getComfyUIBaseUrl();
const localPathRecovered = getComfyuiTargetGeneration() === witness.generation &&
currentBaseUrl === witness.baseUrl &&
isRemoteMode() === witness.remote &&
witness.localPath === undefined &&
config.comfyuiPath !== undefined;
if (localPathRecovered) {
coverage.localPathRecovered = true;
}
else {
coverage.targetChanged = {
startedBaseUrl: witness.baseUrl,
currentBaseUrl,
};
}
}
coverage.answered.length = 0;
coverage.unanswered.length = 0;
coverage.absent = [];
coverage.httpUnavailable = undefined;
coverage.otherRegisteredCategories = undefined;
coverage.usedFilesystem = false;
coverage.noSourceAvailable = true;
return true;
}
/** Model listing plus the provenance needed to describe it honestly. */
export async function listLocalModelsWithCoverage(modelType) {
const target = captureModelListingTarget();
const coverage = {
answered: [],
unanswered: [],
absent: [],
usedFilesystem: false,
};
const panelMemo = {};
const models = await collectLocalModels(modelType, coverage, target, panelMemo);
if (refuseStaleModelListing(target, coverage)) {
return { models: [], coverage };
}
// #962 — a filtered call that found nothing is about to say "none". Before it
// does, ask the server what it actually registers. Bounded to this one case,
// so the common paths pay nothing.
if (models.length === 0 && modelType !== undefined && coverage.answered.includes(modelType)) {
coverage.otherRegisteredCategories = await otherRegisteredCategories(modelType, panelMemo);
if (refuseStaleModelListing(target, coverage)) {
return { models: [], coverage };
}
}
if (refuseStaleModelListing(target, coverage)) {
return { models: [], coverage };
}
return { models, coverage };
}
/**
* Categories this ComfyUI registers, minus the one already asked about (#962).
*
* Undefined on any failure: "we could not ask" must not render as "there is
* nowhere else to look", which is the same fold the coverage type exists for.
*/
async function otherRegisteredCategories(asked, memo) {
try {
const res = await fetchModelsRoute("/models", memo);
if (!res.ok)
return undefined;
const json = (await res.json());
if (!Array.isArray(json))
return undefined;
return [
...new Set(json.filter((c) => typeof c === "string" && c.trim() !== "" && c !== asked)),
];
}
catch (err) {
logger.debug("could not read the registered category list for an empty filtered listing", {
err,
});
return undefined;
}
}
export async function listLocalModels(modelType) {
return (await listLocalModelsWithCoverage(modelType)).models;
}
async function collectLocalModels(modelType, coverage, target, panelMemo) {
const dirsToScan = modelType ? [modelType] : [...MODEL_SUBDIRS];
const results = [];
// Path 1 — HTTP REST. ComfyUI's `/models/<dir>` endpoint reports what is
// actually available to workflows, including symlinked / mounted dirs from
// `extra_model_paths.yaml`. Pure filesystem scans of the install dir miss
// those, and they fail entirely in remote/cloud mode where comfyuiPath is
// undefined. Originally contributed by João Lucas (github.com/joaolvivas) in
// joaolvivas/comfyui-mcp-byjlucas@e2ae39c8 (2026-05-12).
const coreDirs = new Set(MODEL_SUBDIRS);
let httpReturnedAny = false;
try {
const client = getClient(); // throws CLOUD_UNSUPPORTED in cloud mode
// For an unfiltered listing, scan every category the server REGISTERS that
// MODEL_SUBDIRS doesn't already cover. That began as ComfyUI-GGUF's `*_gguf`
// views (#526, the `.gguf` weights core `/models/<dir>` omits) and is now the
// whole registry (#962): a hardcoded list of 15 folder names cannot find
// weights a custom node, a fork, or an extra_model_paths entry registered
// under some other key — and a reporter's UNETLoader was loading exactly such
// a file while this tool reported none.
//
// A FILTERED call already names its exact category, so it needs no discovery.
if (!modelType) {
const extraCategories = await discoverExtraCategories(client, panelMemo);
if (refuseStaleModelListing(target, coverage))
return [];
for (const cat of extraCategories)
dirsToScan.push(cat);
}
// A `*_gguf` view aliases real folders, so the same file can be reported both
// via the view and via a core category (e.g. a custom node that re-registers
// `diffusion_models` to also admit `.gguf`). De-dup by resolved-folder identity
// so it surfaces once, without collapsing genuinely distinct same-name files.
const dedup = makeModelDeduper();
for (const dir of dirsToScan) {
try {
const res = await fetchModelsRoute(`/models/${dir}`, panelMemo);
if (refuseStaleModelListing(target, coverage))
return [];
// A non-OK status or a non-array body means we did NOT learn what this
// category holds. Recording the reason is the whole point: continuing
// silently is what let a warming-up server look like an empty install.
//
// …with ONE exception, and it is the opposite fold (#1015). A 404 is the
// server answering DEFINITIVELY that it does not serve this category —
// "determined not present", not "could not determine". Modern ComfyUI
// renamed two of the folders in MODEL_SUBDIRS (clip → text_encoders,
// unet → diffusion_models), so a current install 404s the old names.
// Reproduced on a live 0.31 server:
//
// clip HTTP 404 bytes=0
// unet HTTP 404 bytes=0
// text_encoders HTTP 200 bytes=297
// diffusion_models HTTP 200 bytes=360
//
// Counting those as unread put a permanent "Partial listing — 2
// categories could not be read" on every healthy modern install, telling
// a user their inventory was incomplete when it was complete — and naming
// aliases whose real contents were already listed under the new names.
//
// Recorded as ABSENT rather than dropped silently, so an unfiltered
// listing can still say which categories this server does not register.
if (res.status === 404) {
(coverage.absent ??= []).push(dir);
// #2480 — a filtered unet (or clip) 404 is not "look under the rename".
// ComfyUI-GGUF still serves those files via unet_gguf / clip_gguf, and
// extra_model_paths can still register the original folder. Probe the
// alias view on a FILTERED call; unfiltered listing already discovers it.
if (modelType) {
for (const view of ggufViewsAliasing(dir)) {
if (!dirsToScan.includes(view))
dirsToScan.push(view);
}
}
continue;
}
if (!res.ok) {
coverage.unanswered.push({ dir, reason: `HTTP ${res.status}` });
continue;
}
// Parse from TEXT rather than res.json(): a proxy, login page, or a
// still-starting server answers 200 with HTML, and res.json() then throws
// `Unexpected token '<', "<!doctype "...` — a raw parser message that says
// nothing about what actually happened. #918 called that out on the sibling
// tool; classify it here instead of leaking it.
const body = await res.text();
if (refuseStaleModelListing(target, coverage))
return [];
let files;
try {
files = JSON.parse(body);
}
catch {
coverage.unanswered.push({ dir, reason: describeUnparsableBody(res.status, body) });
continue;
}
if (!Array.isArray(files)) {
coverage.unanswered.push({
dir,
reason: `the response was JSON but not an array (got ${files === null ? "null" : typeof files})`,
});
continue;
}
coverage.answered.push(dir);
for (const name of files) {
if (typeof name !== "string")
continue;
// Hardening for any `*_gguf` category (discovered OR explicitly requested):
// emit ONLY `.gguf` files. A well-behaved category registers a `{".gguf"}`
// filter, but a malformed one could register an EMPTY extension set (which
// lists every file) over a shared dir — without this guard that would flood
// the output. Core categories never contain `.gguf`, so this stays additive.
if (isGgufCategory(dir) && !name.toLowerCase().endsWith(".gguf"))
continue;
// #962 — the same hardening, generalised to every DISCOVERED category.
// Discovery now folds in the whole registry, which includes
// extension-blind entries (custom_nodes, datasets, a custom node's own)
// whose `/models/<dir>` returns every file in the folder. Requiring a
// weight extension is what keeps that from flooding the listing, and it
// is the reason discovery can be widened at all. CORE categories are
// untouched — their behaviour is unchanged.
if (!coreDirs.has(dir) && !WEIGHT_EXTS.has(extname(name).toLowerCase()))
continue;
const resultDir = panelMemo.objectInfoFallbackCategories?.get(dir) ?? dir;
if (!dedup.add(resultDir, name))
continue; // same file already surfaced elsewhere
httpReturnedAny = true;
results.push({
name,
path: `${resultDir}/${name}`, // ComfyUI-relative; absolute path unknown via REST
size: 0,
modified: "",
type: resultDir,
});
}
}
catch (err) {
logger.debug(`HTTP /models/${dir} failed, continuing`, { err });
coverage.unanswered.push({
dir,
reason: err instanceof Error ? err.message : String(err),
});
}
}
if (httpReturnedAny) {
await enrichWithCivitaiMetadata(results);
if (refuseStaleModelListing(target, coverage))
return [];
return results;
}
}
catch (err) {
if (refuseStaleModelListing(target, coverage))
return [];
logger.debug("HTTP model listing unavailable, trying filesystem", { err });
coverage.httpUnavailable = err instanceof Error ? err.message : String(err);
// The outer throw (no client / cloud mode / GGUF discovery) aborts before any
// per-category read, so nothing below it was answered either.
for (const dir of dirsToScan) {
if (!coverage.answered.includes(dir) &&
// A category already answered 404 was ANSWERED — the later outer failure
// does not un-answer it (#1015).
!(coverage.absent ?? []).includes(dir) &&
!coverage.unanswered.some((u) => u.dir === dir)) {
coverage.unanswered.push({ dir, reason: coverage.httpUnavailable });
}
}
}
// Path 2 — filesystem fallback. Only useful in pure local mode without
// extra_model_paths.yaml. A configured COMFYUI_PATH may deliberately coexist
// with a remote URL, so the path itself is not permission to scan it: doing so
// would answer a remote inventory question with this MCP host's models (#2319).
// Remote callers already have the connected server's HTTP result above; when
// that result is unavailable, return an explicitly source-less answer instead
// of consulting a local tree that belongs to another target.
if (refuseStaleModelListing(target, coverage))
return [];
if (isRemoteMode()) {
coverage.noSourceAvailable = coverage.unanswered.length > 0;
return results;
}
// Returning empty here is only the RIGHT answer when HTTP actually answered;
// when it didn't, this is the false-empty path from #918 and the caller has to
// be told the difference (coverage.noSourceAvailable).
if (!config.comfyuiPath) {
coverage.noSourceAvailable = coverage.unanswered.length > 0;
return results;
}
coverage.usedFilesystem = true;
const modelsRoot = join(config.comfyuiPath, "models");
// extra_model_paths can register unet (and other categories) at a directory
// that is NOT `{comfyuiPath}/models/<dir>` — Desktop's extra_models_config
// is the usual case. REST 404 must still list that configured directory (#2480).
let extraRoots = [];
try {
extraRoots = await getExtraModelRoots();
}
catch {
extraRoots = [];
}
if (refuseStaleModelListing(target, coverage))
return [];
const dedup = makeModelDeduper();
for (const dir of dirsToScan) {
const primary = join(modelsRoot, dir);
const extraForDir = extraRoots
.filter((er) => er.category.trim().toLowerCase() === dir.toLowerCase())
.map((er) => er.dir);
const scanRoots = [];
const seenRoots = new Set();
for (const dirPath of [primary, ...extraForDir]) {
const key = resolve(dirPath);
if (seenRoots.has(key))
continue;
seenRoots.add(key);
scanRoots.push(dirPath);
}
for (const dirPath of scanRoots) {
let entries;
try {
entries = await readdir(dirPath, { recursive: true });
}
catch {
if (refuseStaleModelListing(target, coverage))
return [];
continue;
}
if (refuseStaleModelListing(target, coverage))
return [];
for (const entry of entries) {
// Same `.gguf`-only guard as the HTTP path for `*_gguf` categories (e.g. an
// explicit `listLocalModels("unet_gguf")`): never list non-gguf files here.
if (isGgufCategory(dir) && !entry.toLowerCase().endsWith(".gguf"))
continue;
const filePath = join(dirPath, entry);
try {
const info = await stat(filePath);
if (refuseStaleModelListing(target, coverage))
return [];
if (!info.isFile())
continue;
if (!dedup.add(dir, entry))
continue; // same file already surfaced elsewhere
results.push({
name: entry,
path: filePath,
size: info.size,
modified: info.mtime.toISOString(),
type: dir,
});
}
catch {
if (refuseStaleModelListing(target, coverage))
return [];
// Skip files we can't stat
}
}
}
}
await enrichWithCivitaiMetadata(results);
if (refuseStaleModelListing(target, coverage))
return [];
return results;
}
/**
* Best-effort: attach `triggerWords` + `baseModel` from each model's CivitAI
* sidecar (`<file>.civitai.json`, written by download_model action:"download_civitai") so the
* agent applies the trigger words automatically when generating. Silent when a
* sidecar is missing or there's no local filesystem (remote/cloud).
*/
async function enrichWithCivitaiMetadata(models) {
const base = resolveComfyUIBase();
if (!base)
return;
const modelsRoot = join(base, "models");
await Promise.all(models.map(async (m) => {
// FS-scan paths are absolute; HTTP paths are `dir/name` relative to models/.
const abs = isAbsolute(m.path) ? m.path : join(modelsRoot, m.path);
try {
const raw = await readFile(`${abs}.civitai.json`, "utf8");
const j = JSON.parse(raw);
if (Array.isArray(j.trainedWords) && j.trainedWords.length > 0) {
m.triggerWords = j.trainedWords.map(String);
}
if (typeof j.baseModel === "string" && j.baseModel) {
m.baseModel = j.baseModel;
}
// Provenance: prefer the sidecar's canonical sourceUrl; reconstruct it
// from the ids for older sidecars that predate sourceUrl. Both shapes
// exist in the wild: /models/<id>?modelVersionId=<vid> (model known)
// and /model-versions/<vid> (version-only downloads).
if (typeof j.sourceUrl === "string" && isCivitaiUrl(j.sourceUrl)) {
m.civitaiUrl = j.sourceUrl;
}
else if (typeof j.versionId === "number") {
m.civitaiUrl =
typeof j.modelId === "number"
? `https://civitai.com/models/${j.modelId}?modelVersionId=${j.versionId}`
: `https://civitai.com/model-versions/${j.versionId}`;
}
}
catch {
// No sidecar (or unreadable) — leave the model un-enriched.
}
}));
}
/** True when `url`'s host is civitai.com (or a subdomain), parsed safely. */
function isCivitaiUrl(url) {
try {
const host = new URL(url).host.toLowerCase();
return host === "civitai.com" || host.endsWith(".civitai.com");
}
catch {
return false;
}
}
/**
* True when `url`'s PARSED hostname is huggingface.co (or a subdomain). The single
* authority for the "is this a Hugging Face host?" credential decision — used by BOTH the
* local streaming download and the #473 remote flip probe. NEVER a substring match:
* `https://attacker.example/m.safetensors?ref=huggingface.co` and
* `https://huggingface.co.evil.com/...` both parse to a non-HF host and get NO token
* (a substring `url.includes("huggingface.co")` would leak HF_TOKEN to them). An
* unparseable url is not HF (no token).
*/
function isHuggingFaceHost(url) {
try {
const host = new URL(url).hostname.toLowerCase();
return host === "huggingface.co" || host.endsWith(".huggingface.co");
}
catch {
return false;
}
}
/** Network-restricted regions (issue #127): honor the de-facto HF_ENDPOINT
* mirror var (e.g. https://hf-mirror.com) by rewriting huggingface.co URLs at
* the API/download boundary. Only http(s) endpoints are accepted; anything
* else leaves the URL untouched. Adapted from 1696762169/comfyui-mcp 6a2bd96. */
export function applyHfEndpoint(url) {
const ep = process.env.HF_ENDPOINT?.trim().replace(/\/+$/, "");
if (!ep || !/^https?:\/\//i.test(ep))
return url;
return url.replace(/^https?:\/\/huggingface\.co(?=[/?#]|$)/i, ep);
}
/** Issue #127: CIVITAI_ENABLED=0 disables Civitai access cleanly — tools fail
* fast with a clear message instead of hanging against an unreachable host
* (Civitai is blocked in some regions). Adapted from 1696762169/comfyui-mcp
* a6441c0. */
export function civitaiDisabled() {
const v = (process.env.CIVITAI_ENABLED ?? "").trim().toLowerCase();
return v === "0" || v === "false" || v === "no";
}
export const CIVITAI_DISABLED_MESSAGE = "Civitai access is disabled by config (CIVITAI_ENABLED=0) — common on networks where civitai.com is unreachable. " +
"Unset CIVITAI_ENABLED to re-enable, or download the file elsewhere and pass a direct URL to download_model.";
/**
* Validate a target subfolder under models/ and resolve it to an absolute dir
* that is guaranteed to stay INSIDE models/. Accepts a known MODEL_SUBDIRS name
* OR an arbitrary (possibly nested, e.g. "loras/pusa") relative subfolder, while
* rejecting absolute paths and traversal escapes. Exported so callers can resolve
* an arbitrary target without duplicating the guard.
*/
export function resolveModelSubfolder(targetSubfolder) {
const raw = (targetSubfolder ?? "").trim();
if (!raw) {
throw new ModelError("target_subfolder is required (e.g. 'loras', 'checkpoints').");
}
if (isAbsolute(raw)) {
throw new ModelError(`target_subfolder must be relative to models/, not absolute: ${raw}`);
}
const modelsRoot = resolve(getModelsRoot());
const targetDir = resolve(modelsRoot, raw);
// Confirm the resolved dir stays strictly inside models/ (blocks ".." escapes).
if (targetDir !== modelsRoot && !targetDir.startsWith(modelsRoot + sep)) {
throw new ModelError(`Refusing to write outside the models directory: ${raw}`);
}
return targetDir;
}
// ---------------------------------------------------------------------------
// Live-server visibility (#369): does the CONNECTED ComfyUI actually read here?
// ---------------------------------------------------------------------------
/**
* The file extensions ComfyUI core registers for its model folders
* (`folder_paths.supported_pt_extensions`). Deliberately EXCLUDES `.gguf` and
* other custom-node-registered types: this set is used to decide whether a
* candidate directory's contents SHOULD appear in the live server's listing, and
* over-including would turn "core doesn't list this type" into a false refusal.
*/
const CORE_MODEL_EXTENSIONS = new Set([
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".safetensors",
".sft",
]);
/**
* Model categories whose `/models/<category>` endpoint can NEVER enumerate FILES,
* so "the server does not list this file" is not evidence about the server at all.
* ComfyUI core registers `diffusers` with the extension contract `["folder"]`
* (folder_paths.py) — an extension no real file ever matches — so the listing is
* empty BY DESIGN: ComfyUI loads a diffusers model as a whole directory and never
* lists the component files inside one (`hunyuan3d-paint-v2-0-turbo/vae/diffusion_pytorch_model.bin`,
* `.../text_encoder/pytorch_model.bin`). Sampling those component files and
* finding them "missing" from the listing was the #844 false "a DIFFERENT install"
* refusal — the omission is CONTRACTUAL, not evidential.
* For these categories the listing is inconclusive about files in BOTH directions:
* absence proves nothing (the pre-write refusal) and a landed file's presence
* cannot be confirmed from it either (the post-write check) — both must report
* "unknown", never a determined negative.
*/
const CATEGORIES_WITHOUT_FILE_ENUMERATION = new Set(["diffusers"]);
/** Is this category's live listing contractually incapable of naming FILES? */
function categoryCannotEnumerateFiles(category) {
return CATEGORIES_WITHOUT_FILE_ENUMERATION.has(category.trim().toLowerCase());
}
/** The models/ CATEGORY a target subfolder belongs to — its first path segment
* (`"loras/sdxl"` → `"loras"`), which is the folder name ComfyUI's `/models/<cat>`
* endpoint is keyed by. */
function categoryOf(targetSubfolder) {
return (targetSubfolder ?? "").trim().split(/[\\/]+/).filter(Boolean)[0] ?? "";
}
/** The part of a target subfolder BELOW its category (`"loras/sdxl/x"` → `"sdxl/x"`,
* `"loras"` → `""`). ComfyUI's `/models/<category>` listing is relative to the
* category, so this is what a landed file's entry must be prefixed with. */
function subfolderRemainder(targetSubfolder) {
const parts = (targetSubfolder ?? "").trim().split(/[\\/]+/).filter(Boolean);
return parts.slice(1).join("/");
}
export async function liveCategoryListing(category, options = {}) {
if (!category)
return undefined;
if (categoryCannotEnumerateFiles(category))
return undefined;
try {
const init = options.timeoutMs === undefined
? undefined
: { signal: AbortSignal.timeout(options.timeoutMs) };
const res = await comfyApiFetch(`/models/${encodeURIComponent(category)}`, init);
if (!res.ok)
return undefined;
const json = (await res.json());
if (!Array.isArray(json))
return undefined;
return json.filter((n) => typeof n === "string");
}
catch {
return undefined;
}
}
/**
* Combo option strings from a ComfyUI /object_info input spec (V1 `[[...], cfg]`
* or V3 `["COMBO", {options:[...]}]`). Undefined when this spec is not a
* single-select combo we can search.
*/
function comboOptionStrings(spec) {
if (!Array.isArray(spec) || spec.length === 0)
return undefined;
const cfgRaw = spec[1];
const cfg = cfgRaw && typeof cfgRaw === "object" && !Array.isArray(cfgRaw)
? cfgRaw
: null;
if (cfg?.multiselect)
return undefined;
const type = spec[0];
if (Array.isArray(type))
return type.filter((v) => typeof v === "string");
if (typeof type !== "string" || !/COMBO/i.test(type) || /DYNAMIC/i.test(type)) {
return undefined;
}
if (!cfg)
return undefined;
const opts = cfg.options;
if (!Array.isArray(opts))
return undefined;
const strings = opts.filter((v) => typeof v === "string");
return strings.length === opts.length ? strings : undefined;
}
function objectInfoHasFilename(info, filename) {
if (!info || typeof info !== "object" || Array.isArray(info))
return false;
const defs = info;
const wanted = basename(filename.replace(/\\/g, "/"));
for (const def of Object.values(defs)) {
if (!def || typeof def !== "object" || Array.isArray(def))
continue;
const input = "input" in def ? def.input : undefined;
if (!input || typeof input !== "object" || Array.isArray(input))
continue;
const groups = [];
if ("required" in input)
groups.push(input.required);
if ("optional" in input)
groups.push(input.optional);
for (const group of groups) {
if (!group || typeof group !== "object" || Array.isArray(group))
continue;
const rec = group;
for (const spec of Object.values(rec)) {
const options = comboOptionStrings(spec);
if (!options)
continue;
if (options.some((o) => basename(o.replace(/\\/g, "/")) === wanted))
return true;
}
}
}
return false;
}
/**
* Does any loader combo on /object_info list this filename? Used when
* `/models/<category>` 404s (#2480 — UnetLoaderGGUF still names GGUFs under
* unet even though core REST dropped that route). `undefined` = could not ask.
*/
async function objectInfoListsFilename(filename) {
if (!filename)
return undefined;
try {
const res = await comfyApiFetch("/object_info");
if (!res.ok)
return undefined;
const json = await res.json();
return objectInfoHasFilename(json, filename);
}
catch {
return undefined;
}
}
/**
* Model files under a category directory, as CATEGORY-RELATIVE paths — the exact
* form ComfyUI's `/models/<category>` listing uses (`"sdxl/x.safetensors"`). Bare
* basenames would make `loras/a/shared.safetensors` look accounted for by a live
* `loras/b/shared.safetensors` from a different install (codex gate, round 9).
*/
async function coreModelFilesUnder(categoryDir) {
let entries;
try {
entries = await readdir(categoryDir, { recursive: true });
}
catch {
return [];
}
// Not just tidiness: this feeds a REFUSAL, so an unreadable/odd listing must
// become "no evidence", never a crash inside the download path.
if (!Array.isArray(entries))
return [];
return entries
.filter((e) => typeof e === "string" && CORE_MODEL_EXTENSIONS.has(extname(e).toLowerCase()))
.map((e) => normRel(e));
}
/** How many populated categories of the candidate models root we cross-check.
* Bounded so a large models tree costs a handful of cheap HTTP calls, not one
* per folder. */
const DISAGREEMENT_PROBE_CATEGORIES = 6;
/** How many model files a fully-agreeing category must hold before it CORROBORATES
* that the live server reads this root (#1147). One is not enough: two unrelated
* installs routinely share a single popular checkpoint, and a category holding
* only that file is "fully accounted for" by coincidence (codex gate, round 5).
* Two independent names both coinciding is not an ordinary explanation. */
const CORROBORATING_CONTAINMENT_MIN_FILES = 2;
/** Immediate subdirectories of the candidate models root, in a stable order. */
async function categoriesUnder(modelsRoot) {
try {
const entries = await readdir(modelsRoot, { withFileTypes: true });
if (!Array.isArray(entries))
return [];
const names = [];
for (const e of entries) {
const entry = e;
if (!entry || typeof entry.isDirectory !== "function")
continue;
if (typeof entry.name !== "string")
continue;
if (entry.isDirectory())
names.push(entry.name);
}
return names.sort();
}
catch {
return [];
}
}
/**
* REFUSE a destination the connected ComfyUI demonstrably does not read from.
*
* Runs ONLY when the models root came from LOCAL CONFIGURATION that the running
* server never vouched for (`configured-base` / `base-anchored`) — a live-resolved
* root is already authoritative and needs no second opinion. The test is a pure
* disagreement check against the server's own `/models/<category>` listing: if a
* category directory in the candidate root holds core-extension model files and the
* live server lists NONE of them, that root is not part of its search path. That is
* the #369 signature exactly (3 files on disk, 24 unrelated files in the live
* listing), and catching it BEFORE the transfer saves the user the multi-GB download
* that would otherwise be reported as a success and then be invisible.
*
* The evidence is gathered ROOT-WIDE, not just from the target category: a stale
* install commonly has an EMPTY `diffusion_models/` while its `checkpoints/` is full
* of files the live server has never heard of (codex gate, round 1 — the target
* category alone made an empty-but-wrong destination look evidence-free).
*
* Agreement means CONTAINMENT, not overlap (codex gate, round 5): a directory the
* server scans has ALL of its model files in that server's listing. A single shared
* filename proves nothing — two unrelated installs routinely hold the same popular
* checkpoint — so overlap alone must never suppress the refusal.
*
* CORROBORATION (#1147): a category the server lists COMPLETELY, holding more than
* one file, is positive evidence that it reads this ROOT — every one of those names
* would have to coincide otherwise. That outweighs a single unaccounted file in
* another category, which has ordinary explanations that say nothing about the root
* (an unregistered extension, a cached listing, a nested HuggingFace dump). So the
* scan does not stop at the first disagreement, and a tree the server demonstrably
* reads is not refused over a stray file. A tree it accounts for NOWHERE — the #369
* signature — has no such category to offer and still refuses.
*
* POSITIVE EVIDENCE ONLY. There is deliberately no "the live server lists models
* this tree cannot account for" rule: being unable to EXPLAIN the server's models is
* absence of evidence, not proof that this destination belongs to another install
* (maintainer ruling). Unaccountable is a proceed-unverified state — see the note at
* the end of the body.
*
* Fails OPEN on anything inconclusive — server unreachable, endpoint missing, an
* empty candidate directory, a category whose listing contractually cannot name
* files (`diffusers`, #844: its component files are absent from the listing by
* DESIGN, so their "absence" is not disagreement), or full containment. It must
* never block a legitimate download into a fresh or shared models tree.
*/
async function assertDestinationVisibleToLiveServer(modelsRoot, targetSubfolder, source, snapshot) {
// A live-resolved root that EXISTS on this filesystem is authoritative and needs
// no second opinion. A live-resolved root that does NOT exist locally is a
// different animal: a loopback ComfyUI inside Docker / behind an SSH forward
// reports its CONTAINER-side `--models-directory`, and writing that path here
// silently creates a host directory the server never reads (codex gate, round 3).
// The check below is cheap and fails open, so run it for that case too.
// Only a root the SERVER ITSELF named skips this (#369). `observed-root` reads
// as live-authoritative elsewhere and is not wrong to — it beats local config —
// but it is INFERRED from where the interpreter lives, and that inference can
// land on a stale bundle whose python happens to be on PATH. The exemption
// needs a statement from the server, not our best reading of the OS.
if (modelsDirNamedByServer(source) && existsSync(modelsRoot))
return;
if (!snapshot.reachable || isRemoteMode())
return;
// The target category first (it is the one that matters), then the rest of the
// root — so a populated sibling can still expose a wrong install when the target
// folder happens to be empty.
const target = categoryOf(targetSubfolder);
const present = await categoriesUnder(modelsRoot);
const primary = [...(target ? [target] : []), ...present.filter((c) => c !== target)];
let probed = 0;
let worst;
let corroborating;
for (const category of primary) {
if (probed >= DISAGREEMENT_PROBE_CATEGORIES)
break;
const onDisk = await coreModelFilesUnder(join(modelsRoot, category));
if (onDisk.length === 0)
continue; // nothing here to contradict anything
const listing = await liveCategoryListing(category);
if (listing === undefined)
continue; // the server can't speak for it — no evidence
// #1147 — AN EMPTY LISTING IS NOT DISAGREEMENT.
//
// The #369 signature this guard exists for is a POPULATED listing that
// describes a different tree: 3 files on disk, 24 unrelated files live. That
// is positive evidence — the server demonstrably scans SOMETHING ELSE for
// this category.
//
// An empty listing says only that the server named nothing here, and that is
// equally explained by a server that reads this very directory but does not
// enumerate it the way a filesystem walk does. A reporter's portable install
// held models/birefnet/BiRefNet_lite/model.safetensors — a HuggingFace repo
// dump — while /models/birefnet answered empty, and the download was refused
// into the correct, running install, with their loras/ agreeing perfectly.
//
// So "0 live entries" cannot distinguish "wrong install" from "a category
// this server does not scan like we do", and this guard refuses only on
// positive evidence. The same reasoning the surrounding code already applies
// to `diffusers` (#844), whose listing contractually names no files —
// generalized from one hardcoded category to the condition underneath it.
if (listing.length === 0)
continue;
probed += 1;
// CONTAINMENT, not overlap. If this directory really is one the server reads, it
// SCANS it — so EVERY core-extension file in it must appear in the listing. A
// merely-overlapping name proves nothing: two unrelated installs routinely share
// `sd_xl_base_1.0.safetensors`, and treating that as agreement let a download
// proceed into a stale tree (codex gate, round 5). Compared as CATEGORY-RELATIVE
// paths, so `a/shared.safetensors` is not accounted for by a live
// `b/shared.safetensors` (codex gate, round 9).
const liveNames = new Set(listing.map((n) => normRel(n)));
const missing = onDisk.filter((n) => !liveNames.has(n));
if (missing.length === 0) {
// This folder is fully accounted for — it AGREES, and that is itself positive
// evidence (see the corroboration note below). Keep the largest such category:
// the more names that had to coincide, the less coincidence explains it.
if (onDisk.length >= CORROBORATING_CONTAINMENT_MIN_FILES &&
onDisk.length > (corroborating?.files ?? 0)) {
corroborating = { category, files: onDisk.length };
}
continue;
}
// Record the FIRST disagreement, but do NOT stop scanning. Evidence that this
// root IS read can only come from the categories after it, and breaking here
// threw it away — see the corroboration note below (#1147, 0.51.56).
if (!worst)
worst = { category, missing, onDisk: onDisk.length, live: listing.length };
}
// #1147 — CORROBORATED READERSHIP OUTWEIGHS A SINGLE UNACCOUNTED FILE.
//
// A reporter's ComfyUI Desktop install was refused an `animatediff_models`
// download because ONE unlisted file under `ipadapter` was read as proof of a
// different install, while that same server listed other categories of that same
// tree file for file. This guard gathered positive evidence in the REFUSE
// direction and discarded the positive evidence it had already computed in the
// PROCEED direction — `missing.length === 0` was a bare `continue`, and the first
// disagreement `break`ed before any later category could speak.
//
// The two facts are not symmetric:
//
// A category the server lists COMPLETELY is evidence about the ROOT. For a
// different install to produce it, EVERY name in that category would have to
// coincide — which is why one file is not enough (two unrelated installs really
// do share `sd_xl_base_1.0.safetensors`; codex gate round 5), and why the
// largest agreeing category is the one worth keeping.
//
// A file the server does not name is evidence about that FILE. It has ordinary
// explanations that say nothing about the root: an extension not registered for
// that category, a listing cached before the file arrived, a nested HuggingFace
// dump (#1147's original birefnet report), a folder a custom node registers
// against a different path.
//
// The #369 signature is a tree the server accounts for NOWHERE — 3 files on disk,
// 24 unrelated files live. That still refuses, here and for an inferred root
// (#1562), because a stale bundle has no fully-agreeing category to offer.
//
// Bounded by DISAGREEMENT_PROBE_CATEGORIES like the disagreement scan itself: a
// tree whose agreeing category sorts past the budget is refused as before.
if (worst && corroborating) {
logger.info(`[models] "${worst.category}" holds ${worst.missing.length} file(s) the server does not ` +
`list, but it lists all ${corroborating.files} file(s) of "${corroborating.category}" ` +
`in this same tree — proceeding (#1147).`);
worst = undefined;
}
if (worst) {
const sample = worst.missing.slice(0, 3).join(", ");
throw new ModelError(`Refusing to download into "${modelsRoot}": the connected ComfyUI ` +
`(${getComfyUIBaseUrl()}) does not read from it. Its "${worst.category}" folder holds ` +
`${worst.onDisk} model file(s), ${worst.missing.length} of which the running server does ` +
`NOT list (e.g. ${sample}) — if it scanned this directory it would see all of them, so ` +
"this is a DIFFERENT install than the one serving you. This destination came from local " +
"configuration (COMFYUI_PATH / the saved default workspace), not from the running server, " +
"which could not tell us its own install root. Point COMFYUI_PATH at the ComfyUI that is " +
"actually running, or launch it with an absolute --base-directory, then retry.");
}
// NOTE — there is deliberately NO 'the server lists models this tree does not contain'
// refusal. That inference is itself absence of evidence: not being able to ACCOUNT
// for the server's models is not proof the destination belongs to a different
// install. The roots could be registered by a config we cannot read, one deleted
// after the server loaded it, or a path shape nobody has enumerated yet — and each
// time that rule was tightened it produced another FALSE REFUSAL of a legitimate
// setup (an external drive, a moved YAML). Per the maintainer ruling, unaccountable
// is a PROCEED-UNVERIFIED state, not a refusal: the download goes through and the
// post-write check reports honestly. This guard refuses only on POSITIVE evidence —
// files sitting in this tree that the running server demonstrably does not read.
}
/** Normalize a listing entry / relative path for comparison (ComfyUI reports
* OS-native separators). */
function normRel(s) {
return s.replace(/\\/g, "/").replace(/^\.?\//, "");
}
/** The category-relative entry a file at `<models>/<targetSubfolder>/<filename>`
* would appear as in ComfyUI's `/models/<category>` listing. */
function listingEntryFor(targetSubfolder, filename) {
const rest = subfolderRemainder(targetSubfolder);
return normRel(rest ? `${rest}/${filename}` : filename);
}
/**
* Categories the live server has registered (`GET /models`). Same inventory
* list_local_models uses to discover ComfyUI-GGUF's clip_gguf / unet_gguf.
* `undefined` = could not be asked.
*/
async function liveRegisteredCategories() {
try {
const res = await comfyApiFetch("/models");
if (!res.ok)
return undefined;
const json = (await res.json());
if (!Array.isArray(json))
return undefined;
return new Set(json
.filter((c) => typeof c === "string" && c.trim() !== "")
.map((c) => c.toLowerCase()));
}
catch {
return undefined;
}
}
/**
* Extra `/models/<cat>` views to probe for a .gguf the core listing cannot name.
* Known ComfyUI-GGUF aliases of `category`, filtered to views the live server
* actually registered. When GET /models itself is unaskable, still probe the
* known aliases so a reachable `/models/clip_gguf` can confirm presence.
*/
async function extraGgufListingCategories(category) {
const views = ggufViewsAliasing(category);
if (views.length === 0)
return [];
const registered = await liveRegisteredCategories();
if (registered === undefined)
return views;
return views.filter((view) => registered.has(view.toLowerCase()));
}
function missIsContractualGgufCore(category, filename) {
if (!isGgufFilename(filename))
return false;
if (isGgufCategory(category))
return false;
return MODEL_SUBDIRS.includes(category.toLowerCase());
}
/**
* Combine per-category listing answers. Any hit is present. A miss in a CORE
* category for a .gguf is contractual (supported_pt_extensions omits it) and is
* not a determined negative — that is #2447's false "does not list it under
* text_encoders". A miss in a registered GGUF view is determined.
*/
async function liveListingMatches(categories, filename, pred) {
let sawDeterminedMiss = false;
for (const cat of categories) {
if (!cat)
continue;
const listing = await liveCategoryListing(cat);
if (listing === undefined)
continue;
if (listing.some(pred))
return true;
if (missIsContractualGgufCore(cat, filename))
continue;
sawDeterminedMiss = true;
}
if (sawDeterminedMiss)
return false;
return undefined;
}
async function listingCategoriesFor(targetSubfolder, filename) {
const category = categoryOf(targetSubfolder);
if (!isGgufFilename(filename))
return [category];
const extra = await extraGgufListingCategories(category);
return extra.length === 0 ? [category] : [category, ...extra.filter((c) => c !== category)];
}
/**
* Was this exact entry ALREADY in the live server's listing before we wrote?
*
* Captured BEFORE the transfer so the post-write check can tell "the server now
* sees the file BECAUSE we wrote it" from "the server already had a file of that
* name somewhere else". Without that distinction a download into a stale tree that
* happens to share a filename with the live install verifies as `visible` and is
* reported as a success (codex gate, round 3). `undefined` = the server could not
* answer, so nothing is known either way.
*
* For `.gguf`, also consults ComfyUI-GGUF's registered `*_gguf` views of the
* same folder (clip_gguf for text_encoders/clip, unet_gguf for unet/
* diffusion_models) — core `/models/<cat>` cannot name those files (#2447).
*/
export async function liveListingHasEntry(targetSubfolder, filename) {
const wanted = listingEntryFor(targetSubfolder, filename);
return liveListingMatches(await listingCategoriesFor(targetSubfolder, filename), filename, (n) => normRel(n) === wanted);
}
/**
* Does the live server list a file with this BASENAME anywhere in the category?
*
* Deliberately looser than `liveListingHasEntry`: the manifest's existing-file
* lookup itself matches a basename anywhere in the served category (a `checkpoints`
* target satisfied by `checkpoints/sdxl/big.safetensors`), so confirming it with an
* exact-path check would FAIL legitimate nested installs. This is used only as an
* ADDITIONAL requirement on the skip path — it can rule a skip out, never in on its
* own. `undefined` = the server could not answer.
*
* For `.gguf`, a hit under the live GGUF alias of this folder counts as present
* (#2447) — apply_manifest's existing-file skip uses this function.
*/
export async function liveListingHasBasename(category, filename) {
const wanted = basename(filename);
return liveListingMatches(await listingCategoriesFor(category, filename), filename, (n) => basename(normRel(n)) === wanted);
}
export async function isUnderLiveModelRoots(absPath,
/** The models CATEGORY the path belongs to. Live extra roots are category-scoped
* (`{ category, dir }`), so a `checkpoints` root must never vouch for a LoRA
* (codex gate, round 6). Omitted = consider every root. */
category) {
/** Canonicalize so a junctioned/symlinked root is compared by where it REALLY
* lives. `C:\ComfyUI\models` being a junction to `D:\Models` is a legitimate,
* supported layout; a lexical-only compare would call a correctly-placed file
* "outside the live roots" and fabricate a failure (codex gate, round 6). */
const canon = async (p) => {
const lex = resolve(p);
try {
return { real: resolve(await realpath(p)), lex, ok: true };
}
catch {
return { real: lex, lex, ok: false }; // not created yet / unreadable
}
};
const target = await canon(absPath);
const under = (root) => {
const hit = (r, t) => t === r || t.startsWith(r + sep);
// Either canonical form matching is enough — only ONE side may be resolvable.
return (hit(root.real, target.real) ||
hit(root.lex, target.lex) ||
hit(root.real, target.lex) ||
hit(root.lex, target.real));
};
let dest;
try {
dest = await resolveModelsDirWithBases();
}
catch {
return { inRoots: undefined };
}
// Same narrowing as the pre-write guard, and for the sharper reason (#369,
// review): this answer authorizes the POST-write "visible" verdict. An INFERRED
// root that is actually stale makes the membership test pass against a root the
// server does not read, and a live listing that names the file for an unrelated
// reason — the user already had that model — then reads as proof the download
// landed somewhere the server can see. That is #369's original symptom exactly:
// reported success, model never appears.
//
// Answering UNKNOWN here costs a correct `observed-root` install its confident
// "visible" and gives it the qualified note instead. That is the right trade:
// the note names the file, the root, and the remedy, whereas the false positive
// is silent and the user discovers it at queue time.
//
// One exception (#369, the 0.52.1 reports): an extra model root is vouched for by
// the server's OWN command line when argv carries an ABSOLUTE
// --extra-model-paths-config — that statement is independent of how the PRIMARY
// root was derived, and a download redirected onto such a root
// (resolveModelSubfolderWithLiveRoot) must be able to confirm here, or the file
// lands exactly where the server reads and is STILL reported unconfirmed. With no
// argv-named config the extra roots may come from the auto-loaded config beside
// an INFERRED main.py — that inherits the inference and vouches for nothing, so
// the early UNKNOWN stands. The unvouched primary still cannot support a
// NEGATIVE answer below: unknown stays unknown, never "outside the live roots".
const primaryNamed = modelsDirNamedByServer(dest.source);
if (!primaryNamed &&
!parseExtraModelPathsConfigsFromArgvRaw(dest.snapshot.argv).some((p) => isAbsolute(p))) {
return { inRoots: undefined };
}
const liveRoot = resolve(dest.modelsDir);
let extra;
try {
extra = await getLiveExtraModelRoots(dest.snapshot);
}
catch {
extra = undefined;
}
const wantCat = (category ?? "").trim().toLowerCase();
// A negative answer is only honest when everything it rests on could actually be
// canonicalized; otherwise say UNKNOWN rather than accuse a correct placement.
let fullyCanonical = target.ok;
if (primaryNamed) {
const primary = await canon(dest.modelsDir);
fullyCanonical = fullyCanonical && primary.ok;
if (under(primary))
return { inRoots: true, liveRoot };
}
if (extra?.authoritative) {
for (const r of extra.roots) {
if (wantCat && String(r.category ?? "").trim().toLowerCase() !== wantCat)
continue;
const rc = await canon(r.dir);
fullyCanonical = fullyCanonical && rc.ok;
if (under(rc))
return { inRoots: true, liveRoot };
}
}
if (!primaryNamed)
return { inRoots: undefined, liveRoot };
if (!extra)
return { inRoots: undefined, liveRoot };
// The live roots are known and this path is in none of them.
if (!extra.authoritative)
return { inRoots: undefined, liveRoot };
return { inRoots: fullyCanonical ? false : undefined, liveRoot };
}
/**
* The models root the connected server reads RIGHT NOW — and ONLY when that answer
* is LIVE-AUTHORITATIVE. A transient `/system_stats` outage makes the resolver fall
* back to COMFYUI_PATH, and reporting THAT as "what the server reads now" would
* downgrade a correctly verified download with a false "a DIFFERENT install"
* warning (codex gate, round 12). Unknown is the honest answer there. Never throws.
*/
/** Do two absolute models roots name the same directory? Windows paths are
* case-insensitive and mix separators, so comparing them raw would report a
* server "change" that never happened. */
export function sameModelsRoot(a, b) {
const norm = (s) => {
const slashed = s.replace(/\\/g, "/").replace(/\/+$/, "");
return platform() === "win32" ? slashed.toLowerCase() : slashed;
};
return norm(a) === norm(b);
}
export async function currentLiveModelsRoot() {
try {
const dest = await resolveModelsDirWithBases();
if (!isLiveAuthoritativeModelsDir(dest.source))
return undefined;
return resolve(dest.modelsDir);
}
catch {
return undefined;
}
}
/**
* Why a destination could not be confirmed, and how the USER can upgrade themselves
* into a verified answer.
*
* Saying "unconfirmed" without saying WHY leaves the user with nothing to act on.
* The running server reported a relative `main.py` with no working directory, so its
* own report is not enough to pin the install, and the root had to come from
* somewhere it never named.
*
* #1587 — this used to assert a CONJUNCTION as the cause: "...AND the process
* listening on the ComfyUI port could not be identified". That second half is often
* FALSE. On ComfyUI Desktop the process is identified — the same session's
* `install_comfyui(action:"environment")` reports `python_probe_trusted:true` with a
* process-table PID — and the root is STILL unpinnable, because an interpreter's
* location is evidence of where the BINARY lives, not of where the server reads
* (see `modelsDirNamedByServer`, and the measured stale-portable-python shape in
* #1374). Telling that user their process could not be identified sends them to fix
* something that is not broken, and "make its process observable" is advice they
* have already followed.
*
* So the reasons are stated as ALTERNATIVES, and the `lsof` gap — real, and worth
* naming on POSIX — is offered as a possibility to check rather than as a finding.
*/
function unverifiableDestinationRemedy() {
const probe = platform() === "win32"
? ""
: " If the process on that port could not be identified at all, that needs `lsof`, " +
"which is missing on many minimal images — installing it makes future downloads " +
"verifiable.";
return ("This happens when the running ComfyUI names a RELATIVE main.py with no working " +
"directory, so its models root has to come from somewhere the server did not name — " +
"local configuration, or the install tree around the interpreter the OS reports for " +
"the process on that port (which locates the BINARY, not the models the server " +
`reads).${probe} To get a definite answer, NAME the root: point COMFYUI_PATH at the ` +
"ComfyUI that is actually running, or launch it with an absolute --base-directory. " +
"Meanwhile, confirm with list_local_models.");
}
/** How many times we re-ask the live server before concluding a landed file is
* invisible to it. ComfyUI caches its folder listings and invalidates on the
* directory mtime, so the first re-read normally already sees the new file; the
* retries only cover a filesystem whose mtime lands a beat late. */
const LIVE_VISIBILITY_ATTEMPTS = 3;
const LIVE_VISIBILITY_RETRY_MS = 1000;
/**
* Ask the CONNECTED server whether it now lists a model a Manager dispatch was
* supposed to fetch (#1086).
*
* `verifyLandedModel` cannot answer this: it stats the local filesystem first and
* returns `unknown` outright in remote mode, because there is no local file to
* stat — which is exactly the case a Manager dispatch creates. But the LISTING
* question is answerable remotely, since `liveListingHasEntry` asks the server.
*
* A reporter lost a multi-GB model to this. ComfyUI-Manager picks its own
* destination root and does not necessarily honour `extra_model_paths`, so their
* file landed in the install's base models directory — an ephemeral 20GB overlay
* — while their ComfyUI read from a 100GB volume. Nothing contradicted the
* "download complete" until a pod restart made the file simply absent.
*
* "not-listed" IS NOT FAILURE, and callers must not render it as one. A Manager
* dispatch returns when the task is ACCEPTED, so a large file is still arriving
* for minutes afterwards, and "not there yet" and "landed somewhere the server
* cannot read" are indistinguishable from here. What this does establish is the
* difference between those two and a CONFIRMED presence, which is the thing the
* caller previously had no way to learn except by asking by hand.
*
* Never throws: a verification hiccup must not turn a transfer into an error.
*/
export async function verifyManagerVisibility(targetSubfolder, filename, opts) {
const probe = opts?.probe ?? liveListingHasEntry;
// It was ALREADY listed before the download, so the listing cannot attribute
// what it sees to this dispatch — a pre-existing file of the same name would
// read as a successful landing (the same trap verifyLandedModel's `listedBefore`
// guards on the local path).
if (opts?.listedBefore === true) {
return {
visibility: "unknown",
note: `The connected ComfyUI already listed "${filename}" in ${targetSubfolder} BEFORE this ` +
`dispatch, so its presence now does not establish that this download landed — it may ` +
`still be the older file. Compare the file size or hash on the server if that matters.`,
};
}
// #1374 review, P1-1 — AN UNKNOWN BASELINE IS NOT A NEGATIVE ONE.
//
// `listedBefore === false` is the ONLY answer that lets a listing seen now be
// credited to this dispatch. Anything else — an explicit `"unknown"`, or an
// omitted option — means nobody established what the server listed beforehand,
// so a hit here is equally consistent with a file that was always there. The
// previous shape checked only `=== true`, which folded "we could not find out"
// into "it was not there" and re-opened the #369 trap on the one route that
// had never had a guard against it.
//
// This is NOT short-circuited like the `true` case: a NEGATIVE answer is still
// worth reporting (and is the #1374 finding), and an unaskable server is still
// `unknown`. Only the POSITIVE direction is withheld.
const baselineIsNegative = opts?.listedBefore === false;
const attempts = Math.max(1, opts?.attempts ?? LIVE_VISIBILITY_ATTEMPTS);
const retryMs = opts?.retryMs ?? LIVE_VISIBILITY_RETRY_MS;
let asked = false;
for (let i = 0; i < attempts; i++) {
let has;
try {
has = await probe(targetSubfolder, filename);
}
catch {
has = undefined; // never throws outward — see the docblock
}
if (has === true && !baselineIsNegative) {
return {
visibility: "unknown",
note: `The connected ComfyUI lists "${filename}" in ${targetSubfolder} now, but whether it ` +
`ALREADY listed that name before this dispatch was never established, so the listing ` +
`cannot be attributed to this download — it may be a pre-existing file of the same ` +
`name. Compare the file size or hash on the server if that matters.`,
};
}
if (has === true) {
return {
visibility: "visible",
note: `The connected ComfyUI now lists ${targetSubfolder}/${filename}, so the dispatch ` +
`landed somewhere it reads. That is PLACEMENT, not validity: a listing proves a file ` +
`of that NAME exists, and Manager writes whatever the URL returned under the name you ` +
`asked for. #473 is exactly this — a CivitAI login page saved as a .safetensors, ` +
`listed happily, and only discovered when LoraLoader failed to deserialize it. ` +
`Manager cannot carry this MCP's credentials, so an auth-gated URL is the case to ` +
`distrust: if the file is implausibly small for its kind (a login page is ~10KB), ` +
`treat it as failed and re-fetch it locally with COMFYUI_PATH set.`,
};
}
if (has === false)
asked = true;
if (i < attempts - 1)
await new Promise((r) => setTimeout(r, retryMs));
}
if (!asked) {
return {
visibility: "unknown",
note: `The connected ComfyUI could not be asked whether it lists ${targetSubfolder}/${filename}, ` +
`so nothing about this dispatch's destination was established.`,
};
}
return {
visibility: "not-listed",
note: `The connected ComfyUI does NOT list ${targetSubfolder}/${filename}. That is not proof of ` +
`failure — a Manager dispatch returns when the task is ACCEPTED, so a large file may still ` +
`be arriving. But if it does not appear shortly, the file landed somewhere this server does ` +
`not read: ComfyUI-Manager picks its own destination root and does not necessarily honour ` +
`extra_model_paths, which on a container is commonly an ephemeral overlay that loses the ` +
`file on restart. Re-check with list_local_models, and check free space on the install's ` +
`own models directory rather than your configured model root.`,
};
}
/**
* Confirm, AFTER a download lands, that the bytes are (a) really on disk and
* (b) somewhere the LIVE server reads from — then report the path we actually
* confirmed. Reporting the INTENDED path is what made #369 a fabricated success:
* `download_model action:"status"` said "done … landed at <stale install>" while the running
* ComfyUI could not see the file at all.
*
* Never throws: a completed transfer must not be turned into a failure by a
* verification hiccup. An inconclusive answer is reported as `unknown` with the
* reason, which callers surface instead of an unqualified success.
*/
export async function verifyLandedModel(targetPath, targetSubfolder, opts) {
let verifiedPath;
try {
const info = await stat(targetPath);
if (!info.isFile()) {
return {
liveVisible: "unknown",
note: `${targetPath} exists but is not a file.`,
};
}
try {
verifiedPath = resolve(await realpath(targetPath));
}
catch {
verifiedPath = resolve(targetPath);
}
}
catch {
return {
liveVisible: "unknown",
note: `The file could not be confirmed on disk at ${targetPath}.`,
};
}
if (isRemoteMode()) {
return {
verifiedPath,
liveVisible: "unknown",
note: "The connected ComfyUI is remote, so local on-disk placement cannot be checked against it.",
};
}
const category = categoryOf(targetSubfolder);
// Match the FULL path the server would report relative to the category, not the
// bare filename: `/models/loras` lists nested entries as "a/foo.safetensors", so a
// basename comparison would call a download into `loras/a` "visible" because the
// LIVE tree happens to hold an unrelated `loras/b/foo.safetensors` — a direct
// false success (codex gate, round 2). Separators are normalized because ComfyUI
// reports OS-native ones.
const wanted = listingEntryFor(targetSubfolder, basename(targetPath));
const attempts = opts?.attempts ?? LIVE_VISIBILITY_ATTEMPTS;
const retryMs = opts?.retryMs ?? LIVE_VISIBILITY_RETRY_MS;
// Is the directory we wrote into one the RUNNING server told us about? Only then
// does "the server lists this name" prove that the bytes we just wrote are the
// ones it will load. On a locally-configured root a pre-existing entry of the
// same name in the LIVE tree would otherwise verify our write into a STALE tree
// as a success (codex gate, round 3) — so a name that was already listed BEFORE
// the download proves nothing there.
// Is the file inside a tree the RUNNING server actually reads? `true` makes the
// listing decisive; `false` means it demonstrably is not (a ComfyUI restart onto
// a DIFFERENT install between the write and this check produces exactly that —
// codex gate, round 4); `undefined` means only local configuration could answer,
// so a pre-existing listing entry proves nothing about OUR bytes.
const landed = verifiedPath ?? resolve(targetPath);
// Membership is checked on the LEXICAL write path, not the realpath'd landed
// path: the server scans `<modelsRoot>/<category>` RECURSIVELY FOLLOWING LINKS
// (folder_paths.recursive_search walks with followlinks=True), so "this file
// sits at a path the server walks" is answered by the path as written. Checking
// the realpath instead called a file landed through an in-tree junction —
// `models/vae -> E:\StabilityMatrix\models\VAE` (#870), or a nested link like
// `models/vae/vendor -> ...` — "outside every directory the server reads", a
// false not-visible for a file the server demonstrably lists. The canonical
// forms inside still cover a junctioned ROOT (compared by where it really
// lives), and a host path that merely aliases a container-side root was already
// vouched lexically whenever no link was involved — this extends that existing,
// accepted exposure to linked paths rather than adding a new one.
const membership = await isUnderLiveModelRoots(resolve(targetPath), category);
const destinationIsLiveAuthoritative = membership.inRoots === true;
if (membership.inRoots === false) {
const liveRoot = membership.liveRoot;
return {
verifiedPath,
verifiedAgainstRoot: liveRoot,
liveVisible: "not-visible",
note: `The file IS on disk at ${landed}, but that is OUTSIDE every directory the ` +
`connected ComfyUI (${getComfyUIBaseUrl()}) reads models from` +
(liveRoot ? ` (its models directory is ${liveRoot})` : "") +
". This happens when the running server is replaced by a different install " +
"while a download is in flight. Move the file into the running server's models " +
"tree, or re-download now that the correct server is connected.",
};
}
// A listing hit only PROVES something about the bytes we just wrote when the
// directory we wrote to is one the RUNNING server told us it reads. On any other
// destination the listing describes a tree we cannot tie to this write:
// - the name may have been there all along (round 3);
// - the pre-write listing may have been unavailable, so "it appeared" is not
// established (round 14);
// - the server answering AFTER the write may not even be the one that answered
// BEFORE it, and its own same-named model then masquerades as ours (round 17).
// Each of those was a separate route to a fabricated success, and every fix that
// stopped short of this rule left another. So: a non-authoritative destination is
// reported HONESTLY as unconfirmed. It costs a noisier message on hosts where the
// live root cannot be pinned; it never refuses, never fails, and never lies.
const ambiguous = !destinationIsLiveAuthoritative;
// A category whose listing contractually contains NO files (`diffusers`, #844)
// can neither confirm nor deny a landed FILE — its absence from the listing is
// the contract speaking, not the server. Without this branch the loop below
// would read that contractual absence as "the server does NOT list it" — the
// same could-not-determine → determined-not fold as the pre-write refusal,
// pointed at the user whose download just succeeded.
if (categoryCannotEnumerateFiles(category)) {
return {
verifiedPath,
liveVisible: "unknown",
note: `The file IS on disk at ${verifiedPath}, but the connected ComfyUI ` +
`(${getComfyUIBaseUrl()}) cannot confirm it from its model listing: ComfyUI ` +
`never lists individual files under "${category}" (it loads a "${category}" ` +
"model as a whole directory, and the endpoint's listing is empty by design), " +
"so absence there is contractual, not a mismatch. Confirm the model's " +
"directory is complete on disk.",
};
}
const listingCats = await listingCategoriesFor(targetSubfolder, basename(targetPath));
let sawListing = false;
for (let i = 0; i < attempts; i++) {
for (const cat of listingCats) {
const listing = await liveCategoryListing(cat);
if (listing === undefined)
continue;
sawListing = true;
if (!listing.some((n) => normRel(n) === wanted))
continue;
// RE-CHECK membership AFTER the listing. The root check and the listing are
// two separate observations; a ComfyUI restart onto a DIFFERENT install
// between them would otherwise let the NEW server's own same-named model
// confirm OUR file, which that server cannot read (codex gate, round 11).
const still = await isUnderLiveModelRoots(resolve(targetPath), category);
if (still.inRoots === false) {
return {
verifiedPath,
// Stamp the root THIS check ran against — never a third, later
// observation, which could name yet another server (codex gate, r12).
verifiedAgainstRoot: still.liveRoot,
liveVisible: "not-visible",
note: `The file IS on disk at ${landed}, but the connected ComfyUI ` +
`(${getComfyUIBaseUrl()}) changed while this was being checked and no longer ` +
"reads from that location — the entry it lists is its OWN file of the same name. " +
"Re-download now that the correct server is connected.",
};
}
if (!ambiguous) {
return {
verifiedPath,
// The root the POST-listing membership check just validated against —
// taking a fresh observation here could stamp a server that replaced it
// between the two awaits (codex gate, round 12).
verifiedAgainstRoot: still.liveRoot ?? membership.liveRoot,
liveVisible: "visible",
};
}
return {
verifiedPath,
liveVisible: "unknown",
note: `The connected ComfyUI (${getComfyUIBaseUrl()}) lists "${wanted}" under "${cat}", ` +
"but this destination was not named by the running server — it came from local " +
"configuration, or was inferred from where the server's interpreter lives — so that " +
"listing cannot be tied to the file just written to " +
`${verifiedPath} — it may be the server's own copy elsewhere` +
(opts?.listedBefore === true
? " (it already listed that name before this download)"
: opts?.listedBefore === undefined
? " (whether it listed that name before this download could not be checked)"
: "") +
". " +
unverifiableDestinationRemedy(),
};
}
if (i < attempts - 1 && retryMs > 0) {
await new Promise((r) => setTimeout(r, retryMs));
}
}
if (!sawListing) {
// #2480 — /models/unet 404s on current ComfyUI even when extra_model_paths
// and UnetLoaderGGUF still use that folder. Ask /object_info before treating
// the REST miss as "could not confirm".
const comboListed = await objectInfoListsFilename(basename(targetPath));
if (comboListed === true) {
const still = await isUnderLiveModelRoots(resolve(targetPath), category);
if (still.inRoots === false) {
return {
verifiedPath,
verifiedAgainstRoot: still.liveRoot,
liveVisible: "not-visible",
note: `The file IS on disk at ${landed}, but the connected ComfyUI ` +
`(${getComfyUIBaseUrl()}) changed while this was being checked and no longer ` +
"reads from that location — the entry it lists is its OWN file of the same name. " +
"Re-download now that the correct server is connected.",
};
}
if (!ambiguous) {
return {
verifiedPath,
verifiedAgainstRoot: still.liveRoot ?? membership.liveRoot,
liveVisible: "visible",
note: `The connected ComfyUI (${getComfyUIBaseUrl()}) answered 404 for /models/${category}, ` +
`but /object_info lists "${wanted}" on a loader combo, so a custom loader can select it.`,
};
}
return {
verifiedPath,
liveVisible: "unknown",
note: `The connected ComfyUI (${getComfyUIBaseUrl()}) lists "${wanted}" on a loader combo, ` +
"but this destination was not named by the running server — it came from local " +
"configuration, or was inferred from where the server's interpreter lives — so that " +
"listing cannot be tied to the file just written to " +
`${verifiedPath} — it may be the server's own copy elsewhere` +
(opts?.listedBefore === true
? " (it already listed that name before this download)"
: opts?.listedBefore === undefined
? " (whether it listed that name before this download could not be checked)"
: "") +
". " +
unverifiableDestinationRemedy(),
};
}
return {
verifiedPath,
liveVisible: "unknown",
note: category.toLowerCase() === "unet"
? `The file IS on disk at ${verifiedPath}. The connected ComfyUI answered 404 for ` +
`/models/unet, which does not prove the file is missing or that it lives under ` +
`diffusion_models. extra_model_paths and custom loaders (UnetLoaderGGUF) can still ` +
`use a configured unet directory. Check list_local_models (action:"list_paths") ` +
`for unet roots, or list with no model_type to see unet_gguf.`
: `The connected ComfyUI (${getComfyUIBaseUrl()}) did not answer for the ` +
`"${category}" model folder, so it could not be confirmed that it reads from this location.`,
};
}
// A CORE category's listing enumerates only ComfyUI's own weight extensions
// (folder_paths.supported_pt_extensions — mirrored above as CORE_MODEL_EXTENSIONS).
// A landed file of any OTHER type (.gguf, .onnx, .engine, …) is served, if at all,
// through a custom node's OWN registered category (ComfyUI-GGUF's unet_gguf /
// clip_gguf views, #526), so its absence from THIS listing is contractual — the
// #844 diffusers fold one level down: not the category but the FILE'S EXTENSION is
// outside the enumeration contract. A listing HIT above still confirmed the file
// (a node may re-register the core category to admit the type); only the MISS is
// inconclusive, and it must not render as "the server does NOT list it". The
// 0.51.56 #369 report was exactly that fold: a correctly-placed .gguf answered
// with WARNING: NOT VISIBLE and a "move the file" remedy for a file that was
// already where the server reads.
const ext = extname(basename(targetPath)).toLowerCase();
const isCoreCategory = MODEL_SUBDIRS.includes(category.toLowerCase());
if (isCoreCategory && !CORE_MODEL_EXTENSIONS.has(ext)) {
return {
verifiedPath,
liveVisible: "unknown",
note: `The file IS on disk at ${verifiedPath}, but the connected ComfyUI ` +
`(${getComfyUIBaseUrl()}) cannot confirm it from its "${category}" listing: that ` +
`endpoint enumerates only ComfyUI's core weight extensions ` +
`(${[...CORE_MODEL_EXTENSIONS].join(", ")}), and "${ext || "(none)"}" is not one ` +
`of them. A file of this type is served through a custom node's own registered ` +
`category instead (for .gguf, ComfyUI-GGUF's "unet_gguf"/"clip_gguf" views), so ` +
`its ABSENCE here is contractual, not evidence of misplacement. Confirm with ` +
`list_local_models, which reads those categories too — and if no installed node ` +
`registers a category serving this file type, this server cannot load it at all.`,
};
}
// #369 (0.52.1 recurrence) — RESOLVE THE ROOT **WITH** ITS PROVENANCE.
//
// This used to call `resolveModelsDir()`, which reports the models dir whatever
// its `source` — including `configured-base`, the one value a reachable server
// never vouched for. The verdict below then compared the landed path against it
// and, finding the file inside, told the user the connected ComfyUI READS that
// directory. But that root is the very one this download chose, so the file is
// inside it BY CONSTRUCTION: the comparison is true for a correct destination and
// for a stale second install alike, and cannot tell them apart. On the reporter's
// macOS Comfy Desktop pair (two installs under ~/ComfyUI-Installs, the live one
// reached through extra_model_paths) that produced "it is in the right place …
// Do NOT move the file" for ~25 GB written where the running server never looks.
//
// So the SOURCE travels with the path, and only a server-NAMED root licenses the
// claim. `modelsDirNamedByServer` — not `isLiveAuthoritativeModelsDir` — because
// an `observed-root` is INFERRED from where the interpreter lives and is exactly
// the inference that lands on the wrong install (#1562).
let liveModelsDir;
let liveModelsDirNamedByServer = false;
try {
const dest = await resolveModelsDirWithBases();
liveModelsDir = dest.modelsDir;
liveModelsDirNamedByServer = modelsDirNamedByServer(dest.source);
}
catch {
liveModelsDir = undefined;
liveModelsDirNamedByServer = false;
}
return {
verifiedPath,
...notVisibleVerdict({
verifiedPath,
liveModelsDir,
liveModelsDirNamedByServer,
wanted,
category,
baseUrl: getComfyUIBaseUrl(),
// The membership answer above was computed on the LEXICAL write path and is
// canonical-aware: a destination reached through an in-tree junction
// (StabilityMatrix, #870) realpaths OUTSIDE the lexical live root, so the
// verdict's own path comparison would call a correctly-placed file
// misplaced and prescribe moving it (#369, 0.51.56).
knownInsideLiveRoots: membership.inRoots === true,
}),
};
}
/**
* The verdict for a file that is on disk but absent from the live listing (#1131).
*
* "Not listed" has THREE causes, and the first two take OPPOSITE remedies:
*
* INSIDE a root the SERVER NAMED — not misplaced. The server simply has not
* re-read that folder yet: ComfyUI caches its loader option lists and
* invalidates them on the directory's mtime, so a check this soon after the
* write routinely races it. Telling that user to "move the file into the
* running server's models tree" names a directory the file is ALREADY in — a
* remedy that cannot be followed, which is what #1131's reporter received.
*
* OUTSIDE that root — genuinely in the wrong place. Move it.
*
* INSIDE a root NOBODY VOUCHED FOR — unknown, and it must be SAID (#369, the
* 0.52.1 recurrence). This third state used to be folded into the first,
* because the containment test ran against whatever `resolveModelsDir()`
* returned — including a `configured-base`/`observed-root` value the running
* server never named. That root is the one the download itself resolved to, so
* the landed file is inside it BY CONSTRUCTION and the test answers "inside"
* for a correct destination and a stale second install alike. It is the same
* shape as #1603: two states, one indistinguishable answer, and no way to say
* "unknown". The reporter's macOS Comfy Desktop pair got "so it is in the right
* place … Do NOT move the file" for ~25 GB written into an install the running
* server (reached through extra_model_paths) never reads.
*
* The unverified branch is deliberately NOT the move remedy either: refusing to
* assert readership is not evidence of misplacement, and #1131's harm was exactly
* an unfollowable move instruction. It states both candidates, orders them by
* likelihood, and names the ONE check that separates them.
*
* The DECISION lives here rather than at the call site so it is covered by the
* same tests as the wording; a branch chosen upstream and passed in as a boolean
* would be exactly the untested wiring this repo keeps getting caught by.
*/
function parseablePanelVersion(raw) {
const trimmed = raw?.trim();
return trimmed && SEMVER_RE.test(trimmed) ? trimmed : undefined;
}
/**
* Lowest parseable advertised hello version among connected panel tabs, plus
* the on-disk pack version when that can be confirmed. Used only to word a
* followable refresh — never to invent a listing.
*/
function observeLivePanelBundle() {
let liveVersion;
try {
for (const raw of getUiBridge()?.advertisedPanelVersions() ?? []) {
const version = parseablePanelVersion(raw);
if (!version)
continue;
if (!liveVersion || compareSemver(version, liveVersion) < 0)
liveVersion = version;
}
// unknown-ok: a missing or throwing bridge is an unobserved bundle, not a listing.
}
catch {
liveVersion = undefined;
}
return { liveVersion, installedVersion: parseablePanelVersion(verifiedPanelDiskVersion()) };
}
/**
* How to refresh loader options after a download is on disk but not listed (#2876).
*
* `install_comfyui` has no `refresh_nodes` action. `panel_refresh_nodes` is named
* only when the live tab is on a bundle that can run it. A tab behind the
* installed pack is told to hard-refresh first.
*/
export function staleListingRefreshRemedy(bundle = {}) {
const live = parseablePanelVersion(bundle.liveVersion);
const installed = parseablePanelVersion(bundle.installedVersion);
const refreshMin = minPanelVersionForCmd("refresh_nodes");
const recheck = "then re-check with list_local_models";
if (live && installed && compareSemver(live, installed) < 0) {
return (`Hard-refresh the ComfyUI browser tab first (Ctrl+Shift+R) — this tab is running ` +
`panel ${live} while the installed pack is ${installed}, so the live bundle cannot ` +
`execute panel_refresh_nodes yet. After the tab loads the installed pack, call ` +
`panel_refresh_nodes, or restart ComfyUI, ${recheck}.`);
}
if (live && SEMVER_RE.test(refreshMin) && compareSemver(live, refreshMin) >= 0) {
return (`Refresh the node/model definitions — call panel_refresh_nodes — or restart ` +
`ComfyUI, ${recheck}.`);
}
return (`Refresh the node/model definitions — hard-refresh the ComfyUI browser tab ` +
`(Ctrl+Shift+R), or restart ComfyUI — ${recheck}.`);
}
export function notVisibleVerdict(args) {
const { verifiedPath, liveModelsDir, wanted, category, baseUrl } = args;
const refreshRemedy = staleListingRefreshRemedy("panelBundle" in args ? (args.panelBundle ?? {}) : observeLivePanelBundle());
const insideLexically = liveModelsDir !== undefined && isUnderRoot(verifiedPath, liveModelsDir);
const rootIsServerNamed = args.liveModelsDirNamedByServer === true;
// Containment ALONE is not readership. It counts only when the root it is
// measured against came from the server (`rootIsServerNamed`), or when the
// caller's canonical membership answer — which applies that same narrowing
// itself, and additionally covers live extra roots and in-tree junctions —
// already said yes.
const insideLiveRoot = (insideLexically && rootIsServerNamed) || args.knownInsideLiveRoots === true;
// On disk under the root we resolved, with nothing establishing that the server
// reads it. Neither remedy is earned; say so, and name the check that decides.
const insideUnverifiedRoot = insideLexically && !insideLiveRoot;
if (insideUnverifiedRoot) {
return {
liveVisible: "not-visible",
note: `The file IS on disk at ${verifiedPath}, inside ${liveModelsDir} — the models root this ` +
`download resolved to. Whether the connected ComfyUI (${baseUrl}) READS that root is ` +
`UNCONFIRMED: the running server never named its own models directory, so this root came ` +
`from local configuration (COMFYUI_PATH / the saved default workspace) or was inferred ` +
`from where the server's interpreter lives — and the file is inside it either way, ` +
`because that is the root the download picked. That server does not list "${wanted}" ` +
`under "${category}".\n\n` +
`TWO very different things produce exactly this, and only one is a problem:\n` +
` 1. a STALE LISTING — ComfyUI caches its loader options and invalidates them on the ` +
`directory's mtime, so a check this soon after a write routinely races it; or\n` +
` 2. a DIFFERENT INSTALL — the bytes are in a models tree the running server never ` +
`scans, which is #369 and is how multi-GB downloads get reported as landed and are ` +
`never seen again.\n\n` +
`Do not move it on the strength of this message alone, and do not treat it as placed ` +
`either. SEPARATE THE TWO: ${refreshRemedy} ` +
`If it STILL does not appear, it is case 2: list_local_models (action:"list_paths") ` +
`reports the roots the running server actually reads — move the file into the one for ` +
`"${category}". To make future downloads verifiable, point COMFYUI_PATH at the install ` +
`that is actually running, or launch it with an absolute --base-directory.`,
};
}
return {
// Still not VISIBLE — we did not observe it in the listing, and #369 exists
// because an unobserved placement must not render as confirmed. The verdict
// is unchanged; what changes is the explanation and the remedy.
liveVisible: "not-visible",
note: insideLiveRoot
? // The lexical phrasing NAMES the directory as one the server reads, so it may
// only be used when the SERVER named that directory. Otherwise the membership
// answer is what vouched for the placement (an extra root / a junction), and
// the wording that does not attribute the root is the honest one.
(insideLexically && rootIsServerNamed
? `The file IS on disk at ${verifiedPath}, which is INSIDE the models directory the ` +
`connected ComfyUI reads (${liveModelsDir}) — so it is in the right place. `
: `The file IS on disk at ${verifiedPath}, which is inside a models tree the ` +
`connected ComfyUI reads — reached through a link/junction` +
`${liveModelsDir ? ` under ${liveModelsDir}` : ""}, so its real path lying ` +
`outside that directory is the layout working as intended, not a misplacement. `) +
`That server does not list "${wanted}" under "${category}" YET, which almost always ` +
`means its cached loader options have not been re-read since the write (ComfyUI ` +
`invalidates them on the directory's mtime). Do NOT move the file. ${refreshRemedy}`
: `The file IS on disk at ${verifiedPath}, but the connected ComfyUI ` +
`(${baseUrl}) does NOT list "${wanted}" under "${category}" — it will not be ` +
`usable in a workflow from there.` +
// Same rule as above: name it as WHAT THE SERVER READS only when the server
// named it. An unvouched root is still worth printing — it is where the
// bytes are — but as the destination this download resolved to, not as a
// fact about the server (#369).
(liveModelsDir
? rootIsServerNamed
? ` The models directory that server reads is ${liveModelsDir}.`
: ` This download resolved its destination root to ${liveModelsDir} from local ` +
`configuration — the running server never named its own models directory, so that ` +
`is not a confirmed answer for where the file belongs; list_local_models ` +
`(action:"list_paths") reports the roots it actually reads.`
: "") +
" Move the file into the running server's models tree (or point COMFYUI_PATH at that install and re-download).",
};
}
/**
* Is `file` inside `root`? Compared on NORMALIZED paths (#1131).
*
* Windows mixes separators and is case-insensitive, so `C:\ComfyUI\models` and
* `c:/comfyui/models` name the same directory — comparing raw strings would call
* a correctly-placed file misplaced and print the "move it there" remedy for a
* file already there. The boundary check requires a separator so `…/models2`
* never counts as inside `…/models`.
*/
export function isUnderRoot(file, root, platform = process.platform) {
const norm = (s) => {
const slashed = s.replace(/\\/g, "/").replace(/\/+$/, "");
return platform === "win32" ? slashed.toLowerCase() : slashed;
};
const f = norm(file);
const r = norm(root);
return f === r || f.startsWith(`${r}/`);
}
/** YAML / extra-paths key for a generic models tree (not a per-category folder). */
const GENERIC_MODELS_CATEGORY = "models";
function rootsEqual(a, b) {
return isUnderRoot(a, b) && isUnderRoot(b, a);
}
function isCodeExtraCategory(category) {
return NON_MODEL_EXTRA_CATEGORIES.has(category.trim().toLowerCase());
}
/**
* Roots a download may land in: the primary models dir this resolution already
* computed, plus every extra-model directory list_paths / the resolver can name.
* `custom_nodes` is never a download destination. Does not invent paths.
*/
async function listKnownDownloadModelRoots(primaryModelsDir, snapshot) {
const known = [
{ dir: resolve(primaryModelsDir), category: GENERIC_MODELS_CATEGORY },
];
const seen = new Set([`${known[0].dir}\0${known[0].category}`]);
const add = (dir, category) => {
const cat = category.trim();
if (!cat || isCodeExtraCategory(cat))
return;
const resolved = resolve(dir);
const key = `${resolved}\0${cat}`;
if (seen.has(key))
return;
seen.add(key);
known.push({ dir: resolved, category: cat });
};
try {
// Request-local: each download builds its own list. Extra roots include
// category dirs AND each group's proven base_path as a generic models
// root, so parallel download_civitai calls share the same list_paths
// model_root (#2787). Live extras use THIS snapshot — never a guessed :8188.
for (const er of await getExtraModelRoots())
add(er.dir, er.category);
}
catch {
// Extra roots are additive; an unreadable config just contributes none.
}
if (snapshot.reachable && !isRemoteMode()) {
try {
const live = await getLiveExtraModelRoots(snapshot);
for (const er of live.roots)
add(er.dir, er.category);
}
catch {
// Live extras are optional; static/list_paths roots still constrain the selector.
}
}
return known;
}
function formatKnownDownloadRoots(roots) {
const unique = new Map();
for (const r of roots) {
if (!unique.has(r.dir))
unique.set(r.dir, r);
}
return [...unique.values()].map((r) => `${r.dir} (${r.category})`).join(", ");
}
/**
* Bind a caller-selected absolute model_root to a write destination. The path
* must already be one of the known roots (primary models/ or an extra root from
* list_paths / the resolver). Generic `models` roots take the full subfolder;
* a category extra root must match the target category and takes only the
* remainder. Invented paths are refused.
*/
function applySelectedModelRoot(modelRoot, normalizedSub, rawSub, known) {
const raw = modelRoot.trim();
if (!isAbsolute(raw) && !/^[a-zA-Z]:[\\/]/.test(raw) && !raw.startsWith("\\\\")) {
throw new ModelError(`model_root must be an absolute directory already listed by list_local_models ` +
`action:"list_paths", not relative: ${raw}`);
}
const selected = resolve(raw);
const matches = known.filter((k) => rootsEqual(k.dir, selected));
if (matches.length === 0) {
throw new ModelError(`model_root is not a known model root: ${raw}. Known roots (from ` +
`list_local_models action:"list_paths" / the resolver): ` +
`${formatKnownDownloadRoots(known)}. Do not invent a path.`);
}
const category = categoryOf(normalizedSub).toLowerCase();
const remainder = subfolderRemainder(normalizedSub);
const catMatch = matches.find((m) => m.category.trim().toLowerCase() === category);
const genericMatch = matches.find((m) => m.category.trim().toLowerCase() === GENERIC_MODELS_CATEGORY);
const chosen = catMatch ?? genericMatch;
if (!chosen) {
const served = matches.map((m) => m.category).join(", ");
throw new ModelError(`model_root ${selected} serves categor${matches.length === 1 ? "y" : "ies"} ` +
`"${served}", not "${category}". Pick a known root for "${category}" ` +
`(or a generic models root) from list_local_models action:"list_paths".`);
}
const modelsRoot = chosen.dir;
const targetDir = chosen.category.trim().toLowerCase() === GENERIC_MODELS_CATEGORY
? resolve(modelsRoot, rawSub)
: remainder
? resolve(modelsRoot, remainder)
: modelsRoot;
if (targetDir !== modelsRoot && !targetDir.startsWith(modelsRoot + sep)) {
throw new ModelError(`Refusing to write outside the models directory: ${rawSub}`);
}
return { modelsRoot, targetDir };
}
/**
* Like resolveModelSubfolder, but roots the destination at the CONNECTED
* server's real models directory (its `--base-directory`/models, read from
* /system_stats argv) rather than blindly at `<COMFYUI_PATH>/models`. On a
* ComfyUI Desktop install those diverge, so a download rooted at COMFYUI_PATH
* lands somewhere the live server never reads — reported success, model
* invisible (issues #346/#369). Falls back to `<COMFYUI_PATH>/models` when the
* server is unreachable or was not launched with `--base-directory`. Applies the
* same containment guard as the sync variant. `modelRoot` is an optional
* explicit extra/primary root already known from list_paths (#2499).
*/
export async function resolveModelSubfolderPreferServer(targetSubfolder, modelRoot) {
return (await resolveModelSubfolderWithLiveRoot(targetSubfolder, modelRoot)).targetDir;
}
/**
* As `resolveModelSubfolderPreferServer`, but also returns the LIVE models root that
* THIS resolution used — when it was live-authoritative.
*
* The writer must bind its destination to the server the destination was computed
* for. Taking a separate observation afterwards leaves a window in which the server
* is replaced BETWEEN the resolution and the capture, so both later observations
* agree with each other while the target still points into the previous install
* (codex gate, round 18). Returning it from the same call closes that window.
*/
export async function resolveModelSubfolderWithLiveRoot(targetSubfolder, modelRoot) {
const raw = (targetSubfolder ?? "").trim();
if (!raw) {
throw new ModelError("target_subfolder is required (e.g. 'loras', 'checkpoints').");
}
if (isAbsolute(raw)) {
throw new ModelError(`target_subfolder must be relative to models/, not absolute: ${raw}`);
}
// ONE /system_stats call yields the models dir, the base-install dirs the
// code-root veto needs, AND the snapshot the escape-authorizer uses — so they can
// never disagree (a second stats call could straddle a server restart and combine
// model roots from server B with code bases from server A; #633 codex).
// The category is passed so the #851 inventory rescue can corroborate the folder this
// download actually writes into. A sibling category's match would not establish it.
//
// Derived from the NORMALIZED subfolder, not the raw string: `target_subfolder` accepts
// equivalent spellings, and `loras/../checkpoints` writes to `checkpoints` while its
// raw first segment is `loras`. Taking the raw one would corroborate `loras`, whose
// listing may well match, and authorize a `checkpoints` destination nothing vouched
// for — the sibling-category hole re-opened through a spelling. An escaping form gets
// an empty category (so no rescue can happen); the containment check just below is
// what refuses it.
const normalizedSub = normalize(raw);
const escapes = normalizedSub === ".." ||
normalizedSub.startsWith(`..${sep}`) ||
normalizedSub.startsWith("../");
const { modelsDir, baseDirs, snapshot, source } = await resolveModelsDirWithBases({
targetCategory: escapes ? "" : categoryOf(normalizedSub),
});
let modelsRoot = resolve(modelsDir);
let targetDir = resolve(modelsRoot, raw);
if (targetDir !== modelsRoot && !targetDir.startsWith(modelsRoot + sep)) {
throw new ModelError(`Refusing to write outside the models directory: ${raw}`);
}
const primaryRoot = modelsRoot;
// #2499 — an explicit model_root, constrained to roots list_paths / the resolver
// already know, lets a download land in a configured extra-model tree even when
// the live server is unreachable (the auto-redirect below requires snapshot.reachable
// AND an absolute --extra-model-paths-config). Invented paths are refused.
const selectedRoot = (modelRoot ?? "").trim();
// #369 (the 0.52.1 reports) — a root the SERVER NAMED beats our best inference.
//
// When the primary root came from local configuration or from an INFERRED live
// root, every guard below can do no better than fail OPEN on an empty tree
// (#1147's deliberate trade: an empty listing contradicts nothing), so a stale
// second install received the write whenever its evidence was silent — the
// 0.52.1 reports had gigabytes land in the non-running install while the
// connected Desktop server's models lived in the shared root its
// --extra-model-paths-config declares (the very root list_paths shows).
//
// That config flag is the server's OWN command line naming a root it reads this
// category from — a statement, not an inference — so it outranks the unvouched
// primary and the download goes THERE instead. The gate is deliberately the
// exact evidence: an ABSOLUTE --extra-model-paths-config in argv (a relative
// one cannot be located from this process and fails closed, per extra-paths),
// an AUTHORITATIVE read of it, and a MODEL category (never custom_nodes — a
// download must not become a Python import). Anything less keeps the existing
// resolution and its corroboration guards untouched.
const serverNamedPrimary = modelsDirNamedByServer(source);
let redirectedToExtraRoot = false;
if (selectedRoot) {
const known = await listKnownDownloadModelRoots(primaryRoot, snapshot);
const applied = applySelectedModelRoot(selectedRoot, normalizedSub, raw, known);
modelsRoot = applied.modelsRoot;
targetDir = applied.targetDir;
redirectedToExtraRoot = !rootsEqual(modelsRoot, primaryRoot);
}
else if (!serverNamedPrimary && snapshot.reachable && !isRemoteMode()) {
const category = categoryOf(normalizedSub).toLowerCase();
const argvNamesConfig = parseExtraModelPathsConfigsFromArgvRaw(snapshot.argv).some((p) => isAbsolute(p));
if (argvNamesConfig && category && !NON_MODEL_EXTRA_CATEGORIES.has(category)) {
const extra = await getLiveExtraModelRoots(snapshot);
const namedRoot = extra.authoritative
? extra.roots.find((r) => r.category.trim().toLowerCase() === category)
: undefined;
if (namedRoot) {
const extraRoot = resolve(namedRoot.dir);
const redirected = resolve(extraRoot, subfolderRemainder(normalizedSub));
if (redirected !== extraRoot && !redirected.startsWith(extraRoot + sep)) {
throw new ModelError(`Refusing to write outside the models directory: ${raw}`);
}
logger.info(`Downloading into the extra model root the connected server named for "${category}" ` +
`(${extraRoot}) instead of the unvouched primary root (${modelsRoot}) — the root the ` +
`server's --extra-model-paths-config declares is where it actually reads (#369).`);
modelsRoot = extraRoot;
targetDir = redirected;
redirectedToExtraRoot = true;
}
}
}
if (!redirectedToExtraRoot) {
// The root did NOT come from the running server (it could not tell us where it
// lives, so we fell back to local config). Before writing, check the one thing
// that can still expose a stale-install destination: the server's own listing for
// this category. #369 shipped 4.88 GB into a directory the live ComfyUI had never
// heard of; refusing here costs nothing and saves the transfer. (A server-named
// primary is not skipped here either — the guard itself decides, because a named
// root that does not EXIST locally, e.g. a container-side --models-directory,
// still gets the cheap check. Only the REDIRECTED destination is exempt: the
// server's own config named it, so disagreeing with our inference is moot.)
await assertDestinationVisibleToLiveServer(modelsRoot, raw, source, snapshot);
}
// Path-string containment (above) is not enough: an EXISTING symlink somewhere
// between modelsRoot and targetDir could redirect the real write OUTSIDE the
// models dir. Because THIS resolver is the single canonical write-target for
// every local download (startDownloadJob keying AND downloadModel's write), the
// guard belongs here — co-located with the resolution the write actually uses —
// so it can never diverge from a caller's separate pre-validation (e.g.
// apply_manifest resolving the root a second time; #490 codex review). Runs on
// the REDIRECTED root too: its code-root veto is what keeps a malicious or
// miswritten extra-paths entry from turning this into a write into custom_nodes.
await assertNoEscapingSymlinkAncestor(modelsRoot, targetDir, raw, baseDirs, snapshot);
return {
targetDir,
// A redirected destination is bound to the EXTRA root, but the writer's
// mid-flight swap check compares PRIMARY roots (currentLiveModelsRoot), so
// there is no honest value to bind here — leave it unknown, exactly like any
// other non-server-named primary. The post-write check still verifies the
// landed file against the connected server.
liveRootAtResolve: !redirectedToExtraRoot && isLiveAuthoritativeModelsDir(source) ? modelsRoot : undefined,
};
}
/**
* Enforce that a local model download can ONLY land where the running ComfyUI
* actually reads model WEIGHTS — never in a directory it imports Python from.
* Applied to the resolved write target BEFORE any mkdir/write.
*
* The veto set is built from the running server's OWN configuration and canonical
* (realpath-collapsed) so it matches by real on-disk location regardless of mounts:
* • codeRoots (VETO, inclusive/over-veto) — the dirs ComfyUI IMPORTS PYTHON from
* (`custom_nodes`): each base-install dir's `custom_nodes` (from the base dirs
* the caller derived from the SAME /system_stats call), the models-root sibling,
* and every `custom_nodes`-category extra root (live AND static). A download
* must NEVER land inside one — that is arbitrary CODE execution, not a model
* install — so this is checked by RESOLVED REAL PATH, not category label, and
* it wins even when a model category also claims the same physical dir.
*
* A symlink/junction INSIDE the models tree may redirect the write anywhere that
* veto does not cover (#870 — this SUPERSEDES the #633-era "only a live-registered
* extra root authorizes an escape" rule, codex P0d). The redirect is a physical
* fixture of the user's own models directory — placed there by the user or their
* launcher — and ComfyUI's own scanner FOLLOWS directory symlinks
* (`folder_paths.recursive_search` walks with `followlinks=True`), so a write
* through one lands where the server actually reads. The earlier rule could never
* authorize the mainstream StabilityMatrix layout: it junctions each shared model
* folder (`models/vae` → `E:\comfy\StabilityMatrix\models\VAE`) and registers
* NOTHING — the junction IS the registration, so no live-root lookup can vouch
* for the target, and requiring one refused a correct install (a
* could-not-determine → determined-not fold, pointed at refusal). An exotic
* redirect that lands somewhere the server does not list is not hidden: the
* post-write check (verifyLandedModel) reports it honestly.
*
* Hardening invariants (codex P0a/P0b):
* - The PRIMARY models root itself is code-root-vetoed: if it canonicalizes into
* a `custom_nodes` dir (e.g. `--models-directory <base>/custom_nodes`, or a
* models junction into custom_nodes), EVERY write is refused — the primary root
* is never blanket-exempt.
* - A path segment that EXISTS as a symlink but whose target cannot be
* canonicalized (dangling / circular / unreadable) is REJECTED — never treated
* as an absent segment (which would let a later recursive mkdir FOLLOW the link
* out of every root). Only a genuinely-absent segment (ENOENT/ENOTDIR from
* lstat) is safe to skip; any other lstat error fails closed.
* - An escaping symlink whose REAL target falls under a code root is REJECTED —
* the in-tree authorization above never extends to a directory ComfyUI imports
* Python from.
*
* ACCEPTED RESIDUAL (P0c — TOCTOU): this is a pathname-time check. The Node runtime
* has no `openat`/per-segment `O_NOFOLLOW` traversal, so a LOCAL process that can
* replace an accepted directory/symlink AFTER this check but BEFORE the writer's
* mkdir/rename could still redirect the write. This race is inherent to pathname
* validation (it affects any accepted path, symlinked or not). It is ACCEPTED as a
* non-blocker: this runs on the user's OWN single-user machine, so a
* concurrently-racing local process is out of scope (maintainer ruling —
* "downloads should go where the user wants; it's their computer"). We deliberately
* do NOT reject symlinked/external destinations to defend against it — that would
* break the legitimate #633 external-drive and #870 StabilityMatrix use cases.
* The refusals this guard DOES make are the ACCIDENTAL-corruption ones only: a
* dangling/unresolvable escape (P0a) and a write into a code dir / custom_nodes
* (P0b). Fully eliminating the race would require trusted directory-handle
* traversal (native support) and is out of scope.
*/
async function assertNoEscapingSymlinkAncestor(root, target, label,
/** Candidate ComfyUI base-install dirs, derived by the CALLER from the SAME
* /system_stats call that resolved the models dir (resolveModelsDirWithBases).
* The code-root veto derives `<base>/custom_nodes` from these — threaded in
* rather than re-fetched here so it can never disagree with the models dir a
* divergent --models-directory produced (fail-open race; #633 codex round 4). */
baseInstallDirs = [],
/** The SAME /system_stats snapshot the models/base dirs came from — its live
* extra roots feed the code-root VETO (a live-registered custom_nodes dir is
* refused as a destination), so the veto reflects ONE consistent server state
* (codex inter-snapshot race). Since #870 it no longer AUTHORIZES anything:
* an in-tree symlink's redirect is authorized by its location, not by config. */
liveSnapshot = { reachable: false }) {
// Canonical root: if the models dir is itself a symlink/junction, resolve it so
// descendants are checked against where it REALLY lives (not the lexical path).
// The ROOT itself must be inspected too (P0): a DANGLING primary `models` symlink
// would otherwise pass (realpath fails → treated as "not created") and the
// recursive write would follow it OUT of every root — a normal user's broken
// models symlink escaping. lstat the root FIRST: allow only a genuinely-absent
// (ENOENT) root; reject a symlinked root whose realpath fails; fail closed on any
// other lstat error.
let realRoot;
try {
const rootInfo = await lstat(root);
try {
realRoot = resolve(await realpath(root));
}
catch {
if (rootInfo.isSymbolicLink()) {
throw new ModelError(`Refusing to write through a models-directory symlink that cannot be resolved (dangling or circular): ${label}`);
}
// A non-symlink dir whose realpath raced away — treat as absent; the writer
// recreates it inside the lexical root.
realRoot = resolve(root);
}
}
catch (err) {
if (err instanceof ModelError)
throw err;
const code = err?.code;
if (code === "ENOENT" || code === "ENOTDIR") {
realRoot = resolve(root); // root not yet created — lexical containment only.
}
else {
throw new ModelError(`Refusing to resolve the models directory safely (${code ?? "unreadable"}): ${label}`);
}
}
const canon = async (d) => {
try {
return resolve(await realpath(d));
}
catch {
return resolve(d); // not yet created — compare lexically
}
};
const underAny = (real, roots) => roots.some((r) => real === r || real.startsWith(r + sep));
// Build the CODE roots (veto) EAGERLY — the code veto must also cover the
// primary root (P0b), so it can't be deferred to an escape.
const baseDirSet = new Set([
dirname(realRoot),
...baseInstallDirs.map((b) => resolve(b)),
]);
const codeRoots = [];
for (const b of baseDirSet)
codeRoots.push(await canon(join(b, "custom_nodes")));
const isCodeCategory = (cat) => NON_MODEL_EXTRA_CATEGORIES.has(cat.trim().toLowerCase());
// Static extra roots contribute to the VETO only (over-veto is always safe): any
// custom_nodes dir the static config knows about is refused as a destination.
let staticExtra = [];
try {
staticExtra = await getExtraModelRoots();
}
catch {
staticExtra = [];
}
for (const er of staticExtra) {
if (isCodeCategory(er.category))
codeRoots.push(await canon(er.dir));
}
// LIVE, server-authoritative roots contribute their custom_nodes to the VETO.
// Self-derived from the live /system_stats snapshot (the launched
// --extra-model-paths-config files + the live install's own extra_model_paths.yaml)
// — NEVER a stale local workspace config. They no longer form an ALLOW list
// (#870): an in-tree symlink redirect is authorized by sitting inside the user's
// own models tree, not by appearing in a server-declared root set (a
// StabilityMatrix junction target is declared nowhere).
let live = {
authoritative: false,
roots: [],
};
try {
live = await getLiveExtraModelRoots(liveSnapshot);
}
catch {
live = { authoritative: false, roots: [] };
}
for (const er of live.roots) {
if (isCodeCategory(er.category))
codeRoots.push(await canon(er.dir));
}
const isUnderCode = (real) => underAny(real, codeRoots);
// P0b: the PRIMARY models root must not itself resolve into a code directory
// (e.g. `--models-directory <base>/custom_nodes`, or a models junction into
// custom_nodes) — otherwise a plain download (no symlink) writes into a dir
// ComfyUI imports = RCE. Refuse ALL writes in that case.
if (isUnderCode(realRoot)) {
throw new ModelError(`Refusing to write into a ComfyUI code directory (the models root resolves inside custom_nodes): ${label}`);
}
// Walk descendants only: from target up to (but NOT including) root.
let cursor = target;
while (cursor !== root && cursor.startsWith(root + sep)) {
let info;
try {
info = await lstat(cursor);
}
catch (err) {
const code = err?.code;
// Only a GENUINELY-absent segment is safe to skip (the writer creates it
// inside root). Any other lstat failure (EACCES/EIO/ELOOP/…) fails closed —
// we must not proceed to mkdir past a segment we couldn't inspect (P0a).
if (code === "ENOENT" || code === "ENOTDIR") {
const parent = dirname(cursor);
if (parent === cursor)
break;
cursor = parent;
continue;
}
throw new ModelError(`Refusing to resolve the download path safely (${code ?? "unreadable segment"}): ${label}`);
}
if (info.isSymbolicLink()) {
let real;
try {
real = resolve(await realpath(cursor));
}
catch {
// P0a: the segment IS a symlink but its target can't be canonicalized
// (dangling / circular / unreadable). Treating it as absent would let the
// recursive mkdir FOLLOW it OUT of every root. Reject — never proceed.
throw new ModelError(`Refusing to write through a symlink that cannot be resolved (dangling or circular): ${label}`);
}
// The segment is a symlink/junction INSIDE the models tree (every cursor in
// this walk is under the root by construction). Wherever it points, the
// redirect is a fixture of the user's own models directory and ComfyUI's
// scanner follows it (recursive_search walks with followlinks=True) — so the
// write stays where the server reads. This is the StabilityMatrix layout
// (#870): junctioned category folders whose targets no config declares. The
// ONLY remaining refusal is a landing inside a CODE root — a model download
// must never become a Python import.
if (isUnderCode(real)) {
throw new ModelError(`Refusing to write through a symlink that resolves into a ComfyUI code directory (custom_nodes): ${label}`);
}
}
const parent = dirname(cursor);
if (parent === cursor)
break;
cursor = parent;
}
}
/**
* The SINGLE source of truth for a model download's final on-disk destination,
* shared by downloadModel (the writer) AND the background job registry (which
* keys jobs by this canonical `targetPath`, so any two requests that resolve to
* the SAME file are one writer, and invalid inputs are rejected up front exactly
* as the write would reject them). Extracted so the two can never drift.
*
* Resolution: server-preferred models dir + subfolder via
* resolveModelSubfolderPreferServer (which TRIMS the subfolder, collapses
* "."/"..", and containment-guards against escapes/absolute paths), then the
* filename rule — an OMITTED filename derives from the URL pathname basename
* (→ "model.safetensors" fallback); ANY DEFINED filename is taken as its
* basename and REJECTED if it contained a path separator, or is blank / "." /
* "..". Throws ModelError on an invalid subfolder or filename.
*/
export async function resolveDownloadTarget(url, targetSubfolder, filename, modelRoot) {
const { targetDir, liveRootAtResolve } = await resolveModelSubfolderWithLiveRoot(targetSubfolder, modelRoot);
const rawFilename = filename ?? (basename(new URL(url).pathname) || "model.safetensors");
const resolvedFilename = basename(rawFilename);
if (resolvedFilename !== rawFilename ||
resolvedFilename === "" ||
resolvedFilename === "." ||
resolvedFilename === "..") {
throw new ModelError("Invalid model filename: must be a plain filename without path separators or '..'.", { filename: rawFilename });
}
const targetPath = join(targetDir, resolvedFilename);
if (!resolve(targetPath).startsWith(resolve(targetDir) + sep)) {
throw new ModelError("Refusing to write outside the target model directory.", { filename: rawFilename });
}
// #1371 — a stale COMFYUI_PATH wrote a model into a DIFFERENT local install than the
// one serving the session. There is deliberately NO divergent-install refusal in THIS
// function anymore. The one added for this issue took a SECOND, fresh server
// observation here (isUnderLiveModelRoots re-resolved the models dir), and nothing
// pinned the server across that await. Characterization tests (#1550) measured the
// result: in the steady state — one server, both observations agreeing — the branch
// could never fire, because a source that admits the guard (the destination did NOT
// come from the server) can never be one that answers it (the evidence bails unless
// the server NAMED the root); it fired only when the server changed mid-resolution,
// i.e. it judged the destination against a DIFFERENT server than the one that
// produced it. Nondeterministic exactly where it was needed, unsound where it fired.
//
// The coverage for this report is instead deterministic and lives with the SINGLE
// resolution that produced targetDir, so the destination and the evidence can never
// come from two different observations of the server:
// - resolveModelsDirWithBases REFUSES a configured base the server's own listing
// positively contradicts (output-dir.ts, "DO NOT WRITE INTO A BASE THE SERVER
// DEMONSTRABLY DOES NOT READ"), and refuses an unanchorable relative-main.py
// server outright unless the base corroborates it;
// - assertDestinationVisibleToLiveServer (same snapshot) refuses the #369
// signature — a POPULATED on-disk category the live listing does not account
// for;
// - the writer binds to liveRootAtResolve and re-checks currentLiveModelsRoot
// before any bytes move, so a mid-flight server swap is refused when both roots
// are server-named;
// - verifyLandedModel confirms the landed file against the connected server AFTER
// the write, so any residual wrong destination is reported, never a silent
// success.
// What remains unguardable BY DESIGN: the server's root is known but not on this
// filesystem (Docker / port-forward / remote mount), where COMFYUI_PATH may
// legitimately be the mapped host path. No local evidence can distinguish that from
// a stale install, and the maintainer ruling is to proceed with the existing
// unconfirmed-visibility disclosure rather than refuse a correct setup.
return { targetDir, filename: resolvedFilename, targetPath, liveRootAtResolve };
}
/**
* Resolve a relative-to-models path against a known root, keeping the result
* strictly INSIDE that root. Rejects absolute inputs, "" / "." (the root
* itself), and ".." traversal escapes. Shared by the primary and extra-root
* lookups so every candidate gets the same containment guarantee.
*/
function containWithinRoot(rootDir, relativePath) {
const root = resolve(rootDir);
const target = resolve(root, relativePath);
const rel = relative(root, target);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
throw new ValidationError(`Refusing to operate outside the models directory: ${relativePath}`);
}
// Defense-in-depth: the resolved path must be a descendant of the root and
// not merely share its string prefix (e.g. "models-evil" vs "models").
if (target !== root && !target.startsWith(root + sep)) {
throw new ValidationError(`Refusing to operate outside the models directory: ${relativePath}`);
}
return target;
}
async function getAuthorizedModelRootsForRemoval(configuredModelsRoot) {
if (isRemoteMode()) {
return { primaryRoots: [configuredModelsRoot], extraRoots: [] };
}
// This is the same live resolution used by download destinations. A reachable
// server's configured-base fallback is not deletion authority: it can be a
// different install from the one answering /system_stats. The server-named
// predicate is deliberately narrower than the write-side observed-root
// predicate: an OS-inferred portable bundle is not proof of the primary root.
const resolved = await resolveModelsDirWithBases();
if (resolved.snapshot.reachable) {
const primaryRoots = modelsDirNamedByServer(resolved.source)
? [resolve(resolved.modelsDir)]
: [];
let extraRoots = {
authoritative: false,
roots: [],
};
try {
// This is deletion-specific. It uses the SAME snapshot, but rejects a
// current config that cannot be proven unchanged since the connected
// process started. The generic live helper remains current-config based
// for downloads and symlink authorization.
extraRoots = await getLaunchStateExtraModelRoots(resolved.snapshot);
}
catch {
extraRoots = { authoritative: false, roots: [] };
}
return {
primaryRoots,
extraRoots: extraRoots.authoritative ? extraRoots.roots : [],
};
}
// Removal has no safe offline fallback: the configured path may belong to a
// stale install, and current extra-path config is not proof of what a server
// loaded. Refuse before stat() so an unreachable target cannot authorize a
// local deletion by accident.
return { primaryRoots: [], extraRoots: [] };
}
/**
* Locate an existing model file given a path relative to ComfyUI's models/
* directory. Read-only callers retain the configured primary and extra-root
* lookup. The explicit `mode:"remove"` path instead searches only connected
* server-named/launch-state-proven roots, so deletion cannot inherit read-only
* visibility from mutable local configuration.
*
* Resolution rules:
* - The primary root is searched with the full relative path.
* - Extra roots are per-category (the first path segment, e.g. "checkpoints"),
* so the remainder of the path is resolved within each matching root.
* - Every candidate is containment-checked against its own root; absolute
* paths and ".." traversal are rejected (security guard preserved).
* - A matching FILE wins; if only a directory matches it is returned so the
* caller can surface a precise "not a file" error.
*
* Throws ValidationError for absolute/traversal inputs and ModelError when the
* entry is not found in any root (the message lists the roots searched).
*/
export async function resolveExistingModelFile(relativePath, options = {}) {
if (!resolveComfyUIBase()) {
throw new ModelError("No local ComfyUI path configured. Locating/removing a local model operates on " +
"the local filesystem and is unavailable when targeting a remote ComfyUI. " +
"Set the COMFYUI_PATH environment variable, or save a default workspace with " +
"workspace (action:\"set_default\").");
}
const raw = (relativePath ?? "").trim();
if (!raw) {
throw new ValidationError("Model path is required.");
}
// Reject absolute paths cross-platform: posix-absolute (isAbsolute), a Windows
// drive-letter root (E:\ / E:/), or a UNC path (\\server\share). isAbsolute()
// alone is host-OS-dependent — it wouldn't flag "E:/…" on Linux/macOS — but this
// guard must hold regardless of where the orchestrator runs (the host may be
// Windows, where E:\ is a real model drive a caller could try to escape to).
if (isAbsolute(raw) || /^[a-zA-Z]:[\\/]/.test(raw) || raw.startsWith("\\\\")) {
throw new ValidationError(`Path must be relative to the models directory, not absolute: ${relativePath}`);
}
const searched = [];
let dirHit;
const configuredModelsRoot = resolve(getModelsRoot());
const rootResolution = options.mode === "remove"
? await getAuthorizedModelRootsForRemoval(configuredModelsRoot)
: { primaryRoots: [configuredModelsRoot], extraRoots: undefined };
// Primary roots are searched in authority order. Each candidate remains
// containment-checked, preserving the absolute/traversal guard.
for (const modelsRoot of rootResolution.primaryRoots) {
const primaryTarget = containWithinRoot(modelsRoot, raw);
searched.push(modelsRoot);
try {
const info = await stat(primaryTarget);
if (info.isFile())
return { path: primaryTarget, root: modelsRoot, info };
if (!dirHit)
dirHit = { path: primaryTarget, root: modelsRoot, info };
}
catch {
// Not present under this primary root; keep searching authorized roots.
}
}
// Extra roots are declared per category, so peel off the first path segment
// and resolve the remainder within each matching extra directory.
const segments = raw.split(/[/\\]+/).filter(Boolean);
const category = segments[0];
const remainder = segments.slice(1).join("/");
if (category && remainder) {
const extraRoots = rootResolution.extraRoots ?? (await getExtraModelRoots());
for (const er of extraRoots) {
if (er.category !== category)
continue;
const rootDir = resolve(er.dir);
let target;
try {
target = containWithinRoot(rootDir, remainder);
}
catch {
// A remainder that can't safely resolve within this root is skipped
// rather than failing the whole lookup.
continue;
}
searched.push(rootDir);
try {
const info = await stat(target);
if (info.isFile())
return { path: target, root: rootDir, info };
if (!dirHit)
dirHit = { path: target, root: rootDir, info };
}
catch {
// Not present under this extra root; keep searching.
}
}
}
if (dirHit)
return dirHit;
// #1474 — removal says WHY only its authorized roots were searched. Read-only
// callers use the normal configured-root lookup and do not inherit this policy.
throw new ModelError(modelNotFoundMessage({ relativePath, searched, deletion: options.mode === "remove" }), { path: relativePath, searched });
}
/**
* The auth HEADERS the LOCAL download path would apply for `url` — explicit auth first,
* else the auto HuggingFace/CivitAI host-token injection. MIRRORS the header build in
* `downloadModel`'s local streaming path (kept in sync with it) and is used ONLY by the
* #473 remote flip probe: a credentialed fetch that turns a non-model response into a real
* model proves the URL is authentication-gated (the exact bug — a local token Manager can
* never receive). Returns `{}` when no credential applies (a public/anonymous URL).
*/
function localAuthHeadersFor(url, auth,
/** Whether the ORIGINAL (pre-HF_ENDPOINT-rewrite) url was a huggingface.co url — threaded
* from downloadModel so an HF_ENDPOINT mirror still gets the token (mirrors the local
* streaming path; without it a rewritten url would parse to the mirror host and drop the
* token → a false-negative). */
wasHfUrl, requestHeaders) {
const headers = {
...(requestHeaders ?? applyDownloadAuth(url, auth).headers),
};
// HF token: attach ONLY when the url's PARSED hostname is huggingface.co (or a subdomain),
// or the ORIGINAL url was a HF url that HF_ENDPOINT rewrote to a trusted mirror. NEVER a
// substring match (see isHuggingFaceHost). isCivitaiUrl is likewise hostname-parsed, so
// the CivitAI branch is safe by construction.
const hfToken = config.huggingfaceToken;
const civitaiToken = config.civitaiApiToken;
if (!auth && hfToken && (wasHfUrl || isHuggingFaceHost(url))) {
headers["Authorization"] = `Bearer ${hfToken}`;
}
else if (!auth && civitaiToken && isCivitaiUrl(url)) {
headers["Authorization"] = `Bearer ${civitaiToken}`;
}
return headers;
}
function localPartialAuthMode(rewrittenUrl, auth, headers, storageAuth) {
if (auth !== undefined)
return "explicit";
// Configured host tokens and cloud credentials are reapplied by a restart, but
// their values are intentionally not persisted. The cache digest proves whether
// the current configuration is the same representation; it does not grant a
// restart permission to guess a missing credential.
if (Object.keys(headers).length > 0 || storageAuth !== undefined || supportsCloudDownload(rewrittenUrl)) {
return "configured";
}
return "none";
}
export function localDownloadCacheIdentity(url, auth) {
const wasHfUrl = /^https?:\/\/huggingface\.co([/?#]|$)/i.test(url);
const rewrittenUrl = applyHfEndpoint(url);
const request = applyDownloadAuth(rewrittenUrl, auth);
const headers = localAuthHeadersFor(rewrittenUrl, auth, wasHfUrl, request.headers);
const storageAuth = auth?.type === "s3" ? { s3: auth } : undefined;
const cache = downloadCacheIdentity(request.url, headers, storageAuth);
const partialIdentity = {
version: 1,
cache_key: cache.cacheKey,
auth_mode: localPartialAuthMode(rewrittenUrl, auth, headers, storageAuth),
};
return {
wasHfUrl,
rewrittenUrl,
requestUrl: request.url,
headers,
storageAuth,
progressId: createHash("sha256").update(request.url).digest("hex").slice(0, 16),
partialIdentity,
cachePath: cache.cachePath,
partialPath: cache.partialPath,
};
}
/**
* #1567 (item 3) — the resumable `.partial` a LOCAL writer for `url` would pick up
* if the download were re-issued RIGHT NOW, or null when none is found.
*
* The bare-URL `findResumablePartial(url)` reproduces the writer's staged name only
* for an UNAUTHENTICATED download: the cache identity folds in the representation
* headers (#467 P1-2), and the common gated case — a CivitAI/HuggingFace token from
* the environment, exactly the #1567 reporter's setup — sends one, so its partial
* is staged under a name the bare URL never produces and the plain lookup reports
* "no partial" for gigabytes that are sitting on disk.
*
* This wrapper rebuilds the request the local streaming path would make TODAY —
* the same HF_ENDPOINT rewrite and the same env-credential header block
* `downloadModel` applies, through the same `localAuthHeadersFor` definition, so
* the lookup and the writer cannot drift apart — and stats the partial under THAT
* identity. Explicit per-request auth (and cloud credentials) are deliberately NOT
* reproducible here: a job record keeps no credential, so those partials miss, and
* a miss means "none found under the identity a re-issue would use now", never
* "none exists".
*/
export async function findResumablePartialForLocalDownload(url) {
const identity = localDownloadCacheIdentity(url);
return findResumablePartial(identity.requestUrl, identity.headers, identity.storageAuth);
}
/**
* #1086 — what a Manager dispatch does NOT establish.
*
* This used to end with "the file lists under /models when complete", which is a
* PREDICTION, not an observation, and it was wrong for the reporter: ComfyUI-Manager
* installed into the container base root (/opt/ComfyUI/models) while the server READ
* models from /workspace/models via extra_model_paths. The file never listed, the
* 20 GB overlay was discarded on pod restart, and a multi-GB model was lost — after
* we had reported the transfer as proceeding to a destination we never checked.
*
* We cannot fix the destination from here: extra_model_paths.yaml lives on the
* TARGET filesystem, and for a remote install this process cannot read it. What we
* can stop doing is asserting where the bytes will land.
*
* NAMES THE CHECK, not just the risk. "Verify it yourself" without saying how is a
* dead end; list_local_models reads through the SAME roots the server reads, so a
* file that appears there is genuinely reachable by a workflow.
*/
/**
* The destination ComfyUI-Manager LOGGED for `filename`, read back from
* `/internal/logs` (#1086).
*
* The standing caveat says we cannot know where a Manager dispatch lands, because
* `extra_model_paths.yaml` lives on the target filesystem. That is true of the
* CONFIG — and the reporter's own evidence shows it is not true of the OUTCOME.
* Manager announces the absolute path it picked, in both generations:
*
* glob/manager_server.py:1063 f"Install model '{name}' from '{url}' into '{path}'"
* legacy/manager_server.py:634 (identical)
*
* which is how they discovered their Wan 2.2 file had gone to
* `/opt/ComfyUI/models` while the server read `/workspace/models` — a 20GB overlay
* that discarded it on the next pod restart.
*
* So the destination is unknowable only until we look. This looks.
*
* Returns undefined when the log could not be read OR carried no such line —
* DELIBERATELY not distinguished here, because the caller's remedy is identical
* (fall back to the caveat) and inventing a distinction the caller cannot act on
* would be noise. What must never happen is an unread log rendering as a
* destination, which is why undefined is the only other answer.
*
* Matches the LAST occurrence: a filename can be installed more than once in a
* session, and the newest line is the dispatch we just made.
*/
/**
* Are these two absolute paths written in the SAME path syntax? (#1086, codex review)
*
* A destination read from the REMOTE server's log is in that host's syntax, while
* a root resolved here has been through node's `resolve()` — which on Windows
* turns "/workspace/models" into "C:\workspace\models". Comparing across that
* boundary always answers OUTSIDE, for a destination that may be exactly right,
* and a false OUTSIDE is the dangerous direction: it cries wolf about a good file
* and contradicts a LISTED verdict in the same message.
*
* Deliberately crude — POSIX-absolute vs drive-letter is the only distinction that
* matters here, and anything it cannot classify is treated as incomparable rather
* than guessed.
*/
/**
* The extra_model_paths directories the LIVE server reads, or undefined when that
* could not be established (#1086, codex review).
*
* Undefined is load-bearing: "we could not read the extra roots" must not render
* as "there are none", which would let a destination under a perfectly good extra
* root be reported as unusable.
*/
async function liveExtraRootDirs() {
try {
const snapshot = await resolveModelsDirWithBases();
const live = await getLiveExtraModelRoots(snapshot.snapshot);
if (!live.authoritative)
return undefined;
return live.roots.map((r) => r.dir).filter((d) => typeof d === "string");
}
catch {
return undefined;
}
}
function samePathDomain(a, b) {
const kind = (p) => {
if (/^[A-Za-z]:[\/]/.test(p))
return "win32";
if (p.startsWith("\\\\"))
return "win32"; // UNC
if (p.startsWith("/"))
return "posix";
return "unknown";
};
const ka = kind(a);
const kb = kind(b);
return ka !== "unknown" && ka === kb;
}
/**
* The FILENAME a Manager dispatch actually used, from a finished job (#1086,
* codex review).
*
* `job.filename` is OPTIONAL — a URL-only download never sets it — and the
* fallback in use was `job.path.split(/[\/]/).pop()`. For a Manager dispatch
* `job.path` is not a path at all; it is
*
* "checkpoints/foo.safetensors (dispatched to the remote ComfyUI via …)"
*
* so that fallback returned the whole descriptive tail. Every consumer then
* searched for a filename that cannot match anything: the destination read below
* misses, and `verifyManagerVisibility` — which used the identical fallback long
* before this change — probed a name the server could never list, quietly turning
* every URL-only Manager download into an unverifiable one.
*
* Take the leading "<subfolder>/<filename>" that descriptor is built from,
* stopping at the " (" the note begins with.
*/
export function managerJobFilename(job) {
if (job.filename)
return job.filename;
const head = (job.path ?? "").split(" (")[0];
return head.split(/[\/]/).pop() ?? "";
}
export function parseManagerInstallDestination(logText, filename) {
if (!logText || !filename)
return undefined;
// The name is interpolated into the line inside single quotes, so match it as a
// whole quoted token — a bare substring would let `clip_vision_h.safetensors`
// be matched by a line about `clip_vision_h.safetensors.part`.
const esc = filename.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`Install model '${esc}' from '[^']*' into '([^']+)'`, "g");
let last;
for (const m of logText.matchAll(re))
last = m[1];
return last;
}
/**
* Read the live server's log and report where Manager said it put `filename`,
* plus whether that is inside the models root the server actually reads (#1086).
*
* Never throws: this runs after a transfer, and a diagnostic that cannot be
* gathered must not turn a completed dispatch into an error.
*/
export async function describeManagerDestination(filename, opts) {
let text;
try {
// getLogs() already carries the reconnect-and-retry a Manager reboot needs
// (#399) — a restart is exactly what precedes many of these reads.
text =
opts?.readLog !== undefined
? await opts.readLog()
: (await getLogs()).join("\n");
}
catch {
return undefined;
}
if (!text)
return undefined;
const dest = parseManagerInstallDestination(text, filename);
if (!dest)
return undefined;
const root = opts?.liveModelsDir;
// #1086 (codex review, PR #1190) — ONLY compare when the comparison is
// trustworthy, and the two ways it is not are both dangerous in the SAME
// direction: a false OUTSIDE cries wolf about a file that is fine, and
// contradicts a LISTED verdict sitting in the same message.
//
// - PATH DOMAIN. `dest` comes from the REMOTE server's log, so it is that
// host's path syntax. `currentLiveModelsRoot()` runs its value through
// node's `resolve()`, which on a WINDOWS orchestrator turns the remote
// "/workspace/models" into "C:\workspace\models". Comparing those two
// always says OUTSIDE — for a destination that is exactly right.
// - EXTRA ROOTS. This compares one PRIMARY root, while a server can read
// several. A destination under a legitimately-registered extra root would
// be reported as unusable while `verifyManagerVisibility` lists it.
//
// So the INSIDE/OUTSIDE verdict is asserted only when the domains agree, and
// an OUTSIDE reading also has to survive the extra roots. Anything else falls
// back to locating the file WITHOUT judging it — which is still far more than
// the old "we cannot know where this lands".
if (root !== undefined && !samePathDomain(dest, root)) {
return (`ComfyUI-Manager reported writing it to ${dest}. That path is on the ComfyUI ` +
`host and could not be compared with the models directory known here ` +
`(${root}) — they are written in different path syntaxes, so no INSIDE/OUTSIDE ` +
`verdict is claimed. Check list_local_models to confirm the server can read it.`);
}
if (root === undefined) {
// We know WHERE, but not whether that is a place the server reads — and the
// second half is the one that decides whether the file is usable. Say the
// first without implying the second.
return (`ComfyUI-Manager reported writing it to ${dest}. Whether that path is one the ` +
`connected server reads could not be established from here, so this locates the ` +
`file without confirming it is usable — check list_local_models.`);
}
if (isUnderRoot(dest, root)) {
return (`ComfyUI-Manager reported writing it to ${dest}, which is INSIDE the models ` +
`directory that server reads (${root}).`);
}
// An OUTSIDE reading has to survive the EXTRA roots before it is asserted: a
// server reads more than one tree, and a destination under a registered extra
// root is fine. Unreadable extra roots mean we cannot rule that out, so the
// verdict is withheld rather than guessed — the same three-state discipline as
// everywhere else here.
const extra = await (opts?.extraRoots !== undefined
? opts.extraRoots()
: liveExtraRootDirs());
if (extra === undefined) {
return (`ComfyUI-Manager reported writing it to ${dest}, which is not under the primary ` +
`models directory the connected server reads (${root}). Whether that server also ` +
`reads it through an extra_model_paths entry could NOT be checked from here, so ` +
`this is not being called unusable — confirm with list_local_models.`);
}
if (extra.some((r) => typeof r === "string" && isUnderRoot(dest, r))) {
return (`ComfyUI-Manager reported writing it to ${dest}, which is outside the primary ` +
`models directory (${root}) but INSIDE an extra_model_paths root that server ` +
`reads — so it is reachable.`);
}
return (`⚠ ComfyUI-Manager reported writing it to ${dest}, which is OUTSIDE the models ` +
`directory the connected server reads (${root}) — so a workflow there will NOT see ` +
`it. This is the extra_model_paths case: Manager picks its own destination root ` +
`and does not necessarily honour that config. On a container the base models ` +
`directory is commonly an EPHEMERAL OVERLAY, which discards the file on the next ` +
`restart — move it into ${root} now, or re-download with COMFYUI_PATH set so this ` +
`MCP streams it to a destination it controls.`);
}
export function managerDestinationCaveat() {
return ("ComfyUI-Manager chooses the destination root itself and does not necessarily honour " +
"extra_model_paths — so this has NOT established where the file lands. Confirm with " +
"list_local_models before relying on it. A model that never appears there was written " +
"somewhere the server does not read, commonly the install's base models directory; on a " +
"container that is often an ephemeral overlay, which loses the file on restart.");
}
/**
* Remote-mode download: hand the file off to the connected ComfyUI host via
* ComfyUI-Manager's `install-model` task. Validates the subfolder/filename with
* the same guards as the local path (no traversal, bare filename) before
* dispatch, then returns a human-readable descriptor of where it will land.
*/
async function downloadModelViaManagerRemote(url, targetSubfolder, filename, auth, signal,
/** Whether the ORIGINAL url (before any HF_ENDPOINT rewrite the caller applied) was a
* huggingface.co url — threaded to the #473 flip probe's credential derivation so an
* HF_ENDPOINT mirror still gets the HF token (matches the local streaming path). */
wasHfUrl = false) {
// Cancelled before we dispatched — do not hand the fetch to the remote Manager at all.
if (signal?.aborted)
throw new DOMException("The download was cancelled.", "AbortError");
const raw = (targetSubfolder ?? "").trim();
if (!raw) {
throw new ModelError("target_subfolder is required (e.g. 'loras', 'checkpoints').");
}
if (isAbsolute(raw) || /^[a-zA-Z]:[\\/]/.test(raw) || raw.startsWith("\\\\")) {
throw new ModelError(`target_subfolder must be relative to models/, not absolute: ${raw}`);
}
const segments = raw.split(/[/\\]+/).filter(Boolean);
if (segments.length === 0 || segments.includes("..")) {
throw new ModelError(`Invalid target_subfolder: ${raw}`);
}
const normalizedSubfolder = segments.join("/");
const modelType = segments[0];
const rawFilename = filename ?? (basename(new URL(url).pathname) || "model.safetensors");
const resolvedFilename = basename(rawFilename);
if (resolvedFilename !== rawFilename ||
resolvedFilename === "" ||
resolvedFilename === "." ||
resolvedFilename === "..") {
throw new ModelError("Invalid model filename: must be a plain filename without path separators or '..'.", { filename: rawFilename });
}
// Resolve auth for a server-side (Manager) fetch. Manager fetches the URL on
// the ComfyUI host and cannot receive our per-request HTTP headers, so:
// - query auth → fold the param into the URL (works server-side);
// - header/basic/bearer → cannot be forwarded; surface a clear warning so we
// don't report a clean success for a download that will fail unauthenticated;
// - s3 → no URL/header mutation here (Manager can't use our SigV4 creds either).
let dispatchUrl = url;
let authWarning = "";
if (auth?.type === "query") {
// applyDownloadAuth folds the query_param/query_value into the URL.
dispatchUrl = applyDownloadAuth(url, auth).url;
}
else if (auth && (auth.type === "header" || auth.type === "basic" || auth.type === "bearer")) {
authWarning =
` WARNING: ${auth.type} auth cannot be forwarded to ComfyUI-Manager's` +
` server-side fetch — if this URL requires authentication, the download will` +
` fail. Use a query-auth'd/signed URL, or configure the credential (e.g. an` +
` HF/CivitAI token) on the ComfyUI host.`;
}
else if (auth?.type === "s3") {
authWarning =
` WARNING: s3 auth cannot be forwarded to ComfyUI-Manager's server-side fetch;` +
` if this URL requires S3 credentials, the download will fail.`;
}
// Map our category to a Manager-valid { type, save_path }. For a nested
// target we hand Manager the full relative path; for a top-level category we
// send "default" (mapped types) or the folder name (unmapped categories) so
// the model actually lands. See managerModelDestination().
const { type: managerType, save_path: managerSavePath } = managerModelDestination(modelType, segments.length > 1 ? normalizedSubfolder : undefined);
const sensitiveParams = auth?.type === "query" ? [auth.query_param] : undefined;
// #473 remote residual. The MCP cannot fully close this on the remote path: a server-side
// Manager fetch can't carry our auth headers, the MCP never sees the landed bytes, and
// there is no remote primitive to sniff, size, or delete the file afterward — so a
// login-gated URL makes Manager save an HTML/JSON auth page under the `.safetensors` name
// and report its queue task "done", a corrupt "model" under a false success. (The
// authoritative fix is a host-side authenticated transport — the MCP-to-panel streamed
// upload the issue scopes — tracked separately.)
//
// What the MCP CAN do here WITHOUT ever wrongly blocking a legitimate download: detect,
// with HIGH CONFIDENCE, that the URL is AUTHENTICATION-GATED and warn LOUDLY, so a
// dispatched auth/login page is never silently mistaken for a real model. Proof is a
// credential FLIP: an unauthenticated fetch (what Manager does) returns a non-model
// auth/error page, but the SAME url fetched WITH the credential the local path would
// apply (which Manager can NEVER receive) returns a real model — IP-independent evidence
// of token gating, the exact reported bug. We deliberately DO NOT refuse the dispatch:
// the ComfyUI host fetches from a DIFFERENT network vantage and could still succeed
// (e.g. an IP allowlist the MCP isn't on), so a hard refusal from the MCP's vantage
// could block a download that would actually work. A prominent warning that never blocks
// is the safe ceiling of what the MCP can assert remotely.
const modelExt = extname(resolvedFilename).toLowerCase();
const probe = await probeRemoteModelPayload(dispatchUrl, modelExt, signal, {
// The auth the LOCAL path would apply — used ONLY to prove auth-gating via a credential
// flip; Manager can never receive these headers. Derived from `url` (pre-query-fold) so
// host detection (civitai/hf) matches the local streaming path. `wasHfUrl` preserves the
// pre-HF_ENDPOINT-rewrite HF identity so a mirror still gets the token.
authHeaders: localAuthHeadersFor(url, auth, wasHfUrl),
});
if (signal?.aborted)
throw new DOMException("The download was cancelled.", "AbortError");
if (probe.verdict === "non-model") {
const what = probe.kind === "html"
? "an HTML page (a login/authentication or error page)"
: probe.kind === "json"
? "a JSON document (an API error or auth challenge)"
: "an authentication/error response";
let host = "";
try {
host = new URL(dispatchUrl).hostname;
}
catch {
/* host is only used to tailor the remediation hint */
}
const isCivitai = /(^|\.)civitai\.com$/i.test(host);
const remediation = isCivitai
? `set CIVITAI_API_TOKEN on the ComfyUI HOST (the MCP's locally-configured token is ` +
`NOT forwarded to a remote ComfyUI-Manager), or download to a LOCAL ComfyUI where the ` +
`token is applied and the payload is validated on disk`
: `configure the credential on the ComfyUI host, or download to a LOCAL ComfyUI where the ` +
`credential is applied and the payload is validated on disk`;
// #473 — REFUSE, do not dispatch. The probe has PROVEN the gate: the same URL
// returns a login/error page unauthenticated and a real model with the
// credential, and Manager fetches server-side without our headers. Dispatching
// anyway is knowingly writing a corrupt file under the caller's chosen
// filename — it then LISTS as a model and fails much later inside a loader
// ("header too large" / "Expecting value"), which is how this issue was
// reported three times.
//
// Owner's call (2026-08-08) after weighing the false-refusal risk: a ComfyUI
// HOST that carries its OWN token would have succeeded, and is now blocked.
// That case is speculative, has two documented ways out (below), and fails
// LOUDLY at the point of the request; the corrupt-file case is real,
// recurring, and fails silently hours later on someone else's canvas.
logger.warn("Refusing an authentication-gated model dispatch to ComfyUI-Manager (it cannot carry our credentials)", { url: redactUrlForLogs(dispatchUrl, sensitiveParams), filename: resolvedFilename });
throw new ModelError(`Refusing to dispatch "${resolvedFilename}" to ComfyUI-Manager: this URL is ` +
`AUTHENTICATION-GATED. An unauthenticated fetch returns ${what}, while the SAME URL ` +
`fetched WITH the configured credential returns a real model — and ComfyUI-Manager ` +
`fetches server-side and cannot carry this MCP's auth headers. It would therefore save ` +
`that auth/error page under "${resolvedFilename}" as a CORRUPT model, which lists ` +
`normally and only fails later at load time ("header too large" / "Expecting value"). ` +
`NOTHING was downloaded and nothing was written. To download it, ${remediation}.`, { url: redactUrlForLogs(dispatchUrl, sensitiveParams), filename: resolvedFilename });
}
logger.info("Dispatching model install to remote ComfyUI via ComfyUI-Manager", {
url: redactUrlForLogs(dispatchUrl, sensitiveParams),
type: managerType,
save_path: managerSavePath,
filename: resolvedFilename,
});
try {
await installModelViaManager({
// Manager's do_install_model reads json_data['name'] (required, non-empty).
// We only have the filename to identify the model here, so use it.
name: resolvedFilename,
url: dispatchUrl,
filename: resolvedFilename,
type: managerType,
save_path: managerSavePath,
// Panel tray: watch OUR canonical category for the file to land (#143).
trayCategory: modelType,
});
}
catch (err) {
// #1374 — the ONLY failure entitled to the route explanation: Manager was contacted
// and Manager is what failed. Scoped to this call rather than the whole function
// because everything above it — target resolution, the remote payload preflight — can
// fail for reasons unrelated to the route. The first version wrapped the lot and
// matched on error text; the preflight's own auth-gated refusal mentions
// ComfyUI-Manager, so it was misattributed anyway. A flag at the call site cannot be
// fooled by prose.
if (err instanceof DOMException && err.name === "AbortError")
throw err;
const why = await explainManagerDownloadRoute();
if (!why)
throw err;
const raw = err instanceof Error ? err.message : String(err);
throw new ModelError(`${raw}
WHY THIS WENT THROUGH ComfyUI-Manager AT ALL: ${why}`, {
url,
});
}
// Cancelled while the dispatch was in flight (#515): the local job is cancelled.
// We CAN'T recall a Manager queue task, so the host may keep fetching — but this
// must NOT be reported as a completed local download. Throw so the job records
// "cancelled" (the tool message notes the server may still be fetching).
if (signal?.aborted)
throw new DOMException("The download was cancelled.", "AbortError");
// #947 — "the REMOTE ComfyUI" is not what this decision established. The route
// is chosen by shouldDispatchDownloadToManager(), which sends work to the
// Manager whenever it cannot resolve a local models directory to stream into —
// and a LOOPBACK server does that routinely: no COMFYUI_PATH, no saved
// workspace, and a `python main.py` argv whose root cannot be derived.
//
// A reporter on http://127.0.0.1:8188 was told their download went "to the
// remote ComfyUI", concluded the orchestrator had misclassified a local server,
// and filed against the classification. isRemoteMode() was right the whole
// time; the SENTENCE was wrong. Say what was actually decided, and name the
// setting that would have kept the download local.
const routeNote = isRemoteMode()
? "dispatched to the remote ComfyUI via ComfyUI-Manager"
: "dispatched to the connected ComfyUI via ComfyUI-Manager — this MCP could not resolve a " +
"local models directory to stream into (no COMFYUI_PATH, no saved workspace, and the " +
"running server's launch arguments did not identify one). That is a routing fallback, NOT " +
"a claim that the server is remote; set COMFYUI_PATH to stream directly instead";
return `${normalizedSubfolder}/${resolvedFilename} (${routeNote} — download continues server-side. ${managerDestinationCaveat()})${authWarning}`;
}
/**
* Decide whether a model download must be handed to the CONNECTED ComfyUI's
* ComfyUI-Manager (a server-side fetch) instead of being streamed to local disk.
*
* Returns true when:
* - REMOTE mode — there is no local filesystem at all (the original behavior); OR
* - we are NOMINALLY LOCAL (loopback target) but have NO resolvable local base:
* COMFYUI_PATH is unset — or was LOST across a panel/orchestrator reconnect
* (#420) — and no default workspace is saved, AND the connected server did not
* advertise a discoverable `--base-directory` to stream into, YET a live server
* IS reachable to accept a Manager dispatch. This is exactly the reconnect
* failure in #420: the previous session downloaded fine, the reconnect dropped
* the effective base, and the local path then threw "no local ComfyUI path
* configured" instead of routing through the still-connected Manager (the same
* route list_local_models keeps using over HTTP). #418 fixed base resolution
* at START; this keeps downloads working when the base goes missing LATER.
*
* Stays LOCAL (false) whenever a local base is resolvable (resolveEffectiveComfyUIBase)
* OR the server reports a base/models dir we can write to directly; also false when
* no server is reachable, so the local resolver surfaces its clear, actionable error
* rather than silently succeeding.
*/
/**
* WHY a download went to ComfyUI-Manager, reported as CURRENT STATE (#1374).
*
* The reporter's LOCAL install could not download at all: every attempt died on
* "ComfyUI-Manager's queue API is not reachable", for a capability that needs no Manager.
* That error is raised in generic Manager code which cannot know it is serving a download,
* so it named the thing that BROKE and not the decision that made Manager necessary --
* this MCP could not work out where the connected server keeps its models. Only the second
* has a remedy the user can apply.
*
* FOUR REVIEW ROUNDS WENT INTO A MESSAGE, and every one found the same shape of fault: a
* diagnostic claiming to narrate the routing DECISION while reading state that might not
* be the state that decision was made from. A second /system_stats read could describe a
* different server. A process-global record of the first read could be overwritten by a
* concurrent download. Stamping the target closed the simple overwrite but not an
* A -> B -> A retarget.
*
* Each fix made the window smaller instead of closing it, so what changes here is the
* CLAIM. This reports the state as it is now and says so. It cannot be stale, because it
* no longer asserts it was ever anything else -- and the argv/cwd a reporter needs in
* order to say which case they hit is exactly as useful either way.
*/
export async function explainManagerDownloadRoute() {
if (isRemoteMode()) {
return ("This MCP is in REMOTE mode (--comfyui-url) RIGHT NOW, so downloads are dispatched " +
"to the connected ComfyUI's Manager by design -- there is no local models directory " +
"to stream into. Manager must be installed and enabled on that host. (Current state: " +
"if the target was changed since this download started, it may not be why this one " +
"took that route.)");
}
let argv;
let cwd;
try {
const stats = await getSystemStats();
argv = stats?.system?.argv;
cwd = stats?.system?.cwd;
}
catch {
// Nothing observed now. Say nothing rather than guess why the route was taken.
return "";
}
// Rendered RAW rather than JSON.stringify'd: a Windows argv comes back with doubled
// backslashes, in the one line that exists to be pasted into a bug report.
const detail = `Reading the connected server NOW: argv[0]=${argv?.[0] ?? "(none)"} and ` +
`cwd=${cwd ?? "(not reported)"}. (Current state -- if the server was restarted or ` +
`retargeted since this download started, it may not be what the routing decision saw.)`;
if (hasUnresolvableRelativeModelDirFlag(argv, cwd)) {
return (`Your ComfyUI was started with a RELATIVE --base-directory/--models-directory and did ` +
`not report its working directory, so this MCP cannot tell where its models actually ` +
`live -- writing locally would put the file somewhere the server never reads. ${detail} ` +
`FIX: restart ComfyUI with an ABSOLUTE --base-directory, or set COMFYUI_PATH to the ` +
`install root, and a local download will stream directly with no Manager involved.`);
}
const liveRoot = resolveLiveServerRoot(argv, cwd, { remote: false });
if (liveRoot.root && !existsSync(liveRoot.root)) {
return (`The connected ComfyUI reports its install root as ${liveRoot.root}, which does not ` +
`exist on THIS machine -- it is a container-side or remote path (a Docker/SSH-forwarded ` +
`loopback server looks local but is not). Writing there would create a bogus directory ` +
`instead of reaching the server, so the fetch is handed to its Manager. ${detail} ` +
`FIX: enable Manager on that host, or run this MCP where the models directory really is.`);
}
return (`This MCP could not resolve where the connected ComfyUI keeps its models, so it handed ` +
`the fetch to that server's Manager instead of streaming locally. ${detail} ` +
`FIX: set COMFYUI_PATH to the ComfyUI install root (the directory containing main.py) -- ` +
`a local download then streams directly and needs no Manager. Please include this whole ` +
`paragraph if you report it (#1374): the argv/cwd above is what identifies which case ` +
`this is.`);
}
export async function shouldDispatchDownloadToManager() {
if (isRemoteMode())
return true;
try {
const stats = await getSystemStats();
const argv = stats?.system?.argv;
const cwd = stats?.system?.cwd;
// Ask the LIVE server FIRST. A server launched with a RELATIVE
// --base-directory/--models-directory that did NOT report its cwd has an UNKNOWN
// real models dir: any local guess (COMFYUI_PATH or the main.py root) would be
// the WRONG place it never reads (#346). Route such a download through the
// server's Manager (server-side write, lands correctly) rather than writing
// locally or hard-failing. This MUST win over the COMFYUI_PATH / main.py-root
// local short-circuits below (codex — it was previously skipped when a local
// base was configured).
if (hasUnresolvableRelativeModelDirFlag(argv, cwd))
return true;
// Resolvable. A configured/auto-detected COMFYUI_PATH or saved default workspace
// → stream local.
if (resolveEffectiveComfyUIBase())
return false;
// Reachable server: stream locally when it exposes a base/models dir we can
// write to (--base-directory/--models-directory) OR when we can derive its own
// install root from argv (its main.py path) — the SAME live-first root the
// downloader writes into (resolveModelsDir). Keeping this in lockstep with
// resolveModelsDir is what lets a panel-connected local server with no
// COMFYUI_PATH stream models into its live install (#463) instead of bouncing
// through the Manager (which then fails when Manager isn't installed). Only
// dispatch to the Manager when NEITHER is discoverable (#420 reconnect with an
// opaque/relative argv we can't resolve).
if (parseModelsDirFromArgv(argv, cwd))
return false;
// Derive the server's own install root through the ONE canonical resolver — the
// SAME notion resolveModelsDirWithBases writes into, so this predicate can never
// disagree with the destination (#369). It covers the relative-`main.py`-no-cwd
// shape (Desktop / Windows portable) that the old argv-only check could not
// resolve, which needlessly bounced those installs through the Manager (and then
// failed outright when Manager isn't installed). Only treat the root as a LOCAL
// stream target when it ACTUALLY EXISTS on this filesystem: a loopback ComfyUI
// inside Docker / behind an SSH port-forward reports a container-side path that
// is NOT the host's, so writing there would create a bogus host directory
// instead of reaching the server. When it isn't locally present, hand the fetch
// to the connected Manager (server-side write), which lands correctly regardless.
const liveRoot = resolveLiveServerRoot(argv, cwd, { remote: false }).root;
if (liveRoot && existsSync(liveRoot))
return false;
return true;
}
catch {
// No reachable server → nothing to dispatch to. A configured local base still
// streams local; otherwise let the local resolver surface its clear error.
return false;
}
}
export async function downloadModel(url, targetSubfolder, filename, auth,
/**
* The ALREADY-DECIDED route (Manager vs local disk). startDownloadJob computes
* shouldDispatchDownloadToManager() ONCE to key the job identity, then threads
* that same decision here so the writer can never diverge from the key — a
* reconnect/reachability flip BETWEEN two evaluations would otherwise split the
* job (Manager-key + local-writer, or a duplicate job) (#420 codex round 1).
* Omitted by direct callers (the download_model tool path without a job), which
* evaluate the predicate themselves.
*/
dispatchToManager,
/** Sink for the resume decision, threaded from the job so the outcome is stored
* on that job (#467). Omitted by direct callers (no job to report to). */
onResume,
/** Per-download abort signal, threaded from the job's AbortController into the
* fetch + stream pipeline so download_model action:"cancel" aborts exactly this transfer (#515).
* Omitted by direct callers (no cancellation handle). */
signal,
/** Reports the ACTUAL progress-tray id (a hash of the post-auth/post-HF-rewrite
* request URL) back to the job, so the job's trayId matches the id the streaming
* and done rows are written under. Without it a query-auth or HF_ENDPOINT-rewritten
* download's tray row is keyed differently from the job's original-URL trayId — so
* download_model action:"status" byte display AND cancel cleanup would target the wrong id. Called
* only on the LOCAL streaming path (the Manager path writes no streaming rows). */
onTrayId,
/** Fired the instant the completed, validated file is renamed into its destination
* (#515) — so the job can commit "done" with NO window where the file exists but the
* job still reads "downloading". Local paths only (the remote Manager dispatch has no
* local rename; the caller commits done when this function returns). */
onLanded,
/** Reports the effective local model-download network route to the job status record. */
onDownloadRoute,
/** Reports the exact cache partial selected by the local writer. */
onStagedPartialPath,
/** Optional explicit model-root directory already known from list_paths (#2499). */
modelRoot) {
// Cancelled before we did anything — never start a transfer (local OR server-side).
if (signal?.aborted)
throw new DOMException("The download was cancelled.", "AbortError");
// Resolve the effective local request/cache identity once. This is also the
// source of truth for the staged partial path published to status; it includes
// HF endpoint rewriting, query auth, representation headers, and cloud auth.
const localIdentity = localDownloadCacheIdentity(url, auth);
if (isCivitaiUrl(localIdentity.rewrittenUrl) && civitaiDisabled()) {
throw new ModelError(CIVITAI_DISABLED_MESSAGE);
}
// REMOTE mode: the MCP has no local filesystem, so a local-disk download is
// impossible. Dispatch the download to the connected ComfyUI host through
// ComfyUI-Manager's `install-model` task instead — it fetches the file
// server-side into the right models/ subfolder. The CivitAI/HuggingFace URL
// (and any auth-resolved URL) was already resolved by the caller. Query-style
// auth is folded into the URL before dispatch (Manager fetches server-side and
// can carry query params); header/basic/bearer auth can't be forwarded to
// Manager, so those are surfaced as a clear warning rather than reported as a
// clean success.
// Use the route the caller already decided (job path), else evaluate it now
// (direct callers). Never re-evaluate when a decision was threaded in — that is
// the split-brain guard: the writer must follow the SAME route the job id was
// keyed on, even if reachability/base config flipped since (#420 codex round 1).
const routeToManager = dispatchToManager ?? (await shouldDispatchDownloadToManager());
if (routeToManager) {
// Thread the PRE-rewrite HF identity so the flip probe's credential derivation keeps the
// HF token flowing to an HF_ENDPOINT mirror (matches the local path below).
// The route explanation lives INSIDE the dispatch (see downloadModelViaManagerRemote),
// where a flag records whether Manager was actually contacted. Matching on error text
// from out here could not tell a Manager failure from a preflight refusal that happens
// to mention Manager.
return await downloadModelViaManagerRemote(localIdentity.rewrittenUrl, targetSubfolder, filename, auth, signal, localIdentity.wasHfUrl);
}
// Root the destination at the LIVE server's models dir (its --base-directory),
// not blindly at COMFYUI_PATH/models — otherwise a Desktop install downloads
// into a stale checkout the running server never reads (#346/#369). Resolution
// + filename validation go through the SHARED resolveDownloadTarget so the
// background job registry keys jobs by the exact same targetPath.
//
// DELIBERATE: this RE-RESOLVES rather than reusing the target startDownloadJob
// keyed on. If the live server was replaced in between (a restart onto the same
// port serving a DIFFERENT install), the freshly resolved root is the one the
// server now reads, and the stale key is the one that would put the file where
// nobody looks — so the CURRENT destination wins over a stable key. `onLanded`
// reports the real path back to the job, and verifyLandedModel confirms it, so
// nothing is ever reported at the stale path. Accepted residual (disclosed):
// across such a restart the job's serialization key can name the previous
// destination, so a concurrent same-file request may run a duplicate transfer.
// That is bounded by the existing per-writer O_EXCL temp + atomic rename (#467) —
// a duplicate download, never a corrupt file — and is strictly preferable to
// writing into an install the server no longer serves.
const { targetDir, targetPath, filename: resolvedFilename,
// BIND the destination to the server it was resolved for. Re-resolving is right,
// but the answer must not go stale between resolving it and writing: a ComfyUI
// replaced during the mkdir would otherwise take the bytes into the root the
// PREVIOUS server read. This root comes from the SAME resolution that produced
// targetPath — capturing it separately afterwards would leave a window in which
// the swap happens BEFORE the capture, so both later observations agree while the
// target still points into the old install (codex gate, round 18). Both roots
// must be live-authoritative to compare; when either is unknown there is nothing
// to bind, and the (already never-confirmed) non-authoritative path applies.
liveRootAtResolve: rootAtResolve, } = await resolveDownloadTarget(localIdentity.rewrittenUrl, targetSubfolder, filename, modelRoot);
// Ensure target directory exists
await mkdir(targetDir, { recursive: true });
const rootBeforeWrite = await currentLiveModelsRoot();
if (rootAtResolve && rootBeforeWrite && !sameModelsRoot(rootAtResolve, rootBeforeWrite)) {
throw new ModelError(`Refusing to start this download: the connected ComfyUI changed while the destination was ` +
`being prepared. It read "${rootAtResolve}" when the destination was resolved and reads ` +
`"${rootBeforeWrite}" now, so "${targetPath}" is no longer where the running server ` +
"looks. Re-issue the download now that the current server is connected.");
}
const sensitiveParams = auth?.type === "query" ? [auth.query_param] : undefined;
const logUrl = redactUrlForLogs(localIdentity.requestUrl, sensitiveParams);
logger.info(`Downloading model to ${targetPath}`, { url: logUrl });
// Stable id for the panel tray, keyed on the (pre-redirect) URL so resumes and
// retries map to the same row. Name is the friendly file name.
const progressId = localIdentity.progressId;
// Attempt generation/epoch (panel#489): the id is URL-derived (deterministic), so a
// retry of the SAME URL reuses it — attempt N and attempt N+1 are otherwise
// indistinguishable. Stamp this attempt's start epoch on every row it writes so the
// orchestrator can drop a LATE terminal (failed/done) row from a superseded attempt
// once a newer attempt for the same id is already progressing. nextAttemptEpoch()
// guarantees STRICT monotonicity within this process, so two same-millisecond retries
// never tie (a tie would leave neither able to supersede the other); across processes
// the wall clock orders a later spawn after an earlier one.
const progress = { id: progressId, name: resolvedFilename, attempt: nextAttemptEpoch() };
// Tell the job the id the tray rows actually use, so status display + cancel
// cleanup key on the SAME id even when auth/HF-endpoint rewrote the request URL.
onTrayId?.(progressId);
// Publish the exact cache identity selected above. Status/restart recovery must
// inspect this path, not approximate it from the persisted original URL.
onStagedPartialPath?.(localIdentity.partialPath);
try {
await downloadWithCache({
url: localIdentity.requestUrl,
headers: localIdentity.headers,
targetPath,
logUrl,
storageAuth: localIdentity.storageAuth,
progress,
onResume,
signal,
onLanded,
onRoute: onDownloadRoute,
// #1635 — the failure hint must know a per-request override authenticated
// this request, so a CivitAI 401 indicts THAT credential instead of
// claiming no CIVITAI_API_TOKEN is configured.
callerAuth: auth !== undefined,
});
}
catch (err) {
// On a user cancel (#515) the transfer was aborted, not a real failure — do NOT
// emit an "error" row (the job reports "cancelled"). The tray row is cleared by the
// JOB layer (finalizeCancelled), which is registry-aware so it can't wipe a
// coalesced sibling's live row; clearing here would clear that shared row blindly.
// For a genuine error, surface a failed row, then rethrow.
if (!signal?.aborted) {
reportDownloadProgress({ ...progress, downloaded: 0, total: 0, bytes_per_sec: 0, status: "error" }, true);
}
throw err;
}
const info = await stat(targetPath);
// The file has MATERIALIZED to its destination and passed model-payload/size
// validation. We deliberately do NOT abort here on a late cancel: the file on disk is
// a complete, valid model, so the honest outcome is a completed download (the job
// reports "done"). A cancel that arrived BEFORE the file landed already made this
// function THROW — via the stream abort, or the cache-layer pre-materialize /
// pre-rename guards — so it never reaches this point. (Rolling a validated landed file
// back would be unsafe.) Result: file present ⟺ done; file absent ⟺ cancelled.
logger.info(`Download complete: ${resolvedFilename} (${(info.size / 1024 / 1024).toFixed(1)} MB)`);
// Ensure a terminal "done" row even on a cache hit (no streaming happened).
reportDownloadProgress({ ...progress, downloaded: info.size, total: info.size, bytes_per_sec: 0, status: "done" }, true);
return targetPath;
}
//# sourceMappingURL=model-resolver.js.map