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

2,072 lines • 101 kB
import { execFile } from "node:child_process";
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { homedir, platform } from "node:os";
import { basename, dirname, isAbsolute, join, resolve as pathResolve, sep } from "node:path";
import { promisify } from "node:util";
import { config, getComfyUIBaseUrl, isRemoteMode } from "../config.js";
import { normalizeInstallPathEnv } from "../utils/install-path-env.js";
import { getSystemStats } from "../comfyui/client.js";
import { observeLiveServerProcess, resolveLiveInterpreter } from "./live-interpreter.js";
import { hasUnresolvableRelativeBaseDirFlag, parseBaseDirFromArgv, rawFlagValue, } from "./launch-argv.js";
import { logger } from "../utils/logger.js";
import { ValidationError } from "../utils/errors.js";
const execFileAsync = promisify(execFile);
/**
 * Resolve the path to the workspace config JSON file.
 * Uses XDG_CONFIG_HOME when set, otherwise ~/.config/comfyui-mcp/workspace.json.
 */
function defaultWorkspaceConfigPath() {
    const xdg = process.env.XDG_CONFIG_HOME;
    const root = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
    return join(root, "comfyui-mcp", "workspace.json");
}
// Module-level override hook so tests can point at a temp file. Defaults to the
// platform config path lazily (so env changes in tests are picked up before set).
let configPathOverride;
export function configureWorkspace(opts) {
    configPathOverride = opts.configPath;
}
export function resetWorkspaceConfig() {
    configPathOverride = undefined;
}
// Whether THIS MCP process launched the connected LOCAL ComfyUI during this session
// via a python `spawn` (which inherits process.env). When true, the running server
// shares our environment, so it is safe to expand `$VAR`/`${VAR}`/`%VAR%` in its live
// extra_model_paths config against process.env. A server we did NOT launch (separately
// started, possibly with a DIFFERENT env, or a Desktop-app launch whose env we don't
// share) must NOT have its config vars expanded against our env — that could authorize
// a wrong-place download destination (#633 P1b). Set ONLY on the env-inheriting python
// spawn path, cleared on stop; module-scoped, so it resets each MCP process lifetime.
//
// This flag is ENV-TRUST ONLY. The interpreter we launched with is recorded in
// live-interpreter.ts (recordLaunchedInterpreter) — the ONE launch record, trusted
// only after PID + creation-time identity validation. A second, unvalidated record
// here was the stale-record hazard: our child dies, another server takes the port
// under the same install root, and pip/update work would have been directed into the
// OLD interpreter while claiming to be exact (#401 re-gate).
let localComfyUILaunchedByUs = false;
/** Record that this MCP process spawned the local ComfyUI (env inherited). */
export function markLocalComfyUILaunched() {
    localComfyUILaunchedByUs = true;
}
/** Clear the launched-by-us flag (on stop, and a test seam). */
export function resetLocalComfyUILaunchState() {
    localComfyUILaunchedByUs = false;
}
/** True when this MCP process launched the connected local ComfyUI (shares our env). */
export function didLaunchLocalComfyUI() {
    return localComfyUILaunchedByUs;
}
function workspaceConfigPath() {
    return configPathOverride ?? defaultWorkspaceConfigPath();
}
/**
 * Synchronous read of the saved default workspace (set via workspace action:"set_default").
 * Mirrors readWorkspaceConfig()'s validation but is sync, so sync filesystem-path
 * resolvers (e.g. model-resolver.getModelsRoot) can consult the saved default
 * workspace without going async — this is what lets local downloads / model
 * lookups work when COMFYUI_PATH isn't set but a default workspace is saved.
 * Returns undefined when unset, invalid, or unreadable.
 */
export function getSavedDefaultWorkspaceSync() {
    const path = workspaceConfigPath();
    try {
        if (!existsSync(path))
            return undefined;
        const parsed = JSON.parse(readFileSync(path, "utf-8"));
        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
            return undefined;
        }
        const dw = parsed.defaultWorkspace;
        if (typeof dw === "string" && dw.trim().length > 0)
            return dw;
        return undefined;
    }
    catch {
        return undefined;
    }
}
/**
 * QUESTION 1 — "where is a ComfyUI install on THIS machine?"
 *
 * Answers only that. It says nothing about whether the current session is pointed at
 * that install, and callers must not read it as though it did.
 *
 *   1. config.comfyuiPath — COMFYUI_PATH env or auto-detection (wins).
 *   2. the saved DEFAULT WORKSPACE (workspace action:"set_default").
 *
 * Use this ONLY where the operation is about the local machine as such and the caller
 * has established the session mode itself (panel installation is the real example: the
 * panel lives in the local install, and its callers gate on `isLocalMode()` first).
 */
export function resolveLocalWorkspaceBase() {
    return config.comfyuiPath ?? getSavedDefaultWorkspaceSync();
}
/**
 * QUESTION 2 — "which install does THIS OPERATION act on?"
 *
 * The single source of truth for every filesystem-backed tool (download_model,
 * node_pack (action:"verify"), model lookups, extra-paths, comfy-cli, apply_manifest's
 * adoption). Returns undefined whenever the session is NOT pointed at a local install,
 * because then no directory on this machine is the thing being operated on.
 *
 * The two questions used to share one answer, and that is #490: this function returned
 * `config.comfyuiPath` BEFORE looking at the mode, so a remote `--comfyui-url` session
 * with a stale local COMFYUI_PATH — the ordinary local configuration — was handed that
 * unrelated local install as "the target". Its own docstring promised the opposite. A
 * read-only caller got a wrong answer about the wrong tree; `comfy-cli uninstall` and
 * named-snapshot save DELETED FROM and WROTE INTO an install nobody had asked about,
 * while their replies talked only about the remote server. That is the #369 harm class,
 * and it survived being closed once because the comment claimed the guarantee the code
 * did not implement.
 *
 * SCOPE, stated precisely because an overclaiming comment is how this survived being
 * closed once: this refuses in REMOTE (`--comfyui-url`) mode. It does NOT refuse in
 * CLOUD mode (`isCloudMode()`, an API key), where the serving ComfyUI is equally not
 * this machine and the same wrong-install hazard exists in principle. That variant is
 * unfixed here and is not claimed to be fixed. Cloud is diverted at the client layer
 * (`comfyui/client.ts`) rather than at this resolver, so it is a different seam and
 * wants its own change; `isLocalMode()` is the check it would use.
 *
 * Returns undefined when no usable local target exists — callers then either detect a
 * live server base dir (/system_stats) or emit a clear, actionable error. They must not
 * substitute a local path of their own; doing so re-creates the bug one level up.
 */
export function resolveEffectiveComfyUIBase() {
    if (isRemoteMode())
        return undefined;
    return resolveLocalWorkspaceBase();
}
/**
 * The checkout/code root for synchronous code-facing operations (pip, .venv,
 * core git). Pack reads/writes stay on the data/base root (#1715/#1770).
 *
 * Split deployments may keep input/output/user/models — and, on
 * `--base-directory` runtimes, `custom_nodes` — under COMFYUI_PATH while
 * running main.py and .venv from a separate checkout. An explicit
 * COMFYUI_CODE_PATH names that checkout. When it is absent, this intentionally
 * preserves the historical COMFYUI_PATH/default-workspace behavior.
 */
export function resolveEffectiveComfyUICodeBase() {
    if (isRemoteMode())
        return undefined;
    return config.comfyuiCodePath ?? resolveEffectiveComfyUIBase();
}
/**
 * Live-aware code-root resolver for asynchronous filesystem operations.
 *
 * A reachable local server's observed main.py root is strongest evidence of
 * the checkout actually serving this session (pip/venv/core git). This is NOT
 * the pack root: on `--base-directory` deployments ComfyUI scans custom_nodes/
 * from the data/base directory, which `resolveEffectiveComfyUIBaseLive` answers.
 *
 * Deliberately does NOT apply `hasUnresolvableRelativeBaseDirFlag`. That gate
 * fail-closes the DATA resolver so we never treat main.py as the pack root
 * when `--base-directory` cannot be resolved. For CODE operations, main.py IS
 * the right tree even then.
 *
 * COMFYUI_CODE_PATH is the explicit offline/unresolvable fallback, followed
 * by the legacy effective base for backward compatibility. No directory-layout
 * guessing is performed.
 */
export async function resolveEffectiveComfyUICodeBaseLive() {
    if (isRemoteMode())
        return undefined;
    const snapshot = await getLiveServerSnapshot();
    if (snapshot.reachable) {
        const live = resolveLiveServerRoot(snapshot.argv, snapshot.cwd, { remote: false });
        if (live.root && existsSync(live.root) && hasMainPy(live.root)) {
            return live.root;
        }
    }
    return config.comfyuiCodePath ?? resolveEffectiveComfyUIBase();
}
/**
 * The ASYNC, live-aware variant of `resolveEffectiveComfyUIBase` (#1653, #1715).
 *
 * The sync resolver answers from CONFIGURATION alone (COMFYUI_PATH, then the
 * saved default workspace), so a loopback session with neither configured is
 * told "no local install" even while `install_comfyui(action:"environment")`
 * is reporting the running server's own install root as `local.workspace_path`
 * with `python_probe_trusted:true` — one machine, two contradictory answers,
 * and the writing tools (node_pack scaffold/publish) believed the wrong one.
 *
 * The resolution order:
 *   1. The live server's `--base-directory` (#1715). When the running server
 *      was launched with one, ComfyUI scans custom_nodes/ from THAT directory
 *      and nowhere else — on ComfyUI Desktop it is the user-data dir (e.g.
 *      ~/Documents/ComfyUI), NOT the code install root that holds main.py. A
 *      pack written under the install root is invisible to that runtime, so
 *      the server's own self-report wins over configuration here — the same
 *      precedence the download path already applies (#346: a server whose
 *      --base-directory differs from COMFYUI_PATH roots the download there).
 *      Adopted only when it resolves to an absolute path that exists on disk.
 *   2. `resolveEffectiveComfyUIBase()` — configuration (COMFYUI_PATH, then the
 *      saved default workspace). Unchanged, and never overridden by anything
 *      but the runtime's own base directory above.
 *   3. `liveRootFromArgv()` — the live connected server's own install root
 *      (the `main.py` directory from `/system_stats` argv), filling the gap
 *      when nothing is configured (#1653). That is the server's SELF-REPORT of
 *      where it runs from, the same source `workspace action:"get"` surfaces
 *      as `workspace_source:"live-server"` (#769) and `apply_manifest` adopts
 *      (#463).
 *
 * The live roots are adopted only when they exist on disk — the install root
 * additionally must look like a ComfyUI install (models/ or custom_nodes/
 * present, the same validity check `resolveLocalManifestBase` applies) —
 * because an argv-derived path is the server's claim, and the disk check is
 * what keeps a stale or relocated report from becoming a write target.
 *
 * FAIL CLOSED: when argv carries a `--base-directory` that cannot be resolved
 * (a relative value with no absolute server cwd), every root we could derive
 * names a DIFFERENT tree than the one being served, so the main.py root is NOT
 * adopted (the same gate `hasUnresolvableRelativeBaseDirFlag` documents).
 * Returns undefined (never throws) when nothing checks out; the caller then
 * refuses with an actionable error, exactly as before.
 */
