diff --git a/CHANGELOG.md b/CHANGELOG.md index b86d5d6..649f56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.2] — 2026-05-06 + +This release fixes a critical loader bug from 0.1.1 plus 6 issues surfaced by an outside code review. + +### Fixed — Critical loader collision +- **`'nodes' is not a package` at ComfyUI startup.** v0.1.1 attempted to fix the import error by injecting our package directory into `sys.path`, but ComfyUI's core ships its own `nodes.py` module. Since `sys.modules['nodes']` was already populated with ComfyUI's `nodes.py` (a single-file module), our absolute imports like `from nodes.smoke import ...` resolved to that module and failed because it has no `.smoke` attribute. **Fix:** renamed internal packages from `nodes/` → `mp_nodes/` and `shared/` → `mp_shared/` so our path injection cannot shadow ComfyUI core. + +### Fixed — Security +- **`http_post` no longer reads unbounded responses into memory.** Previous version called `requests.post(...)` and checked `len(resp.content)` after the full body buffered. v0.1.2 streams with `iter_content` and enforces the 50 MB cap mid-stream, matching `http_get`. (DoS protection.) +- **AES key derivation switched from HKDF (no work factor) to PBKDF2-HMAC-SHA256 with 600,000 iterations and a random per-encryption salt.** Ciphertext format is now base64(salt[16] ‖ nonce[12] ‖ tag+ct). Old v0.1.x ciphertexts are not decryptable by 0.1.2+. +- **`fs_mkdir`, `fs_copy`, `fs_move`, `fs_delete` now reject paths outside the allow-list** (ComfyUI input/output/temp + user home). Override with `UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS=1` if needed. Closes a host-wide read/write/delete vulnerability when ComfyUI is exposed over a network. + +### Fixed — UX / correctness +- **Settings dropdowns** for "Pack default theme" and "Global theme" now declare `options` so ComfyUI renders the actual list instead of an empty combo. +- **LATENT default pad changed from a hardcoded (1, 4, 8, 8) zero tensor to `{}` empty dict.** SD3 / Flux use 16-channel latents; the old default caused cryptic shape mismatches downstream. Now downstream nodes see a clean missing-key error. +- **`@op` decorator now rejects widget names `mode` and `theme` at registration time.** These collided with framework kwargs and surfaced as cryptic runtime "multiple values for argument" errors only when the op was first invoked. +- **`_pad_outputs` docstring** updated to match the relaxed opaque-type behavior introduced in 0.1.1. + ## [0.1.1] — 2026-05-06 ### Fixed -- ComfyUI loader compatibility: inject the package directory into `sys.path` at import time so `from nodes._base import ...` and `from shared.conversions import ...` resolve under ComfyUI's `spec_from_file_location` loader (was failing with `ModuleNotFoundError: No module named 'nodes'` at startup). +- ComfyUI loader compatibility: inject the package directory into `sys.path` at import time so `from nodes._base import ...` and `from shared.conversions import ...` resolve under ComfyUI's `spec_from_file_location` loader. **(Insufficient — superseded by the package rename in 0.1.2; see above.)** ## [0.1.0] — 2026-05-06 diff --git a/__init__.py b/__init__.py index 51568b7..ccf570d 100644 --- a/__init__.py +++ b/__init__.py @@ -4,24 +4,24 @@ ComfyUI imports this module and reads NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS, and WEB_DIRECTORY. """ -# ComfyUI loads custom nodes via spec_from_file_location, which does NOT add the -# package's own directory to sys.path. Without this our absolute imports -# (`from nodes._base import ...`, `from shared.conversions import ...`) fail with -# ModuleNotFoundError. Inject the package directory once at import time. +# ComfyUI loads custom nodes via spec_from_file_location, which does NOT add +# the package's own directory to sys.path. Inject the package directory once at +# import time. We use `mp_*` prefixed package names so this injection cannot +# shadow ComfyUI core's `nodes` module (which is what broke v0.1.1). import os as _os import sys as _sys _HERE = _os.path.dirname(_os.path.abspath(__file__)) if _HERE not in _sys.path: _sys.path.insert(0, _HERE) -from nodes.smoke import SmokeTestNode -from nodes.programming import ProgrammingNode -from nodes.prompt import PromptNode -from nodes.image_pro import ImageProNode -from nodes.mask_latent import MaskLatentNode -from nodes.io_workflow import IOWorkflowNode -from nodes.convenience import ConvenienceNode -from nodes.models_sampling import ModelsSamplingNode +from mp_nodes.smoke import SmokeTestNode +from mp_nodes.programming import ProgrammingNode +from mp_nodes.prompt import PromptNode +from mp_nodes.image_pro import ImageProNode +from mp_nodes.mask_latent import MaskLatentNode +from mp_nodes.io_workflow import IOWorkflowNode +from mp_nodes.convenience import ConvenienceNode +from mp_nodes.models_sampling import ModelsSamplingNode NODE_CLASS_MAPPINGS: dict = { "UtilMegaPack_SmokeTest": SmokeTestNode, diff --git a/nodes/__init__.py b/mp_nodes/__init__.py similarity index 100% rename from nodes/__init__.py rename to mp_nodes/__init__.py diff --git a/nodes/_base.py b/mp_nodes/_base.py similarity index 84% rename from nodes/_base.py rename to mp_nodes/_base.py index 371403c..383492e 100644 --- a/nodes/_base.py +++ b/mp_nodes/_base.py @@ -55,6 +55,18 @@ class OpRegistry: raise ValueError( f"duplicate op_id '{op_id}' (already registered as '{self.ops[op_id].display_name}')" ) + # Reserved widget names — collide with `process(mode=..., theme=...)` + # framework kwargs and surface as cryptic "multiple values for + # argument 'mode'" runtime errors (Gemini review #8). Catch at + # registration time so op authors get a clear failure mode. + for section in ("required", "optional"): + for reserved in ("mode", "theme"): + if reserved in input_schema.get(section, {}): + raise ValueError( + f"op '{op_id}': widget name '{reserved}' is reserved " + f"(collides with the framework's `mode`/`theme` kwargs). " + f"Pick a different widget name (e.g. '{reserved}_kind')." + ) self.ops[op_id] = OpDef( op_id=op_id, display_name=display_name, @@ -126,9 +138,11 @@ def _default_for_type(type_name: str, op_id: str): return torch.zeros((1, 1, 1)) if type_name == "LATENT": - import torch - - return {"samples": torch.zeros((1, 4, 8, 8))} + # Empty dict, NOT a hardcoded 4-channel zero latent. Flux/SD3/etc. + # use 16 channels, so the old default would cause cryptic shape + # mismatches downstream (Gemini review #6). Downstream nodes that + # need samples will raise a clear "missing 'samples'" error instead. + return {} return None @@ -143,7 +157,9 @@ def _pad_outputs( Filled slots come from `op_result` in the order given by `output_indices`. Unfilled slots get type-appropriate defaults from `_default_for_type`. - Opaque types (MODEL/CLIP/VAE) raise OutputNotProducedError if unfilled. + Opaque types (MODEL/CLIP/VAE) pad to None — ComfyUI's runtime then surfaces + a clear error at the wire level if a downstream node tries to use None. + LATENT pads to {} for the same reason (avoid wrong-channel-count crashes). """ for idx in output_indices: if idx >= len(return_types): diff --git a/nodes/convenience/__init__.py b/mp_nodes/convenience/__init__.py similarity index 69% rename from nodes/convenience/__init__.py rename to mp_nodes/convenience/__init__.py index ba5f9d9..f1071ac 100644 --- a/nodes/convenience/__init__.py +++ b/mp_nodes/convenience/__init__.py @@ -1,11 +1,11 @@ """ConvenienceNode — reroute, switch, gate, counter, timer, debug, presets, compare.""" -from nodes._base import ANY, MegaPackNodeBase, OpRegistry +from mp_nodes._base import ANY, MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register -from nodes.convenience.operations import convenience # noqa: E402, F401 +from mp_nodes.convenience.operations import convenience # noqa: E402, F401 class ConvenienceNode(MegaPackNodeBase): diff --git a/nodes/convenience/operations/__init__.py b/mp_nodes/convenience/operations/__init__.py similarity index 100% rename from nodes/convenience/operations/__init__.py rename to mp_nodes/convenience/operations/__init__.py diff --git a/nodes/convenience/operations/convenience.py b/mp_nodes/convenience/operations/convenience.py similarity index 99% rename from nodes/convenience/operations/convenience.py rename to mp_nodes/convenience/operations/convenience.py index ee7665c..7ff12ae 100644 --- a/nodes/convenience/operations/convenience.py +++ b/mp_nodes/convenience/operations/convenience.py @@ -7,7 +7,7 @@ import time import torch -from nodes._base import ANY +from mp_nodes._base import ANY from .. import op diff --git a/nodes/image_pro/__init__.py b/mp_nodes/image_pro/__init__.py similarity index 76% rename from nodes/image_pro/__init__.py rename to mp_nodes/image_pro/__init__.py index e5b79de..1ff5c2a 100644 --- a/nodes/image_pro/__init__.py +++ b/mp_nodes/image_pro/__init__.py @@ -1,11 +1,11 @@ """ImageProNode — image loaders + modifications + inspectors.""" -from nodes._base import MegaPackNodeBase, OpRegistry +from mp_nodes._base import MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register -from nodes.image_pro.operations import ( # noqa: E402, F401 +from mp_nodes.image_pro.operations import ( # noqa: E402, F401 load, value_ops, spatial_ops, diff --git a/nodes/image_pro/operations/__init__.py b/mp_nodes/image_pro/operations/__init__.py similarity index 100% rename from nodes/image_pro/operations/__init__.py rename to mp_nodes/image_pro/operations/__init__.py diff --git a/nodes/image_pro/operations/inspect.py b/mp_nodes/image_pro/operations/inspect.py similarity index 98% rename from nodes/image_pro/operations/inspect.py rename to mp_nodes/image_pro/operations/inspect.py index 2a0872b..2992977 100644 --- a/nodes/image_pro/operations/inspect.py +++ b/mp_nodes/image_pro/operations/inspect.py @@ -4,7 +4,7 @@ import json import torch -from shared.conversions import pil_to_tensor, tensor_to_pil +from mp_shared.conversions import pil_to_tensor, tensor_to_pil from .. import op diff --git a/nodes/image_pro/operations/load.py b/mp_nodes/image_pro/operations/load.py similarity index 99% rename from nodes/image_pro/operations/load.py rename to mp_nodes/image_pro/operations/load.py index 7858865..b968f18 100644 --- a/nodes/image_pro/operations/load.py +++ b/mp_nodes/image_pro/operations/load.py @@ -7,7 +7,7 @@ import random from PIL import Image, ExifTags -from shared.conversions import pil_to_tensor +from mp_shared.conversions import pil_to_tensor from .. import op diff --git a/nodes/image_pro/operations/spatial_ops.py b/mp_nodes/image_pro/operations/spatial_ops.py similarity index 98% rename from nodes/image_pro/operations/spatial_ops.py rename to mp_nodes/image_pro/operations/spatial_ops.py index 785c699..97a508a 100644 --- a/nodes/image_pro/operations/spatial_ops.py +++ b/mp_nodes/image_pro/operations/spatial_ops.py @@ -2,7 +2,7 @@ from PIL import Image, ImageFilter -from shared.conversions import pil_to_tensor, tensor_to_pil +from mp_shared.conversions import pil_to_tensor, tensor_to_pil from .. import op diff --git a/nodes/image_pro/operations/style_ops.py b/mp_nodes/image_pro/operations/style_ops.py similarity index 98% rename from nodes/image_pro/operations/style_ops.py rename to mp_nodes/image_pro/operations/style_ops.py index d8f688f..67f10bb 100644 --- a/nodes/image_pro/operations/style_ops.py +++ b/mp_nodes/image_pro/operations/style_ops.py @@ -6,7 +6,7 @@ import random as _random import torch from PIL import Image -from shared.conversions import pil_to_tensor, tensor_to_pil +from mp_shared.conversions import pil_to_tensor, tensor_to_pil from .. import op diff --git a/nodes/image_pro/operations/value_ops.py b/mp_nodes/image_pro/operations/value_ops.py similarity index 98% rename from nodes/image_pro/operations/value_ops.py rename to mp_nodes/image_pro/operations/value_ops.py index 4211ada..64b0f3a 100644 --- a/nodes/image_pro/operations/value_ops.py +++ b/mp_nodes/image_pro/operations/value_ops.py @@ -2,7 +2,7 @@ from PIL import Image, ImageOps -from shared.conversions import pil_to_tensor, tensor_to_pil +from mp_shared.conversions import pil_to_tensor, tensor_to_pil from .. import op diff --git a/nodes/io_workflow/__init__.py b/mp_nodes/io_workflow/__init__.py similarity index 75% rename from nodes/io_workflow/__init__.py rename to mp_nodes/io_workflow/__init__.py index 019021a..1c92894 100644 --- a/nodes/io_workflow/__init__.py +++ b/mp_nodes/io_workflow/__init__.py @@ -1,11 +1,11 @@ """IOWorkflowNode — filesystem, HTTP, save-with-manifest, sweeps, monitors.""" -from nodes._base import MegaPackNodeBase, OpRegistry +from mp_nodes._base import MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register -from nodes.io_workflow.operations import ( # noqa: E402, F401 +from mp_nodes.io_workflow.operations import ( # noqa: E402, F401 filesystem, network, workflow, diff --git a/nodes/io_workflow/operations/__init__.py b/mp_nodes/io_workflow/operations/__init__.py similarity index 100% rename from nodes/io_workflow/operations/__init__.py rename to mp_nodes/io_workflow/operations/__init__.py diff --git a/nodes/io_workflow/operations/filesystem.py b/mp_nodes/io_workflow/operations/filesystem.py similarity index 74% rename from nodes/io_workflow/operations/filesystem.py rename to mp_nodes/io_workflow/operations/filesystem.py index b7d360c..3051174 100644 --- a/nodes/io_workflow/operations/filesystem.py +++ b/mp_nodes/io_workflow/operations/filesystem.py @@ -8,6 +8,52 @@ import shutil from .. import op +# Path confinement for write/delete ops. Gemini review #7: previous version +# accepted any absolute path → host-wide read/write if ComfyUI was exposed. +# Default allow-list: ComfyUI input/output/temp + user home. Override with +# the env var UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS=1 for power users. +def _confined_roots() -> list[str]: + roots: list[str] = [] + try: + import folder_paths # type: ignore + roots.extend([ + folder_paths.get_input_directory(), + folder_paths.get_output_directory(), + folder_paths.get_temp_directory(), + ]) + except Exception: + # Fallback for non-ComfyUI environments (tests, doc gen) + roots.extend([ + os.path.abspath("input"), + os.path.abspath("output"), + os.path.abspath("temp"), + ]) + roots.append(os.path.expanduser("~")) + return [os.path.abspath(r) for r in roots if r] + + +def _path_is_confined(target: str) -> bool: + abs_target = os.path.abspath(target) + for root in _confined_roots(): + try: + if os.path.commonpath([abs_target, root]) == root: + return True + except ValueError: + continue + return False + + +def _require_confined(path: str) -> None: + if os.environ.get("UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS") == "1": + return + if not _path_is_confined(path): + raise PermissionError( + f"path {path!r} is outside the allow-listed roots " + f"(ComfyUI input/output/temp, user home). " + f"Set UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS=1 to override (use with care)." + ) + + @op( op_id="fs_mkdir", display_name="FS Mkdir", @@ -22,6 +68,7 @@ from .. import op def fs_mkdir(self, path, exist_ok=True): if not path: raise ValueError("path is required") + _require_confined(path) os.makedirs(path, exist_ok=bool(exist_ok)) return (path,) @@ -75,6 +122,8 @@ def fs_glob(self, pattern, recursive=False): def fs_copy(self, src, dst, overwrite=False): if not os.path.isfile(src): raise FileNotFoundError(f"source file not found: {src!r}") + _require_confined(src) + _require_confined(dst) if os.path.exists(dst) and not overwrite: raise FileExistsError(f"destination exists and overwrite=False: {dst!r}") os.makedirs(os.path.dirname(dst) or ".", exist_ok=True) @@ -96,6 +145,8 @@ def fs_copy(self, src, dst, overwrite=False): def fs_move(self, src, dst, overwrite=False): if not os.path.isfile(src): raise FileNotFoundError(f"source file not found: {src!r}") + _require_confined(src) + _require_confined(dst) if os.path.exists(dst) and not overwrite: raise FileExistsError(f"destination exists and overwrite=False: {dst!r}") os.makedirs(os.path.dirname(dst) or ".", exist_ok=True) @@ -117,6 +168,7 @@ def fs_move(self, src, dst, overwrite=False): description="Delete a file (not a directory). Use with care.", ) def fs_delete(self, path, missing_ok=False): + _require_confined(path) if not os.path.exists(path): if missing_ok: return (path,) diff --git a/nodes/io_workflow/operations/network.py b/mp_nodes/io_workflow/operations/network.py similarity index 82% rename from nodes/io_workflow/operations/network.py rename to mp_nodes/io_workflow/operations/network.py index 17db705..a471f6c 100644 --- a/nodes/io_workflow/operations/network.py +++ b/mp_nodes/io_workflow/operations/network.py @@ -60,10 +60,16 @@ def http_post(self, url, body_json="{}", timeout_seconds=_DEFAULT_TIMEOUT, heade raise ValueError("url is required") headers = _json.loads(headers_json) if headers_json else {} body = _json.loads(body_json) if body_json else {} - resp = requests.post(url, json=body, headers=headers, timeout=int(timeout_seconds), allow_redirects=True) - if len(resp.content) > _MAX_RESPONSE_BYTES: - raise ValueError(f"response exceeded {_MAX_RESPONSE_BYTES} bytes") - text = resp.text + # stream=True + chunked accumulation so we enforce the size cap BEFORE the + # full response buffers into memory (Gemini review #2: DoS protection). + resp = requests.post(url, json=body, headers=headers, timeout=int(timeout_seconds), + stream=True, allow_redirects=True) + content = b"" + for chunk in resp.iter_content(chunk_size=64 * 1024): + content += chunk + if len(content) > _MAX_RESPONSE_BYTES: + raise ValueError(f"response exceeded {_MAX_RESPONSE_BYTES} bytes") + text = content.decode(resp.encoding or "utf-8", errors="replace") try: parsed = _json.loads(text) if text else {} if not isinstance(parsed, dict): diff --git a/nodes/io_workflow/operations/system.py b/mp_nodes/io_workflow/operations/system.py similarity index 100% rename from nodes/io_workflow/operations/system.py rename to mp_nodes/io_workflow/operations/system.py diff --git a/nodes/io_workflow/operations/workflow.py b/mp_nodes/io_workflow/operations/workflow.py similarity index 99% rename from nodes/io_workflow/operations/workflow.py rename to mp_nodes/io_workflow/operations/workflow.py index 7febcd0..1bc28ce 100644 --- a/nodes/io_workflow/operations/workflow.py +++ b/mp_nodes/io_workflow/operations/workflow.py @@ -13,7 +13,7 @@ from PIL import Image import requests -from shared.conversions import tensor_to_pil +from mp_shared.conversions import tensor_to_pil from .. import op diff --git a/nodes/mask_latent/__init__.py b/mp_nodes/mask_latent/__init__.py similarity index 64% rename from nodes/mask_latent/__init__.py rename to mp_nodes/mask_latent/__init__.py index d308126..354b611 100644 --- a/nodes/mask_latent/__init__.py +++ b/mp_nodes/mask_latent/__init__.py @@ -1,11 +1,11 @@ """MaskLatentNode — mask + latent operations.""" -from nodes._base import MegaPackNodeBase, OpRegistry +from mp_nodes._base import MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register -from nodes.mask_latent.operations import mask_ops, latent_ops # noqa: E402, F401 +from mp_nodes.mask_latent.operations import mask_ops, latent_ops # noqa: E402, F401 class MaskLatentNode(MegaPackNodeBase): diff --git a/nodes/mask_latent/operations/__init__.py b/mp_nodes/mask_latent/operations/__init__.py similarity index 100% rename from nodes/mask_latent/operations/__init__.py rename to mp_nodes/mask_latent/operations/__init__.py diff --git a/nodes/mask_latent/operations/latent_ops.py b/mp_nodes/mask_latent/operations/latent_ops.py similarity index 100% rename from nodes/mask_latent/operations/latent_ops.py rename to mp_nodes/mask_latent/operations/latent_ops.py diff --git a/nodes/mask_latent/operations/mask_ops.py b/mp_nodes/mask_latent/operations/mask_ops.py similarity index 98% rename from nodes/mask_latent/operations/mask_ops.py rename to mp_nodes/mask_latent/operations/mask_ops.py index cb0efae..7387254 100644 --- a/nodes/mask_latent/operations/mask_ops.py +++ b/mp_nodes/mask_latent/operations/mask_ops.py @@ -4,7 +4,7 @@ import torch import torch.nn.functional as F from PIL import ImageFilter -from shared.conversions import pil_to_tensor, tensor_to_pil +from mp_shared.conversions import pil_to_tensor, tensor_to_pil from .. import op diff --git a/nodes/models_sampling/__init__.py b/mp_nodes/models_sampling/__init__.py similarity index 77% rename from nodes/models_sampling/__init__.py rename to mp_nodes/models_sampling/__init__.py index 2c5fb76..e9108af 100644 --- a/nodes/models_sampling/__init__.py +++ b/mp_nodes/models_sampling/__init__.py @@ -1,11 +1,11 @@ """ModelsSamplingNode — model intel, resolution helpers, seed/sampler ops.""" -from nodes._base import MegaPackNodeBase, OpRegistry +from mp_nodes._base import MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register -from nodes.models_sampling.operations import ( # noqa: E402, F401 +from mp_nodes.models_sampling.operations import ( # noqa: E402, F401 model_intel, resolution, seed_sampling, diff --git a/nodes/models_sampling/operations/__init__.py b/mp_nodes/models_sampling/operations/__init__.py similarity index 100% rename from nodes/models_sampling/operations/__init__.py rename to mp_nodes/models_sampling/operations/__init__.py diff --git a/nodes/models_sampling/operations/model_intel.py b/mp_nodes/models_sampling/operations/model_intel.py similarity index 100% rename from nodes/models_sampling/operations/model_intel.py rename to mp_nodes/models_sampling/operations/model_intel.py diff --git a/nodes/models_sampling/operations/resolution.py b/mp_nodes/models_sampling/operations/resolution.py similarity index 100% rename from nodes/models_sampling/operations/resolution.py rename to mp_nodes/models_sampling/operations/resolution.py diff --git a/nodes/models_sampling/operations/seed_sampling.py b/mp_nodes/models_sampling/operations/seed_sampling.py similarity index 100% rename from nodes/models_sampling/operations/seed_sampling.py rename to mp_nodes/models_sampling/operations/seed_sampling.py diff --git a/nodes/programming/__init__.py b/mp_nodes/programming/__init__.py similarity index 78% rename from nodes/programming/__init__.py rename to mp_nodes/programming/__init__.py index 93a418b..660d5eb 100644 --- a/nodes/programming/__init__.py +++ b/mp_nodes/programming/__init__.py @@ -1,11 +1,11 @@ """ProgrammingNode — abstract programming primitives organized by category.""" -from nodes._base import ANY, MegaPackNodeBase, OpRegistry +from mp_nodes._base import ANY, MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register -from nodes.programming.operations import ( # noqa: E402, F401 +from mp_nodes.programming.operations import ( # noqa: E402, F401 encoding, logic_bits, math_signals, diff --git a/nodes/programming/operations/__init__.py b/mp_nodes/programming/operations/__init__.py similarity index 100% rename from nodes/programming/operations/__init__.py rename to mp_nodes/programming/operations/__init__.py diff --git a/nodes/programming/operations/control_flow.py b/mp_nodes/programming/operations/control_flow.py similarity index 100% rename from nodes/programming/operations/control_flow.py rename to mp_nodes/programming/operations/control_flow.py diff --git a/nodes/programming/operations/crypto.py b/mp_nodes/programming/operations/crypto.py similarity index 68% rename from nodes/programming/operations/crypto.py rename to mp_nodes/programming/operations/crypto.py index a49d44a..288ddbc 100644 --- a/nodes/programming/operations/crypto.py +++ b/mp_nodes/programming/operations/crypto.py @@ -7,22 +7,29 @@ import os from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from .. import op -_HKDF_SALT = b"utility-megapack/aes-gcm/v1" +# PBKDF2 work factor — 600k iterations is the OWASP 2023 recommendation for +# SHA-256-based password derivation. Increases the cost of a dictionary +# attack against user-entered passwords substantially. +# v0.1.2 (Gemini review #5) replaces the old HKDF-with-static-salt approach, +# which had no work factor and was vulnerable to brute-force on weak keys. +_PBKDF2_ITERATIONS = 600_000 +_SALT_BYTES = 16 -def _derive_aes_key(key_str: str) -> bytes: - """Derive a 32-byte AES key from an arbitrary-length string via HKDF.""" - return HKDF( +def _derive_aes_key(key_str: str, salt: bytes) -> bytes: + """Derive a 32-byte AES key from an arbitrary-length string via PBKDF2-HMAC-SHA256.""" + kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, - salt=_HKDF_SALT, - info=b"aes-gcm key", - ).derive(key_str.encode("utf-8")) + salt=salt, + iterations=_PBKDF2_ITERATIONS, + ) + return kdf.derive(key_str.encode("utf-8")) def _hash_hex(text: str, name: str) -> str: @@ -117,13 +124,18 @@ def hmac_verify(self, text, key, expected_hex): "key": ("STRING", {"default": ""}), }}, output_indices=(0,), - description="AES-256-GCM. Returns base64(nonce[12] || ciphertext_with_tag).", + description=( + "AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (600k iters). " + "Returns base64(salt[16] || nonce[12] || ciphertext_with_tag). " + "NOTE: ciphertexts from v0.1.0/0.1.1 (HKDF-based) cannot be decrypted by v0.1.2+." + ), ) def aes_encrypt(self, plaintext, key): - aes = AESGCM(_derive_aes_key(key)) + salt = os.urandom(_SALT_BYTES) + aes = AESGCM(_derive_aes_key(key, salt)) nonce = os.urandom(12) ct = aes.encrypt(nonce, plaintext.encode("utf-8"), None) - return (base64.b64encode(nonce + ct).decode("ascii"),) + return (base64.b64encode(salt + nonce + ct).decode("ascii"),) @op( @@ -135,12 +147,17 @@ def aes_encrypt(self, plaintext, key): "key": ("STRING", {"default": ""}), }}, output_indices=(0,), - description="Inverse of aes_encrypt. Raises on tag mismatch.", + description="Inverse of aes_encrypt. Raises on tag mismatch or malformed input.", ) def aes_decrypt(self, ciphertext_b64, key): blob = base64.b64decode(ciphertext_b64) - if len(blob) < 12 + 16: - raise ValueError("ciphertext too short for AES-GCM") - nonce, ct = blob[:12], blob[12:] - aes = AESGCM(_derive_aes_key(key)) + min_len = _SALT_BYTES + 12 + 16 # salt + nonce + GCM tag minimum + if len(blob) < min_len: + raise ValueError( + f"ciphertext too short for AES-GCM with PBKDF2 (need >= {min_len} bytes, got {len(blob)})" + ) + salt = blob[:_SALT_BYTES] + nonce = blob[_SALT_BYTES:_SALT_BYTES + 12] + ct = blob[_SALT_BYTES + 12:] + aes = AESGCM(_derive_aes_key(key, salt)) return (aes.decrypt(nonce, ct, None).decode("utf-8"),) diff --git a/nodes/programming/operations/data_struct.py b/mp_nodes/programming/operations/data_struct.py similarity index 100% rename from nodes/programming/operations/data_struct.py rename to mp_nodes/programming/operations/data_struct.py diff --git a/nodes/programming/operations/encoding.py b/mp_nodes/programming/operations/encoding.py similarity index 100% rename from nodes/programming/operations/encoding.py rename to mp_nodes/programming/operations/encoding.py diff --git a/nodes/programming/operations/logic_bits.py b/mp_nodes/programming/operations/logic_bits.py similarity index 100% rename from nodes/programming/operations/logic_bits.py rename to mp_nodes/programming/operations/logic_bits.py diff --git a/nodes/programming/operations/math_signals.py b/mp_nodes/programming/operations/math_signals.py similarity index 100% rename from nodes/programming/operations/math_signals.py rename to mp_nodes/programming/operations/math_signals.py diff --git a/nodes/programming/operations/text.py b/mp_nodes/programming/operations/text.py similarity index 100% rename from nodes/programming/operations/text.py rename to mp_nodes/programming/operations/text.py diff --git a/nodes/prompt/__init__.py b/mp_nodes/prompt/__init__.py similarity index 70% rename from nodes/prompt/__init__.py rename to mp_nodes/prompt/__init__.py index 47760f7..4e5e653 100644 --- a/nodes/prompt/__init__.py +++ b/mp_nodes/prompt/__init__.py @@ -1,11 +1,11 @@ """PromptNode — prompt-engineering operations.""" -from nodes._base import MegaPackNodeBase, OpRegistry +from mp_nodes._base import MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register -from nodes.prompt.operations import prompt # noqa: E402, F401 +from mp_nodes.prompt.operations import prompt # noqa: E402, F401 class PromptNode(MegaPackNodeBase): diff --git a/nodes/prompt/operations/__init__.py b/mp_nodes/prompt/operations/__init__.py similarity index 100% rename from nodes/prompt/operations/__init__.py rename to mp_nodes/prompt/operations/__init__.py diff --git a/nodes/prompt/operations/prompt.py b/mp_nodes/prompt/operations/prompt.py similarity index 100% rename from nodes/prompt/operations/prompt.py rename to mp_nodes/prompt/operations/prompt.py diff --git a/nodes/smoke/__init__.py b/mp_nodes/smoke/__init__.py similarity index 79% rename from nodes/smoke/__init__.py rename to mp_nodes/smoke/__init__.py index 1d5277e..83cdf46 100644 --- a/nodes/smoke/__init__.py +++ b/mp_nodes/smoke/__init__.py @@ -1,6 +1,6 @@ """Smoke node — exists only to verify the registration pipeline end-to-end.""" -from nodes._base import MegaPackNodeBase, OpRegistry +from mp_nodes._base import MegaPackNodeBase, OpRegistry REGISTRY = OpRegistry() op = REGISTRY.register @@ -8,7 +8,7 @@ op = REGISTRY.register # Trigger op registration. `op` must be bound before this import — Python # caches the partially-initialized module so `from nodes.smoke import op` # inside echo.py finds the already-defined name. -from nodes.smoke.operations import echo # noqa: E402, F401 +from mp_nodes.smoke.operations import echo # noqa: E402, F401 class SmokeTestNode(MegaPackNodeBase): diff --git a/nodes/smoke/operations/__init__.py b/mp_nodes/smoke/operations/__init__.py similarity index 100% rename from nodes/smoke/operations/__init__.py rename to mp_nodes/smoke/operations/__init__.py diff --git a/nodes/smoke/operations/echo.py b/mp_nodes/smoke/operations/echo.py similarity index 100% rename from nodes/smoke/operations/echo.py rename to mp_nodes/smoke/operations/echo.py diff --git a/shared/__init__.py b/mp_shared/__init__.py similarity index 100% rename from shared/__init__.py rename to mp_shared/__init__.py diff --git a/shared/conversions.py b/mp_shared/conversions.py similarity index 100% rename from shared/conversions.py rename to mp_shared/conversions.py diff --git a/node.zip b/node.zip new file mode 100644 index 0000000..78e3de9 Binary files /dev/null and b/node.zip differ diff --git a/pyproject.toml b/pyproject.toml index a8f5a32..364b47f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "comfyui-utility-megapack" -version = "0.1.1" +version = "0.1.2" description = "Comprehensive ComfyUI utility node pack with 156 operations across 7 nodes and 11 swappable visual themes." readme = "README.md" requires-python = ">=3.10" @@ -40,7 +40,7 @@ Changelog = "https://github.com/IxMxAMAR/ComfyUI-Utility-MegaPack/blob/main/CHAN [tool.setuptools.packages.find] where = ["."] -include = ["nodes*", "shared*"] +include = ["mp_nodes*", "mp_shared*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/unit/test_base_anytype.py b/tests/unit/test_base_anytype.py index d652dc1..c700cb7 100644 --- a/tests/unit/test_base_anytype.py +++ b/tests/unit/test_base_anytype.py @@ -1,6 +1,6 @@ """Tests for the AnyType wildcard used to mark wildcard ComfyUI sockets.""" -from nodes._base import ANY, AnyType +from mp_nodes._base import ANY, AnyType def test_anytype_is_string_subclass(): diff --git a/tests/unit/test_base_errors.py b/tests/unit/test_base_errors.py index 8022174..14165f7 100644 --- a/tests/unit/test_base_errors.py +++ b/tests/unit/test_base_errors.py @@ -1,6 +1,6 @@ """Tests for OutputNotProducedError — raised when opaque-type slots are wired but unfilled.""" -from nodes._base import OutputNotProducedError +from mp_nodes._base import OutputNotProducedError def test_error_is_runtime_error_subclass(): diff --git a/tests/unit/test_base_input_types.py b/tests/unit/test_base_input_types.py index 5e64ccf..fad788d 100644 --- a/tests/unit/test_base_input_types.py +++ b/tests/unit/test_base_input_types.py @@ -1,6 +1,6 @@ """Tests for MegaPackNodeBase.INPUT_TYPES — the union builder.""" -from nodes._base import MegaPackNodeBase, OpRegistry, THEME_CHOICES +from mp_nodes._base import MegaPackNodeBase, OpRegistry, THEME_CHOICES def _make_two_op_registry(): diff --git a/tests/unit/test_base_opdef.py b/tests/unit/test_base_opdef.py index dc406f4..c3a4d98 100644 --- a/tests/unit/test_base_opdef.py +++ b/tests/unit/test_base_opdef.py @@ -1,6 +1,6 @@ """Tests for OpDef — the dataclass that describes one registered operation.""" -from nodes._base import OpDef +from mp_nodes._base import OpDef def _noop(self): diff --git a/tests/unit/test_base_pad_outputs.py b/tests/unit/test_base_pad_outputs.py index d165827..72a5680 100644 --- a/tests/unit/test_base_pad_outputs.py +++ b/tests/unit/test_base_pad_outputs.py @@ -3,7 +3,7 @@ import pytest import torch -from nodes._base import ANY, _pad_outputs +from mp_nodes._base import ANY, _pad_outputs def test_filled_indices_use_provided_values(): @@ -100,16 +100,18 @@ def test_unfilled_mask_defaults_to_1x1_zero_tensor(): assert torch.all(result[0] == 0) -def test_unfilled_latent_defaults_to_empty_4_8_8(): +def test_unfilled_latent_defaults_to_empty_dict(): + """LATENT pads to {} (no 'samples') so downstream sees a clean 'missing samples' error. + + The old hardcoded (1, 4, 8, 8) default broke 16-channel models (Flux/SD3). + """ result = _pad_outputs( op_result=(), output_indices=(), return_types=("LATENT",), op_id="x", ) - assert isinstance(result[0], dict) - assert "samples" in result[0] - assert result[0]["samples"].shape == (1, 4, 8, 8) + assert result[0] == {} def test_unfilled_wildcard_defaults_to_none(): diff --git a/tests/unit/test_base_process.py b/tests/unit/test_base_process.py index 5d2c894..e23dfe8 100644 --- a/tests/unit/test_base_process.py +++ b/tests/unit/test_base_process.py @@ -2,7 +2,7 @@ import pytest -from nodes._base import MegaPackNodeBase, OpRegistry +from mp_nodes._base import MegaPackNodeBase, OpRegistry def _make_one_op_node(): diff --git a/tests/unit/test_base_registry.py b/tests/unit/test_base_registry.py index 68ce6e3..adbec3e 100644 --- a/tests/unit/test_base_registry.py +++ b/tests/unit/test_base_registry.py @@ -2,7 +2,7 @@ import pytest -from nodes._base import OpRegistry +from mp_nodes._base import OpRegistry def test_register_stores_op_by_id(): diff --git a/tests/unit/test_base_theme_choices.py b/tests/unit/test_base_theme_choices.py index 58a050f..0aa75ff 100644 --- a/tests/unit/test_base_theme_choices.py +++ b/tests/unit/test_base_theme_choices.py @@ -1,6 +1,6 @@ """Tests for THEME_CHOICES — the dropdown values for the per-node theme widget.""" -from nodes._base import THEME_CHOICES +from mp_nodes._base import THEME_CHOICES def test_theme_choices_is_list_of_strings(): diff --git a/tests/unit/test_convenience.py b/tests/unit/test_convenience.py index e88cbc5..298e561 100644 --- a/tests/unit/test_convenience.py +++ b/tests/unit/test_convenience.py @@ -5,7 +5,7 @@ import json import pytest import torch -from nodes.convenience import ConvenienceNode +from mp_nodes.convenience import ConvenienceNode def run(mode, **kwargs): diff --git a/tests/unit/test_image_pro.py b/tests/unit/test_image_pro.py index 5b09108..8f0581e 100644 --- a/tests/unit/test_image_pro.py +++ b/tests/unit/test_image_pro.py @@ -7,7 +7,7 @@ import pytest import torch from PIL import Image -from nodes.image_pro import ImageProNode +from mp_nodes.image_pro import ImageProNode def run(mode, **kwargs): diff --git a/tests/unit/test_io_workflow.py b/tests/unit/test_io_workflow.py index 5e48ee4..0a30f67 100644 --- a/tests/unit/test_io_workflow.py +++ b/tests/unit/test_io_workflow.py @@ -8,13 +8,21 @@ import pytest import torch from PIL import Image -from nodes.io_workflow import IOWorkflowNode +from mp_nodes.io_workflow import IOWorkflowNode def run(mode, **kwargs): return IOWorkflowNode().process(mode=mode, theme="(use pack default)", **kwargs) +@pytest.fixture(autouse=True) +def _allow_tmp_paths(monkeypatch): + """Tests use pytest's tmp_path which is outside the allow-listed roots. + Opt out of fs confinement for tests; one targeted test below verifies + the confinement actually blocks paths when the env var is unset.""" + monkeypatch.setenv("UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS", "1") + + # ----------------------- # Filesystem # ----------------------- @@ -85,6 +93,46 @@ class TestFilesystem: assert run("path_sanitize_filename", filename='bad:name?.txt', replacement="_")[0] == "bad_name__file_.txt" +class TestFilesystemConfinement: + """When UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS is unset, fs ops reject + paths outside the allow-list (ComfyUI input/output/temp + user home). + This blocks the v0.1.0/0.1.1 vulnerability where a network-exposed + ComfyUI could touch arbitrary files (Gemini review #7). + + These tests pin the allow-list to a known-narrow directory via monkeypatch + so we can test against paths that are definitely outside it, independent + of where the test runner's tmp_path or HOME actually live. + """ + + @pytest.fixture(autouse=True) + def _isolated_confinement(self, monkeypatch, tmp_path): + from mp_nodes.io_workflow.operations import filesystem as fs_mod + # Allow only this temp dir — everything else is outside + allowed = str(tmp_path / "allowed") + os.makedirs(allowed, exist_ok=True) + monkeypatch.setattr(fs_mod, "_confined_roots", lambda: [allowed]) + monkeypatch.delenv("UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS", raising=False) + self._allowed = allowed + self._outside = str(tmp_path / "outside" / "should_fail") + + def test_mkdir_outside_allow_list_rejected(self): + with pytest.raises(RuntimeError, match="outside the allow-listed roots"): + run("fs_mkdir", path=self._outside) + + def test_delete_outside_allow_list_rejected(self): + with pytest.raises(RuntimeError, match="outside the allow-listed roots"): + run("fs_delete", path=self._outside, missing_ok=True) + + def test_inside_allowed_root_succeeds(self): + out = run("fs_mkdir", path=os.path.join(self._allowed, "subdir")) + assert os.path.isdir(out[0]) + + def test_env_var_override_disables_confinement(self, monkeypatch): + monkeypatch.setenv("UTILITY_MEGAPACK_ALLOW_ARBITRARY_PATHS", "1") + out = run("fs_mkdir", path=self._outside) + assert os.path.isdir(out[0]) + + # ----------------------- # Network (mocked) # ----------------------- @@ -94,7 +142,7 @@ class TestNetwork: mock_resp.iter_content = lambda chunk_size: [b'{"hello": "world"}'] mock_resp.encoding = "utf-8" mock_resp.status_code = 200 - with patch("nodes.io_workflow.operations.network.requests.get", return_value=mock_resp): + with patch("mp_nodes.io_workflow.operations.network.requests.get", return_value=mock_resp): out = run("http_get", url="http://example.com") # RETURN_TYPES is (STRING, DICT, IMAGE, INT) assert out[0] == '{"hello": "world"}' @@ -103,15 +151,27 @@ class TestNetwork: def test_http_post_with_body(self): mock_resp = MagicMock() - mock_resp.text = '{"ok": true}' - mock_resp.content = b'{"ok": true}' + mock_resp.iter_content = lambda chunk_size: [b'{"ok": true}'] + mock_resp.encoding = "utf-8" mock_resp.status_code = 201 - with patch("nodes.io_workflow.operations.network.requests.post", return_value=mock_resp): + with patch("mp_nodes.io_workflow.operations.network.requests.post", return_value=mock_resp): out = run("http_post", url="http://example.com", body_json='{"a": 1}', timeout_seconds=5) assert out[3] == 201 assert out[1] == {"ok": True} + def test_http_post_enforces_size_cap(self): + """Verify streaming enforcement: a giant response must error before fully buffering.""" + from mp_nodes.io_workflow.operations.network import _MAX_RESPONSE_BYTES + big_chunk = b"x" * (_MAX_RESPONSE_BYTES + 1) + mock_resp = MagicMock() + mock_resp.iter_content = lambda chunk_size: [big_chunk] + mock_resp.encoding = "utf-8" + mock_resp.status_code = 200 + with patch("mp_nodes.io_workflow.operations.network.requests.post", return_value=mock_resp): + with pytest.raises(RuntimeError, match="exceeded"): + run("http_post", url="http://example.com", body_json="{}", timeout_seconds=5) + # ----------------------- # Workflow diff --git a/tests/unit/test_mask_latent.py b/tests/unit/test_mask_latent.py index fcfbc18..46e0a90 100644 --- a/tests/unit/test_mask_latent.py +++ b/tests/unit/test_mask_latent.py @@ -3,7 +3,7 @@ import pytest import torch -from nodes.mask_latent import MaskLatentNode +from mp_nodes.mask_latent import MaskLatentNode def run(mode, **kwargs): diff --git a/tests/unit/test_models_sampling.py b/tests/unit/test_models_sampling.py index b9092dc..880c8ed 100644 --- a/tests/unit/test_models_sampling.py +++ b/tests/unit/test_models_sampling.py @@ -5,7 +5,7 @@ import struct import pytest -from nodes.models_sampling import ModelsSamplingNode +from mp_nodes.models_sampling import ModelsSamplingNode def run(mode, **kwargs): diff --git a/tests/unit/test_programming_control_flow.py b/tests/unit/test_programming_control_flow.py index 17f9f2b..e249593 100644 --- a/tests/unit/test_programming_control_flow.py +++ b/tests/unit/test_programming_control_flow.py @@ -2,7 +2,7 @@ import pytest -from nodes.programming import ProgrammingNode +from mp_nodes.programming import ProgrammingNode def run(mode, **kwargs): diff --git a/tests/unit/test_programming_crypto.py b/tests/unit/test_programming_crypto.py index 39197bc..b408b44 100644 --- a/tests/unit/test_programming_crypto.py +++ b/tests/unit/test_programming_crypto.py @@ -5,7 +5,7 @@ import hmac as _hmac import pytest -from nodes.programming import ProgrammingNode +from mp_nodes.programming import ProgrammingNode def run(mode, **kwargs): @@ -79,7 +79,17 @@ class TestAES: with pytest.raises(RuntimeError, match="too short"): run("aes_decrypt", ciphertext_b64="dGlueQ==", key="anything") - def test_aes_two_encrypts_differ_due_to_random_nonce(self): + def test_aes_two_encrypts_differ_due_to_random_salt_and_nonce(self): a = run("aes_encrypt", plaintext="same input", key="same key")[0] b = run("aes_encrypt", plaintext="same input", key="same key")[0] assert a != b + + def test_aes_v0_1_x_format_rejected_with_clear_error(self): + """Old HKDF-format ciphertexts (no salt prefix) are too short for the new layout.""" + # 12-byte nonce + 16-byte tag minimum from the old format = 28 bytes; + # the new format expects salt[16] + nonce[12] + tag[16] = 44 bytes minimum. + # An old 28-byte ciphertext must fail with the clearer length error. + old_format = b"x" * 28 + import base64 + with pytest.raises(RuntimeError, match="too short"): + run("aes_decrypt", ciphertext_b64=base64.b64encode(old_format).decode(), key="any") diff --git a/tests/unit/test_programming_data_struct.py b/tests/unit/test_programming_data_struct.py index 0657ecb..eb5ccc2 100644 --- a/tests/unit/test_programming_data_struct.py +++ b/tests/unit/test_programming_data_struct.py @@ -2,7 +2,7 @@ import pytest -from nodes.programming import ProgrammingNode +from mp_nodes.programming import ProgrammingNode def run(mode, **kwargs): diff --git a/tests/unit/test_programming_encoding.py b/tests/unit/test_programming_encoding.py index 0430d85..ebe6a96 100644 --- a/tests/unit/test_programming_encoding.py +++ b/tests/unit/test_programming_encoding.py @@ -2,7 +2,7 @@ import pytest -from nodes.programming import ProgrammingNode +from mp_nodes.programming import ProgrammingNode def run(mode, **kwargs): diff --git a/tests/unit/test_programming_logic_bits.py b/tests/unit/test_programming_logic_bits.py index 5d04379..35b1f89 100644 --- a/tests/unit/test_programming_logic_bits.py +++ b/tests/unit/test_programming_logic_bits.py @@ -1,6 +1,6 @@ """Tests for the Logic & Bits category.""" -from nodes.programming import ProgrammingNode +from mp_nodes.programming import ProgrammingNode def run(mode, **kwargs): diff --git a/tests/unit/test_programming_math_signals.py b/tests/unit/test_programming_math_signals.py index c6d44b7..ed21723 100644 --- a/tests/unit/test_programming_math_signals.py +++ b/tests/unit/test_programming_math_signals.py @@ -2,7 +2,7 @@ import pytest -from nodes.programming import ProgrammingNode +from mp_nodes.programming import ProgrammingNode def run(mode, **kwargs): diff --git a/tests/unit/test_programming_text.py b/tests/unit/test_programming_text.py index d916dc4..e5a88c2 100644 --- a/tests/unit/test_programming_text.py +++ b/tests/unit/test_programming_text.py @@ -2,7 +2,7 @@ import pytest -from nodes.programming import ProgrammingNode +from mp_nodes.programming import ProgrammingNode def run(mode, **kwargs): diff --git a/tests/unit/test_prompt.py b/tests/unit/test_prompt.py index 24c2e0d..a3ecc54 100644 --- a/tests/unit/test_prompt.py +++ b/tests/unit/test_prompt.py @@ -5,7 +5,7 @@ import tempfile import pytest -from nodes.prompt import PromptNode +from mp_nodes.prompt import PromptNode def run(mode, **kwargs): diff --git a/web/extension.js b/web/extension.js index 9632170..8f95290 100644 --- a/web/extension.js +++ b/web/extension.js @@ -59,14 +59,23 @@ function reapplyAll() { for (const node of app.graph._nodes) applyResolved(node); } -function defineSetting(id, name, type, defaultValue, onChange) { - app.ui.settings.addSetting({ +// Theme IDs available as combo options. Kept in sync with registered themes. +const THEME_OPTIONS = registry.ids(); + +function defineSetting(id, name, type, defaultValue, onChange, options) { + const setting = { id: `megapack.${id}`, name, type, defaultValue, onChange, - }); + }; + // ComfyUI's settings API requires `options` for combo-type settings; + // without it the dropdown renders empty (Gemini review #4). + if (type === "combo" && Array.isArray(options)) { + setting.options = options; + } + app.ui.settings.addSetting(setting); } app.registerExtension({ @@ -75,13 +84,15 @@ app.registerExtension({ async setup() { defineSetting("packDefault", "Utility-MegaPack — Pack default theme", "combo", "default", - (v) => { settings.packDefault = v; reapplyAll(); }); + (v) => { settings.packDefault = v; reapplyAll(); }, + THEME_OPTIONS); defineSetting("globalOverrideEnabled", "Utility-MegaPack — Theme ALL ComfyUI nodes", "boolean", false, (v) => { settings.globalOverrideEnabled = v; reapplyAll(); }); defineSetting("globalTheme", "Utility-MegaPack — Global theme", "combo", "default", - (v) => { settings.globalTheme = v; reapplyAll(); }); + (v) => { settings.globalTheme = v; reapplyAll(); }, + THEME_OPTIONS); defineSetting("respectExistingCustomDrawing", "Utility-MegaPack — Respect existing custom drawing", "boolean", true, (v) => { settings.respectExistingCustomDrawing = v; reapplyAll(); });