ComfyUI Base — Handbook
Version 3.21.0. The shared toolchain every workflow package on a machine sources, on any host with an NVIDIA
GPU (RunPod, Verda, Crusoe, an owned box) and on any image or OS that gives it a driver and Python 3. One command,
run once per machine as step one; then each workflow package is step two, still one command. From a Mac,
podctl reaches the machine and hands its boot to the base (§1); after that every boot is the base’s.
0. Design rules
-
The base owns the machine — its boot included; a package owns only what is unique to it. Toolchain (uv, Python, torch, ComfyUI, the shared node packs, launch-arg hygiene), the model library layout, discovery and relocation on the volume, the boot (sshd, JupyterLab, ComfyUI), restart survival and shared state are the base’s. A package declares its workflow, its models, its extra packs and its own steps (hooks), nothing more.
-
One canonical ComfyUI tree on the volume, one venv beside it, one stamp. The tree is the one recorded in
state/boot.env, else an existing volume tree (/workspace/runpod-slim/ComfyUI,/workspace/ComfyUI,/workspace/*/ComfyUI), else the base materialises/workspace/ComfyUIfrom GitHub at the newest release tag (or a newerCOMFY_REF) - beside whatevermodels/ user/ output/ input/ custom_nodes/an image already keeps there. The container disk (/ComfyUI,/opt/ComfyUI,$HOME/ComfyUI) is never adopted and never scanned: it is restored from the image at every start. The venv is$COMFY/.venv-cu130(a name kept for every existing volume; its CUDA build is whatever §0.3 picks), stamped.comfy-base-venv(Python, torch, backend, base, the package that last stamped it); an unstamped venv that passes the probe is adopted and stamped; a rebuild happens at the same path with a backup and automatic rollback. A second tree on the volume is reported (other tree), its models are consolidated into the canonical one, nothing is deleted. -
Always the newest, for everything the base installs (3.0.0), and no fallbacks. Every run, on every host: ComfyUI is its newest
vX.Y.Zrelease on branchcomfy-base-stable(COMFY_REFmay name something NEWER, such as master, for one run; an older ref is refused). Every node pack, declared or not, is at its remote’s HEAD, never moved backwards (local edits stashed by name first). Every Python package is at its newest: ComfyUI’s and every pack’s requirement files are never installed as written, because their==pins would put packages back down;py/reqlift.pymerges them into one set with every hold lifted, uv resolves it, and whatever another package’s cap still holds back is forced to its newest with uv’s overrides. A pack whose own newest code cannot import with a newest dependency is named (“needs upgrading upstream”) and the install goes on; ComfyUI’s own startup failing is the one thing rolled back (only the packages that run changed). torch is the newest release on the newest CUDA build the machine’s driver runs (uv --torch-backend=auto), never moved backwards on a shared venv. Python is the newest CPython minor that resolves that set, its newest patch, among the minors the torch family (torch, torchvision, torchaudio) has wheels for (one small compile each, 3.9.2). uv, JupyterLab, SageAttention (a published wheel or a build, whenever main or torch moves), comfy-cli, pytest and huggingface_hub (in the venv, and in the downloader’s ownfetch/, 3.11.0) are upgraded every run. The OS packages and the NVIDIA driver (NVIDIA’snvidia-open) are upgraded first (§0.15). A pick that cannot be made stops the run. A pack row’s sha is the last-tested record, printed every run and written back bypodctlafter a green install; never a target. One exception, a buyer’s machine (3.1.0, the maintainer’s decision of 2026-09-25): withBASE_RUNTIMEset, a machine installs the newest runtime that passed its maintainer’s own sweep (§0.12) and changes nothing else, so a newcomer’s first run never meets an untested upstream change. Its freshness comes from runtimes being rebuilt and swept continuously, and an Update that applies a newer proven one. Every other run is everything newest. A second exception, a store that already holds the install (3.11.0, Paul’s “as fast as possible” of 2026-10-16): when this package’s last run on the store was green with the same package files, the same base and the same GPU arch, and every file it declares is in place, the run starts ComfyUI if nothing serves and stops (§5);BASE_FULL=1, or any change, runs everything newest. -
Everything the venv boots from lives on the volume, and the boot cannot brick the machine.
UV_PYTHON_INSTALL_DIR=<volume>/comfy-base/python,UV_MANAGED_PYTHON=1; the probe is HARD on an interpreter written to the container disk, so a poisoned venv is never reused.boot.shstarts sshd first and never exits, so a venv that cannot boot leaves a reachable machine with the reason in the boot log;rescuerepairs it without executing the interpreter. A volume root that is not a mountpoint is refused outright, unlessBASE_VOLUME_KIND=dirsays the machine is an owned box (§1). What RUNS is a copy on the machine’s own disk (3.15.0,lib/84-run-local.sh,py/run_local.py). The volume copy is the truth and the durable cache: every install writes it as above, and every ComfyUI process the base starts (the boot’s, the install’s restart, the quick test) starts from a generation on the container disk,/opt/comfy-run/<gen>/<source path>: the tree minusmodels/ input/ output/ user/ temp/(symlinks to the volume’s), every.gitand every file of 64 MiB or more outside the venv a symlink to the volume’s, the venv and the uv Python whole, with the venv’s own paths rewritten in the copy (bin/ links,pyvenv.cfg, shebangs,*.pth, editable finders). A parallel walk copies what changed and hardlinks the rest from the previous generation, so no file is written under a running server; the copy’s python must import torch withsys.prefixinside the generation before it is used; a fingerprint (state/run.gen, which every import check bumps, plus the mtimes of the tree, each pack, the venv’s stamp and site-packages) lets a start that changed nothing skip the walk. A container disk that came back empty is copied again. Anything short of a good copy (no room for it plus 4 GiB, an unreadable file, the check failing) says why on one line and runs from the volume, as before.BASE_LOCAL_RUN=autois on for RunPod when the volume’s filesystem is a network one (fuse,nfs, …;/proc/self/mountinfo);1forces it on any host,0turns it off.BASE_RUN_TWIN=<twin>=<source>(3.15.1, off by default; a template pod sets/opt/comfy-seed=/workspace) names a local twin of what the volume holds: a file whose twin has its size and an mtime within one second is copied from the twin on the container disk instead of read back over the network. The boot starts the copy first thing, in the background beside sshd, JupyterLab and the extensions (boot_prewarm), and_base_run_prewarmgives an install the same head start; the start that follows waits on the copy’s lock. A start that runs from the volume after all has the volume’s code read 64 files at a time in the background beside it (run_local.py warm, network filesystems only). MEASURED before, on RunPod network volumes: 1.72 ms per stat on the volume against 0.01 ms on the container disk,import torchalone 56 s, the import check 7.6 minutes and the restart 3.5 minutes (the platform orchestrator, pod kmgqu8yf0pscts); the Video Swapper’s--quick-test-for-ci5+ minutes at near-zero CPU, 262 of 265 threads waiting and one in a FUSE read. Reading the whole venv and custom_nodes 64 at a time took 18 s, after which the imports took seconds. -
The library is
models/<category>/<Family>[/<Purpose>]/<upstream filename>. A Family is a plain folder name (letters, digits,. _ -); the base checks the shape, and a brand keeps one spelling per family across its packages in<brand>/families.txt(§4). Two packages that want the same file declare the same row and it is downloaded once.vae_approx/is flat;LLM,custom_nodes/<Pack>,embeddings,sams,depthanything,SEEDVR2,RMBG,grounding-dino,latent_upscale_modelsandinsightfaceare freeform (flat by their consumers’ design). Model files in a flat category folder that no row declares (an image’s own downloads, the user’s files) are reported asunclaimedand left where they are. -
No alternate or optional models. A package’s rows cover every loader that is ACTIVE in its shipped workflow; bypassed loaders are reported by name, not downloaded. A
LOCALrow must already be on the volume or the run fails (upload it withpodctl upload; the next run’s scan claims it by name). -
The base boots the machine; a run restarts nothing by itself.
boot.sh(PID 1 once podctl has pointed a RunPod pod’s start command at it; the processcomfy-base-boot.serviceruns on a VM host) runs sshd → JupyterLab → ComfyUI from the base’s venv with the base’s launch line, then sleeps forever. An install prints the exact launch line;BASE_RESTART=1stops the running server (found by what it runs, never PID 1) and starts that line, the same function boot.sh uses (lib/85-launch.sh). One ComfyUI per machine: wherecomfy-base-boot.serviceis enabled the restart is the unit’s (systemctl stop, thenstart), so the server is always the unit’s, and the boot stops any ComfyUI it did not start before starting its own (3.0.3). -
One deletion prompt, default No. Duplicates, superseded files (only inside the package’s own family folders) and partials, never a file another installed package claims (the ledger).
BASE_YES=1approves. -
Privacy and secrets. Outbound hosts are huggingface.co, github.com, pypi.org, pypi.nvidia.com, download.pytorch.org, the distribution’s apt mirrors and developer.download.nvidia.com (NVIDIA’s CUDA repository); a
MODELSrow may name no other host. Nothing is uploaded.--disable-api-nodesis launch-arg hygiene. Tokens come from the shell env, else the machine’s own environment (PID 1’s:HF_TOKENor its aliasesHUGGING_FACE_HUB_TOKEN/HF_HUB_TOKEN), elsestate/tokens.env(0600), else one prompt. Template placeholders (token_here,replace_with_ids…) are named and ignored; a token its service rejects fails the run before anything downloads. No token ever appears in a log or on the process list:hfreads it from its environment, everycurlto the Hub reads it from a 0600 header file (-H @file, 3.12.0), and the suite runs with every token name stripped. The install only reads, so a token whose role iswriteis accepted and named as more than it needs (a warning: a read-only or fine-grained read token is enough); only the role is read out of the Hub’s answer, never the account it names (3.12.0). JupyterLab’s token ridesJUPYTER_TOKEN(orJUPYTER_PASSWORD) in its environment; without one the boot does not start it and says so (2.1.0). Since 2.5.6, whereBASE_LISTENis loopback (every host but RunPod, so the terminal is reachable only through an ssh tunnel) the boot GENERATES a token instead of leaving the terminal dark, and writes it owner-only tostate/tokens.env. It is still never printed, for the same reason no other token is: with a shared store the boot log is a file every machine mounts.podctl jupyter <machine>reads it over ssh and prints the URL with the token already in it. 3.9.0: JupyterLab is the pod’s named “Jupyter Terminal” link, so the boot generates its token on EVERY bind; 2.1.0’s public-bind refusal is gone because nothing listens tokenless and the token now has owner-only places to be read:podctl createputs a fresh per-podJUPYTER_TOKENin a RunPod pod’s env and prints the ready link with it (deploy --likegives the clone its own; a--privatepod gets it on the pod volume over ssh stdin instead),podctl status,jupyterandtunnelprint the link with it, and on RunPod’s public bind a token the boot made (never one the env set) is written as ONE line to the container log, the start command’s own stdout (RunPod’s Logs tab), which is how a pod from a public template with no ssh key gets its terminal. Never to the boot log. Keys (3.19.0). The machine readsHF_TOKEN(aliasesHUGGING_FACE_HUB_TOKEN,HF_HUB_TOKEN) and, for a package that lists it,CIVITAI_TOKEN(aliasesCIVITAI_API_KEY,CIVITAI_API_TOKEN). The Mac-side driver reads each host’s credentials in one order: the environment variable the host’s own tool reads (RUNPOD_API_KEY,VERDA_CLIENT_ID+VERDA_CLIENT_SECRET,CRUSOE_ACCESS_KEY_ID+CRUSOE_SECRET_KEY), else the macOS login Keychain item whose ACCOUNT is that variable’s name (security find-generic-password -a <VAR> -w, any service name;BASE_KEYCHAIN=0turns the lookup off, and the suite runs with it off), else the tool’s own config file. A pair counts only when both halves come from the same one of the three. A value that starts with{{is a RunPod Secret reference RunPod left unresolved (the account has no secret of that name): a placeholder, named and ignored by the install and dropped by the boot so a saved token applies.podctl createon RunPod lists the account’s Secret NAMES (REST v2, never a value) and, forHF_TOKENandCIVITAI_TOKEN, puts{{ RUNPOD_SECRET_<VAR> }}in the new pod’s env only when a secret of exactly that name exists; RunPod puts the value in at start. The references resolve inside the account that owns the pod, so a customer’s pod, made with the customer’s own key, gets the customer’s secrets or none and never the operator’s keys. -
The base owns ComfyUI’s launch flags.
state/comfyui_args.txtis the only source. An image’s own args file (RunPod’srunpod-slim/comfyui_args.txt) is imported once, on the first real run, and never read again; nothing creates a phantom file on images that have none.HYGIENE_ARGSentries are ensured present,HYGIENE_ARGS_REMOVEentries absent (the sage flag stays only whensageattentionimports in the venv). -
The GPU must be ours.
nvidia-smiinside a container reports the whole card’s memory but lists only the container’s own processes; memory in use that no visible process holds belongs to another container or a leaked context on the host, and every render dies at its first node with the card idle.py/gpu_facts.pymeasures it (foreign = used − ours; more than 4 GiB isNOT-OURS), discovery prints the numbers and fails the run onNOT-OURS,boot.shlogs them at every start, and podctl asks the same question over ssh BEFORE it uploads anything (install), after everystart/restart --wait, and instatus. Nothing inside the pod can free that memory: the remedy is a fresh container (stop and start; the leak may live on the machine), else a redeploy of the pod on the volume —podctl deploy --like <pod> --wait, a NEW pod cloned onto the same volume (image, GPU type, disk, ports, env, the base’s boot as its start command; the volume’s datacenter; never a templateId) that lands wherever the datacenter has the GPU free — else the host’s support with the machine id. -
An unattended machine is a mode, not a fork; nothing is ever frozen (2.1.0, 3.0.0).
BASE_NONINTERACTIVE=1makes every prompt take its default. A baked image may carry the base built at the volume’s own paths by a project’s image generator (BASE_SEED=1: toolchain and packs, no models, built where no GPU is, so its venv check asks for no device and its import check runs ComfyUI with--cpu, 3.9.1 and 3.10.0), parked on the container disk; a boot extension (ext/, §9) copies it onto an empty volume once, and the first install then takes everything to its newest like any machine. 3.0.0 retired the frozen mode (BASE_PINNED) andCOMFY_TAG: they held a machine below its newest, which §0.3 forbids. The base ships neither the generator nor the extension. A proven runtime is not a freeze (3.1.0):runtime-capturepacks a green machine’s code (the tree minus its data, the uv Python, the venv, the git-checkout packs, JupyterLab, the wheel cache; never a package’s vendored packs, which its zip brings) into parts under 2 GiB withruntime.json, the only place its versions are written;runtime-apply <manifest>unpacks it on a buyer’s machine, andBASE_RUNTIME=<state/runtime.json>installs from it with no OS, git, pip or uv step, each skip noted, the driver gate held at the manifest’s floor. A package that needs a pack the runtime lacks fails by name (“needs a newer runtime”). It is the §0.3 exception, and nothing else runs this way. -
The model library may be SHARED by several machines (2.4.0).
BASE_LIBRARYnames a store that more than one machine mounts at once, and with it setmodels/and ComfyUI’soutput/andinput/live there instead of on the machine’s own volume: a model is downloaded once for all of them rather than once each, and a file one machine writes is there for the next with no copy. ComfyUI’s ownmodels/becomes a LINK to it, not an extra search path, because several packs never askfolder_pathswhere their weights are and joinfolder_paths.models_dirwith their own folder name instead.user/andtemp/stay on the machine:user/holds the saved workflows the App view opens, and each machine carries only its own package. With no library set nothing changes and every path in this handbook reads as it always did. On Verda the store is anNVMe_Sharedvolume over NFS at/mnt/comfy-library, and a configured library is a hard requirement of the boot unit, so a missing mount means no server rather than a panel of empty dropdowns. -
The WHOLE root may be the shared store, and then a machine is just a GPU (2.5.0). This is the RunPod shape carried to a host whose machines are virtual machines rather than containers. On RunPod a pod has no disk of its own: one network volume mounts at
/workspaceand holdscomfy-base/, ComfyUI, the venv,packages/and the models, so the pod is disposable and the volume is the asset. WithBASE_VOLUME_SHARED=1the same is true here, and a machine carries only the disk it boots from. Two things follow, and the base does both for you: ComfyUI’suser/andtemp/move toBASE_LOCAL_STATEon the machine itself, because saved workflows, frontend settings and scratch are per machine and two servers writing oneuser/tread on each other; and an install takes a lock on the store, because pip and uv write a venv in place and neither expects a second writer. RUNNING takes no lock at all, which is what makes several GPUs on one store safe: running only reads. -
The OS and the NVIDIA driver are at their newest too, and the base never reboots a machine itself (3.0.0). The first stage of every run (
lib/15-system.sh, before the store’s lock and before the driver gate):apt-get full-upgrade, non-interactive, config files kept, needrestart told only to list (Ubuntu 24.04 would otherwise restart the boot unit mid-install). The driver is NVIDIA’s own newest-trackingnvidia-openwithcuda-toolkit, from NVIDIA’s CUDA repository (added from its newestcuda-keyringwhen missing). A machine on another family (Verda’s images ship Ubuntu’snvidia-driver-580-server-open) switches once, in ONE apt transaction that purges the old family’s packages and prebuilt kernel modules, after stopping the boot unit (that machine’s renders end there, as on any install’s restart). After any kernel or driver install the nvidia module must exist for the newest kernel, or the run fails and advises no restart. A driver that changed stops the run before anything uses the GPU: “restart required: A -> B”, andpodctlprints the restart a person approves (Verda forbids an in-guest reboot). RunPod upgrades the container’s packages only (the driver is the host’s;libnvidia-*andcuda-compat*held). A host the base cannot upgrade (not apt, no root and no passwordless sudo) is warned about and the run goes on. A new Ubuntu release comes from deploying the provider’s newest image, never an in-place release upgrade. Each machine writesstate/machines/<host>.env(driver, CUDA, kernel) to the store, so an install that moves the shared venv to a new CUDA major names the machines that must run their own install before they render.
1. Hosts
The core of the base (lib/, py/, suite/) runs on any NVIDIA GPU on any Linux with one persistent volume root.
What differs between hosts is how a machine is created, reached and booted, and that lives in hosts/<host>/: the
host’s provider (Mac-side, loaded by podctl), its startup script and its README. hosts/README.md is the matrix.
| Host | The machine | Volume root | The boot | Listen | Status |
|---|---|---|---|---|---|
runpod |
a container behind RunPod REST v1, sshd on a mapped port | /workspace, a network volume (mount) |
the start command podctl ensure wraps around boot.sh |
0.0.0.0 (RunPod’s HTTP proxy needs it) |
proven |
verda |
a VM behind Verda’s (DataCrunch’s) REST API, root@<ip>:22; an OS volume that IS the image next time |
/workspace, a block volume startup.sh formats once and mounts |
comfy-base-boot.service |
127.0.0.1, reached through podctl tunnel |
proven (live account, 2026-09-17 and 2026-09-18) |
crusoe |
a VM with an every-boot startup script and a default-deny firewall, ubuntu@<ip>:22 |
/workspace (mount) |
comfy-base-boot.service |
127.0.0.1, through the tunnel |
implemented, not yet run on a live account |
local |
an owned NVIDIA box, no API | a directory of your choosing (BASE_VOLUME_KIND=dir) |
comfy-base-boot.service, installed by hand |
127.0.0.1 |
a recipe |
The four knobs the core reads (lib/00-env.sh; _base_host_resolve settles them once every lib is loaded):
BASE_HOST:runpod | verda | crusoe | local | vm. Explicit, elserunpodwhen PID 1’s environment carriesRUNPOD_POD_ID, else read from<volume>/comfy-base/state/host.env(the driver writes it onensureandinstall), elsevm(a VM whose host is unknown).boot.shanswers the same question the same way (boot_host).BASE_VOLUME: the persistent root,/workspaceby default. Every path the base keeps across a restart derives from it (<volume>/comfy-base,<volume>/packages,<volume>/ComfyUIwhen the tree is materialised).BASE_VOLUME_KIND:mount(the default: the root must be a mountpoint, never a directory on the container disk) ordir(the default whenBASE_HOST=local: a directory on an owned box is the volume).BASE_LISTEN: the address ComfyUI and JupyterLab bind.0.0.0.0on RunPod because its HTTP proxy needs it,127.0.0.1everywhere else (Verda has no cloud firewall; the driver’s ssh tunnel is the way in).
How the driver picks a host. podctl [--provider <host>] <command>, the flag before or after the verb; without
it $PODCTL_PROVIDER, else the one brand.toml host of the repository this base sits in (two brands on two hosts
make the flag required), else runpod. The provider answers where the machine is and how it starts, stops, clones
and hands its boot to the base; ssh, scp, upload, install, the lease, the tunnel and the GPU gate are the driver’s and
the same on every host. The Host <alias> block the driver writes is the provider’s name (runpod, verda,
crusoe) unless PODCTL_HOST overrides it, and the key is the provider’s (~/.ssh/id_ed25519 on RunPod,
~/.ssh/verda_comfyui on Verda). ensure and install write state/host.env on the machine (BASE_HOST,
BASE_VOLUME). An experimental provider says so once per run, on stderr. Credentials (RunPod’s key, Verda’s client
secret) are read by the provider from the environment or its tool’s own config file and are never printed, logged or
placed in an argument. A host whose provider is not implemented (Crusoe today) is refused by name: hosts/<host>/
holds the interface and a recipe.
1.1 RunPod (proven)
The one command (2.0.14). Once a pod is reachable (podctl ensure), the whole documented sequence is
uv run "base/comfyui-base/_build/pod/podctl.py" install <pod> --base --pkg "<package dir>"…. For each item in order,
the local zip is checked current, the volume size is recorded for the disk gate, the zip is uploaded and extracted,
--check runs on the pod (non-zero stops everything), the real run goes detached (BASE_RESTART=1 for a package;
--comfy-ref <ref> for a newer ComfyUI, that install only) and is polled until STEP RC=, the pod’s state/logs are
copied to <item>/_build/podruns/<date>/pod-logs/, the summary block is printed, and a green run writes the pack records
it printed (each pack’s HEAD) into the local script and rebuilds its zip. A run that upgraded the NVIDIA driver stops with
“restart required” and prints the podctl restart a person approves; the same install then continues. A red run stops with the console’s path: the log is the deliverable, the fix loop
starts there. podctl tunnel <pod> is the one ssh session that forwards ports (8188, 8888, each app port the machine
serves (3.9.0), and 9199 and 11434 beside them:
the base runs no metrics exporter and no inference server, but a package may run one on the machine and reach
it on loopback, which is the shape a package takes when its point is that nothing leaves the box) for the canvas tools and
the browser; it prints them first as named localhost links (ComfyUI http://localhost:8188, Jupyter Terminal http://localhost:8888/lab?token=…, one per app), which is how a --private pod’s ports are named. podctl prune <pod> lists the old-layout folders an earlier layout left under /workspace/packages
(<Display Name>/ with a <Display Name> Script.sh, and ComfyUI Base/) and their workflow copies in ComfyUI’s
browser; --yes removes exactly those, after the re-install has put the <name>/ folders beside them. --pkg takes
the repository-relative package path (<brand>/packages/<name>) or a bare <name> that one brand has.
The REST key comes from RUNPOD_API_KEY, else ~/.runpod/config.toml (written by runpodctl config --apiKey=…);
it rides a bearer header and is never printed.
uv run "base/comfyui-base/_build/pod/podctl.py" status <pod> # facts; env NAMES and placeholder verdicts, never values; the GPU verdictuv run "base/comfyui-base/_build/pod/podctl.py" deploy --like <pod> --wait # a NEW pod cloned onto the same volume (rule 11's redeploy rung)uv run "base/comfyui-base/_build/pod/podctl.py" create --name <n> --image <img> --data-gb 500 # a first pod: stock check (B200, then B300), PRIVATE: pod volume (not encrypted), 22/tcp only, ComfyUI via podctl tunneluv run "base/comfyui-base/_build/pod/podctl.py" create --name <n> --image <img> --data-gb 500 --public # not private: a network volume, ComfyUI and JupyterLab on proxy URLsuv run "base/comfyui-base/_build/pod/podctl.py" install <pod> --base --pkg <dir> --no-suite --env KEY=VALUE # BASE_NO_SUITE=1 and KEY on every run on the machineuv run "base/comfyui-base/_build/pod/podctl.py" ensure <pod> # 22/tcp · PUBLIC_KEY = the Mac key · the start command · Host runpod · state/host.envuv run "base/comfyui-base/_build/pod/podctl.py" upload <zip>... # to /workspace/packages, size + sha256 checked both sidesuv run "base/comfyui-base/_build/pod/podctl.py" stop|start|restart <pod> --waitensure sets the pod’s start command to bash -c "if [ -x /workspace/comfy-base/boot.sh ]; then exec bash …; fi; <sshd bootstrap>; exec <the image's own entry>" (the image’s entry read from Docker Hub’s registry; --entry for
private images). Before the base is installed that boots the image as it was plus sshd; afterwards it boots the base.
stop/start --wait rewrite the Host runpod block, because RunPod maps a new public port on every start. ComfyUI
(8188) and JupyterLab (8888) are also on RunPod’s HTTP proxy ports, which is why this host binds 0.0.0.0.
Named ports (3.9.0). RunPod’s Connect tab links ports, not paths, and names a port only from the pod’s TEMPLATE:
GraphQL PodTemplate.portsConfig: [{port, name}], written by saveTemplate (REST v1 and v2 have no label field, and a
pod has no names of its own; a pod with no template shows “Port 8188 → HTTP Service”). So podctl create exposes
8188/http “ComfyUI”, 8888/http “Jupyter Terminal” and one /http port per customer app the tree’s packages declare
(§4 APP_PORTS), then 22/tcp, and puts the pod on a private template of the account, comfy-base ports <sig>, made
once per set of names (REST POST /templates, then the names re-sent with every other field through saveTemplate, the
way runpodctl v3 does). That template carries the names and nothing else: no env, no start command, no volume.
--no-names skips it. A failure to write the names is said and never costs the pod. status prints the links
(https://<pod>-<port>.proxy.runpod.net, the terminal’s with its token). On the machine, py/app_proxy.py serves each
app at its own port’s root (§6). Open by default (3.19.1). Since 3.19.1 the links are OPEN by default: one click on the Connect tab opens ComfyUI, each app and the Jupyter Terminal (no key, no token), because RunPod’s proxy passes nothing that names the pod’s owner (its docs: the service “becomes publicly accessible”; “implement proper authentication in your application”), so any login is a step. The key below, and JupyterLab’s token on the public bind, are the opt-in: BASE_LINK_LOCK=1 in the pod’s env (podctl create --lock). The key (3.14.0), now opt-in. Every one of those links but the terminal’s is locked by the pod’s
own BASE_LINK_TOKEN (fresh per pod from create, else generated by the boot): the printed link carries it once
(/?token=<key>), the forwarder trades it for a host-only cookie and redirects to the clean URL, and the Connect tab’s
plain links open from then on; neither gives a plain 401. ComfyUI itself binds 127.0.0.1 on a public bind and the
forwarder serves 8188 on the container’s other addresses (--front), so nothing from outside reaches it unlocked. A
public pod is the default again (--private is opt-in), and it reuses the network volume comfy-base-data where the
GPU is free, so models download once (hosts/runpod/README.md). Fallback
when the API is not available: the JupyterLab terminal on port 8888, where bash lib/boot.sh --print-sshd-bootstrap
prints the sshd snippet to paste.
Which GPU (3.8.0). The video packages are tuned for NVIDIA’s datacenter Blackwell: B200 (RunPod type
NVIDIA B200, compute capability 10.0 = sm_100, 180 GB HBM3e) first, then B300 (NVIDIA B300 SXM6 AC, 10.3 =
sm_103, 288 GB). A package says so with GPU_PREFER="B200 B300" (§4), and py/gpu_fit.py is the one table and rule
behind it: the pod’s own run warns in its banner and SUMMARY, and podctl install warns before it uploads, when the
GPU is below (another architecture with less memory: an H200 at 141 GB, an RTX PRO 6000 at 96 GB). Never a stop.
podctl create takes the same order when no --gpu is given, and a CUDA floor of 13.0. What holds on RunPod, read 2026-10-15/16 (stock moves; the
create reads it live):
- B200 is Secure Cloud, with stock in EU-RO-1, US-CA-2, US-NC-2, US-NE-1 and US-TX-6; B300 in EUR-IS-1, the
one datacenter with B300 that also takes a network volume.
NVIDIA B300 SXM6 AC MIG 1g.34gbis a 34 GB slice, not a B300, andgpu_fit.pynever counts it as one. - A network volume pins its datacenter: create it where the GPU is, and a pod on it can only ever be that DC’s GPU.
- The CUDA floor is 13.0 (REST
allowedCudaVersions: ["13.0"]; GraphQL calls itminCudaVersion): the base’s cu130 torch and the Blackwell kernels need a 580+ driver, and a host on CUDA 12.x answers with an older one. - No NVENC. NVIDIA’s encode/decode matrix gives the HGX B200 and B300 0 NVENC engines (7 NVDEC), like the H100, so an
h264_nvencsave fails there; encode on the CPU (libx264). “RTX” features (RTX Video Super Resolution) name an RTX GPU, which these are not.
Which test lane (3.21.0). A smoke is not a pod run: it goes to the cheapest GPU that holds it. py/gpu_fit.py’s
LANES lists them cheapest first (Kaggle T4 and L4, HF ZeroGPU large and xlarge, Modal’s monthly credits from a T4
to a B200, then RunPod, which is paid), and pick_lane(peak_gib, minutes, needs) returns the first whose card holds the
measured peak plus 10 % and whose own limit holds the run: Kaggle’s 12 h session, ZeroGPU’s 30-minute cap (15 on
xlarge, which costs the quota twice), the minutes $30 buys at Modal’s list price within its 24 h per call. needs
names fast dtypes (fp16 bf16 fp8 fp4, from the compute capability), server (ZeroGPU runs per-call functions, so
never), or GPU classes, judged by fits() as GPU_PREFER is. python3 py/gpu_fit.py --peak-gib 35.7 --minutes 10 --needs bf16 prints lane=zerogpu-large and its card; lane=none exits 1.
Past B200 and B300, and no volume needed (3.18.0, Paul, 2026-10-16: rendering speed first). podctl create with no
--gpu asks for NVIDIA B200, NVIDIA B300 SXM6 AC, then NVIDIA H200, NVIDIA H100 80GB HBM3 and NVIDIA RTX PRO 6000 Blackwell Server Edition (Paul’s order; DEFAULT_GPUS, hosts/runpod/provider.py). The GPU order comes first: for each GPU in turn, the network volume --volume-name where
that GPU is free, else a fresh pod in the first data centre that has it, on a pod volume of --pod-gb (default 400),
where the install downloads the models again, six at a time and pinned (§5). So a B200 with no volume beside it beats a
B300 that has one. --location, --volume and --data-gb keep their meaning; --no-fresh is 3.14.0’s rule. When it
picks a GPU, create says what that GPU means for a package before anything bills: on a B300, INT8 tensor throughput is
about 1/24 of a B200’s (NVIDIA’s HGX table: 3 POPS for HGX B300 against 72 for HGX B200, FP8 the same), so a package
loads fp8 or bf16 weights there; on an H200, nvfp4 is emulated. A pod volume is terminated with its pod at a $0 balance,
and RunPod’s balance trails its spend: podctl balance prints it with the spend per hour and the balance projected past
--lag-min minutes (default 30) of spend not booked yet, and podctl guard --floor 5 --yes stops every running pod
once that projection is at the floor (without --yes it says which it would stop; exit 3 at the floor). A scheduler
on the Mac runs the guard; the base never schedules anything.
- Attention (3.7.5): SageAttention’s
auto(KJ’s default, and--use-sage-attention) has nosm_100dispatch, so on a B200 it fails at the first step. The base runs every KJ mode on a tiny tensor and keeps the answer per GPU ($VENV/.comfy-base-sage/<sm>.json,base_sage_mode,bash base.sh sage); a package uses the mode it names, and SageAttention’s Triton kernel (sageattn_qk_int8_pv_fp16_triton) is the safe choice on both chips.
1.2 Verda
Proven on a live account, 2026-09-17 and 2026-09-18 (2.5.3); the driver no longer warns. What those runs answered,
and the recipe in full, is hosts/verda/README.md; the shape:
- Credentials: a client id and secret (OAuth2 client credentials, exchanged for a bearer token), from
VERDA_CLIENT_ID+VERDA_CLIENT_SECRETor~/.verda/credentials(mode 600). The key is~/.ssh/verda_comfyui;ensureregisters its.pubwith Verda when no registered key equals it. - The machine: an RTX PRO 6000 class instance type (
VERDA_INSTANCE_TYPE), theubuntu-24.04-cuda-13.0-open-dockerimage, one location for the instance and every volume, and ONE data volume named<machine>-databeside it.hosts/verda/startup.sh(registered ascomfy-base-startup, and run again byensureover ssh) finds it as the one unmounted/dev/vd[b-z], formats it ext4 only when blank, mounts it at/workspaceby UUID withnofail, and installscomfy-base-boot.service. - The sequence:
podctl --provider verda pods,start <os volume id>(a new instance from that volume, data volumes attached), thenensure <instance id>,install <instance id> --base,tunnel <instance id>. - Stop and start are delete and redeploy. A shut-down Verda instance still bills the GPU, so
stopdeletes the instance keeping every volume, andstart <os volume id>redeploys from the OS volume (the disk, the packages, the unit and the fstab line all come back). The public ip and the instance id both change on every start; the provider rewrites theHost verdablock, and the OS volume id is the stable handle of a machine. - No cloud firewall: every port bound on
0.0.0.0is on the internet at once, so the base binds loopback here and the tunnel is the way in. The balance is prepaid and at zero every volume goes to the trash, with 96 hours to restore.
The first live pass must settle the ssh user, whether the startup script re-runs on a redeploy, and when ip
populates (the README’s numbered list); the EXPERIMENTAL notice comes off only when all three are known.
1.3 Crusoe (implemented, not yet run live)
A VM with an every-boot startup script and a default-deny firewall, ubuntu as the login user, /workspace as a
mounted block volume and the base’s systemd unit as the boot. hosts/crusoe/provider.py is a signed REST client
carrying the whole Provider interface, and suite/test_crusoe.py drives it against a fake API that verifies every
signature; no call has reached the real service yet, so the driver still says EXPERIMENTAL. Three things differ
from Verda and shape the code: stop is a real stop (a stopped VM bills its disks only), the public ip is dynamic
and changes on every start, and the startup script is a create-time field that cannot be changed through the API,
so ensure ships the current one over ssh. The recipe, and what a first live run should report, is
hosts/crusoe/README.md.
1.4 local (a recipe)
An owned NVIDIA box: no API, no driver. BASE_HOST=local makes BASE_VOLUME_KIND=dir, so BASE_VOLUME is a
directory of your choosing; the base binds loopback; base_boot_install writes comfy-base-boot.service beside
boot.sh and installs it itself when it runs as root on a live systemd host, else it prints the cp and
systemctl enable to run. The recipe is hosts/local/README.md.
2. Step one on a fresh machine
cd <volume>/packagespython3 -m zipfile -e "comfyui-base.zip" . # slim images have no unzipbash "comfyui-base/comfyui-base-script.sh"The base’s zip is comfyui-base.zip, one artefact for every host. The script copies itself to <volume>/comfy-base/
and re-executes from there, then: resolves the host (§1), refuses a volume root that is not a mountpoint (unless
BASE_VOLUME_KIND=dir), upgrades the OS and the NVIDIA driver (§0.15), finds or materialises the canonical tree
(§0.2), reads the tokens (§0.9), moves ComfyUI to its newest release, gates on the version floor, moves the six shared
packs (rgthree, KJNodes, VideoHelperSuite, cg-use-everywhere, ComfyUI-Manager, ComfyUI-advanced-model-manager) to their
HEAD, builds or adopts the venv with every present pack’s requirements at their newest (§0.3), sets the launch-arg hygiene, installs boot.sh + the boot unit on a VM host + the tools venv
(JupyterLab) + state/boot.env, writes its ledger entry, and prints the launch line. --check is the dry run (on a
volume with no tree it stops after “would materialise”). bash base.sh status lists every package installed on the
machine and, when the driver wrote one, prints state/host.env.
On a machine from a baked image step one has already happened inside the image: a boot extension (ext/*.sh, §9)
copies the seed onto the empty volume on the first boot, and the project’s packages install themselves from their
zips with BASE_YES=1 BASE_NONINTERACTIVE=1 BASE_NO_SUITE=1, taking everything to its newest. The base ships no such
extension.
3. Step two: a workflow package
cd <volume>/packages && mkdir -p "<name>" && python3 -m zipfile -e "<name>-<host>.zip" "<name>" && BASE_RESTART=1 bash "<name>/<name>-script.sh"A package zip is <name>-<host>.zip, the host being its brand’s brand.toml (§4; runpod for a package with no
brand above it). It is flat and extracts into its own folder (podctl install does this): two packages both ship
suite.py and pytest.ini, and flat extraction let the second overwrite the first’s (2.0.17).
Same six forms everywhere: install · --check · --latest (3.0.0: the same as a plain install; kept for old callers) ·
test [tier] · rescue · sage (3.7.5: does SageAttention RUN here; it answers before a package’s own sage row) ·
help, plus the package’s own PKG_COMMANDS. BASE_RESTART=1 makes the summary’s smoke,
combos and suite run against the server this run installed.
4. The package contract
A thin <name>-script.sh (60–200 lines) declares, then sources the base and calls base_main "$@":
- Required:
PKG_NAME PKG_ID PKG_VERSION BASE_MIN WF_NAME COMFY_MIN PACKS MODELS. A package whoseBASE_MINis newer than the installed base is refused, both versions named.WF_NAME=""is allowed (2.2.0): a package with no workflow yet; the packager ships no workflow member, and the sync, smoke and combo stages report “no workflow” and pass. - The brand: a package belongs to the brand whose
brand.tomlsits above it (<brand>/brand.toml, withnameandhost = runpod | verda | crusoe | local).py/brand.pyreads it; the packager names the zip<name>-<host>.zipby it and the driver takes its default provider from it. A tree with nobrand.tomlabove it (an extracted zip, a flat checkout) is hostrunpodby default. - Where the packages are (3.6.0; 3.7.0 for
podctl): a package directory is anydholdingd/<basename(d)>-script.sh, in one of two shapes under a consumer root. The submodule shape keeps the base at<root>/base/comfyui-baseand the packages at<root>/<brand>/packages/<name>/. The workspace shape clones one repository per package side by side as<root>/<name>/(the folder is the package’s name, so<name>-script.shinside it passes the rule), keeps the brand’sbrand.tomlandfamilies.txtat<root>, and reads the base from$COMFY_BASE, a shared checkout outside the workspace; the workspace names itself with$BASE_WORKSPACE. The root is an explicit--root <dir>(the Python tools), else$BASE_WORKSPACE(the only overrideverify.shandtestbed.shtake), else<root>of the submodule shape, else none, and then only what the caller named is walked. A named root that does not exist is an error, and every walker fails on it rather than walking zero packages; so doverify.shandtestbed.shwhen the helper itself fails.py/pkgdirs.pyis that rule, once:package.py --all,verify.sh,testbed.sh, podctl’s pin scan (which also reads the base’s ownlib/40-packs.sh, beside the driver) and the unit tier’s cross-package checks all ask it. Dot-directories, acomfyui-base/checkout and the caller’s own base are never packages. - The brand’s families:
<brand>/families.txtbesidebrand.toml, one family spelling per line (#comments), is the brand’s list ofFamilyfolders. The base’s unit tier, run from the brand repository, checks every package’s rows against it (test_unit_every_package_family_is_in_its_brands_families_file); on the machine a Family is checked for shape only, so no zip carries the list. A brand without the file skips the check. - Optional:
WF_VERSION_KEY(theextrakey carrying the workflow’s version; defaultpackage_version),DRIVER_MIN SUPERSEDED LEGACY_DIRS DROPPED_PACKS TOKENS PIP_EXTRA HYGIENE_ARGS HYGIENE_ARGS_REMOVE LOADER_CATS PKG_COMMANDS PKG_IMPORT_CHECK PKG_NO_SUITE ZIP_EXTRA VENDORED_PACKS GPU_PREFER.GPU_PREFER="B200 B300"(3.8.0) names the GPU classes the package is tuned for, best first, frompy/gpu_fit.py‘s table (an unknown class is an invalid declaration); a GPU below them is a warning in the run’s banner and SUMMARY and inpodctl install, never a stop (§1.1).PKG_NO_SUITE=1is how a package that ships nosuite.pysays so: the run’s test stage notes it, andverify.shpasses the package as “no suite, declared”.LOADER_CATSrows (NodeType|library category) extend the base’s map of loader node types to library folders (lib/60-sync.shcovers ComfyUI’s core loaders and the shared packs’); a package whose own pack loads models declares its loaders here. - Optional, 3.9.0:
APP_PORTS=("<port>|<slug>|<name>" …), one row per customer app the package ships, which ComfyUI serves at/apps/<slug>/(a pack the package carries; the base serves none). The port is 1024-65535 and never one of the base’s (22, 8188, 8888, 9199, 11434), the slug is the app’s folder, the name is what the host’s console and the tunnel call it (APP_PORTS=("8189|video-replacer|Video Replacer App")). The BOOT stage writes the rows tostate/apps.d/<package>.listand restarts the forwarder;podctl create,status,tunneland a brand’s template read the same rows from the script. A port two packages declare failspodctl, naming both. The app must reach ComfyUI’s own routes relative to its own URL (new URL("../../", here)), so it works at/apps/<slug>/and at/on its port alike, and may not name a top-level file after a route the forwarder passes through (ws upload view history queue interrupt). VENDORED_PACKS=( dir ... )(3.2.0): the package’s own node packs, folders beside the script that ship in its zip (their files listed inZIP_EXTRA). The base mirrors each intocustom_nodeson every install, right afterpkg_post_venvand before the requirements resolve: the installed folder becomes exactly what the zip carries (a file the pack dropped is gone), and a pack whose content did not change is not touched. Each installed copy carries a stamp,.comfy-base-own(package, version, content digest). On a shared store two packages may ship the same pack: the newestpyproject.tomlversion wins and is never moved backwards, and the same version with different content is installed and named (one of the two missed a version bump). An own pack that fails the import check is put back to its previous copy (kept instate/own/prev/) and the restart is blocked, so the live server keeps the code that worked. The ledger recordsown <dir> <version> <digest>;BASE_DECLARE_ONLY=1printsOWNROW dir|version|digest. A package no longer needs its own copy loop in a hook; one that still has it is harmless (the base saves the live copy before the hooks and mirrors after them).LAUNCH_ENV=( "KEY=value" ... )(3.10.0, optional): variables ComfyUI is started with. A package cannot reach the pod’s start command or the boot unit, so this is its only way to set ComfyUI’s environment. The install writes the validated list tostate/launch.d/<PKG_ID>.env(removed when the package stops declaring one) and every launch, the install’s restart and every boot alike, reads the whole folder (lib/85-launch.sh,_base_launch_env). Allowed keys:OMP_NUM_THREADS MKL_NUM_THREADS OPENBLAS_NUM_THREADS NUMEXPR_NUM_THREADS VECLIB_MAXIMUM_THREADS TOKENIZERS_PARALLELISM PYTORCH_CUDA_ALLOC_CONF PYTORCH_ALLOC_CONF CUDA_MODULE_LOADING RENDER_SPEED_ATTENTION(3.18.0: the attention order a node pack reads at startup,off,cudnnorfa4) and CTranslate2’sCT2_*; a value is one word of[A-Za-z0-9_.,:+-]. Anything else (a path, a library, a token, a variable the base owns such asHF_HOME) refuses the package’s declarations before anything downloads, and a key set twice does too. Precedence: the machine’s own environment (a variable set on the pod, in the unit) wins; then the packages’ files in name order, the first naming a key winning and a different later value named in the launch’s output; then the base’s one default,OMP_NUM_THREADSfrom the container’s CPU quota.BASE_DECLARE_ONLY=1printsLAUNCHENV KEY=value. A base before 3.10.0 ignores the array, so a package that depends on it setsBASE_MIN.- The CPU quota (3.10.0): when nothing sets
OMP_NUM_THREADS, the base sets it to the CPUs the container’s cgroup quota allows (cpu.max, v2, orcpu.cfs_quota_us/cpu.cfs_period_us, v1; rounded up; never more than the CPUs online), and leaves it unset when there is no quota. A RunPod container is a slice of a large host:nprocand torch both see the host’s CPUs while the CFS quota lets the container run only its own. Its effect on torch: the intra-op pool (torch.get_num_threads(), every CPU op: preprocessing, CPU offload, a CPU-side VAE or encoder) is sized byOMP_NUM_THREADSwhen set, else by the CPU count torch sees, so without it a container with (say) a 16-CPU quota on a host torch counts as 112 cores runs 112 threads on 16 CPUs’ worth of time and is throttled. The same variable sizes OpenBLAS’s pool (numpy) and CTranslate2’s: faster-whisper on CPU uses 4 threads unlesscpu_threadsorOMP_NUM_THREADSsays otherwise (itsWhisperModeldocstring), and a node that never passescpu_threadsgets the quota’s count. Inter-op threads, the GPU and CUDA kernels are unaffected. On a machine with no quota torch keeps its own default (the physical cores it sees), the right number there. - Hooks, called if defined, dry-run aware via
$BASE_DRY:pkg_pre_venv pkg_post_venv pkg_pre_models pkg_post_models pkg_hygiene pkg_import_check pkg_post_install pkg_summary, andpkg_cmd_<name>perPKG_COMMANDSrowname|function|help. A failing hook fails the run. A package whosepkg_post_venvcompiles something (SageAttention) is listed for re-run after any venv rebuild. PACKSrows:dir|url|sha|cnr_id|why[|branch]:shais 40 hex, the last-tested record (every run takes the pack to its remote’s HEAD);whyis free text without a|; a base pack may not be redeclared; two packages naming one pack from two URLs (or two branches) is an error naming both, and two different records for one pack is a note.branch(3.6.1, optional, last so every five-field reader is unchanged) names the branch to track when the remote’s HEAD is the wrong one: rgthree publishes a 2023devas HEAD while its work is onmain, so its base row ends in|main. A row with a branch is cloned at that branch, an existing checkout on any other branch (a pod cloned from the stale HEAD, shallow or not) moves to it and is wired to track it, the fast path asks that branch’s tip, and the testbed fetches it; never backwards, edits stashed, exactly as for HEAD. It is a branch, never a pin: a commit, a name with spaces (the tail of awhythat held a|) or a seventh field is refused when the tables are validated. Leave it off unless the remote’s HEAD is genuinely stale.MODELSrows:category|Family|Purpose|file|url|bytes|note|alts—bytesexact (base.sh gen-models <dir>fills it from the Hub);urlis a Hugging FaceresolveURL, a GitHub URL (a release asset),LOCAL, orhf://owner/repowithfileending in/for a repo snapshot; no other host is accepted;altsare legacy basenames the index may adopt. Pin a row to a commit (3.12.0):resolve/<40-hex commit>/<path>for a file,hf://owner/repo@<40-hex commit>for a snapshot (a branch after the@is refused). The revision a row names is the one downloaded, and every Hub download is checked withhf cache verifyagainst the sha256 (LFS) or git hash the Hub states for that revision before it moves into place; a file that fails is deleted, never resumed. A file is hashed once, when it downloads (inside its own fetch job, so beside the other downloads), and its commit and etag go tostate/verified.tsvfrom the metadatahf downloadwrites anyway; a later run checks size and that record and hashes nothing, unless the row’s pin moved to a commit whose file differs (one HEAD tells). An unpinned row followsmain, so its bytes break the day the repository changes.base.sh gen-models <pkg dir> --pinprints the rows pinned to the commits they are at now. A Hub error retrying cannot fix (a gated repository, a refused token, a missing repository, file or revision) is said once in one plain line and not retried.base.sh gen-hashes <pkg dir> [--check](3.4.0) writesmodels.sha256beside the script (<sha256> <file>, thesha256sumform): each Hugging Face file’s sha256 as the Hub states it on a HEAD (x-linked-etag), so nothing is downloaded or hashed; the packager ships it,BASE_DECLARE_ONLY=1printsMODELHASH file|sha256, and stamp-models addshash+hash_typeto those entries.base.sh stamp-models <pkg dir>writes ComfyUI’s ownmodelsarray into the shipped workflow from these rows (--checkcompares).MODELS_LATER=( <file> ... )(optional, 3.1.0): the MODELS rows the package’s FIRST flow does not need, by theirfile. Only a staged run (BASE_STAGED=1, a buyer’s machine) acts on it: each gets a stand-in (a 0-byte file at its dest, listed instate/standins.list), because ComfyUI validates every active loader’s filename before a run, even on branches the run never takes; the server starts on the first flow’s files; a detachedbase.sh fetch-laterdownloads the rest, andstate/progress.json(§7) says where each is. A LOCAL row and a snapshot row cannot be later. The package keeps a buyer off a flow whose files are still stand-ins (its own page readsprogress.jsonand refreshes its dropdowns as files land). Every other run ignores the list, and treats a stand-in it meets as absent.- The workflow beside the script must carry
extra.<WF_VERSION_KEY> == PKG_VERSION; the run refuses a mismatch. - Helpers a hook may use:
ok miss err note warn hdr would todo,base_mkdir,base_run <cmd> <args>…(dry-run aware: runs the command, or prints it under--check; 3.7.2: the install pipeline isbase_install, so a hook that callsbase_runno longer re-enters the whole install),_base_yaml_register <section> <key> <path>,base_build_sageattention(SageAttention 2 from source for this GPU’s sm, dry-run aware;SAGE_REFnames a branch or tag, default main; rebuilt whenever its full cache key moves; 2.0.22, 3.0.0; 3.7.5: then PROVEN,py/sage_probe.pycalling every KJ Patch Sage Attention mode on a tiny tensor against PyTorch attention, kept beside the build’s stamp; “Sage: OK” meansautoruns. On sm_100 upstream’ssageattn()has no dispatch (it raisesUnsupported CUDA architecture: sm100; SM100 support was withdrawn in 2025-12, thu-ml/SageAttention#322, and SageAttention3 refuses sm_100, #346), so the run warns and names the mode that runs, and hygiene drops--use-sage-attention. Upstream’s arch table has no 10.3 (B300 got no qattn kernel); the build adds it as sm_103a. The proof is kept per GPU, as data:base_sage_modeprints the KJsage_attentionvalue for this GPU (auto, the named mode, ordisabled), and$VENV/.comfy-base-sage/<sm>.json(sys.prefixinside ComfyUI) holds{"sm", "build", "verdict", "mode", "runs", "fails", "version"}. 3.13.0: before any build, the wheel this volume already holds for the key, then the one published for it (releasesage-<commit12>-torch<ver>-<cuda>-<cpXY>, assetsm_<arch>.<wheel>, checked againstsm_<arch>.sha256beside it), so a fresh volume downloads in seconds what it built in 10-20 minutes; with neither, a GPU whose mode is Triton builds_fusedalone, about a minute),$COMFY $CN $M $VENV $PY $PKG_DIR $BASE_STATE. A hook that changed something the running server must reload appends its reason toPKG_RESTART_WHY(an array); the run’s restart hand-off then restarts the server (BASE_RESTART=1) or prints the launch line with those reasons, never a green summary on a stale server.
Suites ship as suite.py + pytest.ini beside the script and load the base’s plugin (-p basetest), which gives
load_package, active_loaders, manifest_covers_active_loaders, brand_families, fake_pod (layouts official,
community, bare, volume), run_script, boot_stubs, boot_fake_pod, tree_hash, code_only, zip_extra and the
per-tier table. fake_pod puts in <root>/pkg what the package’s zip unpacks to: its top-level files, its vendored
packs and every path its script’s ZIP_EXTRA lists, read as package.py reads it (3.9.2: a shipped directory that is
not a node pack, such as an app’s app/, was missing; a listed file that is not there is an error). bash "<name>-script.sh" test auto-detects the testbed and the server it started: testbed.sh --owned answers with
the address only when the pid in .testbed-server.pid is what listens on the port in .testbed-server.port, and
anything else (nothing recorded, a stale pair, a port another checkout’s server holds) gives the suite 127.0.0.1:0,
“no server”, so no suite falls back to a default port a stranger holds (3.16.0; the port file alone, else 8199, used to
be enough). _build/verify.sh starts a server of each run’s own by default (testbed.sh --start with a free
TESTBED_PORT and a TESTBED_STATE folder of the run’s own, which also gives it its own --temp-directory; stopped and
removed when the run ends), so two runs at once never share one; VERIFY_SERVER=reuse takes the checkout’s --server
only when --owned proves it and stops the gate otherwise, VERIFY_SERVER=none runs without one, and a testbed with
no venv means none. podctl’s gate asks --owned the same way. --server and --stop end only the server this checkout started,
the pid in .testbed-server.pid while it is still a ComfyUI main.py (3.9.2); a port another process holds is never
killed: --server stops, naming the holder’s pid and a free TESTBED_PORT, and --stop says it left it running. BASE_NODE_SRC / BASE_SERVER override; the runner hands the
suite BASE_COMFY and BASE_VENV, and BASE_WORKSPACE when set (3.6.0: the consumer root py/pkgdirs.py walks in the
workspace shape, so a suite’s cross-package checks see the workspace’s packages); every other BASE_* is scrubbed.
Since 2.6.0 the testbed lives in two possible places, checked in this order: <base>/testbed, provisioned by the
testbed.sh that ships in this repository, and base/testbed beside base/comfyui-base in a brand repository. A root
is taken whole — the port file is read from the same root that supplied the tree, never from the other one. Before
2.6.0 only the second existed, so a clone of this repository on its own could never run the comfyui tier; it skipped
by name forever, which is a poor thing to hand someone who forked the repository to work on it. bash testbed.sh --server provisions an upstream ComfyUI at its newest release tag (or COMFY_REF) plus the base’s own packs at their
HEAD (derived from list-packs, never hand-listed), every requirement at its newest through py/reqlift.py, on the
newest Python torch ships for, and starts it on CPU. It is several GB and gitignored. 3.7.1: uv lists a CPython minor
before torch has wheels for it (3.15, October 2026), so the minors are tried newest first and the first one the torch
family resolves on wins, the pod’s rule (_base_python_pick) on a smaller set; a venv whose python does not run (a
build that died, an interpreter uv removed) is replaced instead of blocking the next run.
5. Pipeline (install)
init (env · log · volume check · discover, or materialise the tree · declarations + BASE_MIN gate · banner) → system
(the OS and the NVIDIA driver at their newest; 3.5.0: started in the background and joined before the venv snapshot,
where the driver gate and a new driver’s “restart required” stop apply; BASE_SYSTEM_FOREGROUND=1 keeps the old order)
→ the store’s install lock → require workflow → tokens (validated; a rejected one stops here) → update ComfyUI → version gate →
consolidate strays (the volume only) → packs git (every pack to its HEAD; 3.5.0: every remote’s HEAD asked at once,
and a pack already on it is not fetched) → [join the OS stage · driver gate] → [start the model downloads in the
background (3.11.0, below)] → the venv snapshot → pkg_pre_venv → venv →
pkg_post_venv → own packs (VENDORED_PACKS mirrored from the zip, 3.2.0) → packs pip (the derived, pin-free set) → Comfy
MCP → newest (every package still behind forced to its newest; 3.3.0: each force is carried as a floor, state/lift/forced.txt,
so the next run’s first resolve keeps it; a force a package refused is remembered in refused.tsv and tried again only when
either version moves) → pkg_pre_models → [join the model downloads] →
models (one index over the volume; a staged run stands in for the MODELS_LATER rows; BASE_FETCH_JOBS files at once; move on the same device else copy-verify-delete; hf-xet for the Hub, curl for
GitHub; disk gate first) → prune (one prompt; unclaimed reported) → sync workflow paths → pkg_post_models →
import check (3.15.0: from the local copy; with BASE_RESTART=1 and no server answering, deferred to the restart, whose
own start is judged from its slice of comfyui.log by the same rules) → hygiene → boot (boot.sh, tools venv, boot.env) →
ledger → restart hand-off (or BASE_RESTART=1) →
smoke → combos → the later stage handed to a detached fetch-later (a staged run) → the package’s suite →
pkg_post_install → summary (exit 0, or 1 when anything failed; the verdict kept in state/last-run/<id>).
The downloads start beside the venv build (3.11.0, lib/52-models-early.sh). Neither needs the other, so once the
packs are on disk and the OS stage is joined (it installs python3-venv, which uv’s bootstrap needs), every download the
model stage would start runs in the background: a URL row whose dest does not exist and that no file on the machine
supplies by name and size (never a LOCAL row, a staged run’s later rows, a partial or a stand-in), through the same
fetch, size and Hub checks. It overlaps the venv, the toolkit probe, pkg_post_venv (a SageAttention build), own packs,
packs pip, Comfy MCP and the newest pass. The join, just before the model stage, shows its output and folds what it got
into the run (counted once, as downloaded); the model stage then walks every row as before: a row the early pass left
alone is fetched there, and a file the early pass could not get fails the run by name with no second round of attempts
(its three were made, and their output is shown at the join). Its downloader is hf from
$BASE_HOME/fetch, a venv of its own with huggingface_hub[hf-xet] at its newest, because the main venv is rebuilt or
upgraded meanwhile; every download uses it once it exists (the model stage and fetch-later too), the main venv’s hf
being the fallback, and it is left as it is while a fetch-later still runs from it. A run that stops before the join
stops its downloads too (they resume from staging next run). The clock shows two rows: MODELS (in the background, beside the venv build), the downloads’ own seconds, and MODELS · joining …, how long they outlasted the venv.
BASE_MODELS_FOREGROUND=1 keeps the 3.10 order; a dry run, a seed build and a buyer’s runtime machine never start them.
What waits for what (3.11.0; Paul, 2026-10-16: “Parallelization of everything. Speed for everything!”). Every step that does not need another’s output runs beside it, and a join stands only where one truly needs the other:
init (discover · tree · declarations · banner) ├─ OS stage (apt) ............................ background ─┐ joined before the venv snapshot (it brings python3-venv) └─ lock → tokens → ComfyUI update → gate → consolidate │ → packs git: every remote's HEAD asked at once, then every clone and fetch at once (BASE_PACK_JOBS) ◄────────────────────────────────────────────────────┘ ├─ model downloads (BASE_FETCH_JOBS at once, largest first) ... background ─┐ joined before the model stage ├─ JupyterLab's tools venv .................................... background ─┼─┐ joined at BOOT └─ venv snapshot → venv → toolkit → pkg_post_venv (Sage) → own packs │ │ → packs pip (skipped when the venv build installed this exact set) │ │ → Comfy MCP → newest → [local-runtime copy, when lib/84-run-local.sh is in] │ → pkg_pre_models ◄─────────────────────────────────────────────────────┘ │ → models (every row judged) → prune → sync → pkg_post_models → import │ → hygiene → BOOT ◄───────────────────────────────────────────────────────┘ → ledger → restart → smoke → combos → later stage → suite → summaryThe serial spine is what really depends on its predecessor: the venv needs uv and Python (the OS stage’s python3-venv);
packs pip needs the venv and every pack on disk; the newest pass needs the installed set; the model stage needs the packs
(a custom_nodes/<Pack> dest is inside its pack) and judges what the downloads left; the import check needs packs, venv
and models; the restart needs the import check. Not overlapped, and why: consolidate moves stray packs into
custom_nodes, so the pack pass waits for it; the ComfyUI update beside the packs would save about a second on a fresh
tree. A store that already holds this exact install green (same package files, same base, same GPU arch, every file in
place) runs none of it: ALREADY INSTALLED, ComfyUI started if nothing serves, done in seconds (BASE_FULL=1 runs it all).
One pass, and its clock (3.2.0). Every stage header closes the stage before it with its seconds; the summary prints
them slowest first and state/logs/<pkg>_<ts>.timings.tsv keeps them, which podctl fetches with the console. When the
only change a run makes is the package’s own packs (ComfyUI, every other pack and the venv’s package list unchanged), the
import check loads ComfyUI with just those packs (--disable-all-custom-nodes --whitelist-custom-nodes); anything short
of a pass there is judged again by the full check. <script> preflight is what podctl runs before an install: seconds,
nothing changed, and it stops only on what would stop the real run before that run changes anything (declarations,
BASE_MIN, the volume, a workflow whose version is not the script’s). --check stays the full dry report, without the
suite, which would only test the server the real run is about to replace. podctl install --base --pkg <dir> installs
the base and runs ONE pipeline, the package’s (it already covers every stage the base’s own run would); the base’s suite
runs on a machine once per base version (state/base-suite.ok). A retry of the same zip against the same base skips the
Mac gate, and podctl polls every 5 s.
A buyer’s machine (BASE_RUNTIME, 3.1.0): the same pipeline with the system step, the ComfyUI update, the pack
moves, the venv build, packs pip, Comfy MCP and the newest pass each replaced by a note; the tree and every pack are
checked against runtime.json instead, the venv must import torch, SageAttention must be the runtime’s own build,
and a failed import check is a failure, never a rollback.
6. Any host: the contract
| Fact | On every host |
|---|---|
| The volume | the volume root, BASE_VOLUME (/workspace on the three clouds; your directory on an owned box): the only filesystem a stop/start keeps; a root that is not a mountpoint is refused unless BASE_VOLUME_KIND=dir |
| The tree | recorded in state/boot.env, else an existing volume tree, else materialised at <volume>/ComfyUI (§0.2) |
| The venv | $COMFY/.venv-cu130, interpreter under <volume>/comfy-base/python |
| What runs | 3.15.0: a copy of the tree’s code, the venv and the uv Python on the machine’s own disk (BASE_RUN_ROOT, default /opt/comfy-run), kept in step with the volume at every start; on by default for RunPod on a network volume (BASE_LOCAL_RUN, §0.4) |
| Packs | the base’s six + each package’s, cloned into $COMFY/custom_nodes; other directories there are left alone but their requirements go into the venv |
| Models | scanned and consolidated on the volume only; the container disk is never read (it is gone at the next start) |
| Launch flags | state/comfyui_args.txt (§0.10) |
| Tokens | the machine’s own environment (a {{-prefixed value is an unresolved RunPod Secret reference: a placeholder), then state/tokens.env (§0.9); HF_TOKEN and CIVITAI_TOKEN with their aliases. A RunPod pod podctl create made references the account’s Secrets of those names when they exist (3.19.0) |
| The host | BASE_HOST, BASE_VOLUME, BASE_VOLUME_KIND, BASE_LISTEN (§1); state/host.env when the driver wrote it |
| The boot | boot.sh: PID 1 via podctl’s start command on RunPod, the process of comfy-base-boot.service on a VM host: sshd (keys only) → JupyterLab (base tools venv, JUPYTER_TOKEN/JUPYTER_PASSWORD, else generated: never tokenless) → boot extensions (§9) → ComfyUI (base venv, base line, bound to BASE_LISTEN) → the app ports (3.9.0) → sleep forever |
| App ports | py/app_proxy.py (standard library, any python3, never the GPU): one process for every row of state/apps.d/*.list, bound to BASE_LISTEN; on a public bind also ComfyUI’s front (--front 8188 on the machine’s non-loopback addresses, ComfyUI on 127.0.0.1), and every link it serves needs the key (BASE_LINK_TOKEN: ?token= once, then a host-only HttpOnly Secure SameSite=Lax cookie; else 401; loopback peers pass; constant-time; never logged; the cookie stripped before ComfyUI). :<port>/<path> → 127.0.0.1:8188/apps/<slug>/<path>, except ws upload view history queue interrupt, which pass unchanged; one request per connection (Connection: close both ways), then bytes piped both ways, so bodies, streams and the websocket’s 101 pass unparsed; Host and Origin untouched; a page that says ComfyUI is starting while it does not answer. boot.sh --apps restarts it (the install runs it) |
| Servers | 3.20.0: one ComfyUI per GPU (nvidia-smi --query-gpu=index, or the machine’s own CUDA_VISIBLE_DEVICES; BASE_SERVERS caps it, 1 is the single server of every earlier base; no GPU is one server). Server 0 is $PORT (8188) exactly as before (the front, the app ports and the links reach it alone); server i is 127.0.0.1:18188+i, never a host port (8189 and up are app ports). All share the tree, venv, packs, models, launch env, args file and the input, output and user folders; each has CUDA_VISIBLE_DEVICES its own GPU, its own --temp-directory <server 0's>/gpu<i> (ComfyUI empties its temp at start) and --database-url sqlite:///…/gpu<i>/comfyui.db (when the ComfyUI has the flag), logs/comfyui-gpu<i>.log, comfyui-gpu<i>.pid. Every process gets COMFY_BASE_SERVERS=127.0.0.1:8188,127.0.0.1:18189,… (server 0 first) and COMFY_BASE_SERVER_INDEX: a package’s runner sends parts to each by POST /prompt and GET /history. The default OMP_NUM_THREADS is the quota divided by N. Started together, at boot and at every restart; server 0’s answer is the verdict, another that fails is named |
| Restart | _base_pids_from_table: a python running main.py from the tree, or naming a …/ComfyUI/main.py; never PID 1. It stops every server; the start brings one up per GPU (3.20.0) |
| Uploads | <volume>/packages/ (podctl upload); each package extracted into its own folder there |
| A baked image | a project’s own (the base ships no generator): the base built at these very paths and parked on the container disk; a boot extension copies it onto an empty volume once and installs the project’s packages, non-interactive, everything newest (§0.12) |
| A buyer’s machine | one OS disk and one NVMe data disk, created from nothing by podctl create; runtime-apply then BASE_RUNTIME + BASE_STAGED=1 per package (podctl buyer-install); pause keeps the disks, put-away copies output/ and input/ to the Mac and deletes them (§0.3, §0.12) |
RunPod, three image families: RunPod’s ComfyUI (tree /workspace/runpod-slim/ComfyUI,
runpod-slim/comfyui_args.txt imported once, sshd native); community templates that keep the code on the container
disk (/ComfyUI) with the persist dirs at /workspace/ComfyUI, so the base materialises the code beside them (no sshd;
their own JupyterLab, if any, unauthenticated unless JUPYTER_TOKEN; the base’s never is); a bare GPU image (everything materialised). /workspace is a
network filesystem: df reports the shared pool, so the disk gate says when it cannot verify headroom. Slim images
have no unzip; python3 -m zipfile -e does the job.
A VM host (Verda, Crusoe): a fresh OS image with a driver and Python 3, and an empty block volume the host’s
startup script formats (ext4, only when blank) and mounts at /workspace by UUID; the base’s comfy-base-boot.service
runs boot.sh at every boot (RequiresMountsFor=/workspace, and it sleeps rather than fails while the base is not
installed yet). Everything else is the bare-image case: the tree, the venv, the packs are all materialised on the
volume, and the machine is reached through the driver’s tunnel because the base binds loopback.
An owned box (local): BASE_HOST=local BASE_VOLUME=<dir>; the directory is the volume (BASE_VOLUME_KIND=dir),
the unit is installed by hand or by the base when it runs as root, sshd is whatever the box already runs, and nothing
listens beyond loopback.
7. State on the machine
<volume>/comfy-base/ holds the library (base.sh lib/ py/ suite/ hosts/, verified against MANIFEST.sha256 on
every source), boot.sh, comfy-base-boot.service (the unit’s text, always written; installed under
/etc/systemd/system on a VM host), ext/ when a project’s image put extensions there (§9), state/ (boot.env,
host.env (BASE_HOST, BASE_VOLUME, written by the driver), comfyui_args.txt, tokens.env 0600, apps.d/<package>.list
(3.9.0: the package’s APP_PORTS rows), launch.d/<id>.env (3.10.0: each package’s LAUNCH_ENV, §4), servers.list (3.20.0: index|port|gpu per ComfyUI server, replaced at every start) and, with more than one, comfyui.pid and comfyui-gpu<i>.pid,
constraints-torch.txt, boot-facts.txt, volume.env on RunPod, run.gen (3.15.0: bumped when the code may have changed, read by the local copy’s fingerprint), gpu.lease, packages/<id>.manifest, logs/: install
logs, boot_<ts>.log, comfyui.log (3.20.0: server 0’s; comfyui-gpu<i>.log each other server’s), jupyter.log, app_proxy.log, fetch-later_<ts>.log; 3.1.0: last-run/<id> (the last run’s
verdict), runtime.json and runtime.stamp (the applied runtime), and for a staged install standins.list,
later.queue, later.failed, fetch-later.pid and progress.json:
{"format": 1, "updated", "stage": "first|later|done", "files": [{"file", "rel", "bytes", "state": "present|standin|downloading|failed"}], "later": {"files", "bytes", "done_files", "done_bytes"}}, every state read
from the disk and the file replaced whole, never written in place; ComfyUI’s environment names the state dir as
COMFY_BASE_STATE, so a package reads $COMFY_BASE_STATE/progress.json and treats its absence as “nothing staged”),
python/ (the uv-managed interpreter), tools/ (JupyterLab), fetch/ (3.11.0: hf, the downloader, in a venv of its own) and
dead-venvs/ (quarantined venvs). A package manifest records its packs, model claims, superseded names and hooks;
bash base.sh status renders them and prints state/host.env when there is one; bash base.sh latest asks every
pinned pack’s remote what HEAD is now.
ComfyUI’s own user folder (_base_user_dir) gets one key from the base (3.17.1): default/comfy.settings.json holds
"Comfy.OnboardingCoachmarks.Seen" with "appMode" in it, so the App Mode tour never opens over a package’s form.
8. Tests
Tiers: unit (source contracts on the library, podctl and the RunPod provider against a fake RunPod REST server,
boot.sh with stubs, the host layer in suite/test_hosts.py (the four knobs, the boot’s host detection and extension
seam, the systemd unit, brand.toml, the driver’s provider binding), the Verda provider against a fake REST server in
suite/test_verda.py, the derive kit in suite/test_derive.py, the canvas kit on a synthetic workflow in
suite/test_canvas.py), install (the stub package under _build/stub run end to end against fake pods of every
layout, offline), comfyui (the repo testbed), runpod / gpu (pod only: the venv the boot activates, the running
server on it, PID 1 = boot.sh with sshd and JupyterLab answering; the last two skip until the stop/start hands the boot
over), bare (the shell sources without a machine).
cd base/comfyui-base && bash base.sh test # every tieruv run --no-project --with pytest python base/comfyui-base/_build/rehearse.py <brand>/packages/<name> --layout official|community|bare|volumebash base/comfyui-base/_build/verify.sh [<brand>/packages/<name>...] # zip current, suite from the repo, suite from the zipVERIFY_SUITE=zip bash base/comfyui-base/_build/verify.sh # every test once: the zip's run, then from the repo only what it skipped (3.17.1)VERIFY_SERVER=reuse bash base/comfyui-base/_build/verify.sh # this checkout's testbed --server, proven by --owned (default: a server of the run's own)Tier 0 (3.17.0), seconds, before every commit: bash _build/quick.sh [<pkg>...] (zip current, the suite’s pure tiers via
test quick, the API prompts against the cached /object_info, the brand’s zip guard). verify.sh is Tier 1, once per
release; a pod is Tier 2 (podctl runner).
The rehearsal unpacks the shipped zips, runs the two steps on a fake pod of that layout, then boots it through
comfy-base/boot.sh with stubs; ALL OK means the zips are ready to upload. Standalone (this base as its own
repository), rehearse.py stub --layout volume uses the fixture package.
8.1 The canvas kit — what every package’s canvas is tested with
The same shape as the installer: the base owns the machinery, a package owns its tables. Each piece was written against the real frontend and a real defect.
| What | Where | Package side |
|---|---|---|
graph helpers (byid through subgraphs, links, the frontend’s bypass resolver resolve_frontend), geometry (nbound, rgthree’s membership), the size floor, structure_sha, note_height, Budgets, CanvasSpec |
py/canvas.py |
from canvas import ... in suite.py |
41 generic tests: the uiux budgets (collisions, spacing, grid, notes and labels fit, LOD cliff, fill, bloat, flow, palette, titles, reading order), the snapshot tier (the real frontend), the shape-only config guards (zip = shipped pair, versions agree, tier gates, handbook numbers, packs pinned) |
py/canvastest.py |
CANVAS = CanvasSpec(...) then from canvastest import *; markers uiux, snapshot, config in pytest.ini |
snapshot.py (photograph + sizes.json; its route_settings stub serves the base’s seeded settings and coach_tour(page) dismisses and reports the frontend’s onboarding tour, 3.17.1), render.py (offline review, --review), page.py (before/after), panels.py (every rgthree row clicked in the real frontend), probe.py (graphToPrompt per state), canvaslayout.py (the layout engine) |
_build/canvas/ (repo tooling, not in the zip) |
every tool takes --pkg <package dir>; <pkg>/_build/layout.py = tables + run(globals()) |
Adopting a package: (1) put its constants in suite.py as literals and declare CANVAS = CanvasSpec(user_facing=..., markers=..., cuda_only=..., start_card=..., display="1512x982@2", nest=..., partial=..., tier_gates=...), then
from canvastest import * before the suite’s marker loop; (2) uv run "base/comfyui-base/_build/canvas/snapshot.py" --pkg <dir>
once, with the testbed server up, for its _build/sizes.json; (3) python3 "base/comfyui-base/_build/canvas/render.py" --pkg <dir> --review and fix what the budgets find – for a canvas that never had them, expect off-grid boxes, frames too big for
what they hold and a saved view under the LOD cliff; canvaslayout.py with a tables file is how a package relays
itself out.
9. Boot extensions
boot.sh runs sshd, JupyterLab and the saved tokens, then sources every ext/*.sh it finds, in name order, before it
starts ComfyUI (boot_extensions, 2.2.0). It looks in two places and takes the first that has any: $BOOT_HOME/ext
(that is <volume>/comfy-base/ext, the installed base on the volume), else ext/ beside boot.sh itself. The second
place exists for a baked image’s FIRST boot: the volume is still empty then, so the only copy that can run is the
seed’s own, beside the seed’s boot.sh on the container disk, and copying the seed onto the volume is exactly what
such an extension does. An extension is a bash file sourced into the boot’s own shell, so it sees BOOT_HOME,
BOOT_VOLUME, BASE_HOST and BASE_LISTEN and must not exit; the boot logs each file it sourced, or “no
extensions” when neither place has one.
The base ships no extension. A project’s image adds its own stages this way (copying its seed onto an empty volume, provisioning, a status page), in the project’s own repository.
9.1 Comfy MCP (the agent’s way in)
comfy-mcp is a stdio MCP server wrapping comfy-cli. Since 2.7.0 the base installs it into the run’s venv
(lib/45-mcp.sh), beside ComfyUI itself, and podctl mcp <machine> writes the client configuration.
BASE_MCP=0 skips the install; the stage never fails a run.
There are two transports and the difference is not cosmetic:
| where comfy-mcp runs | what works | |
|---|---|---|
--over ssh (default) |
on the machine | everything: install_node, search_models, get_logs, fetch_outputs, launch_comfyui, and the run tools |
--over tunnel |
on your Mac, reaching COMFYUI_URL through podctl tunnel |
the run tools only |
The lifecycle and discovery tools need the tree, and the tree is on the machine — which is why the server is
installed there and ssh is the default. The ssh transport passes ClearAllForwardings=yes: the Host block
podctl ssh-config writes carries four LocalForward lines, and a second session binding them while a tunnel
holds them prints warnings onto the very stdio channel MCP is speaking. The tunnel transport takes the alias’s
own local port (tunnel_locals), so two machines never point at one tunnel.
What this repository does NOT ship. No .mcp.json is committed, and no settings file approves one
(enabledMcpjsonServers, enableAllProjectMcpServers). This is a public toolchain many people clone: Claude Code
starts a project’s servers once a person trusts the folder (and a headless claude -p without asking), and a
committed approval folds the per-server prompt, the one that shows the exact command, into that single trust click,
which is the pattern the MCP specification’s security guidance warns against. The entries would also be one person’s
setup (a port, a path). The local case is the person’s own: claude mcp add ... -- comfy-mcp at user or local scope,
as Comfy-Org’s docs show (the README has the line). Corrected in 3.5.0: comfy skills install writes Comfy-Org’s
agent skills, not MCP client configuration; their comfy-skills marketplace plugin connects the hosted Comfy Cloud
server; and comfy-mcp 0.9.0 and 0.10.0 came two days apart, not two months. What stays here is the case upstream does
not cover: a machine the base provisioned on a rented GPU. podctl mcp writes that file beside you, and .gitignore
keeps it out of the tree because it names one machine and one operator’s paths.
COMFY_BIN. comfy-mcp is a wrapper: its discovery and lifecycle tools shell out to comfy-cli, which it
finds through COMFY_BIN or PATH. An MCP client is usually launched by a GUI, whose PATH is not your
shell’s, so PATH is the thing not to rely on. podctl mcp therefore names the binary: over ssh it is the
comfy beside the machine’s comfy-mcp, and over the tunnel it is this machine’s, when one can be found (it
says so when none can). MEASURED 2026-09-19 against a live ComfyUI: without it, server_info answers “Error
executing tool server_info”; with it, it answers with the interpreter, the config path and the bound workspace.
Telemetry. comfy-cli depends on mixpanel and posthog, and rule 9 says the outbound hosts are an
allowlist and nothing is uploaded. That wheel is not our source, so the allowlist test cannot see it. Three
things are done instead, in order of how much they can be relied on: DO_NOT_TRACK and COMFY_NO_TELEMETRY
are exported before anything from the wheel runs — comfy-cli honours the DO_NOT_TRACK convention by never
importing its telemetry extension at all, so this is the code path not loading rather than a request to be
quiet; the same two go into the boot environment, because a machine that reboots must come back as quiet as it
was installed; and comfy tracking disable writes the config file (~/.config/comfy-cli/config.ini on Linux)
for any invocation that somehow arrives without the environment. The suite asserts the first two.
10. Record
-
3.21.0: which test lane a smoke takes (2026-10-17).
py/gpu_fit.pygainsLANES,fast_dtypes()andpick_lane(peak_gib, minutes, needs), which builds onfits()for GPU classes instead of a second rule, and a--peak-gib/--minutes/--needsmode on the command line (§1, “Which test lane”). The unit tier covers the order, the headroom, the limits, the dtypes and the CLI. Nothing else changed; no pod used. -
3.20.3: the CPU-quota and launch-environment tests hold on any machine (2026-10-17). On the free
soulcraft-hfrunner (a 2-CPU Hugging Face CPU Space that exportsOMP_NUM_THREADS=2, workspace integration run 39049424709) three unit tests failed while the code was right. The cap to the CPUs online turned the fake 250000/100000 quota into 2, and the runner’s ownOMP_NUM_THREADSwon, as the contract says the machine’s value must, over every value the launch tests expect. The rounding cases now run with_base_cpus_onlinestubbed to 64, and the cap is proved with the host’s own count and a stubbed 2-CPU host. The launch tests drop every key ofBASE_LAUNCH_ENV_KEYS(read from the base) andCT2_*from their environment. Only the suite changed; the base’s code is as in 3.20.2. -
3.20.2: the testbed links the newest copy of a pack two packages vendor (2026-10-17). FLUX 3 Klein and the Video Replacer both vendor ComfyUI-Content-Gate (one folder name, one set of node classes), and
testbed.shlinked each package’s copy intocustom_nodesby folder name, so whichever package was walked last won (the Replacer’s 1.5.0 in workspace integration run 39045535460, failing Klein’s comfyui and server tiers). It now keeps the newest version, the rule a pod already follows (lib/62-own-packs.sh):py/own_packs.py newestpicks one folder per pack name, a copy with no version loses to one with a version, the same version keeps the first and names two different contents under it.--statuslists each vendored pack’s version and the package it comes from. -
3.20.1: the testbed names a port’s holder on Linux without lsof (2026-10-17). On the free
soulcraft-hfrunner (a Hugging Face CPU Space container, workspace integration run 39045535460) there is no lsof, sotestbed.shnamed every holder of a port “pid ?” and two unit tests that expect the pid failed there while GitHub’s ubuntu runners passed.port_holdersnow falls back to /proc: the LISTEN rows of/proc/net/tcpandtcp6give the socket inodes, and the/proc/<pid>/fdlinks that name them give the pids (every process of this user). “?” remains for a port that answers when neither lsof nor /proc can say who, and the two tests expect it only on such a host; anywhere either can see the holder they still demand its pid. -
3.20.0: one ComfyUI per GPU, so a multi-GPU pod renders several parts of one job at once (2026-10-17). The Video Replacer renders about 1 s of B200 per output frame and a two-person swap is two passes, so a 45 s, 7-shot clip took about 18 min of B200 per person on one GPU; split at its cuts, the shots are independent, and N GPUs finish it in the time of its longest shot at the same cost per GPU-minute. The boot and the install’s restart count the GPUs and start one server on each (§6 Servers): server 0 unchanged on 8188 behind the front, server i on
127.0.0.1:18188+i(not 8188+i: 8189 is the Video Replacer’s app port), each with its own GPU, temp folder and database, all sharing the input, output and user folders, and every one told the whole list inCOMFY_BASE_SERVERS. Each server’s packs load as on one GPU, so a package’s warm-up and readiness checks run on every GPU. The defaultOMP_NUM_THREADSis each server’s share of the CPU quota.BASE_SERVERS=1on the pod is the single server.podctl create --gpus N(1 to 8) puts N GPUs on the pod: the stock check asks RunPod for N in each candidate data centre at once (gpuTypes.lowestPricewith the count anddataCenterId, one aliased query; the old per-type view passes one free GPU for a 4-GPU pod), and before anything is made every create prints RunPod’s price for the pod and the RAM and vCPUs it comes with, then refuses when the projected balance covers under one hour of today’s spend plus this pod above the guard’s floor. Every projection (podctl balance,guard, create’s guard) reads a spend never below the running pods’ own: each pod at the larger of itscostPerHrand its gpuCount x its GPU’s list price (gpuTypes.securePrice), whichever way RunPod’s record counts the GPUs. The models folder takesultralytics/bbox,ultralytics/segm,Wan,DreamID-VandMiniMax-Removeras flat categories (no Family: their packs join those paths themselves). Read live on 2026-10-17: B200 x1 $7.99/h with 188 GB RAM and 24 vCPU; x2 $15.98/h, 502 GB, 48 vCPU (US-CA-2, US-NC-2); x4 and x8 none free anywhere.podctl statusnames the servers. The GPU check (py/gpu_facts.py, podctl’sgpu_probe) judges each card by its own processes (by uuid): before, card 0’s memory was set against every card’s processes, which hid a foreign tenant on any other card. Exercised on this Mac against fakes (a 4-GPU nvidia-smi stub, the fake RunPod) by base CI; the GPU proof on a 2x B200 pod is the orchestrator’s. -
3.19.1: one click on every Connect-tab link (Paul, 2026-10-17: “can you fix this so I can click the links?”). On a pod from 3.14.0 to 3.18.2, RunPod’s own Connect links for ComfyUI, each app and the Jupyter Terminal landed on “This link needs its key” or Jupyter’s token page; the only way in was the full link from the Logs tab or
podctl status, once per service. RunPod’s proxy carries nothing that names the pod’s owner (first-party: “Your service becomes publicly accessible … implement proper authentication in your application”), so no protection can be free of a step, and Paul’s order is speed, simplicity, privacy, then security that costs no step. So the links are OPEN by default, the way RunPod’s own templates behave:py/app_proxy.py --open(the forwarder stays in front, ComfyUI still on 127.0.0.1, every app at its port’s root) and JupyterLab with no token on the public bind, even on a pod whose env still carries a token from an older create. The key and the token are the opt-in,BASE_LINK_LOCK=1(podctl create --lock), exactly as before. A loopback bind (a--privatepod, a VM) keeps JupyterLab’s token, which the tunnel’s link carries; an operator’s ownJUPYTER_PASSWORDis kept.createmints no token and no key unless--lock, and prints the Connect tab’s plain links. Exercised on this Mac against fakes by base CI; not yet on a live pod. -
3.19.0: keys from the Mac Keychain, RunPod Secrets on podctl pods (2026-10-16). The Mac-side driver reads each host’s credentials from the environment, else the macOS login Keychain item whose account is the variable’s name (
keychain()inhosts/__init__.py:security find-generic-password -a <VAR> -w, a Mac only, 10 s, off withBASE_KEYCHAIN=0), else the tool’s config file:RUNPOD_API_KEY,VERDA_CLIENT_ID+VERDA_CLIENT_SECRET,CRUSOE_ACCESS_KEY_ID+CRUSOE_SECRET_KEY. A Verda or Crusoe pair now comes whole from one source; an id from the environment was joined to a secret from the file before.podctl createlists the RunPod account’s Secret names (GET /v2/account/secrets, names only;RUNPOD_API_V2_URLoverrides the host, and a REST base that is not RunPod’s own uses its own/v2) and referencesHF_TOKENandCIVITAI_TOKENas{{ RUNPOD_SECRET_<VAR> }}when a secret of exactly that name exists, on public and private pods; one line says which. A failed listing is a warning.podctl statusnames a reference as one. The install treats a{{-prefixed token as a placeholder,boot.shdrops one so a saved token applies, andCIVITAI_TOKENis read underCIVITAI_API_KEYandCIVITAI_API_TOKENtoo. The suite runs with the lookup off and a tripwiresecurityfirst on PATH that fails the run if anything calls it; the tests of the lookup use a stub. Exercised on the Mac suite and the CI runner only: no live RunPod, Verda or Crusoe call. -
3.18.2: the testbed’s torch is the CPU build on Linux (2026-10-16, error log row 108).
testbed.sh --serversaid “installing torch (CPU)” and installed PyPI’s torch, which on Linux is the CUDA build (2.14.1+cu130 and every nvidia-* wheel: a 6.7 to 7.2 GB venv on x64 and arm64, measured by the oracle-runner session), downloaded again by every CI gate on runners that keep no cache. On Linux it now exportsUV_TORCH_BACKEND=cpubefore its uv installs, so torch and every requirement resolve against the CPU index. macOS (CPU wheels already) and the pod’s install (lib/30-venv.sh, its own backend) are unchanged. A unit test holds it. -
3.18.1: tier 0’s prompt check leaves input files and self-validated inputs to the node (2026-10-16). On the Video Replacer’s pinned CPU server (cpu-validate run 38934921493) ComfyUI accepted all 12 lanes while
_build/objinfo.py checkrefused 30 inputs, every one judged by the node’s ownVALIDATE_INPUTS, which/object_infodoes not show: VHS_LoadVideo’s placeholder clip, and ModelAttentionBackend’s kitchen backend on a server without CUDA. Media file names and theClass.inputpairs a package lists in_build/objinfo-self-validated.txtare now “unjudged”; on that cache the check says OK. A unit test holds both. -
3.18.0: the GPU order past B200 and B300, no volume needed, and the balance projected (Paul, 2026-10-16: rendering speed first).
podctl createwith no--gpuasks for B200, B300, H200, H100, then the RTX PRO 6000 Server Edition (Paul’s order), and takes the first GPU with stock: on the reusable network volume where that GPU is free, else as a FRESH public pod on a pod volume (--pod-gb, default 400) in the first data centre that has it, models downloaded again (--no-freshkeeps 3.14.0’s rule). It names what the chosen GPU means (B300: INT8 is slow, load fp8 weights; H200: nvfp4 is emulated). RunPod’s provider gainsbalance()(GraphQLmyself { clientBalance currentSpendPerHr spendLimit });podctl balanceadds the spend and the projected balance (--lag-min, default 30), and newpodctl guard --floor 5 [--yes]stops every running pod at the projected floor.LAUNCH_ENVallowsRENDER_SPEED_ATTENTION. Tested against the fake RunPod only; NOT RUN ON A POD (no credit on 2026-10-16). -
3.17.1: the App Mode tour is marked seen, so the form opens clear (error log row 81); and
VERIFY_SUITE=zipruns every test once, the zip first (2026-10-16). The tour: ComfyUI frontend 1.55.x opens a full-screen “Welcome to Apps” tour over the App Mode form on a first visit; its scrim takes every click until dismissed, and every package opens in App Mode. Hygiene (_base_settings_seed, after_base_manager_security, beforepkg_hygiene) appends"appMode"to"Comfy.OnboardingCoachmarks.Seen"in$(_base_user_dir)/default/comfy.settings.jsonthrough the newpy/comfy_settings.py: the list kept in order, every other key untouched, written atomically, a file that is not a JSON object left alone and named. First-party sources: ComfyUI_frontend v1.55.17onboardingTours.ts(TOUR_SEEN_SETTING,ENTRY_PATHS),onboardingTourStore.ts(startTourreturns when the list holds the path;markTourSeenappends),useTourTriggers.ts(onlyappModeopens on its own); ComfyUI v0.41.2app/app_settings.py(reads the file on every request, writes it whole on each save). Boot needs nothing: the seed lands in the same user folder as the workflow in the same install (on a shared volume,BASE_LOCAL_STATE/user, the server’s--user-directory; under 3.15’s local copyuser/is a link to the volume’s, never copied from the twin).testbed.shseeds its tree before it starts a server, and the canvas kit’s settings stub serves the seed.coach_tour(page)in_build/canvas/snapshot.pyreplaces the packages’ own dismiss loops: None when no tour shows, else it clicks the frontend’s exact Skip (by itscoach-landing/coach-cardtest ids) or presses Escape and returns{"shown", "dismissed"}, a finding to report: on a machine the base installed, a tour means the seed missed. Tests: hygiene on a fake pod (dry run writes nothing, the list merged, a second run byte-identical, a corrupt file untouched, a shared volume’s file), the merge itself, and the stub pluscoach_touron a fake page. The gate:_build/verify.shran every suite twice, from the tree and from the extracted zip.VERIFY_SUITE=zipruns the zip’s first (what a pod runs), then from the repo ONLY what that run skipped:py/basetest.pyrecords the skipped node ids inBASE_SKIPPED_OUTand keeps only those underBASE_ONLY_IDS(both read once and taken out of the environment, so a test that runs pytest itself cannot overwrite the record). Every test runs once and none twice, so it is lossless for a suite whose repo-only checks SKIP. The base’s suite is (audited: every_build/, testbed and consumer-root check skips; the one that returned early now skips), so the base’s CI uses it. The packages’ suites are not yet (13 tests in six packages branch on_build/or a sibling package instead of skipping, and would pass from the zip without their repo half), sopackage-ci.ymlkeepsbothuntil each package turns those into skips.reporuns the tree’s alone;bothstays the default and the local gate; anything else is a red gate before any work. Tests: the three modes against a stand-in base (zip, then only the skipped id from the repo), and the plugin’s record and filter through real pytest. -
3.17.0: testing that costs nothing, in three tiers (2026-10-16, when the private repositories’ GitHub-hosted minutes ran out). Tier 0,
_build/quick.sh [<pkg>...], seconds on any CPU before every commit:package.py --check, the suite’s pure tiers (<name>-script.sh test quick: every tier the suite declares exceptBASE_HEAVY_TIERS, those that need a live machine), the package’s*.api.jsonprompts against its cached/object_info(_build/objinfo.py check: classes, required inputs, links and their types, ranges, combo options, from the output nodes back, as ComfyUI’s validator reads the schema;objinfo.py savetakes the cache from the pod throughpodctl tunnel, in the shape image-creator-krea-2 already commits), and the brand’s zip guard when itsbrand.tomlnames one (zip_guard). Tier 1 isverify.sh, once per release on a free runner;_build/cloud-verify.mdis the brief for a Claude Code cloud session (what its GitHub proxy allows: no check runs, statuses or deployments). Tier 2,podctl runner <pod>, opt-in: the machine as an ephemeral GitHub self-hosted runner (labelgpu-pod) while it is up; the token from this Mac’sghgoes over ssh’s stdin into the runner’s environment, never argv or output;stop,pauseandput-awayremove it first. Unit tier only (suite/test_quick.py,suite/test_podctl.py,suite/test_unit.py); no pod used. -
3.16.0: the gate talks only to a server it started (2026-10-16, error log row 84).
_build/verify.shused whatever answered on the port in.testbed-server.port, else 8199, and so did the runner’s_base_use_testbed, podctl’s gate and a barepytest suite/. On a Mac where several sessions each run a testbed, the Video Replacer’s local gate met another session’s server: the snapshot tier measured that frontend (other LoadImage sizes), a server test lost it mid-run when its session stopped it, and a clean PR went red. Nowtestbed.sh --ownedis the one answer: the address only when the recorded pid is what listens on the recorded port, else the reason (a foreign holder named by pid).verify.shstarts a ComfyUI of each run’s own by default (newtestbed.sh --start: the server alone from the venv as it is; a free port,TESTBED_STATEkeeps its pid, port and log in a folder of the run’s own and gives it its own temp folder, since ComfyUI empties its temp folder when it starts), hands that address to both suites and stops it on every way out.VERIFY_SERVER=reusetakes the checkout’s--serveronly when--ownedproves it and refuses otherwise before any suite runs;VERIFY_SERVER=noneruns without one; no venv means none; a server that will not come up is a red gate. The runner, podctl’s gate andtest_comfyuiask--owned, and “none” is127.0.0.1:0, said out loud, because an emptyBASE_SERVERlet a suite fall back to 8199 or 8188 (Comfy Desktop).--servernow waits for its own pid to answer, not for any answer on the port. CI (testbed.sh --server, thenverify.sh) still works: the run starts its own beside it;VERIFY_SERVER=reusethere saves that start. Tests: a real--start/--owned/--stopround with two run folders and a foreignmain.pyon the recorded port;verify.sh’s modes against a stand-in base; the runner refusing a foreign server. Also in 3.16.0: a re-install that takes the “already installed” shortcut (3.11.0) prints the models line every run prints (models ok N in place · moved 0 · downloaded 0 · failed 0). It printed only “install skipped”, and every package’s install test, which reads “downloaded 0” from a second run, failed on the free CI runner (image-creator-flux-3-klein#6, video-creator#6, photo-auditor#6). The second-run test now asserts the line. -
3.15.3: a failing suite’s report keeps each failing test whole (2026-10-16).
_build/verify.shprinted only 12 to 24 lines of a failure, so a long assertion lost its cause in CI (it hid a Video Replacer failure’s reason). It now prints pytest’s ERRORS and FAILURES sections (each test’s header, its>lines and its whole E block), capped at 200 lines, then the short summary naming every failing test. A test holds it. -
3.15.2: the first install’s packs pip skip works, and 3.11.0 is measured on RunPod (2026-10-16). 3.11.0 meant the packs’ requirements step to skip the set the venv build had just installed; it resolved it again (138 s on the pod) because the build took its fingerprint before reading the torch constraints from the venv again, and the install had moved setuptools and the like. The build now reads them first, as the packs’ step does. Measured before the fix, two fresh pods made side by side (H100 80GB HBM3, EU-FR-1,
runpod/base:1.4.0-cuda1300-ubuntu2404, a network volume, the MiniMax H3 Video Continuer 1.0.5 and its 41.1 GB,--no-gate --no-suite), wall clock frompodctl createto the workflow listed through the public proxy: 3.9.2 2113 s, 3.11.0 1716 s (397 s less, 19 %). Stage by stage, 3.9.2 then 3.11.0: venv 743 / 689, newest 467 / 467, downloads 226 / hidden (127 s beside the venv chain; the join waited under a second), import check 203 / 144, packs pip 127 / 138 (the defect above), restart 89 / 87, BOOT 83 / 1 (the tools venv’s 112 s hidden), packs git 39 / 57 (uv’s once-per-run refresh moved before the venv). The existing-volume case is not measured: the Continuer’s smoke fails on every base (LoadImageCropnot registered), so it never leaves the green verdict the skip needs. -
3.15.1: the copy reads from a local twin where it has one (2026-10-16, runpod-template-prebuilt’s review). On a template pod’s first boot every file crossed the network volume twice: the image’s seed (
/opt/comfy-seed) rsynced onto the volume, then read back from it for the container-disk copy.BASE_RUN_TWIN=/opt/comfy-seed=/workspace(the template sets it; the pod’s env, or PID 1’s for an install over ssh; comma-separated pairs) makesrun_local.pycopy a file from its twin when the twin is a regular file of the same size with an mtime within one second (rsync -a keeps both; a FUSE volume may keep a coarser clock), and from the volume otherwise. The walk still stats the volume, so what an install changed since the seed is read from the volume; the copy keeps the volume’s mtime, so the next sync compares against the volume. Default off: nothing changes for a pod without a twin. The copy’s line and.run-local.jsoncount what came from the twin. Proven on this Mac (a hit reads the twin’s bytes, an older or resized twin reads the volume, a missing twin copies as before, the shell passes it from its env or PID 1’s); NOT YET RUN ON A POD (no RunPod credit on 2026-10-16). -
3.15.0: ComfyUI runs from a copy of its code on the machine’s own disk, and a first install starts ComfyUI once, not twice (2026-10-16). Why: on a RunPod network volume every file access is a FUSE round trip, and the install’s import check MEASURED 5+ minutes at near-zero CPU (the Video Swapper; 262 of 265 threads on futexes, one in request_wait_answer): the 12 GB venv, the uv Python and custom_nodes were all read over it at every start. What changed (§0.4): every start (boot, restart, quick test) mirrors the volume’s code to
/opt/comfy-run/<gen>/in parallel (32 reads at once), hardlinking what did not change, rewriting the venv’s paths in the copy, proving the copy imports torch, and starts there; the volume stays the truth and no install step changed. The pid matcher knows a server whose cwd is the copy. The boot’s own torch gate is the copy’s check when there is one. And the import check: withBASE_RESTART=1and nothing answering on the port (a fresh pod, a first install), the quick test is skipped and the install’s own start is the proof, judged from its slice ofcomfyui.logwith the same rules (packs named, the module repair with a relaunch, the own-pack judge, which starts once more on a restored copy); a start that never answers is stopped and judged the long way (the quick test, the rollback) and started again. With a server answering, the quick test still runs first, from the copy.BASE_IMPORT_DEFER=0keeps the old order;BASE_LOCAL_RUN=0runs from the volume.base.sh statusprints the copy. The boot starts the copy first thing, in the background; without a copy the volume’s code is read 64 files at a time beside the start (18 s for the whole venv and custom_nodes, measured by the platform orchestrator, after which imports took seconds; before: 1.72 ms per stat,import torch56 s, the import check 7.6 min, the restart 3.5 min). Proven: the suite on this Mac and in CI (suite/test_run_local.py: a real venv copied, relocated and run; hardlinks; fingerprint; fallbacks; the deferred judge against a fake server) and its CI. NOT YET RUN ON A POD: RunPod had no credit on 2026-10-16, so the copy, the head start and the deferred import check are proven against fakes and real local venvs only, and the before/after timings on a network volume follow as soon as there is credit. What keeps that safe: a copy that fails, or a disk too small for it, starts ComfyUI from the volume exactly as before. Every background start (the head start, the warm read) is one exec’drun_local.pyprocess, never a forked shell, which would show its parent’s command line tops(CI caught one through 3.10.1’s stray-forwarder guard). And_build/verify.shnow names a test that ERRORS (setup or teardown), not only one that fails: that CI run said “1 error” and named nothing. -
3.14.0: delight first, then secure, then private (Paul, 2026-10-16).
podctl createon RunPod is PUBLIC by default again, with ComfyUI, the Jupyter Terminal and each app named in the Connect tab;--privateis the opt-in (3.8.1’s default reversed). A public pod reuses the network volume called--volume-name(defaultcomfy-base-data) in a data centre that has it and the GPU free, so models download once; one is made under that name only where none exists (--data-gb). And no link is open: a per-podBASE_LINK_TOKEN(pod env fromcreate, else generated by the boot, saved 0600) locks ComfyUI and every app port.py/app_proxy.pychecks it: the printed link carries it once (?token=), the answer is a host-only cookie (HttpOnly, Secure, SameSite=Lax) and a 303 to the clean URL, a request with neither is a plain 401, connections from the machine itself pass. ComfyUI binds 127.0.0.1 wheneverBASE_LISTENis public, and the forwarder serves its port on the machine’s other addresses (--front), retrying a port it cannot bind yet; the forwarder now runs on a public bind even when no package declares an app. The key is compared in constant time, never logged, never in argv, unset in ComfyUI’s environment and taken out of the Cookie header it forwards.statusprints every link with its key (the env’s, else the machine’s),deploy --likegives a clone its own, and on RunPod a key the boot made is named once on the Logs tab. JupyterLab keeps its own token. The key costs no click after the first link and no measurable time (MEASURED on this Mac, the forwarder against a stand-in ComfyUI, the key checked on every request): +0.24 ms on a small request, +0.25 ms to a websocket’s first frame, +2.35 ms on a 5 MB asset (1 MiB stream buffers; 64 KiB gave +4.5 ms), and it streams, so on a real link the transfer hides it. Exercised on this Mac against fakes; the live pod pass (clicks from create to a working link, a render through the front) is owed: the RunPod balance stopped it. One live finding: until the base’s boot owns a new pod, the image’s own ComfyUI answered on the public 8188 link without a key, for the whole install (error log row 71). Closed here:createmakes the pod with its start command already the boot guard plus sshd and nothing of its image’s own (create_start), so nothing answers on 8188 until the install starts ComfyUI on 127.0.0.1 behind the front;ensurethen has nothing to patch (no reset, no first-boot wait), the base installs ComfyUI itself, and the install’sboot.sh --appsstarts JupyterLab with the pod’s own token (read from PID 1). -
3.13.0: SageAttention is downloaded, not built, on a fresh volume (Paul, 2026-10-16: pods install as fast as possible). With the model downloads in parallel (3.11.0) the source build (10-20 min) was a pod’s biggest step. A pod’s key (upstream commit, Python, torch and its CUDA build, the GPU’s sm) is now built ONCE per arch by
.github/workflows/sage-wheels.yml(daily and by hand:_build/sage/resolve.pyasks what a pod would pick today, torch throughpy/torch_pick.py;_build/sage/build-wheel.shbuilds it in the matchingnvidia/cudadevel container with the base’s own source patch,_base_sage_prepare_src, and checks the wheel carries its kernels and imports) and published as a prereleasesage-<commit12>-torch<ver>-<cuda>-<cpXY>: per arch (9.0, 10.0, 10.3, 12.0)sm_<arch>.<wheel>thensm_<arch>.sha256, andLICENSE-SageAttention.txt(Apache-2.0). Assets are write-once (the workflow refuses to replace one; the organisation’s tag ruleset keepssage-*tags from moving)._base_sage_installtries, in order: this volume’s wheel cache; that release (the sha256 checked before pip sees the file, a mismatch refused by name); a_fused-only build where the GPU’s mode is Triton (this volume’s kept proof says so, or with no proof yet upstream’ssageattn()has no branch for the sm: sm_100 and sm_103 today); the full build._fusedis the one extensioncore.pyimports unconditionally, and the Triton mode calls no qattn kernel, so that build takes about a minute; if its proof then finds Triton does not run, every kernel is built and proven again. The proof’s mode is now also kept on the volume (state/sage/<sm>.mode), so it outlives a venv rebuild. The cuBLAS probe runs only before a build.SAGE_WHEELS=0skips the download,SAGE_FUSED_ONLY=0the_fused-only build,SAGE_WHEELS_URLnames another releases root. The Sage step needs no new join with the early downloads: it runs inpkg_post_venv, inside their window. Exercised on this Mac against a file:// release mirror and upstream’s real setup.py and core.py; the wheels are built by CI; the pod measurement is in the PR. -
3.12.0: the Hub, pinned and checked (Paul, 2026-10-16: Hugging Face best practices). (1) A row’s revision is what downloads: the file leg parsed
resolve/<revision>/and then dropped it, so a commit-pinned URL silently fetchedmain; it now passes--revision. Snapshots takehf://owner/repo@<commit>(the 40-hex form only), which the base could not pin before. (2) Every Hub download is checked withhf cache verifyagainst the hashes the Hub states for that revision (sha256 for LFS files) before it moves into place; size alone had been the check. A file that fails is deleted rather than kept for resume; a snapshot that fails is kept, sincehf downloadresumes it file by file. Anhfwithoutcache verifychecks size only and says so. (3) The size and sha256 HEADs putAuthorization: Bearer <token>in curl’s argv since 2.0, on the process list against §0.9; they now read a 0600 header file.gen-modelsandgen-hashesnow install the exit trap, so that file (and every temp dir) is gone when the verb ends. (4) A token whose role iswriteis accepted with a warning that a read-only or fine-grained read token is enough; the whoami answer is read for its role only and deleted. (5) Gated, 401, not-found and bad-revision errors are said once, in one line that names the fix, and not retried; transient errors keep three attempts, and the last no longer sleeps. (6)base.sh gen-models <dir> --pinprints the rows pinned to the commits they are at now (the first hop’sx-repo-commit, the API’sshafor a snapshot); stamp-models links a pinned snapshot to its/tree/<commit>. The hf_xet fast path (HF_XET_HIGH_PERFORMANCE, 32 range gets, no chunk cache) and resumable downloads were already in place and are unchanged. Speed first (Paul, 2026-10-16): a file is hashed ONCE, in its own fetch job beside the other downloads, never in a pass afterwards;state/verified.tsvrecords its commit and etag from hf’s own--local-dirmetadata (no request), and a file already in place costs nothing on any later run (size + record; one HEAD only when its row’s pin moved). Measured:hf cache verify’s hash runs at 2.6 GB/s on an M4, so the 174 GB Video Replacer set is about 70 s of hashing in total on a fresh volume, spread over the parallel downloads, and 0 s on a volume that has it. 3.11.0’s downloads beside the venv build take the same path (pins, hash, record) and now also take a file whose row’s pin moved, so it no longer waits for the model stage. Exercised on this Mac with a stubbedhfandcurl(suite/test_hub.py) and against the real Hub forhf cache verify(a flipped byte fails it, exit 1); not on a pod. -
3.11.0: the model downloads start beside the venv build (
lib/52-models-early.sh, §5). Once the packs are on disk and the OS stage is joined, every download the model stage would start runs in the background through hf from the downloader’s own venv ($BASE_HOME/fetch), and is joined just before the model stage, which still judges every row: an early file is counted once (as downloaded), a row the early pass left alone is fetched there, and a file it could not get fails the run by name with no second round of attempts. A run that stops before the join stops its downloads (they resume from staging).BASE_MODELS_FOREGROUND=1keeps the old order. Measured on RunPod, two fresh pods made side by side (H100 80GB HBM3, EU-FR-1,runpod/base:1.4.0-cuda1300-ubuntu2404, a network volume, the MiniMax H3 Video Continuer 1.0.5 with its 41.1 GB of models,--no-gate --no-suite), wall clock frompodctl createto the workflow listed through the pod’s public proxy: 3.9.2 1610 s, 3.11.0 1471 s (139 s less; 167 s less from the pod’s first ssh to the end of the install, against a 181 s download stage in 3.9.2). The early downloads ran 605 s at 70 MB/s beside the venv, packs pip and newest pass (304 MB/s alone in 3.9.2), and the join waited under a second. Where 3.9.2 spent its 1532 s: venv 589, newest 346, downloads 181, packs pip 112, import check 108, restart 61, boot 45, packs git 36, consolidate and tree 19, the OS stage 25 in the background. Both runs ended red on the same smoke miss (LoadImageCropnot registered), a package matter. -
3.10.1:
podctl ensureon a fresh RunPod pod, and one clearer STOP (three defects from the Video Swapper’s pod install, 2026-10-16). (1)ensurereset a fresh pod while its image was still in its own first boot (runpod/comfyuicopies ComfyUI and its venv onto the volume then): no venv, a crash loop, ssh never up (pod wibxqf22v26shv). It now waits, before its first reset of a pod the base’s boot does not own yet, until an image that ships ComfyUI answers through the proxy on 8188 (any status below 500), or, for any other image (runpod/base), three minutes after the pod started; at most 15 minutes. (2) After its own reset it kept polling the OLD ssh port: the API answers with the pod as it was for a while after the PATCH, and RunPod had moved 22/tcp (20424 to 36944). It now waits forlastStartedAtto move and looks for sshd on the port the pod has now, re-read on every try. (3)podctl installon a package folder not named after its package (vrforvideo-replacer-minimax-h3) stopped on “vr has no zip (vr-runpod.zip)”; it now says the folder’s name must be the package’s and names the one to rename it to. (4)podctl createoffered data centres a pod cannot be created in: the stock view names more than REST v1POST /podstakes (itsdataCenterIdsenum has 30 ids and lacks US-NE-1, a B200 location: a schema 400), and it picked ones that take no network volume (US-NC-1, US-MO-2, EUR-IS-2:POST /networkvolumes500). The candidates are now those in the live enum (REST v1’s ownopenapi.json, read once per run; the 30 ids read on 2026-10-16 when it cannot be read) and, when a network volume is wanted, those whose GraphQLstorageSupportis true; each one left out is named. Exercised on this Mac against the suite’s fake RunPod, which now answers stale reads after a reset and moves the ssh port; (2) also on a live pod (16174 became 19093, followed). -
3.10.0: a package can set ComfyUI’s launch environment, and ComfyUI starts sized to the container’s CPU quota (2026-10-16).
LAUNCH_ENV=( "KEY=value" ... )(§4): an allowlist of thread, tokenizer, allocator and CTranslate2 (CT2_*) variables, each value one word, so no path or secret can reach a file or a log; anything else, or a key set twice, refuses the package’s declarations before anything downloads. The install writesstate/launch.d/<id>.env(removed when a package drops the array; a change restarts ComfyUI) and every launch, install restart and boot alike, reads the folder: the machine’s own environment first, then the packages in name order (a conflicting later value is named), then the base’s one default,OMP_NUM_THREADSfrom cgroupcpu.max(v2) orcpu.cfs_quota_us(v1), rounded up, capped at the CPUs online, unset with no quota. Exported only in ComfyUI’s own shell and printed on the launch line. Why: a RunPod container is a quota on a large host, and torch’s intra-op pool, OpenBLAS and CTranslate2 sized themselves to the host’s CPU count and were throttled; faster-whisper on CPU, which ran 4 threads unless told otherwise, now takes the quota’s count with no package change (the Video Replacer only raisesBASE_MIN). Checked on this Mac: torch honoursOMP_NUM_THREADS(get_num_threads()); owed on a pod: the boot log’slaunch env:line. And_build/canvas/snapshot.pynames a console error it used to leave unexplained: ComfyUI-Manager’sloadGraphDatawrapper readingundefined.extrawhen comfyui-obvpm 0.2.16’s wrapper turns the frontend’s zero-argument startup call into a one-argument one (the startup default graph then does not load). Matched only with Manager’s frame on top; the one-line fixes belong upstream in either pack. And a seed build gets past its import check: withBASE_SEED=1(the template image, built where no GPU is)main.py --quick-test-for-cidied “Found no NVIDIA driver” before importing a pack, the next wall after 3.9.1’s venv verify (runpod-template’s image job, 2026-10-16). Both the full and the scoped check now pass--cpufor a seed build alone (_base_quick_cpu): every pack is still imported and judged, and a real machine’s check still starts on its GPU. Unit tests on fakes (cgroup root, stub python, the bash 3.2 boot path, the import check’s arguments) and the classifier test. -
3.9.2: three harness fixes (2026-10-16). Two were found porting the Video Replacer 5.1.0 into its own repository.
basetest.fake_podcopies every path a package’sZIP_EXTRAships (new helperzip_extra), so a shipped directory that is not a node pack reaches the fake machine (the Replacer’sapp/, which its install test had copied by hand).testbed.shstops only the server it started: it used to kill whatever listened on$PORT, and on a Mac shared by several sessions that was another checkout’s ComfyUI on 8199. Ours is the pid--serverrecorded (now python’s own pid, not the subshell’s), or its child for a pid file written before, and only while its command line is a ComfyUImain.py;--serveron a held port exits 1 naming the holder and a freeTESTBED_PORT;--statussays whose server answers. The third came from the Video Swapper’s pod install: a fresh venv lost minutes on CPython 3.15, which uv lists before torchaudio ships a cp315 wheel (the backend pre-pick rantorch_pick.pyfor it, stepping down through every CUDA build, then_base_python_pickran the full derived-set resolve for it, before both fell back to 3.14)._base_family_minorsnow asks uv for the torch family alone, per minor, newest first, and both picks start from the newest minor that has it (the testbed has done so since 3.7.1). Unit tests for all three. -
3.9.1:
podctl create --private(the default since 3.8.1) works again, and says plainly that its volume is not encrypted. It sentvolumeEncrypted: true, which REST v1POST /podsrefuses (“Extra input keys”: the field is on the Pod read-back only), so every private create failed before anything was made; and its summary printed “ENCRYPTED pod volume” without asking. Read 2026-10-16 from RunPod’s own sources: REST v1PodCreateInput, REST v2 and runpodctl 3.0.1 have no encryption input; GraphQL’s deploy inputs carry an undocumentedvolumeKeystring (not used: unverified, and the docs say bring-your-own-key is not supported); the storage docs name one way, the console’s “Encrypt volume” box. So the body no longer carries the field, anoteline says “NOT encrypted” before the pod is created, the summary prints what the read-back says, and the seconds-old-pod deletion onfalseis gone (false is now the expected answer). The suite’s fake RunPod refuses anyPOST /podskey outside the livePodCreateInput, so a field the API does not take fails on the Mac first. Exercised on this Mac against the fake; not against the live API. And a seed build works again:BASE_SEED=1(the template image, built where no GPU is) failed its venv verify on “torch.cuda.is_available() is False” and rolled the venv back, sobase-image.ymlhad never pushed an image (two runs on 2026-10-16, both red at step one). The install now passesBASE_NO_GPU=1topy/venv_verify.pyfor a seed build alone, which drops that one question (the CUDA build, the archs and the closure are still checked); a machine with no visible device still fails. Exercised on this Mac against a fake torch; the image build is CI’s to show. -
3.9.0: named ports. On RunPod’s Connect tab every link has a name:
8188“ComfyUI”,8888“Jupyter Terminal”, and one port per customer app a package declares (APP_PORTS, §4), through a private port-names templatepodctl createmakes once per set of names (§1.1; RunPod names ports only on templates, GraphQLportsConfig). The machine serves each app at its port’s root withpy/app_proxy.py(§6), restarted by the install and started by the boot after ComfyUI. JupyterLab is never tokenless and never dark for want of a token: a per-podJUPYTER_TOKENfromcreate(a fresh one for adeploy --likeclone), else generated by the boot on any bind, read back bystatus,jupyterandtunnel, and on RunPod’s public bind named once on the container log, never the boot log (rule 9).podctl tunnelforwards the machine’s app ports too and prints the three kinds of link as named localhost URLs. Exercised on this Mac against fakes (the suite’s fake RunPod REST and GraphQL, a raw-socket ComfyUI, fake pods); the console showing the names and the forwarder in front of a real ComfyUI are owed on a real pod. -
3.8.2: the canvas size cache is compared only on the OS that photographed it. The frontend measures text with the system’s fonts, so the package gate’s Linux runner computed other sizes than the Mac that made
_build/sizes.json(2026-10-16: 22 of the Character Creator’s nodes off on Linux, 1 on the Mac, both on frontend 1.55.17), andtest_snapshot_sizes_cache_is_currentwas red in CI for every package with a cache.snapshot.pynow writesplatforminto the cache; the test skips, naming both, where it differs. A cache without the field predates 3.8.2 and counts as the Mac’s, where the layout tool runs. Exercised on this Mac; the Linux skip is CI’s to show. -
3.8.1: a new pod is PRIVATE unless
--public(Paul, 2026-10-16: everything a person touches is as simple, then as secure, then as private as possible).podctl createon RunPod with no flag makes 3.7.4’s private pod (an encrypted pod volume,22/tcponly, ComfyUI on loopback throughpodctl tunnel) on Secure Cloud, B200 first;--publicis the one way to a network volume and proxy URLs, and--volume(a network volume, which RunPod cannot encrypt) is refused without it, naming both ways out, never silently made public.--privateis still accepted and means the default;--public --privateis refused. A host whose create has no private mode (Verda) is unchanged, and an explicit--privatethere is refused by name. The GPU settings need no flag either: B200/B300 by default,GPU_PREFERper package, the Sage mode per GPU (3.7.5). Exercised on this Mac against the suite’s fake RunPod; not against the live API. -
3.8.0: B200 and B300 are the defaults (Paul’s decision of 2026-10-16: the video packages run on NVIDIA’s datacenter Blackwell, optimised for it).
py/gpu_fit.pyis the one table (B200: compute capability 10.0, 180 GB; B300: 10.3, 288 GB) and the one rule: a package declaresGPU_PREFER="B200 B300"(§4; an unknown class is an invalid declaration), and a GPU of another architecture with less than 90 % of the smallest preferred memory is BELOW. The pod’s run says so in its banner and its SUMMARY warnings (preflight and the real run sharebase_init), andpodctl installasks the pod the same three numbers before it uploads and prints a WARNING; neither ever stops. A RunPod MIG slice (NVIDIA B300 SXM6 AC MIG 1g.34gb, 34 GB) is never a B300.podctl createon RunPod without--gpuasks the stock check forNVIDIA B200, thenNVIDIA B300 SXM6 AC, and creates the first with stock where the pod must go (the--location, the--volume’s data centre, else the first that has it), naming each one’s data centres when none fits;allowedCudaVersionsis never below 13.0 without--min-cuda(the base installs a cu130 torch whatever the image’s tag says). The banner’s torch line names every Blackwell arch the wheel carries (sm_1xx), not onlysm_12x, so a B200’ssm_100shows. The handbook (§1.1 “Which GPU”) andhosts/runpod/README.mdcarry the RunPod facts read 2026-10-15/16: B200 stock in EU-RO-1, US-CA-2, US-NC-2, US-NE-1 and US-TX-6; B300 in EUR-IS-1, the one data centre with B300 that takes a network volume; a volume pins its data centre; the CUDA floor; and NVIDIA’s encode matrix: 0 NVENC on HGX B200 and B300, soh264_nvencfails there. Exercised on this Mac against the suite’s fakes (fake RunPod, fake nvidia-smi, a fake pod); not on a B200 or a B300. -
3.7.5: SageAttention is proven by RUNNING it. A B200 (sm_100, 2026-10-16) imported a correct sm_100a build and died at the first sampling step: upstream’s
sageattn()(KJ’s “auto”, ComfyUI’s--use-sage-attention) dispatches sm80/86/89/90/120/121 only. Upstream withdrew SM100 in 2025-12 (#322) and SageAttention3 refuses sm_100 (#346).py/sage_probe.pycalls every KJ mode on a tiny tensor against PyTorch attention; “Sage: OK” meansautoruns, otherwise the run warns and names the mode that does (Triton first), and no mode at all fails the run. The proof is kept per GPU as$VENV/.comfy-base-sage/<sm>.json(andbase_sage_mode),sageis the base’s own command, hygiene drops--use-sage-attentionwhereautofails, and 10.3 (B300) builds the qattn kernels as sm_103a. Exercised on this Mac against fakes and a fake probe; not run on a B200 or B300. -
3.7.4:
podctl create --private(RunPod), for work that must not be reachable from a URL: an encrypted pod volume of--data-gb(RunPod encrypts only a pod’s volume disk, never a network volume, so--volumeis refused),22/tcpthe only port (no proxy URL, no JupyterLab), andBASE_LISTEN=127.0.0.1in the pod’s env so ComfyUI binds loopback;podctl tunnelis the way in, andinstallcarries the pod’sBASE_LISTENinto its runs (an ssh shell does not see the pod’s env, so an install’s restart would have bound 0.0.0.0).volumeEncryptedis in no documented create input, so the pod is read back: unencrypted is deleted at once and fails, an answer without the field is kept and fails.statussays “encrypted pod volume”, and “jupyter not exposed” for a pod without 8888/http. Exercised on this Mac against the suite’s fake RunPod; the encryption field has not yet been sent to the live API. -
3.7.3:
podctl createon RunPod, the volume a pod really has, andinstall --no-suite/--env. A first pod on a fresh account needed the console or runpodctl, because RunPod had onlydeploy --like <pod>;createnow checks stock first (GraphQLdataCenters, the only stock view RunPod has) and names every data centre where the GPU is free, stops before anything is created when the chosen one (or the--volume’s) has none, makes a network volume of--data-gbthere or reuses--volume, and puts a Secure Cloud pod on it (the image,8188/http 8888/http 22/tcp,PUBLIC_KEY, container disk,allowedCudaVersionsfrom--min-cudaor the image tag); a refused pod takes the volume it made with it.statusprinted “volume NONE” for a pod whose/workspaceis a pod volume (volumeInGb), and every pod read now asksincludeNetworkVolume=true, because the live openapi names only thenetworkVolumeobject and not thenetworkVolumeIdolder answers carried: either shape is read, a pod volume is named as one (and recorded for the disk gate), anddeploysays why a pod volume cannot be cloned.install --no-suitesetsBASE_NO_SUITE=1for the base step and every package run (it had to be done by hand over ssh), and--env KEY=VALUEpasses any variable to every run on the machine, names printed, values never. Exercised on this Mac against the suite’s fake RunPod; the stock query and both volume shapes were read from the live API (read-only). -
3.7.2: no shell function is defined twice.
base.shsourceslib/[0-9][0-9]-*.shin order into one shell, so a name defined in two files is silently the later one: 00-env’s §4 hook helperbase_runwas shadowed by 95-summary’s install pipeline of the same name, and a hook that called it (base_run cp …, as §4 says) re-entered the whole install without end. The pipeline is nowbase_install;base_runis the dry-run aware helper §4 always described, so a hook written to the handbook is correct as written. 30-venv’s one-line_base_all_reqfiles, always replaced by 40-packs’s full one, is gone. The unit tier now fails on any name defined twice in one shell (base.sh+ the numbered lib files;boot.shalone), and checks thatbase_runruns its command, or prints it under--check, after the whole library is sourced. Exercised on this Mac (unit tier andverify.sh); not on a pod. -
3.7.1: the testbed’s Python is the newest torch ships for. uv listed CPython 3.15 on 2026-10-15 before torch had a cp315 wheel, and
testbed.sh --servertook the newest listed minor and died in the torch install (“No solution found”). The minors are now tried newest first and the first one the torch family resolves on wins (a cacheduv pip compile, no install); offline, the venv’s own Python is kept. A venv whose python does not run (a build that died, an interpreter uv removed, a danglingbin/pythonthat-xcould not see) is removed before the rebuild instead of makinguv venvrefuse with “A virtual environment already exists”. The pod side already had this rule since 3.6.1 (_base_python_pick). Exercised on this Mac against the real uv (3.15 rejected, 3.14 picked) and on temp trees with a uv stand-in; not on a pod (the pod path is unchanged). -
3.7.0:
podctlreads the workspace shape too. 3.6.0 taught every package walker the workspace and left two readers on the submodule shape, as its record said:podctl install --pkg <name>from a workspace stopped with “not inside a brand repository”, and the default provider ignored the workspace’sbrand.tomland fell back to runpod. Both now askpy/pkgdirs.pyfor the consumer root ($BASE_WORKSPACE, then the submodule shape):--pkg <name>resolves to<workspace>/<name>, and the provider comes from the brand at the workspace root (brands_at: the root’s ownbrand.toml, else every<root>/<brand>/brand.toml). A$BASE_WORKSPACEthat does not exist is an error, as in every other walker. The workspace folder is<name>/, not<brand>-<name>/as 3.6.0’s prose said: the rule needs the folder’s name to be the script’s stem, and the first consumer (SoulCraft, one repository per workflow under SoulCraft-Org) clones each package as<workspace>/<name>/. New:test_unit_podctl_reads_the_workspace_shape_for_pkg_and_provider. Exercised on temp trees only. Also: the install tier’s “a second run is a no-op” checks were red onmainsince 3.5.0, because 3.5.0’s size cache (state/sizes.tsv, a directory’s size by path and ctime) may gain a row on the second run; likelogs/it is a runtime record, not the store, so every no-op check now skips it (basetest.tree_hash). -
3.6.1: a pack row may name the branch to track. rgthree/rgthree-comfy’s GitHub default branch now points at a stale
dev(5f940dd, 2023-11-20) while its work is onmain(2026-10-15 finding, exporting the Video Replacer). Every install follows the remote’s HEAD, so a fresh pod or testbed got 2023 rgthree, which does not import on ComfyUI v0.41.x (executionhas norecursive_output_delete_if_changed) and has no Fast Groups Bypasser: every package using rgthree broke. An optional sixth field,dir|url|sha|cnr_id|why|branch, names the branch instead (theurl#branchform was rejected: the consumer’s comfybuild readsurlas a GitHub repository and dockerize compares it as one). The rgthree base row tracksmain._base_pack_syncwidens a single-branch clone’s refspec to the branch and moves a checkout on another branch across;py/pack_heads.pyanswersurl#branchkeys with that branch’s tip; the clone, the dry-run plan, the printed records (podctl writes the row back whole),base.sh latest,list-packs(nowdir|url|sha|branch) andtestbed.sh(new--packs: the node packs alone) honour it. Unit tests run against local remotes whose HEAD is a staledev; the install tier runs it throughbase_main. Not yet run on a pod. Also, because the gate was red on 3.6.0 the same day: uv started listing CPython 3.15 before torch had a cp315 wheel, and with nothing else to resolve against_base_python_picktook the newest minor uv listed, so the torch pick (and every dry run on a new pod) failed; the backend pre-pick asked 3.15 only. Both now take the newest minor the torch family resolves on, the rule the pick already followed when there were requirements to resolve.testbed.sh --serverstill picks the newest minor uv lists (a follow-up). -
3.6.0: packages are found in the workspace shape too. Every cross-package walker assumed a consumer with the base as a submodule at
base/comfyui-baseand its packages under<brand>/packages/<name>/; the consumers are moving to one repository per package, cloned side by side as<brand>-<name>/under a workspace, with the base read from$COMFY_BASE.py/pkgdirs.pyholds the one rule (a directory holding<its name>-script.sh, in either shape, deduplicated by resolved path) and the one root resolution (--root, then$BASE_WORKSPACE, then the submodule shape, then none);package.py --all(new--root), podctl’s pin scan,verify.sh,testbed.shandbasetest.converted_packagesuse it instead of five*/packages/*globs with five guards. The pin scan now reads the base’s ownlib/40-packs.shbeside the driver, not<root>/base/comfyui-base/lib/, which a workspace does not have, so a standalone base (no consumer root) now measures its own rows where it used to report nothing: intended, those rows are the base’s.base_testhandsBASE_WORKSPACEto the suite when set, and the unit tier’s cross-package checks walk the consumer root it resolves (skipping when there is none, the 2.2.0 standalone guard). A named root that does not exist is an error everywhere, andverify.sh/testbed.shstop whenpy/pkgdirs.pyis missing or fails instead of going green over zero packages.podctl --pkgresolution and the provider default still read the submodule shape. Exercised on temp trees only. -
3.5.0: every probe once, and in parallel where it can be. The pack HEADs are asked of every remote at once (
py/pack_heads.py, onegit ls-remote --symrefeach, sixteen at a time), and a checkout already on its remote’s HEAD (same commit, on that branch, wired to it, not shallow, no edits) is not fetched; anything else takes the full path, never backwards. ComfyUI asks the remote for its newest release tag first and skips the fetch of every branch when HEAD is already on it. apt runs in the background beside those network checks and is joined before the venv snapshot, where the driver gate and “restart required” stop as before; its output is shown whole at the join. The two filesystem walks skip the uv, pip, Hugging Face and xet caches (.comfy-base-stagingstays pruned), a directory’s name is read without abasenameprocess per line, a backup venv is sized once per path and ctime (state/sizes.tsv), the venv’s torch backend comes from torch’s metadata without importing it (a torch with no local tag is still asked directly), and the cuBLAS probe compiles once per toolkit on a machine. Runtime mode keeps the OS step skipped and its torch import. The README and §9.1 now say what Comfy-Org’s tooling actually does (skills, not MCP client configuration; the marketplace plugin is Comfy Cloud’s) and why the repository still ships no.mcp.jsonand no approval for one. Exercised on fake pods only. -
3.4.0: each model file’s sha256, from the Hub, never hashed here (Paul, 2026-09-25, asked for by the ComfyUI tooling session so a Comfy Build spec needs no second pass over 100+ GB).
base.sh gen-hashes <pkg dir>reads the sha256 the Hub publishes for every LFS file on a HEAD and writesmodels.sha256beside the script;--checkcompares. The MODELS rows are unchanged (no new column for every package to carry): a package opts in by adding the file. It ships in the zip, the declarations dump printsMODELHASHrows, and the workflow’smodelsarray gainshash+hash_typefor ComfyUI’s Missing Models dialog. Rows the Hub does not serve (LOCAL, a repo snapshot, a GitHub asset) are left out and said. -
3.3.0: every Python resolve once, and a force is never undone. Traced on 3.2.1:
base_packs pipinstalled the derived set WITHOUT the overrides, so each package the newest pass had forced past a cap (protobuf 7 over its <6 caps) went back down under it on the next run and was forced up again: two whole resolves and a reinstall, every run; a force its holder refused (huggingface-hub 2 under transformers) was installed, probed and taken out again every run; and the newest pass resolved the very set the pip half had just installed. Now forces are carried as floors (name>=version, never a pin), every resolve applies them (a round’s own force wins its name; floors that no longer resolve are dropped and the install runs without them), a refusal is remembered until either version moves, an identical set is not resolved twice in one run, and the outdated scan runs again only after something was installed. Forward repairs: Python packages that moved are a reason to restart (the live server had kept the old ones loaded); a pack failing on a module nothing declared gets it at its newest from a reviewed map (py/modmap.py, never a name taken from an error) and is judged again; a reused venv never moves torch backwards; comfy-mcp is not installed a second time; SageAttention’s cache key names torch’s CUDA backend, and a build stamped under the old key for the same venv is adopted rather than rebuilt. A runtime machine is untouched (no pip, no restart reason). Exercised on fake pods only. -
3.2.1: a package may write into its own pack, and the mirror keeps what it writes. Qwen 2.1 fetches its rewriter prompts into its own pack in
pkg_post_models(they cannot ship in the zip); they survive because “unchanged” compares the zip’s copy with the stamp, never with the live folder, and because the mirror runs before the models hooks. Found by the Qwen 2.1 session reading 3.2.0; now said inlib/62-own-packs.shand held by two tests. No behaviour change. -
3.2.0: an install does each piece of work once, and says what each stage cost. Paul, 2026-09-25: a two-file change to a package’s own node pack took a full install (about 10 minutes), and he asked for installs that always take everything to its newest while doing only the work that changed. Traced from the code and the consoles of 2026-09-24/25, nothing was ever reinstalled: the minutes were work done twice.
podctl install --base --pkgran two whole pipelines, the base’s step one (with its restart and a 75 to 82 s suite) and then the package’s, which repeats every stage of it; now the base is installed and the package’s run is the one pipeline, with the base suite once per base version. podctl’s full--checkpass before each run is nowpreflight, seconds long (a machine whose base predates it gets--checkas before), and a dry run no longer runs the suite. A package’s own node packs (VENDORED_PACKS) are the base’s to install: mirrored from the zip, untouched when unchanged, newest version first on a shared store, and put back when they fail to import; the import check loads only them when nothing else changed. Every stage is timed. The buyer flow takes the same preflight and single pipeline (no base suite there,BASE_NO_SUITE=1). Where own packs live was decided with Paul the same day: in the zip (buyers’ machines have no git and no GitHub credentials, the published runtime must never carry them, and Comfy’s own build tool takes a local folder as a pack’s source). Exercised on fake pods only; no live host yet. -
3.1.1: a runtime never carries a package’s vendored packs.
runtime-capturearchived everything undercustom_nodes/, which on a machine holds each package’s own plain-folder packs beside the git checkouts; a runtime meant for publishing would have carried that code. Only a git checkout is runtime now (the manifest’spackswere already exactly those); a vendored pack arrives with its package’s zip on every install, which is also why an Update (a newerruntime-apply) is followed by the packages’ installs. -
3.1.0: a buyer’s machine, opt-in end to end, with every plain run unchanged. A proven runtime:
runtime-captureon a green machine (parts under GitHub’s 2 GiB andruntime.json,py/runtime.py),runtime-applyon a buyer’s, andBASE_RUNTIMEinstalling from it with no OS, git, pip or uv step (§0.3’s one exception, §0.12). The first flow first:MODELS_LATERandBASE_STAGED=1stand in for the rest, which a detachedbase.sh fetch-laterbrings behind the started server whilestate/progress.jsonreports each file.BASE_FETCH_JOBSfetches several files at once (default 1, the old serial loop, same output). Verda:balance,availability,create_machine(the data disk, then the instance with its own OS disk, no startup script; a refused instance takes its new disk with it),machine_volumes,delete_volumes. podctl:balance,availability,create,pause,put-away,buyer-install(zips alone: no packager check, no Mac gate, no pack records written),progress,runtime-capture;install()takes a run environment and zip-born items. Each run keeps its verdict instate/last-run/<id>, which is what capture reads. ComfyUI’s environment carriesCOMFY_BASE_STATE(the base’s state dir), so a package’s own route findsprogress.jsonwithout guessing. Exercised by the suite only (macOS, Python 3.12 and 3.14); not yet on a live machine. -
3.0.3: renders stay on the store, and a machine runs one ComfyUI. Both measured on a Verda machine on a shared store (
BASE_VOLUME_SHARED=1). First,comfyui_args.txtstill named--output-directoryand--input-directoryon the old library mount: 2.5.2 stopped using a library under a shared root, but the hygiene only ever ensured those flags and never took them back, so ComfyUI made the old mount point on the OS disk and wrote every render and upload there. Without a library the hygiene now removes a value that is not on the store (a package’s ownHYGIENE_ARGSchoice stands), and the launch line, which the boot also uses, leaves off one that sits on the OS disk while the store is a mount of its own, and never creates it. Second,BASE_RESTART=1started ComfyUI detached (ppid 1), outsidecomfy-base-boot.service’s cgroup, so a latersystemctl restart comfy-base-bootstarted a second one beside it (the old one kept :8188 and 34 GB of VRAM)._base_restart_comfyis now the one way to restart: through the unit where it is enabled and the caller is not inside it, directly elsewhere; andboot_comfystops any ComfyUI it did not start before starting its own. The process matcher moved tolib/85-launch.sh, which gives the boot’s wait the liveness check it lacked (before, it declared a healthy start dead after 2 s). Exercised by the suite only (macOS and Python 3.14); not yet on a live machine. -
3.0.2: every
HTTPErrorthe host providers andpy/smoke.pyread is closed. 3.0.0’s test runner takes the newest Python uv has instead of 3.12, and on Python 3.14 an HTTPError that is read but never closed is aResourceWarningwhen it is collected; with warnings as errors, the repository’s CI went red on 3.0.0 (13 failures, all this one cause, each reported against whichever test was running when it was collected). The suite passes under 3.14 now. -
3.0.1: a SageAttention build for a list of architectures serves each GPU in the list. An image built with no GPU stamps its build for
SAGE_ARCHS="9.0;12.0", and the machine it serves computed its key fromsm_120, so the two never matched and every first install rebuilt SageAttention (10-20 minutes) from the very source, Python and torch the image already carried. The stamp, and a cached wheel, now count as current when they are the same commit, Python and torch and their list holds this GPU’s sm; a GPU the list does not hold still builds. Measured by the session that adapts a baked-image consumer to 3.0.0. -
3.0.0: always the newest, for everything the base installs, on every run and every host (§0.3). ComfyUI stays on its newest release, with
COMFY_REF(podctl install --comfy-ref) as the opt-in for something newer; a pack is at its remote’s HEAD on every run (a row’s sha is the last-tested record); every Python package is at its newest throughpy/reqlift.pyand uv’s overrides (measured on 2026-09-25: ComfyUI v0.37.2 plus 27 pack requirement files resolve on Python 3.14, the frontend lifted from 1.52.7 to 1.54.7, protobuf forced from 5.29.6 to 7.36.2 past a cap thatgoogle-generativeai0.8.6 still imports under); torch follows the newest CUDA build the driver runs through uv’s--torch-backend=auto; the OS and the NVIDIA driver (NVIDIA’snvidia-open, a one-time switch from another family in a single transaction) are upgraded first, and a new driver stops the run before the GPU is used, asking for a restart a person approves. The Verda startup script registered on an account is replaced when it differs (ensure) and never attached when stale. RETIRED, each because it held something below its newest:BASE_PINNED,COMFY_TAG,BASE_LATEST(--latestis a plain run),SAGE_WHEEL, a 40-hexSAGE_REF,BASE_FORCE_SELF,podctl --no-latest. A baked-image consumer that setBASE_PINNED/COMFY_TAGnow gets a machine that upgrades at its first install.podctl installno longer rewrites a shared-root machine’shost.envas a library machine (it read an attached shared volume as a library and droppedBASE_VOLUME_SHARED=1): the Verda provider asks the machine what/workspaceis. The rehearsal’s fake machine now runs the package’s ownCOMFY_MIN(it was always 0.34.7) and carries the package’s vendored node packs (it copied only top-level files), both measured by a consumer whose suite failed only there. 2.12.3’s_base_venv_reconcileis superseded: nothing installs an upstream file one at a time any more (the overshoot it repaired cannot happen in one resolve), and re-resolvingpip checkconflicts would undo a deliberate force; a force its holder refuses at import (transformers with huggingface-hub 2.0.0) is taken back bypy/holders.pyinstead. -
2.12.3: the venv’s requirements are made to agree after every pip half, at their newest. On a Verda machine with a reused venv,
--latestended at the import check:transformers5.17.0, the newest release, requireshuggingface-hub<2.0, and the per-pack loop had taken the hub to 2.0.0.pip install --upgrade -r <file>always takes a requirement the file names to its newest, several packs name a barehuggingface_hub, and pip does not resolve against what is installed outside the request; it only warns._base_venv_reconcilenow runs after the per-pack loop (every run, reuse included) and after the build’s installs:pip check, then both sides of every conflict in ONEpip install --upgradeunder the torch constraint, so the resolver lands on the newest set that agrees (that night, transformers 5.17.0 with huggingface-hub 1.33.0). No pin is written. A conflict pip cannot resolve is reported and left to the import check, which stays the gate. Four unit tests feed it cannedpip checktext. -
2.12.2:
comfyui-base.zipcarriesLICENSE. It never did: the packager’s member list named five files and the licence was not one of them, so every copy of the zip went out without the notice MIT asks to travel with it. The zip is exactly the copy a project built on the base hands to its own users (a workflow package’s buyers install it as step one), so the gap reached every one of them.suite/test_unit.pyasserts the notice is in the extracted zip, beside the manifest check that already proves every shipped file is listed. Step one’s copy list (lib/95-summary.sh) had to learn the file too, or the install failed its own manifest check; a new test holds the packager’s members and the installer’s list together, since they are kept by hand in two places. Also:suite/test_podctl.py’s fake-RunPod CLI call inherited the session’sPODCTL_*, so a workspace exportingPODCTL_PROVIDER=verdasent it, authenticated, to the LIVE Verda API. It strips them now; a test reaches only its own fake server. -
2.12.1: a security review of 2.12.0, and it found two real things, both in the same eight lines. The Jupyter token was interpolated into the ssh COMMAND, and sshd runs a non-login remote command as
bash -c '<cmd>', so it landed in the machine’s/proc/<pid>/cmdline, world-readable on Linux, and inpson the Mac. The code then wrote the file 0600, which protects the value at rest while having published it in the process table on the way there.lib/boot.shstates the rule for this exact secret andsuite/test_unit.pyasserts it: the token rides the environment, never argv. It goes on stdin now, andssh_rungrew aninputparameter to make that possible for any secret. The same line also passed the drop-in body as printf’s FORMAT string. MEASURED: a token ofab%scwas written asabc,100%doneas1000one, andtok%truncated the file. printf failing inside a pipeline leaves the pipeline’s status as tee’s, so the&&chain carried on andensurereported the token written while the machine held a different one. There is no attacker here, the token is the operator’s own, but the failure was silent and that is worse than loud. Also hardened while in there: a public address from the API is parsed withipaddress.ip_address()before it reaches the operator’s~/.ssh/config, where a newline would have become an ssh directive. That needs the API to lie, which is a high bar, but it is one line. And the signing test stopped being circular before the review reached it: it recomputed the expected signature with the same formula the code uses, which proves only that the code agrees with itself. Two golden vectors are pinned as literals now, with an assertion that at least one of them contains a-or_, because standard and url-safe base64 differ in exactly two characters and a signature containing neither cannot tell them apart. -
2.12.0: Crusoe has a driver.
hosts/crusoe/provider.pywas 144 lines in which every method raised, so of the four hosts it was the only one actually broken: the machine side has been complete for a while, but nothing on a Mac could find, reach or move a VM. It is a signed REST client now with the whole Provider interface behind it. The signing was the part worth getting right and the part with no live 200 to check against: the prose docs’ own worked example does not reproduce under any reading, so it is built from two of Crusoe’s own implementations instead, which do agree (client-go auth/v1/auth.go and the Python in crusoe-registry-token-rotator), and the suite asserts it against that formula rather than against a constant. The fake API verifies every signature, so a provider that stops signing fails here rather than in production. Three things are not Verda and shape the code.stopis a real stop, because a stopped Crusoe VM keeps its disks and its id and bills the disks only, where a shut-down Verda instance still bills the GPU and is therefore deleted. The public ip is dynamic, so every lifecycle call rewrites the Host block rather than only the first. And the startup script is a create-time field that cannot be changed through the API and runs on every boot, soensureships the currenthosts/crusoe/startup.shover ssh and re-runs it, which is the only way to move an existing machine forward.deploydetaches the disk before creating the clone and passes it in the create body rather than attaching afterwards, because a VM that boots without its disk sends the startup script into a 15 minute wait for one.ensurealso writes the JUPYTER_TOKEN systemd drop-in, without whichpodctl jupyterreports no token on this host forever and reads as a broken command. Two defects in the stub went with it:record_volumetook noenvkeyword whilePodIO.uploadpasses one, which was a TypeError escaping the PodctlError handler as a traceback rather than a diagnosis; and a lifecycle call that answered FAILED immediately was polled 120 times anyway instead of failing at once.experimentalstays True. Implementing the methods does not earn its removal, a live account does, and this is exactly where Verda sat before its first live run corrected three things the documentation implied. -
2.11.1: the repository names no brand but its own maintainer. The runtime was already clean and a foreign brand already built and gated green, but
CLAUDE.mdnamed two consumer repositories, three Record entries named a foreign brand, two machine aliases and a workflow product, and two test comments named a consumer’s layout and a package. None of it was load-bearing and all of it told a reader this was somebody’s private tool. Only the attribution survives, inLICENSEand the README. The brand-guard test changed shape too: it used to forbid a list of two organisation names, which meant citing the very organisations the rule exists to keep out, and would have gone stale as consumers change. It asserts the invariant now - everypackages/glob intestbed.shis anchored on a variable, never on a literal directory - which is what the original leak violated, catches any future one, and names nobody. Verified against the 2.6.0 leak verbatim and a synthetic new brand: both caught; variable-anchored globs and comments: clean. The README also says what this is for someone who will never write a package: ComfyUI at its newest release tag plus six pinned packs including ComfyUI-Manager, no model weights, bring any workflow. That is what most people want from it, and the front page led with the package contract instead. -
2.11.0: the driver stops knowing one brand’s machines.
TUNNEL_LOCALwas a hard-coded map of threeverda-*aliases belonging to the repository’s own maintainer, andtunnel_localsread a machine’s port offset out of it. Everybody else’s fleet fell through to 0, so their second machine silently shared 8188 with their first, and the only way to fix it was to edit the base - the one thing a consumer repository must never do. The offset is a fact about somebody’s fleet, not about the base or the host, so it comes from$PODCTL_TUNNEL_OFFSETnow, beside thePODCTL_HOSTthat already names the alias, and defaults to 0 so a single machine needs nothing. A value that is not a whole number in 0-1000 is refused rather than read as 0, because 0 is exactly the collision it was set to avoid. Found by building a foreign brand from scratch, with its ownbrand.tomlshipping to a different host, and checking what the base still assumed: everything else passed, including the runtime inlib/andpy/, which carries no brand name at all. Migration: a fleet that relied on the old map must now export the offset per machine. The alias that was the second machine takesPODCTL_TUNNEL_OFFSET=1, the third2; everything else was already 0. -
2.10.1: the front page stops calling a proven host experimental. 2.5.3 promoted Verda after live runs on 2026-09-17 and 2026-09-18: it set
experimental = False, rewrotehosts/verda/README.mdaround what those runs answered, and recorded the change here. It did not touch the four other places that still said otherwise - this handbook’s own host table, its 1.2 heading and opening line,hosts/README.md, and the top-level README in three spots, including the status table a visitor reads first and the sentence naming Verda an open item. So for a day the repository advertised a working host as unproven, which is the one direction a status claim should never be wrong in: overselling gets found out, underselling just loses people. All corrected against the provider flag, which was right the whole time. -
2.10.0: the MCP client configuration stops being this repository’s to own. 2.7.0 committed a
.mcp.jsonat the root, which Claude Code and Cursor read automatically: every person who cloned the repository was prompted to enable acomfyserver that could not work until they had comfy-mcp installed and a tunnel up. Researched rather than guessed, and the answer was upstream’s:comfy skills installwrites six skills across Claude Code, Cursor andAGENTS.mdat user scope, with install / uninstall / status / validate, andComfy-Org/comfy-skillsdescribes itself as the single source of truth for the installer and the MCP server. comfy-mcp shipped 0.9.0 and 0.10.0 within two months. Competing with that would have drifted within weeks. The local case is upstream’s now and the README says so; what stays is the case their tooling does not cover, because its local flow assumes ComfyUI is on your machine and its cloud flow assumes Comfy Cloud: a machine the base provisioned on a rented GPU.podctl mcpstill writes the file, and it is gitignored, because it names one machine and one operator’s paths. Also, two more places still resolved the testbed at the pre-2.6.0 path only:suite/test_comfyui.py, whose fallback made the whole tier skip under a barepytest suite/in a standalone clone, andgate()in podctl. Both look in the base first and beside it second now, as_base_use_testbeddoes. That is the third instance of the same 2.6.0 oversight; the moral is that moving a file one level up means auditing every path expressed relative to it, not just the ones a test happens to cover. -
2.9.2: the testbed finds a consumer repository’s packages again. 2.6.0 moved
testbed.shinto the base, which changed$HEREby one level and silently broke the glob that locates the packages:$HERE/../*/packages/*/from<repo>/base/comfyui-baseresolves to<repo>/base/*/packages/*/and matches nothing. MEASURED on a reconstructed consumer layout: zero matches, so the testbed would have provisioned the base’s own packs and none of the package’s, and thecomfyuitier would have run against a tree missing the very packs it exists to exercise - green, and meaningless. Exactly the failureBASE_SHwas fixed for in 2.6.0; its sibling went unnoticed because a standalone base has no packages either way, and that is the only shape the suite could see. The root is searched for now, one level up then two, and a candidate counts only when it holds a<name>/<name>-script.sh- two levels up from a standalone checkout is an unrelated directory.--statusalso prints what it derived and where from, because silent derivation is how this shipped. Proven on all three layouts: base-as-submodule, testbed.sh-beside-the-base, and standalone. -
2.9.1: the forwarded ports say why they are forwarded.
TUNNEL_SERVICEScarries 9199 and 11434 next to ComfyUI’s 8188 and JupyterLab’s 8888, and the comment beside them explained only the per-alias offset (a collision measured on two live machines), never why those two ports were in the list at all. Read cold they look like leftovers, and this session came close to deleting 11434 as one. They are not: the base runs no metrics exporter and no inference server, but a PACKAGE may run one on the machine and talk to it on loopback, which is the shape a package takes when its whole point is that nothing leaves the box. Removing the forward would break such a package silently, from the Mac side, with nothing in the log to say why. Documented in both places, so the next reader does not have to rediscover it. -
2.9.0: SAGE_REF can finally do what it was documented to do. It is described as the way to pin SageAttention, but the clone used
--depth 1 --branch "$SAGE_REF", and--branchtakes a branch or a tag and refuses a commit: MEASURED,git clone --depth 1 --branch <40-hex>answers “Remote branchnot found in upstream origin”, so passing a commit failed at the clone for as long as the knob has existed. A commit is fetched by object name now, and it also short-circuits the ls-remotethat resolves the cache key, which lists refs and would have resolved a commit to nothing. The DEFAULT staysmain, deliberately: the wheel cache is keyed on the resolved upstream commit so that tracking upstream costs nothing, and a version-keyed cache would be exactly the stale pin the comment above that key refuses. Reproducibility is now available to anyone who wants it without making everyone else carry a pin. Also: the RunPod fixture insuite/test_podctl.pyhad a real-lookingmachineIdanddataCenterIdamong its obviously-fakefakevol001andfaketmpl02; both are fake now. -
2.8.1: the MCP configuration names the
comfybinary. comfy-mcp is a wrapper whose discovery and lifecycle tools shell out to comfy-cli, found throughCOMFY_BINorPATH, and an MCP client is usually launched by a GUI whosePATHis not a shell’s. 2.7.0 shipped neither transport withCOMFY_BINset, which left the ssh form giving an absolute path tocomfy-mcpand then hoping the machine’sPATHhadcomfy. MEASURED 2026-09-19, driving the real stdio protocol against the testbed’s ComfyUI: initialize returned protocol 2025-06-18 and 39 tools listed either way, butserver_infoanswered “Error executing tool server_info” withoutCOMFY_BINand answered properly with it. The unit tests could not have found this; they assert the shape of a config, and the shape was fine. Over ssh the binary besidecomfy-mcpis named; over the tunnel this machine’s is, whencomfycan be found, and the command says so when it cannot rather than writing a config that will fail later. -
2.8.0: the owned-box recipe says what a Blackwell card actually needs.
hosts/local/README.mdnow sets out the chain the whole host hangs on — compute capability 12.0 becomessm_120,sm_120means the CUDA 13 wheels are the only stable ones, CUDA 13 means driver >= 580, and torch is therefore the newest+cu130wheel for the interpreter’s tag rather than a pinned number — with the file each link lives in, so a refusal can be read instead of guessed at. It also says why SageAttention is built from source here (no PyPI wheel carriessm_100/sm_120kernels) and what to pass when the card is not visible at build time. The MCP shape for a box with no pod and no ssh alias is written out, since 2.7.0’spodctl mcphas nothing to point at locally. The row stays honest: still a recipe, and the host-report template is there for the first person to run it end to end. -
2.7.0: the machine is drivable by an agent.
lib/45-mcp.shinstallscomfy-cliandcomfy-mcpinto the run’s venv and binds comfy-cli to the discovered tree;podctl mcp <machine>writes the.mcp.json, over ssh by default and over the tunnel on request. The transport matters more than it looks: comfy-mcp’s lifecycle and discovery tools only work where the tree is, so a tunnel-only client can run a workflow and little else, which is rarely what anyone wanted. §9.1 has the table. Telemetry was the one real obstacle — comfy-cli depends on mixpanel and posthog, rule 9 says nothing is uploaded, and a wheel’s behaviour is invisible to the outbound-host test because it is not our source; both kill switches are exported in the stage and in the boot environment,comfy tracking disablewrites the config too, and the suite asserts it so it cannot regress quietly. MEASURED 2026-09-19, the stage run against a real venv: comfy-cli 1.20.0 and comfy-mcp 0.10.0 installed, the workspace bound, and torch 2.14.0 / torchvision 0.29.0 / torchaudio 2.11.0 unchanged afterwards — none of comfy-cli’s 29 dependencies is torch, torchvision, torchaudio or numpy, and the constraints file is passed anyway. That run also settled how to install:$PY -m pipworks on a pod because 30-venv.sh seeds the venv, but a venv built without--seedhas no pip at all, and uv built the venv in the first place — so the stage uses uv and stops depending on a detail it does not control.BASE_MCP=0skips the whole stage, which never fails a run. -
2.6.0: the testbed the base tests itself with now lives in the base.
testbed.shwas carried by the brand repositories, sosuite/test_unit.pyskipped thecomfyuitier by name — “this base is its own repository (2.2.0); the brand repositories carry one” — and a clone of THIS repository could never run it at all. That is a poor thing to hand someone who forked the repository in order to work on it, and it is the whole tier that proves a pack actually imports.testbed.shships here now;_base_use_testbedandverify.shcheck<base>/testbedfirst andbase/testbedsecond, taking one root whole so the port file always comes from the same place as the tree. Two things had to be fixed on the way in.BASE_SHwas hard-coded to$HERE/comfyui-base/base.sh, which from the repository root resolves to nothing: MEASURED, the script then printed “no ComfyUI Base beside this script — only EXTRA packs are provisioned” and, since EXTRA has been empty since 2026-09-04, would have provisioned a ComfyUI with no custom nodes at all, silently. AndVENDOREDwas three hard-coded../<brand>/packages/...paths — a brand’s name in a repository whose first design rule is that nothing here knows one, and a list that had already gone stale. Both are derived now, and a test asserts no brand is named.testbed.shis deliberately NOT inBASE_MEMBERS: the zip is what goes to a pod, and a CPU testbed has no business there. -
2.5.14: the suite runs without a RunPod credential. The three
test_podio_fetch_*tests drivePodIO.fetchagainst a fake ssh and never reach the API, butPodIO(None, …)builds aRunPodProvider, whose constructor loads the key eagerly (Api(load_api_key())), andload_api_keyfalls back to~/.runpod/config.toml. On a maintainer’s machine that file exists, so the tests passed; anywhere without a RunPod account they raised before the first assertion. MEASURED 2026-09-19 on a GitHub Actions ubuntu-24.04 runner: 3 failed, 293 passed, 15 skipped — the first run of the new CI job, which is exactly the class of bug a gate that only ever runs on the author’s machine cannot find. The tests now put a placeholder key in their own environment. Nothing about auth is asserted by them, so nothing is being pretended; the credential is scaffolding for a constructor. -
2.5.13: a
models/symlink left pointing outside the volume is repointed at it._base_library_linkwrote$COMFY/models -> $BASE_LIBRARY/modelswhen a machine had a separate library mounted; that symlink lives ON THE STORE, so every later machine inherits it, and since 2.5.2BASE_LIBRARYis correctly ignored when the whole root IS the store, which means the function that created it returns at its first line and can never repair it. A second mount of the same store hid this until the release that stopped making one. MEASURED 2026-09-19 on a fresh boot: the link dangled,dfon it failed, the disk gate read 0.00 GB free on a store with 272 GB free and refused every install, and ComfyUI’s own models directory did not resolve. Repaired inbase_discover, beforeMis chosen, rather than in hygiene, because hygiene runs after the model step this breaks. -
2.5.12: the deletion is staged and reversible. Every guard before it is an INFERENCE about identity, and 2.5.11 fixed one that was wrong only after it had offered 63.52 GB of live models; an inference can be wrong again in a shape nobody has met, and the consequence is a model that exists nowhere while the ledger still calls it installed. So
base_prunenow RENAMES each candidate aside in its own directory (atomic, never across a filesystem), re-stats every destination the MODELS table declares, and removes nothing unless all of them are still present at their declared size. If one vanished or changed size, every rename is undone, the step fails and says which model it was. It needs to know nothing about mounts, inodes or symlinks to be safe, because it checks the thing that actually matters rather than inferring it. -
2.5.11: a file reached by two paths is not a duplicate of itself.
base_pruneoffered 63.52 GB of LIVE models for deletion on a three-machine store, measured 2026-09-19: the store was mounted at two points andComfyUI/modelssymlinked into the second, so every file was indexed under two path strings, while the predicate is basename plus size and the identity check compared strings thatcd && pwd -Phad only collapsed SYMLINKS in.rm -funlinks the one directory entry, so the model would have gone from both paths. Now a-eftest (dev:ino) against the destination and between candidates, so a second mountpoint or a hardlink can never reach the deletion list or inflate “reclaimable”; and underBASE_VOLUME_SHAREDthe duplicate sweep is skipped outright, because a run that cannot see the other machines’ ledgers cannot decide what is surplus on a store they share.BASE_YES=1removes the prompt rather than answering it, which is what made this a silent unattended deletion in a baked image rather than a footgun. -
2.5.10: the Mac gate works again. 2.5.7’s
test_unit_each_workflow_gets_its_own_local_portloaded_build/pod/podctl.pywith its own inline import instead of the_load()helper beside it, and that helper is where the “not a repo checkout” skip lives. The shipped zip contains no_build/, so the test raised FileNotFoundError in every run from the extracted zip, which is exactly the runpodctl installperforms before it uploads anything. Every session’s install stopped at the gate, for a reason that had nothing to do with what it was installing. Found while installing a package 2026-09-19. -
2.5.9: a video package’s last node no longer dies on the encoder it asked for. VideoHelperSuite prefers its own bundled imageio_ffmpeg binary, and that build carries NO nvenc encoders, so a format asking for
h264_nvencfailed atVHS_VideoCombinewith “Unknown encoder” AFTER the whole sample had been computed and paid for. Measured on a GPU on 2026-09-19, on a render that had already cleared its policy gate and produced frames. The Verda host now installsffmpegbesidenfs-commonandpython3-venv, and the launch line setsVHS_FORCE_FFMPEG_PATHwhen a system ffmpeg is present AND hash264_nvenc. The nvenc condition is the point: forcing a system build without it would trade one silently wrong encoder for another, and the bundled one at least works for the stock formats. Two tests, one per half. -
2.5.8: and so are the SETTINGS, which is the same bug one path away. 2.5.7 moved
workflows/to the directory the server reads and scoped its guard to that path, so an identical split incomfy.settings.jsonsurvived untouched. This package setspysssss.ImageFeed.Location=hiddenas hygiene, with the comment “it covers App Mode’s Run button”, and wrote it into ComfyUI’s own tree while the server read the machine’s. The setting never took effect, the image feed sat over the Run button, and every Playwright click landed on aDIV.pysssss-image-feed-menu: noPOST /promptwas ever made, so a whole GPU sweep reported “did not queue” for every case with no reason attached.force=Truedoes not help, because it skips the actionability check and still dispatches at the point where the overlay is. Measured 2026-09-19 withdocument.elementFromPointon the Run button’s own centre.lib/80-server.shandlib/70-hygiene.shnow ask_base_user_dir, and the static guard is widened fromworkflowsto the whole of$COMFY/userso the next instance cannot hide one path away again. -
2.5.7: the workflow is written where the SERVER actually reads it. 2.5.0 moved the server’s
--user-directorytoBASE_LOCAL_STATE(lib/70-hygiene.sh), because two machines sharing one store cannot share oneuser/, and left every WRITER pointing at$COMFY/user. So on a shared-root machine the installer put each package’s workflow in ComfyUI’s own tree, the server read the machine’s, andGET /api/userdata?dir=workflowsanswered[]. No package could be opened from the server’s own workflow list by name, which is exactly what an App Mode pass is required to do. Measured on three machines on 2026-09-18, one per brand, each holding workflows in a directory nothing served._base_user_dirand_base_workflows_dirnow answer it once andlib/60-sync.sh,lib/80-server.shandlib/90-ledger.shall ask, so the halves cannot drift apart again;lib/20-comfyui.sh’s scaffold still lays out ComfyUI’s ownuser/default/workflows, which should exist whether or not this machine serves from it. -
2.5.6: the terminal a customer needs, and four things that had to be right before it could exist. JupyterLab now GENERATES a token where
BASE_LISTENis loopback, so the machine is not dark until somebody invents a credential; on a public bind with no credential 2.1.0’s refusal is unchanged, because a generated token there is still a service on a proxy URL that nobody asked to start. The token is written owner-only tostate/tokens.envand is NEVER printed: the boot log is a file on the volume, and with a shared store every machine mounts it, so the rule that has forbidden logging a supplied token since 2.1.0 applies to a generated one too.podctl jupyter <machine>reads it over ssh and prints the URL. The IP in that hint is read from the machine’s own NIC (hostname -I); an earlier draft asked api.ipify.org, which the outbound-host allowlist caught — the base asks the network nothing about itself.--port LOCAL:REMOTEand a per-alias offset (tunnel_locals) because with one GPU per workflow every machine serves 8188 and one number can reach only one of them; a plain8188is unchanged._base_local_dirscreates the machine-local directories the launch line names, which is why ComfyUI refused to start on a shared root:--user-directorypointed at a path no machine had made yet. Andrecall_libraryno longer feeds the shared workspace back to itself as a separate library, which left a stale fstab line naming a mount that no longer exists. -
2.5.5: the GPU lease is the MACHINE’s, so it moved off the volume to
BASE_LOCAL_STATE. It was written to$BASE_VOLUME/comfy-base/state/gpu.lease, which is per machine only while the volume is. Under the shared root (2.5.0)/workspaceIS one store that every machine mounts, so that one file became a global mutex: three machines, three GPUs, one lease, andlease_blocksrefusing whoever asked second. That is the exact inverse of the shape’s own claim, which is that installs serialise on the mkdir lock and RUNNING does not, precisely so several GPUs can render at once. A pre-2.5.5 lease still on the volume is REPORTED bypodctl leaseand not obeyed, because obeying it would reinstate the mutex and it may belong to another machine entirely. The rule, and the one to apply to anything added here: anything about the MACHINE is per machine, anything about the WORK is shared. Deliberately not moved with it, after checking everyVOLUME_ROOT-derived path rather than the one that bit:pkgs_dir()andupload --toare the work’s;state/logs/is the machine’s but the SHELL side writes there too (lib/10-discover.sh:5,lib/boot.sh), so moving only the driver’s half would split an install’s logs across two directories, which is worse than one interleaved directory and wants a two-sided change.state/host.envis the machine’s too and is worse than stale: it PROPAGATES, because a machine that ensures correctly hasrecall_libraryread another machine’sBASE_LIBRARYback out of the shared file and mount the store twice. Measured on two machines 2026-09-18; it is owed its own release. -
2.5.4: a dry run is a report, not a gate, where a report is all it can honestly be.
--checkfailed anyLOCALmodel row whose file was not on disk, although aLOCALrow is placed by the package’s ownpkg_pre_models, which does nothing underBASE_DRY. So the file was absent BY DESIGN at check time and its absence carried no information, andpodctl install --pkgwas gated on a condition the gate itself created: every package with aLOCALrow stopped at “nothing was run” before it could place the file. BothLOCALbranches, the file one and the folder-snapshot one, now report the row as unjudgeable when the package declarespkg_pre_models, and are unchanged otherwise: with no such hook a missing file really is missing and saying so before the run is the useful answer. A real run still fails either way. Measured on a package whose engine is CivitAI only and has no Hub mirror. Four cases in one test hold it, including the positive control that aLOCALfile which IS present still reports ok, so the guard cannot quietly widen to every row. Also: aSUPERSEDEDentry the sweep can never match (a path, or a dotfile) is still skipped, because that sweep matches basenames only and a path pattern would widen it across a store several machines share, but it now says so once per pattern instead of silently doing nothing. -
2.5.3: the Verda host is no longer EXPERIMENTAL. The three questions its README asked are answered by live runs: the ssh user is root, the startup script does re-run on a redeploy and fstab survives on the OS volume, and
ippopulates within about 30 s. The driver no longer prints the warning on every command. -
2.5.2: proven on a live machine, and two things it found.
ensure --workspace-sharedcould not swap a mount under a running system, so the script reported success while the machine was still on its own disk; it now says REBOOT and fails the step, because a report line that lies is worse than an error. AndBASE_VOLUME_SHAREDwithBASE_LIBRARYare alternatives rather than layers: together they mount one store twice and the run warns about its own tree seen through the second path, so a shared root now ignores the library and says so. Measured with the whole root on the store: a coldimport torchcosts 9.28 s against 2.15 s local, warm 1.55 s. -
2.5.1:
_base_auth_fileread its own argument.local a="$1" b="...$a"expands every word beforelocalbinds any, so the second read the CALLER’s scope; it worked only because every in-tree caller heldnameset to the same token name, and a caller holding a different one sent a bearer token for the wrong name with no error. The library link also ignores everything ComfyUI itself ships,models/configs/*.yamlas well as the placeholders, and copies it onto the library rather than discarding it. -
2.5.0:
BASE_VOLUME_SHARED, the whole root on the shared store (§0.14), which is what makes a machine disposable rather than only its models shared.user/andtemp/move toBASE_LOCAL_STATE; installs take a mkdir lock on the store, honoured for two hours and then broken with the owner named; running is unlocked. On Verdapodctl ensure --workspace-sharedmounts the shared volume AS/workspace, so the machine needs no data volume andstartup.shnever hunts for one. -
2.4.0:
BASE_LIBRARY, a model library several machines mount at once (§0.13). Discovery prefers it overextra_model_paths.yaml, which is not yet written on a machine’s first install; staging is per machine so two installs cannot collide on one half-file; the disk gate measures the library rather than the volume. On Verda: a shared volume is resolved from the account and attached by type and location, never by name stem. -
2.3.0: the first public release. Models come from Hugging Face and GitHub only; model families are a brand’s list, not the base’s; the loader map covers ComfyUI’s core loaders and the shared packs’, packages extend it with
LOADER_CATS; the fake-pod layouts areofficial | community | bare | volume.