UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

1,340 lines • 74.6 kB
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
import { extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
import { config, isRemoteMode } from "../config.js";
import { getSystemStats, comfyApiFetch } from "../comfyui/client.js";
import { resolveEffectiveComfyUIBase, getLiveServerSnapshot, resolveLiveServerRoot, hasComfyUIEntrypoint, } from "./workspace-env.js";
import { ValidationError } from "../utils/errors.js";
import { logger } from "../utils/logger.js";
import { flagValue, parseBaseDirFromArgv, rawFlagValue, resolveServerLaunchPath, } from "./launch-argv.js";
// The pure launch-argv helpers live in ./launch-argv.js (a leaf module) so
// workspace-env.ts can build on them WITHOUT importing this module — output-dir
// already imports workspace-env, and a cycle here broke vitest's async
// mock-factory resolution. These re-exports keep the public surface unchanged.
export { hasUnresolvableRelativeBaseDirFlag, parseBaseDirFromArgv, } from "./launch-argv.js";
/** True when the models dir was established from the RUNNING server rather than
 *  from local configuration the server never vouched for. */
export function isLiveAuthoritativeModelsDir(source) {
    return source === "argv-flag" || source === "live-root" || source === "observed-root";
}
/**
 * Did the SERVER ITSELF name this root, rather than us inferring it? (#369)
 *
 * Deliberately narrower than `isLiveAuthoritativeModelsDir`, and deliberately a
 * SEPARATE predicate rather than a change to it — that one has four other callers
 * answering different questions, and widening or narrowing it in place would move
 * all of them at once.
 *
 * The distinction is what a source is EVIDENCE OF:
 *
 *   argv-flag / live-root — the server's own command line, naming its base or its
 *     script root. A statement about where the server reads.
 *   observed-root — a relative `main.py` re-anchored on the interpreter the OS
 *     reports for the process on our port. A statement about where the BINARY
 *     lives, from which the root is INFERRED.
 *
 * Those come apart in an ordinary Windows setup: with a stale portable bundle's
 * python on PATH, `cd D:\live && python ComfyUI\main.py` anchors
 * `C:\stale\ComfyUI` — measured, and reachable through both the absolute-argv[0]
 * tier and the OS-image reading (#1374). A download then lands in an install the
 * running server never reads, which is #369's original outcome by a new route.
 *
 * So only the first two skip the disagreement check. The inferred one runs it, at
 * the cost of one `/models/<category>` listing call — a check that needs positive
 * contradicting evidence and fails open on everything else, so it cannot refuse a
 * fresh or shared tree.
 */
export function modelsDirNamedByServer(source) {
    return source === "argv-flag" || source === "live-root";
}
/** Resolve a possibly-relative dir against a base (or COMFYUI_PATH, or cwd). */
function resolveDir(value, base) {
    if (isAbsolute(value))
        return resolve(value);
    const root = base ?? config.comfyuiPath ?? process.cwd();
    return resolve(root, value);
}
/**
 * Parse the configured output directory out of ComfyUI's launch argv.
 * --output-directory wins; otherwise --base-directory implies <base>/output.
 * Returns undefined when neither flag is present.
 */
export function parseOutputDirFromArgv(argv) {
    if (!argv || argv.length === 0)
        return undefined;
    let outputDir;
    let baseDir;
    for (let i = 0; i < argv.length; i++) {
        outputDir = flagValue(argv, i, "--output-directory") ?? outputDir;
        baseDir = flagValue(argv, i, "--base-directory") ?? baseDir;
    }
    const resolvedBase = baseDir ? resolveDir(baseDir) : undefined;
    if (outputDir)
        return resolveDir(outputDir, resolvedBase);
    if (resolvedBase)
        return join(resolvedBase, "output");
    return undefined;
}
/**
 * True when the server's launch argv carries a RELATIVE `--base-directory` or
 * `--models-directory` that we CANNOT resolve because it did not report an absolute
 * cwd. Callers must then NOT guess a local destination (they'd write to the wrong
 * place); route through the server / report live resolution unavailable instead.
 */
export function hasUnresolvableRelativeModelDirFlag(argv, serverCwd) {
    if (serverCwd && isAbsolute(serverCwd))
        return false; // all relatives resolvable
    const base = rawFlagValue(argv, "--base-directory");
    const models = rawFlagValue(argv, "--models-directory");
    return ((models !== undefined && !isAbsolute(models)) ||
        (base !== undefined && !isAbsolute(base)));
}
/**
 * Parse the models directory the running server actually reads from. ComfyUI's
 * `--models-directory` overrides `<base>/models` and is resolved INDEPENDENTLY via
 * os.path.abspath (relative to the SERVER cwd) — NOT relative to `--base-directory`
 * (folder_paths.py). Otherwise the models root is `<base>/models`. Returns
 * undefined when neither flag is present, or when a relative flag is unresolvable
 * without the server cwd.
 */
export function parseModelsDirFromArgv(argv, serverCwd) {
    const modelsDir = rawFlagValue(argv, "--models-directory");
    // --models-directory resolves on its OWN against the server cwd, not against base.
    if (modelsDir !== undefined)
        return resolveServerLaunchPath(modelsDir, serverCwd);
    const base = parseBaseDirFromArgv(argv, serverCwd);
    return base ? join(base, "models") : undefined;
}
/**
 * Collect all values that follow `flag` at position `index`, supporting both
 * `--flag a b` (argparse nargs='+') and `--flag=a`. Consumes consecutive tokens
 * until the next `--option`. Returns [] when the token at `index` isn't `flag`.
 */
function multiFlagValues(argv, index, flag) {
    const token = argv[index];
    if (token.startsWith(`${flag}=`))
        return [token.slice(flag.length + 1)];
    if (token !== flag)
        return [];
    const values = [];
    for (let j = index + 1; j < argv.length; j++) {
        if (argv[j].startsWith("--"))
            break;
        values.push(argv[j]);
    }
    return values;
}
/**
 * Parse every `--extra-model-paths-config` value out of the launch argv. ComfyUI
 * declares this flag as `nargs='+', action='append'`, so it can carry multiple
 * files per occurrence AND be repeated — both forms are collected here. This is
 * the config file(s) the running server actually loads extra model search paths
 * from — on ComfyUI Desktop it is an auto-generated
 * `…\Comfy Desktop\shared_model_paths.yaml`, NOT the app-data
 * `ComfyUI\extra_models_config.yaml` the tools historically guessed. Returns []
 * when the flag is absent.
 */
export function parseExtraModelPathsConfigsFromArgv(argv) {
    if (!argv || argv.length === 0)
        return [];
    const out = [];
    for (let i = 0; i < argv.length; i++) {
        for (const v of multiFlagValues(argv, i, "--extra-model-paths-config")) {
            out.push(resolveDir(v));
        }
    }
    return out;
}
/**
 * Like parseExtraModelPathsConfigsFromArgv but returns the RAW flag values WITHOUT
 * resolving relatives. Security-critical for AUTHORIZATION (getLiveExtraModelRoots,
 * #633): a RELATIVE `--extra-model-paths-config` value cannot be safely resolved to
 * the live server's file from the MCP process — resolveDir() would anchor it to the
 * local COMFYUI_PATH / MCP cwd, so a stale local same-named config could authorize
 * an escape the running server never loads (codex P0d). The authorizing caller keeps
 * only ABSOLUTE values and fails closed on relative ones.
 */
export function parseExtraModelPathsConfigsFromArgvRaw(argv) {
    if (!argv || argv.length === 0)
        return [];
    const out = [];
    for (let i = 0; i < argv.length; i++) {
        for (const v of multiFlagValues(argv, i, "--extra-model-paths-config"))
            out.push(v);
    }
    return out;
}
/**
 * Resolve the models directory the CONNECTED server actually reads from AND, from
 * the SAME `/system_stats` call, the candidate ComfyUI *base install* directories
 * (used by the download destination guard to locate `custom_nodes` code roots).
 *
 * Deriving both from ONE call is a security invariant, not just an optimization:
 * a SECOND, separate stats call could fail AFTER the models dir was already
 * resolved from a divergent `--models-directory`, leaving the guard without the
 * real `--base-directory` code root and letting a relabeled extra-path alias of
 * `custom_nodes` slip through (fail-open). One call means the models dir and the
 * base dirs are always consistent — if the call fails, BOTH fall back together to
 * the configured local base (no partial-information window).
 *
 * modelsDir: the running ComfyUI's models root (`--base-directory`/`--models-directory`
 * → the live server's main.py root → `<COMFYUI_PATH>`/default workspace), issues
 * #346/#369/#490/#463. baseDirs: the local install roots ComfyUI derives
 * `custom_nodes` from — the argv `--base-directory`, the live main.py root, and the
 * configured local base — collected only in LOCAL mode (the guard runs only
 * locally; a remote server's argv paths are on the remote host).
 */
/** Do two absolute paths name the same directory?
 *
 *  Case is folded on WINDOWS ONLY. Windows filesystems are case-insensitive
 *  everywhere, so a server reporting `comfyui\main.py` against a base of
 *  `...\ComfyUI` genuinely names the same directory. macOS is deliberately NOT
 *  folded even though HFS+/APFS are case-insensitive by DEFAULT: APFS can be
 *  formatted case-SENSITIVE, and on such a volume `/x/ComfyUI` and `/x/comfyui`
 *  are two different installs. This comparison decides whether a download may be
 *  written to a base, so a wrong "same" is the #369 harm (a model landing in an
 *  install the running server never reads, reported as a success). Being strict
 *  costs a case-differing macOS user a refusal they can fix; being lax could cost
 *  them a silently misplaced multi-gigabyte file. */
function samePath(a, b) {
    const norm = (s) => {
        const slashed = resolve(s).replace(/\\/g, "/").replace(/\/+$/, "");
        return process.platform === "win32" ? slashed.toLowerCase() : slashed;
    };
    return norm(a) === norm(b);
}
/**
 * Corroborate a configured local `base` against the RELATIVE `main.py` path the
 * running server reported, and return the install root when — and only when —
 * they agree. Returns undefined when they do not, so the caller refuses rather
 * than guesses (#369's doctrine: an honest "I don't know where this would land"
 * beats a fabricated success).
 *
 * TWO base conventions coexist in this codebase and BOTH are legitimate (#813):
 *
 *   A. `base` is the OUTER launcher root, the server one level down at
 *      `<base>/<relDir>/main.py`. This is ComfyUI Desktop, whose `<base>` holds
 *      the launcher's `standalone-env` python rather than the server.
 *
 *   B. `base` IS the ComfyUI directory (it holds `main.py` directly), and the
 *      server's relative `relDir/main.py` is written from base's PARENT. This is
 *      the classic Windows portable bundle with `COMFYUI_PATH` set to
 *      `...\ComfyUI_windows_portable\ComfyUI` — the value `install_comfyui (action:"environment")`,
 *      `list_local_models` and `resolveEffectiveComfyUIBase` all already treat as
 *      correct. Only this resolver rejected it, so every download refused (#813).
 *
 * Order follows the rule `serverRootsUnder` (workspace-env.ts) already
 * established for exactly this ambiguity, rather than inventing a second
 * convention: a base that DIRECTLY holds `main.py` IS the server root and wins;
 * a nested checkout never outranks it (#401).
 *
 * Convention B is accepted ONLY on the same evidence convention A demands —
 * that the server's reported relative path, anchored one level up, names THIS
 * directory (so `relDir` must match base's own name) AND that `main.py` is
 * really there. A base whose name does not match `relDir` is NOT corroborated
 * and is still refused: the server said its script lives at `<something>/ComfyUI/
 * main.py`, and a base named `ComfyUI-master` is not that, whatever it contains.
 */
function anchorRelativeEntrypointOnBase(base, relDir) {
    // B — base is itself the directory the server named. The evidence is that
    // `base` ENDS WITH `relDir`'s segments: climb that many levels up from base and
    // re-anchor; if we land back on base, then some working directory (the one the
    // server did not report) makes `relDir/main.py` resolve to exactly this install.
    // Handles multi-segment relDir ("sub/ComfyUI") as well as the reported single
    // "ComfyUI"; a base whose tail does NOT match relDir lands elsewhere and is
    // rejected, which is the whole point of corroborating.
    const segments = relDir.split(/[\\/]+/).filter((s) => s !== "" && s !== ".");
    const impliedCwd = resolve(base, ...segments.map(() => ".."));
    const baseIsTheInstall = samePath(resolve(impliedCwd, relDir), base) && hasComfyUIEntrypoint(base);
    // A — base is the outer launcher root; the server is nested under it. (This
    // also covers relDir "." — `resolve(base, ".")` is base — which is how a server
    // launched as plain `python main.py` from inside the install already resolved.)
    const nested = resolve(base, relDir);
    const nestedIsTheInstall = !samePath(nested, base) && hasComfyUIEntrypoint(nested);
    // BOTH readings fit. `<base>/main.py` and `<base>/<relDir>/main.py` both exist,
    // and the server's relative path is consistent with either — so the evidence
    // does not say WHICH install is running, and picking one would be a guess about
    // a destination for multi-gigabyte files. That is exactly the #369 harm (a model
    // landing in a stale install and being reported a success), so refuse and let
    // the caller resolve it. Returning undefined routes into the existing refusal,
    // which names both interpretations.
    if (baseIsTheInstall && nestedIsTheInstall)
        return undefined;
    if (baseIsTheInstall)
        return resolve(base);
    if (nestedIsTheInstall)
        return nested;
    // relDir "." (or empty): nested IS base, so only the base reading can apply.
    return hasComfyUIEntrypoint(nested) ? nested : undefined;
}
/**
 * Corroborate a configured base by asking the SERVER what models it can SEE (#851).
 *
 * `anchorRelativeEntrypointOnBase` corroborates using ComfyUI's CODE layout — it needs a
 * `main.py` at or under the base. That is an assumption about what an install LOOKS
 * like, and every deployment shape that does not match produces another false refusal:
 * #813 was the Windows portable shape, #851 is the Docker/data-dir shape, where
 * `COMFYUI_PATH` is a bind-mounted host directory holding `models/ input/ output/
 * custom_nodes/` and the code lives inside the container at a path that does not exist
 * on the host at all. No amount of layout-matching reaches that, because the evidence it
 * looks for is genuinely not there — and "I could not determine which install this is"
 * was being reported as "I determined it is a DIFFERENT install".
 *
 * But the question is "where does this server read MODELS from?", and for that there IS
 * decisive evidence the running server volunteers: `/models/<category>` is ComfyUI
 * listing the model files it can actually see. If everything it reports for a category
 * is present under `<base>/models/<category>`, then that directory is (or is mounted as)
 * the models root the server reads — established by the server, not by us recognising a
 * layout. That is the same principle #813 used, applied to the evidence that exists in
 * this deployment rather than the evidence that does not.
 *
 * Deliberately conservative, because this decides where multi-gigabyte files land:
 *  - it runs ONLY where the alternative is today's hard refusal, so it can convert a
 *    refusal into a corroborated destination but can never redirect a download that
 *    already resolved;
 *  - it requires a NON-EMPTY category that the base explains COMPLETELY, except for the
 *    narrowly bounded split-namespace case below: at least two listed files are present
 *    and exactly one listed file is absent;
 *  - a server-answered EMPTY category is usable only as no contradiction for a first
 *    download, and keeps the destination non-authoritative;
 *  - anything unreadable or otherwise ambiguous returns a reason and the caller refuses.
 *
 * Residual, stated rather than hidden: a stale second install holding an identical copy
 * of the same model files would also match, and the bounded partial has the same filename-
 * only limitation. These sources are NOT live-authoritative precisely so downstream keeps
 * treating the destination as local configuration that was corroborated, not as a path
 * the server named.
 */
/** Is `candidate` strictly inside `root`? The server's listing supplies both the category
 *  and the file names, and a `..` segment in either (a stray path, a symlinked category)
 *  would let `join` walk out of `<base>/models` and "find" a file that proves nothing
 *  about this base. The root itself is not an entry, so equality is not containment. */
function containedUnder(root, candidate) {
    const r = resolve(root);
    const c = resolve(candidate);
    if (c === r)
        return false;
    const rel = relative(r, c);
    if (rel === "" || isAbsolute(rel))
        return false;
    // `..` only counts as escaping when it is a whole PATH SEGMENT. A bare
    // `startsWith("..")` also rejects `..model.safetensors` — a perfectly ordinary
    // filename that stays inside the directory — and that false refusal would block the
    // very Docker download this corroboration exists to allow. Over-strict is the same
    // defect as over-permissive, pointed the other way.
    return rel !== ".." && !rel.startsWith(`..${sep}`) && !rel.startsWith("../");
}
/**
 * Identity for a server-listed category-relative filename. The bounded partial rescue
 * counts evidence, so repeated spellings must not manufacture distinct physical files.
 * Existing entries use the filesystem's own canonical spelling: a case-insensitive volume
 * resolves `shared.safetensors` and `SHARED.SAFETENSORS` to one identity, while a
 * case-sensitive volume resolves two physically distinct entries to two identities.
 * Missing entries conservatively use only normalized lexical identity — collapsing those
 * without physical evidence could erase genuinely distinct files on a case-sensitive volume.
 */
function modelInventoryIdentity(categoryDir, name) {
    const full = join(categoryDir, name);
    if (containedUnder(categoryDir, full)) {
        try {
            return `physical:${realpathSync.native(full)}`;
        }
        catch {
            try {
                return `physical:${realpathSync(full)}`;
            }
            catch {
                // An absent or unreadable entry has no physical identity to compare.
            }
        }
    }
    return `lexical:${normalize(name)}`;
}
/** Preserve the first server spelling while removing aliases before probing/counting. */
function uniqueModelInventoryFiles(files, categoryDir) {
    const seen = new Set();
    return files.filter((name) => {
        const identity = modelInventoryIdentity(categoryDir, name);
        if (seen.has(identity))
            return false;
        seen.add(identity);
        return true;
    });
}
/** What is at this path — keeping "could not look" out of "is not there", and out of
 *  "is a regular file". A DIRECTORY named like a model would satisfy `existsSync` and
 *  corroborate a base that holds none of the actual files. */
function probeEntry(p) {
    try {
        return statSync(p).isFile() ? { kind: "file" } : { kind: "not-a-file" };
    }
    catch (err) {
        const code = err?.code;
        if (code === "ENOENT" || code === "ENOTDIR")
            return { kind: "absent" };
        return { kind: "indeterminate", detail: code ?? String(err) };
    }
}
/**
 * Extensions that make a directory entry a MODEL rather than housekeeping (#1371).
 *
 * Used only to answer "does this base hold models of its own for this category?" — the
 * question that separates a stale second install from an extra_model_paths root that is
 * simply empty here. A `.gitkeep` or a `desktop.ini` answers neither.
 */
const BASE_OWN_MODEL_EXTENSIONS = new Set([
    ".safetensors",
    ".sft",
    ".ckpt",
    ".pt",
    ".pt2",
    ".pth",
    ".bin",
    ".gguf",
    ".onnx",
    // Formats a first pass missed (codex P1). Omitting one is not neutral: a stale base
    // holding only `.engine` files reads as EMPTY, the contradiction never fires, and the
    // download goes into the wrong install — the exact write this gate exists to stop.
    ".pte",
    ".engine",
    ".trt",
    ".plan",
    ".npz",
    ".msgpack",
    ".pkl",
    ".model",
]);
/**
 * Does this directory hold a model file, one level down? (#1371)
 *
 * Diffusers-style categories keep their weights in per-model folders, so "no model file at
 * the top level" is not the same as "this base is empty". One level is deliberate: it
 * covers the real layout without turning a corroboration check into a recursive walk of a
 * models tree that can hold tens of thousands of files.
 */
function isModelFileEntry(entry) {
    // SYMLINKS COUNT (codex). `Dirent.isFile()` is FALSE for a symbolic link, and sharing one
    // model tree between installs by symlinking is ordinary practice — this file says so
    // elsewhere. Requiring isFile() alone made a stale base whose models are links read as
    // empty, reintroducing the misplaced write for exactly the users most likely to have two
    // installs.
    if (!entry.isFile() && !entry.isSymbolicLink())
        return false;
    return BASE_OWN_MODEL_EXTENSIONS.has(extname(entry.name).toLowerCase());
}
/**
 * Is this entry a directory, INCLUDING a symlink pointing at one? (#1371)
 *
 * `Dirent.isDirectory()` is false for a symlink to a directory — the same trap as
 * `isFile()` above, and it bit the same users twice. `models/checkpoints/SDXL ->
 * /mnt/shared/SDXL` is the layout of someone sharing one model tree between installs,
 * which is precisely the population this gate exists to protect: their base read as empty,
 * so no contradiction was found, so the misplaced write went ahead.
 *
 * Following the link is safe here because the recursion below is depth-bounded and
 * budget-bounded — a link pointing back up its own tree costs at most a couple of extra
 * readdirs, not a cycle. A broken link throws and is simply not a directory.
 */
function entryIsDirectoryLike(dir, entry, budget) {
    if (entry.isDirectory())
        return true;
    if (!entry.isSymbolicLink())
        return false;
    // THE STAT IS ITSELF THE WORK (codex P2). A category holding thousands of directory
    // symlinks reached this line once per entry, because the readdir budget is only consumed
    // one level down — so bounding readdirs left the symlink-heavy tree exactly as
    // unbounded as before, in the shape the symlink fix newly made reachable.
    if (budget.stats <= 0) {
        budget.truncated = true;
        return false;
    }
    budget.stats -= 1;
    try {
        return statSync(join(dir, entry.name)).isDirectory();
    }
    catch {
        return false;
    }
}
/**
 * How many directories one corroboration check may read before it gives up. (#1371, codex P2)
 *
 * Depth alone does not bound the work: a category holding 10k subdirectories is 10k
 * synchronous readdirs at depth 2, on the download path. Exhausting the budget answers
 * "no model found", which is the SAFE direction — no contradiction means no refusal, only
 * the pre-existing unconfirmed-visibility warning. A populated base almost always answers
 * true from a file in the first directory read.
 */
const MODEL_SCAN_DIR_BUDGET = 512;
/** Companion cap on link resolutions, for the same reason one level down. */
const MODEL_SCAN_STAT_BUDGET = 4096;
// A split code/data namespace can make one server-visible entry unavailable to the
// MCP process. A single shared filename is not enough to distinguish that from a stale
// clone (#1147/#369), so the bounded rescue requires at least two matches and permits
// only one unaccounted server entry. Larger or smaller disagreements remain refused.
const MIN_BOUNDED_NAMESPACE_MATCH_FILES = 2;
const MAX_BOUNDED_NAMESPACE_ONLY_FILES = 1;
function newModelScanBudget() {
    return { dirs: MODEL_SCAN_DIR_BUDGET, stats: MODEL_SCAN_STAT_BUDGET, truncated: false };
}
/**
 * Does this directory tree hold a model file, within a bounded depth? (#1371)
 *
 * TWO LEVELS, not one. A Diffusers/HF repo checkout is `<category>/<repo>/transformer/
 * model.safetensors` — one level down finds nothing there, and "found nothing" is read as
 * "this base is empty", which permits the misplaced write this gate exists to stop.
 *
 * Bounded on purpose: a models tree can hold tens of thousands of files, and this runs
 * synchronously inside a corroboration check on the download path. Depth 2 covers the real
 * layouts without turning that into a full walk. Deeper nesting than that is a false
 * negative — it proceeds with the existing unconfirmed-visibility warning, which is where
 * these users were before the gate existed.
 */
function directoryHoldsAModel(dir, depth = 2, budget = newModelScanBudget()) {
    if (depth <= 0)
        return false;
    if (budget.dirs <= 0) {
        budget.truncated = true;
        return false;
    }
    budget.dirs -= 1;
    let entries;
    try {
        entries = readdirSync(dir, { withFileTypes: true });
    }
    catch {
        // Permission-denied or absent: nothing established either way.
        return false;
    }
    for (const entry of entries) {
        if (isModelFileEntry(entry))
            return true;
    }
    for (const entry of entries) {
        if (budget.dirs <= 0) {
            budget.truncated = true;
            return false;
        }
        if (entryIsDirectoryLike(dir, entry, budget) &&
            directoryHoldsAModel(join(dir, entry.name), depth - 1, budget))
            return true;
    }
    return false;
}
async function corroborateBaseByModelInventory(base, targetCategory) {
    // The corroboration must be about the category this download is FOR. A complete match
    // on some OTHER category proves only that THAT folder is under this base — and a server
    // can legitimately read `diffusion_models` from here via an extra_model_paths entry
    // while reading `loras` from somewhere else entirely. Authorising the whole models root
    // on a sibling's evidence would then write the loras file where the server never looks,
    // and the downstream live-disagreement guard cannot catch it: an EMPTY local `loras/`
    // has nothing to contradict, which is exactly the first-download case.
    if (!targetCategory) {
        return {
            ok: false,
            reason: "this call did not name the model category it is downloading into, and corroboration " +
                "has to be about that category — a match on a different one would not establish that " +
                "the server reads THIS folder",
        };
    }
    const modelsDir = resolve(base, "models");
    let modelsDirIsDir;
    try {
        modelsDirIsDir = statSync(modelsDir).isDirectory();
    }
    catch (err) {
        const code = err?.code;
        return {
            ok: false,
            reason: code === "ENOENT" || code === "ENOTDIR"
                ? `it has no "models" directory`
                : `its "models" directory could not be inspected (${code ?? "unknown error"}), so nothing was established either way`,
        };
    }
    if (!modelsDirIsDir)
        return { ok: false, reason: `its "models" entry is not a directory` };
    // ONLY the target category — one question, one request. Scoping to it also removed the
    // /models enumeration and its per-category request budget, which existed solely because
    // this used to sweep for any category that happened to match.
    let unreadableCategories = 0;
    let lastReason;
    let emptyTargetListing = false;
    /** Set when the server's own listing shows the base is NOT a root it reads (#1371). */
    let contradictedBase = false;
    /** The local scan hit its bound before answering. See the assignment for why it matters. */
    let scanTruncated = false;
    for (const category of [targetCategory]) {
        let files;
        try {
            const res = await comfyApiFetch(`/models/${encodeURIComponent(category)}`);
            if (!res.ok) {
                unreadableCategories += 1;
                continue;
            }
            const json = (await res.json());
            if (!Array.isArray(json)) {
                unreadableCategories += 1;
                continue;
            }
            // A malformed ENTRY makes the whole listing inconclusive. Dropping it and matching
            // "the rest" would approve a destination while quietly not accounting for
            // everything the server said it sees — a complete match that is not complete.
            const malformed = json.filter((n) => typeof n !== "string" || n.trim() === "");
            if (malformed.length > 0) {
                unreadableCategories += 1;
                lastReason =
                    `the server's listing for "${category}" contained ${malformed.length} entr(y/ies) that ` +
                        "are not usable filenames, so what it sees there could not be established — and a " +
                        "match on only the readable remainder would not be the complete match this check requires";
                continue;
            }
            files = json;
        }
        catch {
            unreadableCategories += 1;
            continue;
        }
        if (files.length === 0) {
            emptyTargetListing = true;
            continue;
        }
        const categoryDir = join(modelsDir, category);
        // The CATEGORY name comes from the server too, so it gets the same containment
        // check as the files under it.
        if (!containedUnder(modelsDir, categoryDir)) {
            lastReason = `the server's category name "${category}" does not name a directory inside "${modelsDir}", so nothing under it could corroborate this base`;
            continue;
        }
        const missing = [];
        const unreadable = [];
        const notFiles = [];
        const escaping = [];
        // Containment has to be PHYSICAL. Sharing model files between installs by symlinking
        // them is ordinary practice, and a stale base whose `<category>/known.safetensors` is
        // a link to the file the server really reads would satisfy a lexical check while
        // proving the opposite: the evidence lives elsewhere, and a NEW download written here
        // would never appear to the server. `statSync` follows links, so nothing else notices.
        //
        // The canonical category must ALSO stay inside the canonical models root. A category
        // directory that is itself a link out of the tree is a real topology — writes through
        // it do land in the server's dir — but the download authorizer downstream refuses a
        // destination whose canonical path escapes the models root, so corroborating it here
        // would hand the caller an approval the next layer rejects: a contradiction between
        // two guards, which is worse than either answer. This check is deliberately the
        // NARROWER of the two so the layers agree; that topology keeps today's refusal.
        let realModelsDir;
        let realCategoryDir;
        try {
            realModelsDir = realpathSync(modelsDir);
            realCategoryDir = realpathSync(categoryDir);
        }
        catch (err) {
            const code = err?.code;
            lastReason =
                code === "ENOENT" || code === "ENOTDIR"
                    ? `"${categoryDir}" does not exist, so this base holds none of what the server lists under "${category}"`
                    : `"${categoryDir}" could not be canonicalized (${code ?? "unknown error"}), so nothing was established either way`;
            continue;
        }
        if (!containedUnder(realModelsDir, realCategoryDir)) {
            lastReason =
                `"${categoryDir}" resolves to "${realCategoryDir}", outside "${realModelsDir}" — a ` +
                    "category directory linked out of the models tree. Writes through it would reach the " +
                    "server, but the download authorizer refuses a destination whose canonical path leaves " +
                    "the models root, so corroborating it here would only be overruled a step later. Point " +
                    "COMFYUI_PATH at the directory that physically holds the models instead";
            continue;
        }
        files = uniqueModelInventoryFiles(files, categoryDir);
        for (const name of files) {
            const full = join(categoryDir, name);
            if (!containedUnder(categoryDir, full)) {
                escaping.push(name);
                continue;
            }
            const found = probeEntry(full);
            if (found.kind === "absent") {
                missing.push(name);
                continue;
            }
            if (found.kind === "not-a-file") {
                notFiles.push(name);
                continue;
            }
            if (found.kind === "indeterminate") {
                unreadable.push(`${name} (${found.detail ?? "unknown error"})`);
                continue;
            }
            let realEntry;
            try {
                realEntry = realpathSync(full);
            }
            catch (err) {
                unreadable.push(`${name} (${err?.code ?? "unresolvable link"})`);
                continue;
            }
            if (!containedUnder(realCategoryDir, realEntry))
                escaping.push(name);
        }
        // A PARTIAL match does NOT ordinarily corroborate — and my first attempt at this had it
        // backwards (codex gate, twice).
        //
        // The original complaint was real: requiring EVERY listed file to be here
        // assumes one root per category, and `extra_model_paths.yaml` lets ComfyUI
        // scan this directory AND others for the same one, so a file owned by an extra
        // root is legitimately absent. I relaxed the rule to accept any overlap.
        //
        // That was worse than the bug. The listing is FILENAMES ONLY, so a stale clone
        // holding `shared.safetensors` is indistinguishable from a genuine second root
        // holding `shared.safetensors` — and under the relaxed rule a single common
        // name authorized a multi-gigabyte download into an install the running server
        // never reads. A false refusal here is recoverable and tells the user exactly
        // what to set; a wrong destination reported as success is #369, which is the
        // harm this whole path exists to prevent. Between an unrecoverable wrong answer
        // and a recoverable refusal, an inconclusive rescue refuses.
        //
        // So the verdict remains a full match except for the narrow split-namespace shape
        // reported in #1147. The server listed one entry that is unavailable in this
        // process's namespace, while at least two of its other entries are physically
        // present here. This is not proof of the root, so the caller marks it partial and
        // keeps the downstream live-visibility check in force.
        if (missing.length === 0 && unreadable.length === 0 && notFiles.length === 0 && escaping.length === 0) {
            return { ok: true, modelsDir, category, matched: files.length, listed: files.length };
        }
        // Each failure mode says what it actually was. "Could not read it" is not "it is not
        // there", and neither is "something is there but it is not a file" — three different
        // fixes, and the old single "are not under" phrasing named only one of them.
        if (unreadable.length > 0) {
            // ORDERED BEFORE `missing` (codex gate): a mixed result would otherwise be
            // narrated purely as absence, and an entry we could not inspect is not an
            // entry we found to be gone. The inconclusive half decides the wording,
            // because it is the half that is not a finding.
            lastReason =
                `the server lists ${files.length} file(s) under "${category}", and ${unreadable.length} of them ` +
                    `could not be inspected under "${categoryDir}" (e.g. "${unreadable[0]}") — that is NOT a finding ` +
                    `that they are missing, so nothing was established either way` +
                    (missing.length > 0
                        ? ` (${missing.length} other(s) were not found there, but with the above unreadable that ` +
                            `does not settle it)`
                        : "");
        }
        else if (missing.length > 0) {
            const present = files.length - missing.length;
            if (missing.length === MAX_BOUNDED_NAMESPACE_ONLY_FILES &&
                present >= MIN_BOUNDED_NAMESPACE_MATCH_FILES &&
                unreadable.length === 0 &&
                notFiles.length === 0 &&
                escaping.length === 0) {
                return {
                    ok: false,
                    reason: `the server lists ${files.length} file(s) under "${category}" and ${present} are ` +
                        `present under "${categoryDir}", while ${missing.length} listed file is only ` +
                        "visible from the server namespace — a bounded split code/data layout is " +
                        "consistent with this one-entry difference, but the destination remains local " +
                        "configuration rather than live-authoritative",
                    safeFallback: "bounded-server-superset",
                };
            }
            // CONTRADICTION REQUIRES THIS BASE TO HAVE SOMETHING OF ITS OWN.
            //
            // present === 0 means none of the server's files for this category are here. That is
            // a stale base — or an extra_model_paths layout where this IS one of the server's
            // roots and simply has nothing in this category yet. A filename listing cannot tell
            // those apart, and refusing the second would block a working install, which is worse
            // than the wrong-directory write this gate exists to stop.
            //
            // An EMPTY (or absent) category dir here is therefore no evidence, not evidence
            // against. A base holding a different set of files for the same category is the case
            // that is actually diagnostic, and it is the reporter's: a second full install.
            if (present === 0) {
                // …and "has files of its own" must mean MODEL files (codex P0). Counting any
                // directory entry treats a `.gitkeep`, a `desktop.ini` or a `Thumbs.db` as proof
                // the base is a populated root — so a legitimate extra_model_paths root holding
                // nothing but metadata for this category would be refused, which is the working
                // install this whole refinement exists to protect. Only a real model file is
                // evidence that this base is a place models live.
                //
                // KNOWN AND DELIBERATE GAP: a category whose models live in SUBFOLDERS
                // (diffusers-style directories, `clip_vision/<dir>/model.bin`) reads as
                // metadata-only here, so a stale base of that shape is NOT contradicted and the
                // download proceeds with the existing unconfirmed-visibility note. That is the
                // safe direction — it returns the behaviour to what it was before this gate rather
                // than blocking anyone — and the alternative, treating any subdirectory as
                // evidence, re-opens the metadata hole with an empty folder. A missed contradiction
                // costs one misplaced file and a warning; a false one costs every download on a
                // working install.
                let baseHasOwnModelsHere = false;
                const budget = newModelScanBudget();
                // The budget is read BEFORE the link resolution, not after: `.some()` would
                // otherwise stat every one of thousands of entries and only then let the recursion
                // decline for want of budget (codex P2).
                //
                // Named rather than inlined because the guard must MARK the truncation on its way
                // out. Written inline as `budget.dirs > 0 && …` it short-circuited silently, so the
                // scan gave up and reported a clean negative — precisely the fold this budget was
                // being taught to avoid, committed in the line that avoids it.
                const subdirHoldsAModel = (entry) => {
                    if (budget.dirs <= 0) {
                        budget.truncated = true;
                        return false;
                    }
                    return (entryIsDirectoryLike(categoryDir, entry, budget) &&
                        directoryHoldsAModel(join(categoryDir, entry.name), 2, budget));
                };
                try {
                    // withFileTypes, because a DIRECTORY named `foo.safetensors` is not a model
                    // (codex): counting it would refuse a base on the strength of a folder name.
                    // A nested model directory (diffusers-style) still counts — see below.
                    // ONE budget for the whole scan, not one per subdirectory: the point of the
                    // bound is to cap this entire check, and a fresh budget per entry would let a
                    // category with 10k subdirectories spend 10k of them.
                    baseHasOwnModelsHere = readdirSync(categoryDir, { withFileTypes: true }).some((entry) => isModelFileEntry(entry) ||
                        // A SUBDIRECTORY counts too — symlinked ones included — but only when it
                        // actually holds a model file within a bounded depth. An empty folder is not
                        // evidence the base is populated: that is the metadata hole wearing a
                        // directory.
                        subdirHoldsAModel(entry));
                }
                catch {
                    // Absent or unreadable — nothing established either way.
                }
                contradictedBase = baseHasOwnModelsHere;
                // A TRUNCATED SCAN IS NOT A CLEAN NEGATIVE (codex P1, #796 discipline).
                //
                // Not finding a model means this base is a place models do not live, which
                // contradicts nothing and lets the download proceed. Giving up after the bound
                // means we do not know — and a stale base whose only model sits behind the 512th
                // directory reads identically to an empty one, which is the silent misplaced write
                // this whole check exists to stop. The download still proceeds (refusing on an
                // inconclusive scan would block working installs, which is worse), but the fact
                // that the check did not finish is stated rather than folded away.
                scanTruncated = budget.truncated && !baseHasOwnModelsHere;
            }
            lastReason =
                present === 0
                    ? `the server lists ${files.length} file(s) under "${category}" and NONE of them are under ` +
                        `"${categoryDir}" (e.g. "${missing[0]}"), so this base is not a directory the server ` +
                        `reads that category from`
                    : // The multi-root case, named rather than mislabelled as a wrong base.
                        `the server lists ${files.length} file(s) under "${category}" and ${present} of them are ` +
                            `under "${categoryDir}", but ${missing.length} are not (e.g. "${missing[0]}"). That is ` +
                            `consistent with an extra_model_paths layout where this is ONE of several roots for ` +
                            `"${category}" — but it is also what a stale copy sharing some filenames looks like, and ` +
                            `a filename listing cannot tell those apart. Rather than guess a destination, set ` +
                            `COMFYUI_PATH to the root that actually holds "${category}", or launch ComfyUI with an ` +
                            `absolute --base-directory`;
        }
        else if (notFiles.length > 0) {
            lastReason =
                `the server lists ${files.length} file(s) under "${category}", and ${notFiles.length} of the ` +
                    `matching entries under "${categoryDir}" are not regular files (e.g. "${notFiles[0]}")`;
        }
        else {
            lastReason =
                `the server lists ${escaping.length} entr(y/ies) under "${category}" whose path escapes ` +
                    `"${categoryDir}" (e.g. "${escaping[0]}"), so they cannot corroborate this base`;
        }
    }
    if (lastReason)
        return { ok: false, reason: lastReason, contradicted: contradictedBase, scanTruncated };
    // Nothing was COMPARED. Say which of the two reasons that was: the listing could not be
    // read, or it was empty. Reporting the first as the second would be the same fold this
    // whole change is about — and an EMPTY target category genuinely cannot corroborate
    // anything, which is the honest cost of scoping this to the category being written to.
    return {
        ok: false,
        reason: unreadableCategories > 0
            ? `the server's "${targetCategory}" listing could not be read, so there was nothing to compare against`
            : `the server lists no files under "${targetCategory}", so there is nothing there to show that this base is the directory it reads that category from`,
        ...(emptyTargetListing ? { safeFallback: "empty-target" } : {}),
    };
}
export async function resolveModelsDirWithBases(opts) {
    const baseDirs = new Set();
    let modelsDir;
    let source = "configured-base";
    const snapshot = { reachable: false };
    /** The live server's install root, resolved through the ONE canonical resolver. */
    let live;
    try {
        const stats = await getSystemStats();
        const argv = stats.system?.argv;
        const cwd = stats.system?.cwd;
        snapshot.reachable = true;
        snapshot.argv = argv;
        snapshot.cwd = cwd;
        // THE live install root (#369): argv when it resolves, else the OS-observed
        // process anchor for the relative-`main.py`-with-no-cwd shape that ComfyUI
        // Desktop and the Windows portable bundle both report. Computed ONCE here and
        // used for BOTH the code-root bases and the models dir, so the two can never
        // be derived from different notions of "live".
        live = resolveLiveServerRoot(argv, cwd, {
            remote: isRemoteMode(),
            includeProcessStart: true,
        });
        // Collect base-install dirs (LOCAL only) from the SAME call, regardless of how
        // the models dir resolves, so the code-root veto always has the real
        // --base-directory / live-root even when --models-directory diverges.
        if (!isRemoteMode()) {
            const baseDir = parseBaseDirFromArgv(argv, cwd);
            if (baseDir)
                baseDirs.add(resolve(baseDir));
            if (live.root) {
                baseDirs.add(resolve(live.root));
                // Publish it on the snapshot so downstream consumers (the extra-model-root
                // authorizer) use the SAME established root instead of re-deriving a weaker
                // one from argv.
                snapshot.liveRoot = resolve(live.root);
                snapshot.liveRootSource = live.source === "argv" ? "argv" : "observed-process";
            }
            if (live.observedStartedAtMs !== undefined) {
                snapshot.processStartedAtMs = live.observedStartedAtMs;
            }
        }
        const fromArgv = parseModelsDirFromArgv(argv, cwd);
        if (fromArgv) {
            logger.debug("Resolved ComfyUI models directory from launch argv", {
                modelsDir: fromArgv,
            });
            modelsDir = fromArgv;
            source = "argv-flag";
        }
        else if (!isRemoteMode()) {
            // No explicit --base-directory/--models-directory flag: derive the models
            // root from the LIVE connected server's OWN install root. Only adopt it when
            // it EXISTS locally (a Docker/forwarded server reports a container-side path
            // that is not the host's) — else fall through to the corroborated/refusing
            // logic below (#490/#463).
            if (live.root && existsSync(live.root)) {
                modelsDir = join(live.root, "models");
                source = live.source === "argv" ? "live-root" : "observed-root";
                logger.debug("Resolved ComfyUI models directory from the live server's install root", { modelsDir, source });
            }
        }
    }
    catch (err) {
        logger.debug("Could not resolve models dir from /system_stats; using COMFYUI_PATH/models", { error: err instanceof Error ? err.message : String(err) });
    }
    // The running server specified a RELATIVE --base-directory/--models-directory but
    // did not report its cwd, so its real models dir is UNKNOWN. Do NOT fall back to a
    // guessed local path (COMFYUI_PATH/models is the wrong place the server never
    // reads — #346). Fail loudly (outside the try so it propagates) so the caller
    // routes through the server / surfaces the problem rather than writing wrong.
    if (!modelsDir &&
        snapshot.reachable &&
        !isRemoteMode() &&
        hasUnresolvableRelativeModelDirFlag(snapshot.argv, snapshot.cwd)) {
        throw new ValidationError("The connected ComfyUI was launched with a RELATIVE --base-directory/--models-directory " +
            "and did not report its working directory, so its models directory cannot be resolved " +
            "locally. A download can't be placed safely here — connect with an absolute --base-directory, " +
            "or set COMFYUI_PATH to the server's install so the destination is unambiguous.");
    }
    // Effective LOCAL base: COMFYUI_PATH, else the saved default workspace when NOT
    // remote (#415/#416). Always a code-root base candidate too.
    const base = resolveEffectiveComfyUIBase();
    if (base)
        baseDirs.add(resolve(base));
    if (!modelsDir) {
        // The live server NAMED a relative `main.py` we could not pin to an install
        // (no cwd reported, and the OS process table could not identify the process or
        // its interpreter is not inside an install tree). COMFYUI_PATH is then NOT
        // evidence of anything: on the reporter's machine it was a SECOND, stale
        // install and 4.88 GB landed there while the running server never saw it
        // (#369). Accept it ONLY when it CORROBORATES what the server reported — the
        // very same relative `main.py` really exists under it. Otherwise refuse: an
        // honest "I don't know where this would land" beats a fabricated success.
        const anchored = base && live?.relDir !== undefined && live.source === "unresolved"
            ? anchorRelativeEntrypointOnBase(base, live.relDir)
            : undefined;
        if (anchored) {
            modelsDir = join(anchored, "models");
            source = "base-anchored";
            baseDirs.add(anchored);
            logger.debug("Anchored the live server's relative main.py on the configured base", { modelsDir, relDir: live?.relDir, anchored });
        }
        else if (snapshot.reachable && !isRemoteMode() && live?.source === "unresolved" && live.relDir !== undefined) {
            // Before refusing: the code-layout corroboration above needed a `main.py` at or
            // under the base, and a Docker/data-dir deployment genuinely has none — the code
            // lives inside the container (#851). Ask the SERVER what models it can see and
            // accept the base only if it explains that completely, or if the response is one of
            // the two bounded no-contradiction cases below. Runs only here, so it can rescue a
            // refusal but never redirect a download that already resolved.
            const inventory = base
                ? await corroborateBaseByModelInventory(base, (opts?.targetCategory ?? "").trim())
                : { ok: false, reason: "no COMFYUI_PATH or default workspace is configured" };
            if (inventory.ok) {
                modelsDir = inventory.modelsDir;
                source = "base-inventory-corroborated";
                baseDirs.add(resolve(base));
                // CORROBORATION, NOT PROOF (codex gate P1). The server's listing is
                // filenames only, so a stale local clone holding files with the same names
                // satisfies this check without being mounted into the running server at
                // all. There is no stronger local evidence available — the listing carries
                // no sizes or hashes to distinguish them — so this stays the last-resort
                // rescue it was built as, and the log says what it actually established
                // rather than implying the directory was proven live.
                //
                // Refusing on that doubt would be worse: it would reject the ordinary
                // Docker/data-dir layout this rescue exists to serve. What it must not do
                // is let a later reader take "corroborated" for "verified".
                logger.info("Corroborated the configured base against the running server's model inventory " +
                    "(filename match — evidence that this base is one of the roots the server reads, " +
                    "not proof that it is)", {
                    modelsDir,
                    category: inventory.category,
                    matched: inventory.matched,
                    listed: inventory.listed,
                    partial: inventory.matched < inventory.listed,
                    basis: "filename-inventory",
                });
                return { modelsDir, baseDirs: [...baseDirs], snapshot, source };
            }
            if (inventory.safeFallback) {
                modelsDir = resolve(base, "models");
                source =
                    inventory.safeFallback === "bounded-server-superset"
                        ? "base-inventory-partial"
                        : "configured-base";
                baseDirs.add(resolve(base));
                // Neither case proves that this local path is the server's primary root. The
                // bounded partial is the split-namespace recurrence from #1147; the empty
                // response is the first-download case. Returning a non-authoritative source is
                // what keeps resolveModelSubfolderWithLiveRoot's live-visibility gate active.
                logger.warn(inventory.safeFallback === "bounded-server-superset"
                    ? "Using a bounded partial model-inventory match for the configured base; the " +
                        "server-only entry may be in a namespace this process cannot read"
                    : "Using the configured base for a first download into a server-registered " +
                        "category whose live inventory is empty", {
                    modelsDir,
                    category: opts?.targetCategory?.trim(),
                    reason: inventory.reason,
                    basis: "filename-inventory",
                    authoritative: false,
                });
                return { modelsDir, baseDirs: [...baseDirs], snapshot, source };
            }
            throw new ValidationError("The models directory of the CONNECTED ComfyUI could not be determined, so a " +
                "download has no verified destination. What could not be determined:\n" +
                `  - the running server reported its launch script as the RELATIVE path "${join(live.relDir, "main.py")}" and did NOT report a working directory, so its install root is unknown;\n` +
                `  - the OS process table did not identify an interpreter for the ComfyUI listening on ${config.resolvedPort} that sits inside an install tree${live.observedPython ? ` (observed "${live.observedPython}")` : ""};\n` +
                `  - ${base
                    ? `the configured local base "${base}" corroborates neither reading of that path: it does not contain "${join(live.relDir, "main.py")}" (the ComfyUI Desktop / launcher-root shape), and it is not itself "${live.relDir}" holding "main.py" (the portable shape where COMFYUI_PATH already points at the ComfyUI directory) — so that check did NOT corroborate it, which is not the same as establishing it is a different install (a Docker/data-dir base legitimately has no main.py at all, #851)`
                    : "no COMFYUI_PATH or default workspace is configured"}.\n` +
                `  - asking the running server what models it can SEE did not corroborate it either: ${inventory.reason} (this is the check that covers a Docker/data-dir COMFYUI_PATH, which holds models/ but no main.py).\n` +
                "Refusing to write to a guessed directory (that is how a model lands in a stale install and is reported as a success). " +
                "Fix by launching ComfyUI with an ABSOLUTE --base-directory, or set COMFYUI_PATH to the directory whose models/ the server actually reads.");
        }
        else if (base) {
            // #1371 — DO NOT WRITE INTO A BASE THE SERVER DEMONSTRABLY DOES NOT READ.
            //
            // This fallback took COMFYUI_PATH/models with no corroboration at all, and it is
            // reached whenever the live root could not be derived for any reason OTHER than the
            // relative-main.py shape the branch above handles. A reporter running two local
            // ComfyUIs — connected to :8190, COMFYUI_PATH pointing at the other install — had a
            // model written into the install the server never reads, while the tool told them
            // "destination came from local configuration rather than the running server;
            // visibility to the connected ComfyUI is unconfirmed". It knew, and wrote anyway. A
            // warning beside a write is not a substitute for not writing.
            //
            // THE GATE IS EVIDENCE AGAINST, NOT ABSENCE OF EVIDENCE. Refusing whenever the base
            // cannot be corroborated would break every correct setup whose server reports no
            // resolvable root — per #1374 that includes the ordinary Windows-portable shape — and
            // it would block the FIRST download on any fresh install, whose models dir is empty
            // and therefore corroborates nothing. So this refuses only when the server's own
            // listing shows the base is not a root it reads: files listed for this category, none
            // of them here. Silence still writes, with the existing unconfirmed-visibility note.
            if (snapshot.reachable && !isRemoteMode()) {
                const verdict = await corroborateBaseByModelInventory(base, (opts?.targetCategory ?? "").trim());
                if (!verdict.ok && verdict.contradicted) {
                    throw new ValidationError(`Refusing to download into "${resolve(base, "models")}": ${verdict.reason}.

` +
                        `The graph you are downloading for is served by a DIFFERENT ComfyUI than this ` +
                        `configured base describes, so the file would land where that server never looks ` +
                        `and be reported as a success (#1371). Fix by pointing COMFYUI_PATH at the install ` +
                        `the connected server actually runs, or by launching that server with an absolute ` +
                        `--base-directory so its models directory can be read from it directly.`);
                }
                if (!verdict.ok && verdict.scanTruncated) {
                    // Proceeding, but not silently (codex P1). The refusal above needs the base to
                    // be PROVEN stale, and a scan that stopped early proves nothing — so the
                    // download goes ahead exactly as it did before this gate existed, with the one
                    // thing the user cannot otherwise know: the check did not finish.
                    logger.warn(`The local check of "${resolve(base, "models")}" did not finish — it stopped at its ` +
                        `directory bound before finding a model of its own. That is NOT a finding that this ` +
                        `base is empty, so the download proceeds; but if this install is stale, the file will ` +
                        `land where the connected server never looks (#1371). ${verdict.reason}.`, { modelsDir: resolve(base, "models"), basis: "filename-inventory", truncated: true });
                }
            }
            modelsDir = resolve(base, "models");
            source = "configured-base";
        }
        else {
            throw new ValidationError("No local ComfyUI models directory could be resolved. Set the COMFYUI_PATH " +
                "environment variable, save a default workspace with workspace (action:\"set_default\"), " +
                "or connect to a running ComfyUI so its models directory can be detected.");
        }
    }
    return { modelsDir, baseDirs: [...baseDirs], snapshot, source };
}
/**
 * Resolve the models directory the CONNECTED server actually reads from. Asks
 * the running ComfyUI (/system_stats argv → `--base-directory`) first; falls
 * back to `<COMFYUI_PATH>/models`. This is the source of truth for
 * download_model's destination so files land where the live server sees them
 * (issues #346/#369) rather than in a stale COMFYUI_PATH install. Delegates to
 * resolveModelsDirWithBases so the two can never drift.
 */
