Restrict flagged CNR installs on non-local listeners
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user