export async function resolveEffectiveComfyUIBaseLive() {
    const configured = resolveEffectiveComfyUIBase();
    const snapshot = await getLiveServerSnapshot();
    if (snapshot.reachable) {
        const baseDir = parseBaseDirFromArgv(snapshot.argv, snapshot.cwd);
        if (baseDir && existsSync(baseDir))
            return baseDir;
        if (configured)
            return configured;
        // A --base-directory we cannot resolve overrides the main.py root: that
        // root is a confidently WRONG answer here, not merely an unproven one.
        if (hasUnresolvableRelativeBaseDirFlag(snapshot.argv, snapshot.cwd)) {
            return undefined;
        }
        const liveBase = liveRootFromArgv(snapshot.argv, snapshot.cwd);
        if (liveBase &&
            existsSync(liveBase) &&
            (existsSync(join(liveBase, "models")) || existsSync(join(liveBase, "custom_nodes")))) {
            return liveBase;
        }
        return undefined;
    }
    return configured;
}
async function customNodesScanBaseLiveOutcome(options = {}) {
    if (isRemoteMode())
        return { base: undefined, unavailableBaseDirectory: undefined };
    const snapshot = await getLiveServerSnapshot();
    if (snapshot.reachable) {
        const baseDir = parseBaseDirFromArgv(snapshot.argv, snapshot.cwd);
        // A present flag is authoritative even when its directory is temporarily
        // unavailable. Falling through to the main.py checkout here is unsafe:
        // ComfyUI still scans custom_nodes under the configured base once it is
        // reachable, so node_pack would scaffold into an invisible tree.
        const rawBaseDir = rawFlagValue(snapshot.argv, "--base-directory");
        if (rawBaseDir !== undefined &&
            (baseDir === undefined || !existsSync(baseDir))) {
            return { base: undefined, unavailableBaseDirectory: rawBaseDir };
        }
        if (baseDir && existsSync(baseDir)) {
            return { base: baseDir, unavailableBaseDirectory: undefined };
        }
        // Without --base-directory, folder_paths.base_path is the checkout that
        // holds main.py — NOT COMFYUI_PATH / workspace_path, which may only be
        // the extra_model_paths data root.
        const live = resolveLiveServerRoot(snapshot.argv, snapshot.cwd, { remote: false });
        if (live.root && existsSync(live.root) && hasMainPy(live.root)) {
            return { base: live.root, unavailableBaseDirectory: undefined };
        }
    }
    if (options.requireLive) {
        return { base: undefined, unavailableBaseDirectory: undefined };
    }
    return { base: resolveEffectiveComfyUIBase(), unavailableBaseDirectory: undefined };
}
export async function resolveCustomNodesScanBaseLive(options) {
    return (await customNodesScanBaseLiveOutcome(options)).base;
}
/**
 * Authoring operations must preserve an explicit unavailable --base-directory
 * as a refusal. A bare undefined is otherwise a valid "no live evidence"
 * result, and the synchronous service APIs intentionally use it to fall back
 * to COMFYUI_PATH. That fallback is unsafe when ComfyUI itself has already
 * selected a different, currently unavailable scan root.
 */
export async function resolveCustomNodesScanBaseLiveStrict(options) {
    const result = await customNodesScanBaseLiveOutcome(options);
    if (result.unavailableBaseDirectory !== undefined) {
        throw new ValidationError(`The connected ComfyUI declares --base-directory "${result.unavailableBaseDirectory}" ` +
            "but that directory is currently unavailable. Refusing to use COMFYUI_PATH or " +
            "the main.py checkout because custom_nodes would be written where this runtime " +
            "does not scan. Make the base directory reachable and retry.");
    }
    return result.base;
}
/** Canonical spelling for comparing two install roots. realpath resolves the
 *  symlink/`/private/var` spellings of one tree; a failure keeps the lexical
 *  form (a path that cannot be canonicalized is compared as written). Case is
 *  folded only where the platform's filesystem is case-insensitive by default —
 *  folding can only ever make two roots look EQUAL, i.e. suppress a refusal,
 *  which is the safe direction for a check that fails open. */
function canonicalRoot(p) {
    let out = pathResolve(p);
    try {
        out = realpathSync(out);
    }
    catch {
        /* unresolvable — compare the lexical form */
    }
    return platform() === "win32" || platform() === "darwin" ? out.toLowerCase() : out;
}
/** Do these two paths name the SAME install? */
export function sameInstallRoot(a, b) {
    return canonicalRoot(a) === canonicalRoot(b);
}
/**
 * Is `base` — the root a LOCAL filesystem write is about to land in — a
 * different install from the one this session is connected to?
 *
 * #1765 recurrence. On a machine with six ComfyUI installs, `install_custom_node`
 * (`source:"git"`) cloned an unregistered pack into the SAVED DEFAULT WORKSPACE
 * while the session was connected to `http://127.0.0.1:8189`. The call reported
 * `git-clone` success; only the returned `nodeDir` named the wrong tree, and the
 * connected server never received the pack. The cause is that the write resolved
 * its target through `resolveEffectiveComfyUIBase()` — which answers from
 * CONFIGURATION alone (`COMFYUI_PATH` env, then auto-detection, then the saved
 * default workspace) — and never asked `resolveLiveServerRoot`, the resolver this
 * file documents as "the single notion every write-side caller must resolve
 * through, so they can never disagree about which install is the live one".
 *
 * This does NOT pick a winner. It answers only "are these provably two different
 * installs?", so the caller can REFUSE and name both, instead of silently writing
 * into whichever one configuration happened to name. Preferring the live root
 * silently would be a different bug: on a `--base-directory` split install an
 * explicit `COMFYUI_PATH` IS the pack root (#1715/#1770), and #1766 shipped that
 * design for this very issue.
 *
 * Hence the scope, stated precisely because an overclaiming comment is how the
 * original survived: a mismatch is reported ONLY when
 *
 *   1. the session is LOCAL (a remote target has no local write to misdirect —
 *      the callers refuse that separately, and earlier);
 *   2. the connected server PROVES a pack root — its resolved `--base-directory`,
 *      else its own `main.py` root — and that root exists on disk and looks like
 *      an install;
 *   3. the two are not the same tree after canonicalization.
 *
 * FAILS OPEN everywhere else, deliberately: an unreachable server, a relative
 * `main.py` that no process observation can anchor, or a `--base-directory` we
 * cannot resolve all mean we do not KNOW the write is misdirected — and a refusal
 * on a root we merely failed to prove would break every install that works today.
 * Never throws.
 *
 * THERE IS NO "BUT THE USER SET COMFYUI_PATH" CARVE-OUT, and the first revision
 * of this function had one. It was wrong: the panel orchestrator resolves
 * `envComfyuiPath || detectLocalComfyUIPath()` and forwards the RESULT to every
 * child MCP as `COMFYUI_PATH` (orchestrator/index.ts), so in a panel-spawned
 * child an auto-detected guess is byte-identical to a user's deliberate setting.
 * A carve-out keyed on "the env var is set" would therefore have disabled this
 * check in exactly the sessions it exists for, while reading as careful scoping.
 *
 * KNOWN FALSE POSITIVE, stated rather than implied: a deployment that adds
 * `custom_nodes` to the live server's `extra_model_paths` config pointing at the
 * configured base IS read by that server, and this cannot see that — it would be
 * refused. The refusal names both roots and NOTHING is written, and the Manager
 * install route (which targets the connected server directly) is untouched, so
 * the cost is a refusal on an exotic layout rather than a wrong write on a
 * common one. Corroborating against the server's own extra paths is the fix if
 * that shape shows up; it is a different seam and is not claimed here.
 */
export async function detectLocalWriteTargetMismatch(base) {
    if (!base || isRemoteMode())
        return undefined;
    const envPath = normalizeInstallPathEnv(process.env.COMFYUI_PATH).path;
    const savedDefault = getSavedDefaultWorkspaceSync();
    const baseSource = envPath && sameInstallRoot(envPath, base)
        ? "comfyui-path"
        : savedDefault && sameInstallRoot(savedDefault, base)
            ? "saved-default-workspace"
            : "auto-detected";
    const snapshot = await getLiveServerSnapshot();
    if (!snapshot.reachable)
        return undefined;
    // 1. The runtime's OWN base directory. When it is set, ComfyUI scans
    //    custom_nodes/ from there and nowhere else (#1715).
    const baseDir = parseBaseDirFromArgv(snapshot.argv, snapshot.cwd);
    if (baseDir) {
        if (!safeExists(baseDir))
            return undefined;
        return sameInstallRoot(baseDir, base)
            ? undefined
            : { base, baseSource, liveRoot: baseDir, liveSource: "base-directory" };
    }
    // A --base-directory that cannot be resolved makes EVERY root we could derive
    // name a different tree than the one being served — including the main.py root
    // below. Nothing is provable here.
    if (hasUnresolvableRelativeBaseDirFlag(snapshot.argv, snapshot.cwd))
        return undefined;
    // 2. The server's own install root — WITHOUT --base-directory, the directory
    //    it scans custom_nodes/ from. `resolveLiveServerRoot` is the module's one
    //    notion of that, and BOTH its tiers are used here.
    //
    //    An earlier revision used `liveRootFromArgv` alone, on the reasoning that
    //    the `observed-process` tier anchors on where the interpreter BINARY lives
    //    and its own docstring calls that a "KNOWN GAP … not proof of where the
    //    SCRIPT was resolved from" — too weak a basis for refusing a user's write.
    //    That reasoning was refuted by measurement against the ComfyUI running on
    //    the development machine (0.33.2, ComfyUI Desktop):
    //
    //      argv[0] : "ComfyUI\main.py"      (RELATIVE)
    //      cwd     : the /system_stats payload has no `cwd` FIELD AT ALL
    //
    //    `liveRootFromArgv` resolves a relative script only against an absolute
    //    server cwd, and ComfyUI does not report one — so the argv tier answers
    //    only for launches whose argv[0] is absolute. ComfyUI Desktop, the Windows
    //    portable bundle, and `comfy launch` (which runs `python main.py` from the
    //    workspace) are all relative. Argv-only made this check permanently INERT
    //    on the layouts where several installs on one machine is most likely —
    //    green tests, zero reachability.
    //
    //    The `observed-process` tier is also what the DOWNLOAD path already
    //    resolves its write DESTINATION through (#369/#1374), so trusting it to
    //    detect a mismatch is strictly weaker than what already ships. Its gap is
    //    real and bounded: with a stale bundle's python on PATH it can name a
    //    stale root, which here costs a refusal that writes nothing — never a
    //    wrong write.
    const live = resolveLiveServerRoot(snapshot.argv, snapshot.cwd, { remote: false });
    if (!live.root || live.source === "unresolved")
        return undefined;
    if (!safeExists(live.root))
        return undefined;
    if (!safeExists(join(live.root, "models")) && !safeExists(join(live.root, "custom_nodes"))) {
        return undefined;
    }
    return sameInstallRoot(live.root, base)
        ? undefined
        : { base, baseSource, liveRoot: live.root, liveSource: live.source };
}
/**
 * The install root a LOCAL, DESTRUCTIVE operation may act on — or the reason it
 * must not act at all.
 *
 * This began as a workaround for #490: `resolveEffectiveComfyUIBase()` used to
 * return `config.comfyuiPath` BEFORE consulting remote mode, so with
 * `--comfyui-url` and a stale local `COMFYUI_PATH` it handed back an install with
 * nothing to do with the connected server. Reading through that is survivable;
 * DELETING through it is not.
 *
 * That resolver is now fixed (it checks remote mode first), so this is no longer
 * the only thing standing between a destructive path and the wrong tree. It stays
 * because it answers a question `undefined` cannot: WHY there is no target. A
 * caller that must refuse needs to tell the user whether the install is missing or
 * merely unreachable from here, and those have different remedies.
 */