export async function resolveModelsDir() {
    return (await resolveModelsDirWithBases()).modelsDir;
}
/**
 * Best-effort: the running server's `--extra-model-paths-config` file, or
 * undefined when unreachable / not launched with the flag. Never throws.
 */
export async function resolveServerExtraModelConfig() {
    try {
        const stats = await getSystemStats();
        const configs = parseExtraModelPathsConfigsFromArgv(stats.system?.argv);
        return configs[0];
    }
    catch {
        return undefined;
    }
}
/** <COMFYUI_PATH>/output fallback. Throws if COMFYUI_PATH is unset. */
export function localOutputDirFallback() {
    // THE SAVED DEFAULT WORKSPACE COUNTS (#877). Reading `config.comfyuiPath`
    // alone asks whether ONE ENVIRONMENT VARIABLE is set, and that is not the
    // question — a local portable install is located perfectly well by the saved
    // default workspace, which `install_comfyui (action:"environment")` reports and every other resolver
    // in this codebase already consults. Keying off the env var made a perfectly
    // locatable install look pathless, and the caller upstream then fell through
    // to a data source that cannot see the disk and reported no outputs at all.
    //
    // `resolveEffectiveComfyUIBase` is that shared resolver: COMFYUI_PATH, then
    // the saved default workspace, and nothing in remote mode (where a local path
    // would name the wrong machine).
    const base = resolveEffectiveComfyUIBase();
    if (!base) {
        throw new ValidationError("No local ComfyUI install path could be established: COMFYUI_PATH is not set and no " +
            "default workspace is saved. Set COMFYUI_PATH, or save one with the workspace tool " +
            "(action 'set_default').");
    }
    return resolve(base, "output");
}
// ---------------------------------------------------------------------------
// Resolve ComfyUI's REAL input directory — the exact mirror of the output-dir
// logic above. ComfyUI can be launched with --input-directory (or
// --base-directory) which redirects the LoadImage / VHS_LoadVideo / LoadAudio
// search path away from the default <COMFYUI_PATH>/input. Filesystem-path tools
// that write or check files in the input directory must therefore NOT assume
// <COMFYUI_PATH>/input, or a server with a custom --input-directory rejects the
// file ("Invalid image file") while the tool reports success. Prefer the server
// API (/upload/image, see upload_image (action:"stage")) when possible; use this only
// for genuine local filesystem operations.
// ---------------------------------------------------------------------------
/**
 * Parse the configured input directory out of ComfyUI's launch argv.
 * --input-directory wins; otherwise --base-directory implies <base>/input.
 * Returns undefined when neither flag is present.
 */
