Restrict flagged CNR installs on non-local listeners

This commit is contained in:
Dr.Lt.Data
2026-09-19 08:13:38 +09:00
parent 4ad94b2048
commit 0c8f423f52
12 changed files with 1325 additions and 168 deletions
+52 -2
View File
@@ -216,6 +216,7 @@ The following settings are applied based on the section marked as `is_default`.
security_level = <Set the security level => strong|normal|normal-|weak>
allow_git_url_install = <Allow installing custom nodes from arbitrary git URLs. Independent of security_level. Default: False>
allow_pip_install = <Allow installing arbitrary pip packages via the Manager. Independent of security_level. Default: False>
allow_flagged_nodepack_install = <Allow flagged CNR versions on non-local listeners in trusted private networks. Default: False>
always_lazy_install = <Whether to perform dependency installation on restart even in environments other than Windows.>
network_mode = <Set the network mode => public|private|offline|personal_cloud>
```
@@ -328,8 +329,8 @@ The security settings are applied based on whether the ComfyUI server's listener
|-------------|---------------------------------------------------------------------------------------------------------------------------------------|
| high+ | * **Switch ComfyUI version**<BR>* **Fix nodepack** |
| high | _(no features at this tier — `Fix nodepack` promoted to `high+` to align the enforcement gate with the `SECURITY_MESSAGE_HIGH_P` log text)_ |
| middle+ | * Uninstall/Update<BR>* Installation of nodepack registered in the `default channel`.<BR>* Restore/Remove Snapshot<BR>* Install model |
| middle | * Restart |
| middle+ | * Uninstall/Update<BR>* Installation of Git/nightly nodepacks registered in the `default channel`.<BR>* Restore/Remove Snapshot<BR>* Install model |
| middle | * Restart<BR>* Installation of CNR release versions (subject to the flagged-version policy below) |
| low | * Update ComfyUI |
* **Note**: `Install via git url` and `pip install` are no longer gated by `security_level` — they moved to the dedicated flags `allow_git_url_install` / `allow_pip_install`. Installation of a nodepack registered not in the `default channel` likewise requires `allow_git_url_install` (in addition to the `middle+` level preconditions) instead of a `high+` security level. See the [Dedicated install flags](#dedicated-install-flags-allow_git_url_install--allow_pip_install) subsection below.
@@ -359,6 +360,55 @@ The `Install via git url` and `pip install` features are governed by two dedicat
* Changes to these flags require a restart of ComfyUI to take effect.
* Migration note: if you previously relied on `security_level = weak` or `normal-` to use these features, you must now opt in explicitly by setting the flags in the `[default]` section of `config.ini`. The flags are not auto-seeded from your `security_level`.
### Flagged CNR nodepacks
Before downloading a CNR release, Manager checks the selected version's `status`
in the existing `/nodes/{id}/install` response. No additional Registry request is
needed. The check applies to both the default and legacy Manager implementations,
including CNR version changes, updates, and snapshot restores. Legacy batch
reinstallation checks the target before removing the existing nodepack, and a
policy refusal preserves the current installation. For flagged-policy denials,
the UI reports that the current security configuration does not allow the action
and directs users to the terminal. Only the terminal provides loopback-only
`--listen` examples, the private network override setting, and restart instructions.
* Active versions continue through the normal install flow. CNR release installs
use the `middle` security level, so they are available on non-local listeners
with `security_level = normal`, `normal-`, or `weak`. `strong` still denies
these install requests.
* Flagged versions (`NodeVersionStatusFlagged`) must satisfy the same installation
conditions as Active versions. In addition, **all** addresses in `--listen`
must resolve to loopback, such as `127.0.0.1` and `::1`, unless the explicit
override below is enabled.
* To allow flagged versions on a non-local listener in a **trusted private
network**, an administrator can explicitly opt in:
```ini
[default]
allow_flagged_nodepack_install = true
```
* The option defaults to `false`. Only `true` (case-insensitive) enables it;
missing or invalid values are treated as `false`. Restart ComfyUI after editing it.
* With this option enabled, the flagged-version check allows installation
regardless of the listen address. Manager does not automatically detect a
private network. This option does not require `network_mode = private`, and
`network_mode = personal_cloud` alone does not allow flagged versions.
* CNR API refusals, including banned or unavailable packages, still prevent
installation. This option does not override `security_level = strong` or
the separate Git/nightly and pip installation policies.
* 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.
* Newly scheduled deferred version switches retain the returned version status
and check it against the current listen address and option again at restart.
This uses the saved status; it does not query CNR again for later status changes.
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.
# Disclaimer
+2 -1
View File
@@ -134,6 +134,7 @@ class NodeVersion:
id: str
version: str
download_url: str
status: str = ''
def map_node_version(api_node_version):
@@ -165,6 +166,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', ''),
)
@@ -257,4 +259,3 @@ def read_cnr_id(fullpath):
pass
return None
@@ -27,6 +27,22 @@ from aiohttp import web
is_personal_cloud_mode = False
handler_policy = {}
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.'
)
# CORS "simple request" Content-Type set per Fetch spec §3.2.3. Browsers send
# <form method=POST> submissions with one of these three MIME types and do NOT
@@ -83,6 +99,32 @@ def is_loopback(address):
return False
def is_loopback_listener(listen_address: str) -> bool:
"""All addresses bound by --listen must resolve exclusively to loopback."""
import socket
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
if is_loopback(address):
continue
try:
resolved = socket.getaddrinfo(address, None, type=socket.SOCK_STREAM)
except OSError:
return False
if not resolved or any(not is_loopback(item[4][0]) 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 is_dedicated_install_allowed(flag_value: bool, listen_address: str, network_mode: str) -> bool:
"""P-direct predicate for the dedicated install flags (goal265-spec.md §1.2).
+71 -37
View File
@@ -38,6 +38,7 @@ from ..common import cm_global
from ..common.config_writer import DirtyTrackingConfig, write_config_merged
from ..common import cnr_utils
from ..common import manager_util
from ..common import manager_security
from ..common import git_utils
from ..common import manager_downloader
from ..common.node_package import InstalledNodePackage
@@ -881,10 +882,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(context.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}")
@@ -910,35 +911,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_security.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
raise PermissionError(manager_security.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)
@@ -948,23 +959,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)
@@ -1009,7 +1009,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:
@@ -1290,10 +1290,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)
@@ -1539,6 +1553,14 @@ class UnifiedManager:
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}')
if self.is_enabled(node_id, 'nightly'):
# disable nightly nodes
self.unified_disable(node_id, False) # NOTE: don't return from here
@@ -1550,12 +1572,12 @@ 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
@@ -1674,6 +1696,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 run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
print(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -1707,6 +1733,7 @@ WRITTEN_CONFIG_KEYS = (
'verbose',
'allow_git_url_install',
'allow_pip_install',
'allow_flagged_nodepack_install',
)
@@ -1751,6 +1778,7 @@ def read_config():
'verbose': get_bool('verbose', False),
'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),
}
except Exception:
@@ -1781,6 +1809,7 @@ def read_config():
'verbose': False,
'allow_git_url_install': False,
'allow_pip_install': False,
'allow_flagged_nodepack_install': False,
}
@@ -3209,15 +3238,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'):
+10 -7
View File
@@ -44,7 +44,7 @@ from comfyui_manager.glob.utils import (
from server import PromptServer
from . import manager_core as core
from ..common import manager_util
from ..common import manager_util, manager_security
from ..common import cm_global
from ..common import manager_downloader
from ..common import context
@@ -133,6 +133,9 @@ def error_response(
class ManagerFuncsInComfyUI(core.ManagerFuncs):
def is_flagged_install_allowed(self):
return core.get_config()['allow_flagged_nodepack_install'] or manager_security.is_loopback_listener(args.listen)
def run_script(self, cmd, cwd="."):
if len(cmd) > 0 and cmd[0].startswith("#"):
logging.error(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -815,10 +818,6 @@ async def task_worker():
await core.unified_manager.reload(ManagerDatabaseSource.cache.value)
async def do_install(params: InstallPackParams) -> str:
if not security_utils.is_allowed_security_level('middle+'):
logging.error(SECURITY_MESSAGE_MIDDLE_P)
return OperationResult.failed.value
node_id = params.id
node_version = params.selected_version
channel = params.channel
@@ -844,6 +843,11 @@ async def task_worker():
return f"Cannot resolve install target: '{node_id}@{node_version}'"
node_name, version_spec, is_specified = node_spec
# CNR versions have their own flagged-status check before download.
level = 'middle+' if version_spec in ('nightly', 'unknown') else 'middle'
if not security_utils.is_allowed_security_level(level):
logging.error(SECURITY_MESSAGE_MIDDLE_P if level == 'middle+' else SECURITY_MESSAGE_MIDDLE)
return OperationResult.failed.value
res = await core.unified_manager.install_by_id(
node_name,
version_spec,
@@ -915,7 +919,7 @@ async def task_worker():
base_res["msg"] = OperationResult.success.value
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})"
)
@@ -2176,4 +2180,3 @@ threading.Thread(target=lambda: asyncio.run(default_cache_update())).start()
if not os.path.exists(context.manager_config_path):
core.get_config()
core.write_config()
+150 -86
View File
@@ -36,6 +36,7 @@ from ..common import cm_global
from ..common.config_writer import DirtyTrackingConfig, write_config_merged
from ..common import cnr_utils
from ..common import manager_util
from ..common import manager_security
from ..common import git_utils
from ..common import manager_downloader
from ..common.node_package import InstalledNodePackage
@@ -867,10 +868,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(context.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}")
@@ -896,35 +897,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_security.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
raise PermissionError(manager_security.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)
@@ -934,23 +945,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)
@@ -995,7 +995,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:
@@ -1277,10 +1277,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)
@@ -1469,31 +1483,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:
# Fallback for nightly only: use repository URL from CNR map
# when node is registered in CNR but absent from nightly manifest
if version_spec == 'nightly':
cnr_fallback = self.cnr_map.get(node_id)
if cnr_fallback is not None and cnr_fallback.get('repository'):
repo_url = cnr_fallback['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
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}")
@@ -1501,32 +1497,16 @@ 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':
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
elif version_spec in ('unknown', 'nightly'):
return self._install_git(node_id, version_spec, repo_url, instant_execution, no_deps, return_postinstall)
if version_spec == 'nightly':
# disable cnr nodes
if self.is_enabled(node_id, 'cnr'):
self.unified_disable(node_id, False)
# use `repo name` as a dir name instead of `cnr id` if system added nodepack (i.e. publisher is null)
cnr = self.cnr_map.get(node_id)
if cnr is not None and cnr.get('publisher') is None:
repo_name = os.path.basename(git_utils.normalize_url(repo_url))
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), repo_name))
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}')
if self.is_enabled(node_id, 'nightly'):
# disable nightly nodes
@@ -1539,17 +1519,89 @@ 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 not None:
return node['files'][0] if version_spec == 'unknown' else node['repository']
if version_spec == 'nightly':
cnr = self.cnr_map.get(node_id)
if cnr is not None and cnr.get('repository'):
return cnr['repository']
raise LookupError(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
def _install_git(self, node_id, version_spec, repo_url, instant_execution=False, no_deps=False, return_postinstall=False):
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
if version_spec == 'nightly':
# disable cnr nodes
if self.is_enabled(node_id, 'cnr'):
self.unified_disable(node_id, False)
# use `repo name` as a dir name instead of `cnr id` if system added nodepack (i.e. publisher is null)
cnr = self.cnr_map.get(node_id)
if cnr is not None and cnr.get('publisher') is None:
repo_name = os.path.basename(git_utils.normalize_url(repo_url))
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), repo_name))
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()
@@ -1660,6 +1712,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 run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
print(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -1691,6 +1747,7 @@ WRITTEN_CONFIG_KEYS = (
'db_mode',
'allow_git_url_install',
'allow_pip_install',
'allow_flagged_nodepack_install',
)
@@ -1730,6 +1787,7 @@ def read_config():
'db_mode': default_conf.get('db_mode', DBMode.CACHE.value).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),
}
except Exception:
@@ -1755,6 +1813,7 @@ def read_config():
'db_mode': DBMode.CACHE.value,
'allow_git_url_install': False,
'allow_pip_install': False,
'allow_flagged_nodepack_install': False,
}
@@ -3192,15 +3251,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'):
+29 -27
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_security.is_loopback_listener(args.listen)
def run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
logging.error(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -468,7 +471,7 @@ async def task_worker():
await core.unified_manager.reload('cache')
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:
@@ -478,7 +481,15 @@ 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) # discard post install if skip_post_install mode
# Check the resolved target, including requests with misleading metadata.
level = 'middle+' if version_spec in ('nightly', 'unknown') else 'middle'
if not is_allowed_security_level(level):
logging.error(SECURITY_MESSAGE_MIDDLE_P if level == 'middle+' else SECURITY_MESSAGE_MIDDLE)
return 'Installation blocked by security policy'
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)
if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']:
logging.error(f"[ComfyUI-Manager] Installation failed:\n{res.msg}")
@@ -530,7 +541,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:
@@ -705,8 +716,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 == 'enable':
msg = await do_enable(item)
elif kind == 'install-model':
@@ -770,19 +781,9 @@ async def queue_batch(request):
if k == 'update_all':
await _update_all({'mode': v})
elif k == 'reinstall':
elif k in ('install', 'reinstall'):
for x in v:
res = await _uninstall_custom_node(x)
if res.status != 200:
failed.add(x['id'])
else:
res = await _install_custom_node(x)
if res.status != 200:
failed.add(x['id'])
elif k == 'install':
for x in v:
res = await _install_custom_node(x)
res = await _queue_node_install(x, k)
if res.status != 200:
failed.add(x['id'])
@@ -1459,15 +1460,15 @@ async def queue_count(request):
'is_processing': is_processing})
async def _install_custom_node(json_data):
if not is_allowed_security_level('middle+'):
logging.error(SECURITY_MESSAGE_MIDDLE_P)
async def _queue_node_install(json_data, operation):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403, text="A security error has occurred. Please check the terminal logs")
# non-nightly cnr is safe
# CNR versions are checked for flagged status by the install worker.
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
@@ -1509,10 +1510,11 @@ async def _install_custom_node(json_data):
else:
return web.Response(status=404, text=f"Following node pack doesn't provide `nightly` version: ${git_url}")
# goal265 S-C (middle+ entry gate above UNCHANGED): unknown git URL ('high+')
# -> dedicated-flag full predicate replaces the security_level check (spec §1.2);
# unknown pip ('block') -> unconditional deny via is_allowed_security_level (Q1).
# Flag-deny PRESERVES today's 404 response shape at this position (R1).
if risky_level != 'low' and not is_allowed_security_level('middle+'):
logging.error(SECURITY_MESSAGE_MIDDLE_P)
return web.Response(status=403, text="A security error has occurred. Please check the terminal logs")
# Unknown Git installs also require the dedicated flag; preserve the 404 response.
if risky_level == 'high+':
if not _dedicated_install_allowed('allow_git_url_install'):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(
@@ -1523,7 +1525,7 @@ async def _install_custom_node(json_data):
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
temp_queue_batch.append(("install", install_item))
temp_queue_batch.append((operation, install_item))
return web.Response(status=200)
+23 -3
View File
@@ -570,6 +570,13 @@ if os.path.exists(restore_snapshot_path):
if 'COMFYUI_PATH' not in new_env:
new_env['COMFYUI_PATH'] = os.path.dirname(folder_paths.__file__)
from comfy.cli_args import args
from .common import manager_security
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
new_env['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = str(
allow_flagged or manager_security.is_loopback_listener(args.listen)
).lower()
cmd_str = [sys.executable, '-m', 'cm_cli', 'restore-snapshot', restore_snapshot_path]
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
@@ -619,9 +626,19 @@ 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
from .common.manager_security import FLAGGED_NODEPACK_INSTALL_GUIDANCE, is_cnr_install_allowed
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
if not 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(FLAGGED_NODEPACK_INSTALL_GUIDANCE)
return False
# 1. download
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
@@ -669,6 +686,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
@@ -724,8 +743,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])
+509
View File
@@ -0,0 +1,509 @@
"""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 ast
import configparser
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 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-update', 'fixture-reinstall', '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()
REGISTRY_BOOTSTRAP = '''
import json, os, urllib.request
from comfyui_manager.common import 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))
'''
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"
RESTORE_BOOTSTRAP = '''
import os
from comfy.cli_args import args
args.base_directory = os.environ['CM_E2E_BASE']
''' + REGISTRY_BOOTSTRAP + '''
from comfyui_manager.legacy import manager_core as core
core.unified_manager.custom_node_map_cache[(core.normalize_channel('default'), 'cache')] = core.NormalizedKeyDict()
'''
def prepare_environment(root, override, security, registry):
(root / 'custom_nodes').mkdir(exist_ok=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'
)
# The real restore subprocess inherits the same isolated Registry and paths.
(root / 'sitecustomize.py').write_text(
"import sys\nif sys.argv[0] == '-m' and 'cm_cli' in sys.orig_argv:\n"
+ '\n'.join(' ' + line for line in RESTORE_BOOTSTRAP.splitlines())
)
env = dict(os.environ, PYTHONUNBUFFERED='1',
PYTHONPATH=os.pathsep.join([str(root), str(REPO_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_REGISTRY=registry.url)
return env
@contextmanager
def running_server(root, tree, listen, override, security, registry):
env = prepare_environment(root, override, security, registry)
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
base = f'http://127.0.0.1:{port}'
command = [str(E2E_ROOT / 'venv/bin/python'), '-c', BOOTSTRAP,
'--cpu', '--enable-manager', '--base-directory', str(root),
'--listen', listen, '--port', str(port),
'--database-url', f'sqlite:///{root / "comfyui.db"}']
if tree == 'legacy':
command.append('--enable-manager-legacy-ui')
log_path = root / 'server.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()
try:
if requests.get(base + '/system_stats', timeout=1).status_code == 200:
break
except requests.RequestException:
pass
time.sleep(.2)
else:
pytest.fail('ComfyUI did not start:\n' + log_path.read_text())
yield SimpleNamespace(root=root, tree=tree, base=base, listen=listen,
override=override, security=security, registry=registry,
process=process, command=command, env=env)
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@pytest.fixture(scope='module', params=[
(tree, listen, override, security)
for tree in ('legacy', 'glob')
for listen, override, security in [
('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):
tree, listen, override, security = request.param
root = tmp_path_factory.mktemp('flagged-' + tree)
with running_server(root, tree, listen, override, security, registry) as server:
yield server
def install(server, node, version, operation='install'):
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'}
if server.tree == 'legacy':
response = requests.post(server.base + '/v2/manager/queue/batch',
json={'batch_id': ui_id, operation: [params]}, timeout=10)
response.raise_for_status()
if response.json()['failed']:
return response.json()
query = {'id': ui_id}
else:
if operation == 'update':
params = {'node_name': node, 'node_ver': '1.0.0'}
response = requests.post(server.base + '/v2/manager/queue/task', json={
'ui_id': ui_id, 'client_id': 'flagged-e2e', 'kind': operation, 'params': params,
}, timeout=10)
response.raise_for_status()
requests.post(server.base + '/v2/manager/queue/start', timeout=10).raise_for_status()
query = {'ui_id': ui_id}
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
response = requests.get(server.base + '/v2/manager/queue/history', params=query, timeout=5)
if response.status_code == 200:
data = response.json()
if server.tree == 'legacy' or data.get('history'):
return data
time.sleep(.1)
pytest.fail('Install did not complete:\n' + (server.root / 'server.log').read_text()[-6000:])
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):
message = json.dumps(result)
assert ('This action is not allowed by the current security configuration. '
'See the terminal for details.') in message, result
assert 'allow_flagged_nodepack_install' not in message, result
assert '--listen' not in message, result
assert_terminal_guidance(log)
@pytest.mark.parametrize('node,version', [
('fixture-active', '1.0.0'), ('fixture-flagged', '2.0.0'),
('fixture-pending', '3.0.0'), ('fixture-banned', '1.0.0'),
('fixture-latest', 'latest'),
])
def test_install_policy(comfy_server, node, version):
server = comfy_server
log_before = len((server.root / 'server.log').read_text())
before = len(server.registry.calls)
result = install(server, node, version)
calls = server.registry.calls[before:]
strong = server.security == 'strong'
flagged = version in ('2.0.0', 'latest')
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.root / '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, calls
if allowed:
assert marker.read_text() == ('2.0.0' if version == 'latest' else version)
if server.tree == 'legacy':
assert list(result['nodepack_result'].values()) == ['success'], result
else:
assert result['history']['result'] == 'success', result
elif flagged and not strong:
assert_flagged_denial(result, (server.root / 'server.log').read_text()[log_before:])
@pytest.mark.parametrize('operation', ['install', 'update'])
def test_flagged_transition_preserves_active_version(comfy_server, operation):
server = comfy_server
if server.security == 'strong':
pytest.skip('No installed version in strong mode')
# Keep this state separate from test_install_policy, regardless of test order.
node = 'fixture-switch' if operation == 'install' else 'fixture-update'
marker = server.root / 'custom_nodes' / node / 'installed.txt'
install(server, node, '1.0.0')
log_before = len((server.root / 'server.log').read_text())
before = len(server.registry.calls)
result = install(server, node, '2.0.0', operation)
calls = server.registry.calls[before:]
assert sum('/install' in call for call in calls) == 1
assert not any(call.startswith('/zip/') for call in calls)
assert marker.read_text() == '1.0.0'
reserved = server.root / 'user/__manager/startup-scripts/install-scripts.txt'
if server.override or server.listen == '127.0.0.1':
assert '#LAZY-CNR-SWITCH-SCRIPT' in reserved.read_text()
if server.tree == 'legacy':
assert list(result['nodepack_result'].values()) == ['success'], result
else:
assert result['history']['result'] == 'success', result
else:
assert not reserved.exists()
assert_flagged_denial(result, (server.root / 'server.log').read_text()[log_before:])
@pytest.mark.parametrize('listen,override', [
('127.0.0.1', False), ('0.0.0.0', False), ('0.0.0.0', True),
])
@pytest.mark.parametrize('version', ['1.0.0', '2.0.0'])
def test_legacy_reinstall_preserves_denied_pack_and_runs_allowed_script(
tmp_path, registry, listen, override, version):
with running_server(tmp_path, 'legacy', listen, override, 'normal', registry) as server:
node = 'fixture-reinstall'
install(server, node, '1.0.0')
marker = tmp_path / 'custom_nodes' / node / 'installed.txt'
assert marker.read_text() == '1.0.0'
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
stale = marker.parent / 'stale.txt'
stale.write_text('removed only by an allowed reinstall')
before = len(registry.calls)
log_before = len((server.root / 'server.log').read_text())
result = install(server, node, version, 'reinstall')
calls = registry.calls[before:]
allowed = version == '1.0.0' or override or listen == '127.0.0.1'
assert sum('/install' in call for call in calls) == 1, calls
assert any(call.startswith('/zip/') for call in calls) is allowed, calls
if allowed:
assert list(result['nodepack_result'].values()) == ['success'], result
assert marker.read_text() == version, result
assert not stale.exists()
else:
assert_flagged_denial(result, (server.root / 'server.log').read_text()[log_before:])
assert marker.exists(), 'Rejected reinstall removed the existing pack'
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
assert stale.exists()
def test_nonlocal_override_does_not_allow_nightly(comfy_server):
server = comfy_server
if server.listen == '127.0.0.1':
pytest.skip('This regression checks the non-local Git gate')
log_path = server.root / 'server.log'
log_before = len(log_path.read_text())
before = len(server.registry.calls)
result = install(server, 'fixture-nightly', 'nightly')
# A missing node or a failed clone must not count as a security rejection.
if server.tree == 'legacy':
assert result['failed'] == ['fixture-nightly'], result
else:
assert result['history']['result'] == 'failed', result
expected_reason = ('normal or below' if server.security == 'strong' and server.tree == 'legacy'
else 'network_mode must be set to `personal_cloud`')
assert expected_reason in log_path.read_text()[log_before:], result
assert not (server.root / 'custom_nodes/fixture-nightly').exists()
assert not any('/install' in call or call.startswith('/zip/')
for call in server.registry.calls[before:])
@pytest.mark.parametrize('tree', ['legacy', 'glob'])
@pytest.mark.parametrize('listen,override,restart_listen,restart_override,allowed', [
('127.0.0.1', False, '0.0.0.0', False, False),
('0.0.0.0', True, '0.0.0.0', False, False),
('127.0.0.1', False, '127.0.0.1', False, True),
('0.0.0.0', True, '0.0.0.0', True, True),
])
@pytest.mark.parametrize('stored_status', [True, False])
def test_restart_applies_current_policy_without_registry_query(
tree, listen, override, restart_listen, restart_override, allowed, stored_status, tmp_path, registry):
with running_server(tmp_path, tree, listen, override, 'normal', registry) as server:
marker = server.root / 'custom_nodes/fixture-active/installed.txt'
install(server, 'fixture-active', '1.0.0')
reserved = server.root / 'user/__manager/startup-scripts/install-scripts.txt'
install(server, 'fixture-active', '2.0.0')
assert 'NodeVersionStatusFlagged' in reserved.read_text()
original_marker = (marker.read_bytes(), marker.stat().st_mtime_ns)
server.process.terminate()
server.process.wait(timeout=10)
if not stored_status:
record = ast.literal_eval(reserved.read_text().strip())
assert record[1] == '#LAZY-CNR-SWITCH-SCRIPT'
assert len(record) == 9
reserved.write_text(repr(record[:8]) + '\n')
config_path = server.root / 'user/__manager/config.ini'
config = configparser.ConfigParser()
config.read(config_path)
config['default']['allow_flagged_nodepack_install'] = str(restart_override)
with config_path.open('w') as config_file:
config.write(config_file)
command = list(server.command)
command[command.index('--listen') + 1] = restart_listen
before = len(server.registry.calls)
log_path = server.root / 'restart.log'
with log_path.open('w') as log:
process = subprocess.Popen(command, env=server.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(server.base + '/system_stats', timeout=1).status_code == 200:
break
except requests.RequestException:
pass
time.sleep(.2)
else:
pytest.fail(log_path.read_text()[-6000:])
calls = server.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, calls
if allowed:
assert marker.read_text() == '2.0.0', log_path.read_text()[-6000:]
else:
if stored_status:
assert_terminal_guidance(log_path.read_text())
else:
assert 'stored Registry status is missing' in log_path.read_text()
assert 'Request the installation again' in log_path.read_text()
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original_marker
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):
env = prepare_environment(tmp_path, False, 'normal', registry)
command = [str(E2E_ROOT / 'venv/bin/python'), '-m', 'cm_cli', 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.mark.parametrize('tree', ['legacy', 'glob'])
@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_restores_through_server_restart(
tmp_path, registry, tree, 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, tree, '127.0.0.1', False, 'normal', registry) as server:
if existing:
install(server, 'fixture-snapshot', '1.0.0')
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 + '/v2/snapshot/restore',
params={'target': snapshot.stem}, json={}, 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, tree, listen, override, 'normal', registry):
log = (tmp_path / '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, calls
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()
+431
View File
@@ -0,0 +1,431 @@
"""Exercise the real CNR client and both install implementations."""
import asyncio
import ast
import json
import logging
import os
import socket
import zipfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from _install_flags_testutil import import_context, import_reader
@pytest.fixture
def cnr_client(monkeypatch):
import_context()
from comfy.cli_args import args
from comfyui_manager.common import cnr_utils
monkeypatch.setattr(args, 'listen', '0.0.0.0')
return cnr_utils
@pytest.fixture(params=['glob', 'legacy'])
def core(request, cnr_client, monkeypatch, tmp_path):
core = import_reader(request.param)
monkeypatch.setattr(core.manager_funcs, 'is_flagged_install_allowed', lambda: False)
monkeypatch.setattr(core, 'get_config', lambda: {'allow_flagged_nodepack_install': False})
monkeypatch.setattr(core, 'get_default_custom_nodes_path', lambda: str(tmp_path))
return core
def api_response(status='NodeVersionStatusFlagged', http=200):
return SimpleNamespace(status_code=http, json=lambda: {
'id': 'version-id', 'node_id': 'fixture', 'version': '2.0.0',
'status': status, 'downloadUrl': 'https://example.invalid/fixture.zip',
})
@pytest.mark.parametrize('permission,allowed', [(None, True), ('true', True), ('false', False)])
def test_cli_install_uses_only_parent_permission(core, monkeypatch, permission, allowed, tmp_path):
from comfy.cli_args import args
monkeypatch.delenv('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', raising=False)
if permission is not None:
monkeypatch.setenv('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', permission)
monkeypatch.setattr(core, 'manager_funcs', core.ManagerFuncs())
monkeypatch.setattr(args, 'listen', '127.0.0.1' if permission == 'false' else '0.0.0.0')
monkeypatch.setattr(core, 'get_config', lambda: {'allow_flagged_nodepack_install': permission == 'false'})
get = Mock(return_value=api_response())
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
download = Mock(side_effect=lambda url, directory, name: (tmp_path / name).write_bytes(b'fixture'))
monkeypatch.setattr(core.manager_downloader, 'download_url', download)
monkeypatch.setattr(core.manager_util, 'extract_package_as_zip', lambda *a: ['__init__.py'])
manager = core.UnifiedManager()
manager.execute_install_script = Mock(return_value=True)
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert result.result is allowed, result.msg
assert get.call_count == 1
assert download.call_count == int(allowed)
@pytest.mark.parametrize('listen,allowed', [
('127.0.0.1', True), ('127.2.3.4', True), ('::1', True),
('127.0.0.1,::1', True), ('0.0.0.0', False), ('::', False),
('192.168.1.2', False), ('127.0.0.1,0.0.0.0', False),
('0.0.0.0,::', False), ('', False), ('127.0.0.1,', False),
])
def test_listener_policy(cnr_client, listen, allowed):
from comfyui_manager.common.manager_security import is_cnr_install_allowed
assert is_cnr_install_allowed('NodeVersionStatusFlagged', False, listen) is allowed
assert is_cnr_install_allowed('NodeVersionStatusFlagged', True, listen) is True
def test_hostname_requires_all_resolved_addresses_to_be_loopback(cnr_client, monkeypatch):
from comfyui_manager.common.manager_security import is_loopback_listener
def addresses(*hosts):
return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', (host, 0)) for host in hosts]
monkeypatch.setattr(socket, 'getaddrinfo', lambda *a, **k: addresses('127.0.0.1', '::1'))
assert is_loopback_listener('localhost')
monkeypatch.setattr(socket, 'getaddrinfo', lambda *a, **k: addresses('127.0.0.1', '192.168.1.2'))
assert not is_loopback_listener('localhost')
monkeypatch.setattr(socket, 'getaddrinfo', Mock(side_effect=socket.gaierror))
assert not is_loopback_listener('invalid.invalid')
@pytest.mark.parametrize('status,permission,allowed', [
('NodeVersionStatusActive', False, True), ('NodeVersionStatusPending', False, True),
('NodeVersionStatusFlagged', False, False), ('NodeVersionStatusFlagged', True, True),
])
def test_install_uses_status_from_one_response(core, monkeypatch, tmp_path, caplog, status, permission, allowed):
get = Mock(return_value=api_response(status))
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
monkeypatch.setattr(core.manager_funcs, 'is_flagged_install_allowed', lambda: permission)
download = Mock(side_effect=lambda url, directory, name: (tmp_path / name).write_bytes(b'fixture'))
monkeypatch.setattr(core.manager_downloader, 'download_url', download)
monkeypatch.setattr(core.manager_util, 'extract_package_as_zip', lambda *a: ['__init__.py'])
manager = core.UnifiedManager()
manager.execute_install_script = Mock(return_value=True)
result = manager.cnr_install('fixture', '2.0.0')
assert result.result is allowed, result.msg
if not allowed:
assert result.msg == ('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 caplog.text
else:
assert 'allow_flagged_nodepack_install' not in caplog.text
assert get.call_count == 1
assert download.call_count == int(allowed)
@pytest.mark.parametrize('http', [403, 404, 500])
def test_override_does_not_bypass_registry_refusal(core, monkeypatch, tmp_path, http):
monkeypatch.setattr(core.manager_funcs, 'is_flagged_install_allowed', lambda: True)
get = Mock(return_value=api_response(http=http))
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
download = Mock(side_effect=AssertionError('Registry refusal must prevent downloading'))
monkeypatch.setattr(core.manager_downloader, 'download_url', download)
manager = core.UnifiedManager()
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert result.result is False
assert result.msg == 'not available node: fixture@2.0.0'
assert get.call_count == 1
assert download.call_count == 0
assert not (tmp_path / 'fixture').exists()
@pytest.mark.parametrize('operation', ['install', 'lazy', 'instant'])
def test_direct_install_and_switch_reject_before_side_effects(core, monkeypatch, operation):
get = Mock(return_value=api_response())
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
download = Mock(side_effect=AssertionError('download must not happen'))
monkeypatch.setattr(core.manager_downloader, 'download_url', download)
monkeypatch.setattr(core.manager_downloader, 'basic_download_url', download)
manager = core.UnifiedManager()
manager.reserve_cnr_switch = Mock(side_effect=AssertionError('reservation must not happen'))
if operation == 'install':
result = manager.cnr_install('fixture', '2.0.0')
else:
result = manager.cnr_switch_version('fixture', '2.0.0', instant_execution=operation == 'instant')
assert result.result is False
assert result.msg == ('This action is not allowed by the current security configuration. '
'See the terminal for details.')
assert get.call_count == 1
@pytest.mark.parametrize('operation', ['install', 'lazy', 'instant'])
@pytest.mark.parametrize('return_postinstall', [False, True])
def test_direct_cnr_execution_and_postinstall(core, monkeypatch, tmp_path, operation, return_postinstall):
get = Mock(return_value=api_response('NodeVersionStatusActive'))
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
def download(url, directory, name):
with zipfile.ZipFile(Path(directory) / name, 'w') as archive:
archive.writestr('__init__.py', 'VERSION = "2.0.0"')
downloader = Mock(side_effect=download)
monkeypatch.setattr(core.manager_downloader, 'download_url', downloader)
monkeypatch.setattr(core.manager_downloader, 'basic_download_url', downloader)
manager = core.UnifiedManager()
installed = tmp_path / '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)
manager.execute_install_script = 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 is True, 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
assert effect.call_count == int(not return_postinstall)
if return_postinstall:
assert result.postinstall()
assert effect.call_count == 1
assert get.call_count == 1
assert downloader.call_count == int(operation != 'lazy')
assert (installed / '__init__.py').read_text() == (
'original' if operation == 'lazy' else 'VERSION = "2.0.0"')
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}
@pytest.mark.parametrize('installed', ['nightly', 'disabled-cnr', 'new'])
def test_rejection_preserves_existing_install_state(core, monkeypatch, installed):
get = Mock(return_value=api_response())
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
manager = core.UnifiedManager()
manager.is_enabled = lambda node, version=None: installed == 'nightly' and version == 'nightly'
manager.is_disabled = lambda node, version=None: installed == 'disabled-cnr' and version == 'cnr'
manager.unified_disable = Mock(side_effect=AssertionError('must not disable'))
manager.unified_enable = Mock(side_effect=AssertionError('must not enable'))
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert result.result is False
assert result.msg == ('This action is not allowed by the current security configuration. '
'See the terminal for details.')
assert get.call_count == 1
@pytest.mark.parametrize('installed', ['new', 'cnr', 'disabled-cnr'])
@pytest.mark.parametrize('instant', [False, True])
def test_preflight_response_is_reused(core, monkeypatch, installed, instant, tmp_path):
get = Mock(return_value=api_response('NodeVersionStatusActive'))
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
manager = core.UnifiedManager()
manager.is_enabled = lambda node, version=None: installed == 'cnr' and version == 'cnr'
manager.is_disabled = lambda node, version=None: installed == 'disabled-cnr' and version == 'cnr'
manager.unified_enable = Mock()
manager.active_nodes['fixture'] = ('1.0.0', str(tmp_path / 'fixture'))
manager.reserve_cnr_switch = Mock(return_value=True)
manager.execute_install_script = Mock(return_value=True)
marker = tmp_path / 'fixture/__init__.py'
if installed != 'new':
marker.parent.mkdir()
marker.write_text('original')
(marker.parent / '.tracking').write_text('__init__.py')
def download(url, directory, name):
(tmp_path / name).write_bytes(b'fixture')
def extract(archive, directory):
(Path(directory) / '__init__.py').write_text('replacement')
return ['__init__.py']
monkeypatch.setattr(core.manager_downloader, 'download_url', download)
monkeypatch.setattr(core.manager_downloader, 'basic_download_url', download)
monkeypatch.setattr(core.manager_util, 'extract_package_as_zip', extract)
result = asyncio.run(manager.install_by_id('fixture', '2.0.0', instant_execution=instant))
assert result.result is True
assert get.call_count == 1
if installed == 'new' or instant:
assert marker.read_text() == 'replacement'
manager.reserve_cnr_switch.assert_not_called()
manager.execute_install_script.assert_called_once()
assert manager.execute_install_script.call_args.kwargs['instant_execution'] is instant
else:
assert marker.read_text() == 'original'
manager.reserve_cnr_switch.assert_called_once()
manager.execute_install_script.assert_not_called()
def test_exact_installed_version_enables_without_registry_lookup(core, monkeypatch, tmp_path):
manager = core.UnifiedManager()
installed = tmp_path / '.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)}
query = Mock(side_effect=AssertionError('activation must not query the Registry'))
monkeypatch.setattr(core.cnr_utils.requests, 'get', query)
manager.execute_install_script = Mock(side_effect=AssertionError('activation must not install'))
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert result.result is True
assert (tmp_path / 'fixture/__init__.py').read_text() == 'already installed'
assert not installed.exists()
assert manager.active_nodes['fixture'] == ('2.0.0', str(tmp_path / 'fixture'))
assert asyncio.run(manager.install_by_id('fixture', '2.0.0')).action == 'skip'
query.assert_not_called()
manager.execute_install_script.assert_not_called()
def test_snapshot_denial_preserves_existing_pack_without_retry(core, monkeypatch, tmp_path):
manager = core.UnifiedManager()
monkeypatch.setattr(core, 'unified_manager', manager)
installed = tmp_path / 'fixture'
installed.mkdir()
marker = installed / '__init__.py'
marker.write_text('original')
(installed / '.tracking').write_text('__init__.py')
manager.active_nodes['fixture'] = ('1.0.0', str(installed))
manager.reload = AsyncMock()
manager.get_custom_nodes = AsyncMock(return_value={})
monkeypatch.setattr(core.manager_util, 'restore_pip_snapshot', Mock())
get = Mock(return_value=api_response())
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
download = Mock(side_effect=AssertionError('denied target must not download'))
monkeypatch.setattr(core.manager_downloader, 'download_url', download)
monkeypatch.setattr(core.manager_downloader, 'basic_download_url', download)
snapshot = tmp_path / '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)))
assert marker.read_text() == 'original'
assert manager.active_nodes['fixture'] == ('1.0.0', str(installed))
assert download.call_count == 0
assert get.call_count == 1
@pytest.mark.parametrize('core', ['legacy'], indirect=True)
def test_reinstall_stops_when_removal_fails(core, monkeypatch):
get = Mock(return_value=api_response('NodeVersionStatusActive'))
monkeypatch.setattr(core.cnr_utils.requests, 'get', get)
download = Mock(side_effect=AssertionError('failed removal must prevent downloading'))
monkeypatch.setattr(core.manager_downloader, 'download_url', download)
manager = core.UnifiedManager()
manager.unified_uninstall = Mock(return_value=core.ManagedResult('uninstall').fail('cannot remove'))
result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0'))
assert result.result is False
assert result.msg == 'cannot remove'
assert get.call_count == 1
assert download.call_count == 0
@pytest.mark.parametrize('core', ['legacy'], indirect=True)
@pytest.mark.parametrize('version,source', [
('nightly', 'manifest'), ('unknown', 'manifest'),
('nightly', 'cnr'), ('nightly', 'system-repository'),
])
def test_git_reinstall_replaces_existing_pack(core, monkeypatch, tmp_path, version, source):
manager = core.UnifiedManager()
directory = 'repository-name' if source == 'system-repository' else 'fixture'
installed = tmp_path / directory
installed.mkdir()
marker = installed / 'original.txt'
marker.write_text('original')
repo_url = f'https://example.invalid/{directory}'
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]},
} if source == 'manifest' else {})
if source != 'manifest':
manager.cnr_map['fixture'] = {
'repository': repo_url, 'publisher': None if source == 'system-repository' else {'id': 'fixture'},
}
manager.processed_install.add(str(installed / 'install.py'))
execute = Mock(return_value=True)
monkeypatch.setattr(core, 'try_install_script', execute)
monkeypatch.setattr(core.cnr_utils.requests, 'get', Mock(side_effect=AssertionError('Git needs no CNR install query')))
def clone(url, path, **kwargs):
assert url == repo_url
assert not Path(path).exists()
Path(path).mkdir()
(Path(path) / 'replacement.txt').write_text('installed')
(Path(path) / 'install.py').write_text('')
assert manager.execute_install_script(url, path)
return core.ManagedResult('install-git')
manager.repo_install = Mock(side_effect=clone)
result = asyncio.run(manager.reinstall_by_id('fixture', version))
assert result.result is True
assert not marker.exists()
assert (installed / 'replacement.txt').read_text() == 'installed'
assert manager.repo_install.call_count == 1
assert execute.call_count == 1
assert execute.call_args.args[1] == str(installed)
@pytest.mark.parametrize('status', ['', 'NodeVersionStatusActive', 'NodeVersionStatusFlagged'])
@pytest.mark.parametrize('override', [False, True])
@pytest.mark.parametrize('listen', ['127.0.0.1', '0.0.0.0'])
def test_deferred_switch_reuses_status_with_current_policy(cnr_client, monkeypatch, tmp_path, caplog, status, override, listen):
from comfy.cli_args import args as server_args
from comfyui_manager.common import manager_downloader, manager_util
monkeypatch.setattr(server_args, 'listen', listen)
# Execute the real function without running prestartup's module-level startup.
source = Path(cnr_client.__file__).parents[1] / 'prestartup_script.py'
tree = ast.parse(source.read_text())
function = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
and node.name == 'execute_lazy_cnr_switch')
scope = {'__package__': 'comfyui_manager', 'os': os, 'logging': logging,
'manager_downloader': manager_downloader, 'manager_util': manager_util,
'default_conf': {'allow_flagged_nodepack_install': str(override)}}
exec(compile(ast.Module(body=[function], type_ignores=[]), str(source), 'exec'), scope)
from_path, to_path = tmp_path / 'fixture@1.0.0', tmp_path / 'fixture'
from_path.mkdir()
(from_path / '.tracking').write_text('__init__.py')
(from_path / '__init__.py').write_text('')
def download(url, directory, name):
(Path(directory) / name).write_bytes(b'fixture')
downloader = Mock(side_effect=download)
monkeypatch.setattr(manager_downloader, 'download_url', downloader)
monkeypatch.setattr(manager_util, 'extract_package_as_zip', lambda *a: {'__init__.py'})
monkeypatch.setattr(cnr_client.requests, 'get', Mock(side_effect=AssertionError('no CNR query')))
args = ('fixture', 'https://example.invalid/archive.zip', str(from_path), str(to_path), False, str(tmp_path))
# Omit the added argument for the old on-disk reservation format.
result = scope['execute_lazy_cnr_switch'](*args, *([status] if status else []))
allowed = status == 'NodeVersionStatusActive' or override or listen == '127.0.0.1'
assert result is allowed
assert downloader.call_count == int(allowed)
assert to_path.exists() is allowed
assert from_path.exists() is not allowed
if not allowed and not status:
assert 'stored Registry status is missing' in caplog.text
assert 'Request the installation again' in caplog.text
elif not allowed:
for detail in ('--listen 127.0.0.1', '--listen ::1', '[default]',
'allow_flagged_nodepack_install = true', 'Restart ComfyUI'):
assert detail in caplog.text
else:
assert 'allow_flagged_nodepack_install' not in caplog.text
+3 -2
View File
@@ -43,7 +43,7 @@ import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _install_flags_testutil import import_context, import_reader # noqa: E402
FLAG_KEYS = ("allow_git_url_install", "allow_pip_install")
FLAG_KEYS = ("allow_git_url_install", "allow_pip_install", "allow_flagged_nodepack_install")
READERS = ("glob", "legacy")
@@ -169,7 +169,8 @@ def test_sc21_write_config_round_trips_both_flags(
ini = _write_ini(
tmp_path,
"[default]\nsecurity_level = normal\n"
"allow_git_url_install = true\nallow_pip_install = true\n",
"allow_git_url_install = true\nallow_pip_install = true\n"
"allow_flagged_nodepack_install = true\n",
)
point_config(ini)
+3 -3
View File
@@ -240,7 +240,7 @@ def test_p1_install_custom_node_denies_at_strong_403(ms, gate):
"""P1 deny: at sl=strong the middle+ entry gate (first statement of
_install_custom_node) returns 403 before any further processing."""
gate("strong", is_local_mode=True)
resp = _run(ms._install_custom_node({}))
resp = _run(ms._queue_node_install({}, "install"))
assert resp.status == 403
@@ -263,7 +263,7 @@ def test_p1_install_custom_node_passes_entry_gate_at_normal(ms, gate, monkeypatc
"channel": "default",
"mode": "cache",
}
resp = _run(ms._install_custom_node(dict(json_data)))
resp = _run(ms._queue_node_install(dict(json_data), "install"))
assert resp.status == 200
@@ -286,7 +286,7 @@ def test_p2_unknown_pip_block_denies_404_even_at_weak(ms, gate, monkeypatch):
"channel": "default",
"mode": "cache",
}
resp = _run(ms._install_custom_node(dict(json_data)))
resp = _run(ms._queue_node_install(dict(json_data), "install"))
assert resp.status == 404