diff --git a/README.md b/README.md index 7d2d01b9a..02154a13c 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,51 @@ Changes take effect after a **restart** (no hot reload). > outdated ComfyUI deployments. +### Flagged CNR versions: `allow_flagged_nodepack_install` + +Manager checks the version status in the existing CNR install response; no +additional Registry request is needed. Active versions continue to install +normally, subject to the existing security policy. Registry refusals (including +HTTP 404) still prevent installation. + +Flagged versions must also satisfy the existing security policy and Registry +checks. In addition, they require either **all** `--listen` addresses to be +loopback, such as `127.0.0.1` or `::1`, or the explicit override below. +A mixed loopback/non-loopback listener is non-local. + +For a **trusted private network**, administrators can explicitly allow flagged +versions on non-loopback listeners: + +```ini +[default] +allow_flagged_nodepack_install = true +``` + +The default is `false`; only the case-insensitive value `true` enables it. +Restart ComfyUI after editing the setting. This option does not detect private +networks and is independent of `network_mode`. It does not bypass +`security_level`, `allow_git_url_install`, or `allow_pip_install`. + +When the flagged-version check denies an action, the UI reports that the current +security configuration does not allow it and directs users to the terminal. +Only the terminal provides the loopback-only `--listen` examples, the private +network override setting, and the instruction to restart ComfyUI after changes. + +Enabling an already installed version does not query the Registry or reinstall it. +Older deferred switches without a stored status require loopback-only listeners +or the override. Otherwise they are rejected without changing the installed pack; +request the installation again so Manager can check its current Registry status. + +The check applies to server-initiated installation, reinstallation, version switches, updates, +and snapshot restores. A policy refusal preserves the existing nodepack. +Scheduled version switches save the returned status and check the current +listener and setting again at startup, before downloading or running scripts. +Server-scheduled snapshot restores inherit the permission computed from the +server's current listener and setting. Standalone `cm-cli` commands remain local +administrator operations and do not use the server listener policy. +Saved statuses are not refreshed from the Registry. + + # Disclaimer * This extension simply provides the convenience of installing custom nodes and does not guarantee their proper functioning. diff --git a/glob/cnr_utils.py b/glob/cnr_utils.py index 99b347a63..45d44c2f3 100644 --- a/glob/cnr_utils.py +++ b/glob/cnr_utils.py @@ -129,6 +129,7 @@ class NodeVersion: id: str version: str download_url: str + status: str = '' def map_node_version(api_node_version): @@ -160,6 +161,7 @@ def map_node_version(api_node_version): download_url=api_node_version.get( "downloadUrl", "" ), # Provide a default value if 'downloadUrl' is missing + status=api_node_version.get('status', ''), ) @@ -181,7 +183,6 @@ def install_node(node_id, version=None): response = requests.get(url, verify=not manager_util.bypass_ssl) if response.status_code == 200: - # Convert the API response to a NodeVersion object return map_node_version(response.json()) else: return None diff --git a/glob/manager_core.py b/glob/manager_core.py index 5acdc4c21..1804a842a 100644 --- a/glob/manager_core.py +++ b/glob/manager_core.py @@ -953,10 +953,10 @@ class UnifiedManager: return res - def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps): + def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps, status=''): script_path = os.path.join(manager_startup_script_path, "install-scripts.txt") with open(script_path, "a") as file: - obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable] + obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable, status] file.write(f"{obj}\n") print(f"Installation reserved: {target}") @@ -982,35 +982,45 @@ class UnifiedManager: return result + def _get_cnr_install_info(self, node_id, version_spec): + node_info = cnr_utils.install_node(node_id, version_spec) + if node_info is not None and node_info.status == 'NodeVersionStatusFlagged' and not manager_funcs.is_flagged_install_allowed(): + logging.error(manager_util.FLAGGED_NODEPACK_INSTALL_GUIDANCE) + raise PermissionError(manager_util.FLAGGED_NODEPACK_INSTALL_ERROR) + return node_info + def cnr_switch_version(self, node_id, version_spec=None, instant_execution=False, no_deps=False, return_postinstall=False): - if instant_execution: - return self.cnr_switch_version_instant(node_id, version_spec, instant_execution, no_deps, return_postinstall) - else: - return self.cnr_switch_version_lazy(node_id, version_spec, no_deps, return_postinstall) - - def cnr_switch_version_lazy(self, node_id, version_spec=None, no_deps=False, return_postinstall=False): - """ - switch between cnr version (lazy mode) - """ - result = ManagedResult('switch-cnr') - node_info = cnr_utils.install_node(node_id, version_spec) + try: + node_info = self._get_cnr_install_info(node_id, version_spec) + except PermissionError as exc: + return result.fail(str(exc)) if node_info is None or not node_info.download_url: return result.fail(f'not available node: {node_id}@{version_spec}') - version_spec = node_info.version + return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall) - if self.active_nodes[node_id][0] == version_spec: + def _cnr_switch_version(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False): + """Execute a switch using the checked CNR response.""" + if self.active_nodes[node_id][0] == node_info.version: return ManagedResult('skip').with_msg("Up to date") + if instant_execution: + return self._cnr_switch_version_instant(node_id, node_info, no_deps, return_postinstall) + return self._cnr_switch_version_lazy(node_id, node_info, no_deps, return_postinstall) + + def _cnr_switch_version_lazy(self, node_id, node_info, no_deps=False, return_postinstall=False): + """Reserve a switch using the validated install response.""" + result = ManagedResult('switch-cnr') + version_spec = node_info.version zip_url = node_info.download_url from_path = self.active_nodes[node_id][1] target = node_id to_path = os.path.join(get_default_custom_nodes_path(), target) def postinstall(): - return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps) + return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps, node_info.status) if return_postinstall: return result.with_postinstall(postinstall) @@ -1020,23 +1030,12 @@ class UnifiedManager: return result - def cnr_switch_version_instant(self, node_id, version_spec=None, instant_execution=True, no_deps=False, return_postinstall=False): - """ - switch between cnr version - """ - - # 1. download + def _cnr_switch_version_instant(self, node_id, node_info, no_deps=False, return_postinstall=False): + """Apply a switch using the validated install response.""" result = ManagedResult('switch-cnr') - - node_info = cnr_utils.install_node(node_id, version_spec) - if node_info is None or not node_info.download_url: - return result.fail(f'not available node: {node_id}@{version_spec}') - version_spec = node_info.version - if self.active_nodes[node_id][0] == version_spec: - return ManagedResult('skip').with_msg("Up to date") - + # 1. download archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution download_path = os.path.join(get_default_custom_nodes_path(), archive_name) manager_downloader.basic_download_url(node_info.download_url, get_default_custom_nodes_path(), archive_name) @@ -1081,7 +1080,7 @@ class UnifiedManager: result.target = version_spec def postinstall(): - res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=instant_execution, no_deps=no_deps) + res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=True, no_deps=no_deps) return res if return_postinstall: @@ -1298,10 +1297,24 @@ class UnifiedManager: if 'comfyui-manager' in node_id.lower(): return result.fail(f"ignored: installing '{node_id}'") - node_info = cnr_utils.install_node(node_id, version_spec) + try: + node_info = self._get_cnr_install_info(node_id, version_spec) + except PermissionError as exc: + return result.fail(str(exc)) if node_info is None or not node_info.download_url: return result.fail(f'not available node: {node_id}@{version_spec}') + result = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall) + # Keep the requested version in the public result. + if result.target is not None: + result.target = version_spec + return result + + def _cnr_install(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False): + """Install using the checked CNR response.""" + result = ManagedResult('install-cnr') + version_spec = node_info.version + archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution download_path = os.path.join(get_default_custom_nodes_path(), archive_name) @@ -1490,21 +1503,13 @@ class UnifiedManager: else: version_spec = self.resolve_unspecified_version(node_id) - if version_spec == 'unknown' or version_spec == 'nightly': + if version_spec in ('unknown', 'nightly'): try: - custom_nodes = await self.get_custom_nodes(channel, mode) - except InvalidChannel as e: - return ManagedResult('fail').fail(f'Invalid channel is used: {e.channel}') - - the_node = custom_nodes.get(node_id) - if the_node is not None: - if version_spec == 'unknown': - repo_url = the_node['files'][0] - else: # nightly - repo_url = the_node['repository'] - else: - result = ManagedResult('install') - return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]") + repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode) + except InvalidChannel as exc: + return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}') + except LookupError as exc: + return ManagedResult('install').fail(str(exc)) if self.is_enabled(node_id, version_spec): return ManagedResult('skip').with_target(f"{node_id}@{version_spec}") @@ -1512,24 +1517,17 @@ class UnifiedManager: elif self.is_disabled(node_id, version_spec): return self.unified_enable(node_id, version_spec) - elif version_spec == 'unknown' or version_spec == 'nightly': - if version_spec == 'nightly': - # disable cnr nodes - if self.is_enabled(node_id, 'cnr'): - self.unified_disable(node_id, False) + elif version_spec in ('unknown', 'nightly'): + return self._install_git(node_id, version_spec, repo_url, instant_execution, no_deps, return_postinstall) - to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id)) - res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall) - if res.result: - if version_spec == 'unknown': - self.unknown_active_nodes[node_id] = repo_url, to_path - elif version_spec == 'nightly': - cnr_utils.generate_cnr_id(to_path, node_id) - self.active_nodes[node_id] = 'nightly', to_path - else: - return res - - return res.with_target(version_spec) + # Check before changing the existing pack, then reuse this response. + try: + node_info = self._get_cnr_install_info(node_id, version_spec) + except PermissionError as exc: + return ManagedResult('install-cnr').fail(str(exc)) + if node_info is None or not node_info.download_url: + return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}') + version_spec = node_info.version if self.is_enabled(node_id, 'nightly'): # disable nightly nodes @@ -1542,17 +1540,77 @@ class UnifiedManager: if self.is_disabled(node_id, "cnr"): # enable and switch version if cnr is disabled (not specified version) self.unified_enable(node_id, "cnr") - return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall) + return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall) if self.is_enabled(node_id, "cnr"): - return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall) + return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall) - res = self.cnr_install(node_id, version_spec, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall) + res = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall) if res.result: self.active_nodes[node_id] = version_spec, res.to_path return res + async def _get_git_install_url(self, node_id, version_spec, channel, mode): + custom_nodes = await self.get_custom_nodes(channel, mode) + node = custom_nodes.get(node_id) + if node is None: + raise LookupError(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]") + return node['files'][0] if version_spec == 'unknown' else node['repository'] + + def _install_git(self, node_id, version_spec, repo_url, instant_execution=False, no_deps=False, return_postinstall=False): + if version_spec == 'nightly': + # disable cnr nodes + if self.is_enabled(node_id, 'cnr'): + self.unified_disable(node_id, False) + + to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id)) + res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall) + if res.result: + if version_spec == 'unknown': + self.unknown_active_nodes[node_id] = repo_url, to_path + elif version_spec == 'nightly': + cnr_utils.generate_cnr_id(to_path, node_id) + self.active_nodes[node_id] = 'nightly', to_path + else: + return res + + return res.with_target(version_spec) + + async def reinstall_by_id(self, node_id, version_spec, channel=None, mode=None): + if 'comfyui-manager' in node_id.lower(): + return ManagedResult('skip').fail(f"ignored: installing '{node_id}'") + + if version_spec in ('unknown', 'nightly'): + try: + repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode) + except InvalidChannel as exc: + return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}') + except LookupError as exc: + return ManagedResult('install').fail(str(exc)) + else: + try: + node_info = self._get_cnr_install_info(node_id, version_spec) + except PermissionError as exc: + return ManagedResult('install-cnr').fail(str(exc)) + if node_info is None or not node_info.download_url: + return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}') + + removed = self.unified_uninstall(node_id, version_spec == 'unknown') + if not removed.result: + return removed + + for item in removed.items: + path = item if version_spec == 'unknown' else item[1] + self.processed_install.discard(os.path.join(path, 'install.py')) + if version_spec in ('unknown', 'nightly'): + return self._install_git(node_id, version_spec, repo_url) + + result = self._cnr_install(node_id, node_info) + if result.result: + self.active_nodes[node_id] = node_info.version, result.to_path + return result + unified_manager = UnifiedManager() @@ -1663,6 +1721,10 @@ class ManagerFuncs: def __init__(self): pass + def is_flagged_install_allowed(self): + # Server-scheduled restore children inherit only the resolved permission. + return os.environ.get('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', 'true') == 'true' + def get_current_preview_method(self): return "none" @@ -1703,6 +1765,7 @@ WRITTEN_CONFIG_KEYS = ( 'db_mode', 'allow_git_url_install', 'allow_pip_install', + 'allow_flagged_nodepack_install', ) @@ -1877,6 +1940,7 @@ def read_config(): 'db_mode': default_conf.get('db_mode', 'cache').lower(), 'allow_git_url_install': get_bool('allow_git_url_install', False), 'allow_pip_install': get_bool('allow_pip_install', False), + 'allow_flagged_nodepack_install': get_bool('allow_flagged_nodepack_install', False), } manager_migration.force_security_level_if_needed(result) return result @@ -1908,6 +1972,7 @@ def read_config(): 'db_mode': 'cache', # local | cache | remote 'allow_git_url_install': False, 'allow_pip_install': False, + 'allow_flagged_nodepack_install': False, } manager_migration.force_security_level_if_needed(result) return result @@ -3315,15 +3380,20 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None): skip_node_packs.append(f"{x[0]}@{x[1]}") elif not ps.result: failed.append(f"{x[0]}@{x[1]}") + logging.error(ps.msg) - # install listed cnr nodes + # Do not retry processed switches as fresh installs. + checked_cnr = {node_id for node_id, _ in todo_checkout} for k, v in cnr_info.items(): - if 'comfyui-manager' in k: + if 'comfyui-manager' in k or k in checked_cnr: continue ps = await unified_manager.install_by_id(k, version_spec=v, instant_execution=True, return_postinstall=True) if ps.action == 'install-cnr' and ps.result: installed_node_packs.append(f"{k}@{v}") + elif not ps.result: + failed.append(f"{k}@{v}") + logging.error(ps.msg) if ps is not None and ps.result: if hasattr(ps, 'postinstall'): diff --git a/glob/manager_server.py b/glob/manager_server.py index 4b8826260..52fe12507 100644 --- a/glob/manager_server.py +++ b/glob/manager_server.py @@ -162,6 +162,9 @@ async def get_risky_level(files, pip_packages): class ManagerFuncsInComfyUI(core.ManagerFuncs): + def is_flagged_install_allowed(self): + return core.get_config()['allow_flagged_nodepack_install'] or manager_util.is_loopback_listener(args.listen) + def get_current_preview_method(self): if args.preview_method == latent_preview.LatentPreviewMethod.Auto: return "auto" @@ -518,7 +521,7 @@ async def task_worker(): global model_result global tasks_in_progress - async def do_install(item) -> str: + async def do_install(item, operation) -> str: ui_id, node_spec_str, channel, mode, skip_post_install = item try: @@ -528,7 +531,10 @@ async def task_worker(): return f"Cannot resolve install target: '{node_spec_str}'" node_name, version_spec, is_specified = node_spec - res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install) + if operation == 'reinstall': + res = await core.unified_manager.reinstall_by_id(node_name, version_spec, channel, mode) + else: + res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install) # discard post install if skip_post_install mode if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']: @@ -575,7 +581,7 @@ async def task_worker(): base_res['msg'] = 'success' return base_res - base_res['msg'] = f"An error occurred while updating '{node_name}'." + base_res['msg'] = res.msg or f"An error occurred while updating '{node_name}'." logging.error(f"\nERROR: An error occurred while updating '{node_name}'. (res.result={res.result}, res.action={res.action})") return base_res except Exception: @@ -728,8 +734,8 @@ async def task_worker(): tasks_in_progress.add((kind, item[0])) try: - if kind == 'install': - msg = await do_install(item) + if kind in ('install', 'reinstall'): + msg = await do_install(item, kind) elif kind == 'install-model': msg = await do_install_model(item) elif kind == 'update': @@ -1391,8 +1397,7 @@ async def import_fail_info(request): @routes.post("/manager/queue/reinstall") async def reinstall_custom_node(request): - await uninstall_custom_node(request) - await install_custom_node(request) + return await _queue_node_install(request, "reinstall") @routes.post("/manager/queue/reset") @@ -1422,16 +1427,20 @@ async def queue_count(request): @routes.post("/manager/queue/install") async def install_custom_node(request): + return await _queue_node_install(request, "install") + + +async def _queue_node_install(request, operation): if not is_allowed_security_level('middle'): logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW) return web.Response(status=403, text="A security error has occurred. Please check the terminal logs") json_data = await request.json() - # non-nightly cnr is safe + # CNR version status is checked by the worker before installation. risky_level = None cnr_id = json_data.get('id') - skip_post_install = json_data.get('skip_post_install') + skip_post_install = False if operation == 'reinstall' else json_data.get('skip_post_install') git_url = None @@ -1504,7 +1513,7 @@ async def install_custom_node(request): return web.Response(status=404, text="A security error has occurred. Please check the terminal logs") install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install - task_queue.put(("install", install_item)) + task_queue.put((operation, install_item)) return web.Response(status=200) diff --git a/glob/manager_util.py b/glob/manager_util.py index fd110b7fc..4a24665b2 100644 --- a/glob/manager_util.py +++ b/glob/manager_util.py @@ -3,6 +3,8 @@ description: `manager_util` is the lightest module shared across the prestartup_script, main code, and cm-cli of ComfyUI-Manager. """ import traceback +import ipaddress +import socket import aiohttp import json @@ -26,6 +28,50 @@ cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also up use_uv = False bypass_ssl = False +FLAGGED_NODEPACK_INSTALL_ERROR = ( + 'This action is not allowed by the current security configuration. ' + 'See the terminal for details.' +) +FLAGGED_NODEPACK_INSTALL_GUIDANCE = ( + 'Installation of this flagged CNR version is blocked. To satisfy the additional ' + 'flagged-version requirement, choose one of the following:\n' + '1. Run ComfyUI with loopback-only listeners, for example --listen 127.0.0.1 ' + 'or --listen ::1. All configured listen addresses must be loopback.\n' + ' Do not use bare --listen or any non-loopback address, such as 0.0.0.0 or ::.\n' + '2. For a trusted private network, set allow_flagged_nodepack_install = true ' + 'in the [default] section of ComfyUI-Manager\'s config.ini.\n' + 'Restart ComfyUI after changing the listener or configuration. ' + 'All other installation security checks still apply.' +) + + +def is_loopback_listener(listen_address: str) -> bool: + """All addresses bound by --listen must resolve exclusively to loopback.""" + if not isinstance(listen_address, str) or not listen_address: + return False + for address in listen_address.split(','): + address = address.strip() + if not address: + return False + try: + if ipaddress.ip_address(address).is_loopback: + continue + except ValueError: + pass + try: + resolved = socket.getaddrinfo(address, None, type=socket.SOCK_STREAM) + except OSError: + return False + if not resolved or any(not ipaddress.ip_address(item[4][0]).is_loopback for item in resolved): + return False + return True + + +def is_cnr_install_allowed(status: str, allow_flagged: bool, listen_address: str) -> bool: + """Only flagged versions require loopback or the private-network opt-in.""" + return status != 'NodeVersionStatusFlagged' or bool(allow_flagged) or is_loopback_listener(listen_address) + + def add_python_path_to_env(): if platform.system() != "Windows": sep = ':' @@ -630,4 +676,4 @@ def restore_pip_snapshot(pips, options): if res != 0: failed.append(x) - print(f"Installation failed for pip packages: {failed}") \ No newline at end of file + print(f"Installation failed for pip packages: {failed}") diff --git a/prestartup_script.py b/prestartup_script.py index dabd39dae..b6cf581ac 100644 --- a/prestartup_script.py +++ b/prestartup_script.py @@ -613,6 +613,12 @@ if os.path.exists(restore_snapshot_path): if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env: new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path + from comfy.cli_args import args + + allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true' + new_env['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = str( + allow_flagged or manager_util.is_loopback_listener(args.listen) + ).lower() cmd_str = [sys.executable, cm_cli_path, 'restore-snapshot', restore_snapshot_path] exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env) @@ -661,9 +667,18 @@ def execute_lazy_install_script(repo_path, executable): process_wrap(install_cmd, repo_path, env=new_env) -def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path): +def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path, status=''): import uuid import shutil + from comfy.cli_args import args + + allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true' + if not manager_util.is_cnr_install_allowed(status or 'NodeVersionStatusFlagged', allow_flagged, args.listen): + if not status: + logging.error("Cannot execute reserved CNR switch for '%s': stored Registry status is missing. Request the installation again so its current status can be checked.", target) + else: + logging.error(manager_util.FLAGGED_NODEPACK_INSTALL_GUIDANCE) + return False # 1. download archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution @@ -711,6 +726,8 @@ def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom with open(tracking_info_file, "w", encoding='utf-8') as file: file.write('\n'.join(list(extracted))) + return True + script_executed = False @@ -766,8 +783,9 @@ def execute_startup_script(): execute_lazy_install_script(script[0], script[2]) elif script[1] == "#LAZY-CNR-SWITCH-SCRIPT": - execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6]) - execute_lazy_install_script(script[3], script[7]) + status = script[8] if len(script) > 8 else '' + if execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6], status): + execute_lazy_install_script(script[3], script[7]) elif script[1] == "#LAZY-DELETE-NODEPACK": execute_lazy_delete(script[2]) diff --git a/tests/e2e/test_e2e_flagged_nodepacks.py b/tests/e2e/test_e2e_flagged_nodepacks.py new file mode 100644 index 000000000..5a8397be9 --- /dev/null +++ b/tests/e2e/test_e2e_flagged_nodepacks.py @@ -0,0 +1,400 @@ +"""Real ComfyUI HTTP/queue/install tests with a local CNR service and harmless ZIPs. + +Run with E2E_ROOT pointing to the existing ComfyUI + venv fixture. Each server +uses an isolated base directory; no installed nodes or user config are changed. +""" + +import asyncio +import ast +import io +import json +import os +import socket +import subprocess +import threading +import time +import uuid +import zipfile +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +import aiohttp +import pytest +import requests + + +E2E_ROOT = Path(os.environ.get('E2E_ROOT', '/nonexistent')) +REPO_ROOT = Path(__file__).resolve().parents[2] +pytestmark = pytest.mark.skipif( + not (E2E_ROOT / 'venv/bin/python').is_file(), reason='E2E_ROOT is required', +) +PACKS = ['fixture-active', 'fixture-flagged', 'fixture-pending', 'fixture-banned', 'fixture-latest', 'fixture-switch', 'fixture-reinstall', 'fixture-update', 'fixture-snapshot'] + + +@pytest.fixture(scope='module') +def registry(): + calls = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + parsed = urlparse(self.path) + calls.append(parsed.path + ('?' + parsed.query if parsed.query else '')) + parts = parsed.path.strip('/').split('/') + version = parse_qs(parsed.query).get('version', ['2.0.0'])[0] + node = parts[1] if len(parts) > 1 else '' + base = f'http://127.0.0.1:{self.server.server_port}' + + def metadata(node, version): + status = {'1.0.0': 'Active', '1.1.0': 'Active', '2.0.0': 'Flagged', '3.0.0': 'Pending'}[version] + return {'id': f'{node}-{version}', 'node_id': node, 'version': version, + 'status': 'NodeVersionStatus' + status, 'dependencies': [], + 'downloadUrl': f'{base}/zip/{node}/{version}'} + + status = 200 + if parts[0] == 'zip': + node, version = parts[1:] + archive = io.BytesIO() + with zipfile.ZipFile(archive, 'w') as z: + z.writestr('__init__.py', 'NODE_CLASS_MAPPINGS = {}\n') + z.writestr('pyproject.toml', f'[project]\nname = "{node}"\nversion = "{version}"\n') + z.writestr('install.py', 'from pathlib import Path\n' + f'Path("installed.txt").write_text("{version}")\n') + body = archive.getvalue() + elif parsed.path == '/nodes': + body = json.dumps({'nodes': [ + {'id': name, 'name': name, 'description': '', 'status': 'NodeStatusActive', + 'repository': f'https://example.invalid/{name}', + 'publisher': {'id': 'fixture', 'name': 'Fixture'}, + 'latest_version': metadata(name, '2.0.0')} + for name in PACKS], 'totalPages': 1}).encode() + elif len(parts) == 3 and parts[0] == 'nodes' and parts[2] == 'install': + if node == 'fixture-banned': + status, body = 404, b'{"message":"Not found"}' + else: + body = json.dumps(metadata(node, version)).encode() + else: + status, body = 404, b'{}' + self.send_response(status) + self.send_header('Content-Length', str(len(body))) + self.send_header('Content-Type', 'application/zip' if parts[0] == 'zip' else 'application/json') + self.end_headers() + self.wfile.write(body) + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield SimpleNamespace(url=f'http://127.0.0.1:{server.server_port}', calls=calls) + server.shutdown() + server.server_close() + thread.join() + + +# Redirect only the external Registry in both ComfyUI and its restore subprocess. +REGISTRY_BOOTSTRAP = ''' +import asyncio, json, os, sys, urllib.request +sys.path.insert(0, os.environ['CM_E2E_MANAGER_GLOB']) +import manager_core, cnr_utils, manager_util +cnr_utils.base_url = os.environ['CM_E2E_REGISTRY'] +with urllib.request.urlopen(cnr_utils.base_url + '/nodes') as response: + manager_util.save_to_cache(cnr_utils.base_url + '/nodes', json.load(response)) +asyncio.run(manager_core.unified_manager.reload('cache', dont_wait=False)) +manager_core.unified_manager.custom_node_map_cache[(manager_core.normalize_channel('default'), 'cache')] = manager_core.NormalizedKeyDict() +''' +BOOTSTRAP = ''' +import os, runpy, sys +sys.argv = [os.environ['CM_E2E_MAIN'], *sys.argv[1:]] +import comfy.options +comfy.options.enable_args_parsing() +''' + REGISTRY_BOOTSTRAP + "\nrunpy.run_path(sys.argv[0], run_name='__main__')\n" + + +@contextmanager +def running_server(root, listen, override, registry, security='normal'): + custom_nodes = root / 'custom_nodes' + custom_nodes.mkdir(exist_ok=True) + mount = custom_nodes / 'comfyui-manager' + if not mount.exists(): + mount.symlink_to(REPO_ROOT, target_is_directory=True) + config_dir = root / 'user/__manager' + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / 'config.ini').write_text( + '[default]\nnetwork_mode = offline\nuse_uv = false\n' + f'security_level = {security}\nallow_flagged_nodepack_install = {override}\n' + ) + # cm-cli.py inherits this test-only IO redirection from its real parent. + hook = root / 'sitecustomize.py' + hook.write_text("import os, sys\nif os.path.basename(sys.argv[0]) == 'cm-cli.py':\n" + " from comfy.cli_args import args\n" + " args.base_directory = os.environ['CM_E2E_BASE']\n" + + '\n'.join(' ' + line for line in REGISTRY_BOOTSTRAP.splitlines())) + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + port = sock.getsockname()[1] + base = f'http://127.0.0.1:{port}' + env = dict(os.environ, PYTHONUNBUFFERED='1', + PYTHONPATH=os.pathsep.join([str(root), str(E2E_ROOT / 'comfyui')]), + COMFYUI_PATH=str(E2E_ROOT / 'comfyui'), COMFYUI_FOLDERS_BASE_PATH=str(root), + CM_E2E_BASE=str(root), CM_E2E_MAIN=str(E2E_ROOT / 'comfyui/main.py'), + CM_E2E_MANAGER_GLOB=str(REPO_ROOT / 'glob'), CM_E2E_REGISTRY=registry.url) + command = [str(E2E_ROOT / 'venv/bin/python'), '-c', BOOTSTRAP, '--cpu', + '--base-directory', str(root), '--listen', listen, '--port', str(port), + '--database-url', f'sqlite:///{root / "comfyui.db"}'] + log_path = root / ('server-' + uuid.uuid4().hex + '.log') + with log_path.open('w') as log: + process = subprocess.Popen(command, env=env, cwd=E2E_ROOT / 'comfyui', stdout=log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + assert process.poll() is None, log_path.read_text()[-6000:] + try: + if requests.get(base + '/system_stats', timeout=1).status_code == 200: + break + except requests.RequestException: + pass + time.sleep(.2) + else: + pytest.fail(log_path.read_text()[-6000:]) + assert requests.get(base + '/manager/version', timeout=5).status_code == 200, log_path.read_text()[-6000:] + assert 'PRESTARTUP FAILED' not in log_path.read_text(), log_path.read_text() + yield SimpleNamespace(root=root, base=base, listen=listen, override=override, + security=security, registry=registry, log=log_path, env=env) + finally: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +@pytest.mark.parametrize('operation', ['install', 'restore-snapshot']) +def test_direct_cli_installs_flagged_without_server_options(tmp_path, registry, operation): + with running_server(tmp_path, '0.0.0.0', False, registry) as server: + env = server.env + command = [str(E2E_ROOT / 'venv/bin/python'), str(REPO_ROOT / 'cm-cli.py'), operation] + if operation == 'install': + command += ['fixture-flagged@2.0.0', '--mode', 'cache'] + else: + snapshot = tmp_path / 'direct-snapshot.json' + snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-flagged': '2.0.0'}, + 'git_custom_nodes': {}, 'file_custom_nodes': []})) + command.append(str(snapshot)) + command += ['--user-directory', str(tmp_path / 'user')] + before = len(registry.calls) + + result = subprocess.run(command, env=env, cwd=E2E_ROOT / 'comfyui', + capture_output=True, text=True, timeout=60) + + output = result.stdout + result.stderr + assert result.returncode == 0, output + installed = tmp_path / 'custom_nodes/fixture-flagged' + assert 'version = "2.0.0"' in (installed / 'pyproject.toml').read_text(), output + if operation == 'install': + assert (installed / 'installed.txt').read_text() == '2.0.0', output + assert sum('/install' in call for call in registry.calls[before:]) == 1 + assert 'Installation of this flagged CNR version is blocked' not in output + + +@pytest.fixture(scope='module', params=[ + ('127.0.0.1', False, 'normal'), ('0.0.0.0', False, 'normal'), + ('0.0.0.0', True, 'normal'), ('0.0.0.0', True, 'strong'), + ('127.0.0.1', False, 'strong'), +], ids=lambda row: '-'.join(map(str, row))) +def comfy_server(request, tmp_path_factory, registry): + listen, override, security = request.param + root = tmp_path_factory.mktemp('flagged-v3') + with running_server(root, listen, override, registry, security) as server: + yield server + + +def install(server, node, version, operation='install'): + async def request_and_wait(): + ui_id = uuid.uuid4().hex + params = {'id': node, 'version': '1.0.0', 'selected_version': version, + 'mode': 'cache', 'channel': 'default', 'ui_id': ui_id, + 'repository': 'https://example.invalid/fixture'} + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=40)) as session: + # Subscribe first: a fast worker can emit completion before POST returns. + async with session.ws_connect(server.base + '/ws?clientId=' + ui_id) as ws: + async with session.post(server.base + '/manager/queue/' + operation, json=params) as response: + if response.status != 200: + return {'http': response.status, 'message': await response.text()} + async with session.post(server.base + '/manager/queue/start') as response: + response.raise_for_status() + while True: + event = await ws.receive_json(timeout=30) + data = event.get('data', {}) + if event.get('type') == 'cm-queue-status' and data.get('status') == 'done': + if ui_id in data.get('nodepack_result', {}): + return {'http': 200, 'message': data['nodepack_result'][ui_id]} + return asyncio.run(request_and_wait()) + + +def assert_terminal_guidance(log): + for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]', + 'allow_flagged_nodepack_install = true', 'config.ini', + 'trusted private network', 'Restart ComfyUI'): + assert detail in log + + +def assert_flagged_denial(result, log): + assert result['message'] == ('This action is not allowed by the current security configuration. ' + 'See the terminal for details.'), result + assert_terminal_guidance(log) + + +@pytest.mark.parametrize('node,version,flagged', [ + ('fixture-active', '1.0.0', False), ('fixture-flagged', '2.0.0', True), + ('fixture-pending', '3.0.0', False), ('fixture-banned', '1.0.0', False), + ('fixture-latest', 'latest', True), +]) +def test_install_policy(comfy_server, node, version, flagged): + server = comfy_server + log_before = len(server.log.read_text()) + before = len(server.registry.calls) + result = install(server, node, version) + calls = server.registry.calls[before:] + strong = server.security == 'strong' + allowed = not strong and node != 'fixture-banned' and ( + not flagged or server.override or server.listen == '127.0.0.1') + marker = server.root / 'custom_nodes' / node / 'installed.txt' + assert marker.exists() is allowed, (result, server.log.read_text()[-6000:]) + assert sum('/install' in call for call in calls) == (0 if strong else 1), calls + assert any(call.startswith('/zip/') for call in calls) is allowed + if allowed: + assert result['message'] == 'success', result + assert marker.read_text() == ('2.0.0' if version == 'latest' else version) + elif strong: + assert result['http'] == 403, result + elif flagged: + assert_flagged_denial(result, server.log.read_text()[log_before:]) + + +@pytest.mark.parametrize('operation', ['install', 'reinstall', 'update']) +def test_existing_pack_policy(comfy_server, operation): + server = comfy_server + if server.security == 'strong': + pytest.skip('Existing-pack transitions require an installed pack') + node = {'install': 'fixture-switch', 'reinstall': 'fixture-reinstall', 'update': 'fixture-update'}[operation] + assert install(server, node, '1.0.0')['message'] == 'success' + marker = server.root / 'custom_nodes' / node / 'installed.txt' + log_before = len(server.log.read_text()) + before = len(server.registry.calls) + result = install(server, node, '2.0.0', operation) + calls = server.registry.calls[before:] + allowed = server.override or server.listen == '127.0.0.1' + assert sum('/install' in call for call in calls) == 1, calls + if allowed: + assert result['message'] == 'success', result + if operation == 'reinstall': + assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:] + else: + assert marker.read_text() == '1.0.0' + assert 'NodeVersionStatusFlagged' in (server.root / 'user/__manager/startup-scripts/install-scripts.txt').read_text() + else: + assert_flagged_denial(result, server.log.read_text()[log_before:]) + assert marker.read_text() == '1.0.0' + assert any(call.startswith('/zip/') for call in calls) is (allowed and operation == 'reinstall') + + +@pytest.mark.parametrize('restart_listen,restart_override,allowed', [ + ('0.0.0.0', False, False), ('0.0.0.0', True, True), ('127.0.0.1', False, True), +]) +@pytest.mark.parametrize('stored_status', [True, False]) +def test_restart_uses_current_policy(tmp_path, registry, restart_listen, restart_override, allowed, stored_status): + with running_server(tmp_path, '127.0.0.1', False, registry) as server: + assert install(server, 'fixture-switch', '1.0.0')['message'] == 'success' + assert install(server, 'fixture-switch', '2.0.0')['message'] == 'success' + if not stored_status: + reservation = tmp_path / 'user/__manager/startup-scripts/install-scripts.txt' + record = ast.literal_eval(reservation.read_text().strip()) + assert record[1] == '#LAZY-CNR-SWITCH-SCRIPT' + assert len(record) == 9 + reservation.write_text(repr(record[:8]) + '\n') + marker = tmp_path / 'custom_nodes/fixture-switch/installed.txt' + original = (marker.read_bytes(), marker.stat().st_mtime_ns) + before = len(registry.calls) + with running_server(tmp_path, restart_listen, restart_override, registry) as server: + calls = registry.calls[before:] + assert not any('/install' in call for call in calls), calls + assert any(call.startswith('/zip/') for call in calls) is allowed + if allowed: + assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:] + else: + assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original + if stored_status: + assert_terminal_guidance(server.log.read_text()) + else: + assert 'stored Registry status is missing' in server.log.read_text() + assert 'Request the installation again' in server.log.read_text() + + +@pytest.mark.parametrize('surface,payload,flag', [ + ('git_url', {'url': 'https://example.invalid/fixture'}, 'allow_git_url_install'), + ('pip', {'packages': 'fixture-do-not-install'}, 'allow_pip_install'), +]) +def test_other_install_flags_remain_required(comfy_server, surface, payload, flag): + server = comfy_server + before = len(server.registry.calls) + response = requests.post(server.base + '/customnode/install/' + surface, json=payload, timeout=10) + assert response.status_code == 403, response.text + assert response.json() == {'error': flag} + assert server.registry.calls[before:] == [] + + +@pytest.mark.parametrize('existing', [False, True]) +@pytest.mark.parametrize('version,listen,override,allowed', [ + ('1.1.0', '0.0.0.0', False, True), + ('2.0.0', '0.0.0.0', False, False), + ('2.0.0', '0.0.0.0', True, True), + ('2.0.0', '127.0.0.1', False, True), +]) +def test_scheduled_snapshot_inherits_parent_policy(tmp_path, registry, existing, version, listen, override, allowed): + installed = tmp_path / 'custom_nodes/fixture-snapshot' + marker = installed / 'installed.txt' + reservation = tmp_path / 'user/__manager/startup-scripts/restore-snapshot.json' + with running_server(tmp_path, '127.0.0.1', False, registry) as server: + if existing: + assert install(server, 'fixture-snapshot', '1.0.0')['message'] == 'success' + assert marker.read_text() == '1.0.0' + original = (marker.read_bytes(), marker.stat().st_mtime_ns) + snapshot = tmp_path / 'user/__manager/snapshots/flagged-policy.json' + snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-snapshot': version}, + 'git_custom_nodes': {}, 'file_custom_nodes': []})) + before = len(registry.calls) + response = requests.post(server.base + '/snapshot/restore', + json={'target': snapshot.stem}, timeout=10) + assert response.status_code == 200, response.text + assert reservation.read_bytes() == snapshot.read_bytes() + assert registry.calls[before:] == [] + if existing: + assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original + else: + assert not installed.exists() + + before = len(registry.calls) + with running_server(tmp_path, listen, override, registry) as server: + log = server.log.read_text() + calls = registry.calls[before:] + assert 'Restore snapshot done.' in log, log[-6000:] + assert 'Restore snapshot failed.' not in log, log[-6000:] + assert 'Error in sitecustomize' not in log, log[-6000:] + assert not reservation.exists() + assert sum('/install' in call for call in calls) == 1, (calls, log[-6000:]) + assert any(call.startswith('/zip/') for call in calls) is allowed + if allowed: + assert f'version = "{version}"' in (installed / 'pyproject.toml').read_text(), log[-6000:] + assert 'allow_flagged_nodepack_install' not in log + else: + assert_terminal_guidance(log) + if existing: + assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original + assert '1.0.0' in (installed / 'pyproject.toml').read_text() + else: + assert not installed.exists() diff --git a/tests/test_flagged_nodepack_policy.py b/tests/test_flagged_nodepack_policy.py new file mode 100644 index 000000000..d945ad134 --- /dev/null +++ b/tests/test_flagged_nodepack_policy.py @@ -0,0 +1,325 @@ +"""Behavioral checks against real v3 modules in an isolated ComfyUI runtime.""" + +import pytest + +from test_install_flags_config import _run_child + + +PRELUDE = ''' +import asyncio, io, logging, pathlib, zipfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock +import cnr_utils, manager_util +from comfy.cli_args import args +args.listen = '0.0.0.0' +core = manager_core +core.manager_funcs.is_flagged_install_allowed = lambda: False +config = core.get_config() +config['allow_flagged_nodepack_install'] = False +nodes = pathlib.Path(tmp) / 'custom_nodes' +nodes.mkdir() +core.get_default_custom_nodes_path = lambda: str(nodes) +manager = core.UnifiedManager() +core.unified_manager = manager +terminal = io.StringIO() +logging.getLogger().addHandler(logging.StreamHandler(terminal)) +status = 'NodeVersionStatusFlagged' +http = 200 +get = Mock(side_effect=lambda *a, **k: SimpleNamespace(status_code=http, json=lambda: { + 'id': 'version-id', 'node_id': 'fixture', 'version': '2.0.0', 'status': status, + 'downloadUrl': 'https://example.invalid/fixture.zip', +})) +cnr_utils.requests.get = get +def download(url, directory, name): + with zipfile.ZipFile(pathlib.Path(directory) / name, 'w') as archive: + archive.writestr('__init__.py', 'VERSION = "2.0.0"') + archive.writestr('pyproject.toml', '[project]\\nname = "fixture"\\nversion = "2.0.0"\\n') +core.manager_downloader.download_url = Mock(side_effect=download) +core.manager_downloader.basic_download_url = core.manager_downloader.download_url +manager.execute_install_script = Mock(return_value=True) +''' + + +def run(body): + stub = ''' +comfy = types.ModuleType('comfy') +cli_args = types.ModuleType('comfy.cli_args') +cli_args.args = types.SimpleNamespace(listen='127.0.0.1') +sys.modules['comfy'] = comfy +sys.modules['comfy.cli_args'] = cli_args +''' + return _run_child(stub + PRELUDE + body) + + +@pytest.mark.parametrize('permission,allowed', [(None, True), ('true', True), ('false', False)]) +def test_cli_install_uses_only_parent_permission(permission, allowed): + data = run(f''' +permission = {permission!r} +os.environ.pop('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', None) +if permission is not None: + os.environ['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = permission +core.manager_funcs = core.ManagerFuncs() +args.listen = '127.0.0.1' if permission == 'false' else '0.0.0.0' +config['allow_flagged_nodepack_install'] = permission == 'false' +result = asyncio.run(manager.install_by_id('fixture', '2.0.0')) +print(json.dumps({{'ok': result.result, 'queries': get.call_count, + 'downloads': core.manager_downloader.download_url.call_count}})) +''') + assert data == {'ok': allowed, 'queries': 1, 'downloads': int(allowed)} + + +@pytest.mark.parametrize('status,permission,allowed', [ + ('Active', False, True), ('Pending', False, True), + ('Flagged', False, False), ('Flagged', True, True), +]) +def test_install_policy_before_download(status, permission, allowed): + data = run(f''' +status = {'NodeVersionStatus' + status!r} +core.manager_funcs.is_flagged_install_allowed = lambda: {permission!r} +result = asyncio.run(manager.install_by_id('fixture', '2.0.0')) +print(json.dumps({{'ok': result.result, 'message': result.msg, + 'terminal': terminal.getvalue(), + 'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count, + 'installed': (nodes / 'fixture/__init__.py').exists()}})) +''') + assert data['ok'] is allowed, data + assert data['installed'] is allowed, data + assert data['downloads'] == int(allowed), data + assert data['queries'] == 1, data + if not allowed: + assert data['message'] == ('This action is not allowed by the current security configuration. ' + 'See the terminal for details.') + for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]', + 'allow_flagged_nodepack_install = true', 'config.ini', + 'trusted private network', 'Restart ComfyUI'): + assert detail in data['terminal'], data + else: + assert 'allow_flagged_nodepack_install' not in data['terminal'] + + +@pytest.mark.parametrize('http', [403, 404, 500]) +def test_override_does_not_bypass_registry_refusal(http): + data = run(f''' +http = {http} +core.manager_funcs.is_flagged_install_allowed = lambda: True +result = asyncio.run(manager.install_by_id('fixture', '2.0.0')) +print(json.dumps({{'ok': result.result, 'queries': get.call_count, + 'downloads': core.manager_downloader.download_url.call_count}})) +''') + assert data == {'ok': False, 'queries': 1, 'downloads': 0} + + +@pytest.mark.parametrize('operation', ['install', 'lazy', 'instant']) +@pytest.mark.parametrize('return_postinstall', [False, True]) +def test_direct_cnr_execution_and_postinstall(operation, return_postinstall): + data = run(f''' +status = 'NodeVersionStatusActive' +operation = {operation!r} +return_postinstall = {return_postinstall!r} +installed = nodes / 'fixture' +if operation != 'install': + installed.mkdir() + (installed / '__init__.py').write_text('original') + (installed / '.tracking').write_text('__init__.py') + manager.active_nodes['fixture'] = ('1.0.0', str(installed)) +manager.reserve_cnr_switch = Mock(return_value=True) +if operation == 'install': + result = manager.cnr_install('fixture', instant_execution=True, + no_deps=True, return_postinstall=return_postinstall) +else: + result = manager.cnr_switch_version('fixture', instant_execution=operation == 'instant', + no_deps=True, return_postinstall=return_postinstall) +assert result.result, result.msg +assert result.target == ('2.0.0' if operation == 'instant' else None) +effect = manager.reserve_cnr_switch if operation == 'lazy' else manager.execute_install_script +before = effect.call_count +if return_postinstall: + assert result.postinstall() +if operation == 'lazy': + assert effect.call_args.args[1] == 'https://example.invalid/fixture.zip' + assert effect.call_args.args[-2:] == (True, 'NodeVersionStatusActive') +else: + assert effect.call_args.kwargs == {{'instant_execution': True, 'no_deps': True}} +print(json.dumps({{'queries': get.call_count, + 'downloads': core.manager_downloader.download_url.call_count, + 'before': before, 'after': effect.call_count, + 'content': (installed / '__init__.py').read_text()}})) +''') + assert data == {'queries': 1, 'downloads': int(operation != 'lazy'), + 'before': int(not return_postinstall), 'after': 1, + 'content': 'original' if operation == 'lazy' else 'VERSION = "2.0.0"'} + + +@pytest.mark.parametrize('operation', ['install', 'reinstall', 'lazy', 'instant', 'snapshot']) +def test_denial_preserves_installed_version(operation): + data = run(f''' +installed = nodes / 'fixture' +installed.mkdir() +(installed / '__init__.py').write_text('VERSION = "1.0.0"') +(installed / '.tracking').write_text('__init__.py') +manager.active_nodes['fixture'] = ('1.0.0', str(installed)) +manager.reserve_cnr_switch = Mock(return_value=True) +operation = {operation!r} +if operation == 'snapshot': + manager.reload = AsyncMock() + manager.get_custom_nodes = AsyncMock(return_value={{}}) + core.manager_util.restore_pip_snapshot = Mock() + snapshot = pathlib.Path(tmp) / 'snapshot.json' + snapshot.write_text(json.dumps({{'cnr_custom_nodes': {{'fixture': '2.0.0'}}, + 'git_custom_nodes': {{}}, 'file_custom_nodes': []}})) + asyncio.run(core.restore_snapshot(str(snapshot))) +elif operation in ('lazy', 'instant'): + result = manager.cnr_switch_version('fixture', '2.0.0', instant_execution=operation == 'instant') + assert not result.result, result.msg +elif operation == 'reinstall': + result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0')) + assert not result.result, result.msg +else: + result = asyncio.run(manager.install_by_id('fixture', '2.0.0')) + assert not result.result, result.msg +print(json.dumps({{'files': (installed / '__init__.py').read_text(), + 'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count, + 'reservations': manager.reserve_cnr_switch.call_count}})) +''') + assert data == {'files': 'VERSION = "1.0.0"', 'queries': 1, 'downloads': 0, 'reservations': 0} + + +@pytest.mark.parametrize('listen,allowed', [ + ('127.0.0.1', True), ('::1', True), ('127.0.0.1,::1', True), + ('0.0.0.0', False), ('::', False), ('0.0.0.0,::', False), + ('192.168.1.2', False), ('127.0.0.1,0.0.0.0', False), ('', False), +]) +def test_all_listener_addresses_must_be_loopback(listen, allowed): + data = run(f''' +print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, {listen!r}))) +''') + assert data is allowed + + +@pytest.mark.parametrize('addresses,allowed', [ + (['127.0.0.1', '::1'], True), (['127.0.0.1', '192.168.1.2'], False), + ([], False), (None, False), +]) +def test_hostname_must_resolve_exclusively_to_loopback(addresses, allowed): + data = run(f''' +addresses = {addresses!r} +if addresses is None: + manager_util.socket.getaddrinfo = Mock(side_effect=OSError('unresolvable')) +else: + manager_util.socket.getaddrinfo = Mock(return_value=[ + (0, 0, 0, '', (ip, 0)) for ip in addresses]) +print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, 'host.test'))) +''') + assert data is allowed + + +@pytest.mark.parametrize('state', ['nightly', 'disabled']) +def test_denial_preserves_enabled_state(state): + data = run(f''' +state = {state!r} +installed = nodes / ('fixture' if state == 'nightly' else '.disabled/fixture@1_0_0') +installed.mkdir(parents=True) +(installed / '__init__.py').write_text('original') +if state == 'nightly': + manager.active_nodes['fixture'] = ('nightly', str(installed)) +else: + manager.cnr_inactive_nodes['fixture'] = {{'1.0.0': str(installed)}} +before = repr((manager.active_nodes, manager.cnr_inactive_nodes)) +result = asyncio.run(manager.install_by_id('fixture', '2.0.0')) +print(json.dumps({{'ok': result.result, 'original': (installed / '__init__.py').read_text(), + 'unchanged': before == repr((manager.active_nodes, manager.cnr_inactive_nodes)), + 'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count}})) +''') + assert data == {'ok': False, 'original': 'original', 'unchanged': True, 'queries': 1, 'downloads': 0} + + +def test_exact_installed_version_enables_without_registry_lookup(): + data = run(''' +installed = nodes / '.disabled/fixture@2_0_0' +installed.mkdir(parents=True) +(installed / '__init__.py').write_text('already installed') +manager.cnr_inactive_nodes['fixture'] = {'2.0.0': str(installed)} +result = asyncio.run(manager.install_by_id('fixture', '2.0.0')) +assert manager.active_nodes['fixture'] == ('2.0.0', str(nodes / 'fixture')) +assert asyncio.run(manager.install_by_id('fixture', '2.0.0')).action == 'skip' +print(json.dumps({'ok': result.result, 'old_path': installed.exists(), + 'content': (nodes / 'fixture/__init__.py').read_text(), 'queries': get.call_count, + 'downloads': core.manager_downloader.download_url.call_count, + 'scripts': manager.execute_install_script.call_count})) +''') + assert data == {'ok': True, 'old_path': False, 'content': 'already installed', + 'queries': 0, 'downloads': 0, 'scripts': 0} + + +def test_failed_uninstall_stops_reinstall(): + data = run(''' +status = 'NodeVersionStatusActive' +manager.unified_uninstall = Mock(return_value=core.ManagedResult('uninstall').fail('cannot remove')) +result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0')) +print(json.dumps({'ok': result.result, 'message': result.msg, 'queries': get.call_count, + 'downloads': core.manager_downloader.download_url.call_count})) +''') + assert data == {'ok': False, 'message': 'cannot remove', 'queries': 1, 'downloads': 0} + + +@pytest.mark.parametrize('version', ['nightly', 'unknown']) +def test_git_reinstall_replaces_pack_and_reruns_script(version): + data = run(f''' +version = {version!r} +installed = nodes / 'fixture' +installed.mkdir() +(installed / 'original.txt').write_text('original') +repo_url = 'https://example.invalid/fixture' +if version == 'nightly': + manager.active_nodes['fixture'] = ('nightly', str(installed)) +else: + manager.unknown_active_nodes['fixture'] = (repo_url, str(installed)) +manager.get_custom_nodes = AsyncMock(return_value={{ + 'fixture': {{'repository': repo_url, 'files': [repo_url]}}, +}}) +get.side_effect = AssertionError('Git needs no CNR install query') +manager.processed_install.add(str(installed / 'install.py')) +manager.execute_install_script = core.UnifiedManager.execute_install_script.__get__(manager) +core.try_install_script = Mock(return_value=True) +def clone(url, path, **kwargs): + assert url == repo_url + path = pathlib.Path(path) + assert not path.exists() + path.mkdir() + (path / 'replacement.txt').write_text('installed') + (path / 'install.py').write_text('') + assert manager.execute_install_script(url, str(path)) + return core.ManagedResult('install-git') +manager.repo_install = Mock(side_effect=clone) +result = asyncio.run(manager.reinstall_by_id('fixture', version)) +print(json.dumps({{'ok': result.result, 'original': (installed / 'original.txt').exists(), + 'replacement': (installed / 'replacement.txt').read_text(), + 'clones': manager.repo_install.call_count, 'scripts': core.try_install_script.call_count}})) +''') + assert data == {'ok': True, 'original': False, 'replacement': 'installed', 'clones': 1, 'scripts': 1} + + +def test_config_defaults_and_manual_edits_survive_settings_save(): + data = _run_child(''' +write_ini('[default]\\nsecurity_level = normal\\n') +first = fresh_read()['allow_flagged_nodepack_install'] +write_ini('[default]\\nsecurity_level = normal\\nallow_flagged_nodepack_install = true\\n') +manager_core.get_config()['db_mode'] = 'local' +manager_core.write_config() +cached = manager_core.get_config()['allow_flagged_nodepack_install'] +fresh = fresh_read()['allow_flagged_nodepack_install'] +print(json.dumps([first, cached, fresh])) +''') + assert data == [False, False, True] + + +@pytest.mark.parametrize('raw,expected', [('TRUE', True), ('false', False), ('garbage', False)]) +def test_config_value_roundtrip(raw, expected): + data = _run_child(f''' +write_ini('[default]\\nallow_flagged_nodepack_install = {raw}\\n') +loaded = fresh_read()['allow_flagged_nodepack_install'] +manager_core.get_config()['allow_flagged_nodepack_install'] = not loaded +manager_core.write_config() +print(json.dumps([loaded, fresh_read()['allow_flagged_nodepack_install']])) +''') + assert data == [expected, not expected] diff --git a/tests/test_install_flags_gates.py b/tests/test_install_flags_gates.py index 1dcac1abf..ae9ec6ca4 100644 --- a/tests/test_install_flags_gates.py +++ b/tests/test_install_flags_gates.py @@ -56,7 +56,7 @@ _WANTED_CONSTS = { _HANDLER_NAMES = { "install_custom_node_git_url", # S-A "install_custom_node_pip", # S-B - "install_custom_node", # S-C + "_queue_node_install", # S-C } @@ -435,14 +435,14 @@ class BindingProofTest(unittest.TestCase): for name, flag in ( ("install_custom_node_git_url", "allow_git_url_install"), ("install_custom_node_pip", "allow_pip_install"), - ("install_custom_node", "allow_git_url_install"), + ("_queue_node_install", "allow_git_url_install"), ): with self.subTest(handler=name): src = ast.unparse(HANDLERS[name]) self.assertIn("is_dedicated_install_allowed(", src) self.assertIn(flag, src) self.assertIn("args.listen", src) - sc_literals = self._ias_literal_calls(HANDLERS["install_custom_node"]) + sc_literals = self._ias_literal_calls(HANDLERS["_queue_node_install"]) self.assertIn("middle", sc_literals, "entry gate must stay UNCHANGED") self.assertIn(None, sc_literals, "a variable-arg retained path must remain") diff --git a/tests/test_write_config_persistence.py b/tests/test_write_config_persistence.py index cd1241b6f..fa1590acc 100644 --- a/tests/test_write_config_persistence.py +++ b/tests/test_write_config_persistence.py @@ -299,7 +299,8 @@ class WriteConfigPersistenceTest(unittest.TestCase): "security_level": cp["default"].get("security_level", ""), "allow_git_url_install": cp["default"].get( "allow_git_url_install", ""), - "key_count": len(cp["default"]), + "allow_flagged_nodepack_install": cp["default"].get( + "allow_flagged_nodepack_install", ""), })) """ ) @@ -310,10 +311,8 @@ class WriteConfigPersistenceTest(unittest.TestCase): "T5: the install flag must bootstrap secure-by-default", ) self.assertEqual( - 18, payload["key_count"], - "T5: bootstrap wrote %d keys; write_config persists 18 " - "(manager_core.py:1685-1704). A change to that count is a scope " - "signal, not a nit." % payload["key_count"], + "False", payload["allow_flagged_nodepack_install"], + "T5: flagged installs must bootstrap with the override disabled", ) def test_t6_persisted_key_is_not_reclobbered(self):