export function parseInputDirFromArgv(argv, serverCwd) {
    if (!argv || argv.length === 0)
        return undefined;
    let inputDir;
    let baseDir;
    for (let i = 0; i < argv.length; i++) {
        inputDir = flagValue(argv, i, "--input-directory") ?? inputDir;
        baseDir = flagValue(argv, i, "--base-directory") ?? baseDir;
    }
    // ComfyUI resolves these command-line paths with os.path.abspath(), which
    // anchors relative values at the SERVER process cwd, not COMFYUI_PATH or
    // the MCP process cwd. Without an absolute server cwd, fail closed rather
    // than silently selecting a different local install.
    if (inputDir)
        return resolveServerLaunchPath(inputDir, serverCwd);
    const resolvedBase = baseDir ? resolveServerLaunchPath(baseDir, serverCwd) : undefined;
    if (resolvedBase)
        return join(resolvedBase, "input");
    return undefined;
}
/** <COMFYUI_PATH>/input fallback. Throws if COMFYUI_PATH is unset. */
export function localInputDirFallback() {
    // The exact mirror of the output fallback above, and the same #877 reasoning:
    // these two must agree about where the install is, or an upload and the listing
    // of what was uploaded would disagree.
    const base = resolveEffectiveComfyUIBase();
    if (!base) {
        throw new ValidationError("No local ComfyUI install path could be established: COMFYUI_PATH is not set and no " +
            "default workspace is saved. Set COMFYUI_PATH, or save one with the workspace tool " +
            "(action 'set_default').");
    }
    return resolve(base, "input");
}
// ---------------------------------------------------------------------------
// Resolve ComfyUI's REAL temp directory — the same argv chain as input/.
// VHS_VideoCombine writes completed .mp4 files here whenever `save_output` is
// unchecked (`folder_paths.get_temp_directory()`), and get_image list_outputs
// must scan that tree or a finished render lists as nothing (#2370).
// --temp-directory wins; otherwise --base-directory implies <base>/temp.
// ---------------------------------------------------------------------------
/**
 * Parse the configured temp directory out of ComfyUI's launch argv.
 * --temp-directory wins; otherwise --base-directory implies <base>/temp.
 * Returns undefined when neither flag is present.
 */