export function resolveLocalMutationTarget() {
    if (isRemoteMode()) {
        return {
            refusal: "this session targets a REMOTE ComfyUI (--comfyui-url), so there is no local " +
                "install this operation can safely modify. " +
                (config.comfyuiPath
                    ? `COMFYUI_PATH is set to ${config.comfyuiPath}, but that is a DIFFERENT install ` +
                        `from the server you are connected to — acting on it would modify something ` +
                        `you are not looking at, while the checks around it describe the remote server. `
                    : "") +
                "Run this against the machine the install lives on.",
        };
    }
    const base = resolveEffectiveComfyUIBase();
    if (!base) {
        return {
            refusal: "no local ComfyUI install path could be established: COMFYUI_PATH is unset and no " +
                "default workspace is saved (set one with the workspace tool, action 'set_default').",
        };
    }
    return { base };
}
/**
 * The LIVE connected server's own install root, derived from its /system_stats
 * launch argv (the `main.py` path — see liveRootFromArgv). This is the ComfyUI
 * that is ACTUALLY running, so it is the source of truth for where a download /
 * node install must land — even when COMFYUI_PATH is unset, OR points at a
 * DIFFERENT, stale install than the connected one (#490/#463). Returns undefined
 * in remote mode (the live root is a path on the REMOTE host, not usable as a
 * local target), when the server is unreachable, or when argv yields no
 * resolvable absolute root. Best-effort and NEVER throws.
 */
export async function resolveLiveComfyUIBase() {
    if (isRemoteMode())
        return undefined;
    try {
        const stats = await getSystemStats();
        return liveRootFromArgv(stats.system?.argv, stats.system?.cwd);
    }
    catch {
        return undefined;
    }
}
/**
 * Keep values from the server's JSON response at the runtime boundary. The
 * SystemStats TypeScript type describes the normal ComfyUI response, but the
 * response is still untrusted JSON at runtime; a shape such as `argv: [1]`
 * must not reach the string-only launch-argv parsers.
 */
function validatedLaunchArgv(value) {
    return Array.isArray(value) && value.every((token) => typeof token === "string")
        ? value
        : undefined;
}
function validatedServerCwd(value) {
    return typeof value === "string" ? value : undefined;
}
/**
 * ONE `/system_stats` snapshot for callers that need to derive several things from the
 * SAME server state (the launch flags AND the install root), rather than issuing two
 * calls that could straddle a restart — the same invariant `resolveModelsDirWithBases`
 * keeps for the download destination. `reachable: false` covers remote mode (the server's
 * paths are on another host) and any failure. Never throws.
 */
export async function getLiveServerSnapshot() {
    if (isRemoteMode())
        return { reachable: false };
    try {
        const stats = await getSystemStats();
        const system = stats && typeof stats === "object" && stats.system && typeof stats.system === "object"
            ? stats.system
            : undefined;
        return {
            reachable: true,
            argv: validatedLaunchArgv(system?.argv),
            cwd: validatedServerCwd(system?.cwd),
        };
    }
    catch {
        return { reachable: false };
    }
}
async function readWorkspaceConfig() {
    const path = workspaceConfigPath();
    if (!existsSync(path))
        return {};
    try {
        const raw = await readFile(path, "utf-8");
        const parsed = JSON.parse(raw);
        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
            logger.warn("Workspace config is not a JSON object, ignoring", { path });
            return {};
        }
        // Validate the shape rather than blindly casting: defaultWorkspace must be a
        // non-empty string when present, else it is dropped.
        const cfg = {};
        const dw = parsed.defaultWorkspace;
        if (typeof dw === "string" && dw.trim().length > 0) {
            cfg.defaultWorkspace = dw;
        }
        else if (dw !== undefined) {
            logger.warn("Ignoring invalid defaultWorkspace in workspace config", {
                path,
                type: typeof dw,
            });
        }
        return cfg;
    }
    catch (err) {
        logger.warn("Failed to parse workspace config, ignoring", {
            path,
            error: err instanceof Error ? err.message : err,
        });
        return {};
    }
}
async function writeWorkspaceConfig(cfg) {
    const path = workspaceConfigPath();
    await mkdir(dirname(path), { recursive: true });
    await writeFile(path, JSON.stringify(cfg, null, 2), "utf-8");
}
// ---------------------------------------------------------------------------
// ComfyUI install auto-detection
// (mirrors detectComfyUIPaths logic in src/config.ts — kept local because that
//  helper is not exported and config.ts is owned by another unit)
// ---------------------------------------------------------------------------
/**
 * Auto-detect ComfyUI installation directories. Checks common locations on
 * macOS, Linux, and Windows. Returns all found paths, most-preferred first.
 */
export function detectComfyUIInstalls() {
    const home = homedir();
    const candidates = [];
    // macOS: ComfyUI Desktop app stores data here
    candidates.push(join(home, "Documents", "ComfyUI"));
    // macOS: Application Support
    candidates.push(join(home, "Library", "Application Support", "ComfyUI"));
    // Common manual install locations
    candidates.push(join(home, "ComfyUI"));
    candidates.push(join(home, "code", "ComfyUI"));
    candidates.push(join(home, "projects", "ComfyUI"));
    candidates.push(join(home, "src", "ComfyUI"));
    // Linux common paths
    candidates.push("/opt/ComfyUI");
    candidates.push(join(home, ".local", "share", "ComfyUI"));
    // Windows common paths
    candidates.push(join(home, "AppData", "Local", "ComfyUI"));
    candidates.push(join(home, "Desktop", "ComfyUI"));
    // Windows: ComfyUI Desktop app installs here
    candidates.push(join(home, "AppData", "Local", "Programs", "ComfyUI", "resources", "ComfyUI"));
    // Scan ~/Documents and ~/My Documents for any ComfyUI-named directories
    const documentsDirs = [join(home, "Documents"), join(home, "My Documents")];
    for (const dir of documentsDirs) {
        try {
            if (!existsSync(dir))
                continue;
            const entries = readdirSync(dir, { withFileTypes: true });
            for (const entry of entries) {
                if (entry.isDirectory() && entry.name.toLowerCase().includes("comfyui")) {
                    const fullPath = join(dir, entry.name);
                    if (!candidates.includes(fullPath))
                        candidates.push(fullPath);
                }
            }
        }
        catch {
            // Ignore permission errors
        }
    }
    return candidates.filter((p) => {
        if (!existsSync(p))
            return false;
        if (!p.includes("Documents"))
            return true;
        return existsSync(join(p, "models")) || existsSync(join(p, "custom_nodes"));
    });
}
export async function getWorkspace() {
    const cfg = await readWorkspaceConfig();
    const apiTarget = getComfyUIBaseUrl();
    let source;
    if (config.comfyuiPath) {
        // config.comfyuiPath is COMFYUI_PATH env or auto-detection
        // #1512 — normalized so a whitespace-only value is not reported as "env"
        // while config.comfyuiPath actually came from auto-detection.
        source = normalizeInstallPathEnv(process.env.COMFYUI_PATH).path ? "env" : "auto-detected";
    }
    else if (cfg.defaultWorkspace) {
        source = "default-config";
    }
    else {
        source = "none";
    }
    const configuredPath = config.comfyuiPath ?? cfg.defaultWorkspace;
    // #769 — `install_comfyui (action:"environment")` has always reported the live server's own install
    // root as the local workspace, resolved from the process it is talking to.
    // Reporting "none" here for the SAME machine was a straight contradiction,
    // and it sent users hunting for a misconfiguration that did not exist. Only
    // fills a GAP: a configured workspace still wins and is never overridden.
    if (!configuredPath) {
        const liveRoot = await resolveLiveComfyUIBase();
        if (liveRoot) {
            return {
                workspace_path: liveRoot,
                workspace_source: "live-server",
                default_workspace: cfg.defaultWorkspace,
                api_target: apiTarget,
                transient: true,
            };
        }
    }
    return {
        workspace_path: configuredPath,
        workspace_source: source,
        default_workspace: cfg.defaultWorkspace,
        api_target: apiTarget,
    };
}
export async function setDefaultWorkspace(path) {
    const trimmed = path.trim();
    if (trimmed.length === 0) {
        throw new ValidationError("Workspace path must be a non-empty string.");
    }
    const cfg = await readWorkspaceConfig();
    cfg.defaultWorkspace = trimmed;
    await writeWorkspaceConfig(cfg);
    return {
        saved: true,
        default_workspace: trimmed,
        config_path: workspaceConfigPath(),
        exists: existsSync(trimmed),
    };
}
export async function listWorkspaces() {
    const cfg = await readWorkspaceConfig();
    const detected = detectComfyUIInstalls();
    // Merge detected installs with active path and saved default so the caller
    // sees a complete picture even if one isn't in the detection list.
    const paths = new Set(detected);
    if (config.comfyuiPath)
        paths.add(config.comfyuiPath);
    if (cfg.defaultWorkspace)
        paths.add(cfg.defaultWorkspace);
    // #769 — the install we are ACTUALLY connected to belongs in the list, even
    // when it sits somewhere `detectComfyUIInstalls`'s well-known-locations scan
    // never looks. Returning an empty list for a machine with a running ComfyUI
    // is the same contradiction `workspace(get)` had.
    const liveRoot = await resolveLiveComfyUIBase();
    if (liveRoot)
        paths.add(liveRoot);
    const workspaces = [...paths].map((p) => ({
        path: p,
        active: p === config.comfyuiPath,
        is_default: p === cfg.defaultWorkspace,
        looks_valid: existsSync(join(p, "models")) || existsSync(join(p, "custom_nodes")),
    }));
    return {
        active_workspace: config.comfyuiPath,
        default_workspace: cfg.defaultWorkspace,
        workspaces,
    };
}
// ---------------------------------------------------------------------------
// install_comfyui (action:"environment") — mirrors comfy-cli env
// ---------------------------------------------------------------------------
const IS_WIN = platform() === "win32";
/** Run a command quietly; return trimmed stdout or undefined on any failure. */
async function probe(cmd, args, opts) {
    try {
        const { stdout, stderr } = await execFileAsync(cmd, args, {
            cwd: opts?.cwd,
            timeout: 8000,
            windowsHide: true,
        });
        const out = (stdout || stderr || "").trim();
        return out.length > 0 ? out : undefined;
    }
    catch {
        return undefined;
    }
}
/**
 * The launch SCRIPT token of a server argv: a POSITIONAL argument — one appearing BEFORE
 * the first `-`/`--` option token — that ends in `main.py`/`main.pyw`, with surrounding
 * quotes stripped. Python's `sys.argv[0]` is the script, so a main.py-shaped token that
 * appears only AFTER an option is that option's value or a later argument, never the
 * launch script: on a main-less `python -m comfyui … --extra-model-paths-config
 * /host/main.py` launch the CONFIG value would otherwise be accepted as the script and
 * self-prove the server's locality, letting a same-spelled host file be read/written
 * (#648 review). Returns the unquoted token, or undefined when argv has no positional
 * script (scanning stops at the first option token).
 */
