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
JavaScript
// 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