feat(isolation): process isolation for custom nodes via pyisolate

Adds opt-in process isolation for custom nodes using pyisolate's
bwrap sandbox and JSON-RPC bridge. Each isolated node pack runs in
its own child process with zero-copy tensor transfer via shared memory.

Core infrastructure:
- CLI flag --use-process-isolation to enable isolation
- Host/child startup fencing via PYISOLATE_CHILD env var
- Manifest-driven node discovery and extension loading
- JSON-RPC bridge between host and child processes
- Shared memory forensics for leak detection

Proxy layer:
- ModelPatcher, CLIP, VAE, and ModelSampling proxies
- Host service proxies (folder_paths, model_management, progress, etc.)
- Proxy base with automatic method forwarding

Execution integration:
- Extension wrapper with V3 hidden param mapping
- Runtime helpers for isolated node execution
- Host policy for node isolation decisions
- Fenced sampler device handling and model ejection parity

Serializers for cross-process data transfer:
- File3D (GLB), PLY (structured + gaussian), NPZ (streaming frames),
  VIDEO (VideoFromFile + VideoFromComponents) serializers
- data_type flag in SerializerRegistry for type-aware dispatch
- Isolated get_temp_directory() fence

New core save nodes:
- SavePLY and SaveNPZ with comfytype registrations (Ply, Npz)

DynamicVRAM compatibility:
- comfy-aimdo early init gated by isolation fence

Tests:
- Integration and policy tests for isolation lifecycle
- Manifest loader, host policy, proxy, and adapter unit tests

Depends on: pyisolate >= 0.9.2
This commit is contained in:
John Pollock
2026-03-12 01:13:43 -05:00
parent 9ce4c3dd87
commit c5e7b9cdaf
54 changed files with 9061 additions and 78 deletions

View File

@ -1925,6 +1925,7 @@ class ImageInvert:
class ImageBatch:
SEARCH_ALIASES = ["combine images", "merge images", "stack images"]
ESSENTIALS_CATEGORY = "Image Tools"
@classmethod
def INPUT_TYPES(s):
@ -2306,6 +2307,27 @@ async def init_external_custom_nodes():
Returns:
None
"""
whitelist = set()
isolated_module_paths = set()
if args.use_process_isolation:
from pathlib import Path
from comfy.isolation import await_isolation_loading, get_claimed_paths
from comfy.isolation.host_policy import load_host_policy
# Load Global Host Policy
host_policy = load_host_policy(Path(folder_paths.base_path))
whitelist_dict = host_policy.get("whitelist", {})
# Normalize whitelist keys to lowercase for case-insensitive matching
# (matches ComfyUI-Manager's normalization: project.name.strip().lower())
whitelist = set(k.strip().lower() for k in whitelist_dict.keys())
logging.info(f"][ Loaded Whitelist: {len(whitelist)} nodes allowed.")
isolated_specs = await await_isolation_loading()
for spec in isolated_specs:
NODE_CLASS_MAPPINGS.setdefault(spec.node_name, spec.stub_class)
NODE_DISPLAY_NAME_MAPPINGS.setdefault(spec.node_name, spec.display_name)
isolated_module_paths = get_claimed_paths()
base_node_names = set(NODE_CLASS_MAPPINGS.keys())
node_paths = folder_paths.get_folder_paths("custom_nodes")
node_import_times = []
@ -2329,6 +2351,16 @@ async def init_external_custom_nodes():
logging.info(f"Blocked by policy: {module_path}")
continue
if args.use_process_isolation:
if Path(module_path).resolve() in isolated_module_paths:
continue
# Tri-State Enforcement: If not Isolated (checked above), MUST be Whitelisted.
# Normalize to lowercase for case-insensitive matching (matches ComfyUI-Manager)
if possible_module.strip().lower() not in whitelist:
logging.warning(f"][ REJECTED: Node '{possible_module}' is blocked by security policy (not whitelisted/isolated).")
continue
time_before = time.perf_counter()
success = await load_custom_node(module_path, base_node_names, module_parent="custom_nodes")
node_import_times.append((time.perf_counter() - time_before, module_path, success))
@ -2343,6 +2375,14 @@ async def init_external_custom_nodes():
logging.info("{:6.1f} seconds{}: {}".format(n[0], import_message, n[1]))
logging.info("")
if args.use_process_isolation:
from comfy.isolation import isolated_node_timings
if isolated_node_timings:
logging.info("\nImport times for isolated custom nodes:")
for timing, path, count in sorted(isolated_node_timings):
logging.info("{:6.1f} seconds: {} ({})".format(timing, path, count))
logging.info("")
async def init_builtin_extra_nodes():
"""
Initializes the built-in extra nodes in ComfyUI.
@ -2415,6 +2455,8 @@ async def init_builtin_extra_nodes():
"nodes_wan.py",
"nodes_lotus.py",
"nodes_hunyuan3d.py",
"nodes_save_ply.py",
"nodes_save_npz.py",
"nodes_primitive.py",
"nodes_cfg.py",
"nodes_optimalsteps.py",
@ -2435,7 +2477,6 @@ async def init_builtin_extra_nodes():
"nodes_audio_encoder.py",
"nodes_rope.py",
"nodes_logic.py",
"nodes_resolution.py",
"nodes_nop.py",
"nodes_kandinsky5.py",
"nodes_wanmove.py",
@ -2443,7 +2484,6 @@ async def init_builtin_extra_nodes():
"nodes_zimage.py",
"nodes_glsl.py",
"nodes_lora_debug.py",
"nodes_textgen.py",
"nodes_color.py",
"nodes_toolkit.py",
"nodes_replacements.py",