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

799 lines • 38 kB
// GROUND TRUTH for "which python is the running ComfyUI actually using?" (#401). // // Everything else in this codebase INFERS the answer from install layout: look for // a .venv under a root we believe in, prefer this candidate over that one, then // cross-check a version. That inference is what produced the bug this module exists // to end — a confident "Triton: not installed" read off the wrong interpreter, which // made an agent strip working acceleration from a user's workflow. // // Inference cannot be hardened into proof. Two cloned venvs under one root, a conda // env we don't even enumerate, a server launched with an interpreter from somewhere // else entirely — no amount of layout heuristics or version/torch "fingerprints" // distinguishes those, because none of them observe the process. So this module // only reports an interpreter when something OBSERVED it: // // 1. WE LAUNCHED IT — process-control spawned ComfyUI and recorded the exact // interpreter it used. Not a guess: we chose that path. // 2. THE OS TELLS US — the server is a local process listening on our port, and // the OS process table reports the command line it was started with. argv[0] // of a running python IS its interpreter. // // Anything else is `undefined`, and callers must degrade to UNKNOWN rather than // report a package as absent. NOTE for future maintainers: ComfyUI does NOT expose // `sys.executable` over HTTP (verified on 0.29.2 — /system_stats reports // `embedded_python`, a BOOLEAN derived from sys.executable that throws the path // away). If a future ComfyUI adds it, that becomes the best source of all and // should be added here as tier 0 — it works for remote servers too. import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs"; import { isAbsolute, join, resolve as pathResolve } from "node:path"; import { platform } from "node:os"; import { findPidByPort } from "./port-owner.js"; import { logger } from "./../utils/logger.js"; const IS_WIN = platform() === "win32"; // --------------------------------------------------------------------------- // Tier 1 — the interpreter WE launched ComfyUI with // --------------------------------------------------------------------------- let launchRecord; /** * Record the exact interpreter process-control just spawned ComfyUI with, together * with the process's CREATION TIME. A PID alone is not a process identity: PIDs are * recycled, so if our child dies and an externally launched python is handed the * same number before our async cleanup runs, a PID-only check would hand the stale * interpreter to the replacement server. PID + start time is the standard identity * and closes that (#401 round 4). If the start time cannot be read we store none, * and the tier fails closed rather than trusting a bare PID match. */ export function recordLaunchedInterpreter(pid, python) { if (!pid || !python) return; const startedAt = readProcessIdentity(pid)?.startedAt; launchRecord = { pid, python, startedAt }; logger.info("Recorded the interpreter ComfyUI was launched with", { pid, python, startedAt: startedAt ?? "(unavailable)", }); } /** Forget the launch record (our child exited, or a stop was requested). */ export function clearLaunchedInterpreter() { launchRecord = undefined; } /** Test seam / introspection. */ export function getLaunchedInterpreterRecord() { return launchRecord; } // --------------------------------------------------------------------------- // Tier 2 — the OS process table // --------------------------------------------------------------------------- /** * Split a command line into argv, honoring double-quoted paths. Windows launchers * routinely quote the interpreter ("C:\Program Files\...\python.exe"), and a naive * whitespace split would truncate it at the space. */ export function argv0FromCommandLine(cmdline) { const s = cmdline.trim(); if (!s) return undefined; if (s.startsWith('"')) { const end = s.indexOf('"', 1); return end > 1 ? s.slice(1, end) : undefined; } const m = s.match(/^\S+/); return m ? m[0] : undefined; } /** * Split a command line the OS reports as ONE STRING back into argv. * * Needed because Windows (WMI `CommandLine`) and macOS (`ps -o command=`) hand back * a flattened string, and a relaunch needs the pieces. Only double quotes group — * that is the rule on Windows, and on macOS `ps` reproduces the argv joined by * spaces, where a quote is at worst part of a filename. * * On WINDOWS the round-trip is faithful: this implements CommandLineToArgvW, the very * rule the child's own runtime applies to that same string, so an argument containing * a space was necessarily quoted (or the process itself would have seen two). * * On macOS it is LOSSY BY NATURE — `ps` prints argv joined by spaces and the quoting * is gone, so `--output-directory /a/My Outputs` cannot be told from two arguments. * That is why the caller records `argvFidelity`, and why a "flattened" argv may be * READ but never SPAWNED (codex gate round 6). * * The result is likewise never used to CORROBORATE an identity against the command * line it came from — a string tokenised from itself would trivially agree with * itself, which is not evidence of anything. Linux does not use this at all: * `/proc/<pid>/cmdline` is already NUL-separated argv. */ export function tokenizeCommandLine(commandLine) { const tokens = []; let current = ""; let quoted = false; let started = false; let backslashes = 0; /** Flush pending backslashes as literal characters. */ const flushBackslashes = (count) => { current += "\\".repeat(count); if (count > 0) started = true; }; for (const ch of commandLine) { if (ch === "\\") { backslashes++; continue; } if (ch === '"') { // The documented Windows rule (CommandLineToArgvW): 2n backslashes before a // quote emit n backslashes and the quote GROUPS; 2n+1 emit n backslashes and // the quote is LITERAL. Without this, `--flag "a\\" --next` loses the closing // quote and everything after it fuses into one argument — a relaunch command // that spawns with mangled arguments while every existence check upstream // still passes, because those only validate the executable and the script. flushBackslashes(Math.floor(backslashes / 2)); const literalQuote = backslashes % 2 === 1; backslashes = 0; if (literalQuote) { current += '"'; } else { quoted = !quoted; } started = true; continue; } flushBackslashes(backslashes); backslashes = 0; if (!quoted && /\s/.test(ch)) { if (started) tokens.push(current); current = ""; started = false; continue; } current += ch; started = true; } flushBackslashes(backslashes); if (started) tokens.push(current); return tokens; } /** * Read a running process's command line AND creation time from the OS. * * Windows: WMI's `CommandLine` is what answers the INTERPRETER question, NOT * `ExecutablePath`. This distinction is the whole * point — for a venv, Windows reports ExecutablePath as the BASE interpreter the * venv trampoline loads (e.g. …\standalone-env\python.exe) while CommandLine's * argv[0] is the venv python (…\ComfyUI\.venv\Scripts\python.exe), which is what * `sys.executable` reports and whose site-packages the server actually imports. * Verified against a live ComfyUI Desktop instance while fixing #401. * `ExecutablePath` IS read, into its own field, for the separate "what program is * this?" question — see `executablePath`. * * Linux: /proc/PID/cmdline ("\u0000"-separated argv) plus field 22 of /proc/PID/stat * (starttime, in clock ticks since boot). /proc/PID/exe likewise goes to * `executablePath` ONLY, never to the interpreter answer, because it resolves * through the venv symlink to the base interpreter. * macOS: `ps -o lstart=,command=` — which exposes no authenticated executable * column, so `executablePath` is undefined there. */ function parsePid(raw) { if (raw == null) return undefined; const parsed = Number.parseInt(raw.trim(), 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } /** * Convert the platform-native process creation stamp to filesystem epoch * milliseconds. The raw `startedAt` value stays untouched because other callers * compare it only with another reading from the same platform; this normalized * value is for the launch-state config freshness check only. */ function processStartTimeMs(startedAt) { if (!startedAt) return undefined; if (IS_WIN) { // WMI returns FILETIME: 100-ns intervals since 1601-01-01 UTC. const fileTime = Number(startedAt); if (!Number.isFinite(fileTime) || fileTime <= 0) return undefined; const epochMs = fileTime / 10_000 - 11_644_473_600_000; return Number.isFinite(epochMs) && epochMs > 0 ? epochMs : undefined; } if (platform() === "linux") { // Linux /proc/PID/stat reports starttime in clock ticks since boot. Read the // kernel boot epoch and the host clock frequency rather than assuming the // common-but-not-contractual 100 Hz value. const ticks = Number(startedAt); if (!Number.isFinite(ticks) || ticks < 0) return undefined; try { const bootSeconds = Number(readFileSync("/proc/stat", "utf-8").match(/^btime\s+(\d+)$/m)?.[1]); const hz = Number(execFileSync("getconf", ["CLK_TCK"], { encoding: "utf-8", timeout: 8000 }).trim()); if (!Number.isFinite(bootSeconds) || !Number.isFinite(hz) || hz <= 0) return undefined; const epochMs = bootSeconds * 1000 + (ticks * 1000) / hz; return Number.isFinite(epochMs) && epochMs > 0 ? epochMs : undefined; } catch { return undefined; } } // macOS `ps lstart` is a local-time ctime-style string. The filesystem mtime // used alongside it is on the same host, so Date.parse is the appropriate // conversion; its second-level precision intentionally fails closed for a // config that cannot be ordered confidently. const epochMs = Date.parse(startedAt); return Number.isFinite(epochMs) && epochMs > 0 ? epochMs : undefined; } export function readProcessIdentity(pid) { try { if (IS_WIN) { const out = execFileSync("powershell", [ "-NoProfile", "-Command", // #1005 — PowerShell encodes REDIRECTED stdout with the console's // legacy OEM codepage, not UTF-8, and this output is decoded as UTF-8. // Any character the codepage cannot represent arrives as `?` or U+FFFD, // so a ComfyUI under a non-ASCII path (a Cyrillic Windows profile, an // accented folder, CJK) reports a MANGLED command line. // // That is not cosmetic here: commandLineMatchesArgv compares this // against /system_stats argv to corroborate ownership before a restart. // A mangled path never matches, so restart_comfyui refuses to control // the very process it just identified — reporting "is not running the // ComfyUI that answered /system_stats" about the one that is. // // Verified on Windows 11: without this line `Пример-café-日本` comes back // as `??????-caf?-??`; with it, intact. `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` + `if ($p) { "START=" + $p.CreationDate.ToFileTimeUtc(); "PPID=" + $p.ParentProcessId; "EXE=" + $p.ExecutablePath; "CMD=" + $p.CommandLine }`, ], { encoding: "utf-8", timeout: 8000, windowsHide: true }); const startedAt = out.match(/^START=(.+)$/m)?.[1]?.trim(); // PPID before CMD in the output so a multi-line command line (captured with // [\s\S]) cannot swallow it. const parentPid = parsePid(out.match(/^PPID=(.+)$/m)?.[1]); // EXE before CMD for the same reason PPID is: a multi-line command line // (captured with [\s\S]) must not swallow the fields after it. const executablePath = out.match(/^EXE=(.*)$/m)?.[1]?.trim(); const commandLine = out.match(/^CMD=([\s\S]*)$/m)?.[1]?.trim(); if (!startedAt && !commandLine) return undefined; return withVenvPython(pid, { commandLine: commandLine || undefined, argv: commandLine ? tokenizeCommandLine(commandLine) : undefined, argvFidelity: "exact", executablePath: executablePath || undefined, startedAt: startedAt || undefined, parentPid, }); } if (platform() === "linux") { // argv entries are "\u0000"-separated; rejoin them as a command line. const raw = readFileSync(`/proc/${pid}/cmdline`, "utf-8"); // EXACT argv — the kernel already separated it, so nothing is guessed here. const argv = raw.split("\u0000").filter(Boolean); const commandLine = argv.join(" ").trim(); let startedAt; let parentPid; try { const stat = readFileSync(`/proc/${pid}/stat`, "utf-8"); // Field 2 (comm) can contain spaces and parens — parse after the LAST ')'. const after = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/); // After comm, fields run state(3)… so starttime (field 22) is index 19 here. startedAt = after[19]; // ppid is field 4, i.e. index 1 in this same post-comm numbering. parentPid = parsePid(after[1]); } catch { /* start time unavailable → the launched-by-us tier fails closed */ } // The kernel's own record of the binary, which argv[0] cannot forge. Absent // (EACCES) for another user's process, which is exactly the case where the // caller must fall back rather than conclude anything. let executablePath; try { executablePath = readlinkSync(`/proc/${pid}/exe`); } catch { /* not ours to read → undefined, and the caller falls back to argv[0] */ } if (!commandLine && !startedAt) return undefined; return withVenvPython(pid, { commandLine: commandLine || undefined, argv: argv.length > 0 ? argv : undefined, argvFidelity: "exact", executablePath: executablePath || undefined, startedAt, parentPid, }); } const out = execFileSync("ps", ["-p", String(pid), "-o", "ppid=,lstart=,command="], { encoding: "utf-8", timeout: 8000 }).trim(); if (!out) return undefined; // `ppid` first, then `lstart` (a ctime-style stamp: "Fri Aug 1 12:00:00 2026"). const m = out.match(/^\s*(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+([\s\S]*)$/); if (!m) { return withVenvPython(pid, { commandLine: out, argv: tokenizeCommandLine(out), argvFidelity: "flattened", }); } return withVenvPython(pid, { parentPid: parsePid(m[1]), startedAt: m[2].replace(/\s+/g, " "), commandLine: m[3].trim(), argv: tokenizeCommandLine(m[3].trim()), argvFidelity: "flattened", }); } catch { return undefined; } } /** Back-compat helper: just argv[0] of a running process. */ export function readProcessArgv0(pid) { const cmd = readProcessIdentity(pid)?.commandLine; return cmd ? argv0FromCommandLine(cmd) : undefined; } /** Pull only `__PYVENV_LAUNCHER__` and `VIRTUAL_ENV` out of `KEY=VALUE` entries. */ export function venvHintsFromEnvEntries(entries) { const out = {}; for (const raw of entries) { if (raw.startsWith("__PYVENV_LAUNCHER__=")) { const v = raw.slice("__PYVENV_LAUNCHER__=".length).trim(); if (v) out.pyvenvLauncher = v; } else if (raw.startsWith("VIRTUAL_ENV=")) { const v = raw.slice("VIRTUAL_ENV=".length).trim(); if (v) out.virtualEnv = v; } if (out.pyvenvLauncher && out.virtualEnv) break; } return out; } /** Linux `/proc/<pid>/environ` is NUL-separated `KEY=VALUE`. */ export function venvHintsFromEnvironBuffer(buf) { return venvHintsFromEnvEntries(buf.toString("utf8").split("\0")); } /** * macOS `kern.procargs2.<pid>`: int32 argc, exec path, argv[argc], then envp. * Padding NULs sit between the exec path and argv, and between argv and envp. * We only need the env keys; argv is already read from `ps`. */ export function venvHintsFromProcArgs2(buf) { if (buf.length < 8) return {}; const argc = buf.readInt32LE(0); if (argc < 0 || argc > 4096) return {}; let i = 4; const nextNul = (from) => { const at = buf.indexOf(0, from); return at < 0 ? buf.length : at; }; // Skip the exec path and the padding that follows it. i = nextNul(i) + 1; while (i < buf.length && buf[i] === 0) i++; const strings = []; while (i < buf.length) { const end = nextNul(i); if (end === i) { i++; continue; } strings.push(buf.toString("utf8", i, end)); i = end + 1; } return venvHintsFromEnvEntries(strings.slice(Math.max(0, argc))); } /** * Reconstruct the venv interpreter the PROCESS recorded. `__PYVENV_LAUNCHER__` * is the original argv[0] CPython saved before the macOS framework re-exec; * `VIRTUAL_ENV` is the activate-script / uv equivalent. * * The two are NOT equally trustworthy, and treating them as if they were is its own * #401. `__PYVENV_LAUNCHER__` is set by CPython itself, during this process's own * re-exec, so it describes THIS interpreter. `VIRTUAL_ENV` is set by `activate` and * then INHERITED by every child regardless of which interpreter that child is — * measured: * * $ VIRTUAL_ENV=/tmp/ve401 python -c "import sys,os; print(sys.executable)" * sys.executable = C:\Users\A\miniconda3\python.exe <- what it really imports * VIRTUAL_ENV = /tmp/ve401 <- an unrelated venv * * So a server launched by explicit absolute path from a shell with some other venv * active would be probed against that other venv — reporting `Triton: not installed` * off an interpreter the server never loaded, which is the original bug verbatim. * * `VIRTUAL_ENV` may therefore only OVERRIDE a usable argv[0] when the venv's own * `pyvenv.cfg` names argv[0] as the base it was built from. That is exactly the * macOS Python.app re-exec case (argv[0] IS the framework base of this venv), and * exactly not the stale-inheritance case. When there is no usable argv[0] to * contradict, the hint is better than nothing and is used as before. */ export function interpreterFromVenvHints(hints, /** argv[0] of the process, when it is an absolute path that exists. Its presence * DISQUALIFIES `VIRTUAL_ENV`; it never affects `__PYVENV_LAUNCHER__`. */ argv0) { // `__PYVENV_LAUNCHER__` is written by CPython during THIS process's own macOS // framework re-exec, to preserve the `sys.executable` it started with. It is a // statement about this interpreter, so it is authoritative. const launcher = hints.pyvenvLauncher; if (launcher && isAbsolute(launcher) && existsSync(launcher)) return pathResolve(launcher); const venv = hints.virtualEnv; if (!venv || !isAbsolute(venv)) return undefined; // `VIRTUAL_ENV` is a statement about the SHELL, not about this process. `activate` // exports it and every descendant inherits it whatever interpreter it happens to // run, so it can never establish which python is executing. // // An earlier revision tried to license it by checking the venv's `pyvenv.cfg` // against argv[0] — "was this venv built FROM argv[0]?". That is a different // question from "is this process running IN that venv?", and the two come apart // exactly where it matters. Measured on Ubuntu 24.04 (gate round 1): // // $ VIRTUAL_ENV=/tmp/ve402 /usr/bin/python3 main.py // sys.executable = /usr/bin/python3 <- NOT in ve402 // readlink -f /usr/bin/python3 = /usr/bin/python3.14 // /tmp/ve402/pyvenv.cfg executable = /usr/bin/python3.14 <- matches // // Every stock `python -m venv` records the base it was built from, so a system // python launching ComfyUI "corroborates" any venv on the machine. That check // re-filed #401 on configurations `main` handles correctly today. Comparing paths // cannot answer this; there is no path fact that distinguishes the two cases. // // So the rule is positional, not evidential: `VIRTUAL_ENV` is consulted ONLY when // argv[0] gave us nothing to probe (a bare `python`, a relative spelling, a path // that no longer exists). Then it displaces nothing and is strictly better than // the `undefined` we would otherwise return. Whenever argv[0] IS usable it wins, // because it is an observation of the process rather than of the shell. // // The macOS Homebrew case this feature exists for is unaffected: that is a // framework re-exec, which sets `__PYVENV_LAUNCHER__` above. And when neither // hint applies, the interpreter stays argv[0] and the caller's /system_stats // contradiction check discredits the probe — reporting Triton as UNKNOWN, which // is what #401 asked for ("a false `not installed` ... is worse than saying // nothing at all"). if (argv0 && isAbsolute(argv0) && existsSync(argv0)) { logger.info("Ignoring VIRTUAL_ENV: it describes the shell, and argv[0] is usable", { virtualEnv: venv, argv0, }); return undefined; } const candidates = IS_WIN ? [join(venv, "Scripts", "python.exe"), join(venv, "python.exe")] : [join(venv, "bin", "python"), join(venv, "bin", "python3")]; const found = candidates.find((c) => existsSync(c)); return found ? pathResolve(found) : undefined; } function venvHintsFromPid(pid) { try { if (IS_WIN) return undefined; if (platform() === "linux") { return venvHintsFromEnvironBuffer(readFileSync(`/proc/${pid}/environ`)); } const buf = execFileSync("sysctl", ["-b", `kern.procargs2.${pid}`], { timeout: 8000, maxBuffer: 2 * 1024 * 1024, }); if (!Buffer.isBuffer(buf) || buf.length === 0) return undefined; return venvHintsFromProcArgs2(buf); } catch { return undefined; } } /** Attach the raw venv hints. Deliberately does NOT resolve them: `VIRTUAL_ENV` is * only meaningful next to argv[0], which the resolver picks. */ function withVenvPython(pid, identity) { const hints = venvHintsFromPid(pid); if (hints && (hints.pyvenvLauncher || hints.virtualEnv)) identity.venvHints = hints; return identity; } /** * Is the process we found on the port the one the SERVER told us about? * * `/system_stats.argv` is the running server's own `sys.argv` — an observation made * inside the process that answered OUR request. Requiring the command line of the * process holding the port to contain every one of those tokens correlates two * independent observations, instead of trusting whatever happens to hold the port. * * This defeats the concrete hazard: a python reverse proxy on 127.0.0.1:8188 * forwarding to the real ComfyUI elsewhere is a different program with a different * command line, so it cannot match ComfyUI's argv — and its venv (which may well * lack Triton) is never mistaken for the server's. The same applies to a * tunnel/container-side forwarder, and to a second instance whose argv differs. * * Substring matching would absorb the quoting the OS adds around paths containing * spaces, but it also lets `main.py` match `proxy-main.py` and `8188` match * `81880` — a wrapper or neighbouring server could then pass its own venv off as * ComfyUI's. So matching is TOKEN-aware: the command line is split on whitespace, * surrounding quotes stripped, and each argv token must either equal a command-line * token exactly or match its final path segment (relative `main.py` vs absolute * `C:/ComfyUI/main.py`). Multi-word argv values (paths with spaces) fall back to a * full substring check, since they cannot be tokenised. Windows comparison is * case- and separator-insensitive. * * Residual, by design: a RELATIVE argv[0] (`ComfyUI\main.py`) can anchor to any * instance whose command line ends the same way, so two local installs are * indistinguishable here once one dies and the other grabs the port. Closing * that needs the process's cwd, which the restart path already correlates — * this correlation stays argument-only. */ export function commandLineMatchesArgv(commandLine, argv) { if (!commandLine || !Array.isArray(argv) || argv.length === 0) return false; const norm = (s) => (IS_WIN ? s.toLowerCase().replace(/\\/g, "/") : s); const stripQuotes = (s) => s.replace(/^["']+|["']+$/g, ""); const hay = norm(commandLine); const hayTokens = commandLine.split(/\s+/).map((t) => norm(stripQuotes(t))); const tokens = argv.filter((t) => typeof t === "string" && t.trim() !== ""); if (tokens.length === 0) return false; return tokens.every((t) => { const n = norm(stripQuotes(t.trim())); if (n === "") return true; if (n.includes(" ")) return hay.includes(n); // An ABSOLUTE argv path is an exact claim: it must equal a command-line // token, or `C:\ComfyUI\main.py` would "match" a different instance at // `D:\Other\ComfyUI\main.py`. Only a RELATIVE token (`main.py`) may anchor // to a token's final path segment. const absolute = n.startsWith("/") || /^[a-z]:\//.test(n); if (absolute) return hayTokens.some((h) => h === n); return hayTokens.some((h) => h === n || h.endsWith("/" + n)); }); } function absoluteLaunchScriptFromIdentity(identity, serverArgv) { const serverScript = positionalMainScript(serverArgv); if (!serverScript) return { invalid: false }; if (identity?.argvFidelity === "flattened") return { invalid: false }; const processArgv = identity?.argv && identity.argv.length > 0 ? identity.argv : tokenizeCommandLine(identity?.commandLine ?? ""); const processScript = positionalProcessScript(processArgv); if (!processScript || !isAbsolute(processScript)) return { invalid: false }; if (!sameLaunchScriptShape(processScript, serverScript)) return { invalid: false }; try { const resolved = realpathSync(pathResolve(processScript)); return statSync(resolved).isFile() ? { path: resolved, invalid: false } : { invalid: true }; } catch { // This was an explicit, correlated launch-script claim. Do not turn an // unreadable/missing/non-file entry into permission to anchor on python. return { invalid: true }; } } /** * Return the actual Python script position, never a later script-shaped option * value. Unknown options fail closed because their arity is not safe to infer. * `-X` and `-W` are the Python options whose separate value can itself end in * `main.py`; those values must be skipped before looking for the script. */ function positionalProcessScript(argv) { const noValueOptions = new Set([ "-B", "-b", "-d", "-E", "-i", "-I", "-O", "-OO", "-P", "-q", "-s", "-S", "-u", "-v", "-V", "-VV", "--help", "--version", ]); for (let index = 1; index < argv.length; index++) { const arg = argv[index]?.trim().replace(/^["']+|["']+$/g, ""); if (!arg) continue; if (arg === "--") { const script = argv[index + 1]?.trim().replace(/^["']+|["']+$/g, ""); return script && /(^|[\\/])main\.pyw?$/i.test(script) ? script : undefined; } if (!arg.startsWith("-")) { return /(^|[\\/])main\.pyw?$/i.test(arg) ? arg : undefined; } if (arg === "-c" || arg === "-m" || arg === "--command" || arg === "--module") { return undefined; } if (arg === "-X" || arg === "-W" || arg === "--check-hash-based-pycs") index++; else if (!noValueOptions.has(arg) && !/^-[BbdEiIOPqSsuvV]+$/.test(arg)) return undefined; } return undefined; } /** Compare a process's positional script with the server's `sys.argv[0]`. */ function sameLaunchScriptShape(processScript, serverScript) { const clean = (value) => value .split(/[\\/]/) .filter((part) => part !== "" && part !== "."); const processParts = clean(processScript); const serverParts = clean(serverScript); if (serverParts.length === 0 || processParts.length < serverParts.length) return false; const suffix = processParts.slice(-serverParts.length); return serverParts.every((part, index) => part.toLowerCase() === suffix[index]?.toLowerCase()); } /** The positional launch script in the server's own argv. */ function positionalMainScript(argv) { if (!Array.isArray(argv)) return undefined; for (const raw of argv) { if (typeof raw !== "string") continue; const arg = raw.trim().replace(/^["']+|["']+$/g, ""); if (arg.startsWith("-")) return undefined; if (/(^|[\\/])main\.pyw?$/i.test(arg)) return arg; } return undefined; } /** * The interpreter the running ComfyUI is ACTUALLY using, or `undefined` when we * cannot observe it. Never infers from install layout. * * Both tiers demand a real process IDENTITY, not just a PID: * - tier 1 requires the port owner to be the process we launched — same PID AND * same creation time, because PIDs get recycled and an externally launched * python could otherwise inherit our record; * - tier 2 requires the port owner's command line to match the argv the SERVER * itself reported, so a python reverse proxy sitting on the port cannot pass its * own venv off as ComfyUI's. * Any missing signal fails closed to `undefined` — unknown is always acceptable, a * confidently wrong package list is the entire bug (#401). */ export function resolveLiveInterpreter(opts) { const observed = observeLiveServerProcess(opts); if (!observed?.python || !observed.source) return undefined; return { python: observed.python, source: observed.source, pid: observed.pid }; } /** * The same single observation `resolveLiveInterpreter` makes, reported WITHOUT * collapsing it to the interpreter answer — so a caller that only needs to anchor an * install root still gets something when argv[0] cannot supply it (#1374). * * Identity is established exactly as before (the two tiers above); all that differs * is what is handed back. In particular the OS image is returned only AFTER the argv * correlation has passed, so a proxy on the port can no more contribute an anchor * than it can contribute an interpreter. */ export function observeLiveServerProcess(opts) { if (opts.remote) return undefined; const findPid = opts.findPid ?? findPidByPort; const readIdentity = opts.readIdentity ?? readProcessIdentity; let pid = null; try { pid = findPid(opts.port, opts.host); } catch { pid = null; } if (!pid) return undefined; let identity; try { identity = readIdentity(pid); } catch { identity = undefined; } const launchStateFields = (() => { const startedAtMs = processStartTimeMs(identity?.startedAt); return startedAtMs === undefined ? {} : { startedAtMs }; })(); // The OS's own record of the image, normalized: Windows reports it verbatim from // the launch, so a relative spelling comes back as an absolute path that still // contains the `..` it was written with (measured: `…\ComfyUI\..\python_embeded\ // python.exe`). const rawImage = identity?.executablePath; const image = rawImage && isAbsolute(rawImage) && existsSync(rawImage) ? pathResolve(rawImage) : undefined; const launchEvidence = absoluteLaunchScriptFromIdentity(identity, opts.serverArgv); const launchEvidenceFields = launchEvidence.invalid ? { launchScriptInvalid: true } : launchEvidence.path ? { launchScript: launchEvidence.path } : {}; // Tier 1 — the process we launched, confirmed by PID *and* start time. // The recorded interpreter must be ABSOLUTE: a bare `python` would be // re-resolved against OUR cwd/PATH at probe time, not necessarily the // interpreter the child actually spawned with — so a relative record fails // closed to tier 2. if (launchRecord && launchRecord.pid === pid && isAbsolute(launchRecord.python) && existsSync(launchRecord.python)) { // macOS `ps lstart` is SECOND-resolution: a PID recycled within the same // second compares equal. On that platform the timestamp alone is not an // identity, so the observed command line must also corroborate the recorded // interpreter; anywhere the stamp is sub-second (Linux ticks, Windows // FileTime) equality already is proof. const coarseStamp = platform() === "darwin"; const corroborated = !coarseStamp || commandLineMatchesArgv(identity?.commandLine, [launchRecord.python]); if (launchRecord.startedAt && identity?.startedAt && launchRecord.startedAt === identity.startedAt && corroborated) { const result = { python: launchRecord.python, source: "launched-by-us", pid, image, }; return { ...result, ...launchEvidenceFields, ...launchStateFields }; } // Same PID, different (or unreadable) start time → this is NOT our process, or // we cannot prove it is. Fall through to tier 2 rather than trust the record. logger.info("Ignoring the launch record: process identity could not be confirmed", { pid, recorded: launchRecord.startedAt ?? "(unavailable)", observed: identity?.startedAt ?? "(unavailable)", }); } // Tier 2 — the OS process table, correlated against the server's own argv. if (!commandLineMatchesArgv(identity?.commandLine, opts.serverArgv)) return undefined; // A relative or bare argv[0] ("python", "./python") does not identify a file we // can probe — the process's cwd is not ours. Only an absolute path that exists // is usable; anything else is honestly unknown as an INTERPRETER. The OS image // still travels with the result for the weaker anchor question (#1374). const rawArgv0 = argv0FromCommandLine(identity?.commandLine ?? ""); const argv0 = rawArgv0 && isAbsolute(rawArgv0) && existsSync(rawArgv0) ? rawArgv0 : undefined; // Prefer the venv the process itself recorded. After a macOS Python.app re-exec, // argv[0] is the Homebrew/framework BASE interpreter — it exists, versions match // the venv by construction, and probing it reports torch/Triton missing while // /system_stats shows they are there (#401 recurrence, 0.52.1). // // Resolved HERE, with argv[0] in hand, because `VIRTUAL_ENV` is only meaningful // beside it: inherited from an activating shell, it names a venv this process may // have nothing to do with, and letting that displace a good argv[0] would re-file // #401 on machines that never had it. `venvPython` remains honoured when a caller // supplied one directly. const fromEnv = identity?.venvHints ? interpreterFromVenvHints(identity.venvHints, argv0) : identity?.venvPython; if (fromEnv && isAbsolute(fromEnv) && existsSync(fromEnv)) { const result = { python: fromEnv, source: "process-table", pid, image }; return { ...result, ...launchEvidenceFields, ...launchStateFields }; } if (argv0) { const result = { python: argv0, source: "process-table", pid, image }; return { ...result, ...launchEvidenceFields, ...launchStateFields }; } return { pid, image, ...launchEvidenceFields, ...launchStateFields }; } //# sourceMappingURL=live-interpreter.js.map