function scriptTokenFromArgv(argv) {
    if (!Array.isArray(argv))
        return undefined;
    for (const rawArg of argv) {
        if (typeof rawArg !== "string")
            continue;
        // Strip surrounding quotes a launcher may leave on the path.
        const a = rawArg.trim().replace(/^["']+/, "").replace(/["']+$/, "");
        if (a.startsWith("-"))
            return undefined; // options begin — no positional script beyond
        // Must actually END in main.py / main.pyw (boundary guards against notmain.py).
        if (/(^|[\\/])main\.pyw?$/i.test(a))
            return a;
    }
    return undefined;
}
/**
 * Derive the ABSOLUTE directory that holds the running server's `main.py` from its
 * `/system_stats` argv (Python's `sys.argv`, whose argv[0] is the script path). This
 * is the LIVE running instance's install root — the source of truth for which python
 * is actually running ComfyUI (#401 / PR #433 review). Robust against the argv shapes
 * codex flagged: surrounding quotes are stripped; only a POSITIONAL token before the
 * first option counts as the script — never a flag's VALUE (scriptTokenFromArgv,
 * #648 review); a RELATIVE `ComfyUI/main.py` or a bare `main.py` is resolved against
 * the server's reported cwd WHEN AVAILABLE; and if the main.py path cannot be resolved
 * to an absolute directory we return `undefined` (UNRESOLVED) rather than a
 * bogus/relative root — callers must NOT then silently fall back to a persisted
 * default and mark it "live".
 */
export function liveRootFromArgv(argv, cwd) {
    // Deliberately NOT delegating to liveScriptFromArgv. This function feeds the #633
    // download-authorization path, and dirname(scriptPath) normalizes two leaves that this
    // body returns verbatim (a bare "main.py" returns `cwd` exactly; `C:\x\.\main.py`
    // returns `C:\x\.`). The values are equivalent after resolve() — which every caller
    // applies — but "equivalent" is not "identical", and this is not the function to take
    // that risk in. What the two functions SHARE is only the script-token extraction
    // (scriptTokenFromArgv); the return shaping stays separate, pinned by a cross-check test.
    const a = scriptTokenFromArgv(argv);
    if (a === undefined)
        return undefined;
    const dir = dirname(a);
    if (dir === "." || dir === "") {
        return cwd && isAbsolute(cwd) ? cwd : undefined;
    }
    if (isAbsolute(dir))
        return dir;
    if (cwd && isAbsolute(cwd))
        return pathResolve(cwd, dir);
    return undefined;
}
/**
 * The same derivation as `liveRootFromArgv` but returning the `main.py` FILE itself
 * rather than its directory. `dirname()` of this equals `liveRootFromArgv`'s result after
 * normalization (a cross-check test pins that for every argv shape).
 *
 * The file path is what a caller needs to follow a SYMLINK: ComfyUI locates its implicit
 * `extra_model_paths.yaml` next to `os.path.realpath(__file__)`, so a launcher that keeps
 * `/launcher/main.py` symlinked to `/installs/B/main.py` reads `/installs/B/…`. Only the
 * script path can be realpath'd; the directory cannot (the symlink is on the file).
 * Callers that must not follow symlinks — notably the #633 authorization path, which is
 * anchored to the lexical argv root — keep using `liveRootFromArgv`.
 *
 * Like `liveRootFromArgv`, only the POSITIONAL launch-script token counts (shared
 * `scriptTokenFromArgv`) — a main.py-shaped flag VALUE is never the script (#648 review).
 */
export function liveScriptFromArgv(argv, cwd) {
    return liveScriptResolutionFromArgv(argv, cwd).path;
}
function liveScriptResolutionFromArgv(argv, cwd) {
    const a = scriptTokenFromArgv(argv);
    if (a === undefined)
        return { indeterminate: false };
    const dir = dirname(a);
    if (dir === "." || dir === "") {
        // Bare "main.py" — only resolvable via an absolute cwd.
        return cwd && isAbsolute(cwd)
            ? realpathIfPresent(pathResolve(cwd, a))
            : { indeterminate: false };
    }
    if (isAbsolute(dir))
        return realpathIfPresent(a);
    // Relative dir (e.g. "ComfyUI/main.py") — resolve against the server's cwd.
    if (cwd && isAbsolute(cwd))
        return realpathIfPresent(pathResolve(cwd, a));
    return { indeterminate: false }; // cannot resolve to an absolute dir → UNRESOLVED
}
/** Resolve a launch script through symlinks when it exists, preserving the
 * lexical result for callers that need to report the exact failed path. Errors
 * other than a missing path are marked indeterminate; authorization callers
 * must inspect that flag instead of treating the lexical path as evidence. */
function realpathIfPresent(path) {
    try {
        return { path: realpathSync(path), indeterminate: false };
    }
    catch (error) {
        if (isMissingPathError(error))
            return { path, indeterminate: false };
        return { path, indeterminate: true };
    }
}
function isMissingPathError(error) {
    if (typeof error !== "object" || error === null || !("code" in error))
        return false;
    const code = error.code;
    return code === "ENOENT" || code === "ENOTDIR";
}
/**
 * The RELATIVE directory of the running server's `main.py` (e.g. `"ComfyUI"` for
 * `ComfyUI\main.py`, `"."` for a bare `main.py`). ComfyUI **Desktop** reports exactly
 * this — a relative argv[0] with no `cwd` — so `liveRootFromArgv` cannot resolve a
 * live root and interpreter resolution used to fall back to `COMFYUI_PATH` (the
 * bundle root), picking the launcher's `standalone-env` python instead of the
 * server's own `ComfyUI/.venv` (#401 recurrence). Callers anchor this against a
 * configured base and confirm a `main.py` is really there. Shares the positional-only
 * script extraction of `scriptTokenFromArgv` (#648 review). Returns `undefined` when
 * argv has no main.py, or when its dir is already absolute (use `liveRootFromArgv`).
 */
export function liveRelDirFromArgv(argv) {
    const a = scriptTokenFromArgv(argv);
    if (a === undefined)
        return undefined;
    const dir = dirname(a);
    if (isAbsolute(dir))
        return undefined;
    return dir === "" ? "." : dir;
}
/** How far up from the observed interpreter we look for the live install root.
 *  Covers every layout ComfyUI ships: `<root>/python_embeded/python.exe` (1 up),
 *  `<root>/.venv/bin/python` (2 up), and a nested `<bundle>/ComfyUI/.venv/Scripts/
 *  python.exe` re-anchored on `ComfyUI` (4 up). Bounded so a stray `main.py` far
 *  above the install can never be mistaken for the server's. */
const OBSERVED_ROOT_MAX_ASCENT = 5;
/**
 * Env directories that only a ComfyUI BUNDLE ships — a portable/Desktop layout keeps
 * its interpreter here, one level ABOVE the server root (`<bundle>/python_embeded/
 * python.exe` + `<bundle>/ComfyUI/main.py`).
 *
 * A generic `.venv`/`venv`/`env` is deliberately NOT in this set. Those live
 * anywhere: a server started from `C:\Tools\venv\Scripts\python.exe` with a stale
 * `C:\Tools\ComfyUI\main.py` beside it would otherwise have that unrelated install
 * accepted as "observed" — a wrong destination presented as verified success (codex
 * gate, round 13). A generic venv still qualifies via the "interpreter is INSIDE the
 * candidate root" rule, which is what the real `<root>/.venv/...` layout satisfies.
 * Compared case-insensitively (Windows).
 */
const BUNDLE_ENV_DIR_NAMES = new Set([
    "python_embeded",
    "python_embedded",
    "standalone-env",
]);
/** Binary subdirectories that may sit below a bundle env dir. */
const ENV_BIN_DIR_NAMES = new Set(["scripts", "bin"]);
/**
 * Is `python` positioned INSIDE the install rooted at `root`, anchored from `base`?
 *
 * Two accepted shapes, and nothing else:
 *  1. the interpreter lives under `root` itself (`<root>/.venv/Scripts/python.exe`);
 *  2. it sits in a BUNDLE env directory of the bundle `base` that `root` was
 *     anchored on (`<bundle>/python_embeded/python.exe` + `<bundle>/ComfyUI/main.py`)
 *     — a layout only a ComfyUI portable/Desktop bundle has.
 *
 * Without this test the ascent is unsound (codex gate, round 3): a server started
 * with a SYSTEM python (`C:\Python311\python.exe`) has ancestors that are not its
 * install at all, so walking up to `C:\` and finding a stale `C:\ComfyUI\main.py`
 * would confidently name the WRONG install as live — reintroducing the very bug.
 * Shape 2 is restricted to BUNDLE env dirs for the same reason (round 13): a generic
 * `C:\Tools\venv` says nothing about a `C:\Tools\ComfyUI` that happens to sit beside it.
 */
function interpreterBelongsToInstall(python, base, root) {
    const py = pathResolve(python);
    const pyDir = dirname(py);
    const within = (parent, child) => {
        const p = pathResolve(parent);
        const c = pathResolve(child);
        return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
    };
    if (within(root, py))
        return true;
    if (!within(base, py))
        return false;
    const rel = pyDir.slice(pathResolve(base).length).split(sep).filter(Boolean);
    if (rel.length === 0)
        return true; // interpreter sits directly in the bundle root
    const [first, ...rest] = rel.map((seg) => seg.toLowerCase());
    return (BUNDLE_ENV_DIR_NAMES.has(first) && rest.every((seg) => ENV_BIN_DIR_NAMES.has(seg)));
}
/**
 * Anchor a RELATIVE `main.py` dir on the interpreter the OS says the live ComfyUI
 * process is running. Walk up from the interpreter and accept the first ancestor
 * under which `<ancestor>/<relDir>/main.py` really exists — but ONLY when the
 * interpreter is positioned inside that install (interpreterBelongsToInstall), so
 * an unrelated `main.py` further up the filesystem can never be adopted. Returns
 * undefined when nothing on the bounded path qualifies.
 *
 * Returns the accepted install root together with the ancestor it was resolved
 * AGAINST. That ancestor is the working directory the server must have had for its
 * relative `main.py` to name this install — the fact `/proc/<pid>/cwd` supplies on
 * Linux and nothing supplied on Windows (#535). It falls out of the walk for free;
 * discarding it was why the restart path had no Windows anchor to use.
 */
/**
 * KNOWN GAP, measured and filed rather than implied — this anchors on where the
 * BINARY lives, which is not proof of where the SCRIPT was resolved from.
 *
 * Review raised it against the #1374 image fallback: with a stale portable
 * bundle's python on PATH, `cd D:\live && python ComfyUI\main.py` reports the
 * STALE interpreter while the server runs the LIVE script, and the bundle-shape
 * containment happily accepts `C:\stale\ComfyUI` — the #369 failure mode.
 *
 * Measured on this branch, both readings behave IDENTICALLY: an absolute argv[0]
 * naming that same stale python anchors the stale root exactly as the image does.
 * So the image fallback does not introduce this; it is a property of anchoring on
 * the interpreter at all, and it has been reachable via argv[0] since that tier
 * shipped. What the fallback changes is how many launch shapes reach the tier.
 *
 * Not fixed here because the fix is not local to this function: it needs a
 * corroboration the server itself can supply (the models dir it actually reads),
 * which is a different change from "make the relative-argv case resolvable".
 * Tracked separately; do NOT paper over it by tightening the containment test,
 * which would only shrink the set of installs that work without making any
 * remaining answer more trustworthy.
 */
function anchorRelDirOnInterpreter(python, relDir) {
    if (!isAbsolute(python))
        return undefined;
    let dir = dirname(pathResolve(python));
    for (let i = 0; i < OBSERVED_ROOT_MAX_ASCENT; i++) {
        const candidate = pathResolve(dir, relDir);
        if (hasMainPy(candidate) && interpreterBelongsToInstall(python, dir, candidate)) {
            return { root: candidate, anchorDir: dir };
        }
        const parent = dirname(dir);
        if (parent === dir)
            break;
        dir = parent;
    }
    return undefined;
}
/**
 * The BINARY the OS reports for the ComfyUI on our port — to anchor its install
 * root on, which is the only thing this file does with it.
 *
 * Prefers the interpreter (argv[0] of the running process). Falls back to the OS's
 * own image record when argv[0] is relative or bare (#1374): the stock Windows
 * portable bundle launches `.\python_embeded\python.exe`, and an activated venv
 * launches a bare `python`, so on those installs argv[0] names no file to anchor on
 * — the tier built for the relative-`main.py` shape then failed closed on the very
 * layouts it exists to serve, and the download bounced to ComfyUI-Manager (a hard
 * failure wherever Manager is not loaded). The fallback is weaker on purpose: for a
 * venv Windows reports the BASE interpreter, which sits OUTSIDE the install and is
 * rejected by anchorRelDirOnInterpreter's containment test — so it can only ever add
 * a resolution, never move one.
 *
 * DELIBERATELY NOT CACHED. `observeLiveServerProcess` shells out (netstat/WMI on
 * Windows, lsof on POSIX), so memoizing it is tempting — but this answer decides
 * WHERE A DOWNLOAD IS WRITTEN. A cache keyed on port+argv cannot tell a restarted
 * server apart from the one it replaced (a relaunch of ComfyUI reports the same
 * relative `ComfyUI\main.py` on the same port), so any reuse window is a window in
 * which a download can be written into the PREVIOUS install — the exact failure
 * #369 is about (codex gate, round 1). Each resolution re-observes the process
 * that is live right now; correctness here outranks a few hundred milliseconds.
 */
function observeLivePython(argv) {
    let statsHost;
    try {
        statsHost = new URL(getComfyUIBaseUrl()).hostname;
    }
    catch {
        /* unparseable target → no host filter */
    }
    try {
        const live = observeLiveServerProcess({
            port: config.resolvedPort,
            host: statsHost,
            remote: false,
            serverArgv: argv,
        });
        const binary = live?.python ?? live?.image;
        // The PID travels with the interpreter (#535). A caller that is about to STOP
        // a process must be able to confirm the anchor describes that very process
        // and not some other ComfyUI — an interpreter path alone cannot prove it.
        return live &&
            (binary || live.launchScript || live.launchScriptInvalid || live.startedAtMs !== undefined)
            ? {
                python: binary,
                pid: live.pid,
                launchScript: live.launchScript,
                launchScriptInvalid: live.launchScriptInvalid,
                startedAtMs: live.startedAtMs,
            }
            : undefined;
    }
    catch {
        return undefined;
    }
}
/**
 * THE live ComfyUI server's install root — the single notion every write-side
 * caller (download destination, package install) must resolve through, so they can
 * never disagree about which install is "the live one".
 *
 * Two tiers, both anchored on something OBSERVED:
 *
 *  1. `argv` — the server's OWN `/system_stats` argv resolves to an absolute
 *     `main.py` directory (absolute argv[0], or a relative one plus an absolute
 *     server-reported cwd). The server told us where it lives.
 *
 *  2. `observed-process` — the server reported a RELATIVE `main.py` and NO cwd.
 *     This is the shape ComfyUI **Desktop** and the Windows **portable** bundle
 *     both report (`ComfyUI\main.py`), and it is exactly why #369 kept recurring:
 *     with argv unresolvable, the download destination silently fell through to
 *     COMFYUI_PATH — a DIFFERENT, stale install — and the model landed where the
 *     running server never reads. So we ask the OS instead: `observeLiveServerProcess`
 *     identifies the process listening on our port (correlated against the server's
 *     own argv, so a proxy can't impersonate it) and reports its absolute launch
 *     script when available, otherwise the binary it runs — its interpreter, or the
 *     OS's own image record when the launcher spelled the interpreter relatively
 *     (#1374); the relative `main.py` dir is re-anchored on that evidence.
 *
 * Anything else is `unresolved`. There is deliberately NO layout-guess tier: a
 * COMFYUI_PATH that merely looks plausible is what wrote 4.88 GB into the wrong
 * install. Callers decide what an unresolved root means for them (a WRITE must
 * refuse or fall back only to something independently corroborated).
 *
 * Never throws. `remote` short-circuits to unresolved — a remote server's paths are
 * on another host and no local process is it.
 */
export function resolveLiveServerRoot(argv, cwd, opts) {
    const relDir = liveRelDirFromArgv(argv);
    const lexicalFromArgv = liveRootFromArgv(argv, cwd);
    const argvScriptResolution = liveScriptResolutionFromArgv(argv, cwd);
    const remote = opts?.remote ?? isRemoteMode();
    let observedStartedAtMs = opts?.observedStartedAtMs;
    let observed;
    if (observedStartedAtMs === undefined && opts?.includeProcessStart && !remote) {
        observed = observeLivePython(argv);
        observedStartedAtMs = observed?.startedAtMs;
    }
    if (argvScriptResolution.indeterminate) {
        return { source: "unresolved", relDir, observedStartedAtMs };
    }
    const argvScript = argvScriptResolution.path;
    const fromArgv = argvScript ? dirname(argvScript) : lexicalFromArgv;
    // Preserve the historical unresolved/missing-path behavior, but never let an
    // existing directory, FIFO, or dangling symlink named main.py vouch for its
    // parent as a live install.
    if (argvScript) {
        const entry = pathEntryExists(argvScript);
        if (entry === "indeterminate")
            return { source: "unresolved", relDir, observedStartedAtMs };
        if (entry === "present" && regularFileState(argvScript) !== "file") {
            return { source: "unresolved", relDir, observedStartedAtMs };
        }
    }
    if (fromArgv)
        return { root: fromArgv, source: "argv", relDir, observedStartedAtMs };
    if (remote || relDir === undefined)
        return { source: "unresolved", relDir, observedStartedAtMs };
    let observedPython = opts?.observedPython;
    let observedPid = opts?.observedPid;
    let observedLaunchScript = opts?.observedLaunchScript;
    let observedLaunchScriptInvalid = opts?.observedLaunchScriptInvalid;
    if (!observedPython && !observedLaunchScript && !observedLaunchScriptInvalid) {
        observed ??= observeLivePython(argv);
        observedPython = observed?.python;
        observedPid = observed?.pid;
        observedLaunchScript = observed?.launchScript;
        observedLaunchScriptInvalid = observed?.launchScriptInvalid;
        observedStartedAtMs ??= observed?.startedAtMs;
    }
    // A correlated but unusable launch script is explicit contradictory evidence.
    // It must not be erased by anchoring the same observation on its interpreter.
    if (observedLaunchScriptInvalid) {
        return { source: "unresolved", relDir, observedPython, observedPid, observedStartedAtMs };
    }
    if (observedLaunchScript) {
        const resolvedLaunchScript = realpathIfPresent(observedLaunchScript);
        if (!resolvedLaunchScript.path || resolvedLaunchScript.indeterminate) {
            return { source: "unresolved", relDir, observedPython, observedPid, observedStartedAtMs };
        }
        if (resolvedLaunchScript.path) {
            const resolvedPath = resolvedLaunchScript.path;
            const root = dirname(resolvedPath);
            if (isAbsolute(observedLaunchScript) &&
                /(^|[\\/])main\.pyw?$/i.test(observedLaunchScript) &&
                regularFileState(resolvedPath) === "file" &&
                hasMainPy(root)) {
                return {
                    root,
                    source: "observed-process",
                    relDir,
                    observedPython,
                    observedPid,
                    observedLaunchScript: resolvedPath,
                    observedStartedAtMs,
                };
            }
        }
        // An explicit observed script that did not authorize its root is not a
        // reason to fall through to an interpreter anchor from another tree.
        return { source: "unresolved", relDir, observedPython, observedPid, observedStartedAtMs };
    }
    if (!observedPython)
        return { source: "unresolved", relDir, observedStartedAtMs };
    const anchored = anchorRelDirOnInterpreter(observedPython, relDir);
    if (anchored) {
        return {
            root: anchored.root,
            source: "observed-process",
            relDir,
            observedPython,
            observedPid,
            observedLaunchScript,
            anchorDir: anchored.anchorDir,
            observedStartedAtMs,
        };
    }
    return {
        source: "unresolved",
        relDir,
        observedPython,
        observedPid,
        observedLaunchScript,
        observedStartedAtMs,
    };
}
/**
 * Resolve the Python interpreter that ACTUALLY belongs to a ComfyUI install
 * `root`, honoring EVERY layout ComfyUI ships with — portable Windows builds keep
 * python under `standalone-env` / `python_embeded` (NOT just `.venv`/`venv`). Used
 * by apply_manifest's pip installs and the cloned-node deps installer so those run
 * under the install's OWN interpreter, not a bare system `python` that would
 * contaminate the host env while reporting success (#463 codex review). Returns
 * the first candidate present on disk, else a bare platform python name as a last
 * resort. `undefined` root → bare python.
 */
export function resolveRootInterpreter(root) {
    const names = IS_WIN ? ["python.exe", "python"] : ["python3", "python"];
    if (root) {
        // Candidates directly under `root` ONLY — see interpreterCandidates: this is a
        // mutating path (pip installs) with no live server to verify a nested guess.
        for (const c of candidatesIn(root)) {
            if (safeExists(c))
                return c;
        }
    }
    return names[0];
}
/** existsSync that never throws and never touches UNC paths (a dead network share
 *  can block existsSync for seconds). */
function safeExists(p) {
    if (/^\\\\/.test(p))
        return false;
    try {
        return existsSync(p);
    }
    catch {
        return false;
    }
}
/** Does this directory hold a ComfyUI entrypoint (`main.py`/`main.pyw`)? */
function hasMainPy(dir) {
    return safeFileExists(join(dir, "main.py")) || safeFileExists(join(dir, "main.pyw"));
}
function regularFileState(path) {
    if (/^\\\\/.test(path))
        return "indeterminate";
    try {
        return statSync(path).isFile() ? "file" : "not-file";
    }
    catch (error) {
        return isMissingPathError(error) ? "not-file" : "indeterminate";
    }
}
function safeFileExists(path) {
    return regularFileState(path) === "file";
}
function pathEntryExists(path) {
    if (/^\\\\/.test(path))
        return "indeterminate";
    try {
        lstatSync(path);
        return "present";
    }
    catch (error) {
        return isMissingPathError(error) ? "missing" : "indeterminate";
    }
}
/**
 * Public form of the entrypoint test, so a caller that must CORROBORATE a
 * configured base against the relative `main.py` the live server reported uses the
 * exact same on-disk check this module anchors with (never a second, drifting one).
 */
export function hasComfyUIEntrypoint(dir) {
    return hasMainPy(dir);
}
/**
 * The server roots to search under a configured/derived base, MOST SPECIFIC first.
 *
 * ComfyUI Desktop (and the classic Windows portable bundle) nest the actual server
 * one level down: `<base>/ComfyUI/main.py` with its own `<base>/ComfyUI/.venv`,
 * while `<base>` itself holds the launcher's `standalone-env` python — a DIFFERENT
 * interpreter whose site-packages the running server never imports. Picking `<base>`
 * is what made `install_comfyui (action:"environment")` report `standalone-env/python.exe` and made pip
 * installs land where custom nodes couldn't see them (#401 recurrence). So when the
 * nested dir actually contains a `main.py`, it IS the server root and wins.
 */
function serverRootsUnder(base) {
    // The base IS a server root already (its own main.py) — never let a nested checkout
    // outrank it. Otherwise an exact argv-derived root with a stray `ComfyUI/` inside it
    // would silently lose to that nested tree's interpreter.
    if (hasMainPy(base))
        return [base];
    const nested = join(base, "ComfyUI");
    return hasMainPy(nested) ? [nested, base] : [base];
}
/** Platform interpreter candidates sitting DIRECTLY under one root, most-preferred
 *  first. This order is deliberately untouched by #401: it also decides which
 *  interpreter `resolveRootInterpreter` hands to pip installs, and reshuffling it
 *  would silently move installs between environments. Within a root the order is only
 *  a preference, never authority — resolveComfyuiPython marks a root holding several
 *  environments `ambiguous` and refuses to speak for it. */
function candidatesIn(r) {
    const names = IS_WIN ? ["python.exe", "python"] : ["python3", "python"];
    const candidates = [];
    if (IS_WIN) {
        candidates.push(join(r, "standalone-env", "python.exe"));
        candidates.push(join(r, "python_embeded", "python.exe"));
        candidates.push(join(r, "..", "python_embeded", "python.exe"));
    }
    const venvBins = IS_WIN
        ? [join(r, ".venv", "Scripts"), join(r, "venv", "Scripts")]
        : [join(r, ".venv", "bin"), join(r, "venv", "bin")];
    for (const bin of venvBins)
        for (const n of names)
            candidates.push(join(bin, n));
    return candidates;
}
/** Candidates for a configured base, following a NESTED server root when the base is
 *  not one itself. Used only by resolveComfyuiPython — the live-aware reporting path,
 *  which cross-checks whatever it picks against the running server. The install-side
 *  `resolveRootInterpreter` deliberately does NOT follow the nesting: with no live
 *  server to check against, preferring a nested `.venv` over the base's own
 *  interpreter would be an unverifiable guess in a MUTATING path. */
function interpreterCandidates(root) {
    return serverRootsUnder(root).flatMap(candidatesIn);
}
/** Is this candidate a portable "python_embeded" interpreter? ComfyUI's own
 *  `/system_stats.system.embedded_python` is exactly this test applied to its
 *  `sys.executable`, which makes it a cheap, decisive disambiguator between an
 *  install's `.venv` and a bundle's embedded python. */
function isEmbeddedCandidate(candidate) {
    return basename(dirname(candidate)).toLowerCase() === "python_embeded";
}
/**
 * BEST-GUESS interpreter for an install, from layout. Tries the running server's
 * argv-derived root first, then an explicit COMFYUI_PATH, then the saved default
 * workspace (#418); a relative argv `main.py` (the ComfyUI Desktop shape) is
 * re-anchored on those bases when a `main.py` is really there, which is what gets
 * the probe onto the server's own `ComfyUI/.venv` instead of the bundle launcher's
 * `standalone-env`. Portable/standalone installs keep python under python_embeded /
 * standalone-env, so those are checked too. Falls back to a bare PATH name.
 *
 * THIS IS A GUESS. It decides what to PROBE, never what to CLAIM: two cloned venvs
 * under one root, a conda env we don't enumerate, or a server started with an
 * interpreter from elsewhere all defeat layout reasoning, and no version or torch
 * "fingerprint" repairs that. Authority belongs to resolveLiveInterpreter(), which
 * observes the process. Callers must treat this result as unverified (#401).
 */
export function resolveComfyuiPython(comfyuiPath, statsArgv, opts) {
    const names = IS_WIN ? ["python.exe", "python"] : ["python3", "python"];
    const remote = opts?.remote ?? false;
    const argvRoot = liveRootFromArgv(statsArgv, opts?.cwd);
    // The on-disk bases we may inspect, most-trusted first: an explicit COMFYUI_PATH,
    // else the saved default workspace (#418).
    const bases = [];
    if (comfyuiPath)
        bases.push(comfyuiPath);
    else if (!argvRoot && !remote) {
        const saved = resolveEffectiveComfyUIBase();
        if (saved)
            bases.push(saved);
    }
    // Re-anchor a RELATIVE argv `main.py` (ComfyUI Desktop reports `ComfyUI\main.py`
    // with no cwd) onto each base, accepted only when a main.py is really on disk.
    let anchoredRoot;
    if (!argvRoot && !remote) {
        const relDir = liveRelDirFromArgv(statsArgv);
        if (relDir !== undefined) {
            for (const base of bases) {
                const candidate = pathResolve(base, relDir);
                if (hasMainPy(candidate)) {
                    anchoredRoot = candidate;
                    break;
                }
            }
        }
    }
    const liveRoot = argvRoot ?? anchoredRoot;
    const roots = [];
    // Skip a live root entirely in remote mode — a coincident local path of the same
    // name is not the remote server's install.
    if (argvRoot && !remote)
        roots.push(argvRoot);
    if (anchoredRoot)
        roots.push(anchoredRoot);
    for (const base of bases) {
        if (base === argvRoot || base === anchoredRoot)
            continue;
        roots.push(base);
    }
    // The server's `embedded_python` self-report still ORDERS the guess (it says
    // whether its interpreter lives in a `python_embeded` dir), which helps us probe
    // the more likely candidate on a portable bundle. It does not confer authority.
    const hint = opts?.embeddedPython;
    for (const root of roots) {
        const existing = interpreterCandidates(root).filter(safeExists);
        if (existing.length === 0)
            continue;
        const matching = hint === undefined ? existing : existing.filter((c) => isEmbeddedCandidate(c) === hint);
        const pool = matching.length > 0 ? matching : existing;
        return { python: pool[0], verified: true, liveRoot };
    }
    return { python: names[0], verified: false, liveRoot };
}
/** Desktop commonly reports `ComfyUI/main.py` without a cwd.  Anchor that only
 * against the install being modified, and only after confirming main.py exists.
 * Shares the positional-only script extraction of `scriptTokenFromArgv` via
 * `liveRelDirFromArgv` — a main.py-shaped flag VALUE is never the script (#648). */
function liveRootForInstall(argv, cwd, root) {
    // Go through the ONE live-root resolver (#369) so the install path and the
    // download path can never disagree about which install is the live one. It
    // covers the argv-absolute case AND the OS-observed anchor for a relative
    // `main.py` — strictly more than the argv-only resolution this used to do.
    const live = resolveLiveServerRoot(argv, cwd);
    if (live.root)
        return live.root;
    if (!root)
        return undefined;
    const relDir = liveRelDirFromArgv(argv);
    if (relDir === undefined)
        return undefined;
    const candidate = pathResolve(root, relDir);
    if (hasMainPy(candidate))
        return candidate;
    return undefined;
}
/**
 * Do two python version strings describe the same interpreter?
 *
 * We probe `sys.version` — the SAME string `/system_stats` reports — so both sides
 * carry the full banner ("3.13.12 (main, Feb 12 2026, 00:38:53) [MSC v.1944 64 bit
 * (AMD64)]"). When both banners have build/compiler text we compare it too: a
 * different build of the same version is a different interpreter. Otherwise we fall
 * back to the full dotted version, at whatever precision both sides supply (so a
 * bare "3.13" from an old probe never fails against "3.13.12").
 *
 * This is a CONTRADICTION check, never an identity check — two venvs cloned from one
 * base interpreter report byte-identical banners, so agreement proves nothing. Only
 * an OBSERVED interpreter (live-interpreter.ts) carries authority (#401).
 */
export function pythonVersionsAgree(a, b) {
    if (!a || !b)
        return false;
    const norm = (v) => v.replace(/^Python\s+/i, "").replace(/\s+/g, " ").trim();
    const na = norm(a);
    const nb = norm(b);
    // Both carry build/compiler detail → compare the whole banner.
    const detailed = (v) => /[([]/.test(v);
    if (detailed(na) && detailed(nb))
        return na === nb;
    const parts = (v) => (v.match(/^(\d+(?:\.\d+)*)/)?.[1] ?? "").split(".").filter(Boolean);
    const pa = parts(na);
    const pb = parts(nb);
    if (pa.length === 0 || pb.length === 0)
        return false;
    const depth = Math.min(pa.length, pb.length);
    for (let i = 0; i < depth; i++)
        if (pa[i] !== pb[i])
            return false;
    return true;
}
/**
 * Do two torch version strings describe the same install?
 *
 * `/system_stats.pytorch_version` (e.g. "2.12.1" or "2.11.0.dev20260123+cu130")
 * is what the RUNNING server imported. A pip/import probe reports
 * `torch.__version__` from whichever interpreter we asked. Compare the version
 * token and ignore local suffixes (`+cu130`, `+cpu`): those are the same build
 * with a different local tag, not a different install.
 *
 * CONTRADICTION check only — agreement does not prove identity. A Homebrew
 * base and its venv share a python banner, but only the venv has torch
 * (#401 recurrence, 0.52.1).
 */
export function torchVersionsAgree(a, b) {
    if (!a || !b)
        return false;
    const norm = (v) => v
        .replace(/^torch\s+/i, "")
        .split("+")[0]
        .split(/\s/)[0]
        .trim()
        .toLowerCase();
    const na = norm(a);
    const nb = norm(b);
    return na.length > 0 && na === nb;
}
/**
 * The same python-version trust verdict `install_comfyui(action:"environment")`
 * uses for `python_probe_trusted`. Shared so a mutating install cannot pip into
 * an interpreter the environment probe has already marked untrusted (#2530).
 */
function observedPythonTrust(opts) {
    const { source, shortVer, fullVer, runningPython, pid, port } = opts;
    if (!runningPython) {
        if (source === "launched-by-us") {
            return {
                trusted: true,
                reason: `this MCP server launched ComfyUI (PID ${pid}) with this exact ` +
                    `interpreter. The running ComfyUI did not report its own python version, so no ` +
                    `cross-check was possible — none is needed, the interpreter is the one we chose`,
            };
        }
        return {
            trusted: false,
            reason: `the observed interpreter (${source}) reports python ${shortVer}, but ` +
                `the running ComfyUI did not report a python version of its own, so the two could ` +
                `NOT be compared. That is an unverified match, not a mismatch — refusing to ` +
                `attribute this interpreter's packages to the server on an unmade comparison`,
        };
    }
    if (!pythonVersionsAgree(fullVer, runningPython)) {
        return {
            trusted: false,
            reason: `the observed interpreter (${source}) reports python ${shortVer}, which ` +
                `does not match the running ComfyUI python ${runningPython} — ` +
                `refusing to attribute its packages to the server`,
        };
    }
    return {
        trusted: true,
        reason: source === "launched-by-us"
            ? `this MCP server launched ComfyUI (PID ${pid}) with this exact interpreter`
            : `the OS reports PID ${pid} (serving port ${port}) is running this interpreter`,
    };
}
function torchContradictionOf(probed, serverTorch) {
    if (!serverTorch)
        return undefined;
    if (probed.ran && !probed.packages.torch)
        return "absent";
    if (probed.packages.torch && !torchVersionsAgree(probed.packages.torch, serverTorch)) {
        return "version";
    }
    return undefined;
}
function torchContradictionReason(kind, source, torch, serverTorch) {
    return kind === "absent"
        ? `the observed interpreter (${source}) has none of torch while ` +
            `the running ComfyUI reports pytorch ${serverTorch} — this is the base ` +
            `interpreter, not the venv the server imports from. Refusing to attribute ` +
            `its packages to the server`
        : `the observed interpreter (${source}) reports torch ${torch}, ` +
            `which does not match the running ComfyUI pytorch ${serverTorch} — ` +
            `refusing to attribute its packages to the server`;
}
/**
 * Apply the environment probe's trust rules to an observed install interpreter.
 * A known-untrusted python (base vs venv, torch mismatch) must not be handed to
 * pip. Launched-by-us still wins when the interpreter cannot be probed at all —
 * we chose that path — but a successful probe that contradicts the running
 * server refuses (#2530).
 */
async function corroborateObservedInstallPython(python, source, opts) {
    const version = (await probe(python, ["-c", "import sys;print(sys.version)"])) ??
        (await probe(python, ["--version"]));
    if (!version) {
        if (source === "launched-by-us")
            return { trusted: true };
        return {
            trusted: false,
            reason: `Cannot verify the running server's interpreter "${python}": it did not report a python version, ` +
                `so it cannot be corroborated against the connected ComfyUI.`,
        };
    }
    const ver = version.replace(/^Python\s+/i, "").replace(/\s+/g, " ").trim();
    const shortVer = ver.match(/^(\d+(?:\.\d+)*)/)?.[1] ?? ver;
    const initial = observedPythonTrust({
        source,
        shortVer,
        fullVer: ver,
        runningPython: opts.runningPython,
        pid: opts.pid,
        port: config.resolvedPort,
    });
    if (!initial.trusted)
        return { trusted: false, reason: initial.reason };
    const probed = await probePipPackages(python, KEY_PACKAGES);
    const serverTorch = opts.runningTorch?.trim();
    const contradiction = torchContradictionOf(probed, serverTorch);
    if (contradiction && serverTorch) {
        return {
            trusted: false,
            reason: torchContradictionReason(contradiction, source, probed.packages.torch, serverTorch),
        };
    }
    return { trusted: true };
}
function untrustedInstallInterpreterReason(python, detail) {
    return (`Cannot install Python packages with "${python}": ${detail} ` +
        `Never invoke that interpreter for pip. Use install_custom_node(action:"fix") so ` +
        `ComfyUI-Manager can restore/repair dependencies in the connected environment, ` +
        `or set COMFYUI_PYTHON to the interpreter ComfyUI actually imports from.`);
}
/** A bundle root may contain its actual server in `ComfyUI/`; do not confuse a
 * nested independent checkout with that bundle layout. */
function targetsLiveInstall(serverRoot, requestedRoot) {
    if (!requestedRoot)
        return true;
    const server = pathResolve(serverRoot);
    const requested = pathResolve(requestedRoot);
    return server === requested || (server.startsWith(requested + sep) && !hasMainPy(requested));
}
/**
 * Resolve a package-install interpreter without claiming that a path-shaped guess is
 * the process that is currently serving ComfyUI.  Success requires OBSERVED ground
 * truth (#401): an explicit COMFYUI_PYTHON is the operator's own claim; a server this
 * MCP launched is authoritative only while the process on the port is still that same
 * child — PID *and* creation time, validated per-call by live-interpreter.ts tier 1,
 * so a stale launch record (child dead, PID recycled, another server now on the same
 * install root) is never trusted; and the OS process table is ground truth when the
 * port owner's command line corroborates the server's own argv (tier 2).  In every
 * other case the live interpreter is UNOBSERVABLE — /system_stats has no
 * sys.executable, so even a single discovered `.venv` may be unrelated (for example,
 * the server can be using system Python).  FAIL CLOSED: refuse rather than report an
 * install into a layout-guessed env as applied when the running server may not be
 * able to import from it (#651).
 */
export async function resolveInstallInterpreter(root) {
    const override = process.env.COMFYUI_PYTHON?.trim();
    if (override) {
        return { python: override, source: "override", reason: `Using "${override}" because COMFYUI_PYTHON is set.` };
    }
    const refuse = (reason) => ({
        source: "undetermined",
        reason,
    });
    if (isRemoteMode()) {
        return refuse("Cannot verify the running server's interpreter: the connected ComfyUI is remote, " +
            "so a package installed locally would not affect it.");
    }
    let system;
    try {
        system = (await getSystemStats()).system;
    }
    catch {
        return refuse("Cannot verify the running server's interpreter: no local ComfyUI is reachable. " +
            "Start ComfyUI or connect to it first.");
    }
    const serverRoot = liveRootForInstall(system?.argv, system?.cwd, root);
    // OBSERVED ground truth (#401): /system_stats cannot name the interpreter, but the
    // OS can. resolveLiveInterpreter is the ONLY launch record this resolver trusts.
    let statsHost;
    try {
        statsHost = new URL(getComfyUIBaseUrl()).hostname;
    }
    catch {
        /* unparseable target → no host filter */
    }
    const live = resolveLiveInterpreter({
        port: config.resolvedPort,
        host: statsHost,
        remote: false,
        serverArgv: system?.argv,
    });
    const trustLive = async (python, source, pid) => {
        const verdict = await corroborateObservedInstallPython(python, source, {
            runningPython: system?.python_version,
            runningTorch: system?.pytorch_version,
            pid,
        });
        if (verdict.trusted)
            return undefined;
        return refuse(untrustedInstallInterpreterReason(python, verdict.reason));
    };
    // A VALIDATED launched-by-us observation (tier 1) is authoritative whenever the
    // live server is the requested install — or argv names no root to contradict it.
    // Still refuse when the environment probe would mark it untrusted (#2530): a
    // launched base interpreter with no torch is not the env ComfyUI imports from.
    if (live?.source === "launched-by-us" && (!serverRoot || targetsLiveInstall(serverRoot, root))) {
        const refused = await trustLive(live.python, live.source, live.pid);
        if (refused)
            return refused;
        return {
            python: live.python,
            source: "launched",
            reason: `Using "${live.python}", the interpreter this MCP server launched the running ` +
                `ComfyUI with (identity-confirmed for PID ${live.pid}).`,
        };
    }
    if (!serverRoot) {
        return refuse("Cannot verify the running server's interpreter: the running ComfyUI did not report " +
            "a resolvable main.py location.");
    }
    if (!targetsLiveInstall(serverRoot, root)) {
        return refuse(`The running ComfyUI is a different install ("${serverRoot}") than the requested path; ` +
            "installing into the requested layout would not affect the live server.");
    }
    // A process-table observation (tier 2) is ground truth too, but only once the
    // guards above have tied the live server to the requested install. This strictly
    // ADDS a success source: when nothing observes the interpreter the install still
    // refuses (#651 fail-closed). A torch/python contradiction still untrusts it —
    // Stability Matrix's uv base python is a common argv[0] that is not the venv
    // ComfyUI imports from (#2530).
    if (live) {
        const refused = await trustLive(live.python, live.source, live.pid);
        if (refused)
            return refused;
        return {
            python: live.python,
            source: "observed",
            reason: `Using "${live.python}", the interpreter the OS reports for the running ComfyUI ` +
                `process (PID ${live.pid}), corroborated against the server's own argv.`,
        };
    }
    return refuse(`Cannot determine which interpreter the running ComfyUI at "${serverRoot}" uses: ` +
        "/system_stats does not expose sys.executable, so an interpreter discovered from its layout is unconfirmed.");
}
/**
 * `pip show` output for the packages we care about, plus WHETHER THE QUERY RAN.
 *
 * An empty map has two causes that a caller must not confuse: pip answered and none of
 * these packages are installed, or pip never answered at all (absent from this
 * interpreter — common in uv-created venvs — or the probe timed out). Reporting the
 * second as the first invents a capability finding.
 *
 * The discriminator has to come from THIS invocation. A follow-up `pip --version` was
 * the obvious shortcut and is wrong: it shows that pip can start NOW, which is not
 * evidence that the query that already failed ever ran — a `pip show` killed by the 8s
 * timeout would be vouched for by a fast `pip --version` and its silence read as "none
 * installed". So `ran` requires POSITIVE evidence in pip's own output that pip looked:
 * a parsed record, or pip's own `WARNING: Package(s) not found: …`. Anything else —
 * pip absent, python itself failing, the probe killed, or that wording changing in some
 * future pip — is `ran: false`, i.e. "we could not tell", which is the safe direction.
 *
 * `probe()` is deliberately NOT used here. It discards everything on a non-zero exit,
 * and `pip show a b c` exits 1 whenever ANY named package is missing — which, with
 * xformers and diffusers in the list, is the ordinary state of a real machine. That
 * silently threw away the records for the packages pip DID find (torch among them) and
 * left `packages` empty, so `install_comfyui (action:"environment")` reported no packages on installs that had
 * them. A non-zero exit here is a completed run whose output must still be read.
 */
async function probePipPackages(pythonExe, names) {
    // `pip show` is portable across pip/uv-managed venvs.
    const found = {};
    let out = "";
    try {
        const res = await execFileAsync(pythonExe, ["-m", "pip", "show", ...names], {
            timeout: 8000,
            windowsHide: true,
        });
        out = `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
    }
    catch (err) {
        // promisified execFile rejects on a non-zero exit but attaches the output it did
        // collect. Read it: a partial match lives there, and so does pip's not-found
        // warning. A spawn failure or a kill simply leaves both undefined.
        const e = err;
        const parts = [e?.stdout, e?.stderr].filter((s) => typeof s === "string");
        out = parts.join("\n");
    }
    // `pip show A B C` emits records separated by a line of "---".
    for (const block of out.split(/^---$/m)) {
        const nameMatch = block.match(/^Name:\s*(.+)$/m);
        const verMatch = block.match(/^Version:\s*(.+)$/m);
        if (nameMatch && verMatch) {
            found[nameMatch[1].trim().toLowerCase()] = verMatch[1].trim();
        }
    }
    // A parsed record IS pip speaking, so the run is self-evident. With none, only pip's
    // own not-found warning proves it looked — and it has to be pip's STRUCTURED line,
    // anchored at the start of a line with the WARNING prefix and trailing colon. A bare
    // substring match would be satisfied by any traceback or wrapper message that merely
    // quotes the phrase, and that would license the "none of these are installed" claim
    // off a `pip show` that never ran.
    if (Object.keys(found).length > 0)
        return { ran: true, packages: found };
    return { ran: /^\s*WARNING:\s*Package\(s\) not found:/im.test(out), packages: found };
}
async function probeGitRev(workspacePath) {
    if (!existsSync(join(workspacePath, ".git")))
        return undefined;
    const rev = await probe("git", ["rev-parse", "--short", "HEAD"], {
        cwd: workspacePath,
    });
    const branch = await probe("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
        cwd: workspacePath,
    });
    if (!rev && !branch)
        return undefined;
    return { rev, branch };
}
/** Read ComfyUI-Manager version from a custom_nodes checkout.
 *  Fallback only — ComfyUI with --enable-manager serves the pip package and
 *  disables this folder ("Blocked by policy"), so a live version must come
 *  from a trusted interpreter probe first (#2538). */
async function readManagerVersion(workspacePath) {
    const dirNames = ["ComfyUI-Manager", "comfyui-manager"];
    for (const dirName of dirNames) {
        const file = join(workspacePath, "custom_nodes", dirName, "pyproject.toml");
        try {
            if (!existsSync(file))
                continue;
            // Tiny TOML peek — only the version line, no full parser needed.
            const text = await readFile(file, "utf-8");
            const m = text.match(/^version\s*=\s*["']([^"']+)["']/m);
            if (m)
                return m[1];
            // Presence without a parseable version still tells us it's installed.
            return "installed";
        }
        catch {
            // try next
        }
    }
    // Fallback: directory presence
    for (const dirName of dirNames) {
        if (existsSync(join(workspacePath, "custom_nodes", dirName))) {
            return "installed";
        }
    }
    return undefined;
}
const KEY_PACKAGES = [
    "torch",
    "torchvision",
    "torchaudio",
    "xformers",
    "numpy",
    "transformers",
    "diffusers",
    "comfyui-frontend-package",
    // pip Manager (ComfyUI --enable-manager). Must outrank a leftover
    // custom_nodes/ComfyUI-Manager checkout that core has blocked (#2538).
    "comfyui-manager",
];
export async function getEnvironment() {
    const apiTarget = getComfyUIBaseUrl();
    // 1. Running instance via /system_stats (works for remote targets too)
    const running = {
        reachable: false,
        api_target: apiTarget,
    };
    // argv/cwd of the LIVE server (from /system_stats) drive live-first interpreter
    // resolution below — captured here so a probe never targets the wrong python.
    let statsArgv;
    let statsCwd;
    try {
        const stats = await getSystemStats({ diagnosticContext: "environment" });
        running.reachable = true;
        running.os = stats.system.os;
        running.python_version = stats.system.python_version;
        running.embedded_python = stats.system.embedded_python;
        running.comfyui_version = stats.system.comfyui_version;
        running.pytorch_version = stats.system.pytorch_version;
        running.deploy_environment = stats.system
            .deploy_environment;
        statsArgv = stats.system.argv;
        // ComfyUI does not currently report cwd, but tolerate it if a future/build does.
        statsCwd = stats.system.cwd;
        running.devices = (stats.devices ?? []).map((d) => ({
            name: d.name,
            type: d.type,
            vram_total_mb: typeof d.vram_total === "number"
                ? Math.round(d.vram_total / (1024 * 1024))
                : undefined,
            vram_free_mb: typeof d.vram_free === "number"
                ? Math.round(d.vram_free / (1024 * 1024))
                : undefined,
        }));
    }
    catch (err) {
        running.error = err instanceof Error ? err.message : String(err);
    }
    // 2. Local probes — use the active path, else fall back to the saved default
    //    workspace (set via workspace action:"set_default") so `env` still inspects a known
    //    local install when COMFYUI_PATH isn't set.
    const local = {};
    const cfg = await readWorkspaceConfig();
    const workspacePath = config.comfyuiPath ?? cfg.defaultWorkspace;
    const configuredCodePath = config.comfyuiCodePath ?? workspacePath;
    // LIVE-FIRST interpreter resolution — identical to resolveComfyuiPython used by
    // the panel env block, so the two paths can never disagree (#401 / PR #433). The
    // running server's argv root wins over an explicit COMFYUI_PATH, which wins over
    // the saved default.
    const remote = isRemoteMode();
    // GROUND TRUTH — did we launch it, or can the OS tell us? (See live-interpreter.ts.)
    // statsArgv is the server's OWN sys.argv: the process we find on the port must have
    // a command line consistent with it, or it is not the server that answered us.
    let statsHost;
    try {
        statsHost = new URL(apiTarget).hostname;
    }
    catch {
        /* unparseable target → no host filter */
    }
    const live = resolveLiveInterpreter({
        port: config.resolvedPort,
        host: statsHost,
        remote,
        serverArgv: statsArgv,
    });
    const resolved = resolveComfyuiPython(configuredCodePath, statsArgv, {
        cwd: statsCwd,
        remote,
        // Orders the GUESS only (used when there is no ground truth to show instead).
        embeddedPython: running.reachable ? running.embedded_python : undefined,
    });
    // The install root we can actually inspect on disk: an explicit/saved workspace,
    // else the live server's own root (so we still report git/manager for a live
    // server even when no workspace path is configured).
    const codeRoot = remote
        ? undefined
        : resolved.liveRoot ?? config.comfyuiCodePath ?? workspacePath;
    // workspace_path is the DATA/base root. Never label COMFYUI_CODE_PATH as
    // workspace_path — that field means the data root everywhere else. An
    // argv-derived live root may still be shown (including as a remote diagnostic)
    // because that is the server's self-report, not the configured code override.
    const localRoot = workspacePath ?? resolved.liveRoot;
    if (!localRoot && !codeRoot) {
        local.note =
            "No local ComfyUI path configured (COMFYUI_PATH unset, none auto-detected, " +
                "and no saved default workspace) and no live server main.py to locate one. " +
                "Local environment probes skipped; remote /system_stats used instead.";
        return { running_instance: running, local };
    }
    if (localRoot)
        local.workspace_path = localRoot;
    if (codeRoot) {
        local.code_path = codeRoot;
        local.code_path_source = resolved.liveRoot
            ? "live-server"
            : config.comfyuiCodePath
                ? "env"
                : "workspace";
    }
    if (!config.comfyuiPath && cfg.defaultWorkspace) {
        local.note = `Using saved default workspace "${cfg.defaultWorkspace}" (COMFYUI_PATH not set).`;
    }
    if (codeRoot && localRoot && codeRoot !== localRoot) {
        local.note = [
            local.note,
            `Split install: data/base workspace is "${localRoot}"; code checkout is "${codeRoot}" (${local.code_path_source}).`,
        ]
            .filter(Boolean)
            .join(" ");
    }
    else if (codeRoot && !localRoot) {
        local.note = [
            local.note,
            `Using code checkout "${codeRoot}" (${local.code_path_source}); no data/base workspace (COMFYUI_PATH) is configured.`,
        ]
            .filter(Boolean)
            .join(" ");
    }
    // GROUND TRUTH first: the interpreter we LAUNCHED ComfyUI with, or the one the OS
    // says the process on our port is running. Only these are observations; everything
    // below is a layout guess (#401).
    const groundTruth = live ?? undefined;
    const probeExe = groundTruth?.python ?? resolved.python;
    // Read `sys.version` — byte-for-byte what /system_stats reports — so the
    // contradiction check can compare build/compiler text, not just the number.
    // `--version` is the fallback for an interpreter that can't run -c.
    const version = probeExe
        ? ((await probe(probeExe, ["-c", "import sys;print(sys.version)"])) ??
            (await probe(probeExe, ["--version"])))
        : undefined;
    if (probeExe && version) {
        const ver = version.replace(/^Python\s+/i, "").replace(/\s+/g, " ").trim();
        // Display the plain number; keep the full banner for the comparison below.
        const shortVer = ver.match(/^(\d+(?:\.\d+)*)/)?.[1] ?? ver;
        local.python = { executable: probeExe, version: shortVer };
        // THE TERMINATING RULE (#401). An interpreter is authoritative only when we
        // OBSERVED it, never when we inferred it from install layout:
        //   1. we launched the process and recorded the interpreter we used;
        //   2. the OS process table reports argv[0] of the process on our port;
        //   3. otherwise UNKNOWN — no package list, no "not installed", no trust.
        // Everything that used to live here (sole-candidate-under-a-believed-root,
        // python/torch "fingerprints", ambiguity corroboration) was inference dressed up
        // as proof, and inference is what reported the wrong venv in the first place.
        let trusted = false;
        let reason;
        if (remote) {
            reason =
                "the running ComfyUI is REMOTE — no local interpreter is the remote server's, " +
                    "and ComfyUI does not report its own sys.executable over HTTP";
        }
        else if (!groundTruth) {
            const deployed = running.deploy_environment
                ? ` (ComfyUI reports deploy_environment "${running.deploy_environment}")`
                : "";
            reason =
                `the interpreter shown is a BEST GUESS from install layout, not an observation: ` +
                    `we did not launch this ComfyUI and could not read the interpreter of the ` +
                    `process serving port ${config.resolvedPort} from the OS${deployed}. Package ` +
                    `versions are omitted rather than attributed to the wrong environment`;
        }
        else {
            // Shared with resolveInstallInterpreter so a mutating pip cannot target an
            // interpreter this probe has already marked untrusted (#2530).
            const verdict = observedPythonTrust({
                source: groundTruth.source,
                shortVer,
                fullVer: ver,
                runningPython: running.python_version,
                pid: groundTruth.pid,
                port: config.resolvedPort,
            });
            trusted = verdict.trusted;
            reason = verdict.reason;
        }
        local.python_probe_trusted = trusted;
        local.python_probe_source = groundTruth?.source ?? "layout-guess";
        local.python_probe_reason = reason;
        let pkgs;
        if (trusted && probeExe) {
            const probed = await probePipPackages(probeExe, KEY_PACKAGES);
            pkgs = probed.packages;
            // A venv and its base interpreter report the SAME sys.version, so the
            // version check above cannot tell them apart. /system_stats.pytorch_version
            // can: the running server imported that torch. A probe that finds none (or a
            // different build) is looking at the wrong environment — typically the
            // Homebrew/uv BASE python after the process table lost the venv context
            // (#401 recurrence, 0.52.1). Absence only counts when pip actually answered.
            const serverTorch = running.pytorch_version?.trim();
            const torchContradiction = torchContradictionOf(probed, serverTorch);
            if (torchContradiction && serverTorch) {
                trusted = false;
                const observedAs = groundTruth?.source ?? "process-table";
                reason = torchContradictionReason(torchContradiction, observedAs, pkgs.torch, serverTorch);
                local.python_probe_trusted = false;
                local.python_probe_reason = reason;
                local.note = [
                    local.note,
                    `Package versions omitted: ${reason}, so reporting them would be a false ` +
                        `capability report (#401). Start ComfyUI through restart_comfyui (action:"start"), or run it ` +
                        `locally where this process can read its command line, for an accurate report.`,
                ]
                    .filter(Boolean)
                    .join(" ");
            }
            else if (Object.keys(pkgs).length > 0) {
                local.packages = pkgs;
            }
            else {
                // An absent `packages` field otherwise reads identically to the deliberate
                // withholding below, and a reader would take it as "none of these are
                // installed". Which of the two actually happened is knowable, so say which —
                // narrating one cause for both would be the same fold this fix is about.
                local.note = [
                    local.note,
                    probed.ran
                        ? `No package versions to report from ${probeExe}: pip answered and none of ` +
                            `${KEY_PACKAGES.join(", ")} are installed in it. The interpreter IS trusted ` +
                            `(${reason}), so this IS an observation — not a failed query.`
                        : `Package versions could not be READ from ${probeExe}: pip did not answer at ` +
                            `all (it may be absent from this interpreter — uv-created venvs often have ` +
                            `no pip — or the probe timed out). The interpreter itself IS trusted ` +
                            `(${reason}); the empty list is a failed query, NOT evidence that these ` +
                            `packages are missing.`,
                ]
                    .filter(Boolean)
                    .join(" ");
            }
        }
        else {
            local.note = [
                local.note,
                `Package versions omitted: ${reason}, so reporting them would be a false ` +
                    `capability report (#401). Start ComfyUI through restart_comfyui (action:"start"), or run it ` +
                    `locally where this process can read its command line, for an accurate report.`,
            ]
                .filter(Boolean)
                .join(" ");
        }
    }
    else {
        local.note = [
            local.note,
            "Python interpreter not found on PATH or in the workspace venv/embedded python.",
        ]
            .filter(Boolean)
            .join(" ");
    }
    const gitRoot = codeRoot ?? localRoot;
    if (gitRoot) {
        const git = await probeGitRev(gitRoot);
        if (git)
            local.git = git;
    }
    // Manager lives under custom_nodes/, which --base-directory runtimes scan
    // from the data/base root (#1770). Fall back to the checkout only when no
    // data root is known (legacy layout next to main.py).
    //
    // A trusted pip `comfyui-manager` is the live Manager when ComfyUI is
    // started with --enable-manager: core then blocks custom_nodes/ComfyUI-Manager
    // ("Blocked by policy") and the leftover 3.x folder is not what HTTP serves
    // (#2538). An untrusted probe must not invent that pip version.
    const managerRoot = localRoot ?? codeRoot;
    const pipManager = local.python_probe_trusted === true
        ? (local.packages?.["comfyui-manager"] ?? local.packages?.["comfyui_manager"])
        : undefined;
    const managerVersion = pipManager ?? (managerRoot ? await readManagerVersion(managerRoot) : undefined);
    if (managerVersion)
        local.comfyui_manager_version = managerVersion;
    return { running_instance: running, local };
}
//# sourceMappingURL=workspace-env.js.map