export function parseTempDirFromArgv(argv, serverCwd) {
    if (!argv || argv.length === 0)
        return undefined;
    let tempDir;
    let baseDir;
    for (let i = 0; i < argv.length; i++) {
        tempDir = flagValue(argv, i, "--temp-directory") ?? tempDir;
        baseDir = flagValue(argv, i, "--base-directory") ?? baseDir;
    }
    if (tempDir)
        return resolveServerLaunchPath(tempDir, serverCwd);
    const resolvedBase = baseDir ? resolveServerLaunchPath(baseDir, serverCwd) : undefined;
    if (resolvedBase)
        return join(resolvedBase, "temp");
    return undefined;
}
/** <COMFYUI_PATH>/temp fallback. Throws if COMFYUI_PATH is unset. */
export function localTempDirFallback() {
    const base = resolveEffectiveComfyUIBase();
    if (!base) {
        throw new ValidationError("No local ComfyUI install path could be established: COMFYUI_PATH is not set and no " +
            "default workspace is saved. Set COMFYUI_PATH, or save one with the workspace tool " +
            "(action 'set_default').");
    }
    return resolve(base, "temp");
}
/**
 * Resolve the directory ComfyUI actually reads inputs from. Asks the running
 * ComfyUI (/system_stats argv) first; falls back to <COMFYUI_PATH>/input.
 */
/**
 * `<live install root>/<kind>` when the CONNECTED server identifies it, else
 * undefined (#1052, #2539).
 *
 * The argv parse above only answers when ComfyUI was launched with an EXPLICIT
 * --input-directory / --output-directory. Without one, resolution used to fall
 * straight through to the configured/auto-detected install — and on a machine
 * with more than one ComfyUI that is a different tree from the one actually
 * running.
 *
 * A reporter with ComfyUI Desktop installed alongside the git checkout they were
 * connected to had `train_prepare_dataset` refs resolve against Desktop and fail
 * "image not found", while the files sat in the connected server's output dir
 * the whole time. The tool's own description promises refs resolve "against the
 * connected ComfyUI's output/input dirs", so the contract was right and the
 * resolution was not.
 *
 * Two live rungs, in this order:
 *
 *  1. `resolveLiveComfyUIBase` (#2194 / #1052) — argv `main.py` + cwd when that
 *     is absolute. `get_image` action:get type:input uses this when no
 *     `--input-directory` is present, so a second install in COMFYUI_PATH is
 *     not preferred over the connected server.
 *  2. `resolveLiveServerRoot` (#2539 / #369) — OS-observed process when argv is
 *     the relative `ComfyUI/main.py` shape Desktop and the Windows portable
 *     bundle both report (no --output-directory, no cwd). That last shape is
 *     why `get_image list_outputs` scanned an unrelated COMFYUI_PATH while
 *     `/view` served the live server's files.
 *
 * An explicit --output-directory still wins above, and the configured install
 * still catches everything below.
 */
function liveIoDirFromSnapshot(kind, snapshot) {
    if (!snapshot.reachable)
        return undefined;
    // #2539 — relative ComfyUI/main.py with no cwd cannot be derived from argv;
    // resolveLiveServerRoot may use the correlated local process observation for
    // that shape. The snapshot is passed through unchanged so the explicit-dir
    // and live-root answers can never describe different server instances.
    const live = resolveLiveServerRoot(snapshot.argv, snapshot.cwd, { remote: false });
    if (!live.root)
        return undefined;
    // A Docker/forwarded server reports a container-side path that is not
    // host-local. Same gate resolveModelsDir uses for an observed root. An argv
    // root remains the server's direct claim, preserving the existing not-found
    // behavior when that claimed path is unavailable locally.
    if (live.source !== "argv" && !existsSync(live.root))
        return undefined;
    return join(live.root, kind);
}
export async function resolveInputDir() {
    const snapshot = await getLiveServerSnapshot();
    if (snapshot.reachable) {
        const fromArgv = parseInputDirFromArgv(snapshot.argv, snapshot.cwd);
        if (fromArgv) {
            logger.debug("Resolved ComfyUI input directory from launch argv", {
                inputDir: fromArgv,
            });
            return fromArgv;
        }
    }
    // #1052 — before the CONFIGURED install, try the one that is actually
    // running. With two ComfyUIs on a machine these differ, and the connected
    // server is the one whose files the caller means.
    const live = liveIoDirFromSnapshot("input", snapshot);
    if (live) {
        logger.debug("Resolved ComfyUI input directory from the LIVE server's install root", {
            dir: live,
        });
        return live;
    }
    return localInputDirFallback();
}
/**
 * Resolve the directory ComfyUI actually writes outputs to. Asks the running
 * ComfyUI (/system_stats argv) first; falls back to <COMFYUI_PATH>/output.
 */
export async function resolveOutputDir() {
    const snapshot = await getLiveServerSnapshot();
    if (snapshot.reachable) {
        const fromArgv = parseOutputDirFromArgv(snapshot.argv);
        if (fromArgv) {
            logger.debug("Resolved ComfyUI output directory from launch argv", {
                outputDir: fromArgv,
            });
            return fromArgv;
        }
    }
    // #1052 — before the CONFIGURED install, try the one that is actually
    // running. With two ComfyUIs on a machine these differ, and the connected
    // server is the one whose files the caller means.
    const live = liveIoDirFromSnapshot("output", snapshot);
    if (live) {
        logger.debug("Resolved ComfyUI output directory from the LIVE server's install root", {
            dir: live,
        });
        return live;
    }
    return localOutputDirFallback();
}
/**
 * Resolve the directory ComfyUI actually writes temp/preview files to. Asks the
 * running ComfyUI (/system_stats argv) first; falls back to <COMFYUI_PATH>/temp.
 */
export async function resolveTempDir() {
    const snapshot = await getLiveServerSnapshot();
    if (snapshot.reachable) {
        const fromArgv = parseTempDirFromArgv(snapshot.argv, snapshot.cwd);
        if (fromArgv) {
            logger.debug("Resolved ComfyUI temp directory from launch argv", {
                tempDir: fromArgv,
            });
            return fromArgv;
        }
    }
    const live = liveIoDirFromSnapshot("temp", snapshot);
    if (live) {
        logger.debug("Resolved ComfyUI temp directory from the LIVE server's install root", {
            dir: live,
        });
        return live;
    }
    return localTempDirFallback();
}
//# sourceMappingURL=output-dir.js.map