Compare commits

..
Author SHA1 Message Date
Dr.Lt.Data f764cc0e54 bump version to 4.3 2026-09-19 08:46:33 +09:00
Dr.Lt.Data 69bc3ef1c6 v4: Restrict Flagged CNR installs on non-local listeners (#3299) 2026-09-19 08:29:23 +09:00
Dr.Lt.Data 4ad94b2048 v4: Secure legacy UI rendering and preserve administrator settings (#3297) 2026-09-19 08:12:25 +09:00
Dr.Lt.Data a0d89e9a56 Fix uv conflict attribution (#3300) 2026-09-19 06:39:28 +09:00
Dr.Lt.Data bd4ede2237 bump version 2026-06-15 02:41:52 +09:00
Dr.Lt.Data fca7ef149d feat(security): dedicated install flags decouple git_url/pip install from security_level (#2962)
* feat(security): dedicated install flags decouple git_url/pip from security_level

Install via git URL and pip install are no longer gated by
security_level. Each surface gets a dedicated config.ini flag —
allow_git_url_install / allow_pip_install (both default false, secure
by default) — that fully REPLACES the security-level term for these two
features. The network-position invariant is retained: a non-local
listener stays denied regardless of the flags unless
network_mode = personal_cloud.

- New pure predicate is_dedicated_install_allowed() in
  common/manager_security (no config access; callers resolve config)
- Legacy endpoints /v2/customnode/install/git_url and .../pip switch
  from is_allowed_security_level('high+') to the flag gate; batch
  installs of unknown git URLs likewise (middle+ entry gate unchanged,
  unknown-pip 'block' stays unconditional; response shapes preserved)
- Config readers/writers (glob + legacy) parse and persist the flags;
  denial logs and frontend 403 messages name the responsible flag and
  note the non-local-listener requirement (network_mode=personal_cloud)
- No auto-seed from security_level — users previously on weak/normal-
  must opt in explicitly (see CHANGELOG migration notes; README
  documents the new contract)
- Update the pre-existing permissive E2E harness
  (start_comfyui_permissive.sh + test_e2e_legacy_real_ops.py) to the
  new contract: it now also sets allow_git_url_install /
  allow_pip_install = true, since security_level = normal- alone no
  longer opens the git_url/pip endpoints

Tests: predicate truth table proving security_level independence in
both directions, dual-reader config contract, security-level-matrix
freeze guards, legacy gate regression guards (121 unit), plus 22
real-server E2E tests incl. URL-form pip install with self-clean.

* test(e2e): fix fresh-env failures in customnode_info and git_clone harnesses

Two pre-existing harness defects that fail deterministically on a fresh
E2E environment (unrelated to the dedicated-install-flags change):

- test_e2e_customnode_info: TestInstalledPacks asserted the seed pack
  ComfyUI_SigmoidOffsetScheduler is installed, but nothing seeded it —
  the installing module (test_e2e_endpoint) runs alphabetically later
  and uninstalls it at the end. Add a module-scoped autouse fixture
  that installs the pack via cm-cli BEFORE the server starts (the
  imported-mode test asserts against the startup-frozen snapshot, so
  API-based seeding after boot cannot work) and removes it on teardown
  only if the fixture installed it.
- test_e2e_git_clone: _ensure_cache ran cm-cli update-cache with a
  120s timeout; the full DB download routinely exceeds that on slow
  links, erroring the whole module at setup. Raise to 600s.

Verified from a fresh state (seed pack absent): both modules pass
(13 tests, incl. previously-failing TestInstalledPacks 2 and
TestNightlyInstallCycle 3).
2026-06-11 01:44:12 +09:00
Dr.Lt.Data 8b98723b42 fix(git_compat): follow-ups for pygit2 fallback hardening (#2974)
- Route list_remotes() fetch through _fetch_remote so the proxy and
  SSH-to-HTTPS rewrite apply to every fetch entry point, and provide
  pull on its proxies (parity with get_remote and the GitPython proxy)
  via a shared _pull_remote helper
- Rework _to_https_url: handle ssh://git@host:port/... URLs (drop the
  custom SSH port instead of mangling it into the path) and collapse
  leading slashes so git@host:/abs/path no longer yields a double-slash
  URL. Behavior narrowing: colon-less git@host/path (not a valid
  scp-form URL), ssh:// URLs with a port but no path, and IPv6 ssh://
  URLs are now returned unchanged instead of being wrongly converted
- clone_repo: omit the proxy= kwarg when no proxy is configured, so
  proxy-less installs keep working on pygit2 older than 1.18
- Simplify redundant except (KeyError, Exception) clause
- Require pygit2>=1.18 (clone_repository proxy= parameter)
- Add unit tests for _to_https_url (incl. negative cases pinning the
  narrowing) and a regression test that list_remotes proxies route
  fetch/pull through their own remote (late-binding guard)
2026-06-10 18:19:55 +09:00
51 changed files with 7485 additions and 527 deletions
+66
View File
@@ -5,6 +5,70 @@ All notable changes to **ComfyUI-Manager** are documented in this file.
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [4.3] - 2026-09-19
### Security
- Prevent HTML and URL injection in the legacy Manager UI while preserving
supported formatting and links.
- Require loopback-only listeners or `allow_flagged_nodepack_install = true`
to install Flagged CNR versions through either Manager API. This requirement
is additional to the existing installation security checks.
### Fixed
- Preserve administrator settings when saving configuration from the Manager UI.
- Clear legacy install progress state when batch submission fails.
- Attribute multiline uv dependency conflicts to the affected nodepacks while
filtering unrelated stderr output.
## [4.2.2] - 2026-06-15
### Security
- **Dedicated install flags decouple git-URL / pip installs from `security_level`**:
`POST /v2/customnode/install/git_url` and `POST /v2/customnode/install/pip`
(and the batch install path for git URLs not in the custom-node DB) are now
gated by two new `config.ini` `[default]` flags — `allow_git_url_install`
and `allow_pip_install` — instead of `security_level`. Both default to
`false` (secure by default), and a non-loopback listener stays denied unless
`network_mode = personal_cloud` (the existing network-position invariant is
retained — the flags never widen exposure beyond what was possible before).
`security_level` no longer has any effect on these two endpoints, in either
direction. The unknown-pip-package block in batch installs remains
unconditional. Activation requires a restart (no hot reload).
### Migration notes
- **Users running `security_level = weak` or `normal-`**: these environments
could previously use the git-URL / pip install endpoints; after upgrading
they are denied (HTTP 403) until you explicitly opt in by setting
`allow_git_url_install = true` and/or `allow_pip_install = true` in the
`[default]` section of `config.ini`. The flags are NOT auto-seeded from
your `security_level` — explicit opt-in is intentional.
### Fixed
- **pygit2 fallback hardening (Desktop 2.0)**: under `CM_USE_PYGIT2=1` the
pygit2 backend ran `clone_repository` / `remote.fetch` honoring the user's
global git config, so an `insteadOf` rewrite (https→ssh) or credential
helper forced authentication and failed with *"authentication required but
no callback set"*. The system/global/XDG config search path is now blanked
at import time (hermetic libgit2 operations) and SSH-form GitHub URLs are
normalized to anonymous HTTPS on clone and when opening a repo. System
`git` is preferred when available.
- **pygit2 fallback follow-ups**: `list_remotes()` fetches now route through
`_fetch_remote` so the proxy and SSH→HTTPS rewrite apply to every fetch
entry point, with `pull` provided on the proxies via a shared
`_pull_remote` helper. `_to_https_url` now handles `ssh://git@host:port/...`
URLs (drops the custom SSH port instead of mangling it) and collapses
leading slashes; non-scp-form and port-only/IPv6 `ssh://` URLs are returned
unchanged. `clone_repo` omits the `proxy=` kwarg when no proxy is
configured (proxy-less installs keep working on pygit2 < 1.18), and pygit2
is now pinned to `>= 1.18`.
## [4.2.1] - 2026-04-22
Security-hardening release. Contains breaking-ish API changes for
@@ -126,4 +190,6 @@ programmatic clients.
perform the change from a trusted entry point. Read access via `GET` is
unaffected.
[4.3]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.2.2...v4.3
[4.2.2]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.2.1...v4.2.2
[4.2.1]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.1b6...v4.2.1
+71 -3
View File
@@ -214,6 +214,9 @@ The following settings are applied based on the section marked as `is_default`.
model_download_by_agent = <When downloading models, use an agent instead of torchvision_download_url.>
downgrade_blacklist = <Set a list of packages to prevent downgrades. List them separated by commas.>
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>
```
@@ -324,12 +327,14 @@ The security settings are applied based on whether the ComfyUI server's listener
| Risky Level | features |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------|
| high+ | * `Install via git url`, `pip install`<BR>* Installation of nodepack registered not in the `default channel`.<BR>* **Switch ComfyUI version**<BR>* **Fix nodepack** |
| 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.
### Security Level Table
@@ -341,6 +346,69 @@ The security settings are applied based on whether the ComfyUI server's listener
| weak | * All features are available | * All features are available | * `high+` and `middle+` level risky features are not allowed<BR>* `high`, `middle` and `low` level risky features are available
### Dedicated install flags (`allow_git_url_install` / `allow_pip_install`)
The `Install via git url` and `pip install` features are governed by two dedicated `config.ini` flags instead of `security_level`:
* `allow_git_url_install`: Allows installing custom nodes from arbitrary git URLs.
* `allow_pip_install`: Allows installing arbitrary pip packages via the Manager.
* Both flags default to `False` — secure by default. A missing or invalid value (anything other than `true`, case-insensitive) is read as `False`.
* These flags fully **replace** `security_level` for these two features. `security_level` no longer affects them in either direction: a strict security level cannot deny them when the flag is `true`, and a weak security level cannot allow them when the flag is `false`.
* The network-position rule still applies independently: even with a flag enabled, the feature is denied when the server listener is **non-local**, unless `network_mode = personal_cloud`.
* Batch installs of git URLs not registered in the `default channel` are also gated by `allow_git_url_install`, and additionally require the normal batch-install preconditions (the `middle+` rules in the tables above). Unknown pip packages in batch installs remain blocked unconditionally — the flags do not open them.
* 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
+76
View File
@@ -0,0 +1,76 @@
"""Shared settings-file writer for glob and legacy."""
import configparser
import os
import tempfile
from rich import print
class DirtyTrackingConfig(dict):
"""Track keys assigned after loading the startup configuration."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dirty_keys = set()
def __setitem__(self, key, value):
self.dirty_keys.add(key)
super().__setitem__(key, value)
def update(self, *args, **kwargs):
incoming = dict(*args, **kwargs)
self.dirty_keys.update(incoming)
super().update(incoming)
def write_config_merged(config_path, cached, written_config_keys):
"""Merge changed owned keys onto the latest settings on disk.
Preserve parsed keys and values, not INI comments or formatting."""
config = configparser.ConfigParser(strict=False)
try:
loaded = config.read(config_path)
except (configparser.Error, UnicodeDecodeError) as e:
# Preserve the existing fallback for malformed files.
print(f"[ComfyUI-Manager] Warning: '{config_path}' could not be parsed ({e}); rewriting it from the current settings.")
config = configparser.ConfigParser(strict=False)
else:
# ConfigParser.read silently skips unreadable files; do not overwrite one.
if not loaded and os.path.exists(config_path):
raise OSError(f"'{config_path}' exists but could not be read; refusing to overwrite it with default settings. Fix the file's permissions, then retry.")
if config.has_section('default'):
keys = [key for key in written_config_keys if key in cached.dirty_keys]
else:
config['default'] = {}
keys = list(written_config_keys)
section = config['default']
for key in keys:
section[key] = str(cached[key]).replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(config_path)
if directory and not os.path.exists(directory):
os.makedirs(directory)
# Replace from the same directory so readers never see a partial write.
tmp_path = None
try:
with tempfile.NamedTemporaryFile(
'w', dir=directory or '.', prefix='.config-', suffix='.tmp', delete=False
) as configfile:
tmp_path = configfile.name
config.write(configfile)
configfile.flush()
os.fsync(configfile.fileno())
# Retain the existing mode instead of the temporary file's 0600.
if os.path.exists(config_path):
os.chmod(tmp_path, os.stat(config_path).st_mode & 0o777)
os.replace(tmp_path, config_path)
tmp_path = None
finally:
if tmp_path is not None and os.path.exists(tmp_path):
os.remove(tmp_path)
# Clear only persisted keys, and only after a successful write.
cached.dirty_keys.difference_update(keys)
@@ -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,51 @@ 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).
allowed iff flag AND (loopback listener OR network_mode == 'personal_cloud').
Gates the git-URL / standalone-pip install surfaces via the dedicated
``allow_git_url_install`` / ``allow_pip_install`` config flags, fully
decoupled from ``security_level`` (REPLACE, not AND — spec §1.1 inv. 1).
The network-position term retains today's invariant that a public
(non-loopback, non-personal_cloud) listener stays denied regardless of
the flags (spec §1.1 inv. 2).
Pure function — NO config access; callers resolve ``flag_value`` and
``network_mode`` through their own config reader and pass values in
(preserves common/ layering: this module must stay config-import-free).
"""
return bool(flag_value) and (is_loopback(listen_address) or network_mode.lower() == 'personal_cloud')
def do_nothing():
pass
@@ -0,0 +1,7 @@
"""Installation-denial messages shared by the glob and legacy servers."""
SECURITY_MESSAGE_MIDDLE = "ERROR: To use this action, a security_level of `normal or below` is required. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_MIDDLE_P = "ERROR: To use this action, security_level must be `normal or below`, and network_mode must be set to `personal_cloud`. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
@@ -656,10 +656,15 @@ class UnifiedDepResolver:
def _parse_conflicts(stderr: str) -> list[str]:
"""Extract conflict descriptions from ``uv pip compile`` stderr."""
conflicts: list[str] = []
for line in stderr.splitlines():
line = line.strip()
if line and ('conflict' in line.lower() or 'error' in line.lower()):
in_diagnostic = False
for raw_line in stderr.splitlines():
line = raw_line.strip()
if line and ('conflict' in line.lower() or 'error' in line.lower()
or (in_diagnostic and raw_line[0].isspace())):
conflicts.append(line)
in_diagnostic = True
else:
in_diagnostic = False
return conflicts or [stderr.strip()] if stderr.strip() else []
@staticmethod
-9
View File
@@ -1,12 +1,3 @@
SECURITY_MESSAGE_MIDDLE = "ERROR: To use this action, a security_level of `normal or below` is required. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_MIDDLE_P = "ERROR: To use this action, security_level must be `normal or below`, and network_mode must be set to `personal_cloud`. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
def is_loopback(address):
import ipaddress
+103 -70
View File
@@ -35,8 +35,10 @@ from packaging import version
import uuid
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
@@ -880,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}")
@@ -909,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)
@@ -947,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)
@@ -1008,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:
@@ -1289,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)
@@ -1538,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
@@ -1549,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
@@ -1673,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}`")
@@ -1686,39 +1713,33 @@ class ManagerFuncs:
manager_funcs = ManagerFuncs()
# Settings owned by this server; other keys and sections are preserved.
WRITTEN_CONFIG_KEYS = (
'git_exe',
'use_uv',
'use_unified_resolver',
'channel_url',
'share_option',
'bypass_ssl',
'file_logging',
'update_policy',
'windows_selector_event_loop_policy',
'model_download_by_agent',
'downgrade_blacklist',
'security_level',
'always_lazy_install',
'network_mode',
'db_mode',
'verbose',
'allow_git_url_install',
'allow_pip_install',
'allow_flagged_nodepack_install',
)
def write_config():
config = configparser.ConfigParser(strict=False)
config['default'] = {
'git_exe': get_config()['git_exe'],
'use_uv': get_config()['use_uv'],
'use_unified_resolver': get_config()['use_unified_resolver'],
'channel_url': get_config()['channel_url'],
'share_option': get_config()['share_option'],
'bypass_ssl': get_config()['bypass_ssl'],
"file_logging": get_config()['file_logging'],
'update_policy': get_config()['update_policy'],
'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'],
'model_download_by_agent': get_config()['model_download_by_agent'],
'downgrade_blacklist': get_config()['downgrade_blacklist'],
'security_level': get_config()['security_level'],
'always_lazy_install': get_config()['always_lazy_install'],
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
'verbose': get_config()['verbose'],
}
# Sanitize all string values to prevent CRLF injection attacks
for key, value in config['default'].items():
if isinstance(value, str):
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(context.manager_config_path)
if not os.path.exists(directory):
os.makedirs(directory)
with open(context.manager_config_path, 'w') as configfile:
config.write(configfile)
"""Persist changed settings through the shared writer."""
write_config_merged(context.manager_config_path, get_config(), WRITTEN_CONFIG_KEYS)
def read_config():
@@ -1755,6 +1776,9 @@ def read_config():
'security_level': default_conf.get('security_level', SecurityLevel.NORMAL.value).lower(),
'db_mode': default_conf.get('db_mode', DBMode.CACHE.value).lower(),
'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:
@@ -1783,6 +1807,9 @@ def read_config():
'security_level': SecurityLevel.NORMAL.value,
'db_mode': DBMode.CACHE.value,
'verbose': False,
'allow_git_url_install': False,
'allow_pip_install': False,
'allow_flagged_nodepack_install': False,
}
@@ -1790,7 +1817,8 @@ def get_config():
global cached_config
if cached_config is None:
cached_config = read_config()
# Start tracking changes after the startup configuration is loaded.
cached_config = DirtyTrackingConfig(read_config())
if cached_config['http_channel_enabled']:
print("[ComfyUI-Manager] Warning: http channel enabled, make sure server in secure env")
@@ -3210,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'):
+14 -9
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
@@ -83,12 +83,14 @@ from ..data_models import (
ComfyUISwitchVersionParams,
)
from .constants import (
model_dir_name_map,
from ..common.security_messages import (
SECURITY_MESSAGE_MIDDLE,
SECURITY_MESSAGE_MIDDLE_P,
SECURITY_MESSAGE_HIGH_P,
)
from .constants import (
model_dir_name_map,
)
if not manager_util.is_manager_pip_package():
network_mode_description = "offline"
@@ -131,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}`")
@@ -813,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
@@ -842,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,
@@ -913,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})"
)
@@ -2174,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()
@@ -1,6 +1,5 @@
import locale
import sys
import re
def handle_stream(stream, prefix):
@@ -20,41 +19,3 @@ def handle_stream(stream, prefix):
print(prefix, msg, end="", file=sys.stderr)
else:
print(prefix, msg, end="")
def convert_markdown_to_html(input_text):
pattern_a = re.compile(r"\[a/([^]]+)]\(([^)]+)\)")
pattern_w = re.compile(r"\[w/([^]]+)]")
pattern_i = re.compile(r"\[i/([^]]+)]")
pattern_bold = re.compile(r"\*\*([^*]+)\*\*")
pattern_white = re.compile(r"%%([^*]+)%%")
def replace_a(match):
return f"<a href='{match.group(2)}' target='blank'>{match.group(1)}</a>"
def replace_w(match):
return f"<p class='cm-warn-note'>{match.group(1)}</p>"
def replace_i(match):
return f"<p class='cm-info-note'>{match.group(1)}</p>"
def replace_bold(match):
return f"<B>{match.group(1)}</B>"
def replace_white(match):
return f"<font color='white'>{match.group(1)}</font>"
input_text = (
input_text.replace("\\[", "&#91;")
.replace("\\]", "&#93;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
result_text = re.sub(pattern_a, replace_a, input_text)
result_text = re.sub(pattern_w, replace_w, result_text)
result_text = re.sub(pattern_i, replace_i, result_text)
result_text = re.sub(pattern_bold, replace_bold, result_text)
result_text = re.sub(pattern_white, replace_white, result_text)
return result_text.replace("\n", "<BR>")
+10 -9
View File
@@ -14,7 +14,7 @@ import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import {
free_models, install_pip, install_via_git_url, manager_instance,
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
infoToast, showTerminal, setNeedRestart, generateUUID
infoToast, showTerminal, setNeedRestart, generateUUID, sanitizeHTML, sanitizeUrl
} from "./common.js";
import { CustomNodesManager } from "./custom-nodes-manager.js";
import { ModelManager } from "./model-manager.js";
@@ -722,13 +722,14 @@ async function onQueueStatus(event) {
msg += "The following custom nodes have been updated:<ul>";
for(let x in success_list) {
let k = success_list[x];
let url = event.detail.nodepack_result[k].url;
let title = event.detail.nodepack_result[k].title;
// queue results are server-authored and not escaped upstream
let url = sanitizeUrl(event.detail.nodepack_result[k].url);
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
if(url) {
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
msg += `<li><a href='${sanitizeHTML(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
}
else {
msg += `<li>${k}</li>`;
msg += `<li>${sanitizeHTML(String(k))}</li>`;
}
}
msg += "</ul>";
@@ -741,13 +742,13 @@ async function onQueueStatus(event) {
msg += '<br>The update for the following custom nodes has failed:<ul>';
for(let x in failed_list) {
let k = failed_list[x];
let url = event.detail.nodepack_result[k].url;
let title = event.detail.nodepack_result[k].title;
let url = sanitizeUrl(event.detail.nodepack_result[k].url);
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
if(url) {
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
msg += `<li><a href='${sanitizeHTML(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
}
else {
msg += `<li>${k}</li>`;
msg += `<li>${sanitizeHTML(String(k))}</li>`;
}
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { $el, ComfyDialog } from "../../scripts/ui.js";
import { CopusShareDialog } from "./comfyui-share-copus.js";
import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import { YouMLShareDialog } from "./comfyui-share-youml.js";
import { customAlert } from "./common.js";
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
export const SUPPORTED_OUTPUT_NODE_TYPES = [
"PreviewImage",
@@ -951,7 +951,8 @@ export class ShareDialog extends ComfyDialog {
const response_json = await response.json();
if (response_json.comfyworkflows.url) {
this.final_message.innerHTML = "Your art has been shared: <a href='" + response_json.comfyworkflows.url + "' target='_blank'>" + response_json.comfyworkflows.url + "</a>";
const sharedUrl = sanitizeHTML(sanitizeUrl(response_json.comfyworkflows.url));
this.final_message.innerHTML = "Your art has been shared: <a href='" + sharedUrl + "' target='_blank' rel='noopener noreferrer'>" + sharedUrl + "</a>";
if (response_json.matrix.success) {
this.final_message.innerHTML += "<br>Your art has been shared in the ComfyUI Matrix server's #share channel!";
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { $el, ComfyDialog } from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
const env = "prod";
@@ -883,7 +883,7 @@ export class CopusShareDialog extends ComfyDialog {
const { data } = res.data;
if (data) {
const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${sanitizeHTML(sanitizeUrl(url))}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
this.previewImage.src = "";
this.previewImage.style.display = "none";
this.uploadedImages = [];
+2 -2
View File
@@ -1,7 +1,7 @@
import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {ComfyDialog, $el} from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
const LOCAL_STORAGE_KEY = "openart_comfy_workflow_key";
const DEFAULT_HOMEPAGE_URL = "https://openart.ai/workflows/dev?developer=true";
@@ -511,7 +511,7 @@ export class OpenArtShareDialog extends ComfyDialog {
const {workflow_id} = response.data;
if (workflow_id) {
const url = `https://openart.ai/workflows/-/-/${workflow_id}`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${sanitizeHTML(sanitizeUrl(url))}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
this.previewImage.src = "";
this.previewImage.style.display = "none";
this.uploadedImages = [];
+2 -2
View File
@@ -1,7 +1,7 @@
import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {ComfyDialog, $el} from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, sanitizeHTML, sanitizeUrl } from "./common.js";
const BASE_URL = "https://youml.com";
//const BASE_URL = "http://localhost:3000";
@@ -420,7 +420,7 @@ export class YouMLShareDialog extends ComfyDialog {
}
}
this.message.innerHTML = `${messagePrefix} To turn your workflow into an interactive app, ` +
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
`<a href="${sanitizeHTML(sanitizeUrl(recipePageUrl))}" target="_blank" rel="noopener noreferrer">visit it on YouML</a>`;
this.uploadedImages = [];
this.nameInput.value = "";
+53 -3
View File
@@ -216,7 +216,7 @@ export async function install_pip(packages) {
});
if(res.status == 403) {
show_message('This action is not allowed with this security level configuration.');
show_message(await install_denial_message(res, 'allow_pip_install'));
return;
}
@@ -251,7 +251,7 @@ export async function install_via_git_url(url, manager_dialog) {
});
if(res.status == 403) {
show_message('This action is not allowed with this security level configuration.');
show_message(await install_denial_message(res, 'allow_git_url_install'));
return;
}
@@ -659,4 +659,54 @@ function initTooltip () {
document.body.addEventListener('mouseleave', mouseleaveHandler, true);
}
initTooltip();
initTooltip();
// Installation flags and network mode take effect after a restart.
const INSTALL_DENIAL_RESTART_NOTE = "<BR>Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again.";
export const INSTALL_DENIAL_MESSAGES = {
allow_git_url_install: "To use this feature, set <code>allow_git_url_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required." + INSTALL_DENIAL_RESTART_NOTE,
allow_pip_install: "To use this feature, set <code>allow_pip_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required." + INSTALL_DENIAL_RESTART_NOTE
};
// Last resort when no entry above applies. Names no flag on purpose.
const INSTALL_DENIAL_UNKNOWN_REASON = "This action was refused by the server, and the client could not determine which setting caused it. The server terminal log states the exact condition. Please contact the administrator.";
// Use the endpoint's flag when the 403 response body is missing or malformed.
export async function install_denial_message(res, fallbackReason) {
let reason = fallbackReason;
try {
const data = await res.json();
if (typeof data === 'object' && data !== null && typeof data.reason === 'string'
&& Object.prototype.hasOwnProperty.call(INSTALL_DENIAL_MESSAGES, data.reason)) {
reason = data.reason;
}
}
catch {
// absent / non-JSON body: keep the fallback
}
return INSTALL_DENIAL_MESSAGES[reason] ?? INSTALL_DENIAL_UNKNOWN_REASON;
}
/**
* Scheme allow-list for registry-supplied URLs that land in an href.
* Returns the URL if it is http(s) or relative, otherwise "". Never throws.
*/
export function sanitizeUrl(url) {
const raw = String(url ?? '').trim();
if (!raw) {
return '';
}
let parsed;
try {
parsed = new URL(raw);
}
catch {
// not absolute: keep a scheme-less relative path, drop anything else
return /^[a-z][a-z0-9+.\-]*:/i.test(raw) ? '' : raw;
}
// URL parsing already normalised the scheme (`java\tscript:` collapses).
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return raw;
}
return '';
}
+93 -58
View File
@@ -8,11 +8,13 @@ import {
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
showPopover, hidePopover, generateUUID
showPopover, hidePopover, generateUUID, sanitizeUrl
} from "./common.js";
// Registry titles, names and descriptions are server-escaped; other fields are raw.
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import ManagerGrid from "./manager-grid.js";
loadCss("./custom-nodes-manager.css");
@@ -363,19 +365,20 @@ export class CustomNodesManager {
installGroups.enabled = installGroups.enabled.filter(it => it !== "disable" && it !== "uninstall" && it !== "switch");
}
let list = installGroups[action];
const list = installGroups[action];
if(is_selected_button || rowItem?.version === "unknown") {
list = list.filter(it => it !== "switch");
}
if (!list) {
if (!Array.isArray(list)) {
return "";
}
return list.map(id => {
const shown = (is_selected_button || rowItem?.version === "unknown")
? list.filter(it => it !== "switch")
: list;
return shown.map(id => {
const bt = buttons[id];
return `<button class="cn-btn-${id} p-button p-component" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
// `action` is registry-derived and lands in an attribute.
return `<button class="cn-btn-${id} p-button p-component" group="${sanitizeHTML(String(action ?? ''))}" mode="${bt.mode}">${bt.label}</button>`;
}).join("");
}
@@ -518,7 +521,7 @@ export class CustomNodesManager {
initGrid() {
const container = this.element.querySelector(".cn-manager-grid");
const grid = new TG.Grid(container);
const grid = new ManagerGrid(container);
this.grid = grid;
this.flyover = this.createFlyover(container);
@@ -579,6 +582,9 @@ export class CustomNodesManager {
grid.setOption({
highlightKeywords: {
textGenerator: (row, column) => column === 'author' ? sanitizeHTML(String(row[column] ?? '')) : row[column]
},
theme: 'dark',
selectVisible: true,
selectMultiple: true,
@@ -646,7 +652,8 @@ export class CustomNodesManager {
let res = await response.json();
let title = `<FONT COLOR=GREEN><B>Error message occurred while importing the '${rowItem.title}' module.</B></FONT><BR><HR><BR>`
// `title` is server-escaped; `res['msg']` below is raw and is escaped.
let title = `<FONT COLOR=GREEN><B>Error message occurred while importing the '${String(rowItem.title ?? '')}' module.</B></FONT><BR><HR><BR>`
if(res.code == 400)
{
@@ -711,11 +718,12 @@ export class CustomNodesManager {
const link = document.createElement('a');
if(rowItem.originalData.repository)
link.href = rowItem.originalData.repository;
link.href = sanitizeUrl(rowItem.originalData.repository);
else
link.href = rowItem.reference;
link.href = sanitizeUrl(rowItem.reference);
link.target = '_blank';
link.innerHTML = `<b>${title}</b>`;
link.rel = 'noopener noreferrer';
link.innerHTML = `<b>${String(title ?? '')}</b>`;
link.title = rowItem.originalData.id;
container.appendChild(link);
@@ -734,11 +742,11 @@ export class CustomNodesManager {
}
if(rowItem.cnr_latest && version != rowItem.cnr_latest) {
if(version == 'nightly') {
return `<div>${version}</div><div>[${rowItem.cnr_latest}]</div>`;
return `<div>${sanitizeHTML(String(version))}</div><div>[${sanitizeHTML(String(rowItem.cnr_latest))}]</div>`;
}
return `<div>${version}</div><div>[↑${rowItem.cnr_latest}]</div>`;
return `<div>${sanitizeHTML(String(version))}</div><div>[↑${sanitizeHTML(String(rowItem.cnr_latest))}]</div>`;
}
return version;
return sanitizeHTML(String(version));
}
}, {
id: 'action',
@@ -777,13 +785,13 @@ export class CustomNodesManager {
width: 400,
maxWidth: 5000,
invisible: !this.hasAlternatives(),
classMap: 'cn-pack-desc'
classMap: 'cn-pack-desc' // composed HTML; tags are escaped in getAlternatives
}, {
id: 'description',
name: 'Description',
width: 400,
maxWidth: 5000,
classMap: 'cn-pack-desc'
classMap: 'cn-pack-desc' // composed HTML from the server (convert_markdown_to_html)
}, {
id: 'author',
name: 'Author',
@@ -791,9 +799,9 @@ export class CustomNodesManager {
classMap: "cn-pack-author",
formatter: (author, rowItem, columnItem) => {
if (rowItem.trust) {
return `<span tooltip="This author has been active for more than six months in GitHub">✅ ${author}</span>`;
return `<span tooltip="This author has been active for more than six months in GitHub">✅ ${sanitizeHTML(String(author ?? ''))}</span>`;
}
return author;
return sanitizeHTML(String(author ?? ''));
}
}, {
id: 'stars',
@@ -807,7 +815,7 @@ export class CustomNodesManager {
if (typeof stars === 'number') {
return stars.toLocaleString();
}
return stars;
return sanitizeHTML(String(stars ?? ''));
}
}, {
id: 'last_update',
@@ -822,7 +830,7 @@ export class CustomNodesManager {
}
const ago = getTimeAgo(last_update);
const short = `${last_update}`.split(' ')[0];
return `<span tooltip="${ago}">${short}</span>`;
return `<span tooltip="${ago}">${sanitizeHTML(String(short))}</span>`;
}
}];
@@ -1211,7 +1219,7 @@ export class CustomNodesManager {
const rowItem = d.rowItem;
const isNotInstalled = rowItem.action == "not-installed";
let titleHtml = `<div class="cn-nodes-pack" hash="${rowItem.hash}">${rowItem.title}</div>`;
let titleHtml = `<div class="cn-nodes-pack" hash="${rowItem.hash}">${String(rowItem.title ?? '')}</div>`;
if (isNotInstalled) {
titleHtml += '<div class="cn-pack-badge">Not Installed</div>'
}
@@ -1227,11 +1235,12 @@ export class CustomNodesManager {
list.push(`<div class="${rowClass}">`);
list.push(`<div class="cn-nodes-sn">${i+1}</div>`);
list.push(`<div class="cn-nodes-name">${it.name}</div>`);
// `it.name` is a raw node name from getmappings (not the pack name)
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(it.name ?? ''))}</div>`);
if (it.conflicts) {
list.push(`<div class="cn-conflicts-list"><div class="cn-nodes-conflict cn-icon">${icons.conflicts}</div><b>Conflict with</b>${it.conflicts.map(c => {
return `<div class="cn-nodes-pack" hash="${c.hash}">${c.title}</div>`;
return `<div class="cn-nodes-pack" hash="${c.hash}">${String(c.title ?? '')}</div>`;
}).join("<b>,</b>")}</div>`);
}
list.push(`</div>`);
@@ -1349,7 +1358,7 @@ export class CustomNodesManager {
return;
}
const selectedMap = {};
const selectedMap = Object.create(null);
selectedList.forEach(item => {
let type = item.action;
if (item.restart) {
@@ -1367,8 +1376,10 @@ export class CustomNodesManager {
const list = [];
Object.keys(selectedMap).forEach(v => {
const filterItem = this.getFilterItem(v);
// `v` is a registry-supplied `state`; escape it when no filter label matches.
const typeLabel = filterItem ? filterItem.label : sanitizeHTML(String(v ?? ''));
list.push(`<div class="cn-selected-buttons">
<span>Selected <b>${selectedMap[v].length}</b> ${filterItem ? filterItem.label : v}</span>
<span>Selected <b>${selectedMap[v].length}</b> ${typeLabel}</span>
${this.grid.hasMask ? "" : this.getActionButtons(v, null, true)}
</div>`);
});
@@ -1466,7 +1477,6 @@ export class CustomNodesManager {
target.classList.add("cn-btn-loading");
this.showError("");
let needRestart = false;
let errorMsg = "";
let target_items = [];
@@ -1475,13 +1485,13 @@ export class CustomNodesManager {
for (const hash of list) {
const item = this.grid.getRowItemBy("hash", hash);
target_items.push(item);
if (!item) {
errorMsg = `Not found custom node: ${hash}`;
errorMsg = `Not found custom node: ${sanitizeHTML(String(hash))}`;
break;
}
target_items.push(item);
this.grid.scrollRowIntoView(item);
if (!this.focusInstall(item, mode)) {
@@ -1538,23 +1548,48 @@ export class CustomNodesManager {
this.batch_id = generateUUID();
batch['batch_id'] = this.batch_id;
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
method: 'POST',
body: JSON.stringify(batch)
});
let failed;
try {
const { data, error } = await fetchData(`/v2/manager/queue/batch`, {
method: 'POST',
body: JSON.stringify(batch)
});
if (error) throw error;
if (!Array.isArray(data?.failed)) throw new Error('Invalid batch response.');
failed = data.failed;
} catch (error) {
errorMsg = `Failed to submit installation request: ${sanitizeHTML(String(error))}`;
this.showError(errorMsg);
show_message("[Installation Errors]\n" + errorMsg);
target.classList.remove("cn-btn-loading");
this.element.querySelectorAll(".cn-btn-loading").forEach(button => {
button.classList.remove("cn-btn-loading");
});
this.hideLoading();
this.hideStop();
this.install_context = undefined;
return;
}
for (const id of failed) {
const item = target_items.find(item => item.originalData.id === id);
errorMsg += `[FAIL] ${item?.title ?? sanitizeHTML(String(id))}\n`;
}
if (errorMsg) {
this.showError(errorMsg);
show_message("[Installation Errors]\n" + errorMsg);
}
let failed = await res.json();
showTerminal();
if(failed.length > 0) {
for(let k in failed) {
let hash = failed[k];
const item = this.grid.getRowItemBy("hash", hash);
errorMsg = `[FAIL] ${item.title}`;
}
// No queued work means the server will not send batch-done.
if (target_items.every(item => failed.includes(item.originalData.id))) {
this.hideLoading();
this.hideStop();
this.install_context = undefined;
return;
}
this.showStop();
showTerminal();
}
}
@@ -1601,7 +1636,7 @@ export class CustomNodesManager {
let v = result[hash];
if(v != 'success' && v != 'skip')
errorMsg += v+'\n';
errorMsg += sanitizeHTML(String(v))+'\n';
}
for(let k in self.install_context.targets) {
@@ -1719,7 +1754,7 @@ export class CustomNodesManager {
if(unresolved_cnr_list.length > 0) {
let error_msg = "Failed to find the following ComfyRegistry list.\nThe cache may be outdated, or the nodes may have been removed from ComfyRegistry.<HR>";
for(let i in unresolved_cnr_list) {
error_msg += '<li>'+unresolved_cnr_list[i]+'</li>';
error_msg += '<li>'+sanitizeHTML(String(unresolved_cnr_list[i]))+'</li>';
}
show_message(error_msg);
@@ -1769,7 +1804,7 @@ export class CustomNodesManager {
this.showStatus(`Loading missing nodes (${mode}) ...`);
const res = await fetchData(`/v2/customnode/getmappings?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get custom node mappings: ${res.error}`);
this.showError(`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);
return;
}
@@ -1884,7 +1919,7 @@ export class CustomNodesManager {
this.showStatus(`Loading alternatives (${mode}) ...`);
const res = await fetchData(`/v2/customnode/alternatives?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get alternatives: ${res.error}`);
this.showError(`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);
return [];
}
@@ -1901,7 +1936,7 @@ export class CustomNodesManager {
}
const tags = `${item.tags}`.split(",").map(tag => {
return `<div>${tag.trim()}</div>`;
return `<div>${sanitizeHTML(tag.trim())}</div>`;
}).join("");
hashMap[custom_node.hash] = {
@@ -1948,7 +1983,7 @@ export class CustomNodesManager {
this.custom_nodes = node_packs;
if(this.channel !== 'default') {
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${this.channel} (Incomplete list)`;
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${sanitizeHTML(String(this.channel))} (Incomplete list)`;
}
for (const k in node_packs) {
@@ -2077,6 +2112,7 @@ export class CustomNodesManager {
// ===========================================================================================
showSelection(msg) {
// Takes composed HTML (buttons included); ingredients are escaped in renderSelected.
this.element.querySelector(".cn-manager-selection").innerHTML = msg;
}
@@ -2084,18 +2120,17 @@ export class CustomNodesManager {
this.showMessage(err, "red");
}
// Messages contain HTML: preserve server-escaped names and escape raw values at the caller.
showMessage(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".cn-manager-message").innerHTML = msg;
const element = this.element.querySelector(".cn-manager-message");
element.style.color = color || "";
element.innerHTML = msg ?? "";
}
showStatus(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".cn-manager-status").innerHTML = msg;
const element = this.element.querySelector(".cn-manager-status");
element.style.color = color || "";
element.innerHTML = msg ?? "";
}
showLoading() {
+67
View File
@@ -0,0 +1,67 @@
import { Grid } from "./turbogrid.esm.js";
// Keep Manager's search hardening outside the vendored TurboGrid bundle.
export default class ManagerGrid extends Grid {
highlightKeywordsFilter(rowItem, columns, value) {
const { textKey, textGenerator, highlightKey } = this.options.highlightKeywords;
for (const column of columns) {
rowItem[`${highlightKey}${column}`] = null;
}
const keywords = value ? String(value).trim().toLowerCase().split(/\s+/).filter(Boolean) : [];
if (!keywords.length) return true;
let matched = false;
for (const column of columns) {
const value = typeof textGenerator === "function" ? textGenerator(rowItem, column) : rowItem[column];
if (value === null || value === undefined) continue;
let text = String(value).trim();
if (!text) continue;
const key = `${textKey}${column}`;
if (rowItem[key] === null || rowItem[key] === undefined) {
// A detached element's innerHTML can still run image handlers.
rowItem[key] = new DOMParser().parseFromString(text, "text/html").body.textContent;
}
text = rowItem[key].toLowerCase();
let offset = 0;
const found = keywords.every(keyword => {
const index = text.indexOf(keyword, offset);
if (index === -1) return false;
offset = index + keyword.length;
return true;
});
if (found) {
rowItem[`${highlightKey}${column}`] = true;
this.highlightKeywords = keywords;
matched = true;
}
}
return matched;
}
highlightTextNodes(nodes, keywords) {
if (!keywords.length) return;
let keywordIndex = 0;
for (const node of nodes) {
const text = node.textContent;
const lower = text.toLowerCase();
const wrapper = document.createElement("span");
let offset = 0;
while (offset < text.length) {
const keyword = keywords[keywordIndex];
const index = lower.indexOf(keyword, offset);
if (index === -1) break;
wrapper.append(text.slice(offset, index));
const mark = document.createElement("mark");
mark.textContent = text.slice(index, index + keyword.length);
wrapper.append(mark);
offset = index + keyword.length;
keywordIndex = (keywordIndex + 1) % keywords.length;
}
if (wrapper.childNodes.length) {
// Decoded cell text stays in text nodes, including inside <mark>.
wrapper.append(text.slice(offset));
node.replaceWith(wrapper);
}
}
}
}
+92 -94
View File
@@ -3,17 +3,22 @@ import { $el } from "../../scripts/ui.js";
import {
manager_instance, rebootAPI,
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, generateUUID
storeColumnWidth, restoreColumnWidth, loadCss, generateUUID,
sanitizeHTML, sanitizeUrl
} from "./common.js";
// Model names and descriptions are server-escaped; other fields are raw.
import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import ManagerGrid from "./manager-grid.js";
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
loadCss("./model-manager.css");
const gridId = "model";
const escapeCell = (value) => sanitizeHTML(String(value ?? ""));
const pageHtml = `
<div class="cmm-manager cmm-manager-dark">
@@ -101,22 +106,17 @@ export class ModelManager {
updateFilter() {
const $filter = this.element.querySelector(".cmm-manager-filter");
$filter.innerHTML = this.filterList.map(item => {
const selected = item.value === this.filter ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
const option = (item, current) => {
const selected = item.value === current ? " selected" : "";
return `<option value="${sanitizeHTML(String(item.value ?? ''))}"${selected}>${sanitizeHTML(String(item.label ?? ''))}</option>`;
};
$filter.innerHTML = this.filterList.map(item => option(item, this.filter)).join("");
const $type = this.element.querySelector(".cmm-manager-type");
$type.innerHTML = this.typeList.map(item => {
const selected = item.value === this.type ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$type.innerHTML = this.typeList.map(item => option(item, this.type)).join("");
const $base = this.element.querySelector(".cmm-manager-base");
$base.innerHTML = this.baseList.map(item => {
const selected = item.value === this.base ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$base.innerHTML = this.baseList.map(item => option(item, this.base)).join("");
}
@@ -199,7 +199,7 @@ export class ModelManager {
initGrid() {
const container = this.element.querySelector(".cmm-manager-grid");
const grid = new TG.Grid(container);
const grid = new ManagerGrid(container);
this.grid = grid;
grid.bind('onUpdated', (e, d) => {
@@ -227,6 +227,9 @@ export class ModelManager {
});
grid.setOption({
highlightKeywords: {
textGenerator: (row, column) => ['name', 'description'].includes(column) ? row[column] : escapeCell(row[column])
},
theme: 'dark',
selectVisible: true,
@@ -325,7 +328,12 @@ export class ModelManager {
maxWidth: 500,
classMap: 'cmm-node-name',
formatter: function(name, rowItem, columnItem, cellNode) {
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
// Names are server-escaped; raw references need URL and attribute handling.
const href = sanitizeUrl(rowItem.reference);
if (!href) {
return `<b>${String(name ?? '')}</b>`;
}
return `<a href="${sanitizeHTML(href)}" target="_blank" rel="noopener noreferrer"><b>${String(name ?? '')}</b></a>`;
}
}, {
id: 'installed',
@@ -351,7 +359,11 @@ export class ModelManager {
sortable: false,
align: 'center',
formatter: (url, rowItem, columnItem) => {
return `<a class="cmm-btn-download" tooltip="Download file" href="${url}" target="_blank">${icons.download}</a>`;
const href = sanitizeUrl(url);
if (!href) {
return '';
}
return `<a class="cmm-btn-download" tooltip="Download file" href="${sanitizeHTML(href)}" target="_blank" rel="noopener noreferrer">${icons.download}</a>`;
}
}, {
id: 'size',
@@ -361,29 +373,33 @@ export class ModelManager {
if (typeof size === "number") {
return this.formatSize(size);
}
return size;
return sanitizeHTML(String(size ?? ''));
}
}, {
id: 'type',
name: 'Type',
width: 100
width: 100,
formatter: escapeCell
}, {
id: 'base',
name: 'Base'
name: 'Base',
formatter: escapeCell
}, {
id: 'description',
name: 'Description',
width: 400,
maxWidth: 5000,
classMap: 'cmm-node-desc'
classMap: 'cmm-node-desc' // composed HTML from the server (convert_markdown_to_html)
}, {
id: "save_path",
name: 'Save Path',
width: 200
width: 200,
formatter: escapeCell
}, {
id: 'filename',
name: 'Filename',
width: 200
width: 200,
formatter: escapeCell
}];
restoreColumnWidth(gridId, columns);
@@ -433,7 +449,6 @@ export class ModelManager {
btn.classList.add("cmm-btn-loading");
this.showError("");
let needRefresh = false;
let errorMsg = "";
let target_items = [];
@@ -454,6 +469,8 @@ export class ModelManager {
const data = item.originalData;
data.ui_id = item.hash;
// Batch rejections refer to id; models need a stable request ID.
data.id ??= item.hash;
if(batch['install_model']) {
@@ -466,38 +483,54 @@ export class ModelManager {
this.install_context = {btn: btn, targets: target_items};
if(errorMsg) {
this.showError(errorMsg);
show_message("[Installation Errors]\n"+errorMsg);
this.batch_id = generateUUID();
batch['batch_id'] = this.batch_id;
// reset
for(let k in target_items) {
const item = target_items[k];
this.grid.updateCell(item, "installed");
}
}
else {
this.batch_id = generateUUID();
batch['batch_id'] = this.batch_id;
const res = await api.fetchApi(`/v2/manager/queue/batch`, {
let failed;
try {
const { data, error } = await fetchData(`/v2/manager/queue/batch`, {
method: 'POST',
body: JSON.stringify(batch)
});
let failed = await res.json();
if(failed.length > 0) {
for(let k in failed) {
let hash = failed[k];
const item = self.grid.getRowItemBy("hash", hash);
errorMsg = `[FAIL] ${item.title}`;
}
}
this.showStop();
showTerminal();
if (error) throw error;
if (!Array.isArray(data?.failed)) throw new Error('Invalid batch response.');
failed = data.failed;
} catch (error) {
errorMsg = `Failed to submit installation request: ${sanitizeHTML(String(error))}`;
this.showError(errorMsg);
show_message("[Installation Errors]\n" + errorMsg);
btn.classList.remove("cmm-btn-loading");
this.element.querySelectorAll(".cmm-btn-loading").forEach(button => {
button.classList.remove("cmm-btn-loading");
});
this.hideLoading();
this.hideStop();
this.install_context = undefined;
return;
}
for (const id of failed) {
const item = target_items.find(item => item.originalData.id === id);
errorMsg += `[FAIL] ${item?.name ?? sanitizeHTML(String(id))}\n`;
}
if (errorMsg) {
this.showError(errorMsg);
show_message("[Installation Errors]\n" + errorMsg);
}
showTerminal();
// No queued work means the server will not send batch-done.
if (target_items.every(item => failed.includes(item.originalData.id))) {
this.element.querySelectorAll(".cmm-btn-loading").forEach(button => {
button.classList.remove("cmm-btn-loading");
});
this.hideLoading();
this.hideStop();
this.install_context = undefined;
return;
}
this.showStop();
}
async onQueueStatus(event) {
@@ -544,7 +577,7 @@ export class ModelManager {
let v = result[hash];
if(v != 'success')
errorMsg += v + '\n';
errorMsg += sanitizeHTML(String(v)) + '\n';
}
for(let k in self.install_context.targets) {
@@ -720,22 +753,20 @@ export class ModelManager {
this.showMessage(err, "red");
}
// Messages contain HTML: preserve server-escaped names and escape raw values at the caller.
showMessage(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".cmm-manager-message").innerHTML = msg;
const element = this.element.querySelector(".cmm-manager-message");
element.style.color = color || "";
element.innerHTML = msg ?? "";
}
showStatus(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".cmm-manager-status").innerHTML = msg;
const element = this.element.querySelector(".cmm-manager-status");
element.style.color = color || "";
element.innerHTML = msg ?? "";
}
showLoading() {
// this.setDisabled(true);
if (this.grid) {
this.grid.showLoading();
this.grid.showMask({
@@ -745,45 +776,12 @@ export class ModelManager {
}
hideLoading() {
// this.setDisabled(false);
if (this.grid) {
this.grid.hideLoading();
this.grid.hideMask();
}
}
setDisabled(disabled) {
const $close = this.element.querySelector(".cmm-manager-close");
const $refresh = this.element.querySelector(".cmm-manager-refresh");
const $stop = this.element.querySelector(".cmm-manager-stop");
const list = [
".cmm-manager-header input",
".cmm-manager-header select",
".cmm-manager-footer button",
".cmm-manager-selection button"
].map(s => {
return Array.from(this.element.querySelectorAll(s));
})
.flat()
.filter(it => {
return it !== $close && it !== $refresh && it !== $stop;
});
list.forEach($elem => {
if (disabled) {
$elem.setAttribute("disabled", "disabled");
} else {
$elem.removeAttribute("disabled");
}
});
Array.from(this.element.querySelectorAll(".cmm-btn-loading")).forEach($elem => {
$elem.classList.remove("cmm-btn-loading");
});
}
showRefresh() {
this.element.querySelector(".cmm-manager-refresh").style.display = "block";
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { manager_instance, rebootAPI, show_message, loadCss } from "./common.js";
import { manager_instance, rebootAPI, show_message, loadCss, sanitizeHTML } from "./common.js";
import { buildGuiFrame } from "./comfyui-gui-builder.js";
loadCss("./snapshot.css");
@@ -133,7 +133,7 @@ export class SnapshotManager extends ComfyDialog {
startRestore(target) {
const self = SnapshotManager.instance;
self.updateMessage(`<BR><font color="green">Restore snapshot '${target.name}'</font>`);
self.updateMessage(`<BR><font color="green">Restore snapshot '${sanitizeHTML(String(target.name ?? ''))}'</font>`);
for(let i in self.restore_buttons) {
self.restore_buttons[i].disabled = true;
@@ -214,7 +214,7 @@ export class SnapshotManager extends ComfyDialog {
data1.style.textAlign = "center";
data1.innerHTML = i+1;
var data2 = document.createElement('td');
data2.innerHTML = `&nbsp;${data}`;
data2.textContent = `\u00a0${data}`;
var data_button = document.createElement('td');
data_button.style.textAlign = "center";
data_button.className = "data-btns";
+82
View File
@@ -0,0 +1,82 @@
"""HTML rendering helpers used only by the legacy Manager UI."""
import re
from html import escape, unescape
from html.entities import html5
SAFE_URL_SCHEMES = frozenset({'http', 'https'})
_URL_SCHEME_NOISE = re.compile(r'[\x00-\x20\x7f]')
_URL_HEAD_DELIMITERS = ('/', '?', '#')
def escape_html_attribute(value):
"""Escape a value for a quoted HTML attribute."""
return escape(str(value), quote=True)
_HTML_ENTITY = re.compile(r'&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);')
_HTML_TEXT_TOKEN = re.compile(_HTML_ENTITY.pattern + "|[&<>\"']")
def unescape_html_entities(text):
"""Decode complete entities once, leaving query keys such as &notebook intact."""
def replace(match):
entity = match.group(1)
if entity.startswith('#'):
return unescape(match.group(0))
return html5.get(entity + ';', match.group(0))
return _HTML_ENTITY.sub(replace, text)
def escape_html_text(value):
"""Escape HTML text without double-escaping existing entities."""
return _HTML_TEXT_TOKEN.sub(
lambda m: m.group(0) if m.group(1) else escape_html_attribute(m.group(0)),
str(value),
)
def sanitize_url(url):
"""Allow HTTP(S), relative URLs and anchors.
HTML callers must also escape the result as an attribute."""
raw = '' if url is None else str(url)
probe = _URL_SCHEME_NOISE.sub('', raw)
if not probe:
return '#'
cut = len(probe)
for delimiter in _URL_HEAD_DELIMITERS:
found = probe.find(delimiter)
if found != -1:
cut = min(cut, found)
head = probe[:cut]
if ':' in head:
return raw.strip() if head.split(':', 1)[0].lower() in SAFE_URL_SCHEMES else '#'
return raw.strip()
def sanitize_html_fragment(fragment):
"""Sanitize notice HTML, preserving its formatting and opening links safely."""
# Keep nh3 loading within the legacy notice path.
import nh3
return nh3.clean(
'' if fragment is None else str(fragment),
tags=nh3.ALLOWED_TAGS | {'font'},
attributes={
**nh3.ALLOWED_ATTRIBUTES,
'*': {'class', 'title', 'align', 'width', 'height'},
'font': {'color'},
},
# These elements previously hid their contents from the notice.
clean_content_tags={
'script', 'style', 'iframe', 'object', 'embed', 'svg', 'math',
'template', 'noscript', 'textarea', 'title',
},
url_schemes=SAFE_URL_SCHEMES,
set_tag_attribute_values={'a': {'target': '_blank'}},
)
+180 -117
View File
@@ -33,8 +33,10 @@ from packaging import version
import uuid
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
@@ -866,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}")
@@ -895,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)
@@ -933,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)
@@ -994,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:
@@ -1276,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)
@@ -1468,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}")
@@ -1500,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
@@ -1538,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()
@@ -1659,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}`")
@@ -1672,37 +1729,31 @@ class ManagerFuncs:
manager_funcs = ManagerFuncs()
# Settings owned by this server; other keys and sections are preserved.
WRITTEN_CONFIG_KEYS = (
'git_exe',
'use_uv',
'channel_url',
'share_option',
'bypass_ssl',
'file_logging',
'update_policy',
'windows_selector_event_loop_policy',
'model_download_by_agent',
'downgrade_blacklist',
'security_level',
'always_lazy_install',
'network_mode',
'db_mode',
'allow_git_url_install',
'allow_pip_install',
'allow_flagged_nodepack_install',
)
def write_config():
config = configparser.ConfigParser(strict=False)
config['default'] = {
'git_exe': get_config()['git_exe'],
'use_uv': get_config()['use_uv'],
'channel_url': get_config()['channel_url'],
'share_option': get_config()['share_option'],
'bypass_ssl': get_config()['bypass_ssl'],
"file_logging": get_config()['file_logging'],
'update_policy': get_config()['update_policy'],
'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'],
'model_download_by_agent': get_config()['model_download_by_agent'],
'downgrade_blacklist': get_config()['downgrade_blacklist'],
'security_level': get_config()['security_level'],
'always_lazy_install': get_config()['always_lazy_install'],
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
}
# Sanitize all string values to prevent CRLF injection attacks
for key, value in config['default'].items():
if isinstance(value, str):
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(context.manager_config_path)
if not os.path.exists(directory):
os.makedirs(directory)
with open(context.manager_config_path, 'w') as configfile:
config.write(configfile)
"""Persist changed settings through the shared writer."""
write_config_merged(context.manager_config_path, get_config(), WRITTEN_CONFIG_KEYS)
def read_config():
@@ -1734,6 +1785,9 @@ def read_config():
'network_mode': default_conf.get('network_mode', NetworkMode.PUBLIC.value).lower(),
'security_level': default_conf.get('security_level', SecurityLevel.NORMAL.value).lower(),
'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:
@@ -1757,6 +1811,9 @@ def read_config():
'network_mode': NetworkMode.PUBLIC.value,
'security_level': SecurityLevel.NORMAL.value,
'db_mode': DBMode.CACHE.value,
'allow_git_url_install': False,
'allow_pip_install': False,
'allow_flagged_nodepack_install': False,
}
@@ -1764,7 +1821,8 @@ def get_config():
global cached_config
if cached_config is None:
cached_config = read_config()
# Start tracking changes after the startup configuration is loaded.
cached_config = DirtyTrackingConfig(read_config())
if cached_config['http_channel_enabled']:
print("[ComfyUI-Manager] Warning: http channel enabled, make sure server in secure env")
@@ -3193,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'):
+100 -60
View File
@@ -19,6 +19,7 @@ import asyncio
from collections import deque
from . import manager_core as core
from . import html_utils
from ..common import manager_util
from ..common import cm_global
from ..common import manager_downloader
@@ -37,12 +38,15 @@ logging.info("[ComfyUI-Manager] network_mode: " + network_mode_description)
comfy_ui_hash = "-"
comfyui_tag = None
SECURITY_MESSAGE_MIDDLE = "ERROR: To use this action, a security_level of `normal or below` is required. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_MIDDLE_P = "ERROR: To use this action, security_level must be `normal or below`, and network_mode must be set to `personal_cloud`. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
from ..common.security_messages import (
SECURITY_MESSAGE_MIDDLE,
SECURITY_MESSAGE_MIDDLE_P,
SECURITY_MESSAGE_HIGH_P,
SECURITY_MESSAGE_GENERAL,
SECURITY_MESSAGE_NORMAL_MINUS_MODEL,
)
SECURITY_MESSAGE_FLAG_GIT_URL = "ERROR: This action requires BOTH: (1) 'allow_git_url_install = true' in config.ini ([default] section), AND (2) a network position the Manager treats as private - either ComfyUI launched with a loopback '--listen' (any 127.x.x.x address, or ::1), OR 'network_mode = personal_cloud' in config.ini. Currently listening on {listen} with network_mode = {network_mode}. Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again. Both are independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_FLAG_PIP = "ERROR: This action requires BOTH: (1) 'allow_pip_install = true' in config.ini ([default] section), AND (2) a network position the Manager treats as private - either ComfyUI launched with a loopback '--listen' (any 127.x.x.x address, or ::1), OR 'network_mode = personal_cloud' in config.ini. Currently listening on {listen} with network_mode = {network_mode}. Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again. Both are independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
routes = PromptServer.instance.routes
@@ -122,6 +126,18 @@ def is_allowed_security_level(level):
return True
def _dedicated_install_allowed(flag_key: str) -> bool:
"""goal265: P-direct gate for the dedicated install flags (spec §1.2).
allowed iff config[flag_key] AND (loopback listener OR
network_mode == 'personal_cloud') — fully decoupled from security_level.
Resolves config through the LEGACY reader and delegates the pure
predicate to common/manager_security (which stays config-import-free).
"""
return manager_security.is_dedicated_install_allowed(
core.get_config()[flag_key], args.listen, core.get_config()['network_mode'])
async def get_risky_level(files, pip_packages):
json_data1 = await core.get_data_by_mode('local', 'custom-node-list.json')
json_data2 = await core.get_data_by_mode('cache', 'custom-node-list.json', channel_url='https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main')
@@ -146,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}`")
@@ -245,7 +264,6 @@ print_comfyui_version()
core.check_invalid_nodes()
def setup_environment():
git_exe = core.get_config()['git_exe']
@@ -453,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:
@@ -463,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}")
@@ -515,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:
@@ -690,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':
@@ -755,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'])
@@ -960,14 +976,22 @@ async def _update_all(json_data):
def convert_markdown_to_html(input_text):
"""Parse source markdown, escaping text and URLs at their HTML boundaries."""
pattern_a = re.compile(r'\[a/([^]]+)]\(([^)]+)\)')
pattern_w = re.compile(r'\[w/([^]]+)]')
pattern_i = re.compile(r'\[i/([^]]+)]')
pattern_bold = re.compile(r'\*\*([^*]+)\*\*')
pattern_white = re.compile(r'%%([^*]+)%%')
# Format link labels separately from URLs; escape URLs when inserted.
hrefs = []
def replace_a(match):
return f"<a href='{match.group(2)}' target='blank'>{match.group(1)}</a>"
# Entities written in the source URL (e.g. &amp;) retain their meaning.
url = html_utils.unescape_html_entities(match.group(2))
hrefs.append(html_utils.sanitize_url(url))
text = html_utils.escape_html_text(match.group(1))
return f"<a href='\x00H{len(hrefs) - 1}\x00' target='_blank' rel='noopener noreferrer'>{text}</a>"
def replace_w(match):
return f"<p class='cm-warn-note'>{match.group(1)}</p>"
@@ -981,20 +1005,35 @@ def convert_markdown_to_html(input_text):
def replace_white(match):
return f"<font color='white'>{match.group(1)}</font>"
input_text = input_text.replace('\\[', '&#91;').replace('\\]', '&#93;').replace('<', '&lt;').replace('>', '&gt;')
# NUL dropped first so the input cannot forge the href placeholders.
input_text = input_text.replace('\x00', '')
input_text = input_text.replace('\\[', '&#91;').replace('\\]', '&#93;')
result_text = re.sub(pattern_a, replace_a, input_text)
# Parse links before escaping prose, so generated text entities never enter URLs.
parts = []
start = 0
for match in pattern_a.finditer(input_text):
parts.append(manager_util.sanitize_tag(input_text[start:match.start()]))
parts.append(replace_a(match))
start = match.end()
parts.append(manager_util.sanitize_tag(input_text[start:]))
result_text = ''.join(parts)
result_text = re.sub(pattern_w, replace_w, result_text)
result_text = re.sub(pattern_i, replace_i, result_text)
result_text = re.sub(pattern_bold, replace_bold, result_text)
result_text = re.sub(pattern_white, replace_white, result_text)
result_text = result_text.replace("\n", "<BR>")
return result_text.replace("\n", "<BR>")
return re.sub(
r'\x00H(\d+)\x00',
lambda m: html_utils.escape_html_attribute(hrefs[int(m.group(1))]),
result_text,
)
def populate_markdown(x):
if 'description' in x:
x['description'] = convert_markdown_to_html(manager_util.sanitize_tag(x['description']))
x['description'] = convert_markdown_to_html(x['description'])
if 'name' in x:
x['name'] = manager_util.sanitize_tag(x['name'])
@@ -1382,7 +1421,6 @@ async def import_fail_info_bulk(request):
return web.Response(status=500, text="Internal server error")
@routes.post("/v2/manager/queue/reset")
async def reset_queue(request):
rejection = manager_security.reject_simple_form_post(request)
@@ -1398,7 +1436,6 @@ async def reset_queue(request):
return web.Response(status=200)
@routes.get("/v2/manager/queue/status")
async def queue_count(request):
global task_queue
@@ -1423,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
@@ -1473,12 +1510,22 @@ 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}")
if not is_allowed_security_level(risky_level):
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(
listen=args.listen, network_mode=core.get_config()['network_mode']))
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
elif not is_allowed_security_level(risky_level):
logging.error(SECURITY_MESSAGE_GENERAL)
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)
@@ -1527,9 +1574,12 @@ async def _fix_custom_node(json_data):
@routes.post("/v2/customnode/install/git_url")
async def install_custom_node_git_url(request):
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
return web.Response(status=403)
# goal265 S-A: dedicated-flag gate, decoupled from security_level (spec §1.2).
if not _dedicated_install_allowed('allow_git_url_install'):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(
listen=args.listen, network_mode=core.get_config()['network_mode']))
# Only the flag name goes on the wire; the full message is logged above.
return web.json_response({'reason': 'allow_git_url_install'}, status=403)
url = await request.text()
res = await core.gitclone_install(url)
@@ -1547,9 +1597,12 @@ async def install_custom_node_git_url(request):
@routes.post("/v2/customnode/install/pip")
async def install_custom_node_pip(request):
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
return web.Response(status=403)
# goal265 S-B: dedicated-flag gate, decoupled from security_level (spec §1.2).
if not _dedicated_install_allowed('allow_pip_install'):
logging.error(SECURITY_MESSAGE_FLAG_PIP.format(
listen=args.listen, network_mode=core.get_config()['network_mode']))
# Only the flag name goes on the wire; the full message is logged above.
return web.json_response({'reason': 'allow_pip_install'}, status=403)
packages = await request.text()
core.pip_install(packages.split(' '))
@@ -1791,19 +1844,6 @@ async def set_channel_url(request):
return web.Response(status=400, text='Invalid request')
def add_target_blank(html_text):
pattern = r'(<a\s+href="[^"]*"\s*[^>]*)(>)'
def add_target(match):
if 'target=' not in match.group(1):
return match.group(1) + ' target="_blank"' + match.group(2)
return match.group(0)
modified_html = re.sub(pattern, add_target, html_text)
return modified_html
@routes.get("/v2/manager/notice")
async def get_notice(request):
url = "github.com"
@@ -1819,7 +1859,9 @@ async def get_notice(request):
match = pattern.search(html_content)
if match:
markdown_content = match.group(1)
# Remote page content lands in innerHTML on the client:
# keep formatting markup only.
markdown_content = html_utils.sanitize_html_fragment(match.group(1))
version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__')
if version_tag is not None:
markdown_content += f"<HR>ComfyUI: {version_tag} [Desktop]"
@@ -1833,8 +1875,6 @@ async def get_notice(request):
# markdown_content += f"<BR>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;()"
markdown_content += f"<BR>Manager: {core.version_str}"
markdown_content = add_target_blank(markdown_content)
try:
if '__COMFYUI_DESKTOP_VERSION__' not in os.environ:
if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0):
+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])
+4 -2
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "comfyui-manager"
license = { text = "GPL-3.0-only" }
version = "4.2.1"
version = "4.3"
requires-python = ">= 3.9"
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
readme = "README.md"
@@ -35,7 +35,9 @@ dependencies = [
"typing-extensions",
"toml",
"uv",
"chardet"
"chardet",
"packaging",
"nh3>=0.3.7"
]
[project.optional-dependencies]
+2
View File
@@ -10,3 +10,5 @@ typing-extensions
toml
uv
chardet
packaging
nh3>=0.3.7
+177
View File
@@ -0,0 +1,177 @@
"""Shared test-support for the goal265 dedicated-install-flag test modules.
NOT a test module (no ``test_`` prefix — pytest does not collect it).
Provides minimal runtime stubs so ``comfyui_manager`` package modules can be
imported in a plain dev venv (outside a real ComfyUI runtime):
- ``comfy.cli_args.args`` — consumed by ``comfyui_manager/__init__.py`` at
package import time (``from comfy.cli_args import args``). The stub pins
``listen='127.0.0.1'`` (loopback) which matches the default precondition of
every scenario row (goal265-scenarios.md §0 preamble).
- ``folder_paths`` — consumed by ``comfyui_manager/common/context.py`` at
import time to derive the manager user directory. The stub redirects it to
a throwaway temp dir so importing the package NEVER creates directories
inside the repository checkout.
SUITE-ORDER INDEPENDENCE (goal265 FU, task #293): every public ``import_*``
entry point is self-sufficient — it repairs whatever ``sys.modules`` state
earlier test modules left behind, instead of assuming a pristine interpreter:
- Other test modules legitimately install FAKE ``comfyui_manager`` lineage
entries at module level (e.g. tests/test_unified_dep_resolver.py:63-73
registers a plain ``types.ModuleType("comfyui_manager")`` — NOT a package,
no ``__path__`` — to host a file-loaded module under a dotted name). Those
modules are imported at pytest COLLECTION time, so under full-suite
ordering the fake is already in ``sys.modules`` before any fixture here
runs, and ``import comfyui_manager.glob.manager_core`` dies with
``ModuleNotFoundError: 'comfyui_manager' is not a package``.
- ``_purge_fake_comfyui_manager()`` therefore validates the cached lineage
against the REAL package directory on disk and drops fake/broken entries
before importing. Dropping ``sys.modules`` entries is safe for the other
test modules: they hold direct object references to what they loaded;
they do not re-resolve through ``sys.modules``.
- ``ensure_comfy_stubs()`` likewise repairs half-formed ``comfy`` /
``folder_paths`` entries (present but missing the attributes we need)
rather than only handling the absent case.
Used by:
- tests/test_install_flags_config.py (dual-reader config tests)
- tests/test_install_flags_guards.py (out-of-scope guard tests)
Spec SoT: ~/.claude/pair-cowork/scratch/gm-team/goal265-spec.md §4
(test surface contract — binding for Step-4 test authoring).
"""
from __future__ import annotations
import importlib
import os
import sys
import tempfile
import types
#: repo root (parent of tests/)
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
#: the real on-disk package directory the cached lineage must resolve to
_REAL_PKG_DIR = os.path.join(REPO_ROOT, "comfyui_manager")
def ensure_comfy_stubs() -> None:
"""Install or REPAIR minimal ``comfy``/``folder_paths`` stubs (idempotent).
Handles three prior states per module:
- absent -> install a fresh stub
- present but inadequate -> patch the missing attributes in place
(another test's stub, or a partially-initialized real module)
- present and adequate -> leave untouched (e.g. real ComfyUI runtime)
"""
# --- comfy.cli_args.args ------------------------------------------------
comfy = sys.modules.get("comfy")
if comfy is None:
comfy = types.ModuleType("comfy")
sys.modules["comfy"] = comfy
cli_args = sys.modules.get("comfy.cli_args")
if cli_args is None:
cli_args = getattr(comfy, "cli_args", None)
if not isinstance(cli_args, types.ModuleType):
cli_args = types.ModuleType("comfy.cli_args")
sys.modules["comfy.cli_args"] = cli_args
args = getattr(cli_args, "args", None)
if args is None or not hasattr(args, "listen"):
setattr(cli_args, "args", types.SimpleNamespace(
listen="127.0.0.1",
enable_manager=True,
enable_manager_legacy_ui=False,
))
# re-link parent attr idempotently (a bare ``comfy`` stub from another
# test may lack the submodule attribute even when both entries exist)
setattr(comfy, "cli_args", cli_args)
# --- folder_paths -------------------------------------------------------
fp = sys.modules.get("folder_paths")
if fp is None:
fp = types.ModuleType("folder_paths")
sys.modules["folder_paths"] = fp
if not hasattr(fp, "get_system_user_directory"):
base = tempfile.mkdtemp(prefix="cm-flags-test-user-")
setattr(fp, "get_system_user_directory",
lambda name: os.path.join(base, name))
def _module_is_real(mod: types.ModuleType) -> bool:
"""True when a cached ``comfyui_manager*`` module resolves to the real
on-disk package tree (by ``__path__`` for packages, ``__file__`` for
leaf modules)."""
real_root = os.path.abspath(_REAL_PKG_DIR)
paths = getattr(mod, "__path__", None)
if paths is not None:
return any(os.path.abspath(p).startswith(real_root) for p in paths)
file = getattr(mod, "__file__", None)
if file is not None:
return os.path.abspath(file).startswith(real_root)
# neither __path__ nor __file__: a bare ModuleType fake
return False
def _purge_fake_comfyui_manager() -> None:
"""Drop fake/broken ``comfyui_manager`` lineage entries from
``sys.modules`` so a subsequent real-package import succeeds.
Uniform rule — an entry is dropped iff it does NOT resolve to the real
on-disk package tree:
- A fake top-level entry (bare ``ModuleType`` without ``__path__`` —
'not a package') is dropped so the real package can import.
- Fake SUBentries injected under dotted names (e.g.
``comfyui_manager.common.manager_util`` replaced by a stub) are
dropped; the import machinery re-imports the real module on next
access.
- REAL-file modules registered under dotted names by other test
modules (e.g. ``comfyui_manager.common.unified_dep_resolver`` loaded
via spec_from_file_location by tests/test_unified_dep_resolver.py)
are KEPT: those tests later resolve the SAME cached entry through
``mock.patch("comfyui_manager.common.unified_dep_resolver.X")``;
evicting it would make mock.patch import a fresh second instance and
silently patch the wrong module object.
"""
lineage = [n for n in list(sys.modules)
if n == "comfyui_manager" or n.startswith("comfyui_manager.")]
for name in lineage:
mod = sys.modules.get(name)
if mod is None or not _module_is_real(mod):
del sys.modules[name]
def _import_real(dotted: str):
"""ensure stubs -> purge fakes -> import; shared by all entry points."""
ensure_comfy_stubs()
_purge_fake_comfyui_manager()
return importlib.import_module(dotted)
def import_reader(reader: str):
"""Import and return ``comfyui_manager.<reader>.manager_core``.
``reader`` is ``"glob"`` or ``"legacy"`` — the two independent config
reader implementations sharing one config.ini (dual-reader rule,
goal265-mm.md §1.1 / goal265-spec.md §3).
"""
assert reader in ("glob", "legacy"), reader
return _import_real(f"comfyui_manager.{reader}.manager_core")
def import_context():
"""Import and return ``comfyui_manager.common.context`` (holds
``manager_config_path``, consumed by both readers at call time)."""
return _import_real("comfyui_manager.common.context")
def import_glob_security_utils():
"""Import and return ``comfyui_manager.glob.utils.security_utils``
(the glob copy of the security_level gate matrix — guard rows
SC-25C / SC-28)."""
return _import_real("comfyui_manager.glob.utils.security_utils")
+267
View File
@@ -0,0 +1,267 @@
"""[goal265 step4 — RED] Predicate truth table for ``is_dedicated_install_allowed``.
TARGET CONTRACT (NOT YET IMPLEMENTED — goal265-spec.md §1.2, LOCKED):
comfyui_manager/common/manager_security.py::is_dedicated_install_allowed(
flag_value: bool, listen_address: str, network_mode: str) -> bool
P-direct: allowed iff bool(flag_value)
AND (is_loopback(listen_address)
OR network_mode.lower() == 'personal_cloud')
These tests are EXPECTED TO FAIL/ERROR against current code (the predicate
does not exist yet) — RED confirmation is goal265 Step 5. Do NOT weaken them
to pass against today's code.
SC rows covered (goal265-scenarios.md — predicate-level arm):
SC-01, SC-02, SC-03, SC-05, SC-06, SC-07 ([D1] allow_git_url_install)
SC-11, SC-12, SC-13, SC-14, SC-15 ([D2] allow_pip_install)
SC-16, SC-17 (flag independence, [D1][D2])
security_level is ABSENT from the predicate signature — its irrelevance is
proven BY CONSTRUCTION (spec §4 row 1): rows whose preconditions differ only
in security_level (SC-01 vs SC-02; SC-06/SC-14 parametrizations) map onto the
IDENTICAL predicate call, so no security_level value can change the outcome.
Fixtures: none — pure function (spec §4 binding patching constraint).
The module under test is loaded directly by file path so the test does not
import the ``comfyui_manager`` package (whose __init__ needs the ComfyUI
runtime); ``manager_security.py`` itself is dependency-light by design
(spec §1.2: MUST stay config-import-free).
"""
from __future__ import annotations
import importlib.util
import inspect
import pathlib
import pytest
_MANAGER_SECURITY_PATH = (
pathlib.Path(__file__).resolve().parents[2]
/ "comfyui_manager" / "common" / "manager_security.py"
)
# Address / network-mode vocabulary (scenarios §0 preamble):
LOOPBACK = "127.0.0.1"
LOOPBACK_V6 = "::1"
PUBLIC_ADDR = "203.0.113.5" # TEST-NET-3 — non-loopback ("listen=public")
NM_PUBLIC = "public"
NM_PERSONAL_CLOUD = "personal_cloud"
# security_level context values for irrelevance-by-construction params.
# The predicate takes NO security_level argument; these ids document which
# scenario-row context each identical call stands for.
SECURITY_LEVELS = ("strong", "normal", "normal-", "weak")
def _load_manager_security():
spec = importlib.util.spec_from_file_location(
"_manager_security_under_test", _MANAGER_SECURITY_PATH
)
assert spec is not None and spec.loader is not None, _MANAGER_SECURITY_PATH
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def predicate():
"""The predicate under test. FAILS (RED) while the function is absent."""
mod = _load_manager_security()
assert hasattr(mod, "is_dedicated_install_allowed"), (
"RED: comfyui_manager/common/manager_security.py does not define "
"is_dedicated_install_allowed yet (goal265-spec.md §1.2). "
"This is the expected state before Step 6 (Develop)."
)
return mod.is_dedicated_install_allowed
# ---------------------------------------------------------------------------
# Structural: security_level is not even an input (irrelevance by construction)
# ---------------------------------------------------------------------------
def test_signature_has_no_security_level_parameter(predicate):
"""[SC-01..SC-17 foundation] Spec §1.2 locks the signature to exactly
(flag_value, listen_address, network_mode) — security_level CANNOT
influence the decision because it is not an input."""
params = list(inspect.signature(predicate).parameters)
assert params == ["flag_value", "listen_address", "network_mode"], (
f"Locked signature drifted: {params!r} "
"(goal265-spec.md §1.2 — security_level must NOT appear)"
)
# ---------------------------------------------------------------------------
# [D1] allow_git_url_install — happy paths
# ---------------------------------------------------------------------------
def test_sc01_flag_true_loopback_allows_under_strong_security_level(predicate):
"""SC-01: git=true, sl=strong, listen=loopback, nm=public -> allowed.
sl=strong is context only — the call has no security_level input."""
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
def test_sc02_flag_true_loopback_allows_under_default_security_level(predicate):
"""SC-02: git=true, sl=normal (default), listen=loopback, nm=public ->
allowed. Identical call to SC-01: proves sl irrelevance by construction."""
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
def test_sc03_flag_true_public_listener_personal_cloud_allows(predicate):
"""SC-03: git=true, sl=normal, listen=public, nm=personal_cloud ->
allowed (personal_cloud arm of the network-position invariant)."""
assert predicate(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD) is True
# ---------------------------------------------------------------------------
# [D1] allow_git_url_install — failure paths
# ---------------------------------------------------------------------------
def test_sc05_flag_false_denies_even_at_weak_security_level(predicate):
"""SC-05: git=false, sl=weak, listen=loopback -> denied. Deny-direction
proof of security_level irrelevance (today sl=weak would ALLOW)."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
@pytest.mark.parametrize("security_level_context", SECURITY_LEVELS[:3],
ids=[f"sl={s}" for s in SECURITY_LEVELS[:3]])
def test_sc06_flag_false_denies_for_every_security_level(
predicate, security_level_context):
"""SC-06: git=false, sl in {strong, normal, normal-}, listen=loopback ->
denied for EVERY sl value. The parametrize axis documents that all three
scenario contexts collapse onto the same flag-only call — the flag is
the sole decider."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
def test_sc07_flag_true_cannot_open_public_listener(predicate):
"""SC-07: git=true, sl=weak, listen=public, nm=public -> denied.
Network-position invariant retained (spec §1.1 invariant 2): the flag
must never widen exposure beyond what is possible today."""
assert predicate(True, PUBLIC_ADDR, NM_PUBLIC) is False
# ---------------------------------------------------------------------------
# [D2] allow_pip_install — happy paths
# ---------------------------------------------------------------------------
def test_sc11_pip_flag_true_loopback_allows_under_strong(predicate):
"""SC-11: pip=true, sl=strong, listen=loopback, nm=public -> allowed."""
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
def test_sc12_pip_flag_true_public_listener_personal_cloud_allows(predicate):
"""SC-12: pip=true, sl=normal, listen=public, nm=personal_cloud ->
allowed."""
assert predicate(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD) is True
# ---------------------------------------------------------------------------
# [D2] allow_pip_install — failure paths
# ---------------------------------------------------------------------------
def test_sc13_pip_flag_false_denies_even_at_weak(predicate):
"""SC-13: pip=false, sl=weak, listen=loopback -> denied."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
@pytest.mark.parametrize("security_level_context", SECURITY_LEVELS[:3],
ids=[f"sl={s}" for s in SECURITY_LEVELS[:3]])
def test_sc14_pip_flag_false_denies_for_every_security_level(
predicate, security_level_context):
"""SC-14: pip=false, sl in {strong, normal, normal-}, listen=loopback ->
denied each. Same irrelevance-by-construction pattern as SC-06."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
def test_sc15_pip_flag_true_cannot_open_public_listener(predicate):
"""SC-15: pip=true, sl=weak, listen=public, nm=public -> denied
(network invariant)."""
assert predicate(True, PUBLIC_ADDR, NM_PUBLIC) is False
# ---------------------------------------------------------------------------
# Flag independence (cross-link [D1]+[D2])
# ---------------------------------------------------------------------------
def test_sc16_git_false_pip_true_independent_outcomes(predicate):
"""SC-16: git=false, pip=true, sl=normal, listen=loopback -> git denied,
pip allowed. At predicate level each surface passes ONLY its own flag —
same network inputs, opposite outcomes."""
git_allowed = predicate(False, LOOPBACK, NM_PUBLIC)
pip_allowed = predicate(True, LOOPBACK, NM_PUBLIC)
assert git_allowed is False
assert pip_allowed is True
def test_sc17_git_true_pip_false_independent_outcomes(predicate):
"""SC-17: git=true, pip=false, sl=normal, listen=loopback -> git allowed,
pip denied (mirror of SC-16)."""
git_allowed = predicate(True, LOOPBACK, NM_PUBLIC)
pip_allowed = predicate(False, LOOPBACK, NM_PUBLIC)
assert git_allowed is True
assert pip_allowed is False
# ---------------------------------------------------------------------------
# Supplementary: exhaustive flag x loopback x personal_cloud truth table
# (spec §4 row 1 — "flag x loopback x personal_cloud" full table)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("flag", "listen", "network_mode", "expected"),
[
(True, LOOPBACK, NM_PUBLIC, True), # SC-01/02/11
(True, LOOPBACK, NM_PERSONAL_CLOUD, True), # both arms true
(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD, True), # SC-03/12
(True, PUBLIC_ADDR, NM_PUBLIC, False), # SC-07/15
(False, LOOPBACK, NM_PUBLIC, False), # SC-05/06/13/14
(False, LOOPBACK, NM_PERSONAL_CLOUD, False),
(False, PUBLIC_ADDR, NM_PERSONAL_CLOUD, False),
(False, PUBLIC_ADDR, NM_PUBLIC, False),
],
ids=[
"flag+loopback+nm_public",
"flag+loopback+personal_cloud",
"flag+public+personal_cloud",
"flag+public+nm_public",
"noflag+loopback+nm_public",
"noflag+loopback+personal_cloud",
"noflag+public+personal_cloud",
"noflag+public+nm_public",
],
)
def test_truth_table_flag_x_loopback_x_personal_cloud(
predicate, flag, listen, network_mode, expected):
"""Full 2x2x2 truth table for P-direct (spec §1.1): allowed iff flag AND
(loopback OR personal_cloud). Consolidates SC-01/02/03/05/06/07 ([D1])
and SC-11/12/13/14/15 ([D2]) plus the two combinations no single row
pins (flag+loopback+personal_cloud, noflag+public+personal_cloud)."""
assert predicate(flag, listen, network_mode) is expected
# ---------------------------------------------------------------------------
# Supplementary edge handling locked by the spec text
# ---------------------------------------------------------------------------
def test_ipv6_loopback_counts_as_loopback(predicate):
"""SC-01/SC-02 edge (loopback arm): `is_loopback` is ipaddress-based
(manager_security.py) — ::1 must be treated as loopback too."""
assert predicate(True, LOOPBACK_V6, NM_PUBLIC) is True
@pytest.mark.parametrize("network_mode", ["Personal_Cloud", "PERSONAL_CLOUD"],
ids=["mixed-case", "upper-case"])
def test_network_mode_personal_cloud_is_case_insensitive(predicate, network_mode):
"""SC-03/SC-12 edge (personal_cloud arm): spec §1.2 predicate body —
network_mode.lower() == 'personal_cloud' (case-insensitive)."""
assert predicate(True, PUBLIC_ADDR, network_mode) is True
def test_unparseable_listen_address_is_not_loopback(predicate):
"""SC-07/SC-15 edge (network invariant): is_loopback returns False for
unparseable addresses (ValueError arm) — the predicate must fail CLOSED
on a malformed listen address."""
assert predicate(True, "not-an-ip-address", NM_PUBLIC) is False
+25 -10
View File
@@ -10,15 +10,18 @@
# script does NOT restore on its own, so fixture teardown MUST cleanup.
#
# Why permissive mode is needed:
# Three endpoints check is_allowed_security_level('high+')
# (security_utils.py:20-26): at is_local_mode=True (127.0.0.1 listen)
# the gate requires security_level ∈ {weak, normal-}. Default
# `security_level = normal` fails, so the POST returns 403.
# - wi-014 POST /v2/comfyui_manager/comfyui_switch_version
# - wi-037 POST /v2/customnode/install/git_url
# - wi-038 POST /v2/customnode/install/pip
# Setting security_level = normal- allows real E2E execution of these
# endpoints with fixed, trusted inputs (never test-input-derived URLs).
# - wi-014 POST /v2/comfyui_manager/comfyui_switch_version checks
# is_allowed_security_level('high+'): at is_local_mode=True
# (127.0.0.1 listen) the gate requires security_level ∈ {weak,
# normal-}. Default `security_level = normal` fails (403).
# - wi-037 POST /v2/customnode/install/git_url and
# wi-038 POST /v2/customnode/install/pip are gated by the dedicated
# config.ini flags `allow_git_url_install` / `allow_pip_install`
# (goal265 / #2962) — independent of security_level, default false
# (403).
# Patching security_level = normal- plus both flags = true allows real
# E2E execution of these endpoints with fixed, trusted inputs (never
# test-input-derived URLs).
#
# SECURITY NOTE:
# The endpoints are gated at high+ because they execute arbitrary remote
@@ -39,7 +42,8 @@
# Exit: 0=ready, 1=timeout/failure
#
# Side effect: $E2E_ROOT/comfyui/user/__manager/config.ini gets
# `security_level = normal-`. The original value is preserved at
# `security_level = normal-`, `allow_git_url_install = true` and
# `allow_pip_install = true`. The original config is preserved at
# config.ini.before-permissive for the fixture to restore on teardown.
set -euo pipefail
@@ -68,4 +72,15 @@ else
fi
echo "[start_comfyui_permissive] Patched security_level = normal- in $CONFIG"
# Patch the dedicated install flags (goal265 / #2962): install/git_url and
# install/pip are gated by these flags instead of security_level (idempotent).
for flag in allow_git_url_install allow_pip_install; do
if grep -qE "^${flag}\s*=" "$CONFIG"; then
sed -i -E "s/^${flag}\s*=.*/${flag} = true/" "$CONFIG"
else
sed -i -E "/^\[default\]/a ${flag} = true" "$CONFIG"
fi
done
echo "[start_comfyui_permissive] Patched allow_git_url_install/allow_pip_install = true in $CONFIG"
exec env ENABLE_LEGACY_UI=1 bash "$SCRIPT_DIR/start_comfyui.sh" "$@"
+63 -4
View File
@@ -17,13 +17,16 @@ Usage:
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import pytest
import requests
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
CUSTOM_NODES = os.path.join(COMFYUI_PATH, "custom_nodes") if COMFYUI_PATH else ""
SCRIPTS_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scripts"
)
@@ -31,6 +34,16 @@ SCRIPTS_DIR = os.path.join(
PORT = 8199
BASE_URL = f"http://127.0.0.1:{PORT}"
# Seed pack for the `installed` tests — same CNR test package used by
# test_e2e_endpoint.py / test_e2e_task_operations.py. Installed by the
# `seed_pack_on_disk` autouse fixture below (this module runs
# alphabetically BEFORE test_e2e_endpoint.py, so it cannot rely on that
# module having installed the pack — on a fresh E2E env nothing else
# seeds it).
PACK_ID = "ComfyUI_SigmoidOffsetScheduler"
PACK_DIR_NAME = "ComfyUI_SigmoidOffsetScheduler"
PACK_VERSION = "1.0.1"
pytestmark = pytest.mark.skipif(
not E2E_ROOT
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
@@ -84,6 +97,51 @@ def comfyui():
_stop_comfyui()
def _pack_exists() -> bool:
return os.path.isdir(os.path.join(CUSTOM_NODES, PACK_DIR_NAME))
def _cm_cli_path() -> str:
if sys.platform == "win32":
return os.path.join(E2E_ROOT, "venv", "Scripts", "cm-cli.exe")
return os.path.join(E2E_ROOT, "venv", "bin", "cm-cli")
@pytest.fixture(scope="module", autouse=True)
def seed_pack_on_disk():
"""Ensure the seed pack is on disk BEFORE the server starts.
The `installed?mode=imported` test asserts against the startup
snapshot, which is frozen when the server boots — so the pack must be
installed before the module's `comfyui` fixture launches it. Autouse +
module scope guarantees this fixture is instantiated ahead of the
non-autouse `comfyui` fixture. Installs via cm-cli (no server needed,
creates the same CNR `.tracking` layout) and removes the pack on
teardown only if this fixture installed it, leaving the environment
as found.
"""
installed_by_fixture = False
if not _pack_exists():
env = {**os.environ, "COMFYUI_PATH": COMFYUI_PATH}
r = subprocess.run(
[_cm_cli_path(), "install", f"{PACK_ID}@{PACK_VERSION}"],
capture_output=True, text=True, timeout=300, env=env,
)
assert r.returncode == 0 and _pack_exists(), (
f"seed fixture failed: cm-cli install {PACK_ID}@{PACK_VERSION} "
f"(exit {r.returncode})\nSTDOUT: {r.stdout[-500:]}\n"
f"STDERR: {r.stderr[-500:]}"
)
installed_by_fixture = True
yield PACK_ID
if installed_by_fixture:
shutil.rmtree(
os.path.join(CUSTOM_NODES, PACK_DIR_NAME), ignore_errors=True
)
# ---------------------------------------------------------------------------
# Tests — getmappings
# ---------------------------------------------------------------------------
@@ -164,10 +222,11 @@ class TestInstalledPacks:
def test_installed_returns_dict(self, comfyui):
"""GET /v2/customnode/installed returns dict containing seeded E2E pack with valid per-entry schema.
WI-M strengthening: previously only dict-type check. The E2E setup
seeds `ComfyUI_SigmoidOffsetScheduler` (the test package used across
task_operations/endpoint tests); its presence is a hard precondition
for most other tests. We now assert it's in the installed dict AND
WI-M strengthening: previously only dict-type check. The module's
`seed_pack_on_disk` autouse fixture installs
`ComfyUI_SigmoidOffsetScheduler` (the test package used across
task_operations/endpoint tests) before the server starts.
We now assert it's in the installed dict AND
that its entry has the documented InstalledPack fields
(cnr_id/ver/enabled). Defeats a regression where `installed` returns
an empty dict despite packs existing on disk.
+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()
+7 -2
View File
@@ -64,11 +64,16 @@ def _cm_cli_path() -> str:
def _ensure_cache():
"""Run cm-cli update-cache (blocking) to populate Manager cache before tests."""
"""Run cm-cli update-cache (blocking) to populate Manager cache before tests.
Timeout is generous (600s): update-cache downloads the full DB lists
over the network and routinely exceeds 120s on slow links, which used
to fail the whole module at setup.
"""
env = {**os.environ, "COMFYUI_PATH": COMFYUI_PATH}
r = subprocess.run(
[_cm_cli_path(), "update-cache"],
capture_output=True, text=True, timeout=120, env=env,
capture_output=True, text=True, timeout=600, env=env,
)
if r.returncode != 0:
raise RuntimeError(f"update-cache failed:\n{r.stderr}")
+24 -15
View File
@@ -5,10 +5,13 @@ operation via HTTP:
Default-security fixture (middle+ / no-gate endpoints):
- wi-020 POST /v2/manager/queue/install_model (tiny TAEF1 model, <5MB)
- wi-024 POST /v2/manager/queue/update_comfyui (safe via env var)
Permissive-security fixture (high+ endpoints — normal- harness):
- wi-014 POST /v2/comfyui_manager/comfyui_switch_version (no-op self-switch)
- wi-037 POST /v2/customnode/install/git_url (nodepack-test1-do-not-install)
- wi-038 POST /v2/customnode/install/pip (text-unidecode)
Permissive-security fixture (normal- + dedicated install flags):
- wi-014 POST /v2/comfyui_manager/comfyui_switch_version (no-op self-switch;
high+ gate, opened by security_level = normal-)
- wi-037 POST /v2/customnode/install/git_url (nodepack-test1-do-not-install;
gated by allow_git_url_install — goal265 / #2962)
- wi-038 POST /v2/customnode/install/pip (text-unidecode;
gated by allow_pip_install — goal265 / #2962)
Pre-seeded broken-pack fixture (no-gate endpoint, needs scan-time state):
- wi-015 POST /v2/customnode/import_fail_info (pre-seeded broken pack)
@@ -21,13 +24,17 @@ the test venv.
Permissive harness security rationale:
wi-014/037/038 execute arbitrary remote code (version switch, git
clone, pip install) and are gated at `high+` precisely to prevent
such operations at default security. The permissive harness
clone, pip install) and are gated precisely to prevent such
operations at default config — wi-014 at `high+` (security_level),
wi-037/038 by the dedicated `allow_git_url_install` /
`allow_pip_install` flags (goal265 / #2962, independent of
security_level, default false). The permissive harness
(start_comfyui_permissive.sh) reflects the production use case
these endpoints exist to serve — operators in a trusted environment
lower security_level to normal-/weak to enable these features. The
200 path IS a supported feature, and testing it requires exactly
this configuration. Permissive harness uses HARDCODED trusted inputs:
set security_level = normal- and opt in via the flags to enable
these features. The 200 path IS a supported feature, and testing it
requires exactly this configuration. Permissive harness uses
HARDCODED trusted inputs:
- wi-014: the CURRENT ComfyUI version (self-switch no-op)
- wi-037: https://github.com/ltdrdata/nodepack-test1-do-not-install
(project's test-fixture repo, also used by tests/cli/test_uv_compile.py)
@@ -162,7 +169,8 @@ def comfyui_legacy():
def _start_comfyui_permissive() -> int:
"""Launch via start_comfyui_permissive.sh — patches config.ini to
`security_level = normal-` (backup at config.ini.before-permissive)
`security_level = normal-` plus `allow_git_url_install = true` /
`allow_pip_install = true` (backup at config.ini.before-permissive)
then delegates to start_comfyui.sh with ENABLE_LEGACY_UI=1.
The permissive fixture MUST restore config on teardown.
"""
@@ -198,9 +206,10 @@ def _restore_permissive_config():
@pytest.fixture(scope="module")
def comfyui_permissive():
"""Module-scoped fixture: start server with security_level=normal-,
tear down with config restore. Use for wi-014/037/038 which require
`high+` (security_utils.py:20-26 allows weak/normal- at is_local_mode).
"""Module-scoped fixture: start server with security_level=normal- and
the dedicated install flags enabled, tear down with config restore.
Use for wi-014 (high+ gate: weak/normal- at is_local_mode) and
wi-037/038 (allow_git_url_install / allow_pip_install — goal265 / #2962).
"""
pid = _start_comfyui_permissive()
try:
@@ -489,7 +498,7 @@ class TestInstallViaGitUrlRealClone:
)
assert resp.status_code == 200, (
f"install/git_url with trusted URL {TRUSTED_GIT_URL!r} "
f"should return 200 at security_level=normal-, got "
f"should return 200 with allow_git_url_install=true, got "
f"{resp.status_code}: {resp.text[:300]}"
)
assert os.path.isdir(target), (
@@ -555,7 +564,7 @@ class TestInstallPipRealExecute:
)
assert resp.status_code == 200, (
f"install/pip with trusted pkg {TRUSTED_PIP_PKG!r} should "
f"return 200 at security_level=normal-, got "
f"return 200 with allow_pip_install=true, got "
f"{resp.status_code}: {resp.text[:300]}"
)
+408
View File
@@ -0,0 +1,408 @@
"""[goal299 step3] E2E: URL-form pip install through the S-B endpoint.
CONTRACT UNDER TEST (SHIPPED — goal265; this module adds tests only):
the ``allow_pip_install`` gate on POST /v2/customnode/install/pip is
**argument-content-agnostic** (goal299-mm-addendum.md §1): a URL-form
argument (``git+https://github.com/...``) exercises the SAME
dedicated-flag gate as a bare package name — no code path inspects
the argument before the gate decision (legacy/manager_server.py:1574).
FIXTURE NOTE (goal329 — WHY the owned fixture): the install arm
originally exercised ``git+https://github.com/facebookresearch/sam2``;
that external, unpinned, heavy dependency (torch-ecosystem build at
restart time) made the test hostage to upstream maintainer drift
(maintainer-fragility decision, goal329). It is replaced by the OWNED,
purpose-built fixture ``git+https://github.com/ltdrdata/pip-test1-do-not-install``
(PUBLIC; package ``pip-test1-do-not-install``, module
``pip_test1_do_not_install``, ``MARKER = "pip-test1-do-not-install:ok"``,
zero deps, pure Python, no import side effects). The gate contract is
argument-content-agnostic, so the URL swap changes NOTHING about what
is being proven — only the install payload shrinks to a harmless,
owned package.
Two arms (goal299-spec.md §1.1, LOCKED):
DENY arm flag=false at security_level=weak -> 403, NO reservation
entry appended, denial log names allow_pip_install
(weak proves the FLAG, not the security_level, decides).
INSTALL arm flag=true at security_level=strong, loopback -> 200 =
gate-pass + RESERVATION (deferred-execution semantics,
addendum §1: '#FORCE' reserves into install-scripts.txt;
the real `uv pip install` runs at the NEXT server start)
-> restart -> fixture module REALLY importable (MARKER
verified) in the isolated E2E venv -> self-clean ->
absent again.
Placement: NEW SIBLING of tests/e2e/test_e2e_secgate_legacy_flags.py
(spec §1.2) — that module carries an engineered zero-install guarantee
(its header :96-100), so the real-install arm must NOT live there.
Helper reuse contract (spec §1.2, LOCKED): import-reuse is limited to
``_stage_flags_config`` / ``_stop_legacy`` / ``_make_flags_server_fixture``
/ ``_post_pip`` / ``_log_offset``; everything else needed here is a
sibling-local definition (provenance-commented copies where applicable).
Self-cleaning + idempotent (spec §1.3 install-arm steps 1/6): pre-guard
uninstall, teardown uninstall (runs even on failure), reservation-file
hygiene on both ends. The venv is uv-managed and has NO pip module
(addendum R4) — all (un)installs go through ``<venv>/bin/uv``.
Requires a pre-built E2E environment (setup_e2e_env.sh); skip-marked
otherwise. The install arm additionally requires github.com reachability
(addendum R5) and is skip-marked offline; the deny arm always runs.
"""
from __future__ import annotations
import ast
import os
import socket
import subprocess
import pytest
# Import-reuse: the LOCKED helper subset only (goal299-spec.md §1.2).
from test_e2e_secgate_legacy_flags import (
_log_offset,
_make_flags_server_fixture,
_post_pip,
_stop_legacy,
)
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
SERVER_LOG = os.path.join(E2E_ROOT, "logs", "comfyui.log") if E2E_ROOT else ""
# Same port as the flags module (its module constant is not in the locked
# import set; the value is part of the shared legacy-fixture contract).
PORT = 8199
# Owned fixture (goal329 — see FIXTURE NOTE in the module docstring).
# Dist name uses hyphens, import name uses underscores; MARKER gives a
# stronger installed-for-real check than a bare import.
FIXTURE_URL = "git+https://github.com/ltdrdata/pip-test1-do-not-install"
FIXTURE_DIST = "pip-test1-do-not-install"
FIXTURE_MODULE = "pip_test1_do_not_install"
FIXTURE_MARKER = "pip-test1-do-not-install:ok"
VENV_PY = os.path.join(E2E_ROOT, "venv", "bin", "python") if E2E_ROOT else ""
VENV_UV = os.path.join(E2E_ROOT, "venv", "bin", "uv") if E2E_ROOT else ""
# Reservation file (path shape per tests/e2e/test_e2e_legacy_real_ops.py:538;
# producer: reserve_script, legacy/manager_core.py:1836-1843; consumer:
# comfyui_manager/prestartup_script.py:485 at next server start).
SCRIPTS_PATH = (
os.path.join(
COMFYUI_PATH, "user", "__manager", "startup-scripts", "install-scripts.txt"
)
if E2E_ROOT
else ""
)
# Post-reservation restart budget (goal329): the owned fixture is pure
# Python with zero deps — its `uv pip install` completes in seconds, so
# the restart wall time is dominated by the plain server boot
# (+prestartup resolver), observed well under 60s in this harness. 180s
# equals the proven readiness budget the flags module's _start_legacy
# uses for a plain legacy start (>=3x headroom over observed boot); the
# python-side timeout is shell+60 per the helper invariant.
RESTART_BUDGET_S = 180
pytestmark = pytest.mark.skipif(
not E2E_ROOT
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready",
)
# ---------------------------------------------------------------------------
# Sibling-local helpers (spec §1.2 — outside the locked import set)
# ---------------------------------------------------------------------------
def _start_legacy_install(extra_env: dict, shell_timeout: int = RESTART_BUDGET_S) -> int:
"""Parameterized variant of test_e2e_secgate_legacy_flags._start_legacy
(:147-158) — provenance copy per goal299-spec.md §1.2. The flags
module's helper hardcodes its env dict and python-side timeout, which
can neither inject per-restart env nor take a per-call readiness
budget. TIMEOUT is forwarded to start_comfyui_legacy.sh as the shell
readiness budget; the python-side subprocess timeout MUST exceed it
(+60s) so the start script, not python, owns the timeout."""
env = {
**os.environ,
"E2E_ROOT": E2E_ROOT,
"PORT": str(PORT),
"TIMEOUT": str(shell_timeout),
**extra_env,
}
r = subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui_legacy.sh")],
capture_output=True, text=True, timeout=shell_timeout + 60, env=env,
)
if r.returncode != 0:
raise RuntimeError(
f"Failed to start ComfyUI (legacy, install restart):\n{r.stderr}"
)
for part in r.stdout.strip().split():
if part.startswith("COMFYUI_PID="):
return int(part.split("=")[1])
raise RuntimeError(f"Could not parse PID:\n{r.stdout}")
def _log_slice(offset: int) -> str:
"""Provenance copy of test_e2e_secgate_legacy_flags._log_slice
(:241-246) — not in the locked import set (spec §1.2 fallback clause:
fixture semantics are the lock, import path is not)."""
if not os.path.isfile(SERVER_LOG):
return ""
with open(SERVER_LOG, "r", encoding="utf-8", errors="replace") as f:
f.seek(offset)
return f.read()
def _scripts_state() -> str:
"""Current install-scripts.txt content, or the sentinel 'ABSENT' —
the file may legitimately not exist (spec §1.3 deny step 2: it is
created lazily by reserve_script on first reservation)."""
if not os.path.isfile(SCRIPTS_PATH):
return "ABSENT"
with open(SCRIPTS_PATH, "r", encoding="utf-8", errors="replace") as f:
return f.read()
def _venv_fixture_probe() -> subprocess.CompletedProcess:
"""Import the fixture module in the isolated E2E venv and print its
MARKER (subprocess — the observable for really-installed /
really-absent). rc != 0 means absent; rc == 0 AND the MARKER value in
stdout means installed for real (stronger than a bare import)."""
return subprocess.run(
[VENV_PY, "-c", f"import {FIXTURE_MODULE} as m; print(m.MARKER)"],
capture_output=True, text=True, timeout=60,
)
def _uninstall_fixture() -> None:
"""Uninstall the fixture distribution from the E2E venv via uv.
The venv has NO pip module (`python -m pip` fails — addendum R4);
`<venv>/bin/uv pip uninstall --python <venv-python>` is the working
path. A no-op when the dist is absent (uv warns, exits 0)."""
subprocess.run(
[VENV_UV, "pip", "uninstall", FIXTURE_DIST, "--python", VENV_PY],
capture_output=True, text=True, timeout=180,
)
def _strip_fixture_reservation() -> None:
"""Remove any fixture residual line from install-scripts.txt
(addendum R8: a leftover reservation would silently mutate the venv
on the NEXT restart, possibly a different test's). Matches both the
hyphenated dist/URL form and the underscored module form."""
if not os.path.isfile(SCRIPTS_PATH):
return
with open(SCRIPTS_PATH, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
kept = [
ln for ln in lines
if FIXTURE_DIST not in ln.lower() and FIXTURE_MODULE not in ln.lower()
]
if kept != lines:
with open(SCRIPTS_PATH, "w", encoding="utf-8") as f:
f.writelines(kept)
def _github_reachable() -> bool:
"""Runtime network probe for the install arm (addendum R5)."""
try:
socket.create_connection(("github.com", 443), timeout=10).close()
return True
except OSError:
return False
# ===========================================================================
# DENY arm — flag=false, security_level=weak (weak proves flag-not-level
# decides; goal299-spec.md §1.3 TestPipUrlFormDeny)
# ===========================================================================
comfyui_pip_url_deny = _make_flags_server_fixture(False, False, "weak")
class TestPipUrlFormDeny:
"""URL-form POST is denied by the allow_pip_install flag gate exactly
like a bare package name (argument-content-agnostic, addendum §1):
403, no install-side-effect (no reservation entry), denial log names
the flag. Shared-POST shape (spec §1.3, LOCKED): exactly ONE POST via
a class-scoped record fixture; the three tests assert exclusively
against the record — independent of execution order, no re-reads of
live state."""
@pytest.fixture(scope="class")
def deny_post_record(self, comfyui_pip_url_deny):
offset = _log_offset()
snapshot_before = _scripts_state()
resp = _post_pip(FIXTURE_URL)
return {
"log_offset_before": offset,
"scripts_snapshot_before": snapshot_before,
"status_code": resp.status_code,
"log_slice_after": _log_slice(offset),
"scripts_state_after": _scripts_state(),
}
def test_url_form_denied_403(self, deny_post_record):
"""403 from the dedicated-flag gate despite security_level=weak
(deny-direction decoupling, mm §2 row 'deny arm, after POST')."""
assert deny_post_record["status_code"] == 403, (
f"URL-form pip POST: expected 403 (allow_pip_install=false "
f"overrides sl=weak), got {deny_post_record['status_code']} — "
f"the gate must be argument-content-agnostic."
)
def test_url_form_deny_no_reservation(self, deny_post_record):
"""No reservation entry appended on deny (mm §2: install-scripts.txt
'no entry written'; HTTP 200 would have meant reservation, so the
durable on-disk state is the decisive side-effect observable)."""
before = deny_post_record["scripts_snapshot_before"]
after = deny_post_record["scripts_state_after"]
assert after == before, (
f"deny arm: install-scripts.txt changed across the denied POST "
f"— a reservation leaked past the gate.\nbefore:\n{before}\n"
f"after:\n{after}"
)
if after != "ABSENT":
assert FIXTURE_DIST not in after.lower(), (
f"deny arm: a fixture line is present in install-scripts.txt "
f"after a denied POST:\n{after}"
)
def test_url_form_deny_log_names_flag(self, deny_post_record):
"""Denial log names allow_pip_install (SECURITY_MESSAGE_FLAG_PIP,
legacy/manager_server.py:47) and carries no security-level framing
for this denial (goal265 spec §1.1 invariant 6 — same assertion
shape as the flags module's SC-23)."""
log = deny_post_record["log_slice_after"]
assert "allow_pip_install" in log, (
"deny arm: denial log does not name allow_pip_install "
f"(SECURITY_MESSAGE_FLAG_PIP). Slice:\n{log[-1500:]}"
)
assert "security level to 'normal-'" not in log, (
"deny arm: denial log still carries the misleading "
"security-level copy (SECURITY_MESSAGE_NORMAL_MINUS)."
)
# ===========================================================================
# INSTALL arm — flag=true, security_level=strong (strong proves allow-side
# decoupling; goal299-spec.md §1.3 TestPipUrlFormInstall)
# ===========================================================================
comfyui_pip_url_install = _make_flags_server_fixture(False, True, "strong")
@pytest.mark.network
class TestPipUrlFormInstall:
"""Full deferred-install round trip (mm §2 observable-outcome table):
POST 200 = gate-pass + reservation (NOT installation) -> restart
executes the reserved `uv pip install -U git+...` at prestartup
-> fixture module imports with the expected MARKER in the isolated
venv -> self-clean -> absent.
Asserting importability right after the 200 would be a guaranteed
false-FAIL (mm §2 note) — the restart between POST and the import
assertion is the production execution path (addendum §4.1)."""
@pytest.fixture(scope="class", autouse=True)
def _network_guard(self):
if not _github_reachable():
pytest.skip("offline: github.com unreachable — install arm "
"requires network (deny arm unaffected)")
@pytest.fixture(autouse=True)
def _self_clean(self):
"""Teardown runs even on failure (spec §1.3 step 6): uninstall the
dist, strip any residual reservation line (addendum R8), and prove
the venv is back to baseline (import fails again)."""
yield
_uninstall_fixture()
_strip_fixture_reservation()
assert _venv_fixture_probe().returncode != 0, (
f"self-clean: {FIXTURE_MODULE} is still importable in the E2E "
f"venv after `uv pip uninstall {FIXTURE_DIST}` — venv NOT "
f"restored to baseline."
)
def test_url_form_install_end_to_end(self, comfyui_pip_url_install):
# Step 1 — pre-guard (idempotency, spec §1.3 step 1): a previous
# aborted run must not turn this into a false-PASS.
_uninstall_fixture()
_strip_fixture_reservation()
assert _venv_fixture_probe().returncode != 0, (
f"pre-guard: {FIXTURE_MODULE} already importable before the "
f"test — uninstall guard failed; venv not at baseline."
)
# Step 2 — POST the URL-form argument; 200 = gate-pass +
# reservation under the deferred-execution semantics (mm §1).
resp = _post_pip(FIXTURE_URL)
assert resp.status_code == 200, (
f"install arm: expected 200 (allow_pip_install=true overrides "
f"sl=strong), got {resp.status_code} — allow-side decoupling "
f"broken for the URL-form argument."
)
# Step 3 — reservation entry: a Python-list-repr line containing
# the fixture URL substring (producer reserve_script,
# legacy/manager_core.py:1836-1843; precedent assertion shape
# test_e2e_legacy_real_ops.py:516-538). NOT a shell-string match.
state = _scripts_state()
assert state != "ABSENT", (
"install arm: install-scripts.txt missing after 200 — "
"no reservation was written."
)
fixture_lines = [ln for ln in state.splitlines() if FIXTURE_URL in ln]
assert fixture_lines, (
f"install arm: no reservation line contains {FIXTURE_URL!r}.\n"
f"file content:\n{state}"
)
reserved = ast.literal_eval(fixture_lines[0])
assert isinstance(reserved, list) and "#FORCE" in reserved, (
f"install arm: reservation line is not the expected "
f"['..', '#FORCE', <pip cmd>...] list-repr shape: {reserved!r}"
)
# Step 4 — restart: the production execution path for the
# reservation (prestartup_script.py:485 consumes the file at
# server start). Budget rationale at RESTART_BUDGET_S — the
# owned zero-dep fixture installs in seconds; the budget covers
# the plain server boot, no build env vars needed (goal329
# retired the CUDA-build determinism knob with the heavy
# upstream package).
_stop_legacy()
_start_legacy_install({}, shell_timeout=RESTART_BUDGET_S)
# Step 5 — post-restart: reservation CONSUMED (prestartup removes
# the processed file) and the fixture REALLY importable in the
# venv, proven by its MARKER value (stronger than bare import:
# the marker ties the import to the owned fixture's content).
post_state = _scripts_state()
assert post_state == "ABSENT" or FIXTURE_URL not in post_state, (
f"install arm: reservation NOT consumed by the restart — "
f"install-scripts.txt still references the fixture:\n{post_state}"
)
probe = _venv_fixture_probe()
assert probe.returncode == 0, (
f"install arm: `import {FIXTURE_MODULE}` still fails in the "
f"E2E venv after the reservation-executing restart — install "
f"did not happen or did not target the venv.\n"
f"stderr:\n{probe.stderr[-500:]}"
)
assert FIXTURE_MARKER in probe.stdout, (
f"install arm: fixture imported but MARKER mismatch — "
f"expected {FIXTURE_MARKER!r} in stdout, got: {probe.stdout!r}"
)
# Version breadcrumb for triage (owned repo, so drift risk is
# retired — the breadcrumb now just documents what was installed).
show = subprocess.run(
[VENV_UV, "pip", "show", FIXTURE_DIST, "--python", VENV_PY],
capture_output=True, text=True, timeout=60,
)
print(f"\n[goal329 install-evidence] uv pip show {FIXTURE_DIST}:\n"
f"{show.stdout}")
+730
View File
@@ -0,0 +1,730 @@
"""[goal265 step4 — RED] E2E endpoint binding for the dedicated install flags
``allow_git_url_install`` / ``allow_pip_install`` on the LEGACY server.
TARGET CONTRACT (NOT YET IMPLEMENTED — goal265-spec.md §1, LOCKED):
S-A POST /v2/customnode/install/git_url gated by allow_git_url_install
S-B POST /v2/customnode/install/pip gated by allow_pip_install
S-C batch unknown-URL install: retained middle+ entry gate AND the
allow_git_url_install full predicate replaces the high+ risky check;
unknown-pip 'block' branch stays UNCONDITIONAL (Q1).
The security_level term is fully REMOVED from S-A/S-B (spec §1.1 inv. 1);
denial logs name the responsible flag, not the security level (inv. 6).
These tests are EXPECTED TO FAIL against current code (today S-A/S-B are
``is_allowed_security_level('high+')``-gated) — RED confirmation is Step 5.
Do NOT weaken them to pass against today's code.
This module delivers the LGU2/LPP2 legacy-fixture follow-up explicitly
deferred by tests/e2e/test_e2e_secgate_default.py (its header, lines 31-34).
SC rows covered (goal265-scenarios.md), grouped by server config combination
(one legacy-server launch per combination, config.ini staged per flags):
CFG-A (git=T, pip=T, sl=strong, nm=public): SC-01, SC-11, SC-08
CFG-B (git=T, pip=F, sl=normal, nm=public): SC-02, SC-04, SC-10, SC-17,
SC-18 (transitive-pip arm)
CFG-C (git=F, pip=T, sl=normal, nm=public): SC-16
CFG-D (git=F, pip=F, sl=weak, nm=public): SC-05, SC-09, SC-13,
SC-23 (denial-log copy arm)
CFG-E (git=T, pip=T, sl=weak, nm=public): SC-24 (Q1 unknown-pip block)
CFG-F (git=T, pip=T, sl=normal, nm=public): SC-25A/B (guards, flags-on)
CFG-G (git=F, pip=F, sl=normal, nm=public): SC-25A/B (guards, flags-off)
CFG-H (git=T, pip=F, sl=normal, nm=personal_cloud): SC-30 (note below)
SC-30 limitation (deliberate, documented): the E2E harness listens on
127.0.0.1, so the personal_cloud arm of the middle+ entry gate is satisfied
via is_local_mode as well; the public-listener arm of personal_cloud is
proven at predicate level (tests/common/test_install_flag_predicate.py
SC-03/SC-12) — this row re-proves the endpoint binding with
network_mode=personal_cloud staged.
SC-18 strategy: endpoint-level arm — git-URL install transaction completes
(200) with allow_pip_install=false and the captured log slice contains NO
allow_pip_install denial, proving the pip flag is never consulted inside a
git transaction (MM §1.4). The spec's integration-level alternative
(in-process gitclone_install with execute_install_script mocked) remains
open to Step-7 if this arm proves under-discriminating.
Side-effect control: batch rows pre-seed the target node directory so the
queue worker's gitclone_install short-circuits on "Already exists" — the
GATE decision (the contract under test) happens synchronously in
_install_custom_node BEFORE the worker runs, so the batch response's
``failed`` list is a complete gate observable without real clones.
Requires a pre-built E2E environment (setup_e2e_env.sh); skip-marked
otherwise (harness precedent: tests/e2e/test_e2e_secgate_default.py).
"""
from __future__ import annotations
import configparser
import json
import os
import shutil
import subprocess
import pytest
import requests
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
PORT = 8199
BASE_URL = f"http://127.0.0.1:{PORT}"
CONFIG_PATH = os.path.join(COMFYUI_PATH, "user", "__manager", "config.ini")
CONFIG_BACKUP = CONFIG_PATH + ".before-flags"
SERVER_LOG = os.path.join(E2E_ROOT, "logs", "comfyui.log") if E2E_ROOT else ""
# Purpose-built test-fixture repo (same constant as
# tests/e2e/test_e2e_legacy_real_ops.py TRUSTED_GIT_URL) — the DESIGNATED
# do-not-install sample for every S-A surface test that performs a REAL
# clone (direct /v2/customnode/install/git_url).
UNKNOWN_GIT_URL = "https://github.com/ltdrdata/nodepack-test1-do-not-install"
UNKNOWN_GIT_DIRNAME = "nodepack-test1-do-not-install"
# Batch S-C "unknown-URL" rows need a files URL genuinely NOT in the
# custom-node DB (scenario preconditions SC-04/08/09/30: "file URL NOT in
# node DB" -> get_risky_level returns 'high+' -> the dedicated-flag gate
# engages). DISCOVERY (task #297 real-server run): the do-not-install
# sample above IS registered in the Comfy-Org main-channel
# custom-node-list (served from the manager cache), so get_risky_level
# returns 'middle+' for it and the flag gate is bypassed — using it for
# batch rows silently tests the wrong path (observed as an SC-09
# false-FAIL: flag=false yet the item queued via middle+ at sl=weak).
# The batch rows therefore use a SYNTHETIC nonexistent URL. ZERO-INSTALL
# guarantee holds by construction: deny rows (SC-08/09) never reach the
# worker, and allow rows (SC-04/30) pre-seed the matching placeholder dir
# so the worker's gitclone_install short-circuits on "Already exists" —
# the URL is never cloned (and could not be: the repo does not exist).
BATCH_UNKNOWN_GIT_URL = "https://github.com/ltdrdata/goal265-nonexistent-unknown-node"
BATCH_UNKNOWN_DIRNAME = "goal265-nonexistent-unknown-node"
# Long-standing custom-node-list.json entry — used by SC-24/SC-25B rows that
# need a files URL the DB KNOWS (get_risky_level must not short-circuit at
# high+ on the URL check).
KNOWN_GIT_URL = "https://github.com/ltdrdata/ComfyUI-Impact-Pack"
KNOWN_GIT_DIRNAME = "ComfyUI-Impact-Pack"
UNKNOWN_PIP_PKG = "cm-goal265-nonexistent-pip-pkg-xyz"
pytestmark = pytest.mark.skipif(
not E2E_ROOT
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready",
)
# ---------------------------------------------------------------------------
# Config staging + server lifecycle helpers
# ---------------------------------------------------------------------------
def _stage_flags_config(git: bool, pip: bool, security_level: str,
network_mode: str) -> None:
"""Patch config.ini with the row preconditions (backup-once pattern,
mirrors start_comfyui_strict.sh)."""
if not os.path.isfile(CONFIG_BACKUP):
shutil.copy(CONFIG_PATH, CONFIG_BACKUP)
cfg = configparser.ConfigParser(strict=False)
cfg.read(CONFIG_PATH)
if "default" not in cfg:
cfg["default"] = {}
cfg["default"]["allow_git_url_install"] = "true" if git else "false"
cfg["default"]["allow_pip_install"] = "true" if pip else "false"
cfg["default"]["security_level"] = security_level
cfg["default"]["network_mode"] = network_mode
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
cfg.write(f)
def _restore_config() -> None:
if os.path.isfile(CONFIG_BACKUP):
shutil.move(CONFIG_BACKUP, CONFIG_PATH)
def _start_legacy() -> int:
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
r = subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui_legacy.sh")],
capture_output=True, text=True, timeout=180, env=env,
)
if r.returncode != 0:
raise RuntimeError(f"Failed to start ComfyUI (legacy):\n{r.stderr}")
for part in r.stdout.strip().split():
if part.startswith("COMFYUI_PID="):
return int(part.split("=")[1])
raise RuntimeError(f"Could not parse PID:\n{r.stdout}")
def _stop_legacy() -> None:
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
capture_output=True, text=True, timeout=30, env=env,
)
def _make_flags_server_fixture(git: bool, pip: bool, security_level: str,
network_mode: str = "public"):
"""Class-scoped fixture factory: stage config -> start legacy server ->
yield -> stop server -> restore config."""
@pytest.fixture(scope="class")
def _server():
_stage_flags_config(git, pip, security_level, network_mode)
try:
pid = _start_legacy()
try:
yield pid
finally:
_stop_legacy()
finally:
_restore_config()
return _server
comfyui_flags_a = _make_flags_server_fixture(True, True, "strong")
comfyui_flags_b = _make_flags_server_fixture(True, False, "normal")
comfyui_flags_c = _make_flags_server_fixture(False, True, "normal")
comfyui_flags_d = _make_flags_server_fixture(False, False, "weak")
comfyui_flags_e = _make_flags_server_fixture(True, True, "weak")
comfyui_flags_f = _make_flags_server_fixture(True, True, "normal")
comfyui_flags_g = _make_flags_server_fixture(False, False, "normal")
comfyui_flags_h = _make_flags_server_fixture(
True, False, "normal", network_mode="personal_cloud")
# ---------------------------------------------------------------------------
# Request / observation helpers
# ---------------------------------------------------------------------------
def _post_git_url(url: str) -> requests.Response:
return requests.post(
f"{BASE_URL}/v2/customnode/install/git_url", data=url, timeout=120,
)
def _post_pip(packages: str) -> requests.Response:
return requests.post(
f"{BASE_URL}/v2/customnode/install/pip", data=packages, timeout=60,
)
def _batch_install_item(ui_id: str, files: list[str],
pip: list[str] | None = None) -> dict:
"""Unknown-version batch install item shape consumed by
legacy _install_custom_node (version='unknown' -> files URL path)."""
return {
"id": ui_id,
"ui_id": ui_id,
"version": "unknown",
"files": files,
"pip": pip or [],
"channel": "default",
"mode": "cache",
}
def _post_batch(payload: dict) -> requests.Response:
return requests.post(
f"{BASE_URL}/v2/manager/queue/batch", json=payload, timeout=120,
)
def _log_offset() -> int:
return os.path.getsize(SERVER_LOG) if os.path.isfile(SERVER_LOG) else 0
def _log_slice(offset: int) -> str:
if not os.path.isfile(SERVER_LOG):
return ""
with open(SERVER_LOG, "r", encoding="utf-8", errors="replace") as f:
f.seek(offset)
return f.read()
def _custom_nodes_dir() -> str:
return os.path.join(COMFYUI_PATH, "custom_nodes")
def _preseed_node_dir(dirname: str) -> str:
"""Create a placeholder node dir so the queue worker's gitclone_install
short-circuits on 'Already exists' (no real clone; the gate decision
under test already happened synchronously)."""
target = os.path.join(_custom_nodes_dir(), dirname)
os.makedirs(target, exist_ok=True)
marker = os.path.join(target, ".goal265-preseed")
with open(marker, "w", encoding="utf-8") as f:
f.write("goal265 step4 gate-test placeholder\n")
return target
def _remove_node_dir(dirname: str) -> None:
for candidate in (
os.path.join(_custom_nodes_dir(), dirname),
os.path.join(_custom_nodes_dir(), ".disabled", dirname),
os.path.join(_custom_nodes_dir(), dirname + ".disabled"),
):
if os.path.isdir(candidate):
shutil.rmtree(candidate, ignore_errors=True)
# ===========================================================================
# CFG-A: git=T, pip=T, sl=strong, nm=public
# ===========================================================================
class TestFlagsOnStrongLevel:
"""Allow-direction decoupling: flags=true must allow S-A/S-B even at
security_level=strong (today: 403). Batch entry gate stays
security_level-coupled (middle+ fails at strong)."""
def test_sc01_git_url_allowed_at_strong_when_flag_true(
self, comfyui_flags_a):
"""SC-01: git=true, sl=strong, loopback -> POST git_url returns 200
(today 403 — proves security_level irrelevance, allow direction).
Pre-removes the fixture node so a real (tiny) clone proceeds; a
200 via res.action=='skip' is equally a gate-pass observable."""
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
try:
resp = _post_git_url(UNKNOWN_GIT_URL)
assert resp.status_code == 200, (
f"SC-01: expected 200 (flag=true overrides sl=strong), got "
f"{resp.status_code} — security_level still gates S-A. "
f"Body: {resp.text[:200]}"
)
finally:
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
def test_sc11_pip_allowed_at_strong_when_flag_true(self, comfyui_flags_a):
"""SC-11: pip=true, sl=strong, loopback -> POST pip returns 200
(today 403). text-unidecode: pure-Python, tiny, idempotent — same
trusted constant as test_e2e_legacy_real_ops.py."""
resp = _post_pip("text-unidecode")
assert resp.status_code == 200, (
f"SC-11: expected 200 (flag=true overrides sl=strong), got "
f"{resp.status_code} — security_level still gates S-B."
)
def test_sc08_batch_unknown_url_denied_at_strong(self, comfyui_flags_a):
"""SC-08: git=true, sl=strong -> batch unknown-URL install DENIED —
the retained middle+ entry gate fails first (composite gate:
middle+ AND flag; spec §1.2 S-C row, ':1427' gate UNCHANGED)."""
item = _batch_install_item("sc08-unknown", [BATCH_UNKNOWN_GIT_URL])
resp = _post_batch({"install": [item]})
assert resp.status_code == 200, resp.text[:200]
failed = resp.json().get("failed", [])
assert "sc08-unknown" in failed, (
"SC-08: batch unknown-URL install must stay denied at "
"sl=strong (middle+ entry gate) even with "
f"allow_git_url_install=true — failed={failed!r}"
)
# ===========================================================================
# CFG-B: git=T, pip=F, sl=normal, nm=public
# ===========================================================================
class TestGitFlagOnDefaultLevel:
"""Default security_level: git flag alone opens S-A; pip stays closed."""
def test_sc02_git_url_allowed_at_default_level(self, comfyui_flags_b):
"""SC-02: git=true, sl=normal (default), loopback -> 200
(today 403). Self-cleaning (pre+post remove): a leftover clone
would turn the NEXT install of the same repo into an
'Already exists' 400 (gitclone_install unknown-repo path has no
skip arm — observed as an SC-17 false-FAIL in the first #297 run)."""
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
try:
resp = _post_git_url(UNKNOWN_GIT_URL)
assert resp.status_code == 200, (
f"SC-02: expected 200 at sl=normal with flag=true, got "
f"{resp.status_code}. Body: {resp.text[:200]}"
)
finally:
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
def test_sc10_invalid_url_reaches_validation_400(self, comfyui_flags_b):
"""SC-10: git=true, sl=normal -> POST an INVALID url -> 400 from
installer validation, NOT 403 from the gate. Distinguishes gate-403
from install-400 (today the gate 403s before URL validation)."""
resp = _post_git_url("not-a-url")
assert resp.status_code == 400, (
f"SC-10: expected 400 (installer validation), got "
f"{resp.status_code} — a 403 means the flag gate did not open."
)
def test_sc17_git_open_pip_closed_independent(self, comfyui_flags_b):
"""SC-17: git=true, pip=false, sl=normal -> git_url 200, pip 403
(flags fully independent, [D1][D2]). Self-cleaning like SC-02 —
a stale clone from an earlier S-A test would 400 the git arm."""
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
try:
git_resp = _post_git_url(UNKNOWN_GIT_URL)
pip_resp = _post_pip("text-unidecode")
assert git_resp.status_code == 200, (
f"SC-17 git arm: expected 200, got {git_resp.status_code}"
)
assert pip_resp.status_code == 403, (
f"SC-17 pip arm: expected 403, got {pip_resp.status_code}"
)
finally:
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
def test_sc18_git_transaction_never_consults_pip_flag(
self, comfyui_flags_b):
"""SC-18: git=true, pip=false -> a git-URL install transaction
(clone + execute_install_script dependency step) COMPLETES, and the
captured log slice contains no allow_pip_install denial — the pip
flag governs ONLY the standalone S-B surface (MM §1.4 transaction
scope; spec §1.1 invariant 4).
Fresh install forced (pre-remove) so execute_install_script actually
runs inside the transaction window."""
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
offset = _log_offset()
try:
resp = _post_git_url(UNKNOWN_GIT_URL)
log = _log_slice(offset)
assert resp.status_code == 200, (
f"SC-18: git transaction failed ({resp.status_code}) with "
"pip=false — transitive scope broken? Body: "
f"{resp.text[:200]}"
)
assert "allow_pip_install" not in log, (
"SC-18: the git-URL install transaction consulted/logged "
"allow_pip_install — per-surface scope violated (MM §1.4):\n"
+ log[-2000:]
)
finally:
_remove_node_dir(UNKNOWN_GIT_DIRNAME)
def test_sc04_batch_unknown_url_queued_at_default_level(
self, comfyui_flags_b):
"""SC-04: git=true, sl=normal -> batch unknown-URL install is
QUEUED (failed list empty): middle+ entry passes at normal local
AND the flag replaces the high+ risky check (today 403: risky
high+ fails at normal). Target dir pre-seeded — see module
docstring 'Side-effect control'."""
_preseed_node_dir(BATCH_UNKNOWN_DIRNAME)
try:
item = _batch_install_item("sc04-unknown", [BATCH_UNKNOWN_GIT_URL])
resp = _post_batch({"install": [item]})
assert resp.status_code == 200, resp.text[:200]
failed = resp.json().get("failed", [])
assert "sc04-unknown" not in failed, (
"SC-04: batch unknown-URL install still denied at "
"sl=normal with allow_git_url_install=true — the risky "
f"high+ check was not replaced by the flag. failed={failed!r}"
)
finally:
_remove_node_dir(BATCH_UNKNOWN_DIRNAME)
# ===========================================================================
# CFG-C: git=F, pip=T, sl=normal, nm=public
# ===========================================================================
class TestPipFlagOnDefaultLevel:
def test_sc16_git_closed_pip_open_independent(self, comfyui_flags_c):
"""SC-16: git=false, pip=true, sl=normal -> git_url 403, pip 200
(flags fully independent, [D1][D2])."""
git_resp = _post_git_url(UNKNOWN_GIT_URL)
pip_resp = _post_pip("text-unidecode")
assert git_resp.status_code == 403, (
f"SC-16 git arm: expected 403, got {git_resp.status_code}"
)
assert pip_resp.status_code == 200, (
f"SC-16 pip arm: expected 200, got {pip_resp.status_code}"
)
# ===========================================================================
# CFG-D: git=F, pip=F, sl=weak, nm=public
# ===========================================================================
class TestFlagsOffWeakLevel:
"""Deny-direction decoupling: flags=false must deny S-A/S-B even at
security_level=weak (today: 200) — and the denial log must name the
responsible flag (SC-23 log arm, spec §1.3)."""
def test_sc05_sc23_git_url_denied_at_weak_log_names_flag(
self, comfyui_flags_d):
"""SC-05: git=false, sl=weak, loopback -> 403 (today 200 — deny
direction of security_level irrelevance).
SC-23 (log arm): the denial log names allow_git_url_install and
drops the security-level framing (SECURITY_MESSAGE_NORMAL_MINUS)."""
offset = _log_offset()
resp = _post_git_url(UNKNOWN_GIT_URL)
log = _log_slice(offset)
assert resp.status_code == 403, (
f"SC-05: expected 403 (flag=false overrides sl=weak), got "
f"{resp.status_code} — security_level still opens S-A."
)
assert "allow_git_url_install" in log, (
"SC-23: denial log does not name allow_git_url_install "
f"(spec §1.3 SECURITY_MESSAGE_FLAG_GIT_URL). Slice:\n{log[-1500:]}"
)
assert "security level to 'normal-'" not in log, (
"SC-23: denial log still carries the misleading "
"security-level copy (SECURITY_MESSAGE_NORMAL_MINUS)."
)
def test_sc13_sc23_pip_denied_at_weak_log_names_flag(
self, comfyui_flags_d):
"""SC-13: pip=false, sl=weak, loopback -> 403 (today 200).
SC-23 (log arm): denial log names allow_pip_install
(spec §1.3 SECURITY_MESSAGE_FLAG_PIP)."""
offset = _log_offset()
resp = _post_pip("text-unidecode")
log = _log_slice(offset)
assert resp.status_code == 403, (
f"SC-13: expected 403 (flag=false overrides sl=weak), got "
f"{resp.status_code} — security_level still opens S-B."
)
assert "allow_pip_install" in log, (
"SC-23: denial log does not name allow_pip_install "
f"(spec §1.3 SECURITY_MESSAGE_FLAG_PIP). Slice:\n{log[-1500:]}"
)
def test_sc09_batch_unknown_url_denied_when_flag_false(
self, comfyui_flags_d):
"""SC-09: git=false, sl=weak -> batch unknown-URL install denied —
the flag gate fails despite weak (today: allowed). Composite gate's
flag term is the decider here (middle+ passes at weak local)."""
item = _batch_install_item("sc09-unknown", [BATCH_UNKNOWN_GIT_URL])
resp = _post_batch({"install": [item]})
assert resp.status_code == 200, resp.text[:200]
failed = resp.json().get("failed", [])
assert "sc09-unknown" in failed, (
"SC-09: batch unknown-URL install must be denied with "
f"allow_git_url_install=false even at sl=weak. failed={failed!r}"
)
# ===========================================================================
# The 403 denial reason code
#
# A denied install returns 403 with a JSON body naming the flag that denied it
# — `{"reason": "allow_git_url_install"}` or `{"reason": "allow_pip_install"}`
# — so the browser can tell the user WHICH setting to change instead of
# guessing from the endpoint it happened to call. The full
# SECURITY_MESSAGE_FLAG_* text still goes to the terminal via `logging.error`;
# only the flag name goes on the wire.
#
# Every other assertion on these endpoints tests `status_code` alone, so
# nothing else in this file can see the body at all — these rows are the only
# coverage the wire contract has.
#
# Reuses CFG-D (git=F, pip=F, sl=weak) — the config that already produces a 403
# on both arms, so these rows add no new server boot.
# ===========================================================================
class TestDenialReasonCode:
"""The 403 body must name WHICH flag denied — and nothing more."""
def _denial_body(self, resp):
"""Parse the 403 body as the wire contract, with the failure message
carrying what was actually received."""
assert resp.status_code == 403, (
f"precondition: expected 403, got {resp.status_code}"
)
try:
return json.loads(resp.text)
except json.JSONDecodeError as exc:
pytest.fail(
"the 403 body is not JSON. Expected a JSON object carrying "
"the denial reason code; "
f"observed {resp.text!r} (len={len(resp.text)}). "
f"JSONDecodeError: {exc}. A 403 emitted with no `text=` leaves "
"the body EMPTY, and the client then has nothing to derive its "
"message from."
)
def test_t9_pip_denial_body_names_the_flag(self, comfyui_flags_d):
"""The pip 403 body carries the reason code.
Expected: a JSON OBJECT whose `reason` is the string
`allow_pip_install`.
"""
resp = _post_pip("text-unidecode")
body = self._denial_body(resp)
assert isinstance(body, dict), (
"the 403 body must be a JSON OBJECT (the client's guard is "
"`typeof data === 'object' && data !== null && typeof data.reason "
f"=== 'string'`); observed {type(body).__name__} {body!r}"
)
assert body.get("reason") == "allow_pip_install", (
"expected the pip 403 body to carry "
"{'reason': 'allow_pip_install'} so the client can name the "
f"condition the SERVER actually failed on; observed {body!r}"
)
def test_t9_git_url_denial_body_names_the_flag(self, comfyui_flags_d):
"""The git_url 403 body carries its own code.
A separate node from the pip arm on purpose: the two emissions are
separate `return` statements, so one can be changed while the other is
missed.
"""
resp = _post_git_url(UNKNOWN_GIT_URL)
body = self._denial_body(resp)
assert isinstance(body, dict), (
"the 403 body must be a JSON OBJECT; observed "
f"{type(body).__name__} {body!r}"
)
assert body.get("reason") == "allow_git_url_install", (
"expected the git_url 403 body to carry "
f"{{'reason': 'allow_git_url_install'}}; observed {body!r}"
)
def test_t9b_denial_body_discloses_nothing_further(self, comfyui_flags_d):
"""Denied requests expose only the failed flag name."""
responses = {
"allow_pip_install": _post_pip("text-unidecode"),
"allow_git_url_install": _post_git_url(UNKNOWN_GIT_URL),
}
for reason, response in responses.items():
assert self._denial_body(response) == {"reason": reason}
# ===========================================================================
# CFG-E: git=T, pip=T, sl=weak, nm=public — Q1 guard
# ===========================================================================
class TestUnknownPipStaysBlocked:
def test_sc24_batch_unknown_pip_block_is_unconditional(
self, comfyui_flags_e):
"""SC-24 (Q1 guard): batch item whose files are ALL DB-known (no
high+ short-circuit on the URL check) but whose pip list names an
UNKNOWN package -> still denied. get_risky_level's 'block' branch
stays UNCONDITIONAL — the flags do NOT open it (spec §5 freeze
item 7), even with both flags true at sl=weak."""
item = _batch_install_item(
"sc24-known-url-unknown-pip",
[KNOWN_GIT_URL],
pip=[UNKNOWN_PIP_PKG],
)
resp = _post_batch({"install": [item]})
assert resp.status_code == 200, resp.text[:200]
failed = resp.json().get("failed", [])
assert "sc24-known-url-unknown-pip" in failed, (
"SC-24: unknown-pip 'block' branch opened — it must remain "
f"unconditional regardless of the new flags. failed={failed!r}"
)
# ===========================================================================
# CFG-F / CFG-G: out-of-scope legacy guards (SC-25A/B) — flags have NO effect
# ===========================================================================
def _batch_op_item(ui_id: str) -> dict:
"""Item for middle-gated batch ops (fix/uninstall/update). The GATE
check precedes node resolution, so a placeholder spec is sufficient:
the queue worker's later failure on the nonexistent node is irrelevant
to the synchronous gate observable (the response's failed list)."""
return {
"id": ui_id,
"ui_id": ui_id,
"version": "unknown",
"files": [f"https://example.com/{ui_id}"],
}
class _LegacyGuardRows:
"""Shared assertions for SC-25A/B — executed under BOTH flag combos
((t,t) via CFG-F and (f,f) via CFG-G); outcomes must be IDENTICAL,
proving the flags have zero effect on middle/middle+ surfaces."""
flags_label = ""
def _assert_sc25a(self):
"""SC-25A: legacy middle-gated ops (fix/uninstall/update) stay
allowed at sl=normal local for this flag combination."""
payload = {
"fix": [_batch_op_item(f"sc25a-fix-{self.flags_label}")],
"uninstall": [_batch_op_item(f"sc25a-unin-{self.flags_label}")],
"update": [_batch_op_item(f"sc25a-upd-{self.flags_label}")],
}
resp = _post_batch(payload)
assert resp.status_code == 200, resp.text[:200]
failed = resp.json().get("failed", [])
gate_denied = [x for x in failed if x.startswith("sc25a-")]
assert gate_denied == [], (
f"SC-25A({self.flags_label}): middle-gated batch ops were "
f"gate-denied at sl=normal — flags must have ZERO effect on "
f"out-of-scope surfaces. failed={failed!r}"
)
def _assert_sc25b(self):
"""SC-25B: batch KNOWN-node install (middle+ path, DB-resolved
files URL) stays allowed at sl=normal local for this flag
combination. Known-node dir pre-seeded — no real install."""
_preseed_node_dir(KNOWN_GIT_DIRNAME)
try:
item = _batch_install_item(
f"sc25b-known-{self.flags_label}", [KNOWN_GIT_URL])
resp = _post_batch({"install": [item]})
assert resp.status_code == 200, resp.text[:200]
failed = resp.json().get("failed", [])
assert f"sc25b-known-{self.flags_label}" not in failed, (
f"SC-25B({self.flags_label}): KNOWN-node batch install was "
f"denied at sl=normal — middle+ path must be unaffected by "
f"the flags. failed={failed!r}"
)
finally:
_remove_node_dir(KNOWN_GIT_DIRNAME)
class TestGuardsFlagsOn(_LegacyGuardRows):
flags_label = "flags-on"
def test_sc25a_middle_ops_allowed_with_flags_on(self, comfyui_flags_f):
self._assert_sc25a()
def test_sc25b_known_install_allowed_with_flags_on(self, comfyui_flags_f):
self._assert_sc25b()
class TestGuardsFlagsOff(_LegacyGuardRows):
flags_label = "flags-off"
def test_sc25a_middle_ops_allowed_with_flags_off(self, comfyui_flags_g):
self._assert_sc25a()
def test_sc25b_known_install_allowed_with_flags_off(self, comfyui_flags_g):
self._assert_sc25b()
# ===========================================================================
# CFG-H: git=T, pip=F, sl=normal, nm=personal_cloud
# ===========================================================================
class TestPersonalCloudBatch:
def test_sc30_batch_unknown_url_queued_under_personal_cloud(
self, comfyui_flags_h):
"""SC-30: git=true, sl=normal, nm=personal_cloud -> batch
unknown-URL install queued: the S-C composite gate's middle+ arm
passes (is_local_mode OR is_personal_cloud) AND the flag is true.
See module docstring for the loopback-harness limitation; the
public-listener personal_cloud arm is proven at predicate level."""
_preseed_node_dir(BATCH_UNKNOWN_DIRNAME)
try:
item = _batch_install_item("sc30-unknown", [BATCH_UNKNOWN_GIT_URL])
resp = _post_batch({"install": [item]})
assert resp.status_code == 200, resp.text[:200]
failed = resp.json().get("failed", [])
assert "sc30-unknown" not in failed, (
"SC-30: batch unknown-URL install denied under "
f"network_mode=personal_cloud with flag=true. "
f"failed={failed!r}"
)
finally:
_remove_node_dir(BATCH_UNKNOWN_DIRNAME)
+36
View File
@@ -0,0 +1,36 @@
"""Execute the legacy renderer without starting ComfyUI or its network threads."""
import ast
import importlib.util
import json
import re
import sys
import types
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PACKAGE = ROOT / 'comfyui_manager'
def load_functions(path, names, namespace=None):
namespace = {} if namespace is None else namespace
nodes = [node for node in ast.parse(path.read_text(encoding='utf-8')).body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
assert len(nodes) == len(names)
exec(compile(ast.Module(nodes, []), str(path), 'exec'), namespace)
return namespace
def load_renderer():
path = PACKAGE / 'legacy/html_utils.py'
spec = importlib.util.spec_from_file_location('_legacy_html_utils', path)
helpers = importlib.util.module_from_spec(spec)
spec.loader.exec_module(helpers)
util = load_functions(PACKAGE / 'common/manager_util.py', {'sanitize_tag'})
return load_functions(PACKAGE / 'legacy/manager_server.py', {'convert_markdown_to_html', 'populate_markdown'},
{'re': re, 'html_utils': helpers, 'manager_util': types.SimpleNamespace(**util)})
if __name__ == '__main__':
item = json.load(sys.stdin)
load_renderer()['populate_markdown'](item)
print(json.dumps(item))
+11 -3
View File
@@ -5,20 +5,25 @@ Browser-based E2E tests for the ComfyUI-Manager legacy UI.
## Prerequisites
1. **E2E environment** built via `python tests/e2e/scripts/setup_e2e_env.py`
2. **Playwright installed**: `npx playwright install chromium`
2. **Playwright runner and Chromium** (from the repository root):
`npm install --no-save --package-lock=false @playwright/test@1.59.1`, then
`npx playwright install chromium`.
3. **ComfyUI running** with legacy UI enabled:
```bash
E2E_ROOT=/tmp/e2e_full_test
PORT=8199
$E2E_ROOT/venv/bin/python $E2E_ROOT/comfyui/main.py \
PYTHONPATH="$PWD" $E2E_ROOT/venv/bin/python $E2E_ROOT/comfyui/main.py \
--listen 127.0.0.1 --port $PORT \
--enable-manager-legacy-ui \
--enable-manager --enable-manager-legacy-ui \
--cpu
```
## Running Tests
Run from the repository root. XSS fixtures execute the checkout's Python renderer
using `python3`; set `PYTHON=/path/to/python` to select another interpreter.
```bash
# With server already running:
PORT=8199 npx playwright test
@@ -42,3 +47,6 @@ PORT=8199 npx playwright test --debug
| `legacy-ui-model-manager.spec.ts` | Model list grid, filter, search |
| `legacy-ui-snapshot.spec.ts` | Snapshot list, save, remove |
| `legacy-ui-navigation.spec.ts` | Dialog open/close, nested navigation, no duplicates |
| `custom-nodes-xss.spec.ts` | Node metadata, Markdown, links, errors, search highlighting |
| `model-manager-xss.spec.ts` | Model metadata, links, errors, search highlighting |
| `legacy-message-errors.spec.ts` | Batch rejection IDs, name readability, DOM colors, lookup errors |
+796
View File
@@ -0,0 +1,796 @@
/**
* Browser-level XSS regression suite for the Custom Nodes manager UI.
*
* Every row feeds hostile node-pack metadata through a stubbed registry
* response and asserts the value renders as INERT TEXT: the payload is visible
* as literal characters, no element is constructed from it, and its `onerror`
* never runs. The three checks are made together because they fail
* differently — see `expectInert` in ./helpers.ts.
*
* NOT EVERY ROW CLOSES A LIVE HOLE, and the distinction matters when reading a
* failure. `version`/`cnr_latest`, `author`, `stars` and `last_update` are
* served unsanitized and sit in always-visible columns, so opening the manager
* is the whole exploit; so are node names from `/v2/customnode/getmappings`,
* a route that sanitizes nothing. Those fields are escaped ON THE CLIENT and
* their rows target that escape.
*
* `title` / `name` / `description` are different: `populate_markdown` ->
* `sanitize_tag` escapes them SERVER-SIDE, and the client renders them as-is
* on purpose. Escaping them again is not free safety — `sanitizeHTML` also maps
* `&`, so a second pass turns the server's `&lt;` into `&amp;lt;` and the pack
* `ComfyUI-<Impact-Pack>` reaches the user as literal `ComfyUI-&lt;Impact-Pack&gt;`.
* The rows targeting those fields assert the SERVER->CLIENT CHAIN, not an
* independent client guarantee.
*
* CAVEAT: `page.route()` REPLACES the HTTP response, so every fixture here
* bypasses `populate_markdown`. For the raw fields that is exactly what a live
* server delivers. For the server-escaped fields it is NOT, so those rows pass
* their payload through `serverSanitizeTag` first — the real transform, read
* out of `manager_util.py` — and assert on the wire form the server would
* actually have emitted.
*
* Requires ComfyUI running with --enable-manager-legacy-ui on PORT (see
* tests/playwright/README.md).
*/
import { test, expect } from '@playwright/test';
import {
waitForComfyUI,
openManagerMenu,
openCustomNodesManager,
routeNodeList,
routeMappings,
routeAlternatives,
makePack,
expectInert,
serverSanitizeTag,
serverPopulateMarkdown,
serverInstallDenialRestartClause,
XSS_PAYLOAD,
XSS_PAYLOAD_NOSPACE,
} from './helpers';
const GRID = '.cn-manager-grid';
const FLYOVER = '.cn-flyover';
/** Collect page errors so the rows that assert "did not throw" can see them. */
function trackPageErrors(page: import('@playwright/test').Page) {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(String(e)));
return errors;
}
test.describe('grid render sinks', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('hostile title renders inert through the server escape', async ({ page }) => {
// NOT defence-in-depth, and the fixture is what says so. `title` is in the
// populate_markdown set, so the SERVER escape is the one defence and the
// client deliberately renders the value as-is — escaping it a second time
// printed `&lt;` to every user (see the double-escape row below).
//
// The fixture is therefore the server's own sanitize_tag OUTPUT, lifted
// from manager_util.py. A raw payload here would assert the opposite of the
// design and fail by construction, on a wire form the live server cannot
// emit; what this row asserts is that the form it DOES emit stays inert
// once the client renders it.
await routeNodeList(page, {
hostile: makePack('hostile', { title: serverSanitizeTag(XSS_PAYLOAD) }),
});
await openManagerMenu(page);
await openCustomNodesManager(page);
await expectInert(page, GRID, XSS_PAYLOAD, 'img', 'title');
});
// This row needs a WIDER VIEWPORT THAN THE REST, so it gets its own describe.
//
// turbogrid virtualises columns horizontally: at the config's default
// viewport the `.cn-pack-stars` and `.cn-pack-last-update` BODY CELLS are
// not in the DOM at all — a dump returned `stars: []`, `lastUpdate: []`
// with `rowCount: 4`, while the HEADER showed both columns. The row's
// `last_update` leg then failed leg 1 (presence) with legs 2 and 3 passing,
// which is expectInert's own signature for "the payload never rendered",
// i.e. the row was reporting the viewport, not the sink. Measured: at
// 2600x1000 both cells render and both are inert. Pin the viewport so this
// row asserts on the sink it names.
test.describe('wide viewport — stars/last_update are column-virtualised', () => {
test.use({ viewport: { width: 2600, height: 1000 } });
test('hostile version/author/stars/last_update render inert (LIVE no-user-action)', async ({ page }) => {
// The four fields that carry the no-user-action property: served
// unsanitized AND in always-visible columns, so opening the manager is
// the whole exploit.
await routeNodeList(page, {
p1: makePack('p1', { version: XSS_PAYLOAD, cnr_latest: XSS_PAYLOAD }),
p2: makePack('p2', { author: XSS_PAYLOAD }),
// `stars` must be a NON-number to reach the raw fall-through
// (`if (typeof stars === 'number') return stars.toLocaleString()`).
p3: makePack('p3', { stars: XSS_PAYLOAD }),
// `last_update` is cut at the first space, so the payload must be
// space-free or the row would pass for the wrong reason.
p4: makePack('p4', { last_update: XSS_PAYLOAD_NOSPACE }),
});
await openManagerMenu(page);
await openCustomNodesManager(page);
// PRECONDITION — the two virtualised columns must actually have body
// cells before their legs mean anything. Without this the row can go
// pass again the moment a layout change hides them, on a fixture that
// proved nothing (the same vacuous-pass shape the tags row guards against).
await expect(
page.locator(`${GRID} .cn-pack-stars`).first(),
'precondition: no .cn-pack-stars body cell rendered — the stars column is virtualised away at this viewport, so its leg would pass vacuously.',
).toBeVisible({ timeout: 15_000 });
await expect(
page.locator(`${GRID} .cn-pack-last-update`).first(),
'precondition: no .cn-pack-last-update body cell rendered — the last_update column is virtualised away at this viewport, so its leg would pass vacuously.',
).toBeVisible({ timeout: 15_000 });
await expectInert(page, GRID, XSS_PAYLOAD, 'img', 'version/cnr_latest');
await expectInert(page, GRID, XSS_PAYLOAD, 'img', 'author');
await expectInert(page, GRID, XSS_PAYLOAD_NOSPACE, 'img', 'last_update');
});
});
test('hostile tags render inert once the alternatives column is shown (LIVE, gated)', async ({ page }) => {
// The alternatives column is `invisible: !this.hasAlternatives()`, so this
// row MUST establish the gate. Without the precondition assertion below it
// passes vacuously against a grid that never rendered the column at all.
await routeNodeList(page, { alt1: makePack('alt1') });
await routeAlternatives(page, {
alt1: {
id: 'alt1',
title: 'alt1',
description: 'alternative description',
tags: XSS_PAYLOAD,
},
});
await openManagerMenu(page);
await openCustomNodesManager(page);
// ESTABLISH THE GATE. `hasAlternatives()` is `this.filter ===
// ShowMode.ALTERNATIVES`, so the column exists ONLY while the filter
// dropdown is on "Alternatives of A1111" — selecting it is also what
// triggers getAlternatives() and therefore the `:1903` tag loop. Without
// this step the fixture is never fetched, nothing renders, and the row
// would report a failure that has nothing to do with the sink.
const filterSelect = page.locator('select.cn-manager-filter').first();
await expect(filterSelect).toBeVisible({ timeout: 10_000 });
await filterSelect.selectOption({ label: 'Alternatives of A1111' });
// PRECONDITION — assert on `.cn-tag-list` SPECIFICALLY, the div the tag
// loop emits. An earlier draft accepted `.cn-pack-desc` too, which the
// description column also carries: it matched, the row failed on a
// different leg, and it looked like a reproduction. That is the vacuous
// vacuous pass this row has to guard against, arriving as a vacuous
// FAILURE instead.
await expect(
page.locator(`${GRID} .cn-tag-list`).first(),
'precondition: no .cn-tag-list rendered, so the alternatives tag sink was never exercised — the row proves nothing about the sink.',
).toBeVisible({ timeout: 15_000 });
await expectInert(page, GRID, XSS_PAYLOAD, 'img', 'tags');
});
test('a server-escaped title renders decoded (no double-escape)', async ({ page }) => {
// THE REGRESSION GUARD FOR THE DOUBLE-ESCAPE FIX, and it only guards
// anything because the fixture is the WIRE form.
//
// The earlier shape of this row fed the RAW authored title straight to the
// grid. That is input the live server cannot deliver — getlist runs every
// pack through populate_markdown -> sanitize_tag first — and the row passed
// either way: escaped once, `<3` comes back as `<3`; not escaped at
// all, `<3` is not a tag start so it renders as text too. It asserted
// nothing about the chain it was named for.
//
// Now the fixture is the OUTPUT of the server's own sanitize_tag, read out
// of manager_util.py at run time (serverSanitizeTag) rather than re-typed,
// so what enters the client is exactly what the server would have emitted:
// wire form in, authored text out. A client that escapes the field a second
// time prints the entity and BOTH legs below flip.
//
// page.route still carries it: the escaped fields originate in third-party
// registry data (no live pack title contains an angle bracket), so this is
// the only deterministic way to place a known value in the grid. What
// page.route replaces is the TRANSPORT — the transform is the real one.
const AUTHORED = 'Tom & Jerry <3';
const ON_THE_WIRE = serverSanitizeTag(AUTHORED);
expect(
ON_THE_WIRE,
'serverSanitizeTag returned its input unchanged, so the fixture is NOT the ' +
'wire form and this row is back to proving nothing. Check that sanitize_tag ' +
'in comfyui_manager/common/manager_util.py still escapes < and >.',
).not.toBe(AUTHORED);
await routeNodeList(page, { legit: makePack('legit', { title: ON_THE_WIRE }) });
await openManagerMenu(page);
await openCustomNodesManager(page);
const text = (await page.locator(GRID).first().textContent()) ?? '';
const diagnostic =
`authored: ${JSON.stringify(AUTHORED)}\n` +
`on the wire (server sanitize_tag output): ${JSON.stringify(ON_THE_WIRE)}\n` +
`grid text (first 300 chars): ${JSON.stringify(text.slice(0, 300))}`;
expect(
text,
`the server-escaped title did not render as the author wrote it. The client ` +
`escaped an ALREADY-escaped value, so sanitizeHTML mapped the server's & to ` +
`&amp; and the entity reached the user as literal text.\n${diagnostic}`,
).toContain(AUTHORED);
expect(
text,
`the wire form is visible in the grid as literal text — that IS the ` +
`double-escape. A field in the populate_markdown set (title/name/description) ` +
`must reach innerHTML AS-IS.\n${diagnostic}`,
).not.toContain(ON_THE_WIRE);
});
test('missing title/version/author renders without throwing', async ({ page }) => {
// Expected to PASS — `sanitizeHTML(undefined)` raises TypeError inside a
// render path, so the escape MUST be sanitizeHTML(String(v)).
// This row is what turns that mistake into a visible failure.
const errors = trackPageErrors(page);
const bare = makePack('bare');
delete (bare as Record<string, unknown>).title;
delete (bare as Record<string, unknown>).version;
delete (bare as Record<string, unknown>).author;
await routeNodeList(page, { bare });
await openManagerMenu(page);
await openCustomNodesManager(page);
await expect(page.locator(GRID).first()).toBeVisible();
expect(
errors,
`the grid threw while rendering a row with absent title/version/author — sanitizeHTML(undefined) raises TypeError, so the escape must pass String(v). pageerrors: ${errors.join(' | ')}`,
).toEqual([]);
});
test('composed-HTML description keeps rendering as markup (anti-regression)', async ({ page }) => {
// MANDATORY anti-regression. `description` is SERVER-COMPOSED HTML; the
// cheapest way to pass every other row here is to blind-escape the grid,
// which would print literal <a href=...> / <B> to every user. This row
// guards the ESCAPING, not the defect — it is expected to pass both before
// and after, and must never be forced to fail.
const composed = serverPopulateMarkdown({ description: 'See [a/the docs](https://example.com/x) for **details**' }).description;
await routeNodeList(page, { doc: makePack('doc', { description: composed }) });
await openManagerMenu(page);
await openCustomNodesManager(page);
const anchors = await page.locator(`${GRID} a[href="https://example.com/x"]`).count();
expect(
anchors,
'the composed-HTML description no longer renders as markup — a working <a href> is gone, so the description column was blind-escaped. That is a regression, not a fix.',
).toBeGreaterThan(0);
const bold = await page.locator(`${GRID} b, ${GRID} B`).count();
expect(
bold,
'the composed-HTML description lost its <B> markup — the column was escaped wholesale.',
).toBeGreaterThan(0);
});
});
test.describe('URL / scheme sink', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('the title link href carries no javascript: scheme (LIVE, click-gated)', async ({ page }) => {
// `link.href = <registry URL>` with no scheme allow-list. This is the
// client-side twin of the server composer's scheme-trust hole.
//
// Asserted on the href PROPERTY, not on rendered text: the
// rendered label is the pack title and would look perfectly innocent while
// the anchor still carries a javascript: target.
await routeNodeList(page, {
evil: makePack('evil', {
repository: 'javascript:window.__xss_fired=1',
reference: 'javascript:window.__xss_fired=1',
}),
});
await openManagerMenu(page);
await openCustomNodesManager(page);
const hrefs = await page.locator(`${GRID} a`).evaluateAll(
(els) => els.map((e) => (e as HTMLAnchorElement).href),
);
// PRECONDITION — the row asserts that no anchor carries a bad scheme, and
// a grid with NO anchors satisfies that having tested nothing. Pin that at
// least one anchor rendered before reading anything into the result below.
expect(
hrefs.length,
`precondition: the grid rendered no anchors at all, so the href assertion below would pass over an empty list and prove nothing about the URL sink. Observed ${hrefs.length} anchors.`,
).toBeGreaterThan(0);
// ASSERTED AGAINST THE ALLOW-LIST, not against a list of dangerous schemes.
const allowed = ['http:', 'https:'];
const offending = hrefs.filter((h) => {
const scheme = new URL(h).protocol;
return scheme !== null && !allowed.includes(scheme);
});
expect(
offending,
`the title link's href PROPERTY carries a scheme outside the allow-list — a ` +
`registry-controlled URL reached href unfiltered, so one click executes it. ` +
`sanitizeUrl admits ${JSON.stringify(allowed)}; observed anchors: ` +
`${JSON.stringify(hrefs.slice(0, 10))}`,
).toEqual([]);
});
});
test.describe('flyover sinks', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
/** Open the node flyover by clicking the Nodes cell of the first row.
*
* Targets `.cn-pack-nodes` specifically — that div is emitted ONLY by the
* `nodes` column formatter, and only when `rowItem.nodes` is truthy. The
* grid's onClick handler routes to `showNodes` on `columnItem.id ===
* "nodes"`, so any looser selector (e.g. "a cell containing a digit") lands
* on the ID column, silently never opens the flyover, and turns the row
* into a timeout that LOOKS like a failed assertion. Waiting for this
* element first therefore doubles as a precondition that the getmappings
* fixture actually produced a node count. */
async function openFlyover(page: import('@playwright/test').Page) {
const nodesCell = page.locator(`${GRID} .cn-pack-nodes`).first();
await expect(
nodesCell,
'flyover precondition: no .cn-pack-nodes cell rendered, so the getmappings fixture did not attach a node count to the pack — the flyover cannot open and the row would time out for a fixture reason, not a defect.',
).toBeVisible({ timeout: 15_000 });
await nodesCell.click();
await page.waitForSelector(FLYOVER, { state: 'visible', timeout: 10_000 });
}
test('flyover pack title renders inert through the server escape', async ({ page }) => {
// Same contract as the grid title row: `title` is server-escaped, the
// client renders it as-is, so the fixture must be the WIRE form.
await routeNodeList(page, { h: makePack('h', { title: serverSanitizeTag(XSS_PAYLOAD) }) });
await routeMappings(page, {
'https://github.com/example/h': [['NodeA'], { title_aux: 'h' }],
});
await openManagerMenu(page);
await openCustomNodesManager(page);
await openFlyover(page);
await expectInert(page, FLYOVER, XSS_PAYLOAD, 'img', 'flyover title');
});
test('flyover node NAME renders inert (LIVE, click-gated)', async ({ page }) => {
// `/v2/customnode/getmappings` performs NO sanitization —
// `populate_markdown` is never called on that route — so
// this node name is genuinely RAW, not defence-in-depth.
await routeNodeList(page, { h: makePack('h') });
await routeMappings(page, {
'https://github.com/example/h': [[XSS_PAYLOAD], { title_aux: 'h' }],
});
await openManagerMenu(page);
await openCustomNodesManager(page);
await openFlyover(page);
await expectInert(page, FLYOVER, XSS_PAYLOAD, 'img', 'flyover node name');
});
});
// ===========================================================================
// selection bar
// ===========================================================================
test.describe('selection bar', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
for (const state of ['not-a-known-install-group', 'constructor', '__proto__']) {
test(`an unrecognised pack state still renders the selection bar: ${state}`, async ({ page }) => {
const errors = trackPageErrors(page);
await routeNodeList(page, { odd: makePack('odd', { state }) });
await openManagerMenu(page);
await openCustomNodesManager(page);
const checkbox = page.locator(`${GRID} .tg-body .tg-row .tg-checkbox`).first();
await expect(checkbox).toBeVisible({ timeout: 15_000 });
await checkbox.click();
const bar = page.locator('.cn-manager-selection').first();
await expect(bar).toContainText('Selected');
await expect(bar).toContainText(state);
await expect(bar.locator('button')).toHaveCount(0);
expect(errors).toEqual([]);
});
}
});
// ===========================================================================
// Install status uses the same server-composed names as the grid.
// ===========================================================================
test.describe('install status sink', () => {
for (const [source, visible] of [
[XSS_PAYLOAD, XSS_PAYLOAD],
['Pack &amp; Friends <Flux>', 'Pack & Friends <Flux>'],
['Pack &amp;lt;Flux&amp;gt;', 'Pack &lt;Flux&gt;'],
]) {
test(`server title remains readable and inert: ${source}`, async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
await routeNodeList(page, { victim: makePack('victim', {
title: serverSanitizeTag(source), version: 'unknown',
}) });
await page.route('**/v2/manager/queue/batch', route => route.fulfill({
status: 200, contentType: 'application/json', body: '{"failed": []}',
}));
await openManagerMenu(page);
await openCustomNodesManager(page);
await page.locator(`${GRID} .cn-btn-install`).first().click();
await expectInert(page, '.cn-manager-status', visible, 'img', 'install title');
const status = page.locator('.cn-manager-status');
await expect(status).toContainText(visible);
});
}
});
// ===========================================================================
// batch error dialog
// ===========================================================================
test.describe('batch error dialog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('server-authored batch failure text renders inert (LIVE)', async ({ page }) => {
// REAL CONSUMPTION PATH, not a synthetic fixture. The chain exercised here
// is the production one end to end on the client side:
// real Install click -> real POST /v2/manager/queue/batch
// -> real `cm-queue-status` batch-done event on ComfyUI's api
// -> real onQueueStatus -> onQueueCompleted -> errorMsg += v (:1604)
// -> real showError (:1613) -> showMessage -> .cn-manager-message
// .innerHTML (:2091)
// Only the SERVER's two responses are stubbed, which is exactly what
// Tier A is for — the attacker-controlled value enters at `result[hash]`,
// the same slot a real queue result fills.
let capturedBatchId = '';
await page.route('**/v2/manager/queue/batch', async (route) => {
capturedBatchId = route.request().postDataJSON()?.batch_id ?? '';
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"failed": []}' });
});
// `version: 'unknown'` is load-bearing, not filler. The grid click handler
// routes install through a VERSION SELECTOR dialog when
// `item.originalData.version != 'unknown'` (:555), and the batch POST only
// happens after a version is chosen. The unknown-version shape takes the
// direct `installNodes` path (:559), which is the same branch the Python
// e2e rows drive, and keeps this test on the error-render path it is about
// instead of on version-picker UI.
await routeNodeList(page, {
victim: makePack('victim', { state: 'not-installed', version: 'unknown' }),
});
await openManagerMenu(page);
await openCustomNodesManager(page);
const installBtn = page.locator(`${GRID} .cn-btn-install`).first();
await expect(
installBtn,
'precondition: no .cn-btn-install rendered, so the batch flow was never entered and install_context/batch_id were never set — the queue event would be dropped by onQueueCompleted and the row would prove nothing.',
).toBeVisible({ timeout: 15_000 });
await installBtn.click();
// CAPTURE THE SINK'S WRITES, because the settled element cannot be read.
//
// onQueueCompleted writes the error into .cn-manager-message via
// showError (:1613) and then OVERWRITES the same element with the restart
// notice via showMessage (:1620) — inside ONE synchronous handler. So by
// the time any assertion runs, the element holds "To apply the
// installed/updated... restart ComfyUI", and the value under test is
// gone. A MutationObserver does not help: its callback is a microtask and
// both writes land before it runs (measured — it reports only the final
// state). Wrapping the innerHTML SETTER records each write at the moment
// it happens, which is the only way to assert on what the sink actually
// received. Installed here rather than via addInitScript because the
// writes under test all happen after this point.
await page.evaluate(() => {
const w = window as unknown as Record<string, any>;
w.__cnMessageWrites = [];
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')!;
Object.defineProperty(Element.prototype, 'innerHTML', {
configurable: true,
get() {
return desc.get!.call(this);
},
set(value) {
try {
if ((this as Element).classList?.contains('cn-manager-message')) {
w.__cnMessageWrites.push(String(value));
}
} catch {
/* never let the probe break the page under test */
}
desc.set!.call(this, value);
},
});
});
await expect
.poll(() => capturedBatchId, {
timeout: 15_000,
message: 'precondition: the client never POSTed /v2/manager/queue/batch, so no batch_id exists to address the completion event to.',
})
.not.toBe('');
// Deliver the completion event exactly as the server does over the socket.
await page.evaluate(
({ batchId, payload }) => {
const api = (window as unknown as Record<string, any>).comfyAPI.api.api;
api.dispatchEvent(
new CustomEvent('cm-queue-status', {
detail: { status: 'batch-done', batch_id: batchId, nodepack_result: [payload] },
}),
);
},
{ batchId: capturedBatchId, payload: XSS_PAYLOAD },
);
// The write carrying the payload must arrive, and arrive inert. The three
// legs are the same ones expectInert applies, evaluated against the value
// the sink RECEIVED rather than against the element's settled content:
// leg 1 presence — the payload is there as literal text;
// leg 2 structure — parsing that write constructs no <img>;
// leg 3 execution — window.__xss_fired never set (document-wide).
const sink = await page.evaluate((payload) => {
const w = window as unknown as Record<string, any>;
const writes: string[] = w.__cnMessageWrites ?? [];
// Parse each write the way the browser did, in a detached element.
const probe = document.createElement('div');
const parsed = writes.map((html) => {
probe.innerHTML = html;
return { html, text: probe.textContent ?? '', imgs: probe.querySelectorAll('img').length };
});
return {
writes,
carrying: parsed.filter((p) => p.text.includes(payload)),
imgsAnywhere: parsed.reduce((n, p) => n + p.imgs, 0),
firedGlobal: w.__xss_fired,
liveImgs: document.querySelectorAll('img[src="x"]').length,
};
}, XSS_PAYLOAD);
const diagnostic =
`queue failure text — writes observed at .cn-manager-message: ${JSON.stringify(sink.writes)}`;
expect(
sink.carrying.length,
`precondition: no write to .cn-manager-message carried the payload at all, so the sink was never exercised and the row proves nothing — check that onQueueCompleted reached showError. ${diagnostic}`,
).toBeGreaterThan(0);
expect(
sink.imgsAnywhere,
`a write to .cn-manager-message parsed into a live <img> — the server-authored queue text reached innerHTML AS MARKUP. ${diagnostic}`,
).toBe(0);
expect(
sink.liveImgs,
`an <img src=x> from the payload is live in the document. ${diagnostic}`,
).toBe(0);
expect(
sink.firedGlobal,
`the payload EXECUTED (window.__xss_fired is set). ${diagnostic}`,
).toBeUndefined();
});
});
// ===========================================================================
// client-side 403 denial text
// ===========================================================================
// The selectors below are load-bearing and were arrived at the hard way.
// `.p-dialog input` matches the Custom Nodes SEARCH box
// (`.cn-manager-keywords`, in `.p-dialog-header`) before it matches the prompt
// field, so a `.first()` on it silently types the URL into the search box:
// install_via_git_url is never called, the denial modal never renders, and the
// row fails as though the client produced nothing. Targeting
// `.prompt-dialog-content input` hits the prompt on the first attempt.
//
// The general lesson: a selector that matches SOMETHING makes a broken fixture
// look like a product failure, and only checking WHAT it matched tells the two
// apart.
test.describe('403 denial text', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
await routeNodeList(page, { p: makePack('p') });
});
/** Drive the REAL "Install via Git URL" entry point: footer button ->
* customPrompt (ComfyUI's PrimeVue dialog) -> install_via_git_url ->
* the 403 arm at common.js:254. */
async function installViaGitUrl(page: import('@playwright/test').Page, url: string) {
// The REAL entry point, driven exactly as a user does it: footer button ->
// customPrompt (common.js:149 -> app.extensionManager.dialog.prompt) ->
// install_via_git_url -> the 403 arm at common.js:254.
//
// `.prompt-dialog-content input` is the load-bearing selector. A broader
// `.p-dialog input` matches the Custom Nodes SEARCH box first, which is
// silent: the URL lands in the search field, no request is made, and the
// row fails at the denial assertion as though the client rendered nothing.
await page.locator('.cn-manager-install-url').first().click();
const input = page.locator('.prompt-dialog-content input').first();
await expect(
input,
'precondition: the customPrompt dialog never rendered its input, so install_via_git_url was never called and the 403 arm was never reached — the row would prove nothing.',
).toBeVisible({ timeout: 10_000 });
await input.fill(url);
await input.press('Enter');
}
/** The denial modal is `app.ui.dialog.element` (`show_message` ->
* `app.ui.dialog.show`, common.js:98). Several `.comfy-modal` nodes exist,
* so filter by the denial copy rather than taking `.first()`. */
function denialDialog(page: import('@playwright/test').Page) {
return page.locator('.comfy-modal').filter({ hasText: /config\.ini/ }).first();
}
const MALFORMED = [
{ label: 'empty body', body: '' },
{ label: 'non-JSON', body: 'not json' },
{ label: 'JSON null', body: 'null' },
{ label: 'JSON array', body: '[]' },
{ label: 'reason is a number', body: '{"reason": 42}' },
{ label: 'unknown reason code', body: '{"reason": "unknown_flag"}' },
];
for (const variant of MALFORMED) {
test(`403 with ${variant.label} falls back without throwing`, async ({ page }) => {
// The client PARSES the 403 body to learn which condition failed, and
// `res.json()` resolves for far more than an object: `null`, `[]`,
// `"str"` and `3` all parse cleanly. `null.reason` would throw inside the
// render path, while `[].reason` and `{"reason":42}` yield `undefined`
// and would put the literal string "undefined" in front of the user.
// `install_denial_message` guards each of those shapes and falls back to
// the calling endpoint's own condition; this row is what holds that
// guard in place, one variant per malformed shape.
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(String(e)));
await page.route('**/v2/customnode/install/git_url', async (route) => {
await route.fulfill({ status: 403, contentType: 'application/json', body: variant.body });
});
await openManagerMenu(page);
await openCustomNodesManager(page);
await installViaGitUrl(page, 'https://github.com/example/pack');
const dialog = denialDialog(page);
await expect(
dialog,
`${variant.label}: the denial dialog did not render its fallback text. A 403 whose body cannot be interpreted is STILL a denial and must still be explained.`,
).toBeVisible({ timeout: 10_000 });
const text = (await dialog.textContent()) ?? '';
expect(
text,
`${variant.label}: the rendered denial text contains the literal string "undefined" — a malformed body leaked through the reason lookup. text: ${text.slice(0, 300)}`,
).not.toContain('undefined');
expect(
errors,
`${variant.label}: the 403 handler threw. pageerrors: ${errors.join(' | ')}`,
).toEqual([]);
});
}
test('the denial text is SELECTED BY the server reason code', async ({ page }) => {
// CROSS-WIRED ON PURPOSE. Each install arm has its own denial text, so a
// per-endpoint check cannot tell a server-DERIVED selection from a
// hand-maintained constant that happens to match the endpoint it sits on.
// Answering the git_url endpoint with the PIP reason code separates them:
// a client that reads the server's reason names allow_pip_install, and a
// client that prints a per-call-site literal names the other flag no
// matter what the server said. That drift is what this row catches.
await page.route('**/v2/customnode/install/git_url', async (route) => {
await route.fulfill({
status: 403,
contentType: 'application/json',
body: JSON.stringify({ reason: 'allow_pip_install' }),
});
});
await openManagerMenu(page);
await openCustomNodesManager(page);
await installViaGitUrl(page, 'https://github.com/example/pack');
const dialog = denialDialog(page);
await expect(dialog).toBeVisible({ timeout: 10_000 });
const text = (await dialog.textContent()) ?? '';
expect(
text,
`the server said reason="allow_pip_install" and the client must name THAT condition. Observed text names the other flag, i.e. the message is a hand-maintained constant per call site rather than being selected from the server's reason code. text: ${text.slice(0, 400)}`,
).toContain('allow_pip_install');
});
test('the denial text states the SERVER\'s restart requirement', async ({ page }) => {
// THE STANDING GUARD FOR A HAND-MAINTAINED COPY. Only the reason CODE is
// derived from the server; the wording lives in js/common.js, and nothing
// makes the two move together. They had already come apart: the server
// constants say the flag and network_mode are read once at STARTUP, so a
// user who edits config.ini and retries is denied identically — and the
// client text omitted it, leaving that user with a correct edit and no hint
// that a restart is what is missing.
//
// The expected sentence is READ OUT of the server constants
// (serverInstallDenialRestartClause), not typed here, so this row cannot
// become a third copy that drifts on its own. Reword the server and the
// client must follow; drop it from the server and this row says so.
const restartClause = serverInstallDenialRestartClause();
await page.route('**/v2/customnode/install/git_url', async (route) => {
await route.fulfill({
status: 403,
contentType: 'application/json',
body: JSON.stringify({ reason: 'allow_git_url_install' }),
});
});
await openManagerMenu(page);
await openCustomNodesManager(page);
await installViaGitUrl(page, 'https://github.com/example/pack');
const dialog = denialDialog(page);
await expect(dialog).toBeVisible({ timeout: 10_000 });
const text = (await dialog.textContent()) ?? '';
expect(
text,
`the client denial text does not state the restart requirement the SERVER states. ` +
`A user who edits config.ini and retries is denied identically, with nothing in ` +
`the UI saying a restart is what is missing.\n` +
`expected (from SECURITY_MESSAGE_FLAG_*): ${JSON.stringify(restartClause)}\n` +
`observed dialog text: ${JSON.stringify(text.slice(0, 600))}`,
).toContain(restartClause);
});
});
// ===========================================================================
// search-keyword highlight — the grid re-renders matched cells from
// textContent, which would undo the formatter escaping unless the bundle
// escapes every slice it splices (see tests/test_grid_highlight_escaping.py)
// ===========================================================================
test.describe('keyword highlight re-render', () => {
test.use({ viewport: { width: 2600, height: 1000 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('a keyword matching inside an escaped raw field stays inert after highlight', async ({ page }) => {
// The keyword must match VISIBLE text: the match cache decodes markup-
// shaped values to their text, and a bare <img> has none.
const payload = XSS_PAYLOAD + 'PWN_author';
await routeNodeList(page, { p: makePack('p', { author: payload }) });
await openManagerMenu(page);
await openCustomNodesManager(page);
const search = page.locator('.cn-manager-keywords').first();
await expect(search, 'precondition: search box not rendered').toBeVisible();
await search.fill('PWN');
// PRECONDITION: the highlighter actually ran on the payload cell.
await expect(
page.locator(`${GRID} .cn-pack-author mark`).first(),
'precondition: no highlight marker rendered in the author cell, so the re-render path was never exercised',
).toBeVisible({ timeout: 10_000 });
await expectInert(page, GRID, payload, 'img', 'author after keyword highlight');
});
});
+241
View File
@@ -6,8 +6,14 @@
* Model, Snapshot) are launched.
*/
import * as fs from 'fs';
import { execFileSync } from 'child_process';
import * as path from 'path';
import { type Page, expect } from '@playwright/test';
/** Repository root, resolved from this file (tests/playwright/helpers.ts). */
const REPO_ROOT = path.resolve(__dirname, '..', '..');
/** Wait for the ComfyUI page to be fully loaded (queue ready). */
export async function waitForComfyUI(page: Page) {
// ComfyUI shows the canvas once the app is ready. Wait for the
@@ -126,3 +132,238 @@ export async function assertManagerMenuVisible(page: Page) {
const dialog = page.locator('#cm-manager-dialog').first();
await expect(dialog).toBeVisible();
}
// ---------------------------------------------------------------------------
// XSS fixtures. Additive — nothing above is changed.
// ---------------------------------------------------------------------------
/**
* The payload used by every XSS row. Assertions on it have THREE legs, because
* a payload can be neutered in a way that also deletes it:
* (1) presence — the container's textContent contains the literal payload
* (2) structure — no element the payload would have created exists
* (3) execution — window.__xss_fired is undefined after render
*/
export const XSS_PAYLOAD = '<img src=x onerror="window.__xss_fired=1">';
/** Space-free variant — the `last_update` formatter cuts its value at the
* first space with `.split(' ')[0]`, so a spaced payload would be truncated
* and the row would pass for the wrong reason. */
export const XSS_PAYLOAD_NOSPACE = '<img/src=x/onerror="window.__xss_fired=1">';
/** One `/v2/customnode/getlist` pack, shaped exactly like the live response
* (verified against the running server: {channel, node_packs: {id: item}}). */
export function makePack(id: string, overrides: Record<string, unknown> = {}) {
return {
author: 'author-' + id,
description: 'description of ' + id,
files: [`https://github.com/example/${id}`],
install_type: 'git-clone',
reference: `https://github.com/example/${id}`,
repository: `https://github.com/example/${id}`,
title: id,
cnr_latest: '1.0.0',
id,
health: '-',
state: 'not-installed',
version: '1.0.0',
'update-state': 'false',
stars: 5,
last_update: '2026-01-01 00:00:00',
trust: false,
...overrides,
};
}
/** Replace the pack list the grid renders from.
*
* NOTE, because any result read from these fixtures depends on it:
* page.route() REPLACES the HTTP response, so the fixture BYPASSES
* populate_markdown. For
* RAW fields that is what a live server can actually deliver; for
* SERVER-ESCAPED fields it delivers input the live server currently cannot,
* so such a fixture must be pre-transformed with `serverSanitizeTag` below —
* otherwise the row asserts against a wire form that does not exist. */
export async function routeNodeList(page: Page, packs: Record<string, unknown>) {
await page.route('**/customnode/getlist**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ channel: 'default', node_packs: packs }),
});
});
}
/** Seed `/v2/customnode/getmappings` — that route performs NO server-side
* sanitization, so the values it returns are genuinely raw. */
export async function routeMappings(page: Page, mappings: Record<string, unknown>) {
await page.route('**/customnode/getmappings**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mappings),
});
});
}
/** Seed `/customnode/alternatives` — required to OPEN the alternatives column,
* which is `invisible: !this.hasAlternatives()`. Without this the alternatives
* column stays hidden and any test asserting on it passes vacuously. */
export async function routeAlternatives(page: Page, alts: Record<string, unknown>) {
await page.route('**/customnode/alternatives**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(alts),
});
});
}
/** Open Manager menu -> Custom Nodes Manager and wait for the grid to fill. */
export async function openCustomNodesManager(page: Page) {
await clickMenuButton(page, 'Custom Nodes Manager');
await page.waitForSelector('#cn-manager-dialog', { timeout: 15_000 });
await page.waitForSelector('.cn-manager-grid, .tg-body', { timeout: 15_000 });
await page.waitForFunction(
() => document.querySelectorAll('.tg-body .tg-row, .cn-manager-grid tr').length > 0,
{ timeout: 30_000, polling: 500 },
);
}
/** The three legs, applied to one container. `payload` is the literal string
* the user must SEE; `createdSelector` is what the payload would have built. */
export async function expectInert(
page: Page,
containerSelector: string,
payload: string,
createdSelector: string,
label: string,
) {
// ALL THREE LEGS ARE EVALUATED BEFORE ANY ASSERTION FIRES, and that is
// load-bearing rather than stylistic. Asserting presence first and
// short-circuiting cannot distinguish the two ways leg 1 fails:
// - the payload was PARSED AS MARKUP (the defect) — textContent holds the
// rendered result, not the literal source, so presence fails; or
// - the payload NEVER RENDERED (a broken fixture) — presence fails too.
// Those are opposite conclusions from an identical symptom, so a failure whose
// message cannot tell them apart is not evidence of anything. The
// diagnostic below reports all three observations on every failure.
const text = (await page.locator(containerSelector).first().textContent()) ?? '';
const created = await page.locator(`${containerSelector} ${createdSelector}`).count();
const fired = await page.evaluate(() => (window as unknown as Record<string, unknown>).__xss_fired);
const present = text.includes(payload);
const diagnostic =
`${label} — three-leg result in ${containerSelector}:\n` +
` leg 1 presence : ${present ? 'PASS (payload visible as literal text)' : 'FAIL (payload not present as literal text)'}\n` +
` leg 2 structure : ${created === 0 ? 'PASS (no <' + createdSelector + '> constructed)' : `FAIL (${created} live <${createdSelector}> constructed — the value reached innerHTML AS MARKUP)`}\n` +
` leg 3 execution : ${fired === undefined ? 'PASS (window.__xss_fired unset)' : `FAIL (window.__xss_fired=${JSON.stringify(fired)} — the payload EXECUTED)`}\n` +
` READ IT THIS WAY: leg 2 or leg 3 failing = the sink is LIVE (this is the failure being demonstrated).\n` +
` leg 1 failing while legs 2 and 3 PASS = the payload never rendered at all — a BROKEN FIXTURE, not a defect;\n` +
` the row proves nothing in that state and must be fixed rather than reported.\n` +
` container text (first 300 chars): ${JSON.stringify(text.slice(0, 300))}`;
expect(created, diagnostic).toBe(0);
expect(fired, diagnostic).toBeUndefined();
expect(present, diagnostic).toBe(true);
}
/** Run the shipped Python renderer, including its title/name/description contract. */
export function serverPopulateMarkdown(item: Record<string, unknown>): Record<string, any> {
return JSON.parse(execFileSync(process.env.PYTHON || 'python3',
[path.join(REPO_ROOT, 'tests', 'legacy_html_testutil.py')],
{ input: JSON.stringify(item), encoding: 'utf-8' }));
}
export function serverSanitizeTag(value: string): string {
return serverPopulateMarkdown({ title: value }).title;
}
/** The RESTART clause both server install-denial constants state.
*
* `js/common.js: INSTALL_DENIAL_MESSAGES` is a hand-written client copy of a
* fact the SERVER owns: the install flag and `network_mode` are read once at
* ComfyUI startup, so editing config.ini and retrying is denied identically
* until the server is restarted. Nothing makes the two texts move together, so
* the client copy is exactly the kind of thing that silently falls behind — it
* already had, which is why this helper exists.
*
* Read from `SECURITY_MESSAGE_FLAG_GIT_URL` / `SECURITY_MESSAGE_FLAG_PIP` and
* cross-checked against each other, so the assertion is against the SERVER's
* wording rather than a third copy typed into the test. The word "restart" is
* only the search key; the whole asserted sentence comes from the source. */
export function serverInstallDenialRestartClause(): string {
const srcPath = path.join(REPO_ROOT, 'comfyui_manager', 'legacy', 'manager_server.py');
const src = fs.readFileSync(srcPath, 'utf-8');
const names = ['SECURITY_MESSAGE_FLAG_GIT_URL', 'SECURITY_MESSAGE_FLAG_PIP'];
const clauses = names.map((name) => {
const m = new RegExp(`^${name}\\s*=\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'm').exec(src);
if (!m) {
throw new Error(
`serverInstallDenialRestartClause: ${name} not found as a double-quoted ` +
`string literal in ${srcPath} — the server constant moved or changed shape.`,
);
}
const sentence = m[1].split('. ').find((s) => /\brestart\b/i.test(s));
if (!sentence) {
throw new Error(
`serverInstallDenialRestartClause: ${name} states no sentence mentioning a ` +
'restart. Either the server dropped the fact (and the client copy should ' +
'drop it too) or the wording changed — do not paper over it here.',
);
}
return `${sentence.trim()}.`;
});
if (clauses[0] !== clauses[1]) {
throw new Error(
'serverInstallDenialRestartClause: the two server constants state DIFFERENT ' +
`restart clauses, so there is no single fact to check the client against:\n` +
` ${names[0]}: ${JSON.stringify(clauses[0])}\n` +
` ${names[1]}: ${JSON.stringify(clauses[1])}`,
);
}
return clauses[0];
}
// ---------------------------------------------------------------------------
// Model Manager fixtures.
// ---------------------------------------------------------------------------
/** One `/v2/externalmodel/getlist` model, shaped like the live response. */
export function makeModel(name: string, overrides: Record<string, unknown> = {}) {
return {
name,
type: 'checkpoint',
base: 'SD1.5',
save_path: 'checkpoints',
description: 'description of ' + name,
reference: 'https://example.com/' + name,
filename: name + '.safetensors',
url: 'https://example.com/' + name + '.safetensors',
size: '1.0MB',
installed: 'False',
...overrides,
};
}
/** Replace the model list the Model Manager grid renders from. */
export async function routeModelList(page: Page, models: Record<string, unknown>[]) {
await page.route('**/externalmodel/getlist**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ models }),
});
});
}
/** Open Manager menu -> Model Manager and wait for the grid to fill. */
export async function openModelManager(page: Page) {
await clickMenuButton(page, 'Model Manager');
await page.waitForSelector('#cmm-manager-dialog', { timeout: 15_000 });
await page.waitForSelector('.cmm-manager-grid, .tg-body', { timeout: 15_000 });
await page.waitForFunction(
() => document.querySelectorAll('.tg-body .tg-row, .cmm-manager-grid tr').length > 0,
{ timeout: 30_000, polling: 500 },
);
}
@@ -0,0 +1,235 @@
import { test, expect } from '@playwright/test';
import {
waitForComfyUI, openManagerMenu, openCustomNodesManager, openModelManager,
routeNodeList, routeModelList, makePack, makeModel, serverSanitizeTag, XSS_PAYLOAD,
} from './helpers';
for (const kind of ['nodes', 'models']) {
const prefix = kind === 'nodes' ? 'cn' : 'cmm';
const file = kind === 'nodes' ? 'custom-nodes-manager' : 'model-manager';
const exported = kind === 'nodes' ? 'CustomNodesManager' : 'ModelManager';
for (const failure of ['network', 'http', 'json', 'shape']) {
test(`${kind}: ${failure} batch failure clears pending controls`, async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(String(error)));
await page.goto('/');
await waitForComfyUI(page);
if (kind === 'nodes') {
await routeNodeList(page, { victim: makePack('victim', { version: 'unknown' }) });
} else {
await routeModelList(page, [makeModel('victim')]);
}
await page.route('**/v2/manager/queue/batch', async route => {
if (failure === 'network') return route.abort('failed');
if (failure === 'http') return route.fulfill({ status: 500, json: { failed: [] } });
if (failure === 'json') return route.fulfill({ contentType: 'application/json', body: 'invalid' });
return route.fulfill({ json: { failed: {} } });
});
await openManagerMenu(page);
await (kind === 'nodes' ? openCustomNodesManager(page) : openModelManager(page));
await page.locator(`.${prefix}-manager-grid .${prefix}-btn-install`).first().click();
await expect(page.locator(`.${prefix}-manager-message`)).toContainText('Failed to submit installation request');
await expect(page.locator(`.${prefix}-manager-stop`)).toBeHidden();
await expect(page.locator(`.${prefix}-btn-loading`)).toHaveCount(0);
expect(await page.evaluate(async ({ file, exported }) => {
const module = await import(`/extensions/comfyui-manager-legacy/${file}.js`);
return !!module[exported].instance.install_context;
}, { file, exported })).toBe(false);
expect(errors).toEqual([]);
});
}
test(`${kind}: rejected batch names and unknown IDs reach both error displays safely`, async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(String(error)));
await page.goto('/');
await waitForComfyUI(page);
const title = serverSanitizeTag('Pack &amp; Friends ' + XSS_PAYLOAD);
if (kind === 'nodes') {
await routeNodeList(page, { victim: makePack('victim', { title, version: 'unknown' }) });
} else {
await routeModelList(page, [makeModel(title)]);
}
await page.route('**/v2/manager/queue/batch', async route => {
const batch = route.request().postDataJSON();
const items = batch[kind === 'nodes' ? 'install' : 'install_model'];
expect(items[0].id).toBeTruthy();
// The socket event may arrive before the HTTP rejection response.
await page.evaluate(async () => {
const { api } = await import('/scripts/api.js');
api.dispatchEvent(new CustomEvent('cm-queue-status', { detail: { status: 'all-done' } }));
});
await route.fulfill({ json: { failed: [items[0].id, XSS_PAYLOAD] } });
});
await openManagerMenu(page);
await (kind === 'nodes' ? openCustomNodesManager(page) : openModelManager(page));
if (kind === 'models') {
await page.locator('.cmm-manager-type').evaluate(select => select.setAttribute('disabled', ''));
}
await page.locator(`.${prefix}-manager-grid .${prefix}-btn-install`).first().click();
const message = page.locator(`.${prefix}-manager-message`);
await expect(message).toContainText('[FAIL] Pack & Friends ' + XSS_PAYLOAD);
await expect(message).toContainText('[FAIL] ' + XSS_PAYLOAD);
expect(await message.locator('img').count()).toBe(0);
const dialog = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return { text: app.ui.dialog.element.textContent, images: app.ui.dialog.element.querySelectorAll('img').length };
});
expect(dialog.text).toContain('[FAIL] Pack & Friends ' + XSS_PAYLOAD);
expect(dialog.text).toContain('[FAIL] ' + XSS_PAYLOAD);
expect(dialog.images).toBe(0);
await expect(page.locator(`.${prefix}-manager-stop`)).toBeHidden();
await expect(page.locator(`.${prefix}-btn-loading`)).toHaveCount(0);
if (kind === 'models') {
await expect(page.locator('.cmm-manager-type')).toBeDisabled();
}
expect(await page.evaluate(async ({ file, exported }) => {
const module = await import(`/extensions/comfyui-manager-legacy/${file}.js`);
return !!module[exported].instance.install_context;
}, { file, exported })).toBe(false);
expect(await page.evaluate(() => (window as any).__xss_fired)).toBeUndefined();
expect(errors).toEqual([]);
});
for (const rejectedCount of [0, 1]) {
test(`${kind}: ${rejectedCount} rejections keep accepted work pending until completion`, async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
if (kind === 'nodes') {
await routeNodeList(page, {
first: makePack('first', { version: 'unknown' }),
second: makePack('second', { version: 'unknown' }),
});
} else {
await routeModelList(page, [makeModel('first'), makeModel('second')]);
}
let completion: Record<string, unknown>;
await page.route('**/v2/manager/queue/batch', async route => {
const batch = route.request().postDataJSON();
const items = batch[kind === 'nodes' ? 'install' : 'install_model'];
const result = Object.fromEntries(items.slice(rejectedCount).map(item => [item.ui_id, 'success']));
completion = { status: 'batch-done', batch_id: batch.batch_id,
nodepack_result: kind === 'nodes' ? result : {}, model_result: kind === 'models' ? result : {},
done_count: items.length - rejectedCount, total_count: items.length - rejectedCount };
await route.fulfill({ json: { failed: items.slice(0, rejectedCount).map(item => item.id) } });
});
await openManagerMenu(page);
await (kind === 'nodes' ? openCustomNodesManager(page) : openModelManager(page));
await page.evaluate(async ({ file, exported, kind, prefix }) => {
const module = await import(`/extensions/comfyui-manager-legacy/${file}.js`);
const manager = module[exported].instance;
const button = manager.element.querySelector(`.${prefix}-btn-install`);
if (kind === 'nodes') {
await manager.installNodes(Object.values(manager.custom_nodes).map((item: any) => item.hash),
{ target: button, label: 'Install', mode: 'install' });
} else {
await manager.installModels(manager.modelList, button);
}
}, { file, exported, kind, prefix });
await expect(page.locator(`.${prefix}-manager-stop`)).toBeVisible();
expect(await page.evaluate(async ({ file, exported }) => {
const module = await import(`/extensions/comfyui-manager-legacy/${file}.js`);
return !!module[exported].instance.install_context;
}, { file, exported })).toBe(true);
await page.evaluate(async detail => {
const { api } = await import('/scripts/api.js');
api.dispatchEvent(new CustomEvent('cm-queue-status', { detail }));
}, completion!);
await expect(page.locator(`.${prefix}-manager-stop`)).toBeHidden();
await expect(page.locator(`.${prefix}-btn-loading`)).toHaveCount(0);
expect(await page.evaluate(async ({ file, exported }) => {
const module = await import(`/extensions/comfyui-manager-legacy/${file}.js`);
return !!module[exported].instance.install_context;
}, { file, exported })).toBe(false);
});
}
test(`${kind}: HTML messages preserve names and assign color through the DOM`, async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
const wire = serverSanitizeTag('Pack &amp; Friends <Flux>');
const result = await page.evaluate(async ({ file, exported, prefix, wire }) => {
const module = await import(`/extensions/comfyui-manager-legacy/${file}.js`);
const manager = Object.create(module[exported].prototype);
manager.element = document.createElement('div');
manager.element.innerHTML = `<div class="${prefix}-manager-message"></div><div class="${prefix}-manager-status"></div>`;
document.body.append(manager.element);
manager.showMessage(wire, 'rgb(1, 2, 3)');
manager.showStatus(wire, '"><img src=x onerror="window.__xss_fired=1">');
const [message, status] = manager.element.children;
const result = { message: message.textContent, status: status.textContent,
color: message.style.color, children: status.children.length };
manager.showMessage('done');
return { ...result, resetColor: message.style.color };
}, { file, exported, prefix, wire });
expect(result).toEqual({ message: 'Pack & Friends <Flux>', status: 'Pack & Friends <Flux>',
color: 'rgb(1, 2, 3)', children: 0, resetColor: '' });
});
}
test('nodes: lookup errors and missing row IDs remain literal text', async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
const results = await page.evaluate(async payload => {
const { CustomNodesManager } = await import('/extensions/comfyui-manager-legacy/custom-nodes-manager.js');
const common = await import('/extensions/comfyui-manager-legacy/common.js');
const { api } = await import('/scripts/api.js');
const manager = Object.create(CustomNodesManager.prototype);
manager.element = document.createElement('div');
manager.element.innerHTML = '<div class="cn-manager-message"></div><div class="cn-manager-status"></div>';
document.body.append(manager.element);
manager.grid = { getRowItemBy: () => undefined, updateCell: () => { throw new Error('Missing row updated'); } };
const saved = api.fetchApi;
const previousManager = common.manager_instance;
common.setManagerInstance({ datasrc_combo: { value: 'cache' } });
api.fetchApi = async () => ({ status: 500, statusText: payload });
const results = [];
try {
for (const invoke of [
() => manager.getMissingNodesLegacy({}, new Set()),
() => manager.getAlternatives(),
() => manager.installNodes([payload], { target: document.createElement('button'), mode: 'install' }),
]) {
await invoke();
const message = manager.element.querySelector('.cn-manager-message');
results.push({ text: message.textContent, images: message.querySelectorAll('img').length });
}
} finally {
api.fetchApi = saved;
common.setManagerInstance(previousManager);
}
return results;
}, XSS_PAYLOAD);
expect(results).toHaveLength(3);
for (const result of results) {
expect(result.text).toContain(XSS_PAYLOAD);
expect(result.images).toBe(0);
}
expect(await page.evaluate(() => (window as any).__xss_fired)).toBeUndefined();
});
test('nodes: workflow registry IDs remain inert in the Missing Nodes dialog', async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
const result = await page.evaluate(async payload => {
const { app } = await import('/scripts/app.js');
const { CustomNodesManager } = await import('/extensions/comfyui-manager-legacy/custom-nodes-manager.js');
const manager = Object.create(CustomNodesManager.prototype);
manager.custom_nodes = {};
manager.getMissingNodesLegacy = async () => {};
const nodes = app.graph._nodes;
try {
app.graph._nodes = [{ type: 'MissingTestNode', properties: { cnr_id: payload } }];
await manager.getMissingNodes();
} finally {
app.graph._nodes = nodes;
}
const dialog = app.ui.dialog.element;
return { text: dialog.textContent, images: dialog.querySelectorAll('img').length };
}, XSS_PAYLOAD + ' &amp; Registry');
expect(result.images).toBe(0);
expect(result.text).toContain(XSS_PAYLOAD + ' &amp; Registry');
expect(await page.evaluate(() => (window as any).__xss_fired)).toBeUndefined();
});
+244
View File
@@ -0,0 +1,244 @@
/**
* Browser-level XSS regression suite for the Model Manager UI.
*
* Same contract as custom-nodes-xss.spec.ts: `name` / `description` are
* escaped by the server (populate_markdown) and rendered as-is; every other
* model-list field is raw and must be escaped on the client. Rows assert the
* payload is visible as literal text, constructs no element, and never runs.
*
* Requires ComfyUI running with --enable-manager-legacy-ui on PORT.
*/
import { test, expect } from '@playwright/test';
import {
waitForComfyUI,
openManagerMenu,
openModelManager,
routeModelList,
makeModel,
expectInert,
serverSanitizeTag,
XSS_PAYLOAD,
} from './helpers';
const DIALOG = '#cmm-manager-dialog';
const GRID = '.cmm-manager-grid';
function trackPageErrors(page: import('@playwright/test').Page) {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(String(e)));
return errors;
}
/** Records every innerHTML write to elements carrying `className`. */
async function captureInnerHTMLWrites(page: import('@playwright/test').Page, className: string, slot: string) {
await page.evaluate(({ className, slot }) => {
const w = window as unknown as Record<string, any>;
w[slot] = [];
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')!;
Object.defineProperty(Element.prototype, 'innerHTML', {
configurable: true,
get() { return desc.get!.call(this); },
set(value) {
try {
if ((this as Element).classList?.contains(className)) w[slot].push(String(value));
} catch { /* never break the page under test */ }
desc.set!.call(this, value);
},
});
}, { className, slot });
}
async function analyseWrites(page: import('@playwright/test').Page, slot: string, payload: string) {
return page.evaluate(({ slot, payload }) => {
const w = window as unknown as Record<string, any>;
const writes: string[] = w[slot] ?? [];
const probe = document.createElement('div');
const parsed = writes.map((html) => {
probe.innerHTML = html;
return { text: probe.textContent ?? '', imgs: probe.querySelectorAll('img').length };
});
return {
writes,
carrying: parsed.filter((p) => p.text.includes(payload)).length,
imgs: parsed.reduce((n, p) => n + p.imgs, 0),
fired: w.__xss_fired,
liveImgs: document.querySelectorAll('img[src="x"]').length,
};
}, { slot, payload });
}
test.describe('model grid render sinks', () => {
// Columns are virtualised horizontally; pin a wide viewport so every body
// cell this suite asserts on is actually in the DOM.
test.use({ viewport: { width: 2600, height: 1000 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('hostile raw fields render inert (LIVE no-user-action)', async ({ page }) => {
await routeModelList(page, [
makeModel('m1', { type: XSS_PAYLOAD, base: XSS_PAYLOAD }),
makeModel('m2', { save_path: XSS_PAYLOAD, filename: XSS_PAYLOAD }),
// a non-parsable size falls through to the raw branch of the formatter
makeModel('m3', { size: XSS_PAYLOAD }),
]);
await openManagerMenu(page);
await openModelManager(page);
// The dialog covers the grid AND the type/base filter dropdowns, which are
// built from the same raw values.
await expectInert(page, DIALOG, XSS_PAYLOAD, 'img', 'type/base/save_path/filename/size');
});
test('a server-escaped name renders decoded (no double-escape)', async ({ page }) => {
const AUTHORED = 'Tom & Jerry <3';
const ON_THE_WIRE = serverSanitizeTag(AUTHORED);
expect(ON_THE_WIRE).not.toBe(AUTHORED);
// makeModel derives filename/reference/url/description from the name;
// those are RAW columns and would carry the wire form literally, so give
// them values that do not contain the name.
await routeModelList(page, [makeModel(ON_THE_WIRE, {
filename: 'legit.safetensors', reference: 'https://example.com/legit',
url: 'https://example.com/legit.safetensors', description: 'a legit model',
})]);
await openManagerMenu(page);
await openModelManager(page);
const text = (await page.locator(GRID).first().textContent()) ?? '';
expect(text, 'the server-escaped name was escaped a second time').toContain(AUTHORED);
expect(text, 'the wire form is visible as literal text').not.toContain(ON_THE_WIRE);
});
test('missing raw fields render without throwing', async ({ page }) => {
const errors = trackPageErrors(page);
const bare = makeModel('bare') as Record<string, unknown>;
delete bare.type; delete bare.base; delete bare.save_path; delete bare.filename; delete bare.size; delete bare.reference;
await routeModelList(page, [bare]);
await openManagerMenu(page);
await openModelManager(page);
await expect(page.locator(GRID).first()).toBeVisible();
expect(errors, `pageerrors: ${errors.join(' | ')}`).toEqual([]);
});
});
test.describe('model URL sinks', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('name and download links carry no scheme outside the allow-list', async ({ page }) => {
await routeModelList(page, [
makeModel('evil', {
reference: 'javascript:window.__xss_fired=1',
url: 'javascript:window.__xss_fired=1',
}),
// an unquoted href used to let a space inject attributes
makeModel('inject', { reference: 'https://example.com/x onmouseover=window.__xss_fired=1' }),
// a quoted href must not be closed early by a quote inside a URL that
// still parses as https (sanitizeUrl keeps the raw string)
makeModel('quote', { reference: 'https://example.com/x" onmouseover="window.__xss_fired=1' }),
makeModel('quote2', { url: 'https://example.com/y" onmouseover="window.__xss_fired=1' }),
]);
await openManagerMenu(page);
await openModelManager(page);
const anchors = await page.locator(`${GRID} a`).evaluateAll((els) =>
els.map((e) => ({ href: (e as HTMLAnchorElement).href, attrs: e.getAttributeNames() })),
);
expect(anchors.length, 'precondition: no anchors rendered').toBeGreaterThan(0);
const allowed = ['http:', 'https:'];
const offending = anchors.filter((a) => {
const s = new URL(a.href).protocol;
return s !== null && !allowed.includes(s);
});
expect(offending, `anchor href carries a scheme outside ${JSON.stringify(allowed)}`).toEqual([]);
const injected = anchors.filter((a) => a.attrs.some((n) => n.startsWith('on')));
expect(injected, 'an anchor gained an event-handler attribute from the URL').toEqual([]);
// hover the rows so an injected onmouseover would fire if present
for (const row of await page.locator(`${GRID} .tg-body .tg-row`).all()) {
await row.hover();
}
const fired = await page.evaluate(() => (window as unknown as Record<string, unknown>).__xss_fired);
expect(fired, 'a handler injected through a URL EXECUTED').toBeUndefined();
});
});
test.describe('model status and error sinks', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('install status and batch failure text render inert', async ({ page }) => {
const onTheWire = serverSanitizeTag(XSS_PAYLOAD);
expect(onTheWire).not.toBe(XSS_PAYLOAD);
await routeModelList(page, [makeModel(onTheWire)]);
await page.route('**/v2/manager/queue/batch', async (route) => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"failed": []}' });
});
await openManagerMenu(page);
await openModelManager(page);
await captureInnerHTMLWrites(page, 'cmm-manager-status', '__cmmStatusWrites');
await captureInnerHTMLWrites(page, 'cmm-manager-message', '__cmmMessageWrites');
const installBtn = page.locator(`${GRID} .cmm-btn-install`).first();
await expect(installBtn, 'precondition: no install button rendered').toBeVisible({ timeout: 15_000 });
await installBtn.click();
// Status line preserves the server-composed name.
const status = await analyseWrites(page, '__cmmStatusWrites', XSS_PAYLOAD);
expect(status.writes.length, 'precondition: nothing written to the status line').toBeGreaterThan(0);
expect(status.imgs, `status write parsed into <img>: ${JSON.stringify(status.writes)}`).toBe(0);
expect(status.carrying, 'status line does not carry the name as literal text').toBeGreaterThan(0);
// Batch completion: server-authored failure text reaches the message sink.
await page.evaluate((payload) => {
const api = (window as unknown as Record<string, any>).comfyAPI.api.api;
api.dispatchEvent(new CustomEvent('cm-queue-status', {
detail: { status: 'batch-done', model_result: { x: payload }, done_count: 1, total_count: 1 },
}));
}, XSS_PAYLOAD);
const message = await analyseWrites(page, '__cmmMessageWrites', XSS_PAYLOAD);
expect(message.carrying, `precondition: no message write carried the payload: ${JSON.stringify(message.writes)}`).toBeGreaterThan(0);
expect(message.imgs, `message write parsed into <img>: ${JSON.stringify(message.writes)}`).toBe(0);
expect(message.liveImgs, 'an <img src=x> from the payload is live').toBe(0);
expect(message.fired, 'the payload EXECUTED').toBeUndefined();
});
});
test.describe('model keyword highlight re-render', () => {
test.use({ viewport: { width: 2600, height: 1000 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForComfyUI(page);
});
test('a keyword matching inside an escaped raw field stays inert after highlight', async ({ page }) => {
// The keyword must match VISIBLE text (see the custom-nodes row).
const payload = XSS_PAYLOAD + 'PWN_type';
await routeModelList(page, [makeModel('m', { type: payload })]);
await openManagerMenu(page);
await openModelManager(page);
const search = page.locator('.cmm-manager-keywords').first();
await expect(search, 'precondition: search box not rendered').toBeVisible();
await search.fill('PWN');
// The type column has no classMap, so narrow to the cell carrying the payload text.
await expect(
page.locator(`${GRID} .tg-cell`).filter({ hasText: 'PWN_type' }).locator('mark').first(),
'precondition: no highlight marker rendered in the type cell, so the re-render path was never exercised',
).toBeVisible({ timeout: 10_000 });
await expectInert(page, DIALOG, payload, 'img', 'type after keyword highlight');
});
});
@@ -0,0 +1,800 @@
"""Regression suite for the settings writer and the install-denial messages.
Two behaviours are pinned here, both of which used to be wrong in ways a user
only notices after losing data or acting on misleading advice:
* ``write_config()`` MERGES onto what is on disk. It used to rebuild the
``[default]`` section from the process-lifetime cache, so a hand edit made
while the server was up, a key that ``read_config`` reads but the writer
never writes, and any non-``[default]`` section were all silently destroyed
by the next settings write. Those are the four merge rows below.
* the install-denial MESSAGES name the condition that actually failed. A
denial can be caused by the dedicated flag OR by the network mode, and a
message that names only one of them sends the user to change a setting that
will not help.
A later group covers a silent read failure: an existing but
UNREADABLE ``config.ini`` used to fall through the same else-branch as "no
config yet", so the writer treated it as a fresh bootstrap and overwrote the
file it could not read.
RUN IT PINNED. A non-editable ``comfyui_manager`` can live in site-packages,
and the console-script ``pytest`` does not put cwd on ``sys.path``, so an
unpinned run silently exercises the stale installed copy:
PYTHONPATH=<repo abs path> pytest tests/test_config_write_and_install_denial.py
``test_import_origin_is_this_tree`` fails loudly when the run is unpinned,
so a misleading pass or failure cannot go unnoticed.
Harness: the in-repo ``tests/_install_flags_testutil.py``, parameterized over
BOTH config readers. ``manager_server`` is NEVER imported — importing that
module starts a network thread — so the message rows assert against parsed
SOURCE rather than against an imported constant.
"""
from __future__ import annotations
import ast
import configparser
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _install_flags_testutil import import_context, import_reader # noqa: E402
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
READERS = ("glob", "legacy")
LEGACY_SERVER = os.path.join(REPO_ROOT, "comfyui_manager", "legacy", "manager_server.py")
SHARED_MESSAGES = os.path.join(REPO_ROOT, "comfyui_manager", "common", "security_messages.py")
#: the keys ``write_config()`` owns, per tree. Deliberately spelled
#: out rather than read back from the implementation: this is the CONTRACT the
#: code must satisfy, not a mirror of whatever the code currently does.
OWNED_KEYS = {
"legacy": (
"git_exe", "use_uv", "channel_url", "share_option", "bypass_ssl",
"file_logging", "update_policy", "windows_selector_event_loop_policy",
"model_download_by_agent", "downgrade_blacklist", "security_level",
"always_lazy_install", "network_mode", "db_mode",
"allow_git_url_install", "allow_pip_install",
),
"glob": (
"git_exe", "use_uv", "use_unified_resolver", "channel_url",
"share_option", "bypass_ssl", "file_logging", "update_policy",
"windows_selector_event_loop_policy", "model_download_by_agent",
"downgrade_blacklist", "security_level", "always_lazy_install",
"network_mode", "db_mode", "verbose",
"allow_git_url_install", "allow_pip_install",
),
}
#: read by both readers, written by neither — a settings write must leave
#: them alone.
UNWRITTEN_KEYS = ("http_channel_enabled", "default_cache_as_channel_url")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(params=READERS)
def reader_core(request):
"""(reader_name, manager_core) for BOTH readers, cache isolated per test."""
core = import_reader(request.param)
saved = getattr(core, "cached_config", None)
setattr(core, "cached_config", None)
yield request.param, core
setattr(core, "cached_config", saved)
@pytest.fixture
def point_config(monkeypatch):
"""Point ``context.manager_config_path`` at a path (resolved at call time
by read_config AND write_config, so this redirects both readers)."""
ctx = import_context()
def _point(path):
monkeypatch.setattr(ctx, "manager_config_path", str(path))
return _point
def _write_ini(path, body: str):
with open(path, "w", encoding="utf-8") as fh:
fh.write(body)
return path
def _read_raw(path) -> str:
with open(path, encoding="utf-8") as fh:
return fh.read()
def _parse_disk(path) -> configparser.ConfigParser:
cp = configparser.ConfigParser(strict=False)
cp.read(str(path))
return cp
def _boot(core):
"""Simulate process start: populate the module-level config cache once."""
setattr(core, "cached_config", None)
return core.get_config()
def _settings_change(core, key, value):
"""The real caller shape: mutate the cache, then write."""
core.get_config()[key] = value
core.write_config()
def _message_constants(source_path):
"""Module-level ``SECURITY_MESSAGE_* = "..."`` assignments -> {name: text}.
Parsed from source: importing ``manager_server`` starts a network thread.
"""
tree = ast.parse(_read_raw(source_path), filename=source_path)
out = {}
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
if not isinstance(node.value, ast.Constant) or not isinstance(node.value.value, str):
continue
for target in node.targets:
if isinstance(target, ast.Name) and target.id.startswith("SECURITY_MESSAGE_"):
out[target.id] = node.value.value
return out
def _flag_message_emissions(source_path):
"""Every ``logging.error(SECURITY_MESSAGE_FLAG_*...)`` site.
Returns [(lineno, const_name, formatted: bool)]. ``formatted`` is True only
when the argument is ``SECURITY_MESSAGE_FLAG_*.format(listen=args.listen)``.
"""
tree = ast.parse(_read_raw(source_path), filename=source_path)
found = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if not (isinstance(func, ast.Attribute) and func.attr == "error"):
continue
if not node.args:
continue
arg = node.args[0]
# bare constant: logging.error(SECURITY_MESSAGE_FLAG_GIT_URL)
if isinstance(arg, ast.Name) and arg.id.startswith("SECURITY_MESSAGE_FLAG_"):
found.append((node.lineno, arg.id, False))
continue
# formatted: logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(listen=...))
if (isinstance(arg, ast.Call)
and isinstance(arg.func, ast.Attribute)
and arg.func.attr == "format"
and isinstance(arg.func.value, ast.Name)
and arg.func.value.id.startswith("SECURITY_MESSAGE_FLAG_")):
kwargs = {kw.arg for kw in arg.keywords}
listen_kw = "listen" in kwargs
found.append((node.lineno, arg.func.value.id, listen_kw))
return sorted(found)
# ---------------------------------------------------------------------------
# import-origin guard
# ---------------------------------------------------------------------------
def test_import_origin_is_this_tree():
"""The suite must exercise THIS checkout, not the site-packages copy.
Unpinned, the console-script ``pytest`` resolves ``comfyui_manager`` to the
non-editable 4.2.2 install and every other verdict in this file becomes
meaningless. Measured once on the pre-existing suites: an unpinned run
reported 18 failed / 73 passed where a pinned run reported 91 passed.
"""
core = import_reader("legacy")
origin = getattr(core, "__file__", None)
assert origin, "comfyui_manager.legacy.manager_core has no __file__ to check"
resolved = os.path.abspath(origin)
assert resolved.startswith(REPO_ROOT + os.sep), (
"IMPORT-ORIGIN GUARD: comfyui_manager resolved to %r, which is OUTSIDE this "
"checkout (%r). The run is UNPINNED and is testing a different copy of "
"the package. Re-run as: PYTHONPATH=%s pytest tests/test_config_write_and_install_denial.py"
% (resolved, REPO_ROOT, REPO_ROOT)
)
# ---------------------------------------------------------------------------
# write_config merges onto disk
# ---------------------------------------------------------------------------
def test_hand_edit_survives(reader_core, point_config, tmp_path):
"""A hand edit made while the server is UP survives an
unrelated settings change.
``write_config()`` rebuilds ``[default]`` from the process-lifetime
startup snapshot, so the edit is overwritten with the stale cached value —
the user follows the denial message, edits config.ini, touches any Manager
setting, and is denied again after restart.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "[default]\nsecurity_level = normal\nallow_git_url_install = false\n")
point_config(ini)
_boot(core) # startup snapshot: flag False
# user hand-edits config.ini while ComfyUI is running
_write_ini(ini, "[default]\nsecurity_level = normal\nallow_git_url_install = true\n")
_settings_change(core, "db_mode", "local")
on_disk = _parse_disk(ini)["default"].get("allow_git_url_install", "<ABSENT>")
assert str(on_disk).lower() == "true", (
"expected the hand-edited allow_git_url_install=true to SURVIVE "
"an unrelated settings change; observed %r on disk (%s reader). "
"write_config() re-wrote the key from the startup snapshot.\nfile:\n%s"
% (on_disk, reader, _read_raw(ini))
)
def test_unwritten_keys_survive(reader_core, point_config, tmp_path):
"""Keys read_config reads but write_config never writes.
``http_channel_enabled`` gates the insecure-channel warning and
``default_cache_as_channel_url`` steers channel resolution; every settings
write deletes both from the file.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(
ini,
"[default]\nsecurity_level = normal\n"
"http_channel_enabled = true\ndefault_cache_as_channel_url = true\n",
)
point_config(ini)
_boot(core)
_settings_change(core, "db_mode", "local")
section = _parse_disk(ini)["default"]
missing = [k for k in UNWRITTEN_KEYS if k not in section]
assert not missing, (
"expected the read-but-never-written keys %s to SURVIVE a "
"settings write; observed %s DELETED from the file (%s reader). "
"write_config() rebuilds [default] from its own key list.\nfile:\n%s"
% (list(UNWRITTEN_KEYS), missing, reader, _read_raw(ini))
)
def test_foreign_section_survives(reader_core, point_config, tmp_path):
"""A non-[default] section survives a settings write.
``write_config()`` builds a FRESH parser, assigns only
``config['default']`` and opens the file ``'w'``, erasing every other section.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(
ini,
"[default]\nsecurity_level = normal\n\n"
"[my_custom_section]\nfoo = bar\nbaz = 1\n",
)
point_config(ini)
_boot(core)
_settings_change(core, "db_mode", "local")
disk = _parse_disk(ini)
assert disk.has_section("my_custom_section"), (
"expected the foreign section [my_custom_section] to SURVIVE a "
"settings write; observed it ERASED (%s reader).\nfile:\n%s"
% (reader, _read_raw(ini))
)
assert disk["my_custom_section"].get("foo") == "bar", (
"[my_custom_section] survived but lost its content (%s reader).\nfile:\n%s"
% (reader, _read_raw(ini))
)
def test_no_reclobber_after_persist(reader_core, point_config, tmp_path):
"""A key changed once through the API, then hand-edited,
is NOT re-clobbered by the next unrelated write.
This is the residual clobber that upstream's first attempt at the merge
left behind, later fixed with ``dirty_keys.difference_update(keys)``:
without the post-write reset the key stays dirty for the life of the
process and every later write overlays the stale cached value again.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "[default]\nsecurity_level = normal\ndb_mode = cache\n")
point_config(ini)
_boot(core)
_settings_change(core, "db_mode", "local") # persisted through the API
assert _parse_disk(ini)["default"].get("db_mode") == "local", (
"precondition failed: the API change did not reach disk (%s reader)" % reader
)
# user now hand-edits that SAME key while the server is up
raw = _read_raw(ini).replace("db_mode = local", "db_mode = remote")
_write_ini(ini, raw)
_settings_change(core, "share_option", "none") # UNRELATED settings change
on_disk = _parse_disk(ini)["default"].get("db_mode", "<ABSENT>")
assert on_disk == "remote", (
"expected the later hand-edit db_mode=remote to SURVIVE an "
"unrelated settings change; observed %r (%s reader) — the previously "
"API-changed key was written again from the stale cache.\nfile:\n%s"
% (on_disk, reader, _read_raw(ini))
)
def test_bootstrap_seeds_owned_keys(reader_core, point_config, tmp_path):
"""No config.ini at all: the bootstrap path seeds the full
owned key set.
A guard: the merge rework must not break first-launch bootstrap.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
point_config(ini)
assert not os.path.exists(ini), "precondition: config.ini must be absent"
_boot(core)
core.write_config()
assert os.path.exists(ini), (
"bootstrap did not create config.ini (%s reader)" % reader
)
section = _parse_disk(ini)["default"]
missing = [k for k in OWNED_KEYS[reader] if k not in section]
assert not missing, (
"bootstrap seeded an incomplete [default] for the %s reader — "
"missing %s.\nfile:\n%s"
% (reader, missing, _read_raw(ini))
)
def test_malformed_ini_does_not_raise(reader_core, point_config, tmp_path):
"""An unparsable config.ini must not make a settings change
raise; the file is rewritten from the current settings.
A guard. Deliberately does NOT assert survival of unwritten keys or
foreign sections: an unparsable file is exactly where the merge narrows,
and asserting survival here would pin a behavior the design does not
promise.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "this is not an ini file\n[[[unclosed\n= = =\n")
point_config(ini)
_boot(core)
try:
_settings_change(core, "db_mode", "local")
except Exception as exc: # noqa: BLE001 — the point of the row
pytest.fail(
"a settings change raised %s on an unparsable config.ini "
"(%s reader): %s" % (type(exc).__name__, reader, exc)
)
disk = _parse_disk(ini)
assert disk.has_section("default"), (
"after the fallback rewrite the file has no [default] section "
"(%s reader).\nfile:\n%s" % (reader, _read_raw(ini))
)
def test_unowned_key_not_persisted(reader_core, point_config, tmp_path):
"""Dirtying a key write_config does NOT own must not
persist it (unchanged from today's behavior).
A guard: ownership alone decides what a write persists.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "[default]\nsecurity_level = normal\n")
point_config(ini)
_boot(core)
core.get_config()["http_channel_enabled"] = True # unowned
_settings_change(core, "db_mode", "local") # owned
section = _parse_disk(ini)["default"]
assert "http_channel_enabled" not in section, (
"the unowned key http_channel_enabled was persisted (%s reader); "
"write_config() must persist only keys it owns.\nfile:\n%s"
% (reader, _read_raw(ini))
)
def test_unowned_dirty_marker_survives(reader_core, point_config, tmp_path):
"""A dirty marker for a key this write did NOT persist
must SURVIVE the write.
Rests on the ``DirtyTrackingConfig`` cache, and pins
``difference_update(keys)`` over ``clear()``: a blanket clear would drop the
marker for a key the write never persisted, so the change the user made to
it would never reach disk.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "[default]\nsecurity_level = normal\n")
point_config(ini)
cached = _boot(core)
cached["http_channel_enabled"] = True # unowned -> never persisted
_settings_change(core, "db_mode", "local") # owned -> persisted
dirty = getattr(core.get_config(), "dirty_keys", None)
assert dirty is not None, (
"the cached config exposes no dirty_keys (%s reader) — the cache is no "
"longer a DirtyTrackingConfig, so write_config() cannot know what "
"actually changed and falls back to rewriting every owned key from the "
"startup snapshot."
% reader
)
assert "http_channel_enabled" in dirty, (
"the dirty marker for the UNPERSISTED key http_channel_enabled "
"was dropped by the write (%s reader); the post-write reset must "
"difference_update() exactly the persisted keys, never clear() "
". dirty_keys=%r" % (reader, dirty)
)
assert "db_mode" not in dirty, (
"db_mode was persisted by this write but is still marked dirty "
"(%s reader) — without the reset it stays dirty for the life of the "
"process and re-clobbers a later hand-edit. dirty_keys=%r"
% (reader, dirty)
)
def test_partial_write_leaves_file_intact(reader_core, point_config, tmp_path, monkeypatch):
"""A settings write that fails PART-WAY must leave config.ini byte-intact.
The merge is what makes this matter. Foreign keys and foreign sections are
carried across by reading them off the disk, so the file on disk is their
ONLY home — nothing in the process holds a copy. A writer that truncates
that file and then streams into it opens a window in which the content
exists nowhere at all, and a crash, a kill, or a full disk inside the window
leaves a half-written file and nothing to recover from. That is the same
loss the merge exists to prevent, arriving through the writer instead.
The failure has to be INJECTED because the window is invisible from
outside: a write that completes closes it either way, so only interrupting
one distinguishes "wrote elsewhere and renamed" from "truncated in place".
The injection point is ``ConfigParser.write`` mid-output, which is where a
real ENOSPC or SIGKILL would land.
Two legs. The file is unchanged — the previous settings survive the failed
attempt in full. And nothing is left behind: writing to a temporary file is
only an improvement if the failed attempt cleans up after itself, otherwise
the directory accumulates partial files that a later reader could mistake
for the real one.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "[default]\nsecurity_level = normal\ndb_mode = cache\n"
"http_channel_enabled = true\n\n[my_section]\nfoo = bar\n")
point_config(ini)
_boot(core)
before = ini.read_bytes()
def _partial_then_fail(self, fp, *args, **kwargs):
fp.write("[default]\nsecurity_level = nor")
raise OSError(28, "No space left on device")
monkeypatch.setattr(configparser.ConfigParser, "write", _partial_then_fail)
with pytest.raises(OSError):
_settings_change(core, "db_mode", "local")
after = ini.read_bytes()
assert after == before, (
"a settings write that failed mid-output changed config.ini (%s reader). "
"The failed attempt must not touch the target at all — write a temporary "
"file beside it and os.replace() it into place, so the file is either the "
"old one or the complete new one.\nbefore:\n%s\nafter:\n%s"
% (reader, before.decode("utf-8", "replace"), after.decode("utf-8", "replace"))
)
strays = sorted(p.name for p in tmp_path.iterdir() if p.name != "config.ini")
assert not strays, (
"the failed write left %s behind next to config.ini (%s reader). A "
"partial file in the settings directory outlives the failure and can be "
"picked up later; the writer must remove its temporary file when the "
"write does not complete." % (strays, reader)
)
# ---------------------------------------------------------------------------
# install-denial messages
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("const_name,flag_key", [
("SECURITY_MESSAGE_FLAG_GIT_URL", "allow_git_url_install"),
("SECURITY_MESSAGE_FLAG_PIP", "allow_pip_install"),
])
def test_flag_messages_state_both_conds(const_name, flag_key):
"""The denial message states BOTH gate conditions and
carries a ``{listen}`` placeholder for the live value.
Naming only the config flag would send a user to enable a flag that is
often already enabled, while the real blocker is the network-position half
of the predicate. The gate is
``flag AND (loopback OR network_mode == 'personal_cloud')`` — WIDER than
upstream's loopback-only, so BOTH alternatives must appear.
"""
consts = _message_constants(LEGACY_SERVER)
assert const_name in consts, "%s not found in %s" % (const_name, LEGACY_SERVER)
text = consts[const_name]
lowered = text.lower()
assert flag_key in text, (
"%s no longer names its flag %r — the e2e denial-log assertions "
"depend on it. text=%r" % (const_name, flag_key, text)
)
assert "{listen}" in text, (
"expected %s to carry a {listen} placeholder so every emission "
"can print the LIVE --listen value; observed none. text=%r"
% (const_name, text)
)
assert ("loopback" in lowered or "127.0.0.1" in text), (
"expected %s to state the loopback half of the gate "
"(flag AND (loopback OR personal_cloud)); observed a flag-only message. "
"text=%r" % (const_name, text)
)
assert "personal_cloud" in lowered, (
"expected %s to state the network_mode = personal_cloud "
"alternative — our predicate is WIDER than upstream's loopback-only "
", so omitting it makes the message contradict the gate. "
"text=%r" % (const_name, text)
)
def test_flag_message_emissions_format():
"""EVERY flag-message emission renders the live listen value.
There are 3 emission sites in legacy/manager_server.py
(the 404 arm of install_custom_node, plus the git_url and pip endpoints);
each must emit ``.format(listen=args.listen)``.
"""
emissions = _flag_message_emissions(LEGACY_SERVER)
assert len(emissions) == 3, (
"expected 3 flag-message emission sites in %s; found %d: %r"
% (LEGACY_SERVER, len(emissions), emissions)
)
unformatted = [(line, name) for line, name, formatted in emissions if not formatted]
assert not unformatted, (
"these flag-denial emissions do not render the live listen value "
"— expected SECURITY_MESSAGE_FLAG_*.format(listen=args.listen) at every "
"site, observed a bare constant at %r. A user reading the log cannot tell "
"which half of the gate denied them." % (unformatted,)
)
def test_flag_messages_drop_seclevel_copy():
"""The flag messages must NOT carry the security-level copy.
A guard. Two existing e2e tests assert this
substring is ABSENT from flag-denial logs
(tests/e2e/test_e2e_secgate_legacy_flags.py:471,
tests/e2e/test_e2e_pip_url_form.py:286); the rewrite must not trip them.
"""
consts = _message_constants(LEGACY_SERVER)
offenders = {
name: text
for name, text in consts.items()
if name.startswith("SECURITY_MESSAGE_FLAG_") and "security level to 'normal-'" in text
}
assert not offenders, (
"flag-denial message(s) %s carry the substring "
"\"security level to 'normal-'\", which the existing e2e suites assert is "
"ABSENT from the denial log. The dedicated flags are decoupled from "
"security_level, so the copy is also wrong." % sorted(offenders)
)
# ---------------------------------------------------------------------------
# stale 'middle' security_level
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("source_path", [
# ids are repo-RELATIVE on purpose: the absolute path differs per checkout,
# so an absolute id would give the same test a different node-id in every
# checkout and defeat node-id comparison across test runs.
pytest.param(LEGACY_SERVER, id=os.path.relpath(LEGACY_SERVER, REPO_ROOT)),
pytest.param(SHARED_MESSAGES, id=os.path.relpath(SHARED_MESSAGES, REPO_ROOT)),
])
def test_no_middle_security_level_value(source_path):
"""No message may quote ``'middle'`` as a security_level.
The message that motivated this row told the user to "set the security
level to 'middle' or 'weak'", but no such value exists (valid: strong /
normal / normal- / weak) — a user following that copy sets an unrecognised
value and stays denied. Both remaining definition sites are covered: the
shared module, and the per-flag messages that are still declared next to
the legacy gate. The internal gate-LEVEL tokens ``is_allowed_security_level
('middle')`` are NOT security_level values and are deliberately out of
scope — this row only inspects message CONSTANTS.
"""
consts = _message_constants(source_path)
assert consts, "no SECURITY_MESSAGE_* constants found in %s" % source_path
offenders = {name: text for name, text in consts.items() if "'middle'" in text}
assert not offenders, (
"message constant(s) %s in %s quote 'middle' as a security_level "
"value, which does not exist (valid: strong / normal / normal- / weak). "
"A user following this copy sets an unrecognised value and stays denied."
% (sorted(offenders), os.path.relpath(source_path, REPO_ROOT))
)
# ---------------------------------------------------------------------------
# Silent read failure — an existing config.ini that cannot be READ must not
# be mistaken for "no config yet"
# ---------------------------------------------------------------------------
#
# MECHANISM:
# configparser.ConfigParser.read() SWALLOWS OSError — "if a file named in
# filenames cannot be opened, that file will be ignored". On a chmod-0o000
# config.ini it returns [] and raises nothing, so write_config()'s narrow
# `except (configparser.Error, UnicodeDecodeError)` never fires, the parser
# is empty, has_section('default') is False, and the BOOTSTRAP branch runs:
# the file is rewritten from the startup snapshot and the settings call
# returns success. The user's config.ini is destroyed and nothing says so.
#
# The distinction these rows pin: an ABSENT file may legitimately be seeded
# (the absent-file row below is allowed to seed), an UNREADABLE file may NOT —
# because "unreadable" means
# the content is unknown, not that there is no content.
#: chmod cannot make a file unreadable to root, so the scenario is unbuildable
#: there. Skip rather than assert a false pass.
_IS_ROOT = getattr(os, "geteuid", lambda: -1)() == 0
_root_skip = pytest.mark.skipif(
_IS_ROOT or os.name != "posix",
reason="Making a config unreadable with chmod requires POSIX and a non-root user.",
)
#: WRITE-ONLY (0o200), deliberately NOT 0o000.
#:
#: MEASURED against the unguarded writer, and this is the whole reason these
#: rows are worth having: under 0o000 the scenario did NOT reproduce the defect.
#: read() still swallowed the error and the bootstrap branch still ran, but a
#: writer that truncates the target in place ALSO hit PermissionError, so the
#: settings change raised and the file survived — for a reason that had nothing
#: to do with a guard. Both assertions below would have passed against the
#: unguarded writer, so a change could have "fixed" nothing and still passed.
#:
#: 0o200 removes that confound: the read fails silently, the write is fully
#: permitted, and the file is destroyed with the call reporting success. Any
#: raise observed under 0o200 therefore comes from a DELIBERATE check, never
#: from the OS.
_UNREADABLE_MODE = 0o200
def _run_settings_change_on_unreadable(core, ini):
"""Boot readable -> make unreadable-but-writable -> settings change.
Returns ``(raised, before_bytes, after_bytes)``. Permissions are ALWAYS
restored, including on failure, so tmp_path teardown can clean up.
"""
_boot(core) # startup snapshot, taken while readable
before = ini.read_bytes()
os.chmod(ini, _UNREADABLE_MODE) # perms tightened while the server is up
raised = None
try:
_settings_change(core, "db_mode", "local")
except Exception as exc: # noqa: BLE001 — capturing IS the assertion
raised = exc
finally:
os.chmod(ini, 0o600) # restore before asserting, always
return raised, before, ini.read_bytes()
@_root_skip
def test_unreadable_config_is_loud(reader_core, point_config, tmp_path):
"""A settings write against an UNREADABLE config.ini must
fail LOUDLY rather than silently rewriting it.
Without the guard the call returns success: read() ignores the
PermissionError, the empty parser routes into the bootstrap branch, and the
user is told nothing while their file is replaced by defaults. This row
asserts the raise that closes that path.
The file is write-only (0o200), not 0o000 — see ``_UNREADABLE_MODE``. Under
0o000 this row passes against the UNFIXED code, because the OS blocks the
destroying write for reasons unrelated to any guard.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "[default]\nsecurity_level = normal\ndb_mode = cache\n"
"http_channel_enabled = true\n\n[my_section]\nfoo = bar\n")
point_config(ini)
raised, before, after = _run_settings_change_on_unreadable(core, ini)
assert raised is not None, (
"expected the settings change to RAISE on an unreadable "
"config.ini; it returned success (%s reader). The file was %s. "
"config.read() swallows the PermissionError, so the empty parser falls "
"into the bootstrap branch and rewrites the file from the startup "
"snapshot — silent data loss.\nbefore:\n%s\nafter:\n%s"
% (reader,
"DESTROYED" if after != before else "left intact",
before.decode("utf-8", "replace"),
after.decode("utf-8", "replace"))
)
@_root_skip
def test_unreadable_config_file_intact(reader_core, point_config, tmp_path):
"""The unreadable config.ini must be left BYTE-UNTOUCHED.
Separate node from the loudness row on purpose: "was it loud" and "was
the file preserved" are independent failures, and a fix that only logs a
warning satisfies loudness while still destroying the file. Keeping them
apart means neither can be satisfied by sacrificing the other.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
_write_ini(ini, "[default]\nsecurity_level = normal\ndb_mode = cache\n"
"http_channel_enabled = true\n\n[my_section]\nfoo = bar\n")
point_config(ini)
_raised, before, after = _run_settings_change_on_unreadable(core, ini)
assert after == before, (
"the unreadable config.ini was MODIFIED by a settings change "
"(%s reader) — an unreadable file's content is UNKNOWN, so rewriting it "
"destroys data the process never saw. Hand-maintained keys and foreign "
"sections are gone.\nbefore:\n%s\nafter:\n%s"
% (reader,
before.decode("utf-8", "replace"),
after.decode("utf-8", "replace"))
)
def test_genuine_bootstrap_still_seeds(reader_core, point_config, tmp_path):
"""A genuinely ABSENT config.ini must still be seeded.
The guard that keeps the read-failure check honest: it must distinguish
"no file, so there is nothing to lose" from "unreadable file, so the
content is unknown". A check that refuses BOTH breaks first-launch
bootstrap; this row fails if it does.
test_bootstrap_seeds_owned_keys already covers the seeding itself; what
this row adds is the CONTRAST — it is the absent-file half of the same
decision the two unreadable-file rows pin the other half of, so a future
reader sees why one branch may write and the other may not.
"""
reader, core = reader_core
ini = tmp_path / "config.ini"
point_config(ini)
assert not os.path.exists(ini), "precondition: config.ini must be absent"
_boot(core)
try:
core.write_config()
except Exception as exc: # noqa: BLE001 — the point of the row
pytest.fail(
"genuine bootstrap (absent config.ini) raised %s on the "
"%s reader: %s. The read-failure guard must refuse UNREADABLE files "
"without refusing ABSENT ones."
% (type(exc).__name__, reader, exc)
)
assert os.path.exists(ini), (
"bootstrap did not create config.ini (%s reader)" % reader
)
section = _parse_disk(ini)["default"]
missing = [k for k in OWNED_KEYS[reader] if k not in section]
assert not missing, (
"bootstrap seeded an incomplete [default] for the %s reader "
"— missing %s.\nfile:\n%s"
% (reader, missing, _read_raw(ini))
)
+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
+189
View File
@@ -0,0 +1,189 @@
"""Exercise Manager's search overrides with the shipped TurboGrid in Chromium.
Browser cases require Playwright and its Chromium install. All requests are
intercepted locally; no ComfyUI server or external services are used.
"""
import os
from pathlib import Path
import pytest
JS = Path(os.environ.get("MANAGER_JS_DIR") or (Path(__file__).resolve().parent.parent / "comfyui_manager" / "js"))
@pytest.mark.parametrize("filename", ["custom-nodes-manager.js", "model-manager.js"])
def test_searching_managers_use_the_safe_grid(filename):
source = (JS / filename).read_text()
assert 'import ManagerGrid from "./manager-grid.js"' in source
assert "new ManagerGrid(container)" in source
assert 'from "./turbogrid.esm.js"' not in source
@pytest.fixture(scope="module")
def browser():
playwright = pytest.importorskip("playwright.sync_api")
with playwright.sync_playwright() as driver:
if not Path(driver.chromium.executable_path).exists():
pytest.skip("install Chromium with: playwright install chromium")
instance = driver.chromium.launch()
yield instance
instance.close()
@pytest.fixture
def page(browser):
page = browser.new_page()
def route(request):
name = request.request.url.rsplit("/", 1)[-1]
if name in {"manager-grid.js", "turbogrid.esm.js"}:
request.fulfill(path=str(JS / name), content_type="text/javascript")
elif request.request.url == "http://manager.test/":
request.fulfill(
body='<div id="grid" style="width:800px;height:300px"></div>',
content_type="text/html",
)
else:
request.abort()
page.route("**/*", route)
page.goto("http://manager.test/")
page.evaluate("""async () => {
const {default: ManagerGrid} = await import('/manager-grid.js');
window.grid = new ManagerGrid(document.getElementById('grid'));
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: []});
grid.render();
}""")
page.wait_for_function("window.grid.options !== undefined")
yield page
page.close()
@pytest.mark.parametrize("keyword", ["before", "img", "after"])
def test_highlighting_keeps_all_text_inert(page, keyword):
text = 'before <img src="/bad.png" onerror="window.__probe=1"> & after'
result = page.evaluate("""({text, keyword}) => {
const cell = document.createElement('div');
cell.id = 'cell';
cell.textContent = text;
document.body.append(cell);
grid.highlightKeywordsSync([cell], [keyword]);
return {text: cell.textContent, marks: [...cell.querySelectorAll('mark')].map(x => x.textContent),
images: cell.querySelectorAll('img').length};
}""", {"text": text, "keyword": keyword})
assert result == {"text": text, "marks": [keyword], "images": 0}
assert page.evaluate("window.__probe === undefined")
def test_keyword_order_continues_across_text_nodes(page):
result = page.evaluate("""() => {
const cell = document.createElement('div');
cell.innerHTML = '<b>Alpha</b> beta Alpha beta <svg><text>Alpha</text></svg><textarea>beta</textarea>';
grid.highlightKeywordsSync([cell], ['alpha', 'beta']);
return [...cell.querySelectorAll('mark')].map(x => x.textContent);
}""")
assert result == ["Alpha", "beta", "Alpha", "beta"]
def test_search_cache_does_not_execute_html(page):
result = page.evaluate("""() => {
const row = {description: '<img src="/bad.png" onerror="window.__probe=1"><b>Alpha</b> &amp; Beta'};
const matched = grid.highlightKeywordsFilter(row, ['description'], 'alpha & beta');
return {matched, text: row.tg_text_description, highlight: row.tg_highlight_description};
}""")
assert result == {"matched": True, "text": "Alpha & Beta", "highlight": True}
page.wait_for_timeout(50)
assert page.evaluate("window.__probe === undefined")
def test_search_preserves_order_columns_and_empty_query(page):
result = page.evaluate("""() => {
const row = {name: 'Alpha Beta', description: 'Gamma', missing: null, count: 0};
const columns = ['name', 'description', 'missing', 'count'];
const ordered = grid.highlightKeywordsFilter(row, columns, ' ALPHA beta ');
const reversed = grid.highlightKeywordsFilter(row, columns, 'beta alpha');
const acrossColumns = grid.highlightKeywordsFilter(row, columns, 'alpha gamma');
const zero = grid.highlightKeywordsFilter(row, columns, '0');
const empty = grid.highlightKeywordsFilter(row, columns, '');
return {ordered, reversed, acrossColumns, zero, empty,
flags: columns.map(column => row['tg_highlight_' + column])};
}""")
assert result == {"ordered": True, "reversed": False, "acrossColumns": False,
"zero": True, "empty": True, "flags": [None] * 4}
def test_custom_text_generator_and_cache_keys(page):
result = page.evaluate("""() => {
Object.assign(grid.options.highlightKeywords, {
textKey: 'text_', highlightKey: 'mark_',
textGenerator: (row, column) => row[column].label
});
const row = {name: {label: '<b>Model</b> &lt;Flux&gt;'}};
const matched = grid.highlightKeywordsFilter(row, ['name'], 'model <flux>');
return {matched, text: row.text_name, highlight: row.mark_name};
}""")
assert result == {"matched": True, "text": "Model <Flux>", "highlight": True}
def test_grid_render_search_and_clear_keep_names_readable(page):
page.evaluate("""() => {
window.query = 'flux';
grid.setOption({rowFilter: row => grid.highlightKeywordsFilter(row, ['name'], window.query)});
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: [
{name: 'Model &lt;Flux&gt; &lt;img src=x onerror=window.__probe=1&gt;'},
{name: 'Other Model'}
]});
grid.render();
}""")
page.wait_for_function("document.querySelectorAll('#grid mark').length === 1")
assert page.locator("#grid mark").all_text_contents() == ["Flux"]
assert page.evaluate("grid.viewRows.length") == 1
assert page.locator("#grid img").count() == 0
assert 'Model <Flux> <img src=x onerror=window.__probe=1>' in page.locator("#grid").inner_text()
page.evaluate("window.query = 'other'; grid.update()")
page.wait_for_function("document.querySelector('#grid mark')?.textContent === 'Other'")
assert page.evaluate("grid.viewRows.length") == 1
page.evaluate("window.query = ''; grid.update()")
page.wait_for_function("grid.viewRows.length === 2 && !document.querySelector('#grid mark')")
assert page.locator("#grid img").count() == 0
assert page.evaluate("window.__probe === undefined")
@pytest.mark.parametrize("filename,column,value,query,nonmatching", [
("custom-nodes-manager.js", "title", "Pack &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("custom-nodes-manager.js", "description", "Notes &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("custom-nodes-manager.js", "author", "Writer &lt;Alias&gt;", "&lt;alias&gt;", "<alias>"),
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "&lt;alias&gt;"),
("model-manager.js", "name", "Model &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("model-manager.js", "description", "<b>Notes</b> &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("model-manager.js", "filename", "model &lt;Flux&gt;", "&lt;flux&gt;", "<flux>"),
("model-manager.js", "type", "Type <Flux>", "<flux>", "&lt;flux&gt;"),
])
def test_manager_search_matches_visible_text(page, filename, column, value, query, nonmatching):
source = (JS / filename).read_text()
init_body = source.split("\tinitGrid() {", 1)[1].split("\n\t}\n", 1)[0]
common = (JS / "common.js").read_text()
sanitizer = common.split("export function sanitizeHTML(str) {", 1)[1].split("\n}", 1)[0]
escape_cell = "undefined"
if filename == "model-manager.js":
escape_cell = source.split("const escapeCell = ", 1)[1].split(";", 1)[0]
page.evaluate("""({init_body, sanitizer, escape_cell, column, value, query}) => {
const sanitizeHTML = new Function('str', sanitizer);
const escapeCell = new Function('sanitizeHTML', `return (${escape_cell})`)(sanitizeHTML);
const initGrid = new Function('ManagerGrid', 'createFlyover', 'gridId', 'sanitizeHTML', 'escapeCell',
`return function initGrid() {${init_body}}`)(grid.constructor, () => ({}), 'review', sanitizeHTML, escapeCell);
const element = document.createElement('div');
element.innerHTML = '<div id="tested-grid" class="cn-manager-grid cmm-manager-grid" style="width:800px;height:300px"></div>';
document.body.append(element);
window.manager = {element, keywords: query, showStatus() {}, handleFlyoverHover() {}, createFlyover: () => ({}), hasAlternatives: () => false};
initGrid.call(manager);
manager.grid.setData({columns: [{id: column, name: column}], rows: [{[column]: value}]});
manager.grid.render();
}""", {"init_body": init_body, "sanitizer": sanitizer, "escape_cell": escape_cell,
"column": column, "value": value, "query": query})
page.wait_for_function("manager.grid.viewRows !== undefined")
assert page.evaluate("manager.grid.viewRows.length") == 1
page.evaluate("query => {manager.keywords=query;}", nonmatching)
assert not page.evaluate("manager.grid.options.rowFilter(manager.grid.data.rows[0])")
+269
View File
@@ -0,0 +1,269 @@
"""[goal265 step4 — RED] Dual-reader config contract for the dedicated
install flags ``allow_git_url_install`` / ``allow_pip_install``.
TARGET CONTRACT (NOT YET IMPLEMENTED — goal265-spec.md §3, LOCKED):
- Keys: ``allow_git_url_install``, ``allow_pip_install`` in
config.ini ``[default]``.
- BOTH readers carry the keys (read dict + write_config list +
exception-fallback dict — 3 anchors each):
* ``comfyui_manager/glob/manager_core.py``
* ``comfyui_manager/legacy/manager_core.py``
(dual-reader rule — the gated endpoints resolve through the LEGACY
reader; a glob-only registration would silently never reach the gates.)
- Only case-insensitive string "true" is truthy on read; anything else
(including "1", "yes") reads False.
- Missing key reads False — the shared ``get_bool(key, default)`` quirk:
the ``default`` param is IGNORED, missing key -> always False.
- Missing file / missing [default] section -> exception-fallback dict
returns False for both keys.
- Activation is restart-only (module-level ``cached_config``).
These tests are EXPECTED TO FAIL against current code (the keys are not
registered in either reader yet) — RED confirmation is goal265 Step 5.
SC rows covered (goal265-scenarios.md):
SC-19 missing keys -> False (both readers)
SC-20 malformed values -> only "true"/"TRUE"/"True" truthy (both readers)
SC-21 write_config round-trip persistence (both readers)
SC-22 cached_config staleness — restart-only activation (reader-level arm)
SC-29 no config.ini / no [default] section -> fallback False (both readers)
Fixtures (spec §4 binding): tmp config.ini + monkeypatch
``context.manager_config_path`` + per-reader ``cached_config`` reset.
"""
from __future__ import annotations
import os
import sys
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", "allow_flagged_nodepack_install")
READERS = ("glob", "legacy")
def _reset_cache(core) -> None:
"""Clear the reader's module-level cached_config (simulates restart)."""
setattr(core, "cached_config", None)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(params=READERS)
def reader_core(request):
"""Yield (reader_name, manager_core module) for BOTH readers, with the
module-level cached_config saved/cleared around each test so one test's
cache never leaks into another (or into other test modules)."""
core = import_reader(request.param)
saved = getattr(core, "cached_config", None)
_reset_cache(core)
yield request.param, core
setattr(core, "cached_config", saved)
@pytest.fixture
def point_config(monkeypatch):
"""Return a function that points context.manager_config_path at a path.
Both readers resolve ``context.manager_config_path`` at CALL time
(``config.read(context.manager_config_path)``), so monkeypatching the
context attribute redirects glob AND legacy alike."""
ctx = import_context()
def _point(path):
monkeypatch.setattr(ctx, "manager_config_path", str(path))
return _point
def _write_ini(tmp_path, body: str):
ini = tmp_path / "config.ini"
ini.write_text(body, encoding="utf-8")
return ini
# ---------------------------------------------------------------------------
# SC-19 — missing keys read False (get_bool quirk: missing key -> False)
# ---------------------------------------------------------------------------
def test_sc19_missing_keys_read_false(reader_core, point_config, tmp_path):
"""SC-19: config.ini WITHOUT either new key -> read_config returns
allow_git_url_install == False AND allow_pip_install == False.
This is the observable manifestation of the shared ``get_bool(key,
default)`` quirk (spec §3): the ``default`` parameter is IGNORED —
a missing key always reads False, never the passed default. The quirk
is documented, NOT fixed, by this GOAL (spec §5 freeze item 9)."""
reader, core = reader_core
point_config(_write_ini(tmp_path, "[default]\nsecurity_level = normal\n"))
cfg = core.read_config()
for key in FLAG_KEYS:
assert key in cfg, (
f"RED: {reader} read_config() does not register {key!r} "
"(goal265-spec.md §3 reader registration)"
)
assert cfg[key] is False, f"{reader}: missing {key} must read False"
# ---------------------------------------------------------------------------
# SC-20 — malformed values: only case-insensitive "true" is truthy
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("raw_value", "expected"),
[
("true", True),
("TRUE", True),
("True", True),
("1", False),
("yes", False),
("false", False),
("garbage", False),
],
ids=["true", "TRUE", "True", "1", "yes", "false", "garbage"],
)
@pytest.mark.parametrize("flag_key", FLAG_KEYS)
def test_sc20_only_case_insensitive_true_is_truthy(
reader_core, point_config, tmp_path, flag_key, raw_value, expected):
"""SC-20: key present with malformed values -> only case-insensitive
"true" reads True; "1"/"yes"/"false"/"garbage" read False (both flags,
both readers)."""
reader, core = reader_core
point_config(_write_ini(
tmp_path,
f"[default]\nsecurity_level = normal\n{flag_key} = {raw_value}\n",
))
cfg = core.read_config()
assert flag_key in cfg, (
f"RED: {reader} read_config() does not register {flag_key!r}"
)
assert cfg[flag_key] is expected, (
f"{reader}: {flag_key} = {raw_value!r} must read {expected}"
)
# ---------------------------------------------------------------------------
# SC-21 — write_config round-trip (write list registration, both writers)
# ---------------------------------------------------------------------------
def test_sc21_write_config_round_trips_both_flags(
reader_core, point_config, tmp_path):
"""SC-21: both flags true in cached config -> write_config -> reset the
reader's cached_config -> read_config -> both keys persist as True.
Failure mode this guards: keys registered in the read dict but NOT in
the write_config persistence list — the value would silently drop on
the next config save (spec §2 F3/F4 dual-anchor rule, residual risk R2)."""
reader, core = reader_core
ini = _write_ini(
tmp_path,
"[default]\nsecurity_level = normal\n"
"allow_git_url_install = true\nallow_pip_install = true\n"
"allow_flagged_nodepack_install = true\n",
)
point_config(ini)
loaded = core.get_config()
for key in FLAG_KEYS:
assert loaded.get(key) is True, (
f"RED: {reader} get_config() did not load {key!r}=True "
"from staged config.ini"
)
core.write_config()
raw = ini.read_text(encoding="utf-8")
for key in FLAG_KEYS:
assert key in raw, (
f"{reader}: write_config() dropped {key!r} — key missing from "
"the write_config persistence list (spec §3 reader registration)"
)
_reset_cache(core) # per-reader cache reset REQUIRED before re-read
cfg = core.read_config()
for key in FLAG_KEYS:
assert cfg.get(key) is True, (
f"{reader}: {key} did not survive the write/read round-trip"
)
# ---------------------------------------------------------------------------
# SC-22 — cached_config staleness: restart-only activation (reader arm)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("flag_key", FLAG_KEYS)
def test_sc22_flag_edit_without_restart_stays_stale(
reader_core, point_config, tmp_path, flag_key):
"""SC-22: process running with cached flag=false -> config.ini edited to
flag=true WITHOUT restart -> the reader still serves False (module-level
cached_config; identical semantics to security_level today, MM §1.5).
Clearing the cache (= restart) then serves True."""
reader, core = reader_core
ini = _write_ini(
tmp_path,
f"[default]\nsecurity_level = normal\n{flag_key} = false\n",
)
point_config(ini)
first = core.get_config()
assert first.get(flag_key) is False, (
f"RED: {reader} get_config() does not register {flag_key!r}"
)
# Edit config.ini on disk — NO cache reset (no restart).
_write_ini(
tmp_path,
f"[default]\nsecurity_level = normal\n{flag_key} = true\n",
)
stale = core.get_config()
assert stale.get(flag_key) is False, (
f"{reader}: {flag_key} hot-reloaded — activation MUST be "
"restart-only (cached_config, spec §3)"
)
_reset_cache(core) # simulate restart
fresh = core.get_config()
assert fresh.get(flag_key) is True, (
f"{reader}: {flag_key}=true not visible after cache reset (restart)"
)
# ---------------------------------------------------------------------------
# SC-29 — exception-fallback path carries the new keys as False
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("breakage", ["missing_file", "no_default_section"])
def test_sc29_fallback_path_returns_false_for_both_flags(
reader_core, point_config, tmp_path, breakage):
"""SC-29: NO config.ini present / config.ini WITHOUT [default] section ->
read_config's exception-fallback dict returns False for BOTH new keys
(fallback dicts are the third registration anchor per reader,
spec §2 F3/F4)."""
reader, core = reader_core
if breakage == "missing_file":
point_config(tmp_path / "does-not-exist" / "config.ini")
else:
point_config(_write_ini(tmp_path, "[other]\nsomething = 1\n"))
cfg = core.read_config()
for key in FLAG_KEYS:
assert key in cfg, (
f"RED: {reader} exception-fallback dict does not carry {key!r} "
"(goal265-spec.md §3 'Reader registration' — fallback anchor)"
)
assert cfg[key] is False, (
f"{reader}: fallback {key} must be False (secure by default)"
)
+256
View File
@@ -0,0 +1,256 @@
"""[goal265 step4] Out-of-scope GUARD tests + structural frontend-copy
assertion for the dedicated install flags GOAL.
Two populations in this module (intentionally different RED/GREEN states):
1. GUARD rows (SC-25C, SC-26, SC-27, SC-28) — regression-intent '=' rows:
they assert the out-of-scope FREEZE (goal265-spec.md §5) and are expected
to PASS both TODAY and AFTER Step 6. A failure at any point means the
implementation leaked outside the locked scope.
2. SC-23 frontend arm — Δ row, EXPECTED TO FAIL TODAY (RED): the
js/common.js 403 error copy must name the responsible flag after Step 6
(spec §1.4, Q2 in scope). Do NOT weaken it to pass against today's code.
Placement per spec §4 rows 4-5 ('existing glob suite extension or unit' /
'structural — copy-string assertion'): authored as a NEW unit/structural
module so the pre-existing suite stays untouched (Step-4 acceptance bar (d));
layout follows the Step-2 hand-off recommendation
(goal265-scenarios.md §6: test_install_flags_guards.py).
Functional rows import the GLOB copy of the gate matrix
(comfyui_manager/glob/utils/security_utils.py) with the lightweight runtime
stubs from tests/_install_flags_testutil.py; the legacy copy of the same
matrix is exercised end-to-end by tests/e2e/test_e2e_secgate_legacy_flags.py
(SC-25A/B) under a real server.
"""
from __future__ import annotations
import os
import re
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _install_flags_testutil import ( # noqa: E402
REPO_ROOT,
import_glob_security_utils,
)
GLOB_SERVER_PATH = os.path.join(
REPO_ROOT, "comfyui_manager", "glob", "manager_server.py")
CM_CLI_PATH = os.path.join(REPO_ROOT, "cm_cli", "__main__.py")
COMMON_JS_PATH = os.path.join(REPO_ROOT, "comfyui_manager", "js", "common.js")
FLAG_KEYS = ("allow_git_url_install", "allow_pip_install")
def _read(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()
def _extract_js_function(source: str, name: str) -> str:
"""Slice an `export async function <name>` block out of common.js
(up to the next exported/function declaration or EOF)."""
m = re.search(
rf"export\s+async\s+function\s+{re.escape(name)}\b.*?"
r"(?=\nexport\s|\nfunction\s|\Z)",
source,
re.DOTALL,
)
assert m, f"could not locate function {name!r} in js/common.js"
return m.group(0)
# ---------------------------------------------------------------------------
# SC-27 — glob route-table absence (guard, '=')
# ---------------------------------------------------------------------------
def test_sc27_glob_registers_no_git_url_or_pip_install_route():
"""SC-27 (guard): glob/manager_server.py must register NO
/v2/customnode/install/git_url or /v2/customnode/install/pip route —
no new glob HTTP surface under this GOAL (Q4 settled; spec §5 item 5).
Structural: route-decorator scan of the glob server source."""
src = _read(GLOB_SERVER_PATH)
for path in ("/v2/customnode/install/git_url",
"/v2/customnode/install/pip"):
pattern = rf"routes\.(post|get|put|delete)\(\s*[\"']{re.escape(path)}[\"']"
assert not re.search(pattern, src), (
f"SC-27: glob manager_server registers {path} — Q4 forbids a "
"new glob surface for these capabilities under this GOAL"
)
# ---------------------------------------------------------------------------
# SC-26 — cm-cli path stays ungated (guard, '=')
# ---------------------------------------------------------------------------
def test_sc26_cm_cli_install_path_has_no_gate():
"""SC-26 (guard): the cm-cli install path (install_node ->
core.gitclone_install, cm_cli/__main__.py) is a local operator tool —
it must consult NEITHER the security_level gate NOR the new flags
(spec §5 item 4: cm-cli untouched). Static source assertion — no clone
is executed."""
src = _read(CM_CLI_PATH)
assert "is_allowed_security_level" not in src, (
"SC-26: cm_cli/__main__.py grew a security_level gate — cm-cli "
"must stay ungated under this GOAL"
)
for key in FLAG_KEYS:
assert key not in src, (
f"SC-26: cm_cli/__main__.py consults {key!r} — the dedicated "
"flags must not gate the local operator CLI"
)
# ---------------------------------------------------------------------------
# SC-25C — glob comfyui_switch_version stays high+ security_level-coupled
# (guard, '=') — structural arm
# ---------------------------------------------------------------------------
def test_sc25c_glob_switch_version_keeps_high_plus_gate():
"""SC-25C (guard, structural arm): the glob comfyui_switch_version
handler keeps its is_allowed_security_level('high+') guard and does not
consult the new flags (spec §5 item 1). The functional arm (high+ denies
at sl=normal regardless of flags) is test_sc25c_sc28_* below."""
src = _read(GLOB_SERVER_PATH)
m = re.search(
r"@routes\.post\(\s*[\"']/v2/comfyui_manager/comfyui_switch_version[\"']\s*\)"
r".*?(?=\n@routes\.|\Z)",
src,
re.DOTALL,
)
assert m, "SC-25C: comfyui_switch_version route not found in glob server"
handler = m.group(0)
assert re.search(
r"is_allowed_security_level\(\s*[\"']high\+[\"']", handler), (
"SC-25C: comfyui_switch_version no longer carries the "
"is_allowed_security_level('high+') guard — out-of-scope gate "
"changed (spec §5 freeze item 1)"
)
for key in FLAG_KEYS:
assert key not in handler, (
f"SC-25C: comfyui_switch_version consults {key!r} — the new "
"flags must not affect this surface"
)
# ---------------------------------------------------------------------------
# SC-28 (+ SC-25C functional arm) — security_level matrix untouched
# (guard, '=')
# ---------------------------------------------------------------------------
# Frozen truth table of is_allowed_security_level at loopback listen,
# network_mode=public (verified against legacy/manager_server.py:97-122 and
# glob/utils/security_utils.py:22-48 as of base 01799f8c). The new flags
# must leave every cell unchanged.
_EXPECTED_MATRIX = {
# (level, security_level): allowed
("block", "weak"): False,
("block", "normal"): False,
("high+", "weak"): True,
("high+", "normal-"): True,
("high+", "normal"): False,
("high+", "strong"): False,
("high", "weak"): True,
("high", "normal-"): True,
("high", "normal"): False,
("high", "strong"): False,
("middle+", "weak"): True,
("middle+", "normal-"): True,
("middle+", "normal"): True,
("middle+", "strong"): False,
("middle", "weak"): True,
("middle", "normal-"): True,
("middle", "normal"): True,
("middle", "strong"): False,
}
@pytest.mark.parametrize("flags_value", [True, False],
ids=["flags-on", "flags-off"])
def test_sc25c_sc28_security_level_matrix_unchanged_by_flags(
monkeypatch, flags_value):
"""SC-28 (guard): the is_allowed_security_level truth table for the
non-target levels (middle/middle+/high/high+/block) is byte-identical
whether both new flags are true or false — security_level semantics
untouched (spec §5 item 2; MM §3).
SC-25C (functional arm): the ('high+', 'normal') -> False cell is the
switch_version deny — it must hold for BOTH flag combinations.
Exercises the GLOB copy of the matrix (importable without a ComfyUI
runtime); config access is monkeypatched at the consumer module so no
real config.ini is read."""
su = import_glob_security_utils()
fake_config = {
"network_mode": "public",
"allow_git_url_install": flags_value,
"allow_pip_install": flags_value,
}
current_level = {"value": "normal"}
def fake_get_config():
return {**fake_config, "security_level": current_level["value"]}
monkeypatch.setattr(su.core, "get_config", fake_get_config)
monkeypatch.setattr(su.args, "listen", "127.0.0.1")
observed = {}
for (level, sl) in _EXPECTED_MATRIX:
current_level["value"] = sl
observed[(level, sl)] = su.is_allowed_security_level(level)
mismatches = {
cell: (got, _EXPECTED_MATRIX[cell])
for cell, got in observed.items()
if got is not _EXPECTED_MATRIX[cell]
}
assert not mismatches, (
f"SC-28(flags={flags_value}): security_level matrix drifted — "
f"{{cell: (got, expected)}} = {mismatches!r}. The dedicated-flag "
"GOAL must not alter is_allowed_security_level semantics."
)
# ---------------------------------------------------------------------------
# SC-23 frontend arm — js/common.js error copy names the flag (Δ — RED today)
# ---------------------------------------------------------------------------
class TestSc23FrontendCopy:
"""SC-23 (frontend arm, structural — spec §1.4 / Q2 in scope): on a
non-200 install response, the user-visible copy must name the
responsible flag, replacing the 'security level' framing.
Copy-string assertion on js/common.js — not an executed-browser test
(goal265-scenarios.md §6 note). EXPECTED TO FAIL TODAY (RED)."""
def test_sc23_install_pip_error_copy_names_pip_flag(self):
"""install_pip error branch (~:213 call site) names
allow_pip_install and drops the security-level framing."""
block = _extract_js_function(_read(COMMON_JS_PATH), "install_pip")
assert "allow_pip_install" in block, (
"SC-23: js/common.js install_pip error copy does not name "
"allow_pip_install (spec §1.4 frontend copy contract)"
)
assert "security level" not in block.lower(), (
"SC-23: js/common.js install_pip still carries the misleading "
"'security level' framing"
)
def test_sc23_install_git_url_error_copy_names_git_flag(self):
"""install_via_git_url error branch (~:248 call site) names
allow_git_url_install and drops the security-level framing."""
block = _extract_js_function(
_read(COMMON_JS_PATH), "install_via_git_url")
assert "allow_git_url_install" in block, (
"SC-23: js/common.js install_via_git_url error copy does not "
"name allow_git_url_install (spec §1.4 frontend copy contract)"
)
assert "security level" not in block.lower(), (
"SC-23: js/common.js install_via_git_url still carries the "
"misleading 'security level' framing"
)
+340
View File
@@ -0,0 +1,340 @@
"""[GOAL #347] Non-e2e unit regression guards for the OTHER legacy
``security_level`` gate paths in ``comfyui_manager/legacy/manager_server.py``.
CONTEXT (GOAL #346 audit): the dedicated-install-flag work (git_url / pip
endpoints) gained unit + e2e coverage, but the OTHER legacy gate sites — the
ones that still gate on ``is_allowed_security_level(level)`` — had NO non-e2e
coverage; only ``tests/e2e/`` exercised them, and only behind a real-server
harness (E2E_ROOT). These guards close that gap WITHOUT a real server and
WITHOUT modifying production code.
PATHS GUARDED (the OTHER security_level gates):
P1 middle+ batch-install entry gate — ``_install_custom_node`` (L1441)
P2 unknown-pip 'block' unconditional — ``_install_custom_node`` risky
deny branch (``is_allowed_security_level
('block')`` is hard-False; Q1)
P3 nodepack/snapshot high+/middle — ``comfyui_switch_version`` (high+),
gates ``restore_snapshot`` (middle+)
P4 model non-.safetensors high+ gate — ``_install_model`` (L1713)
NON-VACUITY (the bar from the GOAL MM): every assertion below is written so
that WEAKENING the underlying gate would FLIP the test result —
* the decision-table tests drive the REAL ``is_allowed_security_level`` and
pin the exact allow/deny truth for the level each path consumes; changing
that function's logic for any level flips a row;
* the response-shape tests invoke the REAL async handlers and assert the
observable status (403/404/200) — each pairs a DENY case with an opposite
(allow / flip) case proving the gate, not a constant, is what decides.
A guard that merely imports or asserts a constant is explicitly avoided.
ISOLATION: reuses the ``tests/_install_flags_testutil.py`` stub approach
(``ensure_comfy_stubs`` + ``_purge_fake_comfyui_manager``) and adds the
minimal extra stubs ``manager_server`` needs at import (``server.PromptServer``
route table, ``nodes``, ``folder_paths.__file__`` / ``get_folder_paths``), plus
an inert ``manager_util.get_data`` so the module-level cache-warmup thread does
no network I/O. No production code is touched.
"""
from __future__ import annotations
import asyncio
import os
import sys
import tempfile
import types
import pytest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _install_flags_testutil import ( # noqa: E402
ensure_comfy_stubs,
_purge_fake_comfyui_manager,
)
_LEGACY_SERVER = None
def _import_legacy_server():
"""Import ``comfyui_manager.legacy.manager_server`` under minimal stubs
(idempotent; memoized). Returns the real module object so the tests drive
the REAL gate function and REAL handlers — only the runtime *environment*
is stubbed, never the gate logic."""
global _LEGACY_SERVER
if _LEGACY_SERVER is not None:
return _LEGACY_SERVER
ensure_comfy_stubs()
_purge_fake_comfyui_manager()
# folder_paths: manager_server reads ``__file__`` and ``get_folder_paths``
# at import (context.comfy_path derivation + check_invalid_nodes).
fp = sys.modules["folder_paths"]
if not hasattr(fp, "__file__"):
fp.__file__ = os.path.join(tempfile.mkdtemp(prefix="cm-fp-"), "folder_paths.py")
if not hasattr(fp, "get_folder_paths"):
fp.get_folder_paths = lambda *a, **k: []
# nodes: imported but only used inside handlers we don't reach.
sys.modules.setdefault("nodes", types.ModuleType("nodes"))
# server.PromptServer.instance.routes: the @routes.post/.get decorators run
# at import. An identity-decorator route table lets every handler register
# as a plain module-level function without a real aiohttp app.
if "server" not in sys.modules:
server = types.ModuleType("server")
class _Routes:
def _identity(self, *a, **k):
def deco(fn):
return fn
return deco
post = get = put = delete = _identity
class _Inst:
routes = _Routes()
class PromptServer:
instance = _Inst()
server.PromptServer = PromptServer
sys.modules["server"] = server
# Make the module-level cache-warmup thread (manager_server L~1990
# ``threading.Thread(... default_cache_update ...)``) inert — no network.
import importlib
mu = importlib.import_module("comfyui_manager.common.manager_util")
async def _inert_get_data(uri, *a, **k):
return {"custom_nodes": [], "models": [], "node_map": {}}
mu.get_data = _inert_get_data
_LEGACY_SERVER = importlib.import_module("comfyui_manager.legacy.manager_server")
return _LEGACY_SERVER
@pytest.fixture(scope="module")
def ms():
return _import_legacy_server()
@pytest.fixture
def gate(ms, monkeypatch):
"""Return a function ``set_gate(security_level, network_mode='public',
is_local_mode=True)`` that drives the gate inputs.
Builds a COMPLETE config dict off the reader's real fallback so any
concurrent background reader (the warmup thread) never KeyErrors, then
overrides only ``security_level`` / ``network_mode``. ``is_local_mode`` is
the module global the gate consults. monkeypatch auto-restores."""
base = dict(ms.core.read_config())
def set_gate(security_level, network_mode="public", is_local_mode=True):
cfg = dict(base)
cfg["security_level"] = security_level
cfg["network_mode"] = network_mode
monkeypatch.setattr(ms.core, "get_config", lambda: dict(cfg))
monkeypatch.setattr(ms, "is_local_mode", is_local_mode)
return set_gate
def _run(coro):
return asyncio.run(coro)
class FakeRequest:
"""Minimal stand-in for an aiohttp request. ``content_type`` satisfies
``reject_simple_form_post``; nothing else is read on the deny path (the
gate returns before any body access)."""
def __init__(self, content_type="application/json"):
self.content_type = content_type
# ===========================================================================
# Decision-table guards — drive the REAL is_allowed_security_level for the
# exact level each OTHER path consumes. Non-vacuous: weakening the function
# for any level flips a row.
# ===========================================================================
# (security_level, is_local_mode, network_mode, expected_allowed)
_MIDDLE_PLUS = [ # P1 batch entry, _update_all, _install_model entry, restore_snapshot
("weak", True, "public", True),
("normal", True, "public", True),
("normal-", True, "public", True),
("strong", True, "public", False),
("weak", False, "public", False), # public non-loopback -> denied
("normal", False, "public", False),
("weak", False, "personal_cloud", True), # personal_cloud arm of middle+
("strong", False, "personal_cloud", False),
]
_HIGH_PLUS = [ # P3 comfyui_switch_version, P4 model non-.safetensors sub-check
("weak", True, "public", True),
("normal-", True, "public", True),
("normal", True, "public", False),
("strong", True, "public", False),
("weak", False, "public", False),
("weak", False, "personal_cloud", True),
("normal-", False, "personal_cloud", False), # personal_cloud high+ needs weak
]
_MIDDLE = [ # P3 remove_snapshot and the other middle-gated legacy ops
("weak", True, "public", True),
("normal", True, "public", True),
("normal-", True, "public", True),
("strong", True, "public", False),
("normal", False, "public", True), # 'middle' ignores locality
("strong", False, "public", False),
]
_BLOCK_LEVELS = ["weak", "normal", "normal-", "strong"]
@pytest.mark.parametrize(("sl", "local", "nm", "expected"), _MIDDLE_PLUS)
def test_p1_middle_plus_decision(ms, gate, sl, local, nm, expected):
"""P1: the middle+ batch-install entry gate decision. is_allowed_security_level
('middle+') must allow iff sl in {weak,normal,normal-} AND
(loopback OR personal_cloud)."""
gate(sl, network_mode=nm, is_local_mode=local)
assert ms.is_allowed_security_level("middle+") is expected
@pytest.mark.parametrize("sl", _BLOCK_LEVELS)
@pytest.mark.parametrize("local", [True, False], ids=["loopback", "public"])
@pytest.mark.parametrize("nm", ["public", "personal_cloud"])
def test_p2_block_is_unconditional_deny(ms, gate, sl, local, nm):
"""P2: unknown-pip 'block' is an UNCONDITIONAL deny — is_allowed_security_level
('block') is False for EVERY security_level, locality, and network_mode
(Q1: the dedicated flags do not open it). If 'block' ever became
allowable, every row here flips."""
gate(sl, network_mode=nm, is_local_mode=local)
assert ms.is_allowed_security_level("block") is False
@pytest.mark.parametrize(("sl", "local", "nm", "expected"), _HIGH_PLUS)
def test_p3_high_plus_decision(ms, gate, sl, local, nm, expected):
"""P3 (high+ arm): comfyui_switch_version gate decision. Allow iff
(loopback AND sl in {weak,normal-}) OR (personal_cloud AND sl == weak)."""
gate(sl, network_mode=nm, is_local_mode=local)
assert ms.is_allowed_security_level("high+") is expected
@pytest.mark.parametrize(("sl", "local", "nm", "expected"), _MIDDLE)
def test_p3_middle_decision(ms, gate, sl, local, nm, expected):
"""P3 (middle arm): snapshot remove / middle-gated legacy ops. Allow iff
sl in {weak,normal,normal-} (locality-independent)."""
gate(sl, network_mode=nm, is_local_mode=local)
assert ms.is_allowed_security_level("middle") is expected
# ===========================================================================
# Response-shape guards — invoke the REAL async handlers; assert the observable
# status. Each pairs a DENY with an opposite case so the gate (not a constant)
# is provably what decides.
# ===========================================================================
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._queue_node_install({}, "install"))
assert resp.status == 403
def test_p1_install_custom_node_passes_entry_gate_at_normal(ms, gate, monkeypatch):
"""P1 allow-flip: at sl=normal the middle+ entry gate PASSES — with the
downstream risky level forced to an allowed value the handler reaches 200,
proving the 403 above came from the gate, not unconditionally."""
gate("normal", is_local_mode=True)
async def _risky_middle_plus(files, pip):
return "middle+"
monkeypatch.setattr(ms, "get_risky_level", _risky_middle_plus)
json_data = {
"version": "unknown",
"files": ["https://github.com/example/unknown-node.git"],
"pip": [],
"id": "x",
"ui_id": "u",
"channel": "default",
"mode": "cache",
}
resp = _run(ms._queue_node_install(dict(json_data), "install"))
assert resp.status == 200
def test_p2_unknown_pip_block_denies_404_even_at_weak(ms, gate, monkeypatch):
"""P2: with the risky level resolved to 'block' (unknown pip), the handler
returns 404 even at sl=weak (the most permissive level) — proving 'block'
is an unconditional deny, not a security_level-relative one."""
gate("weak", is_local_mode=True)
async def _risky_block(files, pip):
return "block"
monkeypatch.setattr(ms, "get_risky_level", _risky_block)
json_data = {
"version": "unknown",
"files": ["https://github.com/example/unknown-node.git"],
"pip": ["some-unknown-pkg"],
"id": "x",
"ui_id": "u",
"channel": "default",
"mode": "cache",
}
resp = _run(ms._queue_node_install(dict(json_data), "install"))
assert resp.status == 404
def test_p3_comfyui_switch_version_denies_high_plus_403(ms, gate):
"""P3 high+ handler: comfyui_switch_version denies with 403 at sl=normal
(high+ requires normal- or lower on loopback)."""
gate("normal", is_local_mode=True)
resp = _run(ms.comfyui_switch_version(FakeRequest()))
assert resp.status == 403
def test_p3_restore_snapshot_denies_middle_plus_403(ms, gate):
"""P3 middle+ handler: restore_snapshot denies with 403 at sl=strong
(middle+ requires normal or lower)."""
gate("strong", is_local_mode=True)
resp = _run(ms.restore_snapshot(FakeRequest()))
assert resp.status == 403
def test_p4_install_model_non_safetensors_denies_high_plus_403(ms, gate, monkeypatch):
"""P4 deny: a non-.safetensors model not on the whitelist requires high+;
at sl=normal (middle+ passes, high+ fails) the handler returns 403."""
gate("normal", is_local_mode=True)
async def _whitelist_ok(item):
return True
async def _models_empty(*a, **k):
return {"models": []}
monkeypatch.setattr(ms, "check_whitelist_for_model", _whitelist_ok)
monkeypatch.setattr(ms.core, "get_data_by_mode", _models_empty)
json_data = {"filename": "model.ckpt", "url": "http://example/model.ckpt", "ui_id": "u"}
resp = _run(ms._install_model(dict(json_data)))
assert resp.status == 403
def test_p4_install_model_safetensors_skips_high_plus_gate_200(ms, gate, monkeypatch):
"""P4 flip: a .safetensors model skips the non-.safetensors high+ sub-check
entirely and reaches 200 at the SAME sl=normal — proving the 403 above is
driven by the non-.safetensors + high+ compound, not the security level
alone."""
gate("normal", is_local_mode=True)
async def _whitelist_ok(item):
return True
monkeypatch.setattr(ms, "check_whitelist_for_model", _whitelist_ok)
json_data = {"filename": "model.safetensors", "url": "http://example/m.safetensors", "ui_id": "u"}
resp = _run(ms._install_model(dict(json_data)))
assert resp.status == 200
+114
View File
@@ -0,0 +1,114 @@
"""Exercise the full legacy populate_markdown path, including readable URLs."""
from html.parser import HTMLParser
import pytest
from legacy_html_testutil import load_renderer
RENDERER = load_renderer()
def compose(text):
item = {'description': text}
RENDERER['populate_markdown'](item)
return item['description']
class Elements(HTMLParser):
def __init__(self, markup):
super().__init__()
self.elements = []
self.text = ''
self.feed(markup)
self.close()
def handle_starttag(self, tag, attrs):
self.elements.append((tag, dict(attrs)))
def handle_data(self, text):
self.text += text
def anchor(markup):
parsed = Elements(markup)
assert [tag for tag, _ in parsed.elements] == ['a']
attrs = parsed.elements[0][1]
assert set(attrs) == {'href', 'target', 'rel'}
assert attrs['target'] == '_blank'
assert set(attrs['rel'].split()) == {'noopener', 'noreferrer'}
return attrs
@pytest.mark.parametrize('url', [
"' onmouseover='alert`1`", '" onclick="alert`1`',
'https://example.com/a\"><img src=x onerror=alert`1`>',
])
def test_attribute_breakout_is_inert(url):
assert anchor(compose(f'[a/link]({url})'))['href'] == url
@pytest.mark.parametrize('url', [
'javascript:alert`1`', 'JaVaScRiPt:alert`1`', 'data:text/html,evil',
'vbscript:msgbox`1`', 'ftp://host/file', 'mailto:user@example.com',
'java\tscript:alert`1`', 'java\nscript:alert`1`', '\x01javascript:alert`1`',
])
def test_disallowed_schemes_are_rejected(url):
assert anchor(compose(f'[a/link]({url})'))['href'] == '#'
@pytest.mark.parametrize('url,expected', [
('https://example.com/?q=<Flux>', 'https://example.com/?q=<Flux>'),
('https://example.com/?a=1&amp;b=2', 'https://example.com/?a=1&b=2'),
('https://example.com/?a=1&notebook=2&copy=3', 'https://example.com/?a=1&notebook=2&copy=3'),
('https://example.com/?q=&notit;', 'https://example.com/?q=&notit;'),
('https://example.com/?q=&#60;Flux&#x3e;', 'https://example.com/?q=<Flux>'),
('https://example.com/?q=&amp;lt;', 'https://example.com/?q=&lt;'),
('https://example.com/a**b**c', 'https://example.com/a**b**c'),
('https://example.com/%%white%%', 'https://example.com/%%white%%'),
('https://example.com/[w/note]', 'https://example.com/[w/note]'),
('https://example.com/a\nb', 'https://example.com/a\nb'),
('http://example.com/x', 'http://example.com/x'),
('./docs/readme.md', './docs/readme.md'), ('#section', '#section'), ('//host/path', '//host/path'),
('&amp;#106;avascript:alert`1`', '&#106;avascript:alert`1`'),
('&#106avascript:alert`1`', '&#106avascript:alert`1`'),
])
def test_href_survives_entity_decoding_and_formatting(url, expected):
assert anchor(compose(f'[a/link]({url})'))['href'] == expected
def test_labels_prose_and_notes_keep_their_formatting():
out = compose('<img src=x> [w/Read [a/**<Flux>**](https://example.com)]\n%%white%% [i/info]')
parsed = Elements(out)
assert [tag for tag, _ in parsed.elements] == ['p', 'a', 'b', 'br', 'font', 'p']
assert parsed.text == '<img src=x> Read <Flux>white info'
assert "class='cm-warn-note'" in out and "class='cm-info-note'" in out
@pytest.mark.parametrize('label', ['a <b> tag', 'a &lt;b&gt; tag', 'a\x00 <b> tag'])
def test_link_label_is_escaped_once(label):
out = compose(f'[a/{label}](https://example.com)')
anchor(out)
assert Elements(out).text == 'a <b> tag'
def test_source_cannot_forge_a_href_placeholder():
out = compose('\x00H0\x00 [a/link](https://example.com)')
assert out.count('https://example.com') == 1
assert anchor(out)['href'] == 'https://example.com'
def test_name_and_title_preserve_existing_server_contract():
item = {'name': 'Name <Flux>', 'title': 'Pack <Flux>'}
RENDERER['populate_markdown'](item)
assert item == {'name': 'Name &lt;Flux&gt;', 'title': 'Pack &lt;Flux&gt;'}
@pytest.mark.parametrize('url', [
'&#106;avascript:alert`1`', '&#0000106;avascript:alert`1`',
'&#x6A;avascript:alert`1`', '&#X6A;avascript:alert`1`',
'ja&#118;ascript:alert`1`', 'jav&#x61;script:alert`1`',
'&#100;ata:text/html;base64,PHNjcmlwdD4=', 'java&Tab;script:alert`1`',
'&#118;bscript:msgbox`1`',
])
def test_entity_obfuscated_schemes_are_rejected(url):
assert anchor(compose(f'[a/link]({url})'))['href'] == '#'
+134
View File
@@ -0,0 +1,134 @@
"""Exercise the legacy notice sanitizer and its endpoint without network access."""
from __future__ import annotations
import asyncio
import re
from html.parser import HTMLParser
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from aiohttp import web
import pytest
from legacy_html_testutil import PACKAGE, load_functions, load_renderer
sanitize = load_renderer()['html_utils'].sanitize_html_fragment
class _Tags(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.tags = []
def handle_starttag(self, tag, attrs):
self.tags.append((tag, dict(attrs)))
def _tags(html):
p = _Tags()
p.feed(html)
p.close()
return p.tags
@pytest.mark.parametrize("payload", [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'<a href="javascript:alert(1)">x</a>',
'<a href="&#106;avascript:alert(1)">x</a>',
'<a href="java&Tab;script:alert(1)">x</a>',
'<svg onload=alert(1)></svg>',
'<iframe src="//evil"></iframe>',
'<p onclick="alert(1)">x</p>',
'<details open ontoggle=alert(1)>x</details>',
'<style>*{background:url(//evil)}</style>',
])
def test_script_bearing_markup_is_removed(payload):
out = sanitize(payload)
for tag, attrs in _tags(out):
assert tag not in ("script", "svg", "iframe", "style"), out
assert not any(k.startswith("on") for k in attrs), out
for k in ("href", "src"):
v = attrs.get(k)
assert v is None or not v.lower().lstrip().startswith(("javascript", "data", "vbscript")), out
# Script-bearing markup must be gone entirely, as tags AND as raw text.
assert "<script" not in out.lower()
assert "onerror" not in out.lower() and "onload" not in out.lower() and "onclick" not in out.lower()
def test_script_content_is_dropped_not_escaped():
assert "alert" not in sanitize('<p>a<script>alert(1)</script>b</p>')
assert sanitize('<p>a<script>alert(1)</script>b</p>') == "<p>ab</p>"
def test_formatting_markup_survives():
src = '<h2>News</h2><p>See <a href="https://example.com/x?a=1&amp;b=2" title="t">docs</a> and <b>bold</b><br>line</p><ul><li>one</li></ul>'
out = sanitize(src)
parsed = _tags(out)
assert [tag for tag, _ in parsed] == ['h2', 'p', 'a', 'b', 'br', 'ul', 'li']
link = next(attrs for tag, attrs in parsed if tag == 'a')
assert link == {'href': 'https://example.com/x?a=1&b=2', 'title': 't',
'target': '_blank', 'rel': 'noopener noreferrer'}
def test_text_nodes_are_escaped():
assert sanitize('a &lt;b&gt; c') == 'a &lt;b&gt; c'
assert sanitize('<p>1 < 2</p>') == '<p>1 &lt; 2</p>'
def test_unknown_harmless_tag_keeps_content():
assert sanitize('<custom-tag><em>x</em></custom-tag>') == '<em>x</em>'
def test_relative_and_https_urls_kept():
out = sanitize('<a href="/wiki/x">r</a><a href="https://a.b/c">s</a>')
hrefs = [attrs["href"] for tag, attrs in _tags(out) if tag == "a"]
assert hrefs == ["/wiki/x", "https://a.b/c"]
def test_script_body_ends_at_first_close_tag_like_a_browser():
# Script content is raw text up to the FIRST </script>, exactly as the
# browser parses it; what follows is ordinary text and stays visible.
assert sanitize('<script><script>x</script>y</script><i>z</i>') == 'y<i>z</i>'
@pytest.mark.parametrize('prefix', ['<embed>', '<svg/>', '<svg><script></svg>'])
def test_hidden_element_does_not_swallow_following_notice(prefix):
assert sanitize(prefix + '<p>after</p>') == '<p>after</p>'
@pytest.mark.parametrize('url', ['ftp://host/file', 'mailto:user@example.com', 'data:text/html,evil'])
def test_other_url_schemes_are_removed(url):
out = sanitize(f'<a href="{url}">link</a><img src="{url}">')
assert all('href' not in attrs and 'src' not in attrs for _, attrs in _tags(out))
def test_notice_layout_attributes_survive_without_inline_scripts_or_css():
out = sanitize('<font color="red">alert</font><p class="note" align="center" style="color:red">'
'<img src="https://example.com/a.png" width="40" height="20" onerror="boom()"></p>')
assert _tags(out) == [('font', {'color': 'red'}), ('p', {'class': 'note', 'align': 'center'}),
('img', {'src': 'https://example.com/a.png', 'width': '40', 'height': '20'})]
def test_notice_endpoint_sanitizes_remote_content_and_retains_local_version_footer():
response = MagicMock(status=200)
response.text = AsyncMock(return_value='<div class="markdown-body"><embed><p>News '
'<a href="https://example.com" onclick="boom()">link</a><script>boom()</script></p></div>')
session = MagicMock()
session.get.return_value.__aenter__ = AsyncMock(return_value=response)
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=session)
namespace = load_functions(PACKAGE / 'legacy/manager_server.py', {'get_notice'}, {
'routes': web.RouteTableDef(), 're': re, 'web': web,
'html_utils': load_renderer()['html_utils'],
'aiohttp': SimpleNamespace(ClientSession=lambda **kwargs: client, TCPConnector=lambda **kwargs: None),
'os': SimpleNamespace(environ={'__COMFYUI_DESKTOP_VERSION__': '1.0'}),
'core': SimpleNamespace(version_str='4.2.1'),
})
result = asyncio.run(namespace['get_notice'](None))
assert result.status == 200
assert 'News' in result.text and 'ComfyUI: 1.0 [Desktop]' in result.text and 'Manager: 4.2.1' in result.text
assert 'boom' not in result.text and '<embed' not in result.text
link = next(attrs for tag, attrs in _tags(result.text) if tag == 'a')
assert link == {'href': 'https://example.com', 'target': '_blank', 'rel': 'noopener noreferrer'}
+53 -2
View File
@@ -85,6 +85,7 @@ assert _spec is not None and _spec.loader is not None
_udr_module = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = _udr_module
_spec.loader.exec_module(_udr_module)
sys.modules["comfyui_manager.common"].unified_dep_resolver = _udr_module
# Pull symbols into the test namespace
CollectedDeps = _udr_module.CollectedDeps
@@ -942,6 +943,36 @@ class TestResolveAndInstall:
specs = {spec for _, spec in attributed["torch"]}
assert specs == {"torch>=2.1", "torch<2.0"}
def test_multiline_conflict_attribution(self, tmp_path):
from comfyui_manager.common.unified_dep_resolver import attribute_conflicts
p1 = _make_node_pack(str(tmp_path), "pack_a", "python-slugify==8.0.4\n")
p2 = _make_node_pack(str(tmp_path), "pack_b", "text-unidecode==1.2\n")
p3 = _make_node_pack(str(tmp_path), "unrelated", "numpy>=1.20\n")
resolver = _resolver([p1, p2, p3])
stderr = (
"debug: checking numpy>=1.20\n"
"error: No solution found when resolving dependencies\n"
" cause: Because python-slugify==8.0.4 depends on text-unidecode>=1.3 "
"and you require python-slugify==8.0.4, we can conclude that you require text-unidecode>=1.3.\n"
" And because you require text-unidecode==1.2, "
"we can conclude that your requirements are unsatisfiable.\n"
"debug: finished checking numpy>=1.20\n"
)
with mock.patch.object(resolver, "_get_uv_cmd", return_value=["uv"]):
with mock.patch("subprocess.run", return_value=subprocess.CompletedProcess(
[], 1, stdout="", stderr=stderr,
)) as run:
result = resolver.resolve_and_install()
assert not result.success
run.assert_called_once()
assert attribute_conflicts(result.collected.sources, result.lockfile.conflicts) == {
"python_slugify": [(p1, "python-slugify==8.0.4")],
"text_unidecode": [(p2, "text-unidecode==1.2")],
}
def test_conflict_attribution_no_false_positive_on_underscore_prefix(self, tmp_path):
"""'torch' must NOT match 'torch_audio' in conflict text (underscore boundary)."""
from comfyui_manager.common.unified_dep_resolver import attribute_conflicts
@@ -1232,6 +1263,27 @@ class TestParseConflicts:
assert "conflicting" in result[0]
assert "conflict" in result[1]
def test_preserves_indented_diagnostic_continuations(self):
stderr = (
"info: checking packages\n"
"error: No solution found when resolving dependencies\n"
" cause: package-a requires package-b>=2\n"
" but package-b==1 is required\n"
"\n"
" debug: unrelated trace\n"
"error: second diagnostic\n"
" its continuation\n"
"info: finished\n"
" debug: unrelated detail\n"
)
assert UnifiedDepResolver._parse_conflicts(stderr) == [
"error: No solution found when resolving dependencies",
"cause: package-a requires package-b>=2",
"but package-b==1 is required",
"error: second diagnostic",
"its continuation",
]
def test_extracts_error_lines(self):
stderr = "ERROR: No matching distribution found for nonexistent-pkg\n"
result = UnifiedDepResolver._parse_conflicts(stderr)
@@ -1259,8 +1311,7 @@ class TestParseConflicts:
"debug: trace output\n"
)
result = UnifiedDepResolver._parse_conflicts(stderr)
assert len(result) == 1
assert "failed to resolve" in result[0]
assert result == ["error: failed to resolve"]
# ===========================================================================