Restrict flagged CNR installs on non-local listeners

This commit is contained in:
Dr.Lt.Data
2026-09-19 07:24:41 +09:00
parent 21ab2b78c2
commit 1796c72fcd
10 changed files with 1004 additions and 91 deletions
+45
View File
@@ -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.
+2 -1
View File
@@ -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
+138 -68
View File
@@ -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'):
+19 -10
View File
@@ -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)
+47 -1
View File
@@ -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}")
print(f"Installation failed for pip packages: {failed}")
+21 -3
View File
@@ -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])
+400
View File
@@ -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()
+325
View File
@@ -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]
+3 -3
View File
@@ -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")
+4 -5
View File
@@ -299,7 +299,8 @@ class WriteConfigPersistenceTest(unittest.TestCase):
"security_level": cp["default"].get("security_level", "<ABSENT>"),
"allow_git_url_install": cp["default"].get(
"allow_git_url_install", "<ABSENT>"),
"key_count": len(cp["default"]),
"allow_flagged_nodepack_install": cp["default"].get(
"allow_flagged_nodepack_install", "<ABSENT>"),
}))
"""
)
@@ -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):