Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b82d97ae |
@@ -1,58 +0,0 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- draft-v4
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'ltdrdata' || github.repository_owner == 'Comfy-Org' }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install build twine
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: |
|
||||
CURRENT_VERSION=$(grep -oP 'version = "\K[^"]+' pyproject.toml)
|
||||
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: v${{ steps.current_version.outputs.version }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
skip-existing: true
|
||||
verbose: true
|
||||
@@ -7,19 +7,15 @@ on:
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'ltdrdata' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
+1
-3
@@ -16,7 +16,5 @@ comfyworkflows_sharekey
|
||||
github-stats-cache.json
|
||||
pip_overrides.json
|
||||
*.json
|
||||
# Track JSON test fixtures.
|
||||
!tests/cases/*.json
|
||||
check2.sh
|
||||
/venv/
|
||||
/venv/
|
||||
@@ -1,57 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Security policy: dedicated install flags (`allow_git_url_install` / `allow_pip_install`)
|
||||
|
||||
Two new boolean keys in `config.ini` (`[default]` section), both defaulting to
|
||||
`false`, now govern the arbitrary-install surfaces:
|
||||
|
||||
| Flag | Governs |
|
||||
|------|---------|
|
||||
| `allow_git_url_install` | `POST /customnode/install/git_url` and the unknown-git-URL arm of `POST /manager/queue/install` (incl. reinstall delegation) — the entire install transaction, transitive dependency pip installs included. On the batch queue path the flag applies **in addition to** the queue's `security_level` entry gate (see below) |
|
||||
| `allow_pip_install` | `POST /customnode/install/pip` only |
|
||||
|
||||
These surfaces additionally require a **loopback listener** (`--listen` on a
|
||||
loopback IP such as `127.0.0.1` or `::1` — not a general LAN/private address);
|
||||
the flags never open a non-loopback deployment. On the two
|
||||
**direct** endpoints (`POST /customnode/install/git_url` and
|
||||
`POST /customnode/install/pip`), the flags fully **decouple** the surface
|
||||
from `security_level`: it no longer has any effect in either direction — a
|
||||
strict level cannot deny them when the flag is `true`, and a weak level
|
||||
cannot allow them when the flag is `false`. On the **batch queue path**
|
||||
(`POST /manager/queue/install`), the flag is **necessary but not
|
||||
sufficient**: it gates the unknown-git-URL arm at the risky position, while
|
||||
the queue's normal `security_level` entry gate (`middle`) remains in force —
|
||||
at `security_level = strong`, batch unknown-URL installs stay denied even
|
||||
with the flag set to `true`. `security_level` continues to govern every
|
||||
other gated endpoint unchanged. Only the case-insensitive string `true`
|
||||
enables a flag; a missing or malformed key reads as `false`.
|
||||
|
||||
#### Migration note (no auto-seed)
|
||||
|
||||
There is **no automatic migration** from `security_level`. Users who
|
||||
previously relied on `security_level = weak` (or `normal-`) to use
|
||||
install-via-git-URL / install-pip must now **opt in explicitly** by adding to
|
||||
`config.ini`:
|
||||
|
||||
```ini
|
||||
[default]
|
||||
allow_git_url_install = true
|
||||
allow_pip_install = true
|
||||
```
|
||||
|
||||
Changes take effect after a **restart** (no hot reload).
|
||||
|
||||
#### Residual-risk note — outdated ComfyUI behavior change
|
||||
|
||||
On outdated ComfyUI versions (no system-user API), the manager previously
|
||||
forced `security_level = strong`, which unconditionally denied the
|
||||
git-URL/pip install surfaces. After this change those surfaces are governed
|
||||
by the new flags instead: an operator who explicitly sets a flag to `true`
|
||||
on a **loopback** listener can now perform installs on outdated ComfyUI
|
||||
where the forced-strong policy previously denied them. This is an accepted,
|
||||
deliberate trade-off: it requires explicit operator opt-in, remains bounded
|
||||
to loopback listeners, and the flag-deny path on outdated ComfyUI still
|
||||
surfaces the `comfyui_outdated` notice. If you operate an outdated ComfyUI
|
||||
deployment, leave both flags at their default `false` and update ComfyUI.
|
||||
@@ -5,11 +5,10 @@
|
||||

|
||||
|
||||
## NOTICE
|
||||
* V3.38: **Security patch** - Manager data migrated to protected path. See [Migration Guide](docs/en/v3.38-userdata-security-migration.md).
|
||||
* V3.16: Support for `uv` has been added. Set `use_uv` in `config.ini`.
|
||||
* V3.10: `double-click feature` is removed
|
||||
* This feature has been moved to https://github.com/ltdrdata/comfyui-connection-helper
|
||||
* V3.3.2: Overhauled. Officially supports [https://registry.comfy.org/](https://registry.comfy.org/).
|
||||
* V3.3.2: Overhauled. Officially supports [https://comfyregistry.org/](https://comfyregistry.org/).
|
||||
* You can see whole nodes info on [ComfyUI Nodes Info](https://ltdrdata.github.io/) page.
|
||||
|
||||
## Installation
|
||||
@@ -18,7 +17,7 @@
|
||||
|
||||
To install ComfyUI-Manager in addition to an existing installation of ComfyUI, you can follow the following steps:
|
||||
|
||||
1. Go to `ComfyUI/custom_nodes` dir in terminal (cmd)
|
||||
1. goto `ComfyUI/custom_nodes` dir in terminal(cmd)
|
||||
2. `git clone https://github.com/ltdrdata/ComfyUI-Manager comfyui-manager`
|
||||
3. Restart ComfyUI
|
||||
|
||||
@@ -29,8 +28,8 @@ To install ComfyUI-Manager in addition to an existing installation of ComfyUI, y
|
||||
- standalone version
|
||||
- select option: use windows default console window
|
||||
2. Download [scripts/install-manager-for-portable-version.bat](https://github.com/ltdrdata/ComfyUI-Manager/raw/main/scripts/install-manager-for-portable-version.bat) into installed `"ComfyUI_windows_portable"` directory
|
||||
- Don't click. Right-click the link and choose 'Save As...'
|
||||
3. Double-click `install-manager-for-portable-version.bat` batch file
|
||||
- Don't click. Right click the link and use save as...
|
||||
3. double click `install-manager-for-portable-version.bat` batch file
|
||||
|
||||

|
||||
|
||||
@@ -48,7 +47,7 @@ pip install comfy-cli
|
||||
comfy install
|
||||
```
|
||||
|
||||
Linux/macOS:
|
||||
Linux/OSX:
|
||||
```commandline
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
@@ -58,13 +57,13 @@ comfy install
|
||||
* See also: https://github.com/Comfy-Org/comfy-cli
|
||||
|
||||
|
||||
### Installation[method4] (Installation for Linux+venv: ComfyUI + ComfyUI-Manager)
|
||||
### Installation[method4] (Installation for linux+venv: ComfyUI + ComfyUI-Manager)
|
||||
|
||||
To install ComfyUI with ComfyUI-Manager on Linux using a venv environment, you can follow these steps:
|
||||
* **prerequisite: python-is-python3, python3-venv, git**
|
||||
|
||||
1. Download [scripts/install-comfyui-venv-linux.sh](https://github.com/ltdrdata/ComfyUI-Manager/raw/main/scripts/install-comfyui-venv-linux.sh) into empty install directory
|
||||
- Don't click. Right-click the link and choose 'Save As...'
|
||||
- Don't click. Right click the link and use save as...
|
||||
- ComfyUI will be installed in the subdirectory of the specified directory, and the directory will contain the generated executable script.
|
||||
2. `chmod +x install-comfyui-venv-linux.sh`
|
||||
3. `./install-comfyui-venv-linux.sh`
|
||||
@@ -141,27 +140,20 @@ This repository provides Colab notebooks that allow you to install and use Comfy
|
||||
|
||||
|
||||
## Paths
|
||||
Starting from V3.38, Manager uses a protected system path for enhanced security.
|
||||
In `ComfyUI-Manager` V3.0 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/default/ComfyUI-Manager/`.
|
||||
|
||||
* <USER_DIRECTORY>
|
||||
* If executed without any options, the path defaults to ComfyUI/user.
|
||||
* It can be set using --user-directory <USER_DIRECTORY>.
|
||||
* <USER_DIRECTORY>
|
||||
* If executed without any options, the path defaults to ComfyUI/user.
|
||||
* It can be set using --user-directory <USER_DIRECTORY>.
|
||||
|
||||
| ComfyUI Version | Manager Path |
|
||||
|-----------------|--------------|
|
||||
| v0.3.76+ (with System User API) | `<USER_DIRECTORY>/__manager/` |
|
||||
| Older versions | `<USER_DIRECTORY>/default/ComfyUI-Manager/` |
|
||||
|
||||
* Basic config files: `config.ini`
|
||||
* Configurable channel lists: `channels.list`
|
||||
* Configurable pip overrides: `pip_overrides.json`
|
||||
* Configurable pip blacklist: `pip_blacklist.list`
|
||||
* Configurable pip auto fix: `pip_auto_fix.list`
|
||||
* Saved snapshot files: `snapshots/`
|
||||
* Startup script files: `startup-scripts/`
|
||||
* Component files: `components/`
|
||||
|
||||
> **Note**: See [Migration Guide](docs/en/v3.38-userdata-security-migration.md) for upgrade details.
|
||||
* Basic config files: `<USER_DIRECTORY>/default/ComfyUI-Manager/config.ini`
|
||||
* Configurable channel lists: `<USER_DIRECTORY>/default/ComfyUI-Manager/channels.ini`
|
||||
* Configurable pip overrides: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_overrides.json`
|
||||
* Configurable pip blacklist: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_blacklist.list`
|
||||
* Configurable pip auto fix: `<USER_DIRECTORY>/default/ComfyUI-Manager/pip_auto_fix.list`
|
||||
* Saved snapshot files: `<USER_DIRECTORY>/default/ComfyUI-Manager/snapshots`
|
||||
* Startup script files: `<USER_DIRECTORY>/default/ComfyUI-Manager/startup-scripts`
|
||||
* Component files: `<USER_DIRECTORY>/default/ComfyUI-Manager/components`
|
||||
|
||||
|
||||
## `extra_model_paths.yaml` Configuration
|
||||
@@ -184,7 +176,7 @@ The following settings are applied based on the section marked as `is_default`.
|
||||

|
||||
|
||||
|
||||
## cm-cli: command line tools for power users
|
||||
## cm-cli: command line tools for power user
|
||||
* A tool is provided that allows you to use the features of ComfyUI-Manager without running ComfyUI.
|
||||
* For more details, please refer to the [cm-cli documentation](docs/en/cm-cli.md).
|
||||
|
||||
@@ -230,7 +222,7 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
* `<current timestamp>` Ensure that the timestamp is always unique.
|
||||
* "components" should have the same structure as the content of the file stored in `<USER_DIRECTORY>/default/ComfyUI-Manager/components`.
|
||||
* `<component name>`: The name should be in the format `<prefix>::<node name>`.
|
||||
* `<component node data>`: In the node data of the group node.
|
||||
* `<compnent nodeata>`: In the nodedata of the group node.
|
||||
* `<version>`: Only two formats are allowed: `major.minor.patch` or `major.minor`. (e.g. `1.0`, `2.2.1`)
|
||||
* `<datetime>`: Saved time
|
||||
* `<packname>`: If the packname is not empty, the category becomes packname/workflow, and it is saved in the <packname>.pack file in `<USER_DIRECTORY>/default/ComfyUI-Manager/components`.
|
||||
@@ -248,7 +240,7 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
* Dragging and dropping or pasting a single component will add a node. However, when adding multiple components, nodes will not be added.
|
||||
|
||||
|
||||
## Support for installing missing nodes
|
||||
## Support of missing nodes installation
|
||||
|
||||

|
||||
|
||||
@@ -287,10 +279,10 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
* Logging to file feature
|
||||
* This feature is enabled by default and can be disabled by setting `file_logging = False` in the `config.ini`.
|
||||
|
||||
* Fix node (recreate): When right-clicking on a node and selecting `Fix node (recreate)`, you can recreate the node. The widget's values are reset, while the connections maintain those with the same names.
|
||||
* Fix node(recreate): When right-clicking on a node and selecting `Fix node (recreate)`, you can recreate the node. The widget's values are reset, while the connections maintain those with the same names.
|
||||
* It is used to correct errors in nodes of old workflows created before, which are incompatible with the version changes of custom nodes.
|
||||
|
||||
* Double-Click Node Title: You can set the double-click behavior of nodes in the ComfyUI-Manager menu.
|
||||
* Double-Click Node Title: You can set the double click behavior of nodes in the ComfyUI-Manager menu.
|
||||
* `Copy All Connections`, `Copy Input Connections`: Double-clicking a node copies the connections of the nearest node.
|
||||
* This action targets the nearest node within a straight-line distance of 1000 pixels from the center of the node.
|
||||
* In the case of `Copy All Connections`, it duplicates existing outputs, but since it does not allow duplicate connections, the existing output connections of the original node are disconnected.
|
||||
@@ -322,6 +314,9 @@ The following settings are applied based on the section marked as `is_default`.
|
||||
* Use `aria2` as downloader
|
||||
* [howto](docs/en/use_aria2.md)
|
||||
|
||||
* If you add the item `skip_migration_check = True` to `config.ini`, it will not check whether there are nodes that can be migrated at startup.
|
||||
* This option can be used if performance issues occur in a Colab+GDrive environment.
|
||||
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -356,7 +351,7 @@ When you run the `scan.sh` script:
|
||||
|
||||
* It updates the `github-stats.json`.
|
||||
* This uses the GitHub API, so set your token with `export GITHUB_TOKEN=your_token_here` to avoid quickly reaching the rate limit and malfunctioning.
|
||||
* To skip this step, add the `--skip-stat-update` option.
|
||||
* To skip this step, add the `--skip-update-stat` option.
|
||||
|
||||
* The `--skip-all` option applies both `--skip-update` and `--skip-stat-update`.
|
||||
|
||||
@@ -364,9 +359,9 @@ When you run the `scan.sh` script:
|
||||
## Troubleshooting
|
||||
* If your `git.exe` is installed in a specific location other than system git, please install ComfyUI-Manager and run ComfyUI. Then, specify the path including the file name in `git_exe = ` in the `<USER_DIRECTORY>/default/ComfyUI-Manager/config.ini` file that is generated.
|
||||
* If updating ComfyUI-Manager itself fails, please go to the **ComfyUI-Manager** directory and execute the command `git update-ref refs/remotes/origin/main a361cc1 && git fetch --all && git pull`.
|
||||
* If you encounter the error message `Overlapped Object has pending operation at deallocation on ComfyUI Manager load` under Windows
|
||||
* If you encounter the error message `Overlapped Object has pending operation at deallocation on Comfyui Manager load` under Windows
|
||||
* Edit `config.ini` file: add `windows_selector_event_loop_policy = True`
|
||||
* If the `SSL: CERTIFICATE_VERIFY_FAILED` error occurs.
|
||||
* if `SSL: CERTIFICATE_VERIFY_FAILED` error is occured.
|
||||
* Edit `config.ini` file: add `bypass_ssl = True`
|
||||
|
||||
|
||||
@@ -384,124 +379,19 @@ When you run the `scan.sh` script:
|
||||
* all feature is available
|
||||
|
||||
* `high` level risky features
|
||||
* Downloading models that are not in `.safetensors` format and not
|
||||
registered in the `default channel` model list
|
||||
* NOTE: `Install via git url`, `pip install`, and installation of custom nodes
|
||||
not registered in the `default channel` are **no longer governed by
|
||||
`security_level`** — they are governed by the dedicated install flags
|
||||
described below.
|
||||
* `Install via git url`, `pip install`
|
||||
* Installation of custom nodes registered not in the `default channel`.
|
||||
* Fix custom nodes
|
||||
|
||||
* `middle` level risky features
|
||||
* Uninstall/Update
|
||||
* Installation of custom nodes registered in the `default channel`.
|
||||
* Fix custom nodes
|
||||
* Restore/Remove Snapshot
|
||||
* Restart
|
||||
|
||||
* `low` level risky features
|
||||
* Update ComfyUI
|
||||
|
||||
### Dedicated install flags: `allow_git_url_install` / `allow_pip_install`
|
||||
|
||||
The two arbitrary-install surfaces are governed by dedicated boolean keys in
|
||||
`config.ini` (`[default]` section), fully **decoupled** from `security_level`:
|
||||
|
||||
* `allow_git_url_install`
|
||||
* governs `Install via Git URL` (`POST /customnode/install/git_url`) **and**
|
||||
the unknown-git-URL arm of the batch install queue
|
||||
(`POST /manager/queue/install`, including reinstall delegation) — i.e.
|
||||
installing any custom node from a git URL that is not registered in the
|
||||
`default channel` catalog
|
||||
* on the **batch queue path**, the flag is **necessary but not
|
||||
sufficient**: the queue's normal `security_level` entry gate (`middle`)
|
||||
must ALSO pass — at `security_level = strong`, batch unknown-URL
|
||||
installs stay denied even with the flag set to `true` (only the direct
|
||||
`Install via Git URL` endpoint is fully independent of `security_level`)
|
||||
* covers the **entire install transaction** it starts, including the
|
||||
pack's transitive dependency pip installs
|
||||
* `allow_pip_install`
|
||||
* governs **only** the standalone `pip install` feature
|
||||
(`POST /customnode/install/pip`)
|
||||
|
||||
Key properties:
|
||||
|
||||
* **Decoupled from `security_level` (replace, not and)** — on the two
|
||||
**direct endpoints** (`Install via Git URL` and `pip install`),
|
||||
`security_level` no longer has any effect in either direction: a strict
|
||||
level cannot deny them when the flag is `true`, and a weak level cannot
|
||||
allow them when the flag is `false`. (The batch queue path keeps its
|
||||
`security_level` entry gate in ADDITION to the flag — see the scope bullet
|
||||
above.) Every other gated feature remains governed by `security_level` as
|
||||
described above.
|
||||
* **Loopback only** — the flags take effect **only** when the server listens
|
||||
on a loopback address (e.g. `--listen 127.0.0.1`). On a non-loopback
|
||||
listener these surfaces stay denied regardless of the flags; the flags
|
||||
never widen the exposure of a public deployment.
|
||||
* **Default deny / explicit opt-in** — both flags default to `false`. Only
|
||||
the case-insensitive string `true` enables a flag; a missing or malformed
|
||||
key reads as `false`.
|
||||
|
||||
To opt in, edit `config.ini`:
|
||||
|
||||
```ini
|
||||
[default]
|
||||
allow_git_url_install = true
|
||||
allow_pip_install = true
|
||||
```
|
||||
|
||||
Changes take effect after a **restart** (no hot reload).
|
||||
|
||||
> **Migration note**: there is no automatic migration from `security_level`.
|
||||
> If you previously relied on `security_level = weak` (or `normal-`) to use
|
||||
> install-via-git-URL / pip install, you must opt in explicitly with the flags
|
||||
> above. See `CHANGELOG.md` for details, including a behavior note for
|
||||
> outdated ComfyUI deployments.
|
||||
|
||||
|
||||
### Flagged CNR versions: `allow_flagged_nodepack_install`
|
||||
|
||||
Manager checks the version status in the existing CNR install response; no
|
||||
additional Registry request is needed. Active versions continue to install
|
||||
normally, subject to the existing security policy. Registry refusals (including
|
||||
HTTP 404) still prevent installation.
|
||||
|
||||
Flagged versions must also satisfy the existing security policy and Registry
|
||||
checks. In addition, they require either **all** `--listen` addresses to be
|
||||
loopback, such as `127.0.0.1` or `::1`, or the explicit override below.
|
||||
A mixed loopback/non-loopback listener is non-local.
|
||||
|
||||
For a **trusted private network**, administrators can explicitly allow flagged
|
||||
versions on non-loopback listeners:
|
||||
|
||||
```ini
|
||||
[default]
|
||||
allow_flagged_nodepack_install = true
|
||||
```
|
||||
|
||||
The default is `false`; only the case-insensitive value `true` enables it.
|
||||
Restart ComfyUI after editing the setting. This option does not detect private
|
||||
networks and is independent of `network_mode`. It does not bypass
|
||||
`security_level`, `allow_git_url_install`, or `allow_pip_install`.
|
||||
|
||||
When the flagged-version check denies an action, the UI reports that the current
|
||||
security configuration does not allow it and directs users to the terminal.
|
||||
Only the terminal provides the loopback-only `--listen` examples, the private
|
||||
network override setting, and the instruction to restart ComfyUI after changes.
|
||||
|
||||
Enabling an already installed version does not query the Registry or reinstall it.
|
||||
Older deferred switches without a stored status require loopback-only listeners
|
||||
or the override. Otherwise they are rejected without changing the installed pack;
|
||||
request the installation again so Manager can check its current Registry status.
|
||||
|
||||
The check applies to server-initiated installation, reinstallation, version switches, updates,
|
||||
and snapshot restores. A policy refusal preserves the existing nodepack.
|
||||
Scheduled version switches save the returned status and check the current
|
||||
listener and setting again at startup, before downloading or running scripts.
|
||||
Server-scheduled snapshot restores inherit the permission computed from the
|
||||
server's current listener and setting. Standalone `cm-cli` commands remain local
|
||||
administrator operations and do not use the server listener policy.
|
||||
Saved statuses are not refreshed from the Registry.
|
||||
|
||||
|
||||
# Disclaimer
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
"""
|
||||
This file is the entry point for the ComfyUI-Manager package, handling CLI-only mode and initial setup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ find ~/.tmp/default -name "*.py" -print0 | xargs -0 grep -E "crypto|^_A="
|
||||
|
||||
echo
|
||||
echo CHECK3
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#]*https\?:"
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#].*\.whl"
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*https\\?:"
|
||||
find ~/.tmp/default -name "requirements.txt" | xargs grep "\.whl"
|
||||
|
||||
echo
|
||||
|
||||
@@ -43,10 +43,9 @@ import cnr_utils
|
||||
|
||||
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
cm_global.pip_blacklist = {'torch', 'torchaudio', 'torchsde', 'torchvision'}
|
||||
cm_global.pip_downgrade_blacklist = ['torch', 'torchaudio', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
|
||||
|
||||
cm_global.pip_overrides = {}
|
||||
cm_global.pip_blacklist = {'torch', 'torchsde', 'torchvision'}
|
||||
cm_global.pip_downgrade_blacklist = ['torch', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
|
||||
cm_global.pip_overrides = {'numpy': 'numpy<2'}
|
||||
|
||||
if os.path.exists(os.path.join(manager_util.comfyui_manager_path, "pip_overrides.json")):
|
||||
with open(os.path.join(manager_util.comfyui_manager_path, "pip_overrides.json"), 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
@@ -148,6 +147,7 @@ class Ctx:
|
||||
if os.path.exists(core.manager_pip_overrides_path):
|
||||
with open(core.manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
cm_global.pip_overrides = json.load(json_file)
|
||||
cm_global.pip_overrides = {'numpy': 'numpy<2'}
|
||||
|
||||
if os.path.exists(core.manager_pip_blacklist_path):
|
||||
with open(core.manager_pip_blacklist_path, 'r', encoding="UTF-8", errors="ignore") as f:
|
||||
@@ -184,18 +184,13 @@ class Ctx:
|
||||
cmd_ctx = Ctx()
|
||||
|
||||
|
||||
def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
exit_on_fail = kwargs.get('exit_on_fail', False)
|
||||
print(f"install_node exit on fail:{exit_on_fail}...")
|
||||
|
||||
def install_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
if core.is_valid_url(node_spec_str):
|
||||
# install via urls
|
||||
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
|
||||
if not res.result:
|
||||
print(res.msg)
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
|
||||
else:
|
||||
@@ -230,8 +225,6 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
print("")
|
||||
else:
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
@@ -593,7 +586,7 @@ def get_all_installed_node_specs():
|
||||
return res
|
||||
|
||||
|
||||
def for_each_nodes(nodes, act, allow_all=True, **kwargs):
|
||||
def for_each_nodes(nodes, act, allow_all=True):
|
||||
is_all = False
|
||||
if allow_all and 'all' in nodes:
|
||||
is_all = True
|
||||
@@ -605,7 +598,7 @@ def for_each_nodes(nodes, act, allow_all=True, **kwargs):
|
||||
i = 1
|
||||
for x in nodes:
|
||||
try:
|
||||
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
|
||||
act(x, is_all=is_all, cnt_msg=f'{i}/{total}')
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
@@ -649,17 +642,13 @@ def install(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
exit_on_fail: bool = typer.Option(
|
||||
False,
|
||||
help="Exit on failure"
|
||||
)
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
cmd_ctx.set_channel_mode(channel, mode)
|
||||
cmd_ctx.set_no_deps(no_deps)
|
||||
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, core.manager_files_path)
|
||||
for_each_nodes(nodes, act=install_node, exit_on_fail=exit_on_fail)
|
||||
for_each_nodes(nodes, act=install_node)
|
||||
pip_fixer.fix_broken()
|
||||
|
||||
|
||||
@@ -1058,16 +1047,18 @@ def save_snapshot(
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
|
||||
if output is not None:
|
||||
if(not output.endswith('.json') and not output.endswith('.yaml')):
|
||||
print("[bold red]ERROR: output path should be either '.json' or '.yaml' file.[/bold red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
dir_path = os.path.dirname(output)
|
||||
if output is None:
|
||||
print("[bold red]ERROR: missing output path[/bold red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
if(dir_path != '' and not os.path.exists(dir_path)):
|
||||
print(f"[bold red]ERROR: {output} path not exists.[/bold red]")
|
||||
raise typer.Exit(code=1)
|
||||
if(not output.endswith('.json') and not output.endswith('.yaml')):
|
||||
print("[bold red]ERROR: output path should be either '.json' or '.yaml' file.[/bold red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
dir_path = os.path.dirname(output)
|
||||
if(dir_path != '' and not os.path.exists(dir_path)):
|
||||
print(f"[bold red]ERROR: {output} path not exists.[/bold red]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
path = asyncio.run(core.save_snapshot_with_postfix('snapshot', output, not full_snapshot))
|
||||
print(f"Current snapshot is saved as `{path}`")
|
||||
@@ -1280,6 +1271,20 @@ def export_custom_node_ids(
|
||||
print(f"{x['id']}@unknown", file=output_file)
|
||||
|
||||
|
||||
@app.command(
|
||||
"migrate",
|
||||
help="Migrate legacy node system to new node system",
|
||||
)
|
||||
def migrate(
|
||||
user_directory: str = typer.Option(
|
||||
None,
|
||||
help="user directory"
|
||||
)
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
asyncio.run(unified_manager.migrate_unmanaged_nodes())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(app())
|
||||
|
||||
Regular → Executable
+1714
-41692
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
# ComfyUI-Manager: Documentation
|
||||
|
||||
This directory contains documentation for the ComfyUI-Manager, providing guides and tutorials for users in multiple languages.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
The documentation is organized into language-specific directories:
|
||||
|
||||
- **en/**: English documentation
|
||||
- **ko/**: Korean documentation
|
||||
|
||||
## Core Documentation Files
|
||||
|
||||
### Command-Line Interface
|
||||
|
||||
- **cm-cli.md**: Documentation for the ComfyUI-Manager Command Line Interface (CLI), which allows using manager functionality without the UI.
|
||||
|
||||
### Advanced Features
|
||||
|
||||
- **use_aria2.md**: Guide for using the aria2 download accelerator with ComfyUI-Manager for faster model downloads.
|
||||
|
||||
## Documentation Standards
|
||||
|
||||
The documentation follows these standards:
|
||||
|
||||
1. **Markdown Format**: All documentation is written in Markdown for easy rendering on GitHub and other platforms
|
||||
2. **Language-specific Directories**: Content is separated by language to facilitate localization
|
||||
3. **Feature-focused Documentation**: Each major feature has its own documentation file
|
||||
4. **Updated with Releases**: Documentation is kept in sync with software releases
|
||||
|
||||
## Contributing to Documentation
|
||||
|
||||
When contributing new documentation:
|
||||
|
||||
1. Place files in the appropriate language directory
|
||||
2. Use clear, concise language appropriate for the target audience
|
||||
3. Include examples where helpful
|
||||
4. Consider adding screenshots or diagrams for complex features
|
||||
5. Maintain consistent formatting with existing documentation
|
||||
|
||||
This documentation directory will continue to grow to support the expanding feature set of ComfyUI-Manager.
|
||||
+2
-2
@@ -139,9 +139,9 @@ You can set whether to use ComfyUI-Manager solely via CLI.
|
||||
`restore-dependencies`
|
||||
|
||||
* This command can be used if custom nodes are installed under the `ComfyUI/custom_nodes` path but their dependencies are not installed.
|
||||
* It is useful when starting a new cloud instance, like Colab, where dependencies need to be reinstalled and installation scripts re-executed.
|
||||
* It is useful when starting a new cloud instance, like colab, where dependencies need to be reinstalled and installation scripts re-executed.
|
||||
* It can also be utilized if ComfyUI is reinstalled and only the custom_nodes path has been backed up and restored.
|
||||
|
||||
### 7. Clear
|
||||
|
||||
In the GUI, installations, updates, or snapshot restorations are scheduled to execute the next time ComfyUI is launched. The `clear` command clears this scheduled state, ensuring no pre-execution actions are applied.
|
||||
In the GUI, installations, updates, or snapshot restorations are scheduled to execute the next time ComfyUI is launched. The `clear` command clears this scheduled state, ensuring no pre-execution actions are applied.
|
||||
@@ -1,230 +0,0 @@
|
||||
# ComfyUI-Manager V3.38: Userdata Security Migration Guide
|
||||
|
||||
## Introduction
|
||||
|
||||
ComfyUI-Manager V3.38 introduces a **security patch** that migrates Manager's configuration and data to a protected system path. This change leverages ComfyUI's new System User Protection API (PR #10966) to provide enhanced security isolation.
|
||||
|
||||
This guide explains what happens during the migration and how to handle various situations.
|
||||
|
||||
---
|
||||
|
||||
## What Changed
|
||||
|
||||
### Finding Your Paths
|
||||
|
||||
When ComfyUI starts, it displays the full paths in the terminal:
|
||||
|
||||
```
|
||||
** User directory: /path/to/ComfyUI/user
|
||||
** ComfyUI-Manager config path: /path/to/ComfyUI/user/__manager/config.ini
|
||||
```
|
||||
|
||||
Look for these lines in your startup log to find the exact location on your system. In this guide, paths are shown relative to the `user` directory.
|
||||
|
||||
### Path Migration
|
||||
|
||||
| Data | Legacy Path | New Path |
|
||||
|------|-------------|----------|
|
||||
| Configuration | `user/default/ComfyUI-Manager/` | `user/__manager/` |
|
||||
| Snapshots | `user/default/ComfyUI-Manager/snapshots/` | `user/__manager/snapshots/` |
|
||||
|
||||
### Why This Change
|
||||
|
||||
In older ComfyUI versions, the `default/` directory was **unprotected** and accessible via web APIs. If you ran ComfyUI with `--listen 0.0.0.0` or similar options to allow external connections, this data **may have been tampered with** by malicious actors.
|
||||
|
||||
**Note:** If you only used ComfyUI locally (without `--listen` or with `--listen 127.0.0.1`), your data was not exposed to this vulnerability.
|
||||
|
||||
The new `__manager` path uses ComfyUI's protected system directory, which:
|
||||
- **Cannot be accessed** from outside (protected by ComfyUI)
|
||||
- Isolates system settings from user data
|
||||
- Enables stricter security for remote access
|
||||
|
||||
**This is why only `config.ini` is automatically migrated** - other files (snapshots) may have been compromised and should be manually verified before copying.
|
||||
|
||||
---
|
||||
|
||||
## Automatic Migration
|
||||
|
||||
When you start ComfyUI with the new System User Protection API, Manager automatically handles the migration:
|
||||
|
||||
### Step 1: Configuration Migration
|
||||
|
||||
Only `config.ini` is migrated automatically.
|
||||
|
||||
**Important**: Snapshots are **NOT** automatically migrated. You must copy them manually if needed.
|
||||
|
||||
### Step 2: Security Level Check
|
||||
|
||||
During migration, if your security level is below `normal` (i.e., `weak` or `normal-`), it will be automatically raised to `normal`. This is a safety measure because the security level setting itself may have been tampered with in the old version.
|
||||
|
||||
```
|
||||
======================================================================
|
||||
[ComfyUI-Manager] WARNING: Security level adjusted
|
||||
- Previous: 'weak' → New: 'normal'
|
||||
- Raised to prevent unauthorized remote access.
|
||||
======================================================================
|
||||
```
|
||||
|
||||
If you need a lower security level, you can manually edit the config after migration.
|
||||
|
||||
### Step 3: Legacy Backup
|
||||
|
||||
Your entire legacy directory is moved to a backup location:
|
||||
```
|
||||
user/__manager/.legacy-manager-backup/
|
||||
```
|
||||
|
||||
This backup is preserved until you manually delete it.
|
||||
|
||||
---
|
||||
|
||||
## Persistent Backup Notification
|
||||
|
||||
As long as the backup exists, Manager will remind you on **every startup**:
|
||||
|
||||
```
|
||||
----------------------------------------------------------------------
|
||||
[ComfyUI-Manager] NOTICE: Legacy backup exists
|
||||
- Your old Manager data was backed up to:
|
||||
/path/to/ComfyUI/user/__manager/.legacy-manager-backup
|
||||
- Please verify and remove it when no longer needed.
|
||||
----------------------------------------------------------------------
|
||||
```
|
||||
|
||||
**To stop this notification**: Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
|
||||
|
||||
---
|
||||
|
||||
## Recovering Old Data
|
||||
|
||||
### Snapshots
|
||||
|
||||
If you need your old snapshots, copy the contents of `.legacy-manager-backup/snapshots/` to `user/__manager/snapshots/`.
|
||||
|
||||
---
|
||||
|
||||
## Outdated ComfyUI Warning
|
||||
|
||||
If you're running an older version of ComfyUI without the System User Protection API, Manager will:
|
||||
|
||||
1. **Force security level to `strong`** - All installations are blocked
|
||||
2. **Display warning message**:
|
||||
|
||||
```
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
[ComfyUI-Manager] ERROR: ComfyUI version is outdated!
|
||||
- Most operations are blocked for security.
|
||||
- ComfyUI update is still allowed.
|
||||
- Please update ComfyUI to use Manager normally.
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
**Solution**: Update ComfyUI to v0.3.76 or later.
|
||||
|
||||
---
|
||||
|
||||
## Security Levels
|
||||
|
||||
| Level | What's Allowed |
|
||||
|-------|----------------|
|
||||
| `strong` | ComfyUI update only. All other installations blocked. |
|
||||
| `normal` | Install/update/remove registered custom nodes and models. |
|
||||
| `normal-` | Above + Install via Git URL or pip (localhost only). |
|
||||
| `weak` | All operations allowed, including from remote connections. |
|
||||
|
||||
**Notes:**
|
||||
- `strong` is forced on outdated ComfyUI versions.
|
||||
- `normal` is the default and recommended for most users.
|
||||
- `normal-` is for developers who need to install unregistered nodes locally.
|
||||
- `weak` should only be used in isolated development environments.
|
||||
|
||||
### Changing Security Level
|
||||
|
||||
Edit `user/__manager/config.ini`:
|
||||
```ini
|
||||
[default]
|
||||
security_level = normal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Messages
|
||||
|
||||
### "comfyui_outdated" (HTTP 403)
|
||||
|
||||
This error appears when:
|
||||
- Your ComfyUI doesn't have the System User Protection API
|
||||
- All installations are blocked until you update ComfyUI
|
||||
|
||||
**Solution**: Update ComfyUI to the latest version.
|
||||
|
||||
### "security_level" (HTTP 403)
|
||||
|
||||
This error appears when:
|
||||
- Your security level blocks the requested operation
|
||||
- For example, `strong` level blocks all installations
|
||||
|
||||
**Solution**: Lower your security level in config.ini if appropriate for your use case.
|
||||
|
||||
---
|
||||
|
||||
## Security Warning: Suspicious Path
|
||||
|
||||
If you see this error on an **older** ComfyUI:
|
||||
|
||||
```
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
[ComfyUI-Manager] ERROR: Suspicious path detected!
|
||||
- '__manager' exists with low security level: 'weak'
|
||||
- Please verify manually:
|
||||
/path/to/ComfyUI/user/__manager/config.ini
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
```
|
||||
|
||||
On older ComfyUI versions, the `__manager` directory is not normally created. If this directory exists, it may have been created externally. For safety, manually verify the contents of this directory before updating ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### All my installations are blocked
|
||||
|
||||
**Check 1**: Is your ComfyUI updated?
|
||||
- Old ComfyUI forces `security_level = strong`
|
||||
- Update ComfyUI to resolve
|
||||
|
||||
**Check 2**: What's your security level?
|
||||
- Check `user/__manager/config.ini`
|
||||
- `security_level = strong` blocks all installations
|
||||
|
||||
### My snapshots are missing
|
||||
|
||||
Snapshots are not automatically migrated. You need to manually copy the `snapshots` folder from inside `.legacy-manager-backup` to the `user/__manager/` directory.
|
||||
|
||||
### I keep seeing the backup notification
|
||||
|
||||
Delete the `.legacy-manager-backup` folder inside `user/__manager/` after confirming you don't need any data from it.
|
||||
|
||||
### Snapshot restore is blocked
|
||||
|
||||
On old ComfyUI (without System User API), snapshot restore is blocked because security is forced to `strong`. Update ComfyUI to enable snapshot restore.
|
||||
|
||||
---
|
||||
|
||||
## File Structure Reference
|
||||
|
||||
```
|
||||
user/
|
||||
└── __manager/
|
||||
├── config.ini # Manager configuration
|
||||
├── channels.list # Custom node channels
|
||||
├── snapshots/ # Environment snapshots
|
||||
└── .legacy-manager-backup/ # Backup of old Manager data (temporary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **ComfyUI**: v0.3.76 or later (with System User Protection API)
|
||||
- **ComfyUI-Manager**: V3.38 or later
|
||||
+13
-13
@@ -23,13 +23,13 @@ OPTIONS:
|
||||
## How To Use?
|
||||
* `python cm-cli.py` 를 통해서 실행 시킬 수 있습니다.
|
||||
* 예를 들어 custom node를 모두 업데이트 하고 싶다면
|
||||
* ComfyUI-Manager 경로에서 `python cm-cli.py update all` 명령을 실행할 수 있습니다.
|
||||
* ComfyUI-Manager경로 에서 `python cm-cli.py update all` 를 command를 실행할 수 있습니다.
|
||||
* ComfyUI 경로에서 실행한다면, `python custom_nodes/ComfyUI-Manager/cm-cli.py update all` 와 같이 cm-cli.py 의 경로를 지정할 수도 있습니다.
|
||||
|
||||
## Prerequisite
|
||||
* ComfyUI 를 실행하는 python과 동일한 python 환경에서 실행해야 합니다.
|
||||
* venv를 사용할 경우 해당 venv를 activate 한 상태에서 실행해야 합니다.
|
||||
* portable 버전을 사용할 경우 run_nvidia_gpu.bat 파일이 있는 경로인 경우, 다음과 같은 방식으로 명령을 실행해야 합니다.
|
||||
* portable 버전을 사용할 경우 run_nvidia_gpu.bat 파일이 있는 경로인 경우, 다음과 같은 방식으로 코맨드를 실행해야 합니다.
|
||||
`.\python_embeded\python.exe ComfyUI\custom_nodes\ComfyUI-Manager\cm-cli.py update all`
|
||||
* ComfyUI 의 경로는 COMFYUI_PATH 환경 변수로 설정할 수 있습니다. 만약 생략할 경우 다음과 같은 경고 메시지가 나타나며, ComfyUI-Manager가 설치된 경로를 기준으로 상대 경로로 설정됩니다.
|
||||
```
|
||||
@@ -40,8 +40,8 @@ OPTIONS:
|
||||
|
||||
### 1. --channel, --mode
|
||||
* 정보 보기 기능과 커스텀 노드 관리 기능의 경우는 --channel과 --mode를 통해 정보 DB를 설정할 수 있습니다.
|
||||
* 예를 들어 `python cm-cli.py update all --channel recent --mode remote`와 같은 명령을 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
|
||||
* --channel, --mode 는 `simple-show, show, install, uninstall, update, disable, enable, fix` 명령에서만 사용 가능합니다.
|
||||
* 예들 들어 `python cm-cli.py update all --channel recent --mode remote`와 같은 command를 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
|
||||
* --channel, --mode 는 `simple-show, show, install, uninstall, update, disable, enable, fix` command에서만 사용 가능합니다.
|
||||
|
||||
### 2. 관리 정보 보기
|
||||
|
||||
@@ -51,7 +51,7 @@ OPTIONS:
|
||||
* `[show|simple-show]` - `show`는 상세하게 정보를 보여주며, `simple-show`는 간단하게 정보를 보여줍니다.
|
||||
|
||||
|
||||
`python cm-cli.py show installed` 와 같은 명령을 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
|
||||
`python cm-cli.py show installed` 와 같은 코맨드를 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
|
||||
```
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
|
||||
@@ -67,7 +67,7 @@ FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
|
||||
[ DISABLED ] ComfyUI-Loopchain (author: Fannovel16)
|
||||
```
|
||||
|
||||
`python cm-cli.py simple-show installed` 와 같은 명령을 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
|
||||
`python cm-cli.py simple-show installed` 와 같은 코맨드를 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
|
||||
|
||||
```
|
||||
-= ComfyUI-Manager CLI (V2.24) =-
|
||||
@@ -89,7 +89,7 @@ ComfyUI-Loopchain
|
||||
* `installed`: enable, disable 여부와 상관없이 설치된 모든 노드를 보여줍니다
|
||||
* `not-installed`: 설치되지 않은 커스텀 노드의 목록을 보여줍니다.
|
||||
* `all`: 모든 커스텀 노드의 목록을 보여줍니다.
|
||||
* `snapshot`: 현재 설치된 커스텀 노드의 snapshot 정보를 보여줍니다. `show`를 통해서 볼 경우는 json 출력 형태로 보여주며, `simple-show`를 통해서 볼 경우는 간단하게, 커밋 해시와 함께 보여줍니다.
|
||||
* `snapshot`: 현재 설치된 커스텀 노드의 snapshot 정보를 보여줍니다. `show`롤 통해서 볼 경우는 json 출력 형태로 보여주며, `simple-show`를 통해서 볼 경우는 간단하게, 커밋 해시와 함께 보여줍니다.
|
||||
* `snapshot-list`: ComfyUI-Manager/snapshots 에 저장된 snapshot 파일의 목록을 보여줍니다.
|
||||
|
||||
### 3. 커스텀 노드 관리 하기
|
||||
@@ -98,7 +98,7 @@ ComfyUI-Loopchain
|
||||
|
||||
* `python cm-cli.py install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments` 와 같이 커스텀 노드의 이름을 나열해서 관리 기능을 적용할 수 있습니다.
|
||||
* 커스텀 노드의 이름은 `show`를 했을 때 보여주는 이름이며, git repository의 이름입니다.
|
||||
(추후 nickname을 사용 가능하도록 업데이트할 예정입니다.)
|
||||
(추후 nickname 을 사용가능하돌고 업데이트 할 예정입니다.)
|
||||
|
||||
`[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
|
||||
|
||||
@@ -124,7 +124,7 @@ ComfyUI-Loopchain
|
||||
* `--pip-non-local-url`: web URL에 등록된 pip 패키지들에 대해서 복구를 수행
|
||||
* `--pip-local-url`: local 경로를 지정하고 있는 pip 패키지들에 대해서 복구를 수행
|
||||
* `--user-directory`: 사용자 디렉토리 설정
|
||||
* `--restore-to`: 복구될 커스텀 노드가 설치될 경로. (이 옵션을 적용할 경우 오직 대상 경로에 설치된 custom nodes만 설치된 것으로 인식함.)
|
||||
* `--restore-to`: 복구될 커스텀 노드가 설치될 경로. (이 옵션을 적용할 경우 오직 대상 경로에 설치된 custom nodes 만 설치된 것으로 인식함.)
|
||||
|
||||
### 5. CLI only mode
|
||||
|
||||
@@ -133,7 +133,7 @@ ComfyUI-Manager를 CLI로만 사용할 것인지를 설정할 수 있습니다.
|
||||
`cli-only-mode [enable|disable]`
|
||||
|
||||
* security 혹은 policy 의 이유로 GUI 를 통한 ComfyUI-Manager 사용을 제한하고 싶은 경우 이 모드를 사용할 수 있습니다.
|
||||
* CLI only mode를 적용할 경우 ComfyUI-Manager 가 매우 제한된 상태로 로드되어, 내부적으로 제공하는 web API가 비활성화되며, 메인 메뉴에서도 Manager 버튼이 표시되지 않습니다.
|
||||
* CLI only mode를 적용할 경우 ComfyUI-Manager 가 매우 제한된 상태로 로드되어, 내부적으로 제공하는 web API가 비활성화 되며, 메인 메뉴에서도 Manager 버튼이 표시되지 않습니다.
|
||||
|
||||
|
||||
### 6. 의존성 설치
|
||||
@@ -141,10 +141,10 @@ ComfyUI-Manager를 CLI로만 사용할 것인지를 설정할 수 있습니다.
|
||||
`restore-dependencies`
|
||||
|
||||
* `ComfyUI/custom_nodes` 하위 경로에 커스텀 노드들이 설치되어 있긴 하지만, 의존성이 설치되지 않은 경우 사용할 수 있습니다.
|
||||
* Colab과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행되어야 하는 경우 사용합니다.
|
||||
* ComfyUI를 재설치할 경우, custom_nodes 경로만 백업했다가 재설치할 경우 활용 가능합니다.
|
||||
* colab 과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행 되어야 하는 경우 사용합니다.
|
||||
* ComfyUI을 재설치할 경우, custom_nodes 경로만 백업했다가 재설치 할 경우 활용 가능합니다.
|
||||
|
||||
|
||||
### 7. clear
|
||||
|
||||
GUI에서 install, update를 하거나 snapshot을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
|
||||
GUI에서 install, update를 하거나 snapshot 을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
|
||||
+2720
-57865
File diff suppressed because it is too large
Load Diff
+1
-9
@@ -2,7 +2,6 @@ import subprocess
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import time
|
||||
|
||||
import git
|
||||
import json
|
||||
@@ -220,14 +219,7 @@ def gitpull(path):
|
||||
repo.close()
|
||||
return
|
||||
|
||||
try:
|
||||
repo.git.pull('--ff-only')
|
||||
except git.GitCommandError:
|
||||
backup_name = f'backup_{time.strftime("%Y%m%d_%H%M%S")}'
|
||||
repo.create_head(backup_name)
|
||||
print(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
|
||||
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
|
||||
print(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
|
||||
remote.pull()
|
||||
|
||||
repo.git.submodule('update', '--init', '--recursive')
|
||||
new_commit_hash = repo.head.commit.hexsha
|
||||
|
||||
+4851
-24361
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
||||
# ComfyUI-Manager: Core Backend (glob)
|
||||
|
||||
This directory contains the Python backend modules that power ComfyUI-Manager, handling the core functionality of node management, downloading, security, and server operations.
|
||||
|
||||
## Core Modules
|
||||
|
||||
- **manager_core.py**: The central implementation of management functions, handling configuration, installation, updates, and node management.
|
||||
- **manager_server.py**: Implements server functionality and API endpoints for the web interface to interact with the backend.
|
||||
- **manager_downloader.py**: Handles downloading operations for models, extensions, and other resources.
|
||||
- **manager_util.py**: Provides utility functions used throughout the system.
|
||||
|
||||
## Specialized Modules
|
||||
|
||||
- **cm_global.py**: Maintains global variables and state management across the system.
|
||||
- **cnr_utils.py**: Helper utilities for interacting with the custom node registry (CNR).
|
||||
- **git_utils.py**: Git-specific utilities for repository operations.
|
||||
- **node_package.py**: Handles the packaging and installation of node extensions.
|
||||
- **security_check.py**: Implements the multi-level security system for installation safety.
|
||||
- **share_3rdparty.py**: Manages integration with third-party sharing platforms.
|
||||
|
||||
## Architecture
|
||||
|
||||
The backend follows a modular design pattern with clear separation of concerns:
|
||||
|
||||
1. **Core Layer**: Manager modules provide the primary API and business logic
|
||||
2. **Utility Layer**: Helper modules provide specialized functionality
|
||||
3. **Integration Layer**: Modules that connect to external systems
|
||||
|
||||
## Security Model
|
||||
|
||||
The system implements a comprehensive security framework with multiple levels:
|
||||
|
||||
- **Block**: Highest security - blocks most remote operations
|
||||
- **High**: Allows only specific trusted operations
|
||||
- **Middle**: Standard security for most users
|
||||
- **Normal-**: More permissive for advanced users
|
||||
- **Weak**: Lowest security for development environments
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- The backend is designed to work seamlessly with ComfyUI
|
||||
- Asynchronous task queuing is implemented for background operations
|
||||
- The system supports multiple installation modes
|
||||
- Error handling and risk assessment are integrated throughout the codebase
|
||||
|
||||
## API Integration
|
||||
|
||||
The backend exposes a REST API via `manager_server.py` that enables:
|
||||
- Custom node management (install, update, disable, remove)
|
||||
- Model downloading and organization
|
||||
- System configuration
|
||||
- Snapshot management
|
||||
- Workflow component handling
|
||||
+6
-6
@@ -68,6 +68,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
else:
|
||||
form_factor = 'other'
|
||||
|
||||
print(f"form_factor: {form_factor}")
|
||||
while remained:
|
||||
# Add comfyui_version and form_factor to the API request
|
||||
sub_uri = f'{base_url}/nodes?page={page}&limit=30&comfyui_version={comfyui_ver}&form_factor={form_factor}'
|
||||
@@ -77,8 +78,8 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
|
||||
for x in sub_json_obj['nodes']:
|
||||
full_nodes[x['id']] = x
|
||||
|
||||
# if page % 5 == 0:
|
||||
# print(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
|
||||
if page % 5 == 0:
|
||||
print(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
|
||||
|
||||
page += 1
|
||||
time.sleep(0.5)
|
||||
@@ -129,7 +130,6 @@ class NodeVersion:
|
||||
id: str
|
||||
version: str
|
||||
download_url: str
|
||||
status: str = ''
|
||||
|
||||
|
||||
def map_node_version(api_node_version):
|
||||
@@ -161,7 +161,6 @@ def map_node_version(api_node_version):
|
||||
download_url=api_node_version.get(
|
||||
"downloadUrl", ""
|
||||
), # Provide a default value if 'downloadUrl' is missing
|
||||
status=api_node_version.get('status', ''),
|
||||
)
|
||||
|
||||
|
||||
@@ -181,8 +180,9 @@ def install_node(node_id, version=None):
|
||||
else:
|
||||
url = f"{base_url}/nodes/{node_id}/install?version={version}"
|
||||
|
||||
response = requests.get(url, verify=not manager_util.bypass_ssl)
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
# Convert the API response to a NodeVersion object
|
||||
return map_node_version(response.json())
|
||||
else:
|
||||
return None
|
||||
@@ -191,7 +191,7 @@ def install_node(node_id, version=None):
|
||||
def all_versions_of_node(node_id):
|
||||
url = f"{base_url}/nodes/{node_id}/versions?statuses=NodeVersionStatusActive&statuses=NodeVersionStatusPending"
|
||||
|
||||
response = requests.get(url, verify=not manager_util.bypass_ssl)
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
|
||||
@@ -46,8 +46,6 @@ def git_url(fullpath):
|
||||
|
||||
for k, v in config.items():
|
||||
if k.startswith('remote ') and 'url' in v:
|
||||
if 'Comfy-Org/ComfyUI-Manager' in v['url']:
|
||||
return "https://github.com/ltdrdata/ComfyUI-Manager"
|
||||
return v['url']
|
||||
|
||||
return None
|
||||
|
||||
+275
-611
File diff suppressed because it is too large
Load Diff
@@ -55,11 +55,7 @@ def download_url(model_url: str, model_dir: str, filename: str):
|
||||
return aria2_download_url(model_url, model_dir, filename)
|
||||
else:
|
||||
from torchvision.datasets.utils import download_url as torchvision_download_url
|
||||
try:
|
||||
return torchvision_download_url(model_url, model_dir, filename)
|
||||
except Exception as e:
|
||||
logging.error(f"[ComfyUI-Manager] Failed to download: {model_url} / {repr(e)}")
|
||||
raise
|
||||
return torchvision_download_url(model_url, model_dir, filename)
|
||||
|
||||
|
||||
def aria2_find_task(dir: str, filename: str):
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
"""
|
||||
ComfyUI-Manager migration module.
|
||||
Handles migration from legacy paths to new __manager path structure.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import configparser
|
||||
|
||||
# Startup notices for notice board
|
||||
startup_notices = [] # List of (message, level) tuples
|
||||
|
||||
|
||||
def add_startup_notice(message, level='warning'):
|
||||
"""Add a notice to be displayed on Manager notice board.
|
||||
|
||||
Args:
|
||||
message: HTML-formatted message string
|
||||
level: 'warning', 'error', 'info'
|
||||
"""
|
||||
global startup_notices
|
||||
startup_notices.append((message, level))
|
||||
|
||||
|
||||
# Cache for API check (computed once per session)
|
||||
_cached_has_system_user_api = None
|
||||
|
||||
|
||||
def has_system_user_api():
|
||||
"""Check if ComfyUI has the System User Protection API (PR #10966).
|
||||
|
||||
Result is cached for performance.
|
||||
"""
|
||||
global _cached_has_system_user_api
|
||||
if _cached_has_system_user_api is None:
|
||||
try:
|
||||
import folder_paths
|
||||
_cached_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
|
||||
except Exception:
|
||||
_cached_has_system_user_api = False
|
||||
return _cached_has_system_user_api
|
||||
|
||||
|
||||
def get_manager_path(user_dir):
|
||||
"""Get the appropriate manager files path based on ComfyUI version.
|
||||
|
||||
Returns:
|
||||
str: manager_files_path
|
||||
"""
|
||||
if has_system_user_api():
|
||||
return os.path.abspath(os.path.join(user_dir, '__manager'))
|
||||
else:
|
||||
return os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
|
||||
|
||||
|
||||
def run_migration_checks(user_dir, manager_files_path):
|
||||
"""Run all migration and security checks.
|
||||
|
||||
Call this after get_manager_path() to handle:
|
||||
- Legacy config migration (new ComfyUI)
|
||||
- Legacy backup notification (every startup)
|
||||
- Suspicious directory detection (old ComfyUI)
|
||||
- Outdated ComfyUI warning (old ComfyUI)
|
||||
"""
|
||||
if has_system_user_api():
|
||||
migrated = migrate_legacy_config(user_dir, manager_files_path)
|
||||
# Only check for legacy backup if migration didn't just happen
|
||||
# (migration already shows backup location in its message)
|
||||
if not migrated:
|
||||
check_legacy_backup(manager_files_path)
|
||||
else:
|
||||
check_suspicious_manager(user_dir)
|
||||
warn_outdated_comfyui()
|
||||
|
||||
|
||||
def check_legacy_backup(manager_files_path):
|
||||
"""Check for legacy backup and notify user to verify and remove it.
|
||||
|
||||
This runs on every startup to remind users about pending legacy backup.
|
||||
"""
|
||||
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
|
||||
if not os.path.exists(backup_dir):
|
||||
return
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy backup exists")
|
||||
print(" - Your old Manager data was backed up to:")
|
||||
print(f" {backup_dir}")
|
||||
print(" - Please verify and remove it when no longer needed.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"Legacy ComfyUI-Manager data backup exists. Please verify and remove when no longer needed. See terminal for details.",
|
||||
level='info'
|
||||
)
|
||||
|
||||
|
||||
def check_suspicious_manager(user_dir):
|
||||
"""Check for suspicious __manager directory on old ComfyUI.
|
||||
|
||||
On old ComfyUI without System User API, if __manager exists with low security,
|
||||
warn the user to verify manually.
|
||||
|
||||
Returns:
|
||||
bool: True if suspicious setup detected
|
||||
"""
|
||||
if has_system_user_api():
|
||||
return False # Not suspicious on new ComfyUI
|
||||
|
||||
suspicious_path = os.path.abspath(os.path.join(user_dir, '__manager'))
|
||||
if not os.path.exists(suspicious_path):
|
||||
return False
|
||||
|
||||
config_path = os.path.join(suspicious_path, 'config.ini')
|
||||
if not os.path.exists(config_path):
|
||||
return False
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
sec_level = config.get('default', 'security_level', fallback='normal').lower()
|
||||
|
||||
if sec_level in ['weak', 'normal-']:
|
||||
# Terminal output
|
||||
print("\n" + "!"*70)
|
||||
print("[ComfyUI-Manager] ERROR: Suspicious path detected!")
|
||||
print(f" - '__manager' exists with low security level: '{sec_level}'")
|
||||
print(" - Please verify manually:")
|
||||
print(f" {config_path}")
|
||||
print("!"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"[Security Alert] Suspicious path detected. See terminal log for details.",
|
||||
level='error'
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def warn_outdated_comfyui():
|
||||
"""Warn user about outdated ComfyUI without System User API."""
|
||||
if has_system_user_api():
|
||||
return
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "!"*70)
|
||||
print("[ComfyUI-Manager] ERROR: ComfyUI version is outdated!")
|
||||
print(" - Most operations are blocked for security.")
|
||||
print(" - ComfyUI update is still allowed.")
|
||||
print(" - Please update ComfyUI to use Manager normally.")
|
||||
print("!"*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
"[Security Alert] ComfyUI outdated. Installations blocked (update allowed).<BR>"
|
||||
"Update ComfyUI for normal operation.",
|
||||
level='error'
|
||||
)
|
||||
|
||||
|
||||
def migrate_legacy_config(user_dir, manager_files_path):
|
||||
"""Migrate ONLY config.ini to new __manager path if needed.
|
||||
|
||||
IMPORTANT: Only config.ini is migrated. Other files (snapshots, cache, etc.)
|
||||
are NOT migrated - users must recreate them.
|
||||
|
||||
Scenarios:
|
||||
1. Legacy exists, New doesn't exist → Migrate config.ini
|
||||
2. Legacy exists, New exists → First update after upgrade
|
||||
- Run ComfyUI dependency installation
|
||||
- Rename legacy to .backup
|
||||
3. Legacy doesn't exist → No migration needed
|
||||
|
||||
Returns:
|
||||
bool: True if migration was performed
|
||||
"""
|
||||
if not has_system_user_api():
|
||||
return False
|
||||
|
||||
legacy_dir = os.path.join(user_dir, 'default', 'ComfyUI-Manager')
|
||||
legacy_config = os.path.join(legacy_dir, 'config.ini')
|
||||
new_config = os.path.join(manager_files_path, 'config.ini')
|
||||
|
||||
if not os.path.exists(legacy_dir):
|
||||
return False # No legacy directory, nothing to migrate
|
||||
|
||||
# IMPORTANT: Check for config.ini existence, not just directory
|
||||
# (because makedirs() creates __manager before this function is called)
|
||||
|
||||
# Case: Both configs exist (first update after ComfyUI upgrade)
|
||||
# This means user ran new ComfyUI at least once, creating __manager/config.ini
|
||||
if os.path.exists(legacy_config) and os.path.exists(new_config):
|
||||
_handle_first_update_migration(user_dir, legacy_dir, manager_files_path)
|
||||
return True
|
||||
|
||||
# Case: Legacy config exists but new config doesn't (normal migration)
|
||||
# This is the first run after ComfyUI upgrade
|
||||
if os.path.exists(legacy_config) and not os.path.exists(new_config):
|
||||
pass # Continue with normal migration below
|
||||
else:
|
||||
return False
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy config.ini detected")
|
||||
print(f" - Old: {legacy_config}")
|
||||
print(f" - New: {new_config}")
|
||||
print(" - Migrating config.ini only (other files are NOT migrated).")
|
||||
print(" - Security level below 'normal' will be raised.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
_migrate_config_with_security_check(legacy_config, new_config)
|
||||
|
||||
# Move legacy directory to backup
|
||||
_move_legacy_to_backup(legacy_dir, manager_files_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_first_update_migration(user_dir, legacy_dir, manager_files_path):
|
||||
"""Handle first ComfyUI update when both legacy and new directories exist.
|
||||
|
||||
This scenario happens when:
|
||||
- User was on old ComfyUI (using default/ComfyUI-Manager)
|
||||
- ComfyUI was updated (now has System User API)
|
||||
- Manager already created __manager on first new run
|
||||
- But legacy directory still exists
|
||||
|
||||
Actions:
|
||||
1. Run ComfyUI dependency installation
|
||||
2. Move legacy to __manager/.legacy-manager-backup
|
||||
"""
|
||||
# Terminal output
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: First update after ComfyUI upgrade detected")
|
||||
print(" - Both legacy and new directories exist.")
|
||||
print(" - Running ComfyUI dependency installation...")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Run ComfyUI dependency installation
|
||||
# Path: glob/manager_migration.py → glob → comfyui-manager → custom_nodes → ComfyUI
|
||||
try:
|
||||
comfyui_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
requirements_path = os.path.join(comfyui_path, 'requirements.txt')
|
||||
if os.path.exists(requirements_path):
|
||||
subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', requirements_path],
|
||||
capture_output=True, check=False)
|
||||
print("[ComfyUI-Manager] ComfyUI dependencies installation completed.")
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to install ComfyUI dependencies: {e}")
|
||||
|
||||
# Move legacy to backup inside __manager
|
||||
_move_legacy_to_backup(legacy_dir, manager_files_path)
|
||||
|
||||
|
||||
def _move_legacy_to_backup(legacy_dir, manager_files_path):
|
||||
"""Move legacy directory to backup inside __manager.
|
||||
|
||||
Returns:
|
||||
str: Path to backup directory if successful, None if failed
|
||||
"""
|
||||
import shutil
|
||||
|
||||
backup_dir = os.path.join(manager_files_path, '.legacy-manager-backup')
|
||||
|
||||
try:
|
||||
if os.path.exists(backup_dir):
|
||||
shutil.rmtree(backup_dir) # Remove old backup if exists
|
||||
shutil.move(legacy_dir, backup_dir)
|
||||
|
||||
# Terminal output (full paths shown here only)
|
||||
print("\n" + "-"*70)
|
||||
print("[ComfyUI-Manager] NOTICE: Legacy settings migrated")
|
||||
print(f" - Old location: {legacy_dir}")
|
||||
print(f" - Backed up to: {backup_dir}")
|
||||
print(" - Please verify and remove the backup when no longer needed.")
|
||||
print("-"*70 + "\n")
|
||||
|
||||
# Notice board output (no full paths for security)
|
||||
add_startup_notice(
|
||||
"Legacy ComfyUI-Manager data migrated. See terminal for details.",
|
||||
level='info'
|
||||
)
|
||||
return backup_dir
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to backup legacy directory: {e}")
|
||||
add_startup_notice(
|
||||
f"[MIGRATION] Failed to backup legacy directory: {e}",
|
||||
level='warning'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _migrate_config_with_security_check(legacy_path, new_path):
|
||||
"""Migrate legacy config, raising security level only if below default."""
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(legacy_path)
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-Manager] WARNING: Failed to parse config.ini: {e}")
|
||||
print(" - Creating fresh config with default settings.")
|
||||
add_startup_notice(
|
||||
"[MIGRATION] Failed to parse legacy config. Using defaults.",
|
||||
level='warning'
|
||||
)
|
||||
return # Skip migration, let Manager create fresh config
|
||||
|
||||
# Security level hierarchy: strong > normal > normal- > weak
|
||||
# Default is 'normal', only raise if below default
|
||||
if 'default' in config:
|
||||
current_level = config['default'].get('security_level', 'normal').lower()
|
||||
below_default_levels = ['weak', 'normal-']
|
||||
|
||||
if current_level in below_default_levels:
|
||||
config['default']['security_level'] = 'normal'
|
||||
|
||||
# Terminal output
|
||||
print("\n" + "="*70)
|
||||
print("[ComfyUI-Manager] WARNING: Security level adjusted")
|
||||
print(f" - Previous: '{current_level}' → New: 'normal'")
|
||||
print(" - Raised to prevent unauthorized remote access.")
|
||||
print("="*70 + "\n")
|
||||
|
||||
# Notice board output
|
||||
add_startup_notice(
|
||||
f"[MIGRATION] Security level raised: '{current_level}' → 'normal'.<BR>"
|
||||
"To prevent unauthorized remote access.",
|
||||
level='warning'
|
||||
)
|
||||
else:
|
||||
print(f" - Security level: '{current_level}' (no change needed)")
|
||||
|
||||
# Ensure directory exists
|
||||
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
||||
|
||||
with open(new_path, 'w') as f:
|
||||
config.write(f)
|
||||
|
||||
|
||||
def force_security_level_if_needed(config_dict):
|
||||
"""Force security level to 'strong' if on old ComfyUI.
|
||||
|
||||
Args:
|
||||
config_dict: Configuration dictionary to modify in-place
|
||||
|
||||
Returns:
|
||||
bool: True if security level was forced
|
||||
"""
|
||||
if not has_system_user_api():
|
||||
config_dict['security_level'] = 'strong'
|
||||
return True
|
||||
return False
|
||||
+123
-476
@@ -11,8 +11,6 @@ import threading
|
||||
import re
|
||||
import shutil
|
||||
import git
|
||||
import glob
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from server import PromptServer
|
||||
@@ -24,7 +22,6 @@ import asyncio
|
||||
import queue
|
||||
|
||||
import manager_downloader
|
||||
import manager_migration
|
||||
|
||||
|
||||
logging.info(f"### Loading: ComfyUI-Manager ({core.version_str})")
|
||||
@@ -33,35 +30,13 @@ logging.info("[ComfyUI-Manager] network_mode: " + core.get_config()['network_mod
|
||||
comfy_ui_hash = "-"
|
||||
comfyui_tag = None
|
||||
|
||||
SECURITY_MESSAGE_MIDDLE_OR_BELOW = "ERROR: To use this action, security_level must be any value other than 'strong' (allowed: normal, normal-, weak). security_level is read once at ComfyUI startup, so changing it needs a restart, done with the server down: STOP ComfyUI, edit config.ini, then start it again. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_MIDDLE_OR_BELOW = "ERROR: To use this action, a security_level of `middle or below` is required. 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 not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. To download this model, set security_level to 'weak' - or to 'normal-' if ComfyUI is listening on a loopback address (--listen 127.0.0.1 or ::1). Both security_level and the listen address are read once at ComfyUI startup, so changing either needs a restart, done with the server down: stop ComfyUI, edit config.ini, then start it again."
|
||||
SECURITY_MESSAGE_FLAG_GIT_URL = "ERROR: This action requires BOTH: (1) 'allow_git_url_install = true' in config.ini ([default] section), AND (2) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1) - currently listening on {listen}. 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. Reference: https://github.com/ltdrdata/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) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1) - currently listening on {listen}. 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. Reference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_BLOCKED_RISK = "ERROR: This node pack is classified as blocked-risk and cannot be installed at any security_level. This is not a configuration problem - contact the administrator if you believe the classification is wrong.\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."
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
|
||||
def has_per_queue_preview():
|
||||
"""
|
||||
Check if ComfyUI PR #11261 (per-queue live preview override) is merged
|
||||
|
||||
Returns:
|
||||
bool: True if ComfyUI has per-queue preview feature
|
||||
"""
|
||||
try:
|
||||
import latent_preview
|
||||
return hasattr(latent_preview, 'set_preview_method')
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
# Detect ComfyUI per-queue preview override feature (PR #11261)
|
||||
COMFYUI_HAS_PER_QUEUE_PREVIEW = has_per_queue_preview()
|
||||
|
||||
|
||||
def handle_stream(stream, prefix):
|
||||
stream.reconfigure(encoding=locale.getpreferredencoding(), errors='replace')
|
||||
for msg in stream:
|
||||
@@ -87,19 +62,6 @@ def is_loopback(address):
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def is_dedicated_install_allowed(flag_value: bool, listen_address: str) -> bool:
|
||||
"""P-direct predicate (adopter-degraded form): flag AND loopback.
|
||||
|
||||
Pure helper for the dedicated install flags
|
||||
(allow_git_url_install / allow_pip_install) — callers pass the
|
||||
flag value from their own config read and the listener address
|
||||
from the CLI arguments (request-time evaluation; the import-time
|
||||
snapshot above is NOT consulted).
|
||||
"""
|
||||
return bool(flag_value) and is_loopback(listen_address)
|
||||
|
||||
|
||||
is_local_mode = is_loopback(args.listen)
|
||||
|
||||
|
||||
@@ -162,9 +124,6 @@ async def get_risky_level(files, pip_packages):
|
||||
|
||||
|
||||
class ManagerFuncsInComfyUI(core.ManagerFuncs):
|
||||
def is_flagged_install_allowed(self):
|
||||
return core.get_config()['allow_flagged_nodepack_install'] or manager_util.is_loopback_listener(args.listen)
|
||||
|
||||
def get_current_preview_method(self):
|
||||
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
|
||||
return "auto"
|
||||
@@ -222,19 +181,7 @@ def set_preview_method(method):
|
||||
core.get_config()['preview_method'] = method
|
||||
|
||||
|
||||
if COMFYUI_HAS_PER_QUEUE_PREVIEW:
|
||||
logging.info(
|
||||
"[ComfyUI-Manager] ComfyUI per-queue preview override detected (PR #11261). "
|
||||
"Manager's preview method feature is disabled. "
|
||||
"Use ComfyUI's --preview-method CLI option or 'Settings > Execution > Live preview method'."
|
||||
)
|
||||
elif args.preview_method == latent_preview.LatentPreviewMethod.NoPreviews:
|
||||
set_preview_method(core.get_config()['preview_method'])
|
||||
else:
|
||||
logging.warning(
|
||||
"[ComfyUI-Manager] Since --preview-method is set, "
|
||||
"ComfyUI-Manager's preview method feature will be ignored."
|
||||
)
|
||||
set_preview_method(core.get_config()['preview_method'])
|
||||
|
||||
|
||||
def set_component_policy(mode):
|
||||
@@ -307,6 +254,7 @@ print_comfyui_version()
|
||||
core.check_invalid_nodes()
|
||||
|
||||
|
||||
|
||||
def setup_environment():
|
||||
git_exe = core.get_config()['git_exe']
|
||||
|
||||
@@ -320,77 +268,12 @@ setup_environment()
|
||||
|
||||
from aiohttp import web
|
||||
import aiohttp
|
||||
import json
|
||||
import zipfile
|
||||
import urllib.request
|
||||
|
||||
|
||||
def security_403_response(flag_token=None):
|
||||
"""Return appropriate 403 response based on ComfyUI version.
|
||||
|
||||
When `flag_token` is given (dedicated install flag denials), the
|
||||
body names the responsible flag instead of "security_level". The
|
||||
`comfyui_outdated` branch stays the FIRST check regardless, and
|
||||
no-arg callers keep today's body byte-identical.
|
||||
"""
|
||||
if not manager_migration.has_system_user_api():
|
||||
return web.json_response({"error": "comfyui_outdated"}, status=403)
|
||||
if flag_token is not None:
|
||||
return web.json_response({"error": flag_token}, status=403)
|
||||
return web.json_response({"error": "security_level"}, status=403)
|
||||
|
||||
|
||||
# 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
|
||||
# trigger a CORS preflight, so a malicious cross-origin page can silently POST
|
||||
# into state-changing endpoints if we only gate on HTTP method. Blocking these
|
||||
# three Content-Types on no-body mutation endpoints forces any non-same-origin
|
||||
# POST to use a non-simple Content-Type (e.g. application/json), which triggers
|
||||
# a preflight that this server rejects by not advertising an Access-Control-
|
||||
# Allow-Origin response.
|
||||
_SIMPLE_FORM_CONTENT_TYPES = frozenset({
|
||||
'application/x-www-form-urlencoded',
|
||||
'multipart/form-data',
|
||||
'text/plain',
|
||||
})
|
||||
|
||||
|
||||
def _reject_simple_form_content_type(request):
|
||||
"""Reject Content-Types that enable preflight-less <form method=POST> CSRF.
|
||||
|
||||
Applied ONLY to POST handlers that do not consume a request body (e.g.,
|
||||
/snapshot/save, /manager/queue/{reset,start,update_comfyui},
|
||||
/manager/reboot). These are vulnerable to cross-origin <form method=POST>
|
||||
attacks because the handler accepts the request without parsing any body —
|
||||
the attacker needs no ability to forge a valid payload, only to point a
|
||||
hidden form at the URL.
|
||||
|
||||
Handlers that already read a body via ``await request.json()`` are NOT
|
||||
gated here: a cross-origin <form method=POST> cannot forge a valid JSON
|
||||
body because the browser refuses to send ``application/json`` without a
|
||||
CORS preflight, which this server does not answer.
|
||||
|
||||
DO NOT add this gate to body-reading handlers — redundant and UX-breaking.
|
||||
DO NOT remove this gate from no-body handlers — this is the bypass vector.
|
||||
|
||||
aiohttp's ``request.content_type`` normalizes the header (lower-cases,
|
||||
strips parameters), so ``multipart/form-data; boundary=----X`` is compared
|
||||
as ``multipart/form-data``.
|
||||
|
||||
Returns:
|
||||
web.Response(status=400) when the request has a simple-form
|
||||
Content-Type that must be rejected. None when the request is allowed
|
||||
to proceed (no Content-Type, application/json, or any non-simple
|
||||
Content-Type).
|
||||
"""
|
||||
if request.content_type in _SIMPLE_FORM_CONTENT_TYPES:
|
||||
return web.Response(
|
||||
status=400,
|
||||
text='Invalid Content-Type for this endpoint. Use application/json or omit body.',
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_model_dir(data, show_log=False):
|
||||
def get_model_dir(data, show_log=False) -> str | None:
|
||||
if 'download_model_base' in folder_paths.folder_names_and_paths:
|
||||
models_base = folder_paths.folder_names_and_paths['download_model_base'][0][0]
|
||||
else:
|
||||
@@ -520,7 +403,7 @@ async def task_worker():
|
||||
global model_result
|
||||
global tasks_in_progress
|
||||
|
||||
async def do_install(item, operation) -> str:
|
||||
async def do_install(item) -> str:
|
||||
ui_id, node_spec_str, channel, mode, skip_post_install = item
|
||||
|
||||
try:
|
||||
@@ -530,10 +413,7 @@ async def task_worker():
|
||||
return f"Cannot resolve install target: '{node_spec_str}'"
|
||||
|
||||
node_name, version_spec, is_specified = node_spec
|
||||
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)
|
||||
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install)
|
||||
# discard post install if skip_post_install mode
|
||||
|
||||
if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']:
|
||||
@@ -557,10 +437,7 @@ async def task_worker():
|
||||
|
||||
if res.ver == 'unknown':
|
||||
url = core.unified_manager.unknown_active_nodes[node_name][0]
|
||||
try:
|
||||
title = os.path.basename(url)
|
||||
except Exception:
|
||||
title = node_name
|
||||
title = os.path.basename(url)
|
||||
else:
|
||||
url = core.unified_manager.cnr_map[node_name].get('repository')
|
||||
title = core.unified_manager.cnr_map[node_name]['name']
|
||||
@@ -580,7 +457,7 @@ async def task_worker():
|
||||
base_res['msg'] = 'success'
|
||||
return base_res
|
||||
|
||||
base_res['msg'] = res.msg or f"An error occurred while updating '{node_name}'."
|
||||
base_res['msg'] = 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:
|
||||
@@ -706,7 +583,7 @@ async def task_worker():
|
||||
return 'success'
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[ComfyUI-Manager] ERROR: {e}")
|
||||
logging.error(f"[ComfyUI-Manager] ERROR: {e}", file=sys.stderr)
|
||||
|
||||
return f"Model installation error: {model_url}"
|
||||
|
||||
@@ -733,8 +610,8 @@ async def task_worker():
|
||||
tasks_in_progress.add((kind, item[0]))
|
||||
|
||||
try:
|
||||
if kind in ('install', 'reinstall'):
|
||||
msg = await do_install(item, kind)
|
||||
if kind == 'install':
|
||||
msg = await do_install(item)
|
||||
elif kind == 'install-model':
|
||||
msg = await do_install_model(item)
|
||||
elif kind == 'update':
|
||||
@@ -818,19 +695,16 @@ async def fetch_customnode_mappings(request):
|
||||
return web.json_response(json_obj, content_type='application/json')
|
||||
|
||||
|
||||
@routes.post("/customnode/fetch_updates")
|
||||
@routes.get("/customnode/fetch_updates")
|
||||
async def fetch_updates(request):
|
||||
try:
|
||||
json_data = await request.json()
|
||||
mode = json_data.get("mode", "default")
|
||||
|
||||
if mode == "local":
|
||||
if request.rel_url.query["mode"] == "local":
|
||||
channel = 'local'
|
||||
else:
|
||||
channel = core.get_config()['channel_url']
|
||||
|
||||
await core.unified_manager.reload(mode)
|
||||
await core.unified_manager.get_custom_nodes(channel, mode)
|
||||
await core.unified_manager.reload(request.rel_url.query["mode"])
|
||||
await core.unified_manager.get_custom_nodes(channel, request.rel_url.query["mode"])
|
||||
|
||||
res = core.unified_manager.fetch_or_pull_git_repo(is_pull=False)
|
||||
|
||||
@@ -846,111 +720,28 @@ async def fetch_updates(request):
|
||||
except:
|
||||
traceback.print_exc()
|
||||
return web.Response(status=400)
|
||||
|
||||
@routes.get("/customnode/get_node_types_in_workflows")
|
||||
async def get_node_types_in_workflows(request):
|
||||
try:
|
||||
# get our username from the request header
|
||||
user_id = PromptServer.instance.user_manager.get_request_user_id(request)
|
||||
|
||||
# get the base workflow directory (TODO: figure out if non-standard directories are possible, and how to find them)
|
||||
workflow_files_base_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), user_id, "workflows"))
|
||||
|
||||
logging.debug(f"workflows base path: {workflow_files_base_path}")
|
||||
|
||||
# workflow directory doesn't actually exist, return 204 (No Content)
|
||||
if not os.path.isdir(workflow_files_base_path):
|
||||
logging.debug("workflows base path doesn't exist - nothing to do...")
|
||||
return web.Response(status=204)
|
||||
|
||||
# get all JSON files under the workflow directory
|
||||
workflow_file_relative_paths: list[str] = glob.glob(pathname="**/*.json", root_dir=workflow_files_base_path, recursive=True)
|
||||
|
||||
logging.debug(f"found the following workflows: {workflow_file_relative_paths}")
|
||||
|
||||
# set up our list of workflow/node-lists
|
||||
workflow_node_mappings: list[dict[str, str | list[str]]] = []
|
||||
|
||||
# iterate over each found JSON file
|
||||
for workflow_file_path in workflow_file_relative_paths:
|
||||
|
||||
try:
|
||||
workflow_file_absolute_path = os.path.abspath(os.path.join(workflow_files_base_path, workflow_file_path))
|
||||
logging.debug(f"starting work on {workflow_file_absolute_path}")
|
||||
# load the JSON file
|
||||
workflow_file_data = json.load(open(workflow_file_absolute_path, "r"))
|
||||
|
||||
# make sure there's a nodes key (otherwise this might not actually be a workflow file)
|
||||
if "nodes" not in workflow_file_data:
|
||||
logging.warning(f"{workflow_file_path} has no 'nodes' key (possibly invalid?) - skipping...")
|
||||
# skip to next file
|
||||
continue
|
||||
|
||||
# now this looks like a valid file, so let's get to work
|
||||
new_mapping = {"workflow_file_name": workflow_file_path}
|
||||
# we can't use an actual set, because you can't use dicts as set members
|
||||
node_set = []
|
||||
|
||||
# iterate over each node in the workflow
|
||||
for node in workflow_file_data["nodes"]:
|
||||
if "id" not in node:
|
||||
logging.warning("Found a node with no ID - possibly corrupt/invalid workflow?")
|
||||
continue
|
||||
# if there's no type, throw a warning
|
||||
if "type" not in node:
|
||||
logging.warning(f"Node type not found in {workflow_file_path} for node ID {node['id']}")
|
||||
# skip to next node
|
||||
continue
|
||||
|
||||
node_data_to_return = {"type": node["type"]}
|
||||
if "properties" not in node:
|
||||
logging.warning(f"Node ${node['id']} has no properties field - can't determine cnr_id")
|
||||
else:
|
||||
for property_key in ["cnr_id", "ver"]:
|
||||
if property_key in node["properties"]:
|
||||
node_data_to_return[property_key] = node["properties"][property_key]
|
||||
|
||||
# add it to the list for this workflow
|
||||
if not node_data_to_return in node_set:
|
||||
node_set.append(node_data_to_return)
|
||||
|
||||
# annoyingly, Python can't serialize sets to JSON
|
||||
new_mapping["node_types"] = list(node_set)
|
||||
workflow_node_mappings.append(new_mapping)
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Couldn't open {workflow_file_path}: {e}")
|
||||
|
||||
return web.json_response(workflow_node_mappings, content_type='application/json')
|
||||
|
||||
except:
|
||||
traceback.print_exc()
|
||||
return web.Response(status=500)
|
||||
|
||||
|
||||
@routes.post("/manager/queue/update_all")
|
||||
@routes.get("/manager/queue/update_all")
|
||||
async def update_all(request):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return security_403_response()
|
||||
return web.Response(status=403)
|
||||
|
||||
with task_worker_lock:
|
||||
is_processing = task_worker_thread is not None and task_worker_thread.is_alive()
|
||||
if is_processing:
|
||||
return web.Response(status=401)
|
||||
|
||||
|
||||
await core.save_snapshot_with_postfix('autosave')
|
||||
|
||||
json_data = await request.json()
|
||||
mode = json_data.get("mode", "default")
|
||||
|
||||
if mode == "local":
|
||||
if request.rel_url.query["mode"] == "local":
|
||||
channel = 'local'
|
||||
else:
|
||||
channel = core.get_config()['channel_url']
|
||||
|
||||
await core.unified_manager.reload(mode)
|
||||
await core.unified_manager.get_custom_nodes(channel, mode)
|
||||
await core.unified_manager.reload(request.rel_url.query["mode"])
|
||||
await core.unified_manager.get_custom_nodes(channel, request.rel_url.query["mode"])
|
||||
|
||||
for k, v in core.unified_manager.active_nodes.items():
|
||||
if k == 'comfyui-manager':
|
||||
@@ -974,22 +765,14 @@ async def update_all(request):
|
||||
|
||||
|
||||
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):
|
||||
# Entities written in the source URL (e.g. &) retain their meaning.
|
||||
url = manager_util.unescape_html_entities(match.group(2))
|
||||
hrefs.append(manager_util.sanitize_url(url))
|
||||
text = manager_util.escape_html_text(match.group(1))
|
||||
return f"<a href='\x00H{len(hrefs) - 1}\x00' target='_blank' rel='noopener noreferrer'>{text}</a>"
|
||||
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>"
|
||||
@@ -1003,35 +786,20 @@ def convert_markdown_to_html(input_text):
|
||||
def replace_white(match):
|
||||
return f"<font color='white'>{match.group(1)}</font>"
|
||||
|
||||
# NUL dropped first so the input cannot forge the href placeholders.
|
||||
input_text = input_text.replace('\x00', '')
|
||||
input_text = input_text.replace('\\[', '[').replace('\\]', ']')
|
||||
input_text = input_text.replace('\\[', '[').replace('\\]', ']').replace('<', '<').replace('>', '>')
|
||||
|
||||
# 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_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)
|
||||
result_text = result_text.replace("\n", "<BR>")
|
||||
|
||||
return re.sub(
|
||||
r'\x00H(\d+)\x00',
|
||||
lambda m: manager_util.escape_html_attribute(hrefs[int(m.group(1))]),
|
||||
result_text,
|
||||
)
|
||||
return result_text.replace("\n", "<BR>")
|
||||
|
||||
|
||||
def populate_markdown(x):
|
||||
if 'description' in x:
|
||||
x['description'] = convert_markdown_to_html(x['description'])
|
||||
x['description'] = convert_markdown_to_html(manager_util.sanitize_tag(x['description']))
|
||||
|
||||
if 'name' in x:
|
||||
x['name'] = manager_util.sanitize_tag(x['name'])
|
||||
@@ -1091,7 +859,7 @@ async def fetch_customnode_list(request):
|
||||
|
||||
channel = found
|
||||
|
||||
result = dict(channel=channel, node_packs=node_packs.to_dict())
|
||||
result = dict(channel=channel, node_packs=node_packs)
|
||||
|
||||
return web.json_response(result, content_type='application/json')
|
||||
|
||||
@@ -1187,30 +955,16 @@ async def get_snapshot_list(request):
|
||||
return web.json_response({'items': items}, content_type='application/json')
|
||||
|
||||
|
||||
def get_safe_snapshot_path(target):
|
||||
"""
|
||||
Safely construct a snapshot file path, preventing path traversal attacks.
|
||||
"""
|
||||
if '/' in target or '\\' in target or '..' in target or '\x00' in target:
|
||||
return None
|
||||
return os.path.join(core.manager_snapshot_path, f"{target}.json")
|
||||
|
||||
|
||||
@routes.post("/snapshot/remove")
|
||||
@routes.get("/snapshot/remove")
|
||||
async def remove_snapshot(request):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return security_403_response()
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
json_data = await request.json()
|
||||
target = json_data["target"]
|
||||
path = get_safe_snapshot_path(target)
|
||||
|
||||
if path is None:
|
||||
logging.error(f"[ComfyUI-Manager] Invalid snapshot target: {target}")
|
||||
return web.Response(text="Invalid snapshot target", status=400)
|
||||
target = request.rel_url.query["target"]
|
||||
|
||||
path = os.path.join(core.manager_snapshot_path, f"{target}.json")
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
@@ -1219,21 +973,16 @@ async def remove_snapshot(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/snapshot/restore")
|
||||
@routes.get("/snapshot/restore")
|
||||
async def restore_snapshot(request):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return security_403_response()
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
json_data = await request.json()
|
||||
target = json_data["target"]
|
||||
path = get_safe_snapshot_path(target)
|
||||
|
||||
if path is None:
|
||||
logging.error(f"[ComfyUI-Manager] Invalid snapshot target: {target}")
|
||||
return web.Response(text="Invalid snapshot target", status=400)
|
||||
target = request.rel_url.query["target"]
|
||||
|
||||
path = os.path.join(core.manager_snapshot_path, f"{target}.json")
|
||||
if os.path.exists(path):
|
||||
if not os.path.exists(core.manager_startup_script_path):
|
||||
os.makedirs(core.manager_startup_script_path)
|
||||
@@ -1258,11 +1007,8 @@ async def get_current_snapshot_api(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/snapshot/save")
|
||||
@routes.get("/snapshot/save")
|
||||
async def save_snapshot(request):
|
||||
resp = _reject_simple_form_content_type(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
try:
|
||||
await core.save_snapshot_with_postfix('snapshot')
|
||||
return web.Response(status=200)
|
||||
@@ -1419,14 +1165,12 @@ async def import_fail_info(request):
|
||||
|
||||
@routes.post("/manager/queue/reinstall")
|
||||
async def reinstall_custom_node(request):
|
||||
return await _queue_node_install(request, "reinstall")
|
||||
await uninstall_custom_node(request)
|
||||
await install_custom_node(request)
|
||||
|
||||
|
||||
@routes.post("/manager/queue/reset")
|
||||
@routes.get("/manager/queue/reset")
|
||||
async def reset_queue(request):
|
||||
resp = _reject_simple_form_content_type(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
global task_queue
|
||||
task_queue = queue.Queue()
|
||||
return web.Response(status=200)
|
||||
@@ -1449,20 +1193,16 @@ async def queue_count(request):
|
||||
|
||||
@routes.post("/manager/queue/install")
|
||||
async def install_custom_node(request):
|
||||
return await _queue_node_install(request, "install")
|
||||
|
||||
|
||||
async def _queue_node_install(request, operation):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return web.Response(status=403, text="A security error has occurred. Please check the terminal logs")
|
||||
|
||||
json_data = await request.json()
|
||||
|
||||
# CNR version status is checked by the worker before installation.
|
||||
# non-nightly cnr is safe
|
||||
risky_level = None
|
||||
cnr_id = json_data.get('id')
|
||||
skip_post_install = False if operation == 'reinstall' else json_data.get('skip_post_install')
|
||||
skip_post_install = json_data.get('skip_post_install')
|
||||
|
||||
git_url = None
|
||||
|
||||
@@ -1471,26 +1211,6 @@ async def _queue_node_install(request, operation):
|
||||
if skip_post_install:
|
||||
if cnr_id in core.unified_manager.nightly_inactive_nodes or cnr_id in core.unified_manager.cnr_inactive_nodes:
|
||||
core.unified_manager.unified_enable(cnr_id)
|
||||
# Mirror the pair of events (in_progress then done) that the async
|
||||
# task_worker normally emits for queued operations. The in_progress
|
||||
# event is what sets item.restart=true on the client row so the
|
||||
# action cell re-renders as "Restart Required"; without it, the
|
||||
# "Enable" button remains visible after successful enable. The done
|
||||
# event drives the completion UI (toast, restart indicator, button
|
||||
# loading clear).
|
||||
ui_id = json_data.get('ui_id', cnr_id)
|
||||
PromptServer.instance.send_sync(
|
||||
"cm-queue-status",
|
||||
{'status': 'in_progress',
|
||||
'target': ui_id,
|
||||
'ui_target': 'nodepack_manager',
|
||||
'total_count': 1, 'done_count': 0})
|
||||
PromptServer.instance.send_sync(
|
||||
"cm-queue-status",
|
||||
{'status': 'done',
|
||||
'nodepack_result': {ui_id: 'success'},
|
||||
'model_result': {},
|
||||
'total_count': 1, 'done_count': 1})
|
||||
return web.Response(status=200)
|
||||
elif selected_version is None:
|
||||
selected_version = 'latest'
|
||||
@@ -1520,33 +1240,20 @@ async def _queue_node_install(request, operation):
|
||||
else:
|
||||
return web.Response(status=404, text=f"Following node pack doesn't provide `nightly` version: ${git_url}")
|
||||
|
||||
if risky_level == 'high':
|
||||
# unknown-URL arm: governed by the dedicated flag predicate
|
||||
# (flag AND loopback, evaluated at request time). The loopback
|
||||
# term is load-bearing here — the 'middle' entry gate above has
|
||||
# no network-position term.
|
||||
if not is_dedicated_install_allowed(core.get_config()['allow_git_url_install'], args.listen):
|
||||
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(listen=args.listen))
|
||||
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
|
||||
elif not is_allowed_security_level(risky_level):
|
||||
# 'block' arm stays an unconditional deny (is_allowed_security_level
|
||||
# returns False for 'block'); 'middle'/'low' arms unchanged.
|
||||
logging.error(SECURITY_MESSAGE_BLOCKED_RISK)
|
||||
if 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
|
||||
task_queue.put((operation, install_item))
|
||||
task_queue.put(("install", install_item))
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
task_worker_thread:threading.Thread = None
|
||||
|
||||
@routes.post("/manager/queue/start")
|
||||
@routes.get("/manager/queue/start")
|
||||
async def queue_start(request):
|
||||
resp = _reject_simple_form_content_type(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
global nodepack_result
|
||||
global model_result
|
||||
global task_worker_thread
|
||||
@@ -1587,21 +1294,11 @@ async def fix_custom_node(request):
|
||||
|
||||
@routes.post("/customnode/install/git_url")
|
||||
async def install_custom_node_git_url(request):
|
||||
if not is_dedicated_install_allowed(core.get_config()['allow_git_url_install'], args.listen):
|
||||
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(listen=args.listen))
|
||||
return security_403_response(flag_token='allow_git_url_install')
|
||||
if not is_allowed_security_level('high'):
|
||||
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
|
||||
return web.Response(status=403)
|
||||
|
||||
# Read the body as JSON (not raw text): a cross-origin <form method=POST>
|
||||
# cannot forge an application/json body without a CORS preflight, which this
|
||||
# server does not answer — same body-handler convention as every other
|
||||
# request.json() route (see _reject_simple_form_content_type docstring).
|
||||
# A malformed body or a missing 'url' is a client error (400), not a 500:
|
||||
# don't let JSONDecodeError/KeyError bubble up as a server fault.
|
||||
try:
|
||||
json_data = await request.json()
|
||||
url = json_data['url']
|
||||
except (KeyError, ValueError):
|
||||
return web.Response(status=400, text="Invalid request body: expected JSON object with a 'url' field")
|
||||
url = await request.text()
|
||||
res = await core.gitclone_install(url)
|
||||
|
||||
if res.action == 'skip':
|
||||
@@ -1617,18 +1314,11 @@ async def install_custom_node_git_url(request):
|
||||
|
||||
@routes.post("/customnode/install/pip")
|
||||
async def install_custom_node_pip(request):
|
||||
if not is_dedicated_install_allowed(core.get_config()['allow_pip_install'], args.listen):
|
||||
logging.error(SECURITY_MESSAGE_FLAG_PIP.format(listen=args.listen))
|
||||
return security_403_response(flag_token='allow_pip_install')
|
||||
if not is_allowed_security_level('high'):
|
||||
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
|
||||
return web.Response(status=403)
|
||||
|
||||
# JSON body (not raw text) for the same preflight-forcing reason as
|
||||
# /customnode/install/git_url above; malformed body or missing 'packages'
|
||||
# is a 400, not a 500.
|
||||
try:
|
||||
json_data = await request.json()
|
||||
packages = json_data['packages']
|
||||
except (KeyError, ValueError):
|
||||
return web.Response(status=400, text="Invalid request body: expected JSON object with a 'packages' field")
|
||||
packages = await request.text()
|
||||
core.pip_install(packages.split(' '))
|
||||
|
||||
return web.Response(status=200)
|
||||
@@ -1678,11 +1368,8 @@ async def update_custom_node(request):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/manager/queue/update_comfyui")
|
||||
@routes.get("/manager/queue/update_comfyui")
|
||||
async def update_comfyui(request):
|
||||
resp = _reject_simple_form_content_type(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
is_stable = core.get_config()['update_policy'] != 'nightly-comfyui'
|
||||
task_queue.put(("update-comfyui", ('comfyui', is_stable)))
|
||||
return web.Response(status=200)
|
||||
@@ -1699,12 +1386,11 @@ async def comfyui_versions(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/comfyui_manager/comfyui_switch_version")
|
||||
@routes.get("/comfyui_manager/comfyui_switch_version")
|
||||
async def comfyui_switch_version(request):
|
||||
try:
|
||||
json_data = await request.json()
|
||||
if "ver" in json_data:
|
||||
core.switch_comfyui(json_data['ver'])
|
||||
if "ver" in request.rel_url.query:
|
||||
core.switch_comfyui(request.rel_url.query['ver'])
|
||||
|
||||
return web.Response(status=200)
|
||||
except Exception as e:
|
||||
@@ -1735,12 +1421,6 @@ async def disable_node(request):
|
||||
async def check_whitelist_for_model(item):
|
||||
json_obj = await core.get_data_by_mode('cache', 'model-list.json')
|
||||
|
||||
for x in json_obj.get('models', []):
|
||||
if x['save_path'] == item['save_path'] and x['base'] == item['base'] and x['filename'] == item['filename']:
|
||||
return True
|
||||
|
||||
json_obj = await core.get_data_by_mode('local', 'model-list.json')
|
||||
|
||||
for x in json_obj.get('models', []):
|
||||
if x['save_path'] == item['save_path'] and x['base'] == item['base'] and x['filename'] == item['filename']:
|
||||
return True
|
||||
@@ -1781,89 +1461,87 @@ async def install_model(request):
|
||||
|
||||
|
||||
@routes.get("/manager/preview_method")
|
||||
async def get_preview_method(request):
|
||||
if COMFYUI_HAS_PER_QUEUE_PREVIEW:
|
||||
return web.Response(text="DISABLED", status=200)
|
||||
return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200)
|
||||
async def preview_method(request):
|
||||
if "value" in request.rel_url.query:
|
||||
set_preview_method(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200)
|
||||
|
||||
|
||||
@routes.post("/manager/preview_method")
|
||||
async def set_preview_method_handler(request):
|
||||
if COMFYUI_HAS_PER_QUEUE_PREVIEW:
|
||||
return web.Response(text="DISABLED", status=403)
|
||||
|
||||
json_data = await request.json()
|
||||
set_preview_method(json_data['value'])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/manager/db_mode")
|
||||
async def get_db_mode(request):
|
||||
return web.Response(text=core.get_config()['db_mode'], status=200)
|
||||
async def db_mode(request):
|
||||
if "value" in request.rel_url.query:
|
||||
set_db_mode(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()['db_mode'], status=200)
|
||||
|
||||
|
||||
@routes.post("/manager/db_mode")
|
||||
async def set_db_mode_handler(request):
|
||||
json_data = await request.json()
|
||||
set_db_mode(json_data['value'])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
|
||||
@routes.get("/manager/policy/component")
|
||||
async def get_component_policy(request):
|
||||
return web.Response(text=core.get_config()['component_policy'], status=200)
|
||||
async def component_policy(request):
|
||||
if "value" in request.rel_url.query:
|
||||
set_component_policy(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()['component_policy'], status=200)
|
||||
|
||||
|
||||
@routes.post("/manager/policy/component")
|
||||
async def set_component_policy_handler(request):
|
||||
json_data = await request.json()
|
||||
set_component_policy(json_data['value'])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/manager/policy/update")
|
||||
async def get_update_policy(request):
|
||||
return web.Response(text=core.get_config()['update_policy'], status=200)
|
||||
async def update_policy(request):
|
||||
if "value" in request.rel_url.query:
|
||||
set_update_policy(request.rel_url.query['value'])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()['update_policy'], status=200)
|
||||
|
||||
|
||||
@routes.post("/manager/policy/update")
|
||||
async def set_update_policy_handler(request):
|
||||
json_data = await request.json()
|
||||
set_update_policy(json_data['value'])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/manager/channel_url_list")
|
||||
async def get_channel_url_list(request):
|
||||
async def channel_url_list(request):
|
||||
channels = core.get_channel_dict()
|
||||
selected = 'custom'
|
||||
selected_url = core.get_config()['channel_url']
|
||||
if "value" in request.rel_url.query:
|
||||
channel_url = channels.get(request.rel_url.query['value'])
|
||||
if channel_url is not None:
|
||||
core.get_config()['channel_url'] = channel_url
|
||||
core.write_config()
|
||||
else:
|
||||
selected = 'custom'
|
||||
selected_url = core.get_config()['channel_url']
|
||||
|
||||
for name, url in channels.items():
|
||||
if url == selected_url:
|
||||
selected = name
|
||||
break
|
||||
for name, url in channels.items():
|
||||
if url == selected_url:
|
||||
selected = name
|
||||
break
|
||||
|
||||
res = {'selected': selected,
|
||||
'list': core.get_channel_list()}
|
||||
return web.json_response(res, status=200)
|
||||
res = {'selected': selected,
|
||||
'list': core.get_channel_list()}
|
||||
return web.json_response(res, status=200)
|
||||
|
||||
|
||||
@routes.post("/manager/channel_url_list")
|
||||
async def set_channel_url_list(request):
|
||||
json_data = await request.json()
|
||||
channels = core.get_channel_dict()
|
||||
channel_url = channels.get(json_data['value'])
|
||||
if channel_url is not None:
|
||||
core.get_config()['channel_url'] = channel_url
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
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("/manager/notice")
|
||||
async def get_notice(request):
|
||||
url = "github.com"
|
||||
@@ -1879,8 +1557,7 @@ async def get_notice(request):
|
||||
match = pattern.search(html_content)
|
||||
|
||||
if match:
|
||||
# Sanitize remote HTML before appending Manager's version text.
|
||||
markdown_content = manager_util.sanitize_html_fragment(match.group(1))
|
||||
markdown_content = 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]"
|
||||
@@ -1894,6 +1571,8 @@ async def get_notice(request):
|
||||
# markdown_content += f"<BR> ()"
|
||||
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):
|
||||
@@ -1903,16 +1582,6 @@ async def get_notice(request):
|
||||
except:
|
||||
pass
|
||||
|
||||
# Prepend startup notices from manager_migration
|
||||
for message, level in reversed(manager_migration.startup_notices):
|
||||
if level == 'error':
|
||||
style = 'color:red; background-color:white; font-weight:bold'
|
||||
elif level == 'warning':
|
||||
style = 'color:orange; background-color:white; font-weight:bold'
|
||||
else:
|
||||
style = 'color:blue; background-color:white'
|
||||
markdown_content = f'<P style="{style}">{message}</P>' + markdown_content
|
||||
|
||||
return web.Response(text=markdown_content, status=200)
|
||||
else:
|
||||
return web.Response(text="Unable to retrieve Notice", status=200)
|
||||
@@ -1920,38 +1589,11 @@ async def get_notice(request):
|
||||
return web.Response(text="Unable to retrieve Notice", status=200)
|
||||
|
||||
|
||||
@routes.get("/manager/startup_alerts")
|
||||
async def get_startup_alerts(request):
|
||||
"""Return startup alerts for customAlert display on page load.
|
||||
|
||||
Returns JSON array of alerts that should be shown to user immediately.
|
||||
All startup notices (error, warning, info) are returned.
|
||||
"""
|
||||
alerts = []
|
||||
|
||||
# Return all startup notices for alert display
|
||||
for message, level in manager_migration.startup_notices:
|
||||
# Convert HTML BR to newlines for customAlert
|
||||
text = message.replace('<BR>', '\n').replace('<br>', '\n')
|
||||
# Add [ComfyUI-Manager] prefix for customAlert (notice board shows in Manager UI anyway)
|
||||
text = text.replace('[Security Alert]', '[ComfyUI-Manager] Security Alert:')
|
||||
text = text.replace('[MIGRATION]', '[ComfyUI-Manager] Migration:')
|
||||
alerts.append({
|
||||
'message': text,
|
||||
'level': level
|
||||
})
|
||||
|
||||
return web.json_response(alerts)
|
||||
|
||||
|
||||
@routes.post("/manager/reboot")
|
||||
@routes.get("/manager/reboot")
|
||||
def restart(self):
|
||||
resp = _reject_simple_form_content_type(self)
|
||||
if resp is not None:
|
||||
return resp
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
|
||||
return security_403_response()
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
sys.stdout.close_log()
|
||||
@@ -2106,6 +1748,11 @@ async def default_cache_update():
|
||||
|
||||
logging.info("[ComfyUI-Manager] All startup tasks have been completed.")
|
||||
|
||||
# NOTE: hide migration button temporarily.
|
||||
# if not core.get_config()['skip_migration_check']:
|
||||
# await core.check_need_to_migrate()
|
||||
# else:
|
||||
# logging.info("[ComfyUI-Manager] Migration check is skipped...")
|
||||
|
||||
threading.Thread(target=lambda: asyncio.run(default_cache_update())).start()
|
||||
|
||||
|
||||
+52
-287
@@ -3,8 +3,6 @@ description:
|
||||
`manager_util` is the lightest module shared across the prestartup_script, main code, and cm-cli of ComfyUI-Manager.
|
||||
"""
|
||||
import traceback
|
||||
import ipaddress
|
||||
import socket
|
||||
|
||||
import aiohttp
|
||||
import json
|
||||
@@ -17,9 +15,6 @@ import re
|
||||
import logging
|
||||
import platform
|
||||
import shlex
|
||||
from functools import lru_cache
|
||||
from html import escape, unescape
|
||||
from html.entities import html5
|
||||
|
||||
|
||||
cache_lock = threading.Lock()
|
||||
@@ -28,50 +23,6 @@ comfyui_manager_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '
|
||||
cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also updated together in **manager_core.update_user_directory**.
|
||||
|
||||
use_uv = False
|
||||
bypass_ssl = False
|
||||
|
||||
FLAGGED_NODEPACK_INSTALL_ERROR = (
|
||||
'This action is not allowed by the current security configuration. '
|
||||
'See the terminal for details.'
|
||||
)
|
||||
FLAGGED_NODEPACK_INSTALL_GUIDANCE = (
|
||||
'Installation of this flagged CNR version is blocked. To satisfy the additional '
|
||||
'flagged-version requirement, choose one of the following:\n'
|
||||
'1. Run ComfyUI with loopback-only listeners, for example --listen 127.0.0.1 '
|
||||
'or --listen ::1. All configured listen addresses must be loopback.\n'
|
||||
' Do not use bare --listen or any non-loopback address, such as 0.0.0.0 or ::.\n'
|
||||
'2. For a trusted private network, set allow_flagged_nodepack_install = true '
|
||||
'in the [default] section of ComfyUI-Manager\'s config.ini.\n'
|
||||
'Restart ComfyUI after changing the listener or configuration. '
|
||||
'All other installation security checks still apply.'
|
||||
)
|
||||
|
||||
|
||||
def is_loopback_listener(listen_address: str) -> bool:
|
||||
"""All addresses bound by --listen must resolve exclusively to loopback."""
|
||||
if not isinstance(listen_address, str) or not listen_address:
|
||||
return False
|
||||
for address in listen_address.split(','):
|
||||
address = address.strip()
|
||||
if not address:
|
||||
return False
|
||||
try:
|
||||
if ipaddress.ip_address(address).is_loopback:
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
resolved = socket.getaddrinfo(address, None, type=socket.SOCK_STREAM)
|
||||
except OSError:
|
||||
return False
|
||||
if not resolved or any(not ipaddress.ip_address(item[4][0]).is_loopback for item in resolved):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_cnr_install_allowed(status: str, allow_flagged: bool, listen_address: str) -> bool:
|
||||
"""Only flagged versions require loopback or the private-network opt-in."""
|
||||
return status != 'NodeVersionStatusFlagged' or bool(allow_flagged) or is_loopback_listener(listen_address)
|
||||
|
||||
|
||||
def add_python_path_to_env():
|
||||
@@ -83,63 +34,11 @@ def add_python_path_to_env():
|
||||
os.environ['PATH'] = os.path.dirname(sys.executable)+sep+os.environ['PATH']
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def get_pip_cmd(force_uv=False):
|
||||
"""
|
||||
Get the base pip command, with automatic fallback to uv if pip is unavailable.
|
||||
|
||||
Args:
|
||||
force_uv (bool): If True, use uv directly without trying pip
|
||||
|
||||
Returns:
|
||||
list: Base command for pip operations
|
||||
"""
|
||||
embedded = 'python_embeded' in sys.executable
|
||||
|
||||
# Try pip first (unless forcing uv)
|
||||
if not force_uv:
|
||||
try:
|
||||
test_cmd = [sys.executable] + (['-s'] if embedded else []) + ['-m', 'pip', '--version']
|
||||
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
|
||||
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'pip']
|
||||
except Exception:
|
||||
logging.warning("[ComfyUI-Manager] `python -m pip` not available. Falling back to `uv`.")
|
||||
|
||||
# Try uv (either forced or pip failed)
|
||||
import shutil
|
||||
|
||||
# Try uv as Python module
|
||||
try:
|
||||
test_cmd = [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', '--version']
|
||||
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
|
||||
logging.info("[ComfyUI-Manager] Using `uv` as Python module for pip operations.")
|
||||
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', 'pip']
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try standalone uv
|
||||
if shutil.which('uv'):
|
||||
logging.info("[ComfyUI-Manager] Using standalone `uv` for pip operations.")
|
||||
return ['uv', 'pip']
|
||||
|
||||
# Nothing worked
|
||||
logging.error("[ComfyUI-Manager] Neither `python -m pip` nor `uv` are available. Cannot proceed with package operations.")
|
||||
raise Exception("Neither `pip` nor `uv` are available for package management")
|
||||
|
||||
|
||||
def make_pip_cmd(cmd):
|
||||
"""
|
||||
Create a pip command by combining the cached base pip command with the given arguments.
|
||||
|
||||
Args:
|
||||
cmd (list): List of pip command arguments (e.g., ['install', 'package'])
|
||||
|
||||
Returns:
|
||||
list: Complete command list ready for subprocess execution
|
||||
"""
|
||||
global use_uv
|
||||
base_cmd = get_pip_cmd(force_uv=use_uv)
|
||||
return base_cmd + cmd
|
||||
if use_uv:
|
||||
return [sys.executable, '-m', 'uv', 'pip'] + cmd
|
||||
else:
|
||||
return [sys.executable, '-m', 'pip'] + cmd
|
||||
|
||||
|
||||
# DON'T USE StrictVersion - cannot handle pre_release version
|
||||
@@ -231,7 +130,7 @@ async def get_data(uri, silent=False):
|
||||
print(f"FETCH DATA from: {uri}", end="")
|
||||
|
||||
if uri.startswith("http"):
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=not bypass_ssl)) as session:
|
||||
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
|
||||
headers = {
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
@@ -316,84 +215,6 @@ def sanitize_tag(x):
|
||||
return x.replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
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 ¬ebook 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).replace('\x00', ''),
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
# prestartup_script imports this module before installing dependencies.
|
||||
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'}},
|
||||
)
|
||||
|
||||
|
||||
def extract_package_as_zip(file_path, extract_path):
|
||||
import zipfile
|
||||
try:
|
||||
@@ -429,7 +250,7 @@ def get_installed_packages(renew=False):
|
||||
pip_map[normalized_name] = y[1]
|
||||
except subprocess.CalledProcessError:
|
||||
logging.error("[ComfyUI-Manager] Failed to retrieve the information of installed pip packages.")
|
||||
return {}
|
||||
return set()
|
||||
|
||||
return pip_map
|
||||
|
||||
@@ -480,7 +301,6 @@ def parse_requirement_line(line):
|
||||
|
||||
|
||||
torch_torchvision_torchaudio_version_map = {
|
||||
'2.7.0': ('0.22.0', '2.7.0'),
|
||||
'2.6.0': ('0.21.0', '2.6.0'),
|
||||
'2.5.1': ('0.20.0', '2.5.0'),
|
||||
'2.5.0': ('0.20.0', '2.5.0'),
|
||||
@@ -499,32 +319,6 @@ torch_torchvision_torchaudio_version_map = {
|
||||
}
|
||||
|
||||
|
||||
def torch_rollback(prev):
|
||||
spec = prev.split('+')
|
||||
if len(spec) > 1:
|
||||
platform = spec[1]
|
||||
else:
|
||||
cmd = make_pip_cmd(['install', '--force', 'torch', 'torchvision', 'torchaudio'])
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
logging.error(cmd)
|
||||
return
|
||||
|
||||
torch_ver = StrictVersion(spec[0])
|
||||
torch_ver = f"{torch_ver.major}.{torch_ver.minor}.{torch_ver.patch}"
|
||||
torch_torchvision_torchaudio_ver = torch_torchvision_torchaudio_version_map.get(torch_ver)
|
||||
|
||||
if torch_torchvision_torchaudio_ver is None:
|
||||
cmd = make_pip_cmd(['install', '--pre', 'torch', 'torchvision', 'torchaudio',
|
||||
'--index-url', f"https://download.pytorch.org/whl/nightly/{platform}"])
|
||||
logging.info("[ComfyUI-Manager] restore PyTorch to nightly version")
|
||||
else:
|
||||
torchvision_ver, torchaudio_ver = torch_torchvision_torchaudio_ver
|
||||
cmd = make_pip_cmd(['install', f'torch=={torch_ver}', f'torchvision=={torchvision_ver}', f"torchaudio=={torchaudio_ver}",
|
||||
'--index-url', f"https://download.pytorch.org/whl/{platform}"])
|
||||
logging.info(f"[ComfyUI-Manager] restore PyTorch to {torch_ver}+{platform}")
|
||||
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
|
||||
|
||||
class PIPFixer:
|
||||
def __init__(self, prev_pip_versions, comfyui_path, manager_files_path):
|
||||
@@ -532,6 +326,32 @@ class PIPFixer:
|
||||
self.comfyui_path = comfyui_path
|
||||
self.manager_files_path = manager_files_path
|
||||
|
||||
def torch_rollback(self):
|
||||
spec = self.prev_pip_versions['torch'].split('+')
|
||||
if len(spec) > 0:
|
||||
platform = spec[1]
|
||||
else:
|
||||
cmd = make_pip_cmd(['install', '--force', 'torch', 'torchvision', 'torchaudio'])
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
logging.error(cmd)
|
||||
return
|
||||
|
||||
torch_ver = StrictVersion(spec[0])
|
||||
torch_ver = f"{torch_ver.major}.{torch_ver.minor}.{torch_ver.patch}"
|
||||
torch_torchvision_torchaudio_ver = torch_torchvision_torchaudio_version_map.get(torch_ver)
|
||||
|
||||
if torch_torchvision_torchaudio_ver is None:
|
||||
cmd = make_pip_cmd(['install', '--pre', 'torch', 'torchvision', 'torchaudio',
|
||||
'--index-url', f"https://download.pytorch.org/whl/nightly/{platform}"])
|
||||
logging.info("[ComfyUI-Manager] restore PyTorch to nightly version")
|
||||
else:
|
||||
torchvision_ver, torchaudio_ver = torch_torchvision_torchaudio_ver
|
||||
cmd = make_pip_cmd(['install', f'torch=={torch_ver}', f'torchvision=={torchvision_ver}', f"torchaudio=={torchaudio_ver}",
|
||||
'--index-url', f"https://download.pytorch.org/whl/{platform}"])
|
||||
logging.info(f"[ComfyUI-Manager] restore PyTorch to {torch_ver}+{platform}")
|
||||
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
|
||||
def fix_broken(self):
|
||||
new_pip_versions = get_installed_packages(True)
|
||||
|
||||
@@ -553,7 +373,7 @@ class PIPFixer:
|
||||
elif self.prev_pip_versions['torch'] != new_pip_versions['torch'] \
|
||||
or self.prev_pip_versions['torchvision'] != new_pip_versions['torchvision'] \
|
||||
or self.prev_pip_versions['torchaudio'] != new_pip_versions['torchaudio']:
|
||||
torch_rollback(self.prev_pip_versions['torch'])
|
||||
self.torch_rollback()
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore PyTorch")
|
||||
logging.error(e)
|
||||
@@ -584,7 +404,7 @@ class PIPFixer:
|
||||
|
||||
if len(targets) > 0:
|
||||
for x in targets:
|
||||
cmd = make_pip_cmd(['install', f"{x}=={versions[0].version_string}"])
|
||||
cmd = make_pip_cmd(['install', f"{x}=={versions[0].version_string}", "numpy<2"])
|
||||
subprocess.check_output(cmd, universal_newlines=True)
|
||||
|
||||
logging.info(f"[ComfyUI-Manager] 'opencv' dependencies were fixed: {targets}")
|
||||
@@ -592,6 +412,19 @@ class PIPFixer:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore opencv")
|
||||
logging.error(e)
|
||||
|
||||
# fix numpy
|
||||
try:
|
||||
np = new_pip_versions.get('numpy')
|
||||
if np is not None:
|
||||
if StrictVersion(np) >= StrictVersion('2'):
|
||||
cmd = make_pip_cmd(['install', "numpy<2"])
|
||||
subprocess.check_output(cmd , universal_newlines=True)
|
||||
|
||||
logging.info("[ComfyUI-Manager] 'numpy' dependency were fixed")
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore numpy")
|
||||
logging.error(e)
|
||||
|
||||
# fix missing frontend
|
||||
try:
|
||||
# NOTE: package name in requirements is 'comfyui-frontend-package'
|
||||
@@ -606,12 +439,10 @@ class PIPFixer:
|
||||
lines = file.readlines()
|
||||
|
||||
front_line = next((line.strip() for line in lines if line.startswith('comfyui-frontend-package')), None)
|
||||
if front_line is None:
|
||||
logging.info("[ComfyUI-Manager] Skipped fixing the 'comfyui-frontend-package' dependency because the ComfyUI is outdated.")
|
||||
else:
|
||||
cmd = make_pip_cmd(['install', front_line])
|
||||
subprocess.check_output(cmd , universal_newlines=True)
|
||||
logging.info("[ComfyUI-Manager] 'comfyui-frontend-package' dependency were fixed")
|
||||
cmd = make_pip_cmd(['install', front_line])
|
||||
subprocess.check_output(cmd , universal_newlines=True)
|
||||
|
||||
logging.info("[ComfyUI-Manager] 'comfyui-frontend-package' dependency were fixed")
|
||||
except Exception as e:
|
||||
logging.error("[ComfyUI-Manager] Failed to restore comfyui-frontend-package")
|
||||
logging.error(e)
|
||||
@@ -630,7 +461,7 @@ class PIPFixer:
|
||||
normalized_name = parsed['package'].lower().replace('-', '_')
|
||||
if normalized_name in new_pip_versions:
|
||||
if 'version' in parsed and 'operator' in parsed:
|
||||
cur = StrictVersion(new_pip_versions[normalized_name])
|
||||
cur = StrictVersion(new_pip_versions[parsed['package']])
|
||||
dest = parsed['version']
|
||||
op = parsed['operator']
|
||||
if cur == dest:
|
||||
@@ -691,69 +522,3 @@ def robust_readlines(fullpath):
|
||||
|
||||
print(f"[ComfyUI-Manager] Failed to recognize encoding for: {fullpath}")
|
||||
return []
|
||||
|
||||
|
||||
def restore_pip_snapshot(pips, options):
|
||||
non_url = []
|
||||
local_url = []
|
||||
non_local_url = []
|
||||
|
||||
for k, v in pips.items():
|
||||
# NOTE: skip torch related packages
|
||||
if k.startswith("torch==") or k.startswith("torchvision==") or k.startswith("torchaudio==") or k.startswith("nvidia-"):
|
||||
continue
|
||||
|
||||
if v == "":
|
||||
non_url.append(k)
|
||||
else:
|
||||
if v.startswith('file:'):
|
||||
local_url.append(v)
|
||||
else:
|
||||
non_local_url.append(v)
|
||||
|
||||
|
||||
# restore other pips
|
||||
failed = []
|
||||
if '--pip-non-url' in options:
|
||||
# try all at once
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_output(make_pip_cmd(['install'] + non_url))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# fallback
|
||||
if res != 0:
|
||||
for x in non_url:
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_output(make_pip_cmd(['install', '--no-deps', x]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
failed.append(x)
|
||||
|
||||
if '--pip-non-local-url' in options:
|
||||
for x in non_local_url:
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_output(make_pip_cmd(['install', '--no-deps', x]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
failed.append(x)
|
||||
|
||||
if '--pip-local-url' in options:
|
||||
for x in local_url:
|
||||
res = 1
|
||||
try:
|
||||
res = subprocess.check_output(make_pip_cmd(['install', '--no-deps', x]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if res != 0:
|
||||
failed.append(x)
|
||||
|
||||
print(f"Installation failed for pip packages: {failed}")
|
||||
|
||||
+16
-69
@@ -2,8 +2,6 @@ import sys
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
import manager_util
|
||||
|
||||
|
||||
def security_check():
|
||||
print("[START] Security scan")
|
||||
@@ -53,40 +51,6 @@ And kill and remove /tmp/ultralytics_runner
|
||||
|
||||
The version 8.3.41 to 8.3.42 of the Ultralytics package you installed is compromised. Please uninstall that version and reinstall the latest version.
|
||||
https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situation/
|
||||
""",
|
||||
"litellm==1.82.7": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall litellm
|
||||
|
||||
The litellm PyPI package versions 1.82.7 and 1.82.8 were compromised via a supply chain attack.
|
||||
Malicious code harvests SSH keys, environment variables, API keys, cloud credentials, and exfiltrates them to an attacker-controlled server.
|
||||
Version 1.82.8 also installs a .pth file that executes malware on ANY Python startup, even without importing litellm.
|
||||
|
||||
1. Uninstall litellm immediately.
|
||||
2. Assume all credentials accessible to the litellm environment are compromised.
|
||||
3. Rotate all API keys, cloud credentials, SSH keys, and database passwords.
|
||||
4. Check site-packages for unexpected .pth files (e.g. litellm_init.pth) and remove them.
|
||||
5. Run a full malware scan.
|
||||
|
||||
Details: https://github.com/BerriAI/litellm/issues/24518
|
||||
Advisory: PYSEC-2026-2
|
||||
""",
|
||||
"litellm==1.82.8": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall litellm
|
||||
|
||||
The litellm PyPI package versions 1.82.7 and 1.82.8 were compromised via a supply chain attack.
|
||||
Malicious code harvests SSH keys, environment variables, API keys, cloud credentials, and exfiltrates them to an attacker-controlled server.
|
||||
Version 1.82.8 also installs a .pth file that executes malware on ANY Python startup, even without importing litellm.
|
||||
|
||||
1. Uninstall litellm immediately.
|
||||
2. Assume all credentials accessible to the litellm environment are compromised.
|
||||
3. Rotate all API keys, cloud credentials, SSH keys, and database passwords.
|
||||
4. Check site-packages for unexpected .pth files (e.g. litellm_init.pth) and remove them.
|
||||
5. Run a full malware scan.
|
||||
|
||||
Details: https://github.com/BerriAI/litellm/issues/24518
|
||||
Advisory: PYSEC-2026-2
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -94,10 +58,7 @@ Advisory: PYSEC-2026-2
|
||||
|
||||
pip_blacklist = {
|
||||
"AppleBotzz": "ComfyUI_LLMVISION",
|
||||
"ultralytics==8.3.41": "ultralytics==8.3.41",
|
||||
"ultralytics==8.3.42": "ultralytics==8.3.42",
|
||||
"litellm==1.82.7": "litellm==1.82.7",
|
||||
"litellm==1.82.8": "litellm==1.82.8",
|
||||
"ultralytics==8.3.41": "ultralytics==8.3.41"
|
||||
}
|
||||
|
||||
file_blacklist = {
|
||||
@@ -105,23 +66,18 @@ Advisory: PYSEC-2026-2
|
||||
"lolMiner": [os.path.join(comfyui_path, 'lolMiner')]
|
||||
}
|
||||
|
||||
installed_pips = subprocess.check_output(manager_util.make_pip_cmd(["freeze"]), text=True)
|
||||
installed_pips = subprocess.check_output([sys.executable, '-m', "pip", "freeze"], text=True)
|
||||
|
||||
detected = set()
|
||||
try:
|
||||
anthropic_info = subprocess.check_output(manager_util.make_pip_cmd(["show", "anthropic"]), text=True, stderr=subprocess.DEVNULL)
|
||||
requires_lines = [x for x in anthropic_info.split('\n') if x.startswith("Requires")]
|
||||
if requires_lines:
|
||||
anthropic_reqs = requires_lines[0].split(": ", 1)[1]
|
||||
if "pycrypto" in anthropic_reqs:
|
||||
location_lines = [x for x in anthropic_info.split('\n') if x.startswith("Location")]
|
||||
if location_lines:
|
||||
location = location_lines[0].split(": ", 1)[1]
|
||||
for fi in os.listdir(location):
|
||||
if fi.startswith("anthropic"):
|
||||
guide["ComfyUI_LLMVISION"] = (f"\n0.Remove {os.path.join(location, fi)}" + guide["ComfyUI_LLMVISION"])
|
||||
detected.add("ComfyUI_LLMVISION")
|
||||
|
||||
anthropic_info = subprocess.check_output([sys.executable, '-m', "pip", "show", "anthropic"], text=True, stderr=subprocess.DEVNULL)
|
||||
anthropic_reqs = [x for x in anthropic_info.split('\n') if x.startswith("Requires")][0].split(': ')[1]
|
||||
if "pycrypto" in anthropic_reqs:
|
||||
location = [x for x in anthropic_info.split('\n') if x.startswith("Location")][0].split(': ')[1]
|
||||
for fi in os.listdir(location):
|
||||
if fi.startswith("anthropic"):
|
||||
guide["ComfyUI_LLMVISION"] = f"\n0.Remove {os.path.join(location, fi)}" + guide["ComfyUI_LLMVISION"]
|
||||
detected.add("ComfyUI_LLMVISION")
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
@@ -130,15 +86,10 @@ Advisory: PYSEC-2026-2
|
||||
print(f"[SECURITY ALERT] custom node '{k}' is dangerous.")
|
||||
detected.add(v)
|
||||
|
||||
installed_pip_set = set(installed_pips.strip().split('\n'))
|
||||
|
||||
for k, v in pip_blacklist.items():
|
||||
if '==' in k:
|
||||
if k in installed_pip_set:
|
||||
detected.add(v)
|
||||
else:
|
||||
if any(line.split('==')[0] == k for line in installed_pip_set):
|
||||
detected.add(v)
|
||||
if k in installed_pips:
|
||||
detected.add(v)
|
||||
break
|
||||
|
||||
for k, v in file_blacklist.items():
|
||||
for x in v:
|
||||
@@ -147,14 +98,10 @@ Advisory: PYSEC-2026-2
|
||||
break
|
||||
|
||||
if len(detected) > 0:
|
||||
for line in installed_pip_set:
|
||||
for line in installed_pips.split('\n'):
|
||||
for k, v in pip_blacklist.items():
|
||||
if '==' in k:
|
||||
if line == k:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
else:
|
||||
if line.split('==')[0] == k:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
if k in line:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
|
||||
print("\n########################################################################")
|
||||
print(" Malware has been detected, forcibly terminating ComfyUI execution.")
|
||||
|
||||
+14
-63
@@ -335,7 +335,8 @@ async def share_art(request):
|
||||
content_type = assetFileType
|
||||
|
||||
try:
|
||||
from nio import AsyncClient, LoginResponse, UploadResponse
|
||||
from matrix_client.api import MatrixHttpApi
|
||||
from matrix_client.client import MatrixClient
|
||||
|
||||
homeserver = 'matrix.org'
|
||||
if matrix_auth:
|
||||
@@ -344,35 +345,20 @@ async def share_art(request):
|
||||
if not homeserver.startswith("https://"):
|
||||
homeserver = "https://" + homeserver
|
||||
|
||||
client = AsyncClient(homeserver, matrix_auth['username'])
|
||||
|
||||
# Login
|
||||
login_resp = await client.login(matrix_auth['password'])
|
||||
if not isinstance(login_resp, LoginResponse) or not login_resp.access_token:
|
||||
await client.close()
|
||||
client = MatrixClient(homeserver)
|
||||
try:
|
||||
token = client.login(username=matrix_auth['username'], password=matrix_auth['password'])
|
||||
if not token:
|
||||
return web.json_response({"error": "Invalid Matrix credentials."}, content_type='application/json', status=400)
|
||||
except:
|
||||
return web.json_response({"error": "Invalid Matrix credentials."}, content_type='application/json', status=400)
|
||||
|
||||
# Upload asset
|
||||
matrix = MatrixHttpApi(homeserver, token=token)
|
||||
with open(asset_filepath, 'rb') as f:
|
||||
upload_resp, _maybe_keys = await client.upload(f, content_type=content_type, filename=filename)
|
||||
asset_data = f.seek(0) or f.read() # get size for info below
|
||||
if not isinstance(upload_resp, UploadResponse) or not upload_resp.content_uri:
|
||||
await client.close()
|
||||
return web.json_response({"error": "Failed to upload asset to Matrix."}, content_type='application/json', status=500)
|
||||
mxc_url = upload_resp.content_uri
|
||||
mxc_url = matrix.media_upload(f.read(), content_type, filename=filename)['content_uri']
|
||||
|
||||
# Upload workflow JSON
|
||||
import io
|
||||
workflow_json_bytes = json.dumps(prompt['workflow']).encode('utf-8')
|
||||
workflow_io = io.BytesIO(workflow_json_bytes)
|
||||
upload_workflow_resp, _maybe_keys = await client.upload(workflow_io, content_type='application/json', filename='workflow.json')
|
||||
workflow_io.seek(0)
|
||||
if not isinstance(upload_workflow_resp, UploadResponse) or not upload_workflow_resp.content_uri:
|
||||
await client.close()
|
||||
return web.json_response({"error": "Failed to upload workflow to Matrix."}, content_type='application/json', status=500)
|
||||
workflow_json_mxc_url = upload_workflow_resp.content_uri
|
||||
workflow_json_mxc_url = matrix.media_upload(prompt['workflow'], 'application/json', filename='workflow.json')['content_uri']
|
||||
|
||||
# Send text message
|
||||
text_content = ""
|
||||
if title:
|
||||
text_content += f"{title}\n"
|
||||
@@ -380,44 +366,9 @@ async def share_art(request):
|
||||
text_content += f"{description}\n"
|
||||
if credits:
|
||||
text_content += f"\ncredits: {credits}\n"
|
||||
await client.room_send(
|
||||
room_id=comfyui_share_room_id,
|
||||
message_type="m.room.message",
|
||||
content={"msgtype": "m.text", "body": text_content}
|
||||
)
|
||||
|
||||
# Send image
|
||||
await client.room_send(
|
||||
room_id=comfyui_share_room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.image",
|
||||
"body": filename,
|
||||
"url": mxc_url,
|
||||
"info": {
|
||||
"mimetype": content_type,
|
||||
"size": len(asset_data)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Send workflow JSON file
|
||||
await client.room_send(
|
||||
room_id=comfyui_share_room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.file",
|
||||
"body": "workflow.json",
|
||||
"url": workflow_json_mxc_url,
|
||||
"info": {
|
||||
"mimetype": "application/json",
|
||||
"size": len(workflow_json_bytes)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
await client.close()
|
||||
|
||||
matrix.send_message(comfyui_share_room_id, text_content)
|
||||
matrix.send_content(comfyui_share_room_id, mxc_url, filename, 'm.image')
|
||||
matrix.send_content(comfyui_share_room_id, workflow_json_mxc_url, 'workflow.json', 'm.file')
|
||||
except:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# ComfyUI-Manager: Frontend (js)
|
||||
|
||||
This directory contains the JavaScript frontend implementation for ComfyUI-Manager, providing the user interface components that interact with the backend API.
|
||||
|
||||
## Core Components
|
||||
|
||||
- **comfyui-manager.js**: Main entry point that initializes the manager UI and integrates with ComfyUI.
|
||||
- **custom-nodes-manager.js**: Implements the UI for browsing, installing, and managing custom nodes.
|
||||
- **model-manager.js**: Handles the model management interface for downloading and organizing AI models.
|
||||
- **components-manager.js**: Manages reusable workflow components system.
|
||||
- **snapshot.js**: Implements the snapshot system for backing up and restoring installations.
|
||||
- **node-usage-analyzer.js**: Implements the UI for analyzing node usage in workflows.
|
||||
|
||||
## Sharing Components
|
||||
|
||||
- **comfyui-share-common.js**: Base functionality for workflow sharing features.
|
||||
- **comfyui-share-copus.js**: Integration with the ComfyUI Copus sharing platform.
|
||||
- **comfyui-share-openart.js**: Integration with the OpenArt sharing platform.
|
||||
- **comfyui-share-youml.js**: Integration with the YouML sharing platform.
|
||||
|
||||
## Utility Components
|
||||
|
||||
- **cm-api.js**: Client-side API wrapper for communication with the backend.
|
||||
- **common.js**: Shared utilities and helper functions used across the frontend.
|
||||
- **node_fixer.js**: Utilities for fixing disconnected links and repairing malformed nodes by recreating them while preserving connections.
|
||||
- **popover-helper.js**: UI component for popup tooltips and contextual information.
|
||||
- **turbogrid.esm.js**: Grid component library - https://github.com/cenfun/turbogrid
|
||||
- **workflow-metadata.js**: Handles workflow metadata parsing, validation and cross-repository compatibility including versioning, dependencies tracking, and resource management.
|
||||
|
||||
## Architecture
|
||||
|
||||
The frontend follows a modular component-based architecture:
|
||||
|
||||
1. **Integration Layer**: Connects with ComfyUI's existing UI system
|
||||
2. **Manager Components**: Individual functional UI components (node manager, model manager, etc.)
|
||||
3. **Sharing Components**: Platform-specific sharing implementations
|
||||
4. **Utility Layer**: Reusable UI components and helpers
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- The frontend integrates directly with ComfyUI's UI system through `app.js`
|
||||
- Dialog-based UI for most manager functions to avoid cluttering the main interface
|
||||
- Asynchronous API calls to handle backend operations without blocking the UI
|
||||
|
||||
## Styling
|
||||
|
||||
CSS files are included for specific components:
|
||||
- **custom-nodes-manager.css**: Styling for the node management UI
|
||||
- **model-manager.css**: Styling for the model management UI
|
||||
- **node-usage-analyzer.css**: Styling for the node usage analyzer UI
|
||||
|
||||
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { sleep, customConfirm, customAlert, handle403Response, show_message } from "./common.js";
|
||||
import { sleep, customConfirm, customAlert } from "./common.js";
|
||||
|
||||
async function tryInstallCustomNode(event) {
|
||||
let msg = '-= [ComfyUI Manager] extension installation request =-\n\n';
|
||||
@@ -42,7 +42,7 @@ async function tryInstallCustomNode(event) {
|
||||
});
|
||||
|
||||
if(response.status == 403) {
|
||||
await handle403Response(response);
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
else if(response.status == 400) {
|
||||
@@ -52,9 +52,9 @@ async function tryInstallCustomNode(event) {
|
||||
}
|
||||
}
|
||||
|
||||
let response = await api.fetchApi("/manager/reboot", { method: 'POST' });
|
||||
let response = await api.fetchApi("/manager/reboot");
|
||||
if(response.status == 403) {
|
||||
await handle403Response(response);
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
|
||||
function normalizeContent(content) {
|
||||
const tmp = document.createElement('div');
|
||||
if (typeof content === 'string') {
|
||||
tmp.innerHTML = content;
|
||||
return Array.from(tmp.childNodes);
|
||||
}
|
||||
if (content instanceof Node) {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function createSettingsCombo(label, content) {
|
||||
const settingItem = $el("div.setting-item", {}, [
|
||||
$el("div.flex.flex-row.items-center.gap-2",[
|
||||
$el("div.form-label.flex.grow.items-center", [
|
||||
$el("span.text-muted", { textContent: label },)
|
||||
]),
|
||||
$el("div.form-input.flex.justify-end",
|
||||
[content]
|
||||
)
|
||||
]
|
||||
)
|
||||
]);
|
||||
return settingItem;
|
||||
}
|
||||
|
||||
export function buildGuiFrame(dialogId, title, iconClass, content, owner) {
|
||||
const dialog_mask = $el("div.p-dialog-mask.p-overlay-mask.p-overlay-mask-enter", {
|
||||
parent: document.body,
|
||||
style: {
|
||||
position: "fixed",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pointerEvents: "auto",
|
||||
zIndex: "1000"
|
||||
},
|
||||
onclick: (e) => {
|
||||
if (e.target === dialog_mask) {
|
||||
owner.close();
|
||||
}
|
||||
}
|
||||
// data-pc-section="mask"
|
||||
});
|
||||
|
||||
const header_actions = $el("div.p-dialog-header-actions", {
|
||||
// [TODO]
|
||||
// data-pc-section="headeractions"
|
||||
}
|
||||
);
|
||||
|
||||
const close_button = $el("button.p-button.p-component.p-button-icon-only.p-button-secondary.p-button-rounded.p-button-text.p-dialog-close-button", {
|
||||
parent: header_actions,
|
||||
type: "button",
|
||||
ariaLabel: "Close",
|
||||
onclick: () => owner.close(),
|
||||
// "data-pc-name": "pcclosebutton",
|
||||
// "data-p-disabled": "false",
|
||||
// "data-p-severity": "secondary",
|
||||
// "data-pc-group-section": "headericon",
|
||||
// "data-pc-extend": "button",
|
||||
// "data-pc-section": "root",
|
||||
// [FIXME] Not sure how to do most of the SVG using $el
|
||||
innerHTML: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="p-icon p-button-icon" aria-hidden="true"><path d="M8.01186 7.00933L12.27 2.75116C12.341 2.68501 12.398 2.60524 12.4375 2.51661C12.4769 2.42798 12.4982 2.3323 12.4999 2.23529C12.5016 2.13827 12.4838 2.0419 12.4474 1.95194C12.4111 1.86197 12.357 1.78024 12.2884 1.71163C12.2198 1.64302 12.138 1.58893 12.0481 1.55259C11.9581 1.51625 11.8617 1.4984 11.7647 1.50011C11.6677 1.50182 11.572 1.52306 11.4834 1.56255C11.3948 1.60204 11.315 1.65898 11.2488 1.72997L6.99067 5.98814L2.7325 1.72997C2.59553 1.60234 2.41437 1.53286 2.22718 1.53616C2.03999 1.53946 1.8614 1.61529 1.72901 1.74767C1.59663 1.88006 1.5208 2.05865 1.5175 2.24584C1.5142 2.43303 1.58368 2.61419 1.71131 2.75116L5.96948 7.00933L1.71131 11.2675C1.576 11.403 1.5 11.5866 1.5 11.7781C1.5 11.9696 1.576 12.1532 1.71131 12.2887C1.84679 12.424 2.03043 12.5 2.2219 12.5C2.41338 12.5 2.59702 12.424 2.7325 12.2887L6.99067 8.03052L11.2488 12.2887C11.3843 12.424 11.568 12.5 11.7594 12.5C11.9509 12.5 12.1346 12.424 12.27 12.2887C12.4053 12.1532 12.4813 11.9696 12.4813 11.7781C12.4813 11.5866 12.4053 11.403 12.27 11.2675L8.01186 7.00933Z" fill="currentColor"></path></svg><span class="p-button-label" data-pc-section="label"> </span><!---->'
|
||||
}
|
||||
);
|
||||
|
||||
const dialog_header = $el("div.p-dialog-header",
|
||||
[
|
||||
$el("div", [
|
||||
$el("div",
|
||||
{
|
||||
id: "frame-title-container",
|
||||
},
|
||||
[
|
||||
$el("h2.px-4", [
|
||||
$el(iconClass, {
|
||||
style: {
|
||||
"font-size": "1.25rem",
|
||||
"margin-right": ".5rem"
|
||||
}
|
||||
}),
|
||||
$el("span", { textContent: title })
|
||||
])
|
||||
]
|
||||
)
|
||||
]),
|
||||
header_actions
|
||||
]
|
||||
);
|
||||
|
||||
const contentFrame = $el("div.p-dialog-content", {}, normalizeContent(content));
|
||||
const manager_dialog = $el("div.p-dialog.p-component.global-dialog", {
|
||||
id: dialogId,
|
||||
parent: dialog_mask,
|
||||
style: {
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'pointer-events': 'auto',
|
||||
'margin': '0px',
|
||||
},
|
||||
role: 'dialog',
|
||||
ariaModal: 'true',
|
||||
// [TODO]
|
||||
// ariaLabbelledby: 'cm-title',
|
||||
// maximized: 'false',
|
||||
// data-pc-name: 'dialog',
|
||||
// data-pc-section: 'root',
|
||||
// data-pd-focustrap: 'true'
|
||||
},
|
||||
[ dialog_header, contentFrame ]
|
||||
);
|
||||
|
||||
const hidden_accessible = $el("span.p-hidden-accessible.p-hidden-focusable", {
|
||||
parent: manager_dialog,
|
||||
tabindex: "0",
|
||||
role: "presentation",
|
||||
ariaHidden: "true",
|
||||
"data-p-hidden-accessible": "true",
|
||||
"data-p-hidden-focusable": "true",
|
||||
"data-pc-section": "firstfocusableelement"
|
||||
});
|
||||
|
||||
return dialog_mask;
|
||||
}
|
||||
|
||||
export function buildGuiFrameCustomHeader(dialogId, customHeader, content, owner) {
|
||||
const dialog_mask = $el("div.p-dialog-mask.p-overlay-mask.p-overlay-mask-enter", {
|
||||
parent: document.body,
|
||||
style: {
|
||||
position: "fixed",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
left: "0px",
|
||||
top: "0px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pointerEvents: "auto",
|
||||
zIndex: "1000"
|
||||
},
|
||||
onclick: (e) => {
|
||||
if (e.target === dialog_mask) {
|
||||
owner.close();
|
||||
}
|
||||
}
|
||||
// data-pc-section="mask"
|
||||
});
|
||||
|
||||
const header_actions = $el("div.p-dialog-header-actions", {
|
||||
// [TODO]
|
||||
// data-pc-section="headeractions"
|
||||
}
|
||||
);
|
||||
|
||||
const close_button = $el("button.p-button.p-component.p-button-icon-only.p-button-secondary.p-button-rounded.p-button-text.p-dialog-close-button", {
|
||||
parent: header_actions,
|
||||
type: "button",
|
||||
ariaLabel: "Close",
|
||||
onclick: () => owner.close(),
|
||||
// "data-pc-name": "pcclosebutton",
|
||||
// "data-p-disabled": "false",
|
||||
// "data-p-severity": "secondary",
|
||||
// "data-pc-group-section": "headericon",
|
||||
// "data-pc-extend": "button",
|
||||
// "data-pc-section": "root",
|
||||
// [FIXME] Not sure how to do most of the SVG using $el
|
||||
innerHTML: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg" class="p-icon p-button-icon" aria-hidden="true"><path d="M8.01186 7.00933L12.27 2.75116C12.341 2.68501 12.398 2.60524 12.4375 2.51661C12.4769 2.42798 12.4982 2.3323 12.4999 2.23529C12.5016 2.13827 12.4838 2.0419 12.4474 1.95194C12.4111 1.86197 12.357 1.78024 12.2884 1.71163C12.2198 1.64302 12.138 1.58893 12.0481 1.55259C11.9581 1.51625 11.8617 1.4984 11.7647 1.50011C11.6677 1.50182 11.572 1.52306 11.4834 1.56255C11.3948 1.60204 11.315 1.65898 11.2488 1.72997L6.99067 5.98814L2.7325 1.72997C2.59553 1.60234 2.41437 1.53286 2.22718 1.53616C2.03999 1.53946 1.8614 1.61529 1.72901 1.74767C1.59663 1.88006 1.5208 2.05865 1.5175 2.24584C1.5142 2.43303 1.58368 2.61419 1.71131 2.75116L5.96948 7.00933L1.71131 11.2675C1.576 11.403 1.5 11.5866 1.5 11.7781C1.5 11.9696 1.576 12.1532 1.71131 12.2887C1.84679 12.424 2.03043 12.5 2.2219 12.5C2.41338 12.5 2.59702 12.424 2.7325 12.2887L6.99067 8.03052L11.2488 12.2887C11.3843 12.424 11.568 12.5 11.7594 12.5C11.9509 12.5 12.1346 12.424 12.27 12.2887C12.4053 12.1532 12.4813 11.9696 12.4813 11.7781C12.4813 11.5866 12.4053 11.403 12.27 11.2675L8.01186 7.00933Z" fill="currentColor"></path></svg><span class="p-button-label" data-pc-section="label"> </span><!---->'
|
||||
}
|
||||
);
|
||||
|
||||
const _customHeader = normalizeContent(customHeader);
|
||||
const dialog_header = $el("div.p-dialog-header",
|
||||
[
|
||||
$el("div", [
|
||||
$el("div",
|
||||
{
|
||||
id: "frame-title-container",
|
||||
},
|
||||
Array.isArray(_customHeader) ? _customHeader : [_customHeader]
|
||||
)
|
||||
]),
|
||||
header_actions
|
||||
]
|
||||
);
|
||||
|
||||
const contentFrame = $el("div.p-dialog-content", {}, normalizeContent(content));
|
||||
const manager_dialog = $el("div.p-dialog.p-component.global-dialog", {
|
||||
id: dialogId,
|
||||
parent: dialog_mask,
|
||||
style: {
|
||||
'display': 'flex',
|
||||
'flex-direction': 'column',
|
||||
'pointer-events': 'auto',
|
||||
'margin': '0px',
|
||||
},
|
||||
role: 'dialog',
|
||||
ariaModal: 'true',
|
||||
// [TODO]
|
||||
// ariaLabbelledby: 'cm-title',
|
||||
// maximized: 'false',
|
||||
// data-pc-name: 'dialog',
|
||||
// data-pc-section: 'root',
|
||||
// data-pd-focustrap: 'true'
|
||||
},
|
||||
[ dialog_header, contentFrame ]
|
||||
);
|
||||
|
||||
const hidden_accessible = $el("span.p-hidden-accessible.p-hidden-focusable", {
|
||||
parent: manager_dialog,
|
||||
tabindex: "0",
|
||||
role: "presentation",
|
||||
ariaHidden: "true",
|
||||
"data-p-hidden-accessible": "true",
|
||||
"data-p-hidden-focusable": "true",
|
||||
"data-pc-section": "firstfocusableelement"
|
||||
});
|
||||
|
||||
return dialog_mask;
|
||||
}
|
||||
+167
-261
@@ -13,15 +13,13 @@ import {
|
||||
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, handle403Response, sanitizeHTML, safeHref
|
||||
rebootAPI, migrateAPI, setManagerInstance, show_message, customAlert, customPrompt,
|
||||
infoToast, showTerminal, setNeedRestart
|
||||
} from "./common.js";
|
||||
import { ComponentBuilderDialog, getPureName, load_components, set_component_policy } from "./components-manager.js";
|
||||
import { CustomNodesManager } from "./custom-nodes-manager.js";
|
||||
import { NodeUsageAnalyzer } from "./node-usage-analyzer.js";
|
||||
import { ModelManager } from "./model-manager.js";
|
||||
import { SnapshotManager } from "./snapshot.js";
|
||||
import { buildGuiFrame, createSettingsCombo } from "./comfyui-gui-builder.js";
|
||||
|
||||
let manager_version = await getVersion();
|
||||
|
||||
@@ -46,16 +44,12 @@ docStyle.innerHTML = `
|
||||
|
||||
#cm-manager-dialog {
|
||||
width: 1000px;
|
||||
height: auto;
|
||||
height: 455px;
|
||||
box-sizing: content-box;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#cm-manager-dialog br {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.cb-widget {
|
||||
width: 400px;
|
||||
height: 25px;
|
||||
@@ -86,7 +80,6 @@ docStyle.innerHTML = `
|
||||
}
|
||||
|
||||
.cm-menu-container {
|
||||
padding : calc(var(--spacing)*2);
|
||||
column-gap: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -147,8 +140,8 @@ docStyle.innerHTML = `
|
||||
}
|
||||
|
||||
.cm-notice-board {
|
||||
width: auto;
|
||||
height: 280px;
|
||||
width: 290px;
|
||||
height: 230px;
|
||||
overflow: auto;
|
||||
color: var(--input-text);
|
||||
border: 1px solid var(--descrip-text);
|
||||
@@ -245,50 +238,68 @@ var is_updating = false;
|
||||
// copied style from https://github.com/pythongosssss/ComfyUI-Custom-Scripts
|
||||
const style = `
|
||||
#workflowgallery-button {
|
||||
height: 50px;
|
||||
width: 310px;
|
||||
height: 27px;
|
||||
padding: 0px !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
#cm-nodeinfo-button {
|
||||
|
||||
width: 310px;
|
||||
height: 27px;
|
||||
padding: 0px !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
#cm-manual-button {
|
||||
|
||||
width: 310px;
|
||||
height: 27px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cm-button {
|
||||
width: auto;
|
||||
width: 310px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
border-color: var(--border-color);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.cm-button:hover {
|
||||
filter: brightness(125%);
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.cm-button-red {
|
||||
width: 310px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
background-color: #500000 !important;
|
||||
border-color: #88181b !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.cm-button-red:hover {
|
||||
background-color: #88181b !important;
|
||||
}
|
||||
|
||||
.cm-button-orange {
|
||||
width: 310px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
font-weight: bold;
|
||||
background-color: orange !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.cm-experimental-button {
|
||||
width: 100%;
|
||||
width: 290px;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
.cm-experimental {
|
||||
width: 310px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
@@ -315,14 +326,8 @@ const style = `
|
||||
|
||||
.cm-menu-combo {
|
||||
cursor: pointer;
|
||||
padding: 0.5em 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--comfy-menu-secondary-bg);
|
||||
}
|
||||
|
||||
.cm-menu-combo:hover {
|
||||
filter: brightness(125%);
|
||||
width: 310px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cm-small-button {
|
||||
@@ -471,12 +476,12 @@ async function updateComfyUI() {
|
||||
|
||||
set_inprogress_mode();
|
||||
|
||||
const response = await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
|
||||
const response = await api.fetchApi('/manager/queue/update_comfyui');
|
||||
|
||||
showTerminal();
|
||||
|
||||
is_updating = true;
|
||||
await api.fetchApi('/manager/queue/start', { method: 'POST' });
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
}
|
||||
|
||||
function showVersionSelectorDialog(versions, current, onSelect) {
|
||||
@@ -626,14 +631,14 @@ async function switchComfyUI() {
|
||||
showVersionSelectorDialog(versions, obj.current, async (selected_version) => {
|
||||
if(selected_version == 'nightly') {
|
||||
update_policy_combo.value = 'nightly-comfyui';
|
||||
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'nightly-comfyui' }) });
|
||||
api.fetchApi('/manager/policy/update?value=nightly-comfyui');
|
||||
}
|
||||
else {
|
||||
update_policy_combo.value = 'stable-comfyui';
|
||||
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'stable-comfyui' }) });
|
||||
api.fetchApi('/manager/policy/update?value=stable-comfyui');
|
||||
}
|
||||
|
||||
let response = await api.fetchApi('/comfyui_manager/comfyui_switch_version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ver: selected_version }), cache: "no-store" });
|
||||
let response = await api.fetchApi(`/comfyui_manager/comfyui_switch_version?ver=${selected_version}`, { cache: "no-store" });
|
||||
if (response.status == 200) {
|
||||
infoToast(`ComfyUI version is switched to ${selected_version}`);
|
||||
}
|
||||
@@ -713,12 +718,12 @@ async function onQueueStatus(event) {
|
||||
for(let x in success_list) {
|
||||
let k = success_list[x];
|
||||
let url = event.detail.nodepack_result[k].url;
|
||||
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
|
||||
let title = event.detail.nodepack_result[k].title;
|
||||
if(url) {
|
||||
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
|
||||
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
|
||||
}
|
||||
else {
|
||||
msg += `<li>${sanitizeHTML(String(k))}</li>`;
|
||||
msg += `<li>${k}</li>`;
|
||||
}
|
||||
}
|
||||
msg += "</ul>";
|
||||
@@ -732,12 +737,12 @@ async function onQueueStatus(event) {
|
||||
for(let x in failed_list) {
|
||||
let k = failed_list[x];
|
||||
let url = event.detail.nodepack_result[k].url;
|
||||
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
|
||||
let title = event.detail.nodepack_result[k].title;
|
||||
if(url) {
|
||||
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
|
||||
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
|
||||
}
|
||||
else {
|
||||
msg += `<li>${sanitizeHTML(String(k))}</li>`;
|
||||
msg += `<li>${k}</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,9 +753,9 @@ async function onQueueStatus(event) {
|
||||
|
||||
const rebootButton = document.getElementById('cm-reboot-button5');
|
||||
rebootButton?.addEventListener("click",
|
||||
async function() {
|
||||
if(await rebootAPI()) {
|
||||
manager_instance.close();
|
||||
function() {
|
||||
if(rebootAPI()) {
|
||||
manager_dialog.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -770,22 +775,17 @@ async function updateAll(update_comfyui) {
|
||||
|
||||
if(update_comfyui) {
|
||||
update_all_button.innerText = "Updating ComfyUI...";
|
||||
await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
|
||||
await api.fetchApi('/manager/queue/update_comfyui');
|
||||
}
|
||||
|
||||
const response = await api.fetchApi('/manager/queue/update_all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: mode }) });
|
||||
const response = await api.fetchApi(`/manager/queue/update_all?mode=${mode}`);
|
||||
|
||||
if (response.status == 403) {
|
||||
await handle403Response(response);
|
||||
reset_action_buttons();
|
||||
}
|
||||
else if (response.status == 401) {
|
||||
if (response.status == 401) {
|
||||
customAlert('Another task is already in progress. Please stop the ongoing task first.');
|
||||
reset_action_buttons();
|
||||
}
|
||||
else if(response.status == 200) {
|
||||
is_updating = true;
|
||||
await api.fetchApi('/manager/queue/start', { method: 'POST' });
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,7 +814,7 @@ function restartOrStop() {
|
||||
rebootAPI();
|
||||
}
|
||||
else {
|
||||
api.fetchApi('/manager/queue/reset', { method: 'POST' });
|
||||
api.fetchApi('/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
}
|
||||
@@ -826,7 +826,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
const isElectron = 'electronAPI' in window;
|
||||
|
||||
update_comfyui_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Update ComfyUI",
|
||||
style: {
|
||||
@@ -837,7 +837,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
switch_comfyui_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Switch ComfyUI",
|
||||
style: {
|
||||
@@ -848,7 +848,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
restart_stop_button =
|
||||
$el("button.p-button.p-component.cm-button-red", {
|
||||
$el("button.cm-button-red", {
|
||||
type: "button",
|
||||
textContent: "Restart",
|
||||
onclick: () => restartOrStop()
|
||||
@@ -856,7 +856,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
|
||||
if(isElectron) {
|
||||
update_all_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Update All Custom Nodes",
|
||||
onclick:
|
||||
@@ -865,7 +865,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
else {
|
||||
update_all_button =
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Update All",
|
||||
onclick:
|
||||
@@ -875,7 +875,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
|
||||
const res =
|
||||
[
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Custom Nodes Manager",
|
||||
onclick:
|
||||
@@ -887,7 +887,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Install Missing Custom Nodes",
|
||||
onclick:
|
||||
@@ -899,7 +899,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Custom Nodes In Workflow",
|
||||
onclick:
|
||||
@@ -910,20 +910,9 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
CustomNodesManager.instance.show(CustomNodesManager.ShowMode.IN_WORKFLOW);
|
||||
}
|
||||
}),
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Node Usage Analyzer",
|
||||
onclick:
|
||||
() => {
|
||||
if(!NodeUsageAnalyzer.instance) {
|
||||
NodeUsageAnalyzer.instance = new NodeUsageAnalyzer(app, self);
|
||||
}
|
||||
NodeUsageAnalyzer.instance.show(NodeUsageAnalyzer.SortMode.BY_PACKAGE);
|
||||
}
|
||||
}),
|
||||
|
||||
$el("div", {}, []),
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("br", {}, []),
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Model Manager",
|
||||
onclick:
|
||||
@@ -935,7 +924,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
type: "button",
|
||||
textContent: "Install via Git URL",
|
||||
onclick: async () => {
|
||||
@@ -947,16 +936,38 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
}),
|
||||
|
||||
$el("div", {}, []),
|
||||
$el("br", {}, []),
|
||||
update_all_button,
|
||||
update_comfyui_button,
|
||||
switch_comfyui_button,
|
||||
// fetch_updates_button,
|
||||
|
||||
$el("div", {}, []),
|
||||
$el("br", {}, []),
|
||||
restart_stop_button,
|
||||
];
|
||||
|
||||
let migration_btn =
|
||||
$el("button.cm-button-orange", {
|
||||
type: "button",
|
||||
textContent: "Migrate to New Node System",
|
||||
onclick: () => migrateAPI()
|
||||
});
|
||||
|
||||
migration_btn.style.display = 'none';
|
||||
|
||||
res.push(migration_btn);
|
||||
|
||||
api.fetchApi('/manager/need_to_migrate')
|
||||
.then(response => response.text())
|
||||
.then(text => {
|
||||
if (text === 'True') {
|
||||
migration_btn.style.display = 'block';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error checking migration status:', error);
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -966,126 +977,42 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
let self = this;
|
||||
|
||||
// db mode
|
||||
|
||||
this.datasrc_combo = document.createElement("select");
|
||||
this.datasrc_combo.setAttribute("title", "Configure where to retrieve node/model information. If set to 'local,' the channel is ignored, and if set to 'channel (remote),' it fetches the latest information each time the list is opened.");
|
||||
this.datasrc_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled ";
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'cache', text: 'Channel (1day cache)' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'Local' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'Channel (remote)' }, []));
|
||||
this.datasrc_combo.className = "cm-menu-combo";
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'cache', text: 'DB: Channel (1day cache)' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'local', text: 'DB: Local' }, []));
|
||||
this.datasrc_combo.appendChild($el('option', { value: 'remote', text: 'DB: Channel (remote)' }, []));
|
||||
|
||||
api.fetchApi('/manager/db_mode')
|
||||
.then(response => response.text())
|
||||
.then(data => { this.datasrc_combo.value = data; });
|
||||
|
||||
this.datasrc_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi('/manager/db_mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
|
||||
api.fetchApi(`/manager/db_mode?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
const dbRetrievalSetttingItem = createSettingsCombo("DB", this.datasrc_combo);
|
||||
|
||||
// preview method
|
||||
let preview_combo = document.createElement("select");
|
||||
preview_combo.setAttribute("title", "Configure how latent variables will be decoded during preview in the sampling process.");
|
||||
preview_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
preview_combo.className = "cm-menu-combo";
|
||||
preview_combo.appendChild($el('option', { value: 'auto', text: 'Preview method: Auto' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'taesd', text: 'Preview method: TAESD (slow)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'latent2rgb', text: 'Preview method: Latent2RGB (fast)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'none', text: 'Preview method: None (very fast)' }, []));
|
||||
|
||||
// Loading state to prevent flash of enabled state
|
||||
preview_combo.appendChild($el('option', { value: '', text: 'Loading...', disabled: true }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'auto', text: 'Auto' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'taesd', text: 'TAESD (slow)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'latent2rgb', text: 'Latent2RGB (fast)' }, []));
|
||||
preview_combo.appendChild($el('option', { value: 'none', text: 'None (very fast)' }, []));
|
||||
|
||||
// Start disabled to prevent flash
|
||||
preview_combo.disabled = true;
|
||||
preview_combo.value = '';
|
||||
|
||||
// Fetch current state
|
||||
api.fetchApi('/manager/preview_method')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
// Remove loading option
|
||||
preview_combo.querySelector('option[value=""]')?.remove();
|
||||
|
||||
if (data === "DISABLED") {
|
||||
// ComfyUI per-queue preview feature is active
|
||||
preview_combo.disabled = true;
|
||||
preview_combo.value = 'auto';
|
||||
|
||||
// Accessibility attributes
|
||||
preview_combo.setAttribute("aria-disabled", "true");
|
||||
preview_combo.setAttribute("aria-label",
|
||||
"Preview method setting (disabled - managed by ComfyUI). " +
|
||||
"Use Settings > Execution > Live preview method instead."
|
||||
);
|
||||
|
||||
// Tooltip for mouse users
|
||||
preview_combo.setAttribute("title",
|
||||
"This feature is now provided natively by ComfyUI. " +
|
||||
"Please use 'Settings > Execution > Live preview method' instead."
|
||||
);
|
||||
|
||||
// Visual feedback
|
||||
preview_combo.style.opacity = '0.6';
|
||||
preview_combo.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
// Manager feature is active
|
||||
preview_combo.disabled = false;
|
||||
preview_combo.value = data;
|
||||
|
||||
// Accessibility for enabled state
|
||||
preview_combo.setAttribute("aria-label",
|
||||
"Preview method setting. Select how latent variables are decoded during preview."
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[ComfyUI-Manager] Failed to fetch preview method status:', error);
|
||||
// Error recovery: fallback to enabled
|
||||
preview_combo.querySelector('option[value=""]')?.remove();
|
||||
preview_combo.disabled = false;
|
||||
preview_combo.value = 'auto';
|
||||
});
|
||||
.then(data => { preview_combo.value = data; });
|
||||
|
||||
preview_combo.addEventListener('change', function (event) {
|
||||
// Ignore if disabled
|
||||
if (preview_combo.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal operation
|
||||
api.fetchApi('/manager/preview_method', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) })
|
||||
.then(response => {
|
||||
if (response.status === 403) {
|
||||
// Feature transitioned to native
|
||||
alert(
|
||||
'This feature is now provided natively by ComfyUI.\n' +
|
||||
'Please use \'Settings > Execution > Live preview method\' instead.'
|
||||
);
|
||||
preview_combo.disabled = true;
|
||||
preview_combo.style.opacity = '0.6';
|
||||
preview_combo.style.cursor = 'not-allowed';
|
||||
|
||||
// Update aria attributes
|
||||
preview_combo.setAttribute("aria-disabled", "true");
|
||||
preview_combo.setAttribute("aria-label",
|
||||
"Preview method setting (disabled - managed by ComfyUI). " +
|
||||
"Use Settings > Execution > Live preview method instead."
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[ComfyUI-Manager] Preview method update failed:', error);
|
||||
});
|
||||
api.fetchApi(`/manager/preview_method?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
const previewSetttingItem = createSettingsCombo("Preview method", preview_combo);
|
||||
|
||||
// channel
|
||||
let channel_combo = document.createElement("select");
|
||||
channel_combo.setAttribute("title", "Configure the channel for retrieving data from the Custom Node list (including missing nodes) or the Model list.");
|
||||
channel_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
channel_combo.className = "cm-menu-combo";
|
||||
api.fetchApi('/manager/channel_url_list')
|
||||
.then(response => response.json())
|
||||
.then(async data => {
|
||||
@@ -1094,12 +1021,12 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
for (let i in urls) {
|
||||
if (urls[i] != '') {
|
||||
let name_url = urls[i].split('::');
|
||||
channel_combo.appendChild($el('option', { value: name_url[0], text: `${name_url[0]}` }, []));
|
||||
channel_combo.appendChild($el('option', { value: name_url[0], text: `Channel: ${name_url[0]}` }, []));
|
||||
}
|
||||
}
|
||||
|
||||
channel_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi('/manager/channel_url_list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
|
||||
api.fetchApi(`/manager/channel_url_list?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
channel_combo.value = data.selected;
|
||||
@@ -1109,13 +1036,11 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
});
|
||||
|
||||
const channelSetttingItem = createSettingsCombo("Channel", channel_combo);
|
||||
|
||||
|
||||
// share
|
||||
let share_combo = document.createElement("select");
|
||||
share_combo.setAttribute("title", "Hide the share button in the main menu or set the default action upon clicking it. Additionally, configure the default share site when sharing via the context menu's share button.");
|
||||
share_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
share_combo.className = "cm-menu-combo";
|
||||
const share_options = [
|
||||
['none', 'None'],
|
||||
['openart', 'OpenArt AI'],
|
||||
@@ -1126,7 +1051,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
['all', 'All'],
|
||||
];
|
||||
for (const option of share_options) {
|
||||
share_combo.appendChild($el('option', { value: option[0], text: `${option[1]}` }, []));
|
||||
share_combo.appendChild($el('option', { value: option[0], text: `Share: ${option[1]}` }, []));
|
||||
}
|
||||
|
||||
api.fetchApi('/manager/share_option')
|
||||
@@ -1148,14 +1073,12 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
});
|
||||
|
||||
const shareSetttingItem = createSettingsCombo("Share", share_combo);
|
||||
|
||||
let component_policy_combo = document.createElement("select");
|
||||
component_policy_combo.setAttribute("title", "When loading the workflow, configure which version of the component to use.");
|
||||
component_policy_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
component_policy_combo.appendChild($el('option', { value: 'workflow', text: 'Use workflow version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'higher', text: 'Use higher version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'mine', text: 'Use my version' }, []));
|
||||
component_policy_combo.className = "cm-menu-combo";
|
||||
component_policy_combo.appendChild($el('option', { value: 'workflow', text: 'Component: Use workflow version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'higher', text: 'Component: Use higher version' }, []));
|
||||
component_policy_combo.appendChild($el('option', { value: 'mine', text: 'Component: Use my version' }, []));
|
||||
api.fetchApi('/manager/policy/component')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
@@ -1164,18 +1087,19 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
component_policy_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi('/manager/policy/component', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
|
||||
api.fetchApi(`/manager/policy/component?value=${event.target.value}`);
|
||||
set_component_policy(event.target.value);
|
||||
});
|
||||
|
||||
const componentSetttingItem = createSettingsCombo("Component", component_policy_combo);
|
||||
|
||||
update_policy_combo = document.createElement("select");
|
||||
|
||||
if(isElectron)
|
||||
update_policy_combo.style.display = 'none';
|
||||
|
||||
update_policy_combo.setAttribute("title", "Sets the policy to be applied when performing an update.");
|
||||
update_policy_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
|
||||
update_policy_combo.appendChild($el('option', { value: 'stable-comfyui', text: 'ComfyUI Stable Version' }, []));
|
||||
update_policy_combo.appendChild($el('option', { value: 'nightly-comfyui', text: 'ComfyUI Nightly Version' }, []));
|
||||
update_policy_combo.className = "cm-menu-combo";
|
||||
update_policy_combo.appendChild($el('option', { value: 'stable-comfyui', text: 'Update: ComfyUI Stable Version' }, []));
|
||||
update_policy_combo.appendChild($el('option', { value: 'nightly-comfyui', text: 'Update: ComfyUI Nightly Version' }, []));
|
||||
api.fetchApi('/manager/policy/update')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
@@ -1183,25 +1107,23 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
update_policy_combo.addEventListener('change', function (event) {
|
||||
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
|
||||
api.fetchApi(`/manager/policy/update?value=${event.target.value}`);
|
||||
});
|
||||
|
||||
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
|
||||
|
||||
if(isElectron)
|
||||
updateSetttingItem.style.display = 'none';
|
||||
|
||||
return [
|
||||
dbRetrievalSetttingItem,
|
||||
channelSetttingItem,
|
||||
previewSetttingItem,
|
||||
shareSetttingItem,
|
||||
componentSetttingItem,
|
||||
updateSetttingItem,
|
||||
//[TODO] replace mt-2 with wrapper div with flex column gap
|
||||
$el("filedset.cm-experimental.mt-auto", {}, [
|
||||
$el("br", {}, []),
|
||||
this.datasrc_combo,
|
||||
channel_combo,
|
||||
preview_combo,
|
||||
share_combo,
|
||||
component_policy_combo,
|
||||
update_policy_combo,
|
||||
$el("br", {}, []),
|
||||
|
||||
$el("br", {}, []),
|
||||
$el("filedset.cm-experimental", {}, [
|
||||
$el("legend.cm-experimental-legend", {}, ["EXPERIMENTAL"]),
|
||||
$el("button.p-button.p-component.cm-button.cm-experimental-button", {
|
||||
$el("button.cm-experimental-button", {
|
||||
type: "button",
|
||||
textContent: "Snapshot Manager",
|
||||
onclick:
|
||||
@@ -1211,7 +1133,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
SnapshotManager.instance.show();
|
||||
}
|
||||
}),
|
||||
$el("button.p-button.p-component.cm-button.cm-experimental-button.mt-2", {
|
||||
$el("button.cm-experimental-button", {
|
||||
type: "button",
|
||||
textContent: "Install PIP packages",
|
||||
onclick:
|
||||
@@ -1229,7 +1151,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
|
||||
createControlsRight() {
|
||||
const elts = [
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
id: 'cm-manual-button',
|
||||
type: "button",
|
||||
textContent: "Community Manual",
|
||||
@@ -1280,11 +1202,11 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
})
|
||||
]),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button", {
|
||||
id: 'workflowgallery-button',
|
||||
type: "button",
|
||||
style: {
|
||||
// ...(localStorage.getItem("wg_last_visited") ? {height: '50px'} : {})
|
||||
...(localStorage.getItem("wg_last_visited") ? {height: '50px'} : {})
|
||||
},
|
||||
onclick: (e) => {
|
||||
const last_visited_site = localStorage.getItem("wg_last_visited")
|
||||
@@ -1307,7 +1229,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}, [
|
||||
$el("p", {
|
||||
id: 'workflowgallery-button-last-visited-label',
|
||||
textContent: `(${localStorage.getItem("wg_last_visited") ? localStorage.getItem("wg_last_visited").split('/')[2] : 'none selected'})`,
|
||||
textContent: `(${localStorage.getItem("wg_last_visited") ? localStorage.getItem("wg_last_visited").split('/')[2] : ''})`,
|
||||
style: {
|
||||
'text-align': 'center',
|
||||
'color': 'var(--input-text)',
|
||||
@@ -1323,12 +1245,13 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
})
|
||||
]),
|
||||
|
||||
$el("button.p-button.p-component.cm-button", {
|
||||
$el("button.cm-button", {
|
||||
id: 'cm-nodeinfo-button',
|
||||
type: "button",
|
||||
textContent: "Nodes Info",
|
||||
onclick: () => { window.open("https://ltdrdata.github.io/", "comfyui-node-info"); }
|
||||
}),
|
||||
$el("br", {}, []),
|
||||
];
|
||||
|
||||
var textarea = document.createElement("div");
|
||||
@@ -1343,23 +1266,31 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const content = $el("div.cm-menu-container",
|
||||
[
|
||||
$el("div.cm-menu-column.gap-2", [...this.createControlsLeft()]),
|
||||
$el("div.cm-menu-column.gap-2", [...this.createControlsMid()]),
|
||||
$el("div.cm-menu-column.gap-2", [...this.createControlsRight()])
|
||||
]
|
||||
);
|
||||
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => this.close() });
|
||||
|
||||
const frame = buildGuiFrame(
|
||||
'cm-manager-dialog', // dialog id
|
||||
`ComfyUI Manager ${manager_version}`, // dialog title
|
||||
"i.mdi.mdi-puzzle", // dialog icon class to show before title
|
||||
content, // dialog content element
|
||||
this
|
||||
); // send this so we can attach close functions
|
||||
const content =
|
||||
$el("div.comfy-modal-content",
|
||||
[
|
||||
$el("tr.cm-title", {}, [
|
||||
$el("font", {size:6, color:"white"}, [`ComfyUI Manager ${manager_version}`])]
|
||||
),
|
||||
$el("br", {}, []),
|
||||
$el("div.cm-menu-container",
|
||||
[
|
||||
$el("div.cm-menu-column", [...this.createControlsLeft()]),
|
||||
$el("div.cm-menu-column", [...this.createControlsMid()]),
|
||||
$el("div.cm-menu-column", [...this.createControlsRight()])
|
||||
]),
|
||||
|
||||
this.element = frame;
|
||||
$el("br", {}, []),
|
||||
close_button,
|
||||
]
|
||||
);
|
||||
|
||||
content.style.width = '100%';
|
||||
content.style.height = '100%';
|
||||
|
||||
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
@@ -1367,7 +1298,7 @@ class ManagerMenuDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
show() {
|
||||
this.element.style.display = "flex";
|
||||
this.element.style.display = "block";
|
||||
}
|
||||
|
||||
toggleVisibility() {
|
||||
@@ -1544,31 +1475,6 @@ app.registerExtension({
|
||||
|
||||
load_components();
|
||||
|
||||
// Fetch and show startup alerts (critical errors like outdated ComfyUI)
|
||||
// Poll until extensionManager.toast is ready (set in Vue onMounted)
|
||||
const showStartupAlerts = async () => {
|
||||
let toastWaitCount = 0;
|
||||
const waitForToast = () => {
|
||||
if (window['app']?.extensionManager?.toast) {
|
||||
fetch('/manager/startup_alerts')
|
||||
.then(response => response.ok ? response.json() : [])
|
||||
.then(alerts => {
|
||||
for (const alert of alerts) {
|
||||
customAlert(alert.message);
|
||||
}
|
||||
})
|
||||
.catch(e => console.warn('[ComfyUI-Manager] Failed to fetch startup alerts:', e));
|
||||
} else if (toastWaitCount < 300) { // Max 30 seconds (300 * 100ms)
|
||||
toastWaitCount++;
|
||||
setTimeout(waitForToast, 100);
|
||||
} else {
|
||||
console.warn('[ComfyUI-Manager] Timeout waiting for toast. Startup alerts skipped.');
|
||||
}
|
||||
};
|
||||
waitForToast();
|
||||
};
|
||||
showStartupAlerts();
|
||||
|
||||
const menu = document.querySelector(".comfy-menu");
|
||||
const separator = document.createElement("hr");
|
||||
|
||||
|
||||
@@ -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, safeHref, sanitizeHTML } from "./common.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
export const SUPPORTED_OUTPUT_NODE_TYPES = [
|
||||
"PreviewImage",
|
||||
@@ -937,7 +937,7 @@ 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='" + safeHref(response_json.comfyworkflows.url) + "' target='_blank' rel='noopener noreferrer'>" + sanitizeHTML(response_json.comfyworkflows.url) + "</a>";
|
||||
this.final_message.innerHTML = "Your art has been shared: <a href='" + response_json.comfyworkflows.url + "' target='_blank'>" + response_json.comfyworkflows.url + "</a>";
|
||||
if (response_json.matrix.success) {
|
||||
this.final_message.innerHTML += "<br>Your art has been shared in the ComfyUI Matrix server's #share channel!";
|
||||
}
|
||||
|
||||
+48
-231
@@ -1,6 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { customAlert, safeHref } from "./common.js";
|
||||
import { customAlert } from "./common.js";
|
||||
|
||||
const env = "prod";
|
||||
|
||||
@@ -71,7 +71,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
this.allFiles = [];
|
||||
this.titleNum = 0;
|
||||
}
|
||||
|
||||
|
||||
createButtons() {
|
||||
const inputStyle = {
|
||||
display: "block",
|
||||
@@ -201,15 +201,13 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
});
|
||||
this.LockInput = $el("input", {
|
||||
type: "text",
|
||||
placeholder: "0",
|
||||
style: {
|
||||
placeholder: "",
|
||||
style: {
|
||||
width: "100px",
|
||||
padding: "7px",
|
||||
paddingLeft: "30px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ddd",
|
||||
boxSizing: "border-box",
|
||||
position: "relative",
|
||||
},
|
||||
oninput: (event) => {
|
||||
let input = event.target.value;
|
||||
@@ -303,7 +301,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
|
||||
const titleNumDom = $el(
|
||||
"label",
|
||||
{
|
||||
@@ -344,11 +342,15 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
["0/70"]
|
||||
);
|
||||
// Additional Inputs Section
|
||||
const additionalInputsSection = $el("div", { style: { ...sectionStyle } }, [
|
||||
$el("label", { style: labelStyle }, ["3️⃣ Title "]),
|
||||
this.TitleInput,
|
||||
titleNumDom,
|
||||
]);
|
||||
const additionalInputsSection = $el(
|
||||
"div",
|
||||
{ style: { ...sectionStyle, } },
|
||||
[
|
||||
$el("label", { style: labelStyle }, ["3️⃣ Title "]),
|
||||
this.TitleInput,
|
||||
titleNumDom,
|
||||
]
|
||||
);
|
||||
const SubtitleSection = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["4️⃣ Subtitle "]),
|
||||
this.SubTitleInput,
|
||||
@@ -377,7 +379,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
const blockChainSection_lock = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["6️⃣ Download threshold"]),
|
||||
$el("label", { style: labelStyle }, ["6️⃣ Pay to download"]),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
@@ -390,42 +392,11 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
},
|
||||
[
|
||||
this.radioButtonsCheck_lock,
|
||||
$el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
marginLeft: "5px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
position: "relative",
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("span", { style: { marginLeft: "5px" } }, ["ON"]),
|
||||
$el(
|
||||
"span",
|
||||
{
|
||||
style: {
|
||||
marginLeft: "20px",
|
||||
marginRight: "10px",
|
||||
color: "#fff",
|
||||
},
|
||||
},
|
||||
["Unlock with"]
|
||||
),
|
||||
$el("img", {
|
||||
style: {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
position: "absolute",
|
||||
right: "75px",
|
||||
zIndex: "100",
|
||||
},
|
||||
src: "https://static.copus.io/images/admin/202507/prod/e2919a1d8f3c2d99d3b8fe27ff94b841.png",
|
||||
}),
|
||||
this.LockInput,
|
||||
]
|
||||
),
|
||||
$el("div", { style: { marginLeft: "5px" ,display:'flex',alignItems:'center'} }, [
|
||||
$el("span", { style: { marginLeft: "5px" } }, ["ON"]),
|
||||
$el("span", { style: { marginLeft: "20px",marginRight:'10px' ,color:'#fff'} }, ["Price US$"]),
|
||||
this.LockInput
|
||||
]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
@@ -433,25 +404,14 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
{ style: { display: "flex", alignItems: "center", cursor: "pointer" } },
|
||||
[
|
||||
this.radioButtonsCheckOff_lock,
|
||||
$el(
|
||||
"div",
|
||||
{
|
||||
style: {
|
||||
marginLeft: "5px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
},
|
||||
[$el("span", { style: { marginLeft: "5px" } }, ["OFF"])]
|
||||
),
|
||||
$el("span", { style: { marginLeft: "5px" } }, ["OFF"]),
|
||||
]
|
||||
),
|
||||
|
||||
|
||||
$el(
|
||||
"p",
|
||||
{ style: { fontSize: "16px", color: "#fff", margin: "10px 0 0 0" } },
|
||||
[
|
||||
]
|
||||
["Get paid from your workflow. You can change the price and withdraw your earnings on Copus."]
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -472,7 +432,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
const blockChainSection = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["8️⃣ Store on blockchain "]),
|
||||
$el("label", { style: labelStyle }, ["7️⃣ Store on blockchain "]),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
@@ -503,139 +463,6 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
),
|
||||
]);
|
||||
|
||||
this.ratingRadioButtonsCheck0 = $el("input", {
|
||||
type: "radio",
|
||||
name: "content_rating",
|
||||
value: "0",
|
||||
id: "content_rating0",
|
||||
});
|
||||
this.ratingRadioButtonsCheck1 = $el("input", {
|
||||
type: "radio",
|
||||
name: "content_rating",
|
||||
value: "1",
|
||||
id: "content_rating1",
|
||||
});
|
||||
this.ratingRadioButtonsCheck2 = $el("input", {
|
||||
type: "radio",
|
||||
name: "content_rating",
|
||||
value: "2",
|
||||
id: "content_rating2",
|
||||
});
|
||||
this.ratingRadioButtonsCheck_1 = $el("input", {
|
||||
type: "radio",
|
||||
name: "content_rating",
|
||||
value: "-1",
|
||||
id: "content_rating_1",
|
||||
checked: true,
|
||||
});
|
||||
|
||||
// content rating
|
||||
const contentRatingSection = $el("div", { style: sectionStyle }, [
|
||||
$el("label", { style: labelStyle }, ["7️⃣ Content rating "]),
|
||||
$el(
|
||||
"label",
|
||||
{
|
||||
style: {
|
||||
marginTop: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
[
|
||||
this.ratingRadioButtonsCheck0,
|
||||
$el("img", {
|
||||
style: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
marginLeft: "5px",
|
||||
},
|
||||
src: "https://static.copus.io/images/client/202507/test/b9f17da83b054d53cd0cb4508c2c30dc.png",
|
||||
}),
|
||||
$el("span", { style: { marginLeft: "5px", color: "#fff" } }, [
|
||||
"All ages",
|
||||
]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"p",
|
||||
{ style: { fontSize: "10px", color: "#fff", marginLeft: "20px" } },
|
||||
["Safe for all viewers; no profanity, violence, or mature themes."]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{ style: { display: "flex", alignItems: "center", cursor: "pointer" } },
|
||||
[
|
||||
this.ratingRadioButtonsCheck1,
|
||||
$el("img", {
|
||||
style: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
marginLeft: "5px",
|
||||
},
|
||||
src: "https://static.copus.io/images/client/202507/test/7848bc0d3690671df21c7cf00c4cfc81.png",
|
||||
}),
|
||||
$el("span", { style: { marginLeft: "5px", color: "#fff" } }, [
|
||||
"13+ (Teen)",
|
||||
]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"p",
|
||||
{ style: { fontSize: "10px", color: "#fff", marginLeft: "20px" } },
|
||||
[
|
||||
"Mild language, light themes, or cartoon violence; no explicit content. ",
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{ style: { display: "flex", alignItems: "center", cursor: "pointer" } },
|
||||
[
|
||||
this.ratingRadioButtonsCheck2,
|
||||
$el("img", {
|
||||
style: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
marginLeft: "5px",
|
||||
},
|
||||
src: "https://static.copus.io/images/client/202507/test/bc51839c208d68d91173e43c23bff039.png",
|
||||
}),
|
||||
$el("span", { style: { marginLeft: "5px", color: "#fff" } }, [
|
||||
"18+ (Explicit)",
|
||||
]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"p",
|
||||
{ style: { fontSize: "10px", color: "#fff", marginLeft: "20px" } },
|
||||
[
|
||||
"Explicit content, including sexual content, strong violence, or intense themes. ",
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"label",
|
||||
{ style: { display: "flex", alignItems: "center", cursor: "pointer" } },
|
||||
[
|
||||
this.ratingRadioButtonsCheck_1,
|
||||
$el("img", {
|
||||
style: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
marginLeft: "5px",
|
||||
},
|
||||
src: "https://static.copus.io/images/client/202507/test/5c802fdcaaea4e7bbed37393eec0d5ba.png",
|
||||
}),
|
||||
$el("span", { style: { marginLeft: "5px", color: "#fff" } }, [
|
||||
"Not Rated",
|
||||
]),
|
||||
]
|
||||
),
|
||||
$el(
|
||||
"p",
|
||||
{ style: { fontSize: "10px", color: "#fff", marginLeft: "20px" } },
|
||||
["No age rating provided."]
|
||||
),
|
||||
]);
|
||||
|
||||
// Message Section
|
||||
this.message = $el(
|
||||
@@ -699,7 +526,6 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
DescriptionSection,
|
||||
// contestSection,
|
||||
blockChainSection_lock,
|
||||
contentRatingSection,
|
||||
blockChainSection,
|
||||
this.message,
|
||||
buttonsSection,
|
||||
@@ -708,7 +534,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
return layout;
|
||||
}
|
||||
/**
|
||||
* api
|
||||
* api
|
||||
* @param {url} path
|
||||
* @param {params} options
|
||||
* @param {statusText} statusText
|
||||
@@ -761,9 +587,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
url: data,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
"make sure your API key is correct and try again later"
|
||||
);
|
||||
throw new Error("make sure your API key is correct and try again later");
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 413) {
|
||||
@@ -804,15 +628,8 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
subTitle: this.SubTitleInput.value,
|
||||
content: this.descriptionInput.value,
|
||||
storeOnChain: this.radioButtonsCheck.checked ? true : false,
|
||||
lockState: this.radioButtonsCheck_lock.checked ? 2 : 0,
|
||||
unlockPrice: this.LockInput.value,
|
||||
rating: this.ratingRadioButtonsCheck0.checked
|
||||
? 0
|
||||
: this.ratingRadioButtonsCheck1.checked
|
||||
? 1
|
||||
: this.ratingRadioButtonsCheck2.checked
|
||||
? 2
|
||||
: -1,
|
||||
lockState:this.radioButtonsCheck_lock.checked ? 2 : 0,
|
||||
unlockPrice:this.LockInput.value,
|
||||
};
|
||||
|
||||
if (!this.keyInput.value) {
|
||||
@@ -827,8 +644,8 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
throw new Error("Title is required");
|
||||
}
|
||||
|
||||
if (this.radioButtonsCheck_lock.checked) {
|
||||
if (!this.LockInput.value) {
|
||||
if(this.radioButtonsCheck_lock.checked){
|
||||
if (!this.LockInput.value){
|
||||
throw new Error("Price is required");
|
||||
}
|
||||
}
|
||||
@@ -878,23 +695,23 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
"Uploading workflow..."
|
||||
);
|
||||
|
||||
if (res.status && res.data.status && res.data) {
|
||||
localStorage.setItem("copus_token", this.keyInput.value);
|
||||
const { data } = res.data;
|
||||
if (data) {
|
||||
const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
this.allFilesImages = [];
|
||||
this.allFiles = [];
|
||||
this.TitleInput.value = "";
|
||||
this.SubTitleInput.value = "";
|
||||
this.descriptionInput.value = "";
|
||||
this.selectedFile = null;
|
||||
}
|
||||
}
|
||||
if (res.status && res.data.status && res.data) {
|
||||
localStorage.setItem("copus_token",this.keyInput.value);
|
||||
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.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
this.allFilesImages = [];
|
||||
this.allFiles = [];
|
||||
this.TitleInput.value = "";
|
||||
this.SubTitleInput.value = "";
|
||||
this.descriptionInput.value = "";
|
||||
this.selectedFile = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error("Error sharing workflow: " + e.message);
|
||||
}
|
||||
@@ -940,7 +757,7 @@ export class CopusShareDialog extends ComfyDialog {
|
||||
this.element.style.display = "block";
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.keyInput.value = apiToken != null ? apiToken : "";
|
||||
this.keyInput.value = apiToken!=null?apiToken:"";
|
||||
this.uploadedImages = [];
|
||||
this.allFilesImages = [];
|
||||
this.allFiles = [];
|
||||
|
||||
@@ -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, safeHref } from "./common.js";
|
||||
import { customAlert } 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="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
|
||||
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
|
||||
this.previewImage.src = "";
|
||||
this.previewImage.style.display = "none";
|
||||
this.uploadedImages = [];
|
||||
|
||||
@@ -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, safeHref } from "./common.js";
|
||||
import { customAlert } 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="${safeHref(recipePageUrl)}" target="_blank" rel="noopener noreferrer">visit it on YouML</a>`;
|
||||
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
|
||||
|
||||
this.uploadedImages = [];
|
||||
this.nameInput.value = "";
|
||||
|
||||
+39
-628
@@ -1,7 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { $el, ComfyDialog } from "../../scripts/ui.js";
|
||||
import { getBestPosition, getPositionStyle, getRect } from './popover-helper.js';
|
||||
|
||||
|
||||
function internalCustomConfirm(message, confirmMessage, cancelMessage) {
|
||||
@@ -100,19 +99,6 @@ export function show_message(msg) {
|
||||
app.ui.dialog.element.style.zIndex = 1100;
|
||||
}
|
||||
|
||||
export async function handle403Response(res, defaultMessage) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
show_message('ComfyUI version is outdated.<BR>Please update ComfyUI to use Manager normally.');
|
||||
} else {
|
||||
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
|
||||
}
|
||||
} catch {
|
||||
show_message(defaultMessage || 'This action is not allowed with this security level configuration.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -122,9 +108,9 @@ export async function customConfirm(message) {
|
||||
let res = await
|
||||
window['app'].extensionManager.dialog
|
||||
.confirm({
|
||||
title: 'Confirm',
|
||||
message: message
|
||||
});
|
||||
title: 'Confirm',
|
||||
message: message
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -164,9 +150,9 @@ export async function customPrompt(title, message) {
|
||||
let res = await
|
||||
window['app'].extensionManager.dialog
|
||||
.prompt({
|
||||
title: title,
|
||||
message: message
|
||||
});
|
||||
title: title,
|
||||
message: message
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
@@ -176,22 +162,36 @@ export async function customPrompt(title, message) {
|
||||
}
|
||||
|
||||
|
||||
export async function rebootAPI() {
|
||||
export function rebootAPI() {
|
||||
if ('electronAPI' in window) {
|
||||
window.electronAPI.restartApp();
|
||||
return true;
|
||||
}
|
||||
|
||||
const isConfirmed = await customConfirm("Are you sure you'd like to reboot the server?");
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
const response = await api.fetchApi("/manager/reboot", { method: 'POST' });
|
||||
if (response.status == 403) {
|
||||
await handle403Response(response);
|
||||
return false;
|
||||
customConfirm("Are you sure you'd like to reboot the server?").then((isConfirmed) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
api.fetchApi("/manager/reboot");
|
||||
}
|
||||
catch(exception) {}
|
||||
}
|
||||
catch(exception) {}
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
export async function migrateAPI() {
|
||||
let confirmed = await customConfirm("When performing a migration, existing installed custom nodes will be renamed and the server will be restarted. Are you sure you want to apply this?\n\n(If you don't perform the migration, ComfyUI-Manager's start-up time will be longer each time due to re-checking during startup.)")
|
||||
if (confirmed) {
|
||||
try {
|
||||
await api.fetchApi("/manager/migrate_unmanaged_nodes");
|
||||
api.fetchApi("/manager/reboot");
|
||||
}
|
||||
catch(exception) {
|
||||
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -223,19 +223,16 @@ function isValidURL(url) {
|
||||
}
|
||||
|
||||
export async function install_pip(packages) {
|
||||
if(packages.includes('&')) {
|
||||
if(packages.includes('&'))
|
||||
app.ui.dialog.show(`Invalid PIP package enumeration: '${packages}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await api.fetchApi("/customnode/install/pip", {
|
||||
method: "POST",
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ packages: packages }),
|
||||
body: packages,
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
await handle403Response(res, "To use this feature you need BOTH: (1) 'allow_pip_install = true' in config.ini ([default] section), AND (2) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1). 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.");
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -266,12 +263,11 @@ export async function install_via_git_url(url, manager_dialog) {
|
||||
|
||||
const res = await api.fetchApi("/customnode/install/git_url", {
|
||||
method: "POST",
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url }),
|
||||
body: url,
|
||||
});
|
||||
|
||||
if(res.status == 403) {
|
||||
await handle403Response(res, "To use this feature you need BOTH: (1) 'allow_git_url_install = true' in config.ini ([default] section), AND (2) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1). 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.");
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,9 +278,9 @@ export async function install_via_git_url(url, manager_dialog) {
|
||||
const self = this;
|
||||
|
||||
rebootButton.addEventListener("click",
|
||||
async function() {
|
||||
if(await rebootAPI()) {
|
||||
manager_instance.close();
|
||||
function() {
|
||||
if(rebootAPI()) {
|
||||
manager_dialog.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -408,14 +404,12 @@ export async function fetchData(route, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// https://cenfun.github.io/open-icons/
|
||||
export const icons = {
|
||||
search: '<svg viewBox="0 0 24 24" width="100%" height="100%" pointer-events="none" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 21-4.486-4.494M19 10.5a8.5 8.5 0 1 1-17 0 8.5 8.5 0 0 1 17 0"/></svg>',
|
||||
extensions: '<svg viewBox="64 64 896 896" width="100%" height="100%" pointer-events="none" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M843.5 737.4c-12.4-75.2-79.2-129.1-155.3-125.4S550.9 676 546 752c-153.5-4.8-208-40.7-199.1-113.7 3.3-27.3 19.8-41.9 50.1-49 18.4-4.3 38.8-4.9 57.3-3.2 1.7.2 3.5.3 5.2.5 11.3 2.7 22.8 5 34.3 6.8 34.1 5.6 68.8 8.4 101.8 6.6 92.8-5 156-45.9 159.2-132.7 3.1-84.1-54.7-143.7-147.9-183.6-29.9-12.8-61.6-22.7-93.3-30.2-14.3-3.4-26.3-5.7-35.2-7.2-7.9-75.9-71.5-133.8-147.8-134.4S189.7 168 180.5 243.8s40 146.3 114.2 163.9 149.9-23.3 175.7-95.1c9.4 1.7 18.7 3.6 28 5.8 28.2 6.6 56.4 15.4 82.4 26.6 70.7 30.2 109.3 70.1 107.5 119.9-1.6 44.6-33.6 65.2-96.2 68.6-27.5 1.5-57.6-.9-87.3-5.8-8.3-1.4-15.9-2.8-22.6-4.3-3.9-.8-6.6-1.5-7.8-1.8l-3.1-.6c-2.2-.3-5.9-.8-10.7-1.3-25-2.3-52.1-1.5-78.5 4.6-55.2 12.9-93.9 47.2-101.1 105.8-15.7 126.2 78.6 184.7 276 188.9 29.1 70.4 106.4 107.9 179.6 87 73.3-20.9 119.3-93.4 106.9-168.6M329.1 345.2a83.3 83.3 0 1 1 .01-166.61 83.3 83.3 0 0 1-.01 166.61M695.6 845a83.3 83.3 0 1 1 .01-166.61A83.3 83.3 0 0 1 695.6 845"/></svg>',
|
||||
conflicts: '<svg viewBox="0 0 400 400" width="100%" height="100%" pointer-events="none" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="m397.2 350.4.2-.2-180-320-.2.2C213.8 24.2 207.4 20 200 20s-13.8 4.2-17.2 10.4l-.2-.2-180 320 .2.2c-1.6 2.8-2.8 6-2.8 9.6 0 11 9 20 20 20h360c11 0 20-9 20-20 0-3.6-1.2-6.8-2.8-9.6M220 340h-40v-40h40zm0-60h-40V120h40z"/></svg>',
|
||||
passed: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 426.667 426.667"><path fill="#6AC259" d="M213.333,0C95.518,0,0,95.514,0,213.333s95.518,213.333,213.333,213.333c117.828,0,213.333-95.514,213.333-213.333S331.157,0,213.333,0z M174.199,322.918l-93.935-93.931l31.309-31.309l62.626,62.622l140.894-140.898l31.309,31.309L174.199,322.918z"/></svg>',
|
||||
download: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" width="100%" height="100%" viewBox="0 0 32 32"><path fill="currentColor" d="M26 24v4H6v-4H4v4a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-4zm0-10l-1.41-1.41L17 20.17V2h-2v18.17l-7.59-7.58L6 14l10 10l10-10z"></path></svg>',
|
||||
close: '<svg xmlns="http://www.w3.org/2000/svg" pointer-events="none" width="100%" height="100%" viewBox="0 0 16 16"><g fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="m7.116 8-4.558 4.558.884.884L8 8.884l4.558 4.558.884-.884L8.884 8l4.558-4.558-.884-.884L8 7.116 3.442 2.558l-.884.884L7.116 8z"/></g></svg>',
|
||||
arrowRight: '<svg xmlns="http://www.w3.org/2000/svg" pointer-events="none" width="100%" height="100%" viewBox="0 0 20 20"><path fill="currentColor" fill-rule="evenodd" d="m2.542 2.154 7.254 7.26c.136.14.204.302.204.483a.73.73 0 0 1-.204.5l-7.575 7.398c-.383.317-.724.317-1.022 0-.299-.317-.299-.643 0-.98l7.08-6.918-6.754-6.763c-.237-.343-.215-.654.066-.935.281-.28.598-.295.951-.045Zm9 0 7.254 7.26c.136.14.204.302.204.483a.73.73 0 0 1-.204.5l-7.575 7.398c-.383.317-.724.317-1.022 0-.299-.317-.299-.643 0-.98l7.08-6.918-6.754-6.763c-.237-.343-.215-.654.066-.935.281-.28.598-.295.951-.045Z"/></svg>'
|
||||
download: '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" width="100%" height="100%" viewBox="0 0 32 32"><path fill="currentColor" d="M26 24v4H6v-4H4v4a2 2 0 0 0 2 2h20a2 2 0 0 0 2-2v-4zm0-10l-1.41-1.41L17 20.17V2h-2v18.17l-7.59-7.58L6 14l10 10l10-10z"></path></svg>'
|
||||
}
|
||||
|
||||
export function sanitizeHTML(str) {
|
||||
@@ -427,40 +421,6 @@ export function sanitizeHTML(str) {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Keep URL handling aligned with glob/manager_util.py; covered by shared cases.
|
||||
export const SAFE_URL_SCHEMES = ['http', 'https'];
|
||||
const URL_SCHEME_NOISE = /[\x00-\x20\x7f]/g;
|
||||
const URL_HEAD_DELIMITERS = ['/', '?', '#'];
|
||||
// Match Python str.strip(); JavaScript trim() uses different Unicode whitespace.
|
||||
const URL_TRIM = /^[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g;
|
||||
const trimUrl = (value) => value.replace(URL_TRIM, '');
|
||||
|
||||
export function sanitizeUrl(url) {
|
||||
const raw = (url === null || url === undefined) ? '' : String(url);
|
||||
const probe = raw.replace(URL_SCHEME_NOISE, '');
|
||||
if (!probe) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
let cut = probe.length;
|
||||
for (const delimiter of URL_HEAD_DELIMITERS) {
|
||||
const found = probe.indexOf(delimiter);
|
||||
if (found !== -1) {
|
||||
cut = Math.min(cut, found);
|
||||
}
|
||||
}
|
||||
const head = probe.slice(0, cut);
|
||||
|
||||
if (head.includes(':')) {
|
||||
return SAFE_URL_SCHEMES.includes(head.split(':', 1)[0].toLowerCase()) ? trimUrl(raw) : '#';
|
||||
}
|
||||
|
||||
return trimUrl(raw);
|
||||
}
|
||||
|
||||
// Validate the URL, then escape it for a quoted href attribute.
|
||||
export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));
|
||||
|
||||
export function showTerminal() {
|
||||
try {
|
||||
const panel = app.extensionManager.bottomPanel;
|
||||
@@ -543,552 +503,3 @@ export function restoreColumnWidth(gridId, columns) {
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
export function getTimeAgo(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const units = [
|
||||
{ max: 2760000, value: 60000, name: 'minute', past: 'a minute ago', future: 'in a minute' },
|
||||
{ max: 72000000, value: 3600000, name: 'hour', past: 'an hour ago', future: 'in an hour' },
|
||||
{ max: 518400000, value: 86400000, name: 'day', past: 'yesterday', future: 'tomorrow' },
|
||||
{ max: 2419200000, value: 604800000, name: 'week', past: 'last week', future: 'in a week' },
|
||||
{ max: 28512000000, value: 2592000000, name: 'month', past: 'last month', future: 'in a month' }
|
||||
];
|
||||
const diff = Date.now() - date.getTime();
|
||||
// less than a minute
|
||||
if (Math.abs(diff) < 60000)
|
||||
return 'just now';
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
if (Math.abs(diff) < units[i].max) {
|
||||
return format(diff, units[i].value, units[i].name, units[i].past, units[i].future, diff < 0);
|
||||
}
|
||||
}
|
||||
function format(diff, divisor, unit, past, future, isInTheFuture) {
|
||||
const val = Math.round(Math.abs(diff) / divisor);
|
||||
if (isInTheFuture)
|
||||
return val <= 1 ? future : 'in ' + val + ' ' + unit + 's';
|
||||
return val <= 1 ? past : val + ' ' + unit + 's ago';
|
||||
}
|
||||
return format(diff, 31536000000, 'year', 'last year', 'in a year', diff < 0);
|
||||
};
|
||||
|
||||
export const loadCss = (cssFile) => {
|
||||
const cssPath = import.meta.resolve(cssFile);
|
||||
//console.log(cssPath);
|
||||
const $link = document.createElement("link");
|
||||
$link.setAttribute("rel", 'stylesheet');
|
||||
$link.setAttribute("href", cssPath);
|
||||
document.head.appendChild($link);
|
||||
};
|
||||
|
||||
export const copyText = (text) => {
|
||||
return new Promise((resolve) => {
|
||||
let err;
|
||||
try {
|
||||
navigator.clipboard.writeText(text);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
if (err) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function renderPopover($elem, target, options = {}) {
|
||||
// async microtask
|
||||
queueMicrotask(() => {
|
||||
|
||||
const containerRect = getRect(window);
|
||||
const targetRect = getRect(target);
|
||||
const elemRect = getRect($elem);
|
||||
|
||||
const positionInfo = getBestPosition(
|
||||
containerRect,
|
||||
targetRect,
|
||||
elemRect,
|
||||
options.positions
|
||||
);
|
||||
const style = getPositionStyle(positionInfo, {
|
||||
bgColor: options.bgColor,
|
||||
borderColor: options.borderColor,
|
||||
borderRadius: options.borderRadius
|
||||
});
|
||||
|
||||
$elem.style.top = positionInfo.top + "px";
|
||||
$elem.style.left = positionInfo.left + "px";
|
||||
$elem.style.background = style.background;
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
let $popover;
|
||||
export function hidePopover() {
|
||||
if ($popover) {
|
||||
$popover.remove();
|
||||
$popover = null;
|
||||
}
|
||||
}
|
||||
export function showPopover(target, text, className, options) {
|
||||
hidePopover();
|
||||
$popover = document.createElement("div");
|
||||
$popover.className = ['cn-popover', className].filter(it => it).join(" ");
|
||||
document.body.appendChild($popover);
|
||||
$popover.innerHTML = text;
|
||||
$popover.style.display = "block";
|
||||
renderPopover($popover, target, {
|
||||
borderRadius: 10,
|
||||
... options
|
||||
});
|
||||
}
|
||||
|
||||
export async function uninstallNodes(nodeList, options = {}) {
|
||||
const {
|
||||
title = `${nodeList.length} custom nodes`,
|
||||
onProgress = () => {},
|
||||
onError = () => {},
|
||||
onSuccess = () => {},
|
||||
channel = 'default',
|
||||
mode = 'default'
|
||||
} = options;
|
||||
|
||||
// Check if queue is busy
|
||||
let stats = await api.fetchApi('/manager/queue/status');
|
||||
stats = await stats.json();
|
||||
if (stats.is_processing) {
|
||||
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
|
||||
return { success: false, error: 'Queue is busy' };
|
||||
}
|
||||
|
||||
// Confirmation dialog for uninstall
|
||||
const confirmed = await customConfirm(`Are you sure uninstall ${title}?`);
|
||||
if (!confirmed) {
|
||||
return { success: false, error: 'User cancelled' };
|
||||
}
|
||||
|
||||
let errorMsg = "";
|
||||
let target_items = [];
|
||||
|
||||
await api.fetchApi('/manager/queue/reset');
|
||||
|
||||
for (const nodeItem of nodeList) {
|
||||
target_items.push(nodeItem);
|
||||
|
||||
// Titles are escaped by the caller; the name fallback is a raw pack key.
|
||||
const displayTitle = nodeItem.title || sanitizeHTML(String(nodeItem.name));
|
||||
onProgress(`Uninstall ${displayTitle} ...`);
|
||||
|
||||
const data = nodeItem.originalData || nodeItem;
|
||||
data.channel = channel;
|
||||
data.mode = mode;
|
||||
data.ui_id = nodeItem.hash || md5(nodeItem.name || nodeItem.title);
|
||||
|
||||
const res = await api.fetchApi(`/manager/queue/uninstall`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.status != 200) {
|
||||
errorMsg = `'${displayTitle}': `;
|
||||
|
||||
if (res.status == 403) {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
} else {
|
||||
errorMsg += sanitizeHTML(await res.text()) + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
onError(errorMsg);
|
||||
show_message("[Uninstall Errors]\n" + errorMsg);
|
||||
return { success: false, error: errorMsg, targets: target_items };
|
||||
} else {
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
onSuccess(target_items);
|
||||
showTerminal();
|
||||
return { success: true, targets: target_items };
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
// Workflow Utilities Consolidation
|
||||
|
||||
export async function getWorkflowNodeTypes() {
|
||||
try {
|
||||
const res = await fetchData('/customnode/get_node_types_in_workflows');
|
||||
|
||||
if (res.status === 200) {
|
||||
return { success: true, data: res.data };
|
||||
} else if (res.status === 204) {
|
||||
// No workflows found - return empty list
|
||||
return { success: true, data: [] };
|
||||
} else {
|
||||
return { success: false, error: res.error };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error };
|
||||
}
|
||||
}
|
||||
|
||||
export function findPackageByCnrId(cnrId, nodePackages, installedOnly = true) {
|
||||
if (!cnrId || !nodePackages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tier 1: Direct key match
|
||||
if (nodePackages[cnrId]) {
|
||||
const pack = nodePackages[cnrId];
|
||||
if (!installedOnly || pack.state !== "not-installed") {
|
||||
return { key: cnrId, pack: pack };
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2: Case-insensitive match
|
||||
const cnrIdLower = cnrId.toLowerCase();
|
||||
for (const packKey of Object.keys(nodePackages)) {
|
||||
if (packKey.toLowerCase() === cnrIdLower) {
|
||||
const pack = nodePackages[packKey];
|
||||
if (!installedOnly || pack.state !== "not-installed") {
|
||||
return { key: packKey, pack: pack };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3: URL/reference contains match
|
||||
for (const packKey of Object.keys(nodePackages)) {
|
||||
const pack = nodePackages[packKey];
|
||||
|
||||
// Skip non-installed packages if installedOnly is true
|
||||
if (installedOnly && pack.state === "not-installed") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if reference URL contains cnr_id
|
||||
if (pack.reference && pack.reference.includes(cnrId)) {
|
||||
return { key: packKey, pack: pack };
|
||||
}
|
||||
|
||||
// Check if any file URL contains cnr_id
|
||||
if (pack.files && Array.isArray(pack.files)) {
|
||||
for (const fileUrl of pack.files) {
|
||||
if (fileUrl.includes(cnrId)) {
|
||||
return { key: packKey, pack: pack };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function analyzeWorkflowUsage(nodePackages) {
|
||||
const result = await getWorkflowNodeTypes();
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
const workflowNodeList = result.data;
|
||||
const usageMap = new Map();
|
||||
const workflowDetailsMap = new Map();
|
||||
|
||||
if (workflowNodeList && Array.isArray(workflowNodeList)) {
|
||||
const cnrIdCounts = new Map();
|
||||
const cnrIdToWorkflows = new Map();
|
||||
|
||||
// Process each workflow
|
||||
workflowNodeList.forEach((workflowObj, workflowIndex) => {
|
||||
if (workflowObj.node_types && Array.isArray(workflowObj.node_types)) {
|
||||
const workflowCnrIds = new Set();
|
||||
|
||||
// Get workflow filename
|
||||
const workflowFilename = workflowObj.workflow_file_name ||
|
||||
workflowObj.filename ||
|
||||
workflowObj.file ||
|
||||
workflowObj.name ||
|
||||
workflowObj.path ||
|
||||
`Workflow ${workflowIndex + 1}`;
|
||||
|
||||
// Count nodes per cnr_id in this workflow
|
||||
const workflowCnrIdCounts = new Map();
|
||||
workflowObj.node_types.forEach(nodeTypeObj => {
|
||||
const cnrId = nodeTypeObj.cnr_id;
|
||||
|
||||
if (cnrId && cnrId !== "comfy-core") {
|
||||
// Track unique cnr_ids per workflow
|
||||
workflowCnrIds.add(cnrId);
|
||||
|
||||
// Count nodes per cnr_id in this specific workflow
|
||||
const workflowNodeCount = workflowCnrIdCounts.get(cnrId) || 0;
|
||||
workflowCnrIdCounts.set(cnrId, workflowNodeCount + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Record workflow details for each unique cnr_id found in this workflow
|
||||
workflowCnrIds.forEach(cnrId => {
|
||||
// Count occurrences of this cnr_id across all workflows
|
||||
const currentCount = cnrIdCounts.get(cnrId) || 0;
|
||||
cnrIdCounts.set(cnrId, currentCount + 1);
|
||||
|
||||
// Track workflow details
|
||||
if (!cnrIdToWorkflows.has(cnrId)) {
|
||||
cnrIdToWorkflows.set(cnrId, []);
|
||||
}
|
||||
cnrIdToWorkflows.get(cnrId).push({
|
||||
filename: workflowFilename,
|
||||
nodeCount: workflowCnrIdCounts.get(cnrId) || 0
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Map cnr_id to installed packages with workflow details
|
||||
cnrIdCounts.forEach((count, cnrId) => {
|
||||
const workflowDetails = cnrIdToWorkflows.get(cnrId) || [];
|
||||
|
||||
const foundPackage = findPackageByCnrId(cnrId, nodePackages, true);
|
||||
if (foundPackage) {
|
||||
usageMap.set(foundPackage.key, count);
|
||||
workflowDetailsMap.set(foundPackage.key, workflowDetails);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
usageMap: usageMap,
|
||||
workflowDetailsMap: workflowDetailsMap
|
||||
};
|
||||
}
|
||||
|
||||
// Size formatting utilities - consolidated from model-manager.js and node-usage-analyzer.js
|
||||
export function formatSize(v) {
|
||||
const base = 1000;
|
||||
const units = ['', 'K', 'M', 'G', 'T', 'P'];
|
||||
const space = '';
|
||||
const postfix = 'B';
|
||||
if (v <= 0) {
|
||||
return `0${space}${postfix}`;
|
||||
}
|
||||
for (let i = 0, l = units.length; i < l; i++) {
|
||||
const min = Math.pow(base, i);
|
||||
const max = Math.pow(base, i + 1);
|
||||
if (v > min && v <= max) {
|
||||
const unit = units[i];
|
||||
if (unit) {
|
||||
const n = v / min;
|
||||
const nl = n.toString().split('.')[0].length;
|
||||
const fl = Math.max(3 - nl, 1);
|
||||
v = n.toFixed(fl);
|
||||
}
|
||||
v = v + space + unit + postfix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// for size sort
|
||||
export function sizeToBytes(v) {
|
||||
if (typeof v === "number") {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === "string") {
|
||||
const n = parseFloat(v);
|
||||
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
|
||||
if (unit === "K") {
|
||||
return n * 1000;
|
||||
}
|
||||
if (unit === "M") {
|
||||
return n * 1000 * 1000;
|
||||
}
|
||||
if (unit === "G") {
|
||||
return n * 1000 * 1000 * 1000;
|
||||
}
|
||||
if (unit === "T") {
|
||||
return n * 1000 * 1000 * 1000 * 1000;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Flyover component - consolidated from custom-nodes-manager.js and node-usage-analyzer.js
|
||||
export function createFlyover(container, options = {}) {
|
||||
const {
|
||||
enableHover = false,
|
||||
hoverHandler = null,
|
||||
context = null
|
||||
} = options;
|
||||
|
||||
const $flyover = document.createElement("div");
|
||||
$flyover.className = "cn-flyover";
|
||||
$flyover.innerHTML = `<div class="cn-flyover-header">
|
||||
<div class="cn-flyover-close">${icons.arrowRight}</div>
|
||||
<div class="cn-flyover-title"></div>
|
||||
<div class="cn-flyover-close">${icons.close}</div>
|
||||
</div>
|
||||
<div class="cn-flyover-body"></div>`
|
||||
container.appendChild($flyover);
|
||||
|
||||
const $flyoverTitle = $flyover.querySelector(".cn-flyover-title");
|
||||
const $flyoverBody = $flyover.querySelector(".cn-flyover-body");
|
||||
|
||||
let width = '50%';
|
||||
let visible = false;
|
||||
|
||||
let timeHide;
|
||||
const closeHandler = (e) => {
|
||||
if ($flyover === e.target || $flyover.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeHide);
|
||||
timeHide = setTimeout(() => {
|
||||
flyover.hide();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
const displayHandler = () => {
|
||||
if (visible) {
|
||||
$flyover.classList.remove("cn-slide-in-right");
|
||||
} else {
|
||||
$flyover.classList.remove("cn-slide-out-right");
|
||||
$flyover.style.width = '0px';
|
||||
$flyover.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
const flyover = {
|
||||
show: (titleHtml, bodyHtml) => {
|
||||
clearTimeout(timeHide);
|
||||
if (context && context.element) {
|
||||
context.element.removeEventListener("click", closeHandler);
|
||||
}
|
||||
$flyoverTitle.innerHTML = titleHtml;
|
||||
$flyoverBody.innerHTML = bodyHtml;
|
||||
$flyover.style.display = "block";
|
||||
$flyover.style.width = width;
|
||||
if(!visible) {
|
||||
$flyover.classList.add("cn-slide-in-right");
|
||||
}
|
||||
visible = true;
|
||||
setTimeout(() => {
|
||||
if (context && context.element) {
|
||||
context.element.addEventListener("click", closeHandler);
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
hide: (now) => {
|
||||
visible = false;
|
||||
if (context && context.element) {
|
||||
context.element.removeEventListener("click", closeHandler);
|
||||
}
|
||||
if(now) {
|
||||
displayHandler();
|
||||
return;
|
||||
}
|
||||
$flyover.classList.add("cn-slide-out-right");
|
||||
}
|
||||
}
|
||||
|
||||
$flyover.addEventListener("animationend", (e) => {
|
||||
displayHandler();
|
||||
});
|
||||
|
||||
// Add hover handlers if enabled
|
||||
if (enableHover && hoverHandler) {
|
||||
$flyover.addEventListener("mouseenter", hoverHandler, true);
|
||||
$flyover.addEventListener("mouseleave", hoverHandler, true);
|
||||
}
|
||||
|
||||
$flyover.addEventListener("click", (e) => {
|
||||
if(e.target.classList.contains("cn-flyover-close")) {
|
||||
flyover.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward other click events to the provided handler or context
|
||||
if (context && context.handleFlyoverClick) {
|
||||
context.handleFlyoverClick(e);
|
||||
}
|
||||
});
|
||||
|
||||
return flyover;
|
||||
}
|
||||
|
||||
// Message methods accept HTML; callers escape raw data and preserve formatted content.
|
||||
export function createUIStateManager(element, selectors) {
|
||||
return {
|
||||
showSelection: (msg) => {
|
||||
const el = element.querySelector(selectors.selection);
|
||||
if (el) el.innerHTML = msg;
|
||||
},
|
||||
|
||||
showError: (err) => {
|
||||
const el = element.querySelector(selectors.message);
|
||||
if (el) {
|
||||
const msg = err ? `<font color="red">${err}</font>` : "";
|
||||
el.innerHTML = msg;
|
||||
}
|
||||
},
|
||||
|
||||
showMessage: (msg, color) => {
|
||||
const el = element.querySelector(selectors.message);
|
||||
if (el) {
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
el.innerHTML = msg;
|
||||
}
|
||||
},
|
||||
|
||||
showStatus: (msg, color) => {
|
||||
const el = element.querySelector(selectors.status);
|
||||
if (el) {
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
el.innerHTML = msg;
|
||||
}
|
||||
},
|
||||
|
||||
showLoading: (grid) => {
|
||||
if (grid) {
|
||||
grid.showLoading();
|
||||
grid.showMask({
|
||||
opacity: 0.05
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
hideLoading: (grid) => {
|
||||
if (grid) {
|
||||
grid.hideLoading();
|
||||
grid.hideMask();
|
||||
}
|
||||
},
|
||||
|
||||
showRefresh: () => {
|
||||
const el = element.querySelector(selectors.refresh);
|
||||
if (el) el.style.display = "block";
|
||||
},
|
||||
|
||||
showStop: () => {
|
||||
const el = element.querySelector(selectors.stop);
|
||||
if (el) el.style.display = "block";
|
||||
},
|
||||
|
||||
hideStop: () => {
|
||||
const el = element.querySelector(selectors.stop);
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,7 +678,7 @@ export class ComponentBuilderDialog extends ComfyDialog {
|
||||
|
||||
let orig_handleFile = app.handleFile;
|
||||
|
||||
async function handleFile(file, ...args) {
|
||||
async function handleFile(file) {
|
||||
if (file.name?.endsWith(".json") || file.name?.endsWith(".pack")) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
@@ -694,7 +694,7 @@ async function handleFile(file, ...args) {
|
||||
await handle_import_components(jsonContent);
|
||||
}
|
||||
else {
|
||||
orig_handleFile.call(app, file, ...args);
|
||||
orig_handleFile.call(app, file);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
@@ -702,7 +702,7 @@ async function handleFile(file, ...args) {
|
||||
return;
|
||||
}
|
||||
|
||||
orig_handleFile.call(app, file, ...args);
|
||||
orig_handleFile.call(app, file);
|
||||
}
|
||||
|
||||
app.handleFile = handleFile;
|
||||
@@ -780,7 +780,7 @@ export function set_component_policy(v) {
|
||||
|
||||
let graphToPrompt = app.graphToPrompt;
|
||||
app.graphToPrompt = async function () {
|
||||
let p = await graphToPrompt.apply(app, arguments);
|
||||
let p = await graphToPrompt.call(app);
|
||||
try {
|
||||
let groupNodes = p.workflow.extra?.groupNodes;
|
||||
if(groupNodes) {
|
||||
|
||||
@@ -1,729 +0,0 @@
|
||||
.cn-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80vw;
|
||||
height: 75vh;
|
||||
min-height: 30em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
text-underline-offset: 3px;
|
||||
outline: none;
|
||||
margin: calc(var(--spacing)*2);
|
||||
}
|
||||
|
||||
.cn-manager .cn-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cn-manager button {
|
||||
width: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-color: var(--border-color);
|
||||
margin: 0;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.cn-manager button:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cn-manager button:disabled,
|
||||
.cn-manager input:disabled,
|
||||
.cn-manager select:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.cn-manager button:disabled {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-restart {
|
||||
display: none;
|
||||
background-color: #500000 !important;
|
||||
border-color: #88181b !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-restart:hover {
|
||||
background-color: #88181b !important;
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-stop {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-manager-back {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
margin-right: 5px;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.cn-icon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cn-icon svg {
|
||||
display: block;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cn-manager-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cn-manager-header label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cn-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
|
||||
cursor: pointer;
|
||||
padding: 0.5em 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cn-manager-filter:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cn-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background: var(--comfy-input-bg);
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
|
||||
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
outline-color: transparent;
|
||||
}
|
||||
|
||||
.cn-manager-status {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.cn-manager-grid {
|
||||
flex: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cn-manager-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cn-manager-message {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cn-manager-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cn-manager-grid .tg-turbogrid {
|
||||
font-family: var(--grid-font);
|
||||
font-size: 15px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.cn-manager-grid .tg-turbogrid .tg-highlight::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #80bdff11;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-name a {
|
||||
color: skyblue;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-desc a {
|
||||
color: #5555FF;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cn-manager-grid .tg-cell a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-version {
|
||||
line-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-nodes {
|
||||
line-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-nodes:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-manager-grid .cn-pack-conflicts {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cn-popover {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
color: #1e1e1e;
|
||||
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cn-flyover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
animation-duration: 0.2s;
|
||||
animation-fill-mode: both;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cn-flyover::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
content: "";
|
||||
z-index: 10;
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
left: -10px;
|
||||
background-image: linear-gradient(to left, rgb(0 0 0 / 20%), rgb(0 0 0 / 0%));
|
||||
}
|
||||
|
||||
.cn-flyover-header {
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cn-flyover-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-flyover-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cn-flyover-close svg {
|
||||
display: block;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.cn-flyover-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
gap: 10px;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cn-flyover-body {
|
||||
height: calc(100% - 45px);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
}
|
||||
|
||||
@keyframes cn-slide-in-right {
|
||||
from {
|
||||
visibility: visible;
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.cn-slide-in-right {
|
||||
animation-name: cn-slide-in-right;
|
||||
}
|
||||
|
||||
@keyframes cn-slide-out-right {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
visibility: hidden;
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.cn-slide-out-right {
|
||||
animation-name: cn-slide-out-right;
|
||||
}
|
||||
|
||||
.cn-nodes-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cn-nodes-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cn-nodes-row:nth-child(odd) {
|
||||
background-color: rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.cn-nodes-row:hover {
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.cn-nodes-sn {
|
||||
text-align: right;
|
||||
min-width: 35px;
|
||||
color: var(--drag-text);
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.cn-nodes-name {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.cn-nodes-name::after {
|
||||
content: attr(action);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
left: 100%;
|
||||
transform: translate(5px, -50%);
|
||||
font-size: 12px;
|
||||
color: var(--drag-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 3px 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cn-nodes-name.action::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cn-nodes-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-nodes-conflict .cn-nodes-name,
|
||||
.cn-nodes-conflict .cn-icon {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cn-conflicts-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.cn-conflicts-list b {
|
||||
font-weight: normal;
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.cn-nodes-pack {
|
||||
cursor: pointer;
|
||||
color: skyblue;
|
||||
}
|
||||
|
||||
.cn-nodes-pack:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-pack-badge {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 3px 8px;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.cn-preview {
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
min-height: 120px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
padding: 12px;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.cn-preview-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--comfy-input-bg);
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: grey;
|
||||
position: relative;
|
||||
filter: drop-shadow(1px 2px 3px rgb(0 0 0 / 30%));
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-optional::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 50%;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-left: 1px solid var(--comfy-input-bg);
|
||||
border-right: 1px solid var(--comfy-input-bg);
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
left: 2px;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-top: 1px solid var(--comfy-input-bg);
|
||||
border-bottom: 1px solid var(--comfy-input-bg);
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cn-preview-name {
|
||||
flex: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cn-preview-io {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-column > div {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cn-preview-input {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cn-preview-output {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cn-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-switch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
text-wrap: nowrap;
|
||||
padding: 2px 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cn-preview-switch::before,
|
||||
.cn-preview-switch::after {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
color: var(--fg-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cn-preview-switch::before {
|
||||
content: "◀";
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.cn-preview-switch::after {
|
||||
content: "▶";
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.cn-preview-value {
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.cn-preview-string {
|
||||
min-height: 30px;
|
||||
max-height: 300px;
|
||||
background: var(--bg-color);
|
||||
color: var(--descrip-text);
|
||||
border-radius: 3px;
|
||||
padding: 3px 5px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.cn-preview-description {
|
||||
margin: 0px 10px 10px 10px;
|
||||
padding: 6px;
|
||||
background: var(--border-color);
|
||||
color: var(--descrip-text);
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cn-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cn-tag-list > div {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cn-install-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-install-buttons button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.cn-selected-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-enable {
|
||||
background-color: #333399;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-disable {
|
||||
background-color: #442277;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-update {
|
||||
background-color: #1155AA;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-try-update {
|
||||
background-color: Gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-try-fix {
|
||||
background-color: #6495ED;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-import-failed {
|
||||
background-color: #AA1111;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-install {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-try-install {
|
||||
background-color: Gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-uninstall {
|
||||
background-color: #993333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-reinstall {
|
||||
background-color: #993333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cn-manager .cn-btn-switch {
|
||||
background-color: #448833;
|
||||
color: white;
|
||||
|
||||
}
|
||||
|
||||
@keyframes cn-btn-loading-bg {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
100% {
|
||||
left: -105px;
|
||||
}
|
||||
}
|
||||
|
||||
.cn-manager button.cn-btn-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgb(0 119 207 / 80%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cn-manager button.cn-btn-loading::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
width: 500px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(0 119 207 / 30%),
|
||||
rgb(0 119 207 / 30%) 10px,
|
||||
transparent 10px,
|
||||
transparent 15px
|
||||
);
|
||||
animation: cn-btn-loading-bg 2s linear infinite;
|
||||
}
|
||||
|
||||
.cn-manager-light .cn-pack-name a {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.cn-manager-light .cm-warn-note {
|
||||
background-color: #ccc !important;
|
||||
}
|
||||
|
||||
.cn-manager-light .cn-btn-install {
|
||||
background-color: #333;
|
||||
}
|
||||
+589
-738
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
.cmm-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80vw;
|
||||
height: 75vh;
|
||||
min-height: 30em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
margin: calc(var(--spacing)*2);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cmm-manager button {
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-color: var(--border-color);
|
||||
margin: 0;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.cmm-manager button:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cmm-manager button:disabled,
|
||||
.cmm-manager input:disabled,
|
||||
.cmm-manager select:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.cmm-manager button:disabled {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-manager-refresh {
|
||||
display: none;
|
||||
background-color: #000080 !important;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-manager-stop {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-header label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-type,
|
||||
.cmm-manager-base,
|
||||
.cmm-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
|
||||
cursor: pointer;
|
||||
padding: 0.5em 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager-type:hover,
|
||||
.cmm-manager-base:hover,
|
||||
.cmm-manager-filter:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.cmm-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background: var(--comfy-input-bg);
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
|
||||
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
outline-color: transparent;
|
||||
}
|
||||
|
||||
.cmm-manager-status {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.cmm-manager-grid {
|
||||
flex: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmm-manager-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .tg-turbogrid {
|
||||
font-family: var(--grid-font);
|
||||
font-size: 15px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.cmm-manager-grid .cmm-node-name a {
|
||||
color: skyblue;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .cmm-node-desc a {
|
||||
color: #5555FF;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .tg-cell a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cmm-icon-passed {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: calc(50% - 10px);
|
||||
top: calc(50% - 10px);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-enable {
|
||||
background-color: blue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-disable {
|
||||
background-color: MediumSlateBlue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-install {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-btn-install {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.cmm-btn-download {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
left: calc(50% - 10px);
|
||||
top: calc(50% - 10px);
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cmm-btn-download:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-btn-download {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@keyframes cmm-btn-loading-bg {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
100% {
|
||||
left: -105px;
|
||||
}
|
||||
}
|
||||
|
||||
.cmm-manager button.cmm-btn-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgb(0 119 207 / 80%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager button.cmm-btn-loading::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
width: 500px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(0 119 207 / 30%),
|
||||
rgb(0 119 207 / 30%) 10px,
|
||||
transparent 10px,
|
||||
transparent 15px
|
||||
);
|
||||
animation: cmm-btn-loading-bg 2s linear infinite;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-node-name a {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cm-warn-note {
|
||||
background-color: #ccc !important;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-btn-install {
|
||||
background-color: #333;
|
||||
}
|
||||
+346
-107
@@ -1,40 +1,266 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
import {
|
||||
manager_instance, rebootAPI,
|
||||
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes,
|
||||
sanitizeHTML, safeHref
|
||||
storeColumnWidth, restoreColumnWidth
|
||||
} from "./common.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
import ManagerGrid from "./manager-grid.js";
|
||||
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
|
||||
|
||||
loadCss("./model-manager.css");
|
||||
import TG from "./turbogrid.esm.js";
|
||||
|
||||
const gridId = "model";
|
||||
|
||||
// Escape raw cells while preserving empty values.
|
||||
const escapeCell = (value) => (value === null || value === undefined) ? value : sanitizeHTML(String(value));
|
||||
const pageCss = `
|
||||
.cmm-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
}
|
||||
|
||||
// Escape option markup while comparing the original values.
|
||||
const renderOptions = (list, current) => list.map(item => {
|
||||
const selected = item.value === current ? " selected" : "";
|
||||
return `<option value="${sanitizeHTML(String(item.value))}"${selected}>${sanitizeHTML(String(item.label))}</option>`;
|
||||
}).join("");
|
||||
.cmm-manager .cmm-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cmm-manager button {
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.cmm-manager button:disabled,
|
||||
.cmm-manager input:disabled,
|
||||
.cmm-manager select:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.cmm-manager button:disabled {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-manager-refresh {
|
||||
display: none;
|
||||
background-color: #000080;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-manager-stop {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cmm-manager-header label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-type,
|
||||
.cmm-manager-base,
|
||||
.cmm-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.cmm-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,${encodeURIComponent(icons.search.replace("currentColor", "#888"))}");
|
||||
}
|
||||
|
||||
.cmm-manager-status {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.cmm-manager-grid {
|
||||
flex: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmm-manager-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-message {
|
||||
|
||||
}
|
||||
|
||||
.cmm-manager-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .tg-turbogrid {
|
||||
font-family: var(--grid-font);
|
||||
font-size: 15px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.cmm-manager-grid .cmm-node-name a {
|
||||
color: skyblue;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .cmm-node-desc a {
|
||||
color: #5555FF;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cmm-manager-grid .tg-cell a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cmm-icon-passed {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: calc(50% - 10px);
|
||||
top: calc(50% - 10px);
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-enable {
|
||||
background-color: blue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-disable {
|
||||
background-color: MediumSlateBlue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-manager .cmm-btn-install {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cmm-btn-download {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
position: absolute;
|
||||
left: calc(50% - 10px);
|
||||
top: calc(50% - 10px);
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.cmm-btn-download:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-btn-download {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
@keyframes cmm-btn-loading-bg {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
100% {
|
||||
left: -105px;
|
||||
}
|
||||
}
|
||||
|
||||
.cmm-manager button.cmm-btn-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgb(0 119 207 / 80%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.cmm-manager button.cmm-btn-loading::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
width: 500px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(0 119 207 / 30%),
|
||||
rgb(0 119 207 / 30%) 10px,
|
||||
transparent 10px,
|
||||
transparent 15px
|
||||
);
|
||||
animation: cmm-btn-loading-bg 2s linear infinite;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-node-name a {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cm-warn-note {
|
||||
background-color: #ccc !important;
|
||||
}
|
||||
|
||||
.cmm-manager-light .cmm-btn-install {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
const pageHtml = `
|
||||
<div class="cmm-manager cmm-manager-dark">
|
||||
<div class="cmm-manager-grid"></div>
|
||||
<div class="cmm-manager-selection"></div>
|
||||
<div class="cmm-manager-message"></div>
|
||||
<div class="cmm-manager-footer">
|
||||
<button class="cmm-manager-refresh p-button p-component">Refresh</button>
|
||||
<button class="cmm-manager-stop p-button p-component">Stop</button>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
<div class="cmm-manager-header">
|
||||
<label>Filter
|
||||
<select class="cmm-manager-filter"></select>
|
||||
</label>
|
||||
<label>Type
|
||||
<select class="cmm-manager-type"></select>
|
||||
</label>
|
||||
<label>Base
|
||||
<select class="cmm-manager-base"></select>
|
||||
</label>
|
||||
<input class="cmm-manager-keywords" type="search" placeholder="Search" />
|
||||
<div class="cmm-manager-status"></div>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
<div class="cmm-manager-grid"></div>
|
||||
<div class="cmm-manager-selection"></div>
|
||||
<div class="cmm-manager-message"></div>
|
||||
<div class="cmm-manager-footer">
|
||||
<button class="cmm-manager-back">
|
||||
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<button class="cmm-manager-refresh">Refresh</button>
|
||||
<button class="cmm-manager-stop">Stop</button>
|
||||
<div class="cmm-flex-auto"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -57,23 +283,19 @@ export class ModelManager {
|
||||
}
|
||||
|
||||
init() {
|
||||
const header = $el("div.cmm-manager-header", {}, [
|
||||
createSettingsCombo("Filter", $el("select.cmm-manager-filter")),
|
||||
createSettingsCombo("Type", $el("select.cmm-manager-type")),
|
||||
createSettingsCombo("Base", $el("select.cmm-manager-base")),
|
||||
$el("input.cmm-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }),
|
||||
$el("div.cmm-manager-status"),
|
||||
$el("div.cmm-flex-auto")
|
||||
]);
|
||||
|
||||
const frame = buildGuiFrameCustomHeader(
|
||||
'cmm-manager-dialog', // dialog id
|
||||
header, // custom header element
|
||||
pageHtml, // dialog content element
|
||||
this
|
||||
); // send this so we can attach close functions
|
||||
if (!document.querySelector(`style[context="${this.id}"]`)) {
|
||||
const $style = document.createElement("style");
|
||||
$style.setAttribute("context", this.id);
|
||||
$style.innerHTML = pageCss;
|
||||
document.head.appendChild($style);
|
||||
}
|
||||
|
||||
this.element = frame;
|
||||
this.element = $el("div", {
|
||||
parent: document.body,
|
||||
className: "comfy-modal cmm-manager"
|
||||
});
|
||||
this.element.innerHTML = pageHtml;
|
||||
this.initFilter();
|
||||
this.bindEvents();
|
||||
this.initGrid();
|
||||
@@ -86,13 +308,10 @@ export class ModelManager {
|
||||
value: ""
|
||||
}, {
|
||||
label: "Installed",
|
||||
value: "installed"
|
||||
value: "True"
|
||||
}, {
|
||||
label: "Not Installed",
|
||||
value: "not_installed"
|
||||
}, {
|
||||
label: "In Workflow",
|
||||
value: "in_workflow"
|
||||
value: "False"
|
||||
}];
|
||||
|
||||
this.typeList = [{
|
||||
@@ -111,13 +330,22 @@ export class ModelManager {
|
||||
|
||||
updateFilter() {
|
||||
const $filter = this.element.querySelector(".cmm-manager-filter");
|
||||
$filter.innerHTML = renderOptions(this.filterList, this.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 $type = this.element.querySelector(".cmm-manager-type");
|
||||
$type.innerHTML = renderOptions(this.typeList, this.type);
|
||||
$type.innerHTML = this.typeList.map(item => {
|
||||
const selected = item.value === this.type ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
const $base = this.element.querySelector(".cmm-manager-base");
|
||||
$base.innerHTML = renderOptions(this.baseList, this.base);
|
||||
$base.innerHTML = this.baseList.map(item => {
|
||||
const selected = item.value === this.base ? " selected" : "";
|
||||
return `<option value="${item.value}"${selected}>${item.label}</option>`
|
||||
}).join("");
|
||||
|
||||
}
|
||||
|
||||
@@ -171,7 +399,7 @@ export class ModelManager {
|
||||
|
||||
".cmm-manager-stop": {
|
||||
click: () => {
|
||||
api.fetchApi('/manager/queue/reset', { method: 'POST' });
|
||||
api.fetchApi('/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
},
|
||||
@@ -200,7 +428,7 @@ export class ModelManager {
|
||||
|
||||
initGrid() {
|
||||
const container = this.element.querySelector(".cmm-manager-grid");
|
||||
const grid = new ManagerGrid(container);
|
||||
const grid = new TG.Grid(container);
|
||||
this.grid = grid;
|
||||
|
||||
grid.bind('onUpdated', (e, d) => {
|
||||
@@ -228,9 +456,6 @@ export class ModelManager {
|
||||
});
|
||||
|
||||
grid.setOption({
|
||||
highlightKeywords: {
|
||||
textGenerator: (row, column) => ['name', 'description'].includes(column) ? row[column] : escapeCell(row[column])
|
||||
},
|
||||
theme: 'dark',
|
||||
|
||||
selectVisible: true,
|
||||
@@ -256,31 +481,12 @@ export class ModelManager {
|
||||
rowFilter: (rowItem) => {
|
||||
|
||||
const searchableColumns = ["name", "type", "base", "description", "filename", "save_path"];
|
||||
const models_extensions = ['.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft'];
|
||||
|
||||
let shouldShown = grid.highlightKeywordsFilter(rowItem, searchableColumns, this.keywords);
|
||||
|
||||
if (shouldShown) {
|
||||
if(this.filter) {
|
||||
if (this.filter == "in_workflow") {
|
||||
rowItem.in_workflow = null;
|
||||
if (Array.isArray(app.graph._nodes)) {
|
||||
app.graph._nodes.forEach((item, i) => {
|
||||
if (Array.isArray(item.widgets_values)) {
|
||||
item.widgets_values.forEach((_item, i) => {
|
||||
if (rowItem.in_workflow === null && _item !== null && models_extensions.includes("." + _item.toString().split('.').pop())) {
|
||||
let filename = _item.match(/([^\/]+)(?=\.\w+$)/)[0];
|
||||
if (grid.highlightKeywordsFilter(rowItem, searchableColumns, filename)) {
|
||||
rowItem.in_workflow = "True";
|
||||
grid.highlightKeywordsFilter(rowItem, searchableColumns, "");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return ((this.filter == "installed" && rowItem.installed == "True") || (this.filter == "not_installed" && rowItem.installed == "False") || (this.filter == "in_workflow" && rowItem.in_workflow == "True"));
|
||||
if(this.filter && rowItem.installed !== this.filter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(this.type && rowItem.type !== this.type) {
|
||||
@@ -329,8 +535,7 @@ export class ModelManager {
|
||||
maxWidth: 500,
|
||||
classMap: 'cmm-node-name',
|
||||
formatter: function(name, rowItem, columnItem, cellNode) {
|
||||
// Names are server-escaped; raw references need URL and attribute handling.
|
||||
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
|
||||
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'installed',
|
||||
@@ -347,7 +552,7 @@ export class ModelManager {
|
||||
if (installed === "True") {
|
||||
return `<div class="cmm-icon-passed">${icons.passed}</div>`;
|
||||
}
|
||||
return `<button class="cmm-btn-install p-button p-component" mode="install">Install</button>`;
|
||||
return `<button class="cmm-btn-install" mode="install">Install</button>`;
|
||||
}
|
||||
}, {
|
||||
id: 'url',
|
||||
@@ -356,7 +561,7 @@ export class ModelManager {
|
||||
sortable: false,
|
||||
align: 'center',
|
||||
formatter: (url, rowItem, columnItem) => {
|
||||
return `<a class="cmm-btn-download" title="Download file" href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">${icons.download}</a>`;
|
||||
return `<a class="cmm-btn-download" title="Download file" href="${url}" target="_blank">${icons.download}</a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'size',
|
||||
@@ -364,21 +569,18 @@ export class ModelManager {
|
||||
width: 100,
|
||||
formatter: (size) => {
|
||||
if (typeof size === "number") {
|
||||
return formatSize(size);
|
||||
return this.formatSize(size);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
}, {
|
||||
id: 'type',
|
||||
name: 'Type',
|
||||
width: 100,
|
||||
formatter: escapeCell
|
||||
width: 100
|
||||
}, {
|
||||
id: 'base',
|
||||
name: 'Base',
|
||||
formatter: escapeCell
|
||||
name: 'Base'
|
||||
}, {
|
||||
// Descriptions are formatted HTML from the server.
|
||||
id: 'description',
|
||||
name: 'Description',
|
||||
width: 400,
|
||||
@@ -387,13 +589,11 @@ export class ModelManager {
|
||||
}, {
|
||||
id: "save_path",
|
||||
name: 'Save Path',
|
||||
width: 200,
|
||||
formatter: escapeCell
|
||||
width: 200
|
||||
}, {
|
||||
id: 'filename',
|
||||
name: 'Filename',
|
||||
width: 200,
|
||||
formatter: escapeCell
|
||||
width: 200
|
||||
}];
|
||||
|
||||
restoreColumnWidth(gridId, columns);
|
||||
@@ -425,7 +625,7 @@ export class ModelManager {
|
||||
}
|
||||
|
||||
this.selectedModels = selectedList;
|
||||
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install p-button p-component" mode="install">Install</button>`);
|
||||
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install" mode="install">Install</button>`);
|
||||
}
|
||||
|
||||
focusInstall(item) {
|
||||
@@ -454,7 +654,7 @@ export class ModelManager {
|
||||
let needRefresh = false;
|
||||
let errorMsg = "";
|
||||
|
||||
await api.fetchApi('/manager/queue/reset', { method: 'POST' });
|
||||
await api.fetchApi('/manager/queue/reset');
|
||||
|
||||
let target_items = [];
|
||||
|
||||
@@ -468,7 +668,6 @@ export class ModelManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Model names are already escaped by the server.
|
||||
this.showStatus(`Install ${item.name} ...`);
|
||||
|
||||
const data = item.originalData;
|
||||
@@ -483,18 +682,9 @@ export class ModelManager {
|
||||
errorMsg = `'${item.name}': `;
|
||||
|
||||
if(res.status == 403) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
if(data.error === 'comfyui_outdated') {
|
||||
errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\n`;
|
||||
} else {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
} catch {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
}
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
} else {
|
||||
errorMsg += sanitizeHTML(await res.text()) + '\n';
|
||||
errorMsg += await res.text() + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -514,7 +704,7 @@ export class ModelManager {
|
||||
}
|
||||
}
|
||||
else {
|
||||
await api.fetchApi('/manager/queue/start', { method: 'POST' });
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
this.showStop();
|
||||
showTerminal();
|
||||
}
|
||||
@@ -563,9 +753,8 @@ export class ModelManager {
|
||||
for(let hash in result){
|
||||
let v = result[hash];
|
||||
|
||||
// Escape raw task errors for both HTML message destinations.
|
||||
if(v != 'success')
|
||||
errorMsg += sanitizeHTML(String(v)) + '\n';
|
||||
errorMsg += v + '\n';
|
||||
}
|
||||
|
||||
for(let k in self.install_context.targets) {
|
||||
@@ -594,7 +783,7 @@ export class ModelManager {
|
||||
models.forEach((item, i) => {
|
||||
const { type, base, name, reference, installed } = item;
|
||||
item.originalData = JSON.parse(JSON.stringify(item));
|
||||
item.size = sizeToBytes(item.size);
|
||||
item.size = this.sizeToBytes(item.size);
|
||||
item.hash = md5(name + reference);
|
||||
item.id = i + 1;
|
||||
|
||||
@@ -671,6 +860,7 @@ export class ModelManager {
|
||||
const { models } = res.data;
|
||||
|
||||
this.modelList = this.getModelList(models);
|
||||
// console.log("models", this.modelList);
|
||||
|
||||
this.updateFilter();
|
||||
|
||||
@@ -682,7 +872,56 @@ export class ModelManager {
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// Messages accept HTML; see createUIStateManager in common.js.
|
||||
formatSize(v) {
|
||||
const base = 1000;
|
||||
const units = ['', 'K', 'M', 'G', 'T', 'P'];
|
||||
const space = '';
|
||||
const postfix = 'B';
|
||||
if (v <= 0) {
|
||||
return `0${space}${postfix}`;
|
||||
}
|
||||
for (let i = 0, l = units.length; i < l; i++) {
|
||||
const min = Math.pow(base, i);
|
||||
const max = Math.pow(base, i + 1);
|
||||
if (v > min && v <= max) {
|
||||
const unit = units[i];
|
||||
if (unit) {
|
||||
const n = v / min;
|
||||
const nl = n.toString().split('.')[0].length;
|
||||
const fl = Math.max(3 - nl, 1);
|
||||
v = n.toFixed(fl);
|
||||
}
|
||||
v = v + space + unit + postfix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// for size sort
|
||||
sizeToBytes(v) {
|
||||
if (typeof v === "number") {
|
||||
return v;
|
||||
}
|
||||
if (typeof v === "string") {
|
||||
const n = parseFloat(v);
|
||||
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
|
||||
if (unit === "K") {
|
||||
return n * 1000;
|
||||
}
|
||||
if (unit === "M") {
|
||||
return n * 1000 * 1000;
|
||||
}
|
||||
if (unit === "G") {
|
||||
return n * 1000 * 1000 * 1000;
|
||||
}
|
||||
if (unit === "T") {
|
||||
return n * 1000 * 1000 * 1000 * 1000;
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
showSelection(msg) {
|
||||
this.element.querySelector(".cmm-manager-selection").innerHTML = msg;
|
||||
}
|
||||
@@ -783,4 +1022,4 @@ export class ModelManager {
|
||||
close() {
|
||||
this.element.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,699 +0,0 @@
|
||||
.nu-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
text-underline-offset: 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nu-manager .nu-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.nu-manager button {
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 8px;
|
||||
border-color: var(--border-color);
|
||||
border-style: solid;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.nu-manager button:disabled,
|
||||
.nu-manager input:disabled,
|
||||
.nu-manager select:disabled {
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.nu-manager button:disabled {
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.nu-manager .nu-manager-restart {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .nu-manager-stop {
|
||||
display: none;
|
||||
background-color: #500000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .nu-manager-back {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
margin-right: 5px;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
.cn-icon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.cn-icon svg {
|
||||
display: block;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nu-manager-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.nu-manager-header label {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nu-manager-filter {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.nu-manager-keywords {
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
padding: 0 5px 0 26px;
|
||||
background-size: 16px;
|
||||
background-position: 5px center;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
|
||||
}
|
||||
|
||||
.nu-manager-status {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.nu-manager-grid {
|
||||
flex: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nu-manager-selection {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nu-manager-message {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nu-manager-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nu-manager-grid .tg-turbogrid {
|
||||
font-family: var(--grid-font);
|
||||
font-size: 15px;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.nu-manager-grid .tg-turbogrid .tg-highlight::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: #80bdff11;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nu-manager-grid .nu-pack-name a {
|
||||
color: skyblue;
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.nu-manager-grid .cn-pack-desc a {
|
||||
color: #5555FF;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nu-manager-grid .tg-cell a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nu-manager-grid .cn-pack-version {
|
||||
line-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.nu-manager-grid .cn-pack-nodes {
|
||||
line-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nu-manager-grid .cn-pack-nodes:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nu-manager-grid .cn-pack-conflicts {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cn-popover {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
color: #1e1e1e;
|
||||
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cn-flyover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
background-color: var(--comfy-menu-bg);
|
||||
animation-duration: 0.2s;
|
||||
animation-fill-mode: both;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cn-flyover::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
content: "";
|
||||
z-index: 10;
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
left: -10px;
|
||||
background-image: linear-gradient(to left, rgb(0 0 0 / 20%), rgb(0 0 0 / 0%));
|
||||
}
|
||||
|
||||
.cn-flyover-header {
|
||||
height: 45px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.cn-flyover-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-flyover-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cn-flyover-close svg {
|
||||
display: block;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.cn-flyover-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
gap: 10px;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.cn-flyover-body {
|
||||
height: calc(100% - 45px);
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
background-color: var(--comfy-menu-secondary-bg);
|
||||
}
|
||||
|
||||
@keyframes cn-slide-in-right {
|
||||
from {
|
||||
visibility: visible;
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.cn-slide-in-right {
|
||||
animation-name: cn-slide-in-right;
|
||||
}
|
||||
|
||||
@keyframes cn-slide-out-right {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
visibility: hidden;
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.cn-slide-out-right {
|
||||
animation-name: cn-slide-out-right;
|
||||
}
|
||||
|
||||
.cn-nodes-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cn-nodes-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cn-nodes-row:nth-child(odd) {
|
||||
background-color: rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
.cn-nodes-row:hover {
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.cn-nodes-sn {
|
||||
text-align: right;
|
||||
min-width: 35px;
|
||||
color: var(--drag-text);
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.cn-nodes-name {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
padding: 8px 5px;
|
||||
}
|
||||
|
||||
.cn-nodes-name::after {
|
||||
content: attr(action);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
left: 100%;
|
||||
transform: translate(5px, -50%);
|
||||
font-size: 12px;
|
||||
color: var(--drag-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 3px 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cn-nodes-name.action::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cn-nodes-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-nodes-conflict .cn-nodes-name,
|
||||
.cn-nodes-conflict .cn-icon {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
.cn-conflicts-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.cn-conflicts-list b {
|
||||
font-weight: normal;
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.cn-nodes-pack {
|
||||
cursor: pointer;
|
||||
color: skyblue;
|
||||
}
|
||||
|
||||
.cn-nodes-pack:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cn-pack-badge {
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 3px 8px;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.cn-preview {
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
min-height: 120px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
padding: 12px;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.cn-preview-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--comfy-input-bg);
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: grey;
|
||||
position: relative;
|
||||
filter: drop-shadow(1px 2px 3px rgb(0 0 0 / 30%));
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-optional::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-radius: 50%;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-left: 1px solid var(--comfy-input-bg);
|
||||
border-right: 1px solid var(--comfy-input-bg);
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
left: 2px;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cn-preview-dot.cn-preview-grid::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
border-top: 1px solid var(--comfy-input-bg);
|
||||
border-bottom: 1px solid var(--comfy-input-bg);
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cn-preview-name {
|
||||
flex: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cn-preview-io {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-column > div {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cn-preview-input {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cn-preview-output {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cn-preview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
||||
.cn-preview-switch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--bg-color);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
text-wrap: nowrap;
|
||||
padding: 2px 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cn-preview-switch::before,
|
||||
.cn-preview-switch::after {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 50%;
|
||||
transform: translate(0, -50%);
|
||||
color: var(--fg-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cn-preview-switch::before {
|
||||
content: "◀";
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
.cn-preview-switch::after {
|
||||
content: "▶";
|
||||
right: 5px;
|
||||
}
|
||||
|
||||
.cn-preview-value {
|
||||
color: var(--descrip-text);
|
||||
}
|
||||
|
||||
.cn-preview-string {
|
||||
min-height: 30px;
|
||||
max-height: 300px;
|
||||
background: var(--bg-color);
|
||||
color: var(--descrip-text);
|
||||
border-radius: 3px;
|
||||
padding: 3px 5px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.cn-preview-description {
|
||||
margin: 0px 10px 10px 10px;
|
||||
padding: 6px;
|
||||
background: var(--border-color);
|
||||
color: var(--descrip-text);
|
||||
border-radius: 5px;
|
||||
font-style: italic;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.cn-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.cn-tag-list > div {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.cn-install-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cn-selected-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-enable {
|
||||
background-color: #333399;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-disable {
|
||||
background-color: #442277;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-update {
|
||||
background-color: #1155AA;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-try-update {
|
||||
background-color: Gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-try-fix {
|
||||
background-color: #6495ED;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-import-failed {
|
||||
background-color: #AA1111;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-install {
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-try-install {
|
||||
background-color: Gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-uninstall {
|
||||
background-color: #993333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-reinstall {
|
||||
background-color: #993333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nu-manager .cn-btn-switch {
|
||||
background-color: #448833;
|
||||
color: white;
|
||||
|
||||
}
|
||||
|
||||
@keyframes nu-btn-loading-bg {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
100% {
|
||||
left: -105px;
|
||||
}
|
||||
}
|
||||
|
||||
.nu-manager button.nu-btn-loading {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgb(0 119 207 / 80%);
|
||||
background-color: var(--comfy-input-bg);
|
||||
}
|
||||
|
||||
.nu-manager button.nu-btn-loading::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
content: "";
|
||||
width: 500px;
|
||||
height: 100%;
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
rgb(0 119 207 / 30%),
|
||||
rgb(0 119 207 / 30%) 10px,
|
||||
transparent 10px,
|
||||
transparent 15px
|
||||
);
|
||||
animation: nu-btn-loading-bg 2s linear infinite;
|
||||
}
|
||||
|
||||
.nu-manager-light .nu-pack-name a {
|
||||
color: blue;
|
||||
}
|
||||
|
||||
.nu-manager-light .cm-warn-note {
|
||||
background-color: #ccc !important;
|
||||
}
|
||||
|
||||
.nu-manager-light .cn-btn-install {
|
||||
background-color: #333;
|
||||
}
|
||||
@@ -1,750 +0,0 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
import {
|
||||
manager_instance,
|
||||
fetchData, md5, show_message, customAlert, infoToast, showTerminal,
|
||||
storeColumnWidth, restoreColumnWidth, loadCss, uninstallNodes,
|
||||
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager,
|
||||
sanitizeHTML, safeHref
|
||||
} from "./common.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
// https://cenfun.github.io/turbogrid/api.html
|
||||
import TG from "./turbogrid.esm.js";
|
||||
|
||||
loadCss("./node-usage-analyzer.css");
|
||||
|
||||
const gridId = "model";
|
||||
|
||||
const pageHtml = `
|
||||
<div class="nu-manager-header">
|
||||
<div class="nu-manager-status"></div>
|
||||
<input type="text" class="nu-manager-keywords" placeholder="Filter keywords..." />
|
||||
<div class="nu-flex-auto"></div>
|
||||
</div>
|
||||
<div class="nu-manager-grid"></div>
|
||||
<div class="nu-manager-selection"></div>
|
||||
<div class="nu-manager-message"></div>
|
||||
<div class="nu-manager-footer">
|
||||
<button class="nu-manager-back">
|
||||
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<button class="nu-manager-refresh">Refresh</button>
|
||||
<button class="nu-manager-stop">Stop</button>
|
||||
<div class="nu-flex-auto"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export class NodeUsageAnalyzer {
|
||||
static instance = null;
|
||||
|
||||
static SortMode = {
|
||||
BY_PACKAGE: 'by_package'
|
||||
};
|
||||
|
||||
constructor(app, manager_dialog) {
|
||||
this.app = app;
|
||||
this.manager_dialog = manager_dialog;
|
||||
this.id = "nu-manager";
|
||||
|
||||
this.filter = '';
|
||||
this.type = '';
|
||||
this.base = '';
|
||||
this.keywords = '';
|
||||
|
||||
this.init();
|
||||
|
||||
// Initialize shared UI state manager
|
||||
this.ui = createUIStateManager(this.element, {
|
||||
selection: ".nu-manager-selection",
|
||||
message: ".nu-manager-message",
|
||||
status: ".nu-manager-status",
|
||||
refresh: ".nu-manager-refresh",
|
||||
stop: ".nu-manager-stop"
|
||||
});
|
||||
|
||||
api.addEventListener("cm-queue-status", this.onQueueStatus);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.element = $el("div", {
|
||||
parent: document.body,
|
||||
className: "comfy-modal nu-manager"
|
||||
});
|
||||
this.element.innerHTML = pageHtml;
|
||||
this.bindEvents();
|
||||
this.initGrid();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
const eventsMap = {
|
||||
".nu-manager-selection": {
|
||||
click: (e) => {
|
||||
const target = e.target;
|
||||
const mode = target.getAttribute("mode");
|
||||
if (mode === "install") {
|
||||
this.installModels(this.selectedModels, target);
|
||||
} else if (mode === "uninstall") {
|
||||
this.uninstallModels(this.selectedModels, target);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
".nu-manager-refresh": {
|
||||
click: () => {
|
||||
app.refreshComboInNodes();
|
||||
}
|
||||
},
|
||||
|
||||
".nu-manager-stop": {
|
||||
click: () => {
|
||||
api.fetchApi('/manager/queue/reset');
|
||||
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
|
||||
}
|
||||
},
|
||||
|
||||
".nu-manager-back": {
|
||||
click: (e) => {
|
||||
this.close()
|
||||
manager_instance.show();
|
||||
}
|
||||
}
|
||||
};
|
||||
Object.keys(eventsMap).forEach(selector => {
|
||||
const target = this.element.querySelector(selector);
|
||||
if (target) {
|
||||
const events = eventsMap[selector];
|
||||
if (events) {
|
||||
Object.keys(events).forEach(type => {
|
||||
target.addEventListener(type, events[type]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
initGrid() {
|
||||
const container = this.element.querySelector(".nu-manager-grid");
|
||||
const grid = new TG.Grid(container);
|
||||
this.grid = grid;
|
||||
|
||||
this.flyover = createFlyover(container, { context: this });
|
||||
|
||||
grid.bind('onUpdated', (e, d) => {
|
||||
this.ui.showStatus(`${grid.viewRows.length.toLocaleString()} installed packages`);
|
||||
|
||||
});
|
||||
|
||||
grid.bind('onSelectChanged', (e, changes) => {
|
||||
this.renderSelected();
|
||||
});
|
||||
|
||||
grid.bind("onColumnWidthChanged", (e, columnItem) => {
|
||||
storeColumnWidth(gridId, columnItem)
|
||||
});
|
||||
|
||||
grid.bind('onClick', (e, d) => {
|
||||
const { rowItem } = d;
|
||||
const target = d.e.target;
|
||||
const mode = target.getAttribute("mode");
|
||||
|
||||
if (mode === "install") {
|
||||
this.installModels([rowItem], target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "uninstall") {
|
||||
this.uninstallModels([rowItem], target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle click on usage count
|
||||
if (d.columnItem.id === "used_in_count" && rowItem.used_in_count > 0) {
|
||||
this.showUsageDetails(rowItem);
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
grid.setOption({
|
||||
theme: 'dark',
|
||||
|
||||
selectVisible: true,
|
||||
selectMultiple: true,
|
||||
selectAllVisible: true,
|
||||
|
||||
textSelectable: true,
|
||||
scrollbarRound: true,
|
||||
|
||||
frozenColumn: 1,
|
||||
rowNotFound: "No Results",
|
||||
|
||||
rowHeight: 40,
|
||||
bindWindowResize: true,
|
||||
bindContainerResize: true,
|
||||
|
||||
cellResizeObserver: (rowItem, columnItem) => {
|
||||
const autoHeightColumns = ['name', 'description'];
|
||||
return autoHeightColumns.includes(columnItem.id)
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
renderGrid() {
|
||||
|
||||
// update theme
|
||||
const colorPalette = this.app.ui.settings.settingsValues['Comfy.ColorPalette'];
|
||||
Array.from(this.element.classList).forEach(cn => {
|
||||
if (cn.startsWith("nu-manager-")) {
|
||||
this.element.classList.remove(cn);
|
||||
}
|
||||
});
|
||||
this.element.classList.add(`nu-manager-${colorPalette}`);
|
||||
|
||||
const options = {
|
||||
theme: colorPalette === "light" ? "" : "dark"
|
||||
};
|
||||
|
||||
const rows = this.modelList || [];
|
||||
|
||||
const columns = [{
|
||||
id: 'title',
|
||||
name: 'Title',
|
||||
width: 200,
|
||||
minWidth: 100,
|
||||
maxWidth: 500,
|
||||
classMap: 'nu-pack-name',
|
||||
formatter: function (name, rowItem, columnItem, cellNode) {
|
||||
// Names are escaped during loadData; references need URL and attribute handling.
|
||||
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
|
||||
}
|
||||
}, {
|
||||
id: 'used_in_count',
|
||||
name: 'Used in',
|
||||
width: 100,
|
||||
formatter: function (usedCount, rowItem, columnItem) {
|
||||
if (!usedCount || usedCount === 0) {
|
||||
return '0';
|
||||
}
|
||||
const plural = usedCount > 1 ? 's' : '';
|
||||
return `<div class="cn-pack-nodes" style="cursor: pointer;">${usedCount} workflow${plural}</div>`;
|
||||
}
|
||||
}, {
|
||||
id: 'action',
|
||||
name: 'Action',
|
||||
width: 160,
|
||||
minWidth: 140,
|
||||
maxWidth: 200,
|
||||
sortable: false,
|
||||
align: 'center',
|
||||
formatter: function (action, rowItem, columnItem) {
|
||||
// Only show uninstall button for installed packages
|
||||
if (rowItem.originalData && rowItem.originalData.state && rowItem.originalData.state !== "not-installed") {
|
||||
return `<div class="cn-install-buttons"><button class="nu-btn-uninstall" mode="uninstall">Uninstall</button></div>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}];
|
||||
|
||||
restoreColumnWidth(gridId, columns);
|
||||
|
||||
this.grid.setData({
|
||||
options,
|
||||
rows,
|
||||
columns
|
||||
});
|
||||
|
||||
this.grid.render();
|
||||
|
||||
}
|
||||
|
||||
updateGrid() {
|
||||
if (this.grid) {
|
||||
this.grid.update();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
showUsageDetails(rowItem) {
|
||||
const workflowList = rowItem.workflowDetails;
|
||||
if (!workflowList || workflowList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let titleHtml = `<div class="cn-nodes-pack">${rowItem.title}</div>`;
|
||||
|
||||
const list = [];
|
||||
list.push(`<div class="cn-nodes-list">`);
|
||||
|
||||
workflowList.forEach((workflow, i) => {
|
||||
list.push(`<div class="cn-nodes-row">`);
|
||||
list.push(`<div class="cn-nodes-sn">${i + 1}</div>`);
|
||||
// Workflow filenames are raw server data.
|
||||
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(workflow.filename))}</div>`);
|
||||
list.push(`<div class="cn-nodes-details">${workflow.nodeCount} node${workflow.nodeCount > 1 ? 's' : ''}</div>`);
|
||||
list.push(`</div>`);
|
||||
});
|
||||
|
||||
list.push("</div>");
|
||||
const bodyHtml = list.join("");
|
||||
|
||||
this.flyover.show(titleHtml, bodyHtml);
|
||||
}
|
||||
|
||||
renderSelected() {
|
||||
const selectedList = this.grid.getSelectedRows();
|
||||
if (!selectedList.length) {
|
||||
this.ui.showSelection("");
|
||||
return;
|
||||
}
|
||||
|
||||
const installedSelected = selectedList.filter(item =>
|
||||
item.originalData && item.originalData.state && item.originalData.state !== "not-installed"
|
||||
);
|
||||
|
||||
if (installedSelected.length === 0) {
|
||||
this.ui.showSelection(`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedModels = installedSelected;
|
||||
|
||||
this.ui.showSelection(`
|
||||
<div class="nu-selected-buttons">
|
||||
<span>Selected <b>${installedSelected.length}</b> installed packages</span>
|
||||
<button class="nu-btn-uninstall" mode="uninstall">Uninstall Selected</button>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
async installModels(list, btn) {
|
||||
let stats = await api.fetchApi('/manager/queue/status');
|
||||
|
||||
stats = await stats.json();
|
||||
if (stats.is_processing) {
|
||||
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add("nu-btn-loading");
|
||||
this.ui.showError("");
|
||||
|
||||
let needRefresh = false;
|
||||
let errorMsg = "";
|
||||
|
||||
await api.fetchApi('/manager/queue/reset');
|
||||
|
||||
let target_items = [];
|
||||
|
||||
for (const item of list) {
|
||||
this.grid.scrollRowIntoView(item);
|
||||
target_items.push(item);
|
||||
|
||||
|
||||
// Here item.name is a raw pack key, not a server-escaped title.
|
||||
this.ui.showStatus(`Install ${sanitizeHTML(String(item.name))} ...`);
|
||||
|
||||
const data = item.originalData;
|
||||
data.ui_id = item.hash;
|
||||
|
||||
const res = await api.fetchApi(`/manager/queue/install_model`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (res.status != 200) {
|
||||
// Escape raw task errors for both HTML message destinations.
|
||||
errorMsg = `'${sanitizeHTML(String(item.name))}': `;
|
||||
|
||||
if (res.status == 403) {
|
||||
errorMsg += `This action is not allowed with this security level configuration.\n`;
|
||||
} else {
|
||||
errorMsg += sanitizeHTML(await res.text()) + '\n';
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.install_context = { btn: btn, targets: target_items };
|
||||
|
||||
if (errorMsg) {
|
||||
this.ui.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n" + errorMsg);
|
||||
|
||||
// reset
|
||||
for (let k in target_items) {
|
||||
const item = target_items[k];
|
||||
this.grid.updateCell(item, "installed");
|
||||
}
|
||||
}
|
||||
else {
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
this.ui.showStop();
|
||||
showTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
async uninstallModels(list, btn) {
|
||||
btn.classList.add("nu-btn-loading");
|
||||
this.ui.showError("");
|
||||
|
||||
const result = await uninstallNodes(list, {
|
||||
title: list.length === 1 ? list[0].title || list[0].name : `${list.length} custom nodes`,
|
||||
channel: 'default',
|
||||
mode: 'default',
|
||||
onProgress: (msg) => {
|
||||
this.showStatus(msg);
|
||||
},
|
||||
onError: (errorMsg) => {
|
||||
this.showError(errorMsg);
|
||||
},
|
||||
onSuccess: (targets) => {
|
||||
this.showStatus(`Uninstalled ${targets.length} custom node(s) successfully`);
|
||||
this.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
|
||||
// Update the grid to reflect changes
|
||||
for (let item of targets) {
|
||||
if (item.originalData) {
|
||||
item.originalData.state = "not-installed";
|
||||
}
|
||||
this.grid.updateRow(item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
this.showStop();
|
||||
}
|
||||
|
||||
btn.classList.remove("nu-btn-loading");
|
||||
}
|
||||
|
||||
async onQueueStatus(event) {
|
||||
let self = NodeUsageAnalyzer.instance;
|
||||
|
||||
if (event.detail.status == 'in_progress' && (event.detail.ui_target == 'model_manager' || event.detail.ui_target == 'nodepack_manager')) {
|
||||
const hash = event.detail.target;
|
||||
|
||||
const item = self.grid.getRowItemBy("hash", hash);
|
||||
|
||||
if (item) {
|
||||
item.refresh = true;
|
||||
self.grid.setRowSelected(item, false);
|
||||
item.selectable = false;
|
||||
self.grid.updateRow(item);
|
||||
}
|
||||
}
|
||||
else if (event.detail.status == 'done') {
|
||||
self.hideStop();
|
||||
self.onQueueCompleted(event.detail);
|
||||
}
|
||||
}
|
||||
|
||||
async onQueueCompleted(info) {
|
||||
let result = info.model_result || info.nodepack_result;
|
||||
|
||||
if (!result || result.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let self = NodeUsageAnalyzer.instance;
|
||||
|
||||
if (!self.install_context) {
|
||||
return;
|
||||
}
|
||||
|
||||
let btn = self.install_context.btn;
|
||||
|
||||
self.hideLoading();
|
||||
btn.classList.remove("nu-btn-loading");
|
||||
|
||||
let errorMsg = "";
|
||||
|
||||
for (let hash in result) {
|
||||
let v = result[hash];
|
||||
|
||||
// Escape raw task errors for both HTML message destinations.
|
||||
if (v != 'success' && v != 'skip')
|
||||
errorMsg += sanitizeHTML(String(v)) + '\n';
|
||||
}
|
||||
|
||||
for (let k in self.install_context.targets) {
|
||||
let item = self.install_context.targets[k];
|
||||
if (info.model_result) {
|
||||
self.grid.updateCell(item, "installed");
|
||||
} else if (info.nodepack_result) {
|
||||
// Handle uninstall completion
|
||||
if (item.originalData) {
|
||||
item.originalData.state = "not-installed";
|
||||
}
|
||||
self.grid.updateRow(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMsg) {
|
||||
self.showError(errorMsg);
|
||||
show_message("Operation Error:\n" + errorMsg);
|
||||
} else {
|
||||
if (info.model_result) {
|
||||
self.showStatus(`Install ${Object.keys(result).length} models successfully`);
|
||||
self.showRefresh();
|
||||
self.showMessage(`To apply the installed model, please click the 'Refresh' button.`, "red");
|
||||
} else if (info.nodepack_result) {
|
||||
self.showStatus(`Uninstall ${Object.keys(result).length} custom node(s) successfully`);
|
||||
self.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
|
||||
}
|
||||
}
|
||||
|
||||
infoToast('Tasks done', `[ComfyUI-Manager] All tasks in the queue have been completed.\n${info.done_count}/${info.total_count}`);
|
||||
self.install_context = undefined;
|
||||
}
|
||||
|
||||
getModelList(models) {
|
||||
const typeMap = new Map();
|
||||
const baseMap = new Map();
|
||||
|
||||
models.forEach((item, i) => {
|
||||
const { type, base, name, reference, installed } = item;
|
||||
// CRITICAL FIX: Do NOT overwrite originalData - it contains the needed state field!
|
||||
item.size = sizeToBytes(item.size);
|
||||
item.hash = md5(name + reference);
|
||||
|
||||
if (installed === "True") {
|
||||
item.selectable = false;
|
||||
}
|
||||
|
||||
typeMap.set(type, type);
|
||||
baseMap.set(base, base);
|
||||
|
||||
});
|
||||
|
||||
const typeList = [];
|
||||
typeMap.forEach(type => {
|
||||
typeList.push({
|
||||
label: type,
|
||||
value: type
|
||||
});
|
||||
});
|
||||
typeList.sort((a, b) => {
|
||||
const au = a.label.toUpperCase();
|
||||
const bu = b.label.toUpperCase();
|
||||
if (au !== bu) {
|
||||
return au > bu ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
this.typeList = [{
|
||||
label: "All",
|
||||
value: ""
|
||||
}].concat(typeList);
|
||||
|
||||
|
||||
const baseList = [];
|
||||
baseMap.forEach(base => {
|
||||
baseList.push({
|
||||
label: base,
|
||||
value: base
|
||||
});
|
||||
});
|
||||
baseList.sort((a, b) => {
|
||||
const au = a.label.toUpperCase();
|
||||
const bu = b.label.toUpperCase();
|
||||
if (au !== bu) {
|
||||
return au > bu ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
this.baseList = [{
|
||||
label: "All",
|
||||
value: ""
|
||||
}].concat(baseList);
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
async loadData() {
|
||||
|
||||
this.showLoading();
|
||||
this.showStatus(`Analyzing node usage ...`);
|
||||
|
||||
const mode = manager_instance.datasrc_combo.value;
|
||||
|
||||
const nodeListRes = await fetchData(`/customnode/getlist?mode=${mode}&skip_update=true`);
|
||||
if (nodeListRes.error) {
|
||||
this.showError("Failed to get custom node list.");
|
||||
this.hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
const { channel, node_packs } = nodeListRes.data;
|
||||
delete node_packs['comfyui-manager'];
|
||||
this.installed_custom_node_packs = node_packs;
|
||||
|
||||
// Use the consolidated workflow analysis utility
|
||||
const result = await analyzeWorkflowUsage(node_packs);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error.toString().includes('204')) {
|
||||
this.showMessage("No workflows were found for analysis.");
|
||||
} else {
|
||||
this.showError(sanitizeHTML(String(result.error)));
|
||||
this.hideLoading();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Transform node_packs into models format - ONLY INSTALLED PACKAGES
|
||||
const models = [];
|
||||
|
||||
Object.keys(node_packs).forEach((packKey, index) => {
|
||||
const pack = node_packs[packKey];
|
||||
|
||||
// Only include installed packages (filter out "not-installed" packages)
|
||||
if (pack.state === "not-installed") {
|
||||
return; // Skip non-installed packages
|
||||
}
|
||||
|
||||
const usedCount = result.usageMap?.get(packKey) || 0;
|
||||
const workflowDetails = result.workflowDetailsMap?.get(packKey) || [];
|
||||
|
||||
models.push({
|
||||
// Preserve server-escaped titles; escape only the raw pack-key fallback.
|
||||
title: pack.title || sanitizeHTML(String(packKey)),
|
||||
reference: pack.reference || pack.files?.[0] || '#',
|
||||
used_in_count: usedCount,
|
||||
workflowDetails: workflowDetails,
|
||||
name: packKey,
|
||||
originalData: pack
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by usage count (descending) then by title
|
||||
models.sort((a, b) => {
|
||||
if (b.used_in_count !== a.used_in_count) {
|
||||
return b.used_in_count - a.used_in_count;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
this.modelList = this.getModelList(models);
|
||||
|
||||
this.renderGrid();
|
||||
|
||||
this.hideLoading();
|
||||
|
||||
}
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// Messages accept HTML; see createUIStateManager in common.js.
|
||||
showSelection(msg) {
|
||||
this.element.querySelector(".nu-manager-selection").innerHTML = msg;
|
||||
}
|
||||
|
||||
showError(err) {
|
||||
this.showMessage(err, "red");
|
||||
}
|
||||
|
||||
showMessage(msg, color) {
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".nu-manager-message").innerHTML = msg;
|
||||
}
|
||||
|
||||
showStatus(msg, color) {
|
||||
if (color) {
|
||||
msg = `<font color="${color}">${msg}</font>`;
|
||||
}
|
||||
this.element.querySelector(".nu-manager-status").innerHTML = msg;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
// this.setDisabled(true);
|
||||
if (this.grid) {
|
||||
this.grid.showLoading();
|
||||
this.grid.showMask({
|
||||
opacity: 0.05
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
// this.setDisabled(false);
|
||||
if (this.grid) {
|
||||
this.grid.hideLoading();
|
||||
this.grid.hideMask();
|
||||
}
|
||||
}
|
||||
|
||||
setDisabled(disabled) {
|
||||
const $close = this.element.querySelector(".nu-manager-close");
|
||||
const $refresh = this.element.querySelector(".nu-manager-refresh");
|
||||
const $stop = this.element.querySelector(".nu-manager-stop");
|
||||
|
||||
const list = [
|
||||
".nu-manager-header input",
|
||||
".nu-manager-header select",
|
||||
".nu-manager-footer button",
|
||||
".nu-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(".nu-btn-loading")).forEach($elem => {
|
||||
$elem.classList.remove("nu-btn-loading");
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
showRefresh() {
|
||||
this.element.querySelector(".nu-manager-refresh").style.display = "block";
|
||||
}
|
||||
|
||||
showStop() {
|
||||
this.element.querySelector(".nu-manager-stop").style.display = "block";
|
||||
}
|
||||
|
||||
hideStop() {
|
||||
this.element.querySelector(".nu-manager-stop").style.display = "none";
|
||||
}
|
||||
|
||||
setKeywords(keywords = "") {
|
||||
this.keywords = keywords;
|
||||
this.element.querySelector(".nu-manager-keywords").value = keywords;
|
||||
}
|
||||
|
||||
show(sortMode) {
|
||||
this.element.style.display = "flex";
|
||||
this.setKeywords("");
|
||||
this.showSelection("");
|
||||
this.showMessage("");
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.style.display = "none";
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,6 @@ app.registerExtension({
|
||||
app.canvas.graph.add(new_node, false);
|
||||
node_info_copy(this, new_node, true);
|
||||
app.canvas.graph.remove(this);
|
||||
requestAnimationFrame(() => app.canvas.setDirty(true, true))
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,619 +0,0 @@
|
||||
const hasOwn = function(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
};
|
||||
|
||||
const isNum = function(num) {
|
||||
if (typeof num !== 'number' || isNaN(num)) {
|
||||
return false;
|
||||
}
|
||||
const isInvalid = function(n) {
|
||||
if (n === Number.MAX_VALUE || n === Number.MIN_VALUE || n === Number.NEGATIVE_INFINITY || n === Number.POSITIVE_INFINITY) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isInvalid(num)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const toNum = (num) => {
|
||||
if (typeof (num) !== 'number') {
|
||||
num = parseFloat(num);
|
||||
}
|
||||
if (isNaN(num)) {
|
||||
num = 0;
|
||||
}
|
||||
num = Math.round(num);
|
||||
return num;
|
||||
};
|
||||
|
||||
const clamp = function(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
};
|
||||
|
||||
const isWindow = (obj) => {
|
||||
return Boolean(obj && obj === obj.window);
|
||||
};
|
||||
|
||||
const isDocument = (obj) => {
|
||||
return Boolean(obj && obj.nodeType === 9);
|
||||
};
|
||||
|
||||
const isElement = (obj) => {
|
||||
return Boolean(obj && obj.nodeType === 1);
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
export const toRect = (obj) => {
|
||||
if (obj) {
|
||||
return {
|
||||
left: toNum(obj.left || obj.x),
|
||||
top: toNum(obj.top || obj.y),
|
||||
width: toNum(obj.width),
|
||||
height: toNum(obj.height)
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
};
|
||||
|
||||
export const getElement = (selector) => {
|
||||
if (typeof selector === 'string' && selector) {
|
||||
if (selector.startsWith('#')) {
|
||||
return document.getElementById(selector.slice(1));
|
||||
}
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
if (isDocument(selector)) {
|
||||
return selector.body;
|
||||
}
|
||||
if (isElement(selector)) {
|
||||
return selector;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRect = (target, fixed) => {
|
||||
if (!target) {
|
||||
return toRect();
|
||||
}
|
||||
|
||||
if (isWindow(target)) {
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
const elem = getElement(target);
|
||||
if (!elem) {
|
||||
return toRect(target);
|
||||
}
|
||||
|
||||
const br = elem.getBoundingClientRect();
|
||||
const rect = toRect(br);
|
||||
|
||||
// fix offset
|
||||
if (!fixed) {
|
||||
rect.left += window.scrollX;
|
||||
rect.top += window.scrollY;
|
||||
}
|
||||
|
||||
rect.width = elem.offsetWidth;
|
||||
rect.height = elem.offsetHeight;
|
||||
|
||||
return rect;
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculators = {
|
||||
|
||||
bottom: (info, containerRect, targetRect) => {
|
||||
info.space = containerRect.top + containerRect.height - targetRect.top - targetRect.height - info.height;
|
||||
info.top = targetRect.top + targetRect.height;
|
||||
info.left = Math.round(targetRect.left + targetRect.width * 0.5 - info.width * 0.5);
|
||||
},
|
||||
|
||||
top: (info, containerRect, targetRect) => {
|
||||
info.space = targetRect.top - info.height - containerRect.top;
|
||||
info.top = targetRect.top - info.height;
|
||||
info.left = Math.round(targetRect.left + targetRect.width * 0.5 - info.width * 0.5);
|
||||
},
|
||||
|
||||
right: (info, containerRect, targetRect) => {
|
||||
info.space = containerRect.left + containerRect.width - targetRect.left - targetRect.width - info.width;
|
||||
info.top = Math.round(targetRect.top + targetRect.height * 0.5 - info.height * 0.5);
|
||||
info.left = targetRect.left + targetRect.width;
|
||||
},
|
||||
|
||||
left: (info, containerRect, targetRect) => {
|
||||
info.space = targetRect.left - info.width - containerRect.left;
|
||||
info.top = Math.round(targetRect.top + targetRect.height * 0.5 - info.height * 0.5);
|
||||
info.left = targetRect.left - info.width;
|
||||
}
|
||||
};
|
||||
|
||||
// with order
|
||||
export const getDefaultPositions = () => {
|
||||
return Object.keys(calculators);
|
||||
};
|
||||
|
||||
const calculateSpace = (info, containerRect, targetRect) => {
|
||||
const calculator = calculators[info.position];
|
||||
calculator(info, containerRect, targetRect);
|
||||
if (info.space >= 0) {
|
||||
info.passed += 1;
|
||||
}
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculateAlignOffset = (info, containerRect, targetRect, alignType, sizeType) => {
|
||||
|
||||
const popoverStart = info[alignType];
|
||||
const popoverSize = info[sizeType];
|
||||
|
||||
const containerStart = containerRect[alignType];
|
||||
const containerSize = containerRect[sizeType];
|
||||
|
||||
const targetStart = targetRect[alignType];
|
||||
const targetSize = targetRect[sizeType];
|
||||
|
||||
const targetCenter = targetStart + targetSize * 0.5;
|
||||
|
||||
// size overflow
|
||||
if (popoverSize > containerSize) {
|
||||
const overflow = (popoverSize - containerSize) * 0.5;
|
||||
info[alignType] = containerStart - overflow;
|
||||
info.offset = targetCenter - containerStart + overflow;
|
||||
return;
|
||||
}
|
||||
|
||||
const space1 = popoverStart - containerStart;
|
||||
const space2 = (containerStart + containerSize) - (popoverStart + popoverSize);
|
||||
|
||||
// both side passed, default to center
|
||||
if (space1 >= 0 && space2 >= 0) {
|
||||
if (info.passed) {
|
||||
info.passed += 2;
|
||||
}
|
||||
info.offset = popoverSize * 0.5;
|
||||
return;
|
||||
}
|
||||
|
||||
// one side passed
|
||||
if (info.passed) {
|
||||
info.passed += 1;
|
||||
}
|
||||
|
||||
if (space1 < 0) {
|
||||
const min = containerStart;
|
||||
info[alignType] = min;
|
||||
info.offset = targetCenter - min;
|
||||
return;
|
||||
}
|
||||
|
||||
// space2 < 0
|
||||
const max = containerStart + containerSize - popoverSize;
|
||||
info[alignType] = max;
|
||||
info.offset = targetCenter - max;
|
||||
|
||||
};
|
||||
|
||||
const calculateHV = (info, containerRect) => {
|
||||
if (['top', 'bottom'].includes(info.position)) {
|
||||
info.top = clamp(info.top, containerRect.top, containerRect.top + containerRect.height - info.height);
|
||||
return ['left', 'width'];
|
||||
}
|
||||
info.left = clamp(info.left, containerRect.left, containerRect.left + containerRect.width - info.width);
|
||||
return ['top', 'height'];
|
||||
};
|
||||
|
||||
const calculateOffset = (info, containerRect, targetRect) => {
|
||||
|
||||
const [alignType, sizeType] = calculateHV(info, containerRect);
|
||||
|
||||
calculateAlignOffset(info, containerRect, targetRect, alignType, sizeType);
|
||||
|
||||
info.offset = clamp(info.offset, 0, info[sizeType]);
|
||||
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculateDistance = (info, previousPositionInfo) => {
|
||||
if (!previousPositionInfo) {
|
||||
return;
|
||||
}
|
||||
// no change if position no change with previous
|
||||
if (info.position === previousPositionInfo.position) {
|
||||
return;
|
||||
}
|
||||
const ax = info.left + info.width * 0.5;
|
||||
const ay = info.top + info.height * 0.5;
|
||||
const bx = previousPositionInfo.left + previousPositionInfo.width * 0.5;
|
||||
const by = previousPositionInfo.top + previousPositionInfo.height * 0.5;
|
||||
const dx = Math.abs(ax - bx);
|
||||
const dy = Math.abs(ay - by);
|
||||
info.distance = Math.round(Math.sqrt(dx * dx + dy * dy));
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculatePositionInfo = (info, containerRect, targetRect, previousPositionInfo) => {
|
||||
calculateSpace(info, containerRect, targetRect);
|
||||
calculateOffset(info, containerRect, targetRect);
|
||||
calculateDistance(info, previousPositionInfo);
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const calculateBestPosition = (containerRect, targetRect, infoMap, withOrder, previousPositionInfo) => {
|
||||
|
||||
// position space: +1
|
||||
// align space:
|
||||
// two side passed: +2
|
||||
// one side passed: +1
|
||||
|
||||
const safePassed = 3;
|
||||
|
||||
if (previousPositionInfo) {
|
||||
const prevInfo = infoMap[previousPositionInfo.position];
|
||||
if (prevInfo) {
|
||||
calculatePositionInfo(prevInfo, containerRect, targetRect);
|
||||
if (prevInfo.passed >= safePassed) {
|
||||
return prevInfo;
|
||||
}
|
||||
prevInfo.calculated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const positionList = [];
|
||||
Object.values(infoMap).forEach((info) => {
|
||||
if (!info.calculated) {
|
||||
calculatePositionInfo(info, containerRect, targetRect, previousPositionInfo);
|
||||
}
|
||||
positionList.push(info);
|
||||
});
|
||||
|
||||
positionList.sort((a, b) => {
|
||||
if (a.passed !== b.passed) {
|
||||
return b.passed - a.passed;
|
||||
}
|
||||
|
||||
if (withOrder && a.passed >= safePassed && b.passed >= safePassed) {
|
||||
return a.index - b.index;
|
||||
}
|
||||
|
||||
if (a.space !== b.space) {
|
||||
return b.space - a.space;
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
// logTable(positionList);
|
||||
|
||||
return positionList[0];
|
||||
};
|
||||
|
||||
// const logTable = (() => {
|
||||
// let time_id;
|
||||
// return (info) => {
|
||||
// clearTimeout(time_id);
|
||||
// time_id = setTimeout(() => {
|
||||
// console.table(info);
|
||||
// }, 10);
|
||||
// };
|
||||
// })();
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const getAllowPositions = (positions, defaultAllowPositions) => {
|
||||
if (!positions) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(positions)) {
|
||||
positions = positions.join(',');
|
||||
}
|
||||
positions = String(positions).split(',').map((it) => it.trim().toLowerCase()).filter((it) => it);
|
||||
positions = positions.filter((it) => defaultAllowPositions.includes(it));
|
||||
if (!positions.length) {
|
||||
return;
|
||||
}
|
||||
return positions;
|
||||
};
|
||||
|
||||
const isPositionChanged = (info, previousPositionInfo) => {
|
||||
if (!previousPositionInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (info.left !== previousPositionInfo.left) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (info.top !== previousPositionInfo.top) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// const log = (name, time) => {
|
||||
// if (time > 0.1) {
|
||||
// console.log(name, time);
|
||||
// }
|
||||
// };
|
||||
|
||||
export const getBestPosition = (containerRect, targetRect, popoverRect, positions, previousPositionInfo) => {
|
||||
|
||||
const defaultAllowPositions = getDefaultPositions();
|
||||
let withOrder = true;
|
||||
let allowPositions = getAllowPositions(positions, defaultAllowPositions);
|
||||
if (!allowPositions) {
|
||||
allowPositions = defaultAllowPositions;
|
||||
withOrder = false;
|
||||
}
|
||||
|
||||
// console.log('withOrder', withOrder);
|
||||
|
||||
// const start_time = performance.now();
|
||||
|
||||
const infoMap = {};
|
||||
allowPositions.forEach((k, i) => {
|
||||
infoMap[k] = {
|
||||
position: k,
|
||||
index: i,
|
||||
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: popoverRect.width,
|
||||
height: popoverRect.height,
|
||||
|
||||
space: 0,
|
||||
|
||||
offset: 0,
|
||||
passed: 0,
|
||||
|
||||
distance: 0
|
||||
};
|
||||
});
|
||||
|
||||
// log('infoMap', performance.now() - start_time);
|
||||
|
||||
|
||||
const bestPosition = calculateBestPosition(containerRect, targetRect, infoMap, withOrder, previousPositionInfo);
|
||||
|
||||
// check left/top
|
||||
bestPosition.changed = isPositionChanged(bestPosition, previousPositionInfo);
|
||||
|
||||
return bestPosition;
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
const getTemplatePath = (width, height, arrowOffset, arrowSize, borderRadius) => {
|
||||
const p = (px, py) => {
|
||||
return [px, py].join(',');
|
||||
};
|
||||
|
||||
const px = function(num, alignEnd) {
|
||||
const floor = Math.floor(num);
|
||||
let n = num < floor + 0.5 ? floor + 0.5 : floor + 1.5;
|
||||
if (alignEnd) {
|
||||
n -= 1;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
const pxe = function(num) {
|
||||
return px(num, true);
|
||||
};
|
||||
|
||||
const ls = [];
|
||||
|
||||
const innerLeft = px(arrowSize);
|
||||
const innerRight = pxe(width - arrowSize);
|
||||
arrowOffset = clamp(arrowOffset, innerLeft, innerRight);
|
||||
|
||||
const innerTop = px(arrowSize);
|
||||
const innerBottom = pxe(height - arrowSize);
|
||||
|
||||
const startPoint = p(innerLeft, innerTop + borderRadius);
|
||||
const arrowPoint = p(arrowOffset, 1);
|
||||
|
||||
const LT = p(innerLeft, innerTop);
|
||||
const RT = p(innerRight, innerTop);
|
||||
|
||||
const AOT = p(arrowOffset - arrowSize, innerTop);
|
||||
const RRT = p(innerRight - borderRadius, innerTop);
|
||||
|
||||
ls.push(`M${startPoint}`);
|
||||
ls.push(`V${innerBottom - borderRadius}`);
|
||||
ls.push(`Q${p(innerLeft, innerBottom)} ${p(innerLeft + borderRadius, innerBottom)}`);
|
||||
ls.push(`H${innerRight - borderRadius}`);
|
||||
ls.push(`Q${p(innerRight, innerBottom)} ${p(innerRight, innerBottom - borderRadius)}`);
|
||||
ls.push(`V${innerTop + borderRadius}`);
|
||||
|
||||
if (arrowOffset < innerLeft + arrowSize + borderRadius) {
|
||||
ls.push(`Q${RT} ${RRT}`);
|
||||
ls.push(`H${arrowOffset + arrowSize}`);
|
||||
ls.push(`L${arrowPoint}`);
|
||||
if (arrowOffset < innerLeft + arrowSize) {
|
||||
ls.push(`L${LT}`);
|
||||
ls.push(`L${startPoint}`);
|
||||
} else {
|
||||
ls.push(`L${AOT}`);
|
||||
ls.push(`Q${LT} ${startPoint}`);
|
||||
}
|
||||
} else if (arrowOffset > innerRight - arrowSize - borderRadius) {
|
||||
if (arrowOffset > innerRight - arrowSize) {
|
||||
ls.push(`L${RT}`);
|
||||
} else {
|
||||
ls.push(`Q${RT} ${p(arrowOffset + arrowSize, innerTop)}`);
|
||||
}
|
||||
ls.push(`L${arrowPoint}`);
|
||||
ls.push(`L${AOT}`);
|
||||
ls.push(`H${innerLeft + borderRadius}`);
|
||||
ls.push(`Q${LT} ${startPoint}`);
|
||||
} else {
|
||||
ls.push(`Q${RT} ${RRT}`);
|
||||
ls.push(`H${arrowOffset + arrowSize}`);
|
||||
ls.push(`L${arrowPoint}`);
|
||||
ls.push(`L${AOT}`);
|
||||
ls.push(`H${innerLeft + borderRadius}`);
|
||||
ls.push(`Q${LT} ${startPoint}`);
|
||||
}
|
||||
return ls.join('');
|
||||
};
|
||||
|
||||
const getPathData = function(position, width, height, arrowOffset, arrowSize, borderRadius) {
|
||||
|
||||
const handlers = {
|
||||
|
||||
bottom: () => {
|
||||
const d = getTemplatePath(width, height, arrowOffset, arrowSize, borderRadius);
|
||||
return {
|
||||
d,
|
||||
transform: ''
|
||||
};
|
||||
},
|
||||
|
||||
top: () => {
|
||||
const d = getTemplatePath(width, height, width - arrowOffset, arrowSize, borderRadius);
|
||||
return {
|
||||
d,
|
||||
transform: `rotate(180,${width * 0.5},${height * 0.5})`
|
||||
};
|
||||
},
|
||||
|
||||
left: () => {
|
||||
const d = getTemplatePath(height, width, arrowOffset, arrowSize, borderRadius);
|
||||
const x = (width - height) * 0.5;
|
||||
const y = (height - width) * 0.5;
|
||||
return {
|
||||
d,
|
||||
transform: `translate(${x} ${y}) rotate(90,${height * 0.5},${width * 0.5})`
|
||||
};
|
||||
},
|
||||
|
||||
right: () => {
|
||||
const d = getTemplatePath(height, width, height - arrowOffset, arrowSize, borderRadius);
|
||||
const x = (width - height) * 0.5;
|
||||
const y = (height - width) * 0.5;
|
||||
return {
|
||||
d,
|
||||
transform: `translate(${x} ${y}) rotate(-90,${height * 0.5},${width * 0.5})`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return handlers[position]();
|
||||
};
|
||||
|
||||
// ===========================================================================================
|
||||
|
||||
// position style cache
|
||||
const styleCache = {
|
||||
// position: '',
|
||||
// top: {},
|
||||
// bottom: {},
|
||||
// left: {},
|
||||
// right: {}
|
||||
};
|
||||
|
||||
export const getPositionStyle = (info, options = {}) => {
|
||||
|
||||
const o = {
|
||||
bgColor: '#fff',
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 5,
|
||||
arrowSize: 10
|
||||
};
|
||||
Object.keys(o).forEach((k) => {
|
||||
|
||||
if (hasOwn(options, k)) {
|
||||
const d = o[k];
|
||||
const v = options[k];
|
||||
|
||||
if (typeof d === 'string') {
|
||||
// string
|
||||
if (typeof v === 'string' && v) {
|
||||
o[k] = v;
|
||||
}
|
||||
} else {
|
||||
// number
|
||||
if (isNum(v) && v >= 0) {
|
||||
o[k] = v;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
const key = [
|
||||
info.width,
|
||||
info.height,
|
||||
info.offset,
|
||||
o.arrowSize,
|
||||
o.borderRadius,
|
||||
o.bgColor,
|
||||
o.borderColor
|
||||
].join('-');
|
||||
|
||||
const positionCache = styleCache[info.position];
|
||||
if (positionCache && key === positionCache.key) {
|
||||
const st = positionCache.style;
|
||||
st.changed = styleCache.position !== info.position;
|
||||
styleCache.position = info.position;
|
||||
return st;
|
||||
}
|
||||
|
||||
// console.log(options);
|
||||
|
||||
const data = getPathData(info.position, info.width, info.height, info.offset, o.arrowSize, o.borderRadius);
|
||||
// console.log(data);
|
||||
|
||||
const viewBox = [0, 0, info.width, info.height].join(' ');
|
||||
const svg = [
|
||||
`<svg viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">`,
|
||||
`<path d="${data.d}" fill="${o.bgColor}" stroke="${o.borderColor}" transform="${data.transform}" />`,
|
||||
'</svg>'
|
||||
].join('');
|
||||
|
||||
// console.log(svg);
|
||||
const backgroundImage = `url("data:image/svg+xml;charset=utf8,${encodeURIComponent(svg)}")`;
|
||||
|
||||
const background = `${backgroundImage} center no-repeat`;
|
||||
|
||||
const padding = `${o.arrowSize + o.borderRadius}px`;
|
||||
|
||||
const style = {
|
||||
background,
|
||||
backgroundImage,
|
||||
padding,
|
||||
changed: true
|
||||
};
|
||||
|
||||
styleCache.position = info.position;
|
||||
styleCache[info.position] = {
|
||||
key,
|
||||
style
|
||||
};
|
||||
|
||||
return style;
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
.snapshot-manager {
|
||||
--grid-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
z-index: 1099;
|
||||
width: 80vw;
|
||||
height: 75vh;
|
||||
min-height: 30em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--fg-color);
|
||||
font-family: arial, sans-serif;
|
||||
text-underline-offset: 3px;
|
||||
outline: none;
|
||||
margin: calc(var(--spacing)*2);
|
||||
}
|
||||
|
||||
.snapshot-manager button {
|
||||
width: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
color: var(--input-text);
|
||||
background-color: var(--comfy-input-bg);
|
||||
border-color: var(--border-color);
|
||||
margin: 0;
|
||||
min-width: 100px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.snapshot-manager .snapshot-restore-btn {
|
||||
background-color: #00158f !important;
|
||||
border-color: #2025b9 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.snapshot-manager .snapshot-remove-btn {
|
||||
background-color: #970000 !important;
|
||||
border-color: #be2127 !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.snapshot-manager button:hover {
|
||||
filter: brightness(125%);
|
||||
}
|
||||
|
||||
.snapshot-manager .data-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing)*2);
|
||||
padding: calc(var(--spacing)*2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.snapshot-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.snapshot-manager .cn-flex-auto {
|
||||
flex: auto;
|
||||
}
|
||||
+34
-45
@@ -1,18 +1,16 @@
|
||||
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, handle403Response, loadCss, sanitizeHTML } from "./common.js";
|
||||
import { buildGuiFrame } from "./comfyui-gui-builder.js";
|
||||
import { manager_instance, rebootAPI, show_message } from "./common.js";
|
||||
|
||||
loadCss("./snapshot.css");
|
||||
|
||||
async function restore_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi('/snapshot/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
|
||||
const response = await api.fetchApi(`/snapshot/restore?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
await handle403Response(response);
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,7 +27,7 @@ async function restore_snapshot(target) {
|
||||
}
|
||||
finally {
|
||||
await SnapshotManager.instance.invalidateControl();
|
||||
SnapshotManager.instance.updateMessage("<BR>To apply the snapshot, please <button id='cm-reboot-button2' class='p-button p-component'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
|
||||
SnapshotManager.instance.updateMessage("<BR>To apply the snapshot, please <button id='cm-reboot-button2' class='cm-small-button'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,10 +35,10 @@ async function restore_snapshot(target) {
|
||||
async function remove_snapshot(target) {
|
||||
if(SnapshotManager.instance) {
|
||||
try {
|
||||
const response = await api.fetchApi('/snapshot/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
|
||||
const response = await api.fetchApi(`/snapshot/remove?target=${target}`, { cache: "no-store" });
|
||||
|
||||
if(response.status == 403) {
|
||||
await handle403Response(response);
|
||||
show_message('This action is not allowed with this security level configuration.');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -63,7 +61,7 @@ async function remove_snapshot(target) {
|
||||
|
||||
async function save_current_snapshot() {
|
||||
try {
|
||||
const response = await api.fetchApi('/snapshot/save', { method: 'POST', cache: "no-store" });
|
||||
const response = await api.fetchApi('/snapshot/save', { cache: "no-store" });
|
||||
app.ui.dialog.close();
|
||||
return true;
|
||||
}
|
||||
@@ -90,8 +88,6 @@ export class SnapshotManager extends ComfyDialog {
|
||||
message_box = null;
|
||||
data = null;
|
||||
|
||||
content = $el("div.snapshot-manager");
|
||||
|
||||
clear() {
|
||||
this.restore_buttons = [];
|
||||
this.message_box = null;
|
||||
@@ -100,18 +96,9 @@ export class SnapshotManager extends ComfyDialog {
|
||||
|
||||
constructor(app, manager_dialog) {
|
||||
super();
|
||||
// this.manager_dialog = manager_dialog;
|
||||
this.manager_dialog = manager_dialog;
|
||||
this.search_keyword = '';
|
||||
|
||||
const frame = buildGuiFrame(
|
||||
'snapshot-manager-dialog', // dialog id
|
||||
'Snapshot Manager', // title
|
||||
'i.mdi.mdi-puzzle', // icon class
|
||||
this.content, // dialog content element
|
||||
this
|
||||
); // send this so we can attach close functions
|
||||
|
||||
this.element = frame;
|
||||
this.element = $el("div.comfy-modal", { parent: document.body }, []);
|
||||
}
|
||||
|
||||
async remove_item() {
|
||||
@@ -122,7 +109,7 @@ export class SnapshotManager extends ComfyDialog {
|
||||
|
||||
createControls() {
|
||||
return [
|
||||
$el("button.p-button.p-component", {
|
||||
$el("button.cm-small-button", {
|
||||
type: "button",
|
||||
textContent: "Close",
|
||||
onclick: () => { this.close(); }
|
||||
@@ -133,7 +120,7 @@ export class SnapshotManager extends ComfyDialog {
|
||||
startRestore(target) {
|
||||
const self = SnapshotManager.instance;
|
||||
|
||||
self.updateMessage(`<BR><font color="green">Restore snapshot '${sanitizeHTML(target.name)}'</font>`);
|
||||
self.updateMessage(`<BR><font color="green">Restore snapshot '${target.name}'</font>`);
|
||||
|
||||
for(let i in self.restore_buttons) {
|
||||
self.restore_buttons[i].disabled = true;
|
||||
@@ -145,8 +132,8 @@ export class SnapshotManager extends ComfyDialog {
|
||||
this.clear();
|
||||
this.data = (await getSnapshotList()).items;
|
||||
|
||||
while (this.content.children.length) {
|
||||
this.content.removeChild(this.content.children[0]);
|
||||
while (this.element.children.length) {
|
||||
this.element.removeChild(this.element.children[0]);
|
||||
}
|
||||
|
||||
await this.createGrid();
|
||||
@@ -158,8 +145,8 @@ export class SnapshotManager extends ComfyDialog {
|
||||
if(btn_id) {
|
||||
const rebootButton = document.getElementById(btn_id);
|
||||
const self = this;
|
||||
rebootButton.onclick = async function() {
|
||||
if(await rebootAPI()) {
|
||||
rebootButton.onclick = function() {
|
||||
if(rebootAPI()) {
|
||||
self.close();
|
||||
self.manager_dialog.close();
|
||||
}
|
||||
@@ -214,24 +201,23 @@ export class SnapshotManager extends ComfyDialog {
|
||||
data1.style.textAlign = "center";
|
||||
data1.innerHTML = i+1;
|
||||
var data2 = document.createElement('td');
|
||||
data2.innerHTML = ` ${sanitizeHTML(data)}`;
|
||||
data2.innerHTML = ` ${data}`;
|
||||
var data_button = document.createElement('td');
|
||||
data_button.style.textAlign = "center";
|
||||
data_button.className = "data-btns";
|
||||
|
||||
var restoreBtn = document.createElement('button');
|
||||
restoreBtn.className = "snapshot-restore-btn p-button p-component";
|
||||
restoreBtn.innerHTML = 'Restore';
|
||||
restoreBtn.style.width = "100px";
|
||||
restoreBtn.style.backgroundColor = 'blue';
|
||||
|
||||
restoreBtn.addEventListener('click', function() {
|
||||
restore_snapshot(data);
|
||||
});
|
||||
|
||||
var removeBtn = document.createElement('button');
|
||||
removeBtn.className = "snapshot-remove-btn p-button p-component";
|
||||
removeBtn.innerHTML = 'Remove';
|
||||
removeBtn.style.width = "100px";
|
||||
removeBtn.style.backgroundColor = 'red';
|
||||
|
||||
removeBtn.addEventListener('click', function() {
|
||||
remove_snapshot(data);
|
||||
@@ -255,14 +241,13 @@ export class SnapshotManager extends ComfyDialog {
|
||||
let self = this;
|
||||
const panel = document.createElement('div');
|
||||
panel.style.width = "100%";
|
||||
panel.style.height = "100%";
|
||||
panel.appendChild(grid);
|
||||
|
||||
function handleResize() {
|
||||
const parentHeight = self.element.clientHeight;
|
||||
const gridHeight = parentHeight - 200;
|
||||
|
||||
// grid.style.height = gridHeight + "px";
|
||||
grid.style.height = gridHeight + "px";
|
||||
}
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
@@ -271,17 +256,25 @@ export class SnapshotManager extends ComfyDialog {
|
||||
grid.style.width = "100%";
|
||||
grid.style.height = "100%";
|
||||
grid.style.overflowY = "scroll";
|
||||
|
||||
this.content.appendChild(panel);
|
||||
this.element.style.height = "85%";
|
||||
this.element.style.width = "80%";
|
||||
this.element.appendChild(panel);
|
||||
|
||||
handleResize();
|
||||
}
|
||||
|
||||
async createBottomControls() {
|
||||
var close_button = document.createElement("button");
|
||||
close_button.className = "cm-small-button";
|
||||
close_button.innerHTML = "Close";
|
||||
close_button.onclick = () => { this.close(); }
|
||||
close_button.style.display = "inline-block";
|
||||
|
||||
var save_button = document.createElement("button");
|
||||
save_button.className = "p-button p-component";
|
||||
save_button.className = "cm-small-button";
|
||||
save_button.innerHTML = "Save snapshot";
|
||||
save_button.onclick = () => { save_current_snapshot(); }
|
||||
save_button.style.display = "inline-block";
|
||||
save_button.style.horizontalAlign = "right";
|
||||
save_button.style.width = "170px";
|
||||
|
||||
@@ -289,19 +282,15 @@ export class SnapshotManager extends ComfyDialog {
|
||||
this.message_box.style.height = '60px';
|
||||
this.message_box.style.verticalAlign = 'middle';
|
||||
|
||||
const footer = $el("div.snapshot-footer");
|
||||
const spacer = $el("div.cn-flex-auto");
|
||||
footer.appendChild(spacer);
|
||||
footer.appendChild(save_button);
|
||||
|
||||
this.content.appendChild(this.message_box);
|
||||
this.content.appendChild(footer);
|
||||
this.element.appendChild(this.message_box);
|
||||
this.element.appendChild(close_button);
|
||||
this.element.appendChild(save_button);
|
||||
}
|
||||
|
||||
async show() {
|
||||
try {
|
||||
this.invalidateControl();
|
||||
this.element.style.display = "flex";
|
||||
this.element.style.display = "block";
|
||||
this.element.style.zIndex = 1099;
|
||||
}
|
||||
catch(exception) {
|
||||
|
||||
@@ -70,8 +70,8 @@ class WorkflowMetadataExtension {
|
||||
if (cnr_id === "comfy-core") return; // don't allow hijacking comfy-core name
|
||||
if (cnr_id) nodeProperties.cnr_id = cnr_id;
|
||||
else nodeProperties.aux_id = aux_id;
|
||||
if (ver) nodeProperties.ver = ver.trim();
|
||||
} else if (["nodes", "comfy_extras", "comfy_api_nodes"].includes(moduleType)) {
|
||||
if (ver) nodeProperties.ver = ver;
|
||||
} else if (["nodes", "comfy_extras"].includes(moduleType)) {
|
||||
nodeProperties.cnr_id = "comfy-core";
|
||||
nodeProperties.ver = this.comfyCoreVersion;
|
||||
}
|
||||
|
||||
+16
-255
@@ -1,264 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""JSON Entry Validator
|
||||
|
||||
Validates JSON entries based on content structure.
|
||||
|
||||
Validation rules based on JSON content:
|
||||
- {"custom_nodes": [...]}: Validates required fields (author, title, reference, files, install_type, description)
|
||||
- {"models": [...]}: Validates JSON syntax only (no required fields)
|
||||
- Other JSON structures: Validates JSON syntax only
|
||||
|
||||
Git repository URL validation (for custom_nodes):
|
||||
1. URLs must NOT end with .git
|
||||
2. URLs must follow format: https://github.com/{author}/{reponame}
|
||||
3. .py and .js files are exempt from this check
|
||||
|
||||
Supported formats:
|
||||
- Array format: [{...}, {...}]
|
||||
- Object format: {"custom_nodes": [...]} or {"models": [...]}
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple
|
||||
import argparse
|
||||
|
||||
|
||||
# Required fields for each entry type
|
||||
REQUIRED_FIELDS_CUSTOM_NODE = ['author', 'title', 'reference', 'files', 'install_type', 'description']
|
||||
REQUIRED_FIELDS_MODEL = [] # model-list.json doesn't require field validation
|
||||
|
||||
# Pattern for valid GitHub repository URL (without .git suffix)
|
||||
GITHUB_REPO_PATTERN = re.compile(r'^https://github\.com/[^/]+/[^/]+$')
|
||||
|
||||
|
||||
def get_entry_context(entry: Dict) -> str:
|
||||
"""Get identifying information from entry for error messages
|
||||
|
||||
Args:
|
||||
entry: JSON entry
|
||||
|
||||
Returns:
|
||||
String with author and reference info
|
||||
"""
|
||||
parts = []
|
||||
if 'author' in entry:
|
||||
parts.append(f"author={entry['author']}")
|
||||
if 'reference' in entry:
|
||||
parts.append(f"ref={entry['reference']}")
|
||||
if 'title' in entry:
|
||||
parts.append(f"title={entry['title']}")
|
||||
|
||||
if parts:
|
||||
return " | ".join(parts)
|
||||
else:
|
||||
# No identifying info - show actual entry content (truncated)
|
||||
import json
|
||||
entry_str = json.dumps(entry, ensure_ascii=False)
|
||||
if len(entry_str) > 100:
|
||||
entry_str = entry_str[:100] + "..."
|
||||
return f"content={entry_str}"
|
||||
|
||||
|
||||
def validate_required_fields(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
|
||||
"""Validate that all required fields are present
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
required_fields: List of required field names
|
||||
|
||||
Returns:
|
||||
List of error descriptions (without entry prefix/context)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for field in required_fields:
|
||||
if field not in entry:
|
||||
errors.append(f"Missing required field '{field}'")
|
||||
elif entry[field] is None:
|
||||
errors.append(f"Field '{field}' is null")
|
||||
elif isinstance(entry[field], str) and not entry[field].strip():
|
||||
errors.append(f"Field '{field}' is empty")
|
||||
elif field == 'files' and not entry[field]: # Empty array
|
||||
errors.append("Field 'files' is empty array")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_git_repo_urls(entry: Dict, entry_index: int) -> List[str]:
|
||||
"""Validate git repository URLs in 'files' array
|
||||
|
||||
Requirements:
|
||||
- Git repo URLs must NOT end with .git
|
||||
- Must follow format: https://github.com/{author}/{reponame}
|
||||
- .py and .js files are exempt
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
|
||||
Returns:
|
||||
List of error descriptions (without entry prefix/context)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
if 'files' not in entry or not isinstance(entry['files'], list):
|
||||
return errors
|
||||
|
||||
for file_url in entry['files']:
|
||||
if not isinstance(file_url, str):
|
||||
continue
|
||||
|
||||
# Skip .py and .js files - they're exempt from git repo validation
|
||||
if file_url.endswith('.py') or file_url.endswith('.js'):
|
||||
continue
|
||||
|
||||
# Check if it's a GitHub URL (likely a git repo)
|
||||
if 'github.com' in file_url:
|
||||
# Error if URL ends with .git
|
||||
if file_url.endswith('.git'):
|
||||
errors.append(f"Git repo URL must NOT end with .git: {file_url}")
|
||||
continue
|
||||
|
||||
# Validate format: https://github.com/{author}/{reponame}
|
||||
if not GITHUB_REPO_PATTERN.match(file_url):
|
||||
errors.append(f"Invalid git repo URL format (expected https://github.com/author/reponame): {file_url}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_entry(entry: Dict, entry_index: int, required_fields: List[str]) -> List[str]:
|
||||
"""Validate a single JSON entry
|
||||
|
||||
Args:
|
||||
entry: JSON entry to validate
|
||||
entry_index: Index of entry in array (for error reporting)
|
||||
required_fields: List of required field names
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check required fields
|
||||
errors.extend(validate_required_fields(entry, entry_index, required_fields))
|
||||
|
||||
# Check git repository URLs
|
||||
errors.extend(validate_git_repo_urls(entry, entry_index))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_json_file(file_path: str) -> Tuple[bool, List[str]]:
|
||||
"""Validate JSON file containing entries
|
||||
|
||||
Args:
|
||||
file_path: Path to JSON file
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check file exists
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return False, [f"File not found: {file_path}"]
|
||||
|
||||
# Load JSON
|
||||
def check_json_syntax(file_path):
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
json_str = file.read()
|
||||
json.loads(json_str)
|
||||
print(f"[ OK ] {file_path}")
|
||||
except UnicodeDecodeError as e:
|
||||
print(f"Unicode decode error: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
return False, [f"Invalid JSON: {e}"]
|
||||
except Exception as e:
|
||||
return False, [f"Error reading file: {e}"]
|
||||
|
||||
# Determine required fields based on JSON content
|
||||
required_fields = []
|
||||
|
||||
# Validate structure - support both array and object formats
|
||||
entries_to_validate = []
|
||||
|
||||
if isinstance(data, list):
|
||||
# Direct array format: [{...}, {...}]
|
||||
entries_to_validate = data
|
||||
elif isinstance(data, dict):
|
||||
# Object format: {"custom_nodes": [...]} or {"models": [...]}
|
||||
# Determine validation based on keys
|
||||
if 'custom_nodes' in data and isinstance(data['custom_nodes'], list):
|
||||
required_fields = REQUIRED_FIELDS_CUSTOM_NODE
|
||||
entries_to_validate = data['custom_nodes']
|
||||
elif 'models' in data and isinstance(data['models'], list):
|
||||
required_fields = REQUIRED_FIELDS_MODEL
|
||||
entries_to_validate = data['models']
|
||||
else:
|
||||
# Other JSON structures (extension-node-map.json, etc.) - just validate JSON syntax
|
||||
return True, []
|
||||
else:
|
||||
return False, ["JSON root must be either an array or an object containing arrays"]
|
||||
|
||||
# Validate each entry
|
||||
for idx, entry in enumerate(entries_to_validate, start=1):
|
||||
if not isinstance(entry, dict):
|
||||
# Show actual value for type errors
|
||||
entry_str = json.dumps(entry, ensure_ascii=False) if not isinstance(entry, str) else repr(entry)
|
||||
if len(entry_str) > 150:
|
||||
entry_str = entry_str[:150] + "..."
|
||||
errors.append(f"\n❌ Entry #{idx}: Must be an object, got {type(entry).__name__}")
|
||||
errors.append(f" Actual value: {entry_str}")
|
||||
continue
|
||||
|
||||
entry_errors = validate_entry(entry, idx, required_fields)
|
||||
if entry_errors:
|
||||
# Group errors by entry with context
|
||||
context = get_entry_context(entry)
|
||||
errors.append(f"\n❌ Entry #{idx} ({context}):")
|
||||
for error in entry_errors:
|
||||
errors.append(f" - {error}")
|
||||
|
||||
is_valid = len(errors) == 0
|
||||
return is_valid, errors
|
||||
|
||||
print(f"[FAIL] {file_path}\n\n {e}\n")
|
||||
except FileNotFoundError:
|
||||
print(f"[FAIL] {file_path}\n\n File not found\n")
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python json-checker.py <json-file>")
|
||||
print("\nValidates JSON entries based on content:")
|
||||
print(" - {\"custom_nodes\": [...]}: Validates required fields (author, title, reference, files, install_type, description)")
|
||||
print(" - {\"models\": [...]}: Validates JSON syntax only (no required fields)")
|
||||
print(" - Other JSON structures: Validates JSON syntax only")
|
||||
print("\nGit repo URL validation (for custom_nodes):")
|
||||
print(" - URLs must NOT end with .git")
|
||||
print(" - URLs must follow: https://github.com/{author}/{reponame}")
|
||||
sys.exit(1)
|
||||
parser = argparse.ArgumentParser(description="JSON File Syntax Checker")
|
||||
parser.add_argument("file_path", type=str, help="Path to the JSON file for syntax checking")
|
||||
|
||||
file_path = sys.argv[1]
|
||||
args = parser.parse_args()
|
||||
check_json_syntax(args.file_path)
|
||||
|
||||
is_valid, errors = validate_json_file(file_path)
|
||||
|
||||
if is_valid:
|
||||
print(f"✅ {file_path}: Validation passed")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"Validating: {file_path}")
|
||||
print("=" * 60)
|
||||
print("❌ Validation failed!\n")
|
||||
print("Errors:")
|
||||
# Count actual errors (lines starting with " -")
|
||||
error_count = sum(1 for e in errors if e.strip().startswith('-'))
|
||||
for error in errors:
|
||||
# Don't add ❌ prefix to grouped entries (they already have it)
|
||||
if error.strip().startswith('❌'):
|
||||
print(error)
|
||||
else:
|
||||
print(error)
|
||||
print(f"\nTotal errors: {error_count}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+3
-1524
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
||||
# ComfyUI-Manager: Node Database (node_db)
|
||||
|
||||
This directory contains the JSON database files that power ComfyUI-Manager's legacy node registry system. While the manager is gradually transitioning to the online Custom Node Registry (CNR), these local JSON files continue to provide important metadata about custom nodes, models, and their integrations.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
The node_db directory is organized into several subdirectories, each serving a specific purpose:
|
||||
|
||||
- **dev/**: Development channel files with latest additions and experimental nodes
|
||||
- **legacy/**: Historical/legacy nodes that may require special handling
|
||||
- **new/**: New nodes that have passed initial verification but are still being evaluated
|
||||
- **forked/**: Forks of existing nodes with modifications
|
||||
- **tutorial/**: Example and tutorial nodes designed for learning purposes
|
||||
|
||||
## Core Database Files
|
||||
|
||||
Each subdirectory contains a standard set of JSON files:
|
||||
|
||||
- **custom-node-list.json**: Primary database of custom nodes with metadata
|
||||
- **extension-node-map.json**: Maps between extensions and individual nodes they provide
|
||||
- **model-list.json**: Catalog of models that can be downloaded through the manager
|
||||
- **alter-list.json**: Alternative implementations of nodes for compatibility or functionality
|
||||
- **github-stats.json**: GitHub repository statistics for node popularity metrics
|
||||
|
||||
## Database Schema
|
||||
|
||||
### custom-node-list.json
|
||||
```json
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"title": "Node display name",
|
||||
"name": "Repository name",
|
||||
"reference": "Original repository if forked",
|
||||
"files": ["GitHub URL or other source location"],
|
||||
"install_type": "git",
|
||||
"description": "Description of the node's functionality",
|
||||
"pip": ["optional pip dependencies"],
|
||||
"js": ["optional JavaScript files"],
|
||||
"tags": ["categorization tags"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### extension-node-map.json
|
||||
```json
|
||||
{
|
||||
"extension-id": [
|
||||
["list", "of", "node", "classes"],
|
||||
{
|
||||
"author": "Author name",
|
||||
"description": "Extension description",
|
||||
"nodename_pattern": "Optional regex pattern for node name matching"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Transition to Custom Node Registry (CNR)
|
||||
|
||||
This local database system is being progressively replaced by the online Custom Node Registry (CNR), which provides:
|
||||
- Real-time updates without manual JSON maintenance
|
||||
- Improved versioning support
|
||||
- Better security validation
|
||||
- Enhanced metadata
|
||||
|
||||
The Manager supports both systems simultaneously during the transition period.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- The database follows a channel-based architecture for different sources
|
||||
- Multiple database modes are supported: Channel, Local, and Remote
|
||||
- The system supports differential updates to minimize bandwidth usage
|
||||
- Security levels are enforced for different node installations based on source
|
||||
|
||||
## Usage in the Application
|
||||
|
||||
The Manager's backend uses these database files to:
|
||||
|
||||
1. Provide browsable lists of available nodes and models
|
||||
2. Resolve dependencies for installation
|
||||
3. Track updates and new versions
|
||||
4. Map node classes to their source repositories
|
||||
5. Assess risk levels for installation security
|
||||
|
||||
## Maintenance Scripts
|
||||
|
||||
Each subdirectory contains a `scan.sh` script that assists with:
|
||||
- Scanning repositories for new nodes
|
||||
- Updating metadata
|
||||
- Validating database integrity
|
||||
- Generating proper JSON structures
|
||||
|
||||
This database system enables a flexible, secure, and comprehensive management system for the ComfyUI ecosystem while the transition to CNR continues.
|
||||
+928
-8293
File diff suppressed because it is too large
Load Diff
+980
-12050
File diff suppressed because it is too large
Load Diff
+874
-4614
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
rm ~/.tmp/dev/*.py > /dev/null 2>&1
|
||||
python ../../scanner.py ~/.tmp/dev $*
|
||||
python ../../scanner.py ~/.tmp/dev
|
||||
|
||||
@@ -1,45 +1,5 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "Fossiel",
|
||||
"title": "ComfyUI-MultiGPU-Patched",
|
||||
"reference": "https://github.com/Fossiel/ComfyUI-MultiGPU-Patched",
|
||||
"files": [
|
||||
"https://github.com/Fossiel/ComfyUI-MultiGPU-Patched"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Patched fork of ComfyUI-MultiGPU providing universal .safetensors and GGUF multi-GPU distribution with DisTorch 2.0 engine, model-driven allocation options (bytes/ratio modes), WanVideoWrapper integration, and up to 10% faster GGUF inference. (Description by CC)"
|
||||
},
|
||||
{
|
||||
"author": "synchronicity-labs",
|
||||
"title": "ComfyUI Sync Lipsync Node",
|
||||
"reference": "https://github.com/synchronicity-labs/sync-comfyui",
|
||||
"files": [
|
||||
"https://github.com/synchronicity-labs/sync-comfyui"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "This custom node allows you to perform audio-video lip synchronization inside ComfyUI using a simple interface."
|
||||
},
|
||||
{
|
||||
"author": "joaomede",
|
||||
"title": "ComfyUI-Unload-Model-Fork",
|
||||
"reference": "https://github.com/joaomede/ComfyUI-Unload-Model-Fork",
|
||||
"files": [
|
||||
"https://github.com/joaomede/ComfyUI-Unload-Model-Fork"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "For unloading a model or all models, using the memory management that is already present in ComfyUI. Copied from [a/https://github.com/willblaschko/ComfyUI-Unload-Models](https://github.com/willblaschko/ComfyUI-Unload-Models) but without the unnecessary extra stuff."
|
||||
},
|
||||
{
|
||||
"author": "SanDiegoDude",
|
||||
"title": "ComfyUI-HiDream-Sampler [WIP]",
|
||||
"reference": "https://github.com/SanDiegoDude/ComfyUI-HiDream-Sampler",
|
||||
"files": [
|
||||
"https://github.com/SanDiegoDude/ComfyUI-HiDream-Sampler"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A collection of enhanced nodes for ComfyUI that provide powerful additional functionality to your workflows.\nNOTE: The files in the repo are not organized."
|
||||
},
|
||||
{
|
||||
"author": "PramaLLC",
|
||||
"title": "ComfyUI BEN - Background Erase Network",
|
||||
@@ -179,16 +139,6 @@
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A fork of KJNodes for ComfyUI.\nVarious quality of life -nodes for ComfyUI, mostly just visual stuff to improve usability"
|
||||
},
|
||||
{
|
||||
"author": "huixingyun",
|
||||
"title": "ComfyUI-SoundFlow",
|
||||
"reference": "https://github.com/huixingyun/ComfyUI-SoundFlow",
|
||||
"files": [
|
||||
"https://github.com/huixingyun/ComfyUI-SoundFlow"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "forked from https://github.com/fredconex/ComfyUI-SoundFlow (removed)"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+435
-9956
File diff suppressed because it is too large
Load Diff
+2720
-57865
File diff suppressed because it is too large
Load Diff
+561
-518
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,5 @@
|
||||
{
|
||||
"custom_nodes": [
|
||||
{
|
||||
"author": "Comfy-Org",
|
||||
"title": "ComfyUI React Extension Template",
|
||||
"reference": "https://github.com/Comfy-Org/ComfyUI-React-Extension-Template",
|
||||
"files": [
|
||||
"https://github.com/Comfy-Org/ComfyUI-React-Extension-Template"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A minimal template for creating React/TypeScript frontend extensions for ComfyUI, with complete boilerplate setup including internationalization and unit testing."
|
||||
},
|
||||
{
|
||||
"author": "comfyui-wiki",
|
||||
"title": "ComfyUI-i18n-demo",
|
||||
"reference": "https://github.com/comfyui-wiki/ComfyUI-i18n-demo",
|
||||
"files": [
|
||||
"https://github.com/comfyui-wiki/ComfyUI-i18n-demo"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "ComfyUI custom node develop i18n support demo "
|
||||
},
|
||||
{
|
||||
"author": "Suzie1",
|
||||
"title": "Guide To Making Custom Nodes in ComfyUI",
|
||||
@@ -331,56 +311,6 @@
|
||||
],
|
||||
"description": "ComfyUI node for creating some Turtle Graphic demos.",
|
||||
"install_type": "git-clone"
|
||||
},
|
||||
{
|
||||
"author": "cozy-comfyui",
|
||||
"title": "cozy_ex_dynamic",
|
||||
"reference": "https://github.com/cozy-comfyui/cozy_ex_dynamic",
|
||||
"files": [
|
||||
"https://github.com/cozy-comfyui/cozy_ex_dynamic"
|
||||
],
|
||||
"description": "Dynamic Node examples for ComfyUI",
|
||||
"install_type": "git-clone"
|
||||
},
|
||||
{
|
||||
"author": "Jonathon-Doran",
|
||||
"title": "remote-combo-demo",
|
||||
"reference": "https://github.com/Jonathon-Doran/remote-combo-demo",
|
||||
"files": [
|
||||
"https://github.com/Jonathon-Doran/remote-combo-demo"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "A minimal test suite demonstrating how remote COMBO inputs behave in ComfyUI, with and without force_input"
|
||||
},
|
||||
{
|
||||
"author": "J1mB091",
|
||||
"title": "ComfyUI-J1mB091 Custom Nodes",
|
||||
"reference": "https://github.com/J1mB091/ComfyUI-J1mB091",
|
||||
"files": [
|
||||
"https://github.com/J1mB091/ComfyUI-J1mB091"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Vibe Coded ComfyUI Custom Nodes"
|
||||
},
|
||||
{
|
||||
"author": "aiforhumans",
|
||||
"title": "XDev Nodes - Complete Toolkit",
|
||||
"reference": "https://github.com/aiforhumans/comfyui-xdev-nodes",
|
||||
"files": [
|
||||
"https://github.com/aiforhumans/comfyui-xdev-nodes"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Complete ComfyUI development toolkit with 8 professional nodes including VAE tools, universal type testing, and comprehensive debugging infrastructure."
|
||||
},
|
||||
{
|
||||
"author": "ganlvtech",
|
||||
"title": "ComfyUI-CustomModelPatcher",
|
||||
"reference": "https://github.com/ganlvtech/ComfyUI-CustomModelPatcher",
|
||||
"files": [
|
||||
"https://github.com/ganlvtech/ComfyUI-CustomModelPatcher"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Demonstrates GPU memory management techniques for external models like onnxruntime and InsightFace in ComfyUI by pre-allocating VRAM. (Description by CC)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-1086
File diff suppressed because it is too large
Load Diff
+40
-90
@@ -21,26 +21,24 @@ import cm_global
|
||||
import manager_downloader
|
||||
import folder_paths
|
||||
|
||||
manager_util.add_python_path_to_env()
|
||||
|
||||
import datetime as dt
|
||||
|
||||
if hasattr(dt, 'datetime'):
|
||||
from datetime import datetime as dt_datetime
|
||||
|
||||
import datetime
|
||||
if hasattr(datetime, 'datetime'):
|
||||
from datetime import datetime
|
||||
def current_timestamp():
|
||||
return dt_datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
||||
return datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
|
||||
else:
|
||||
# NOTE: Occurs in some Mac environments.
|
||||
import time
|
||||
logging.error(f"[ComfyUI-Manager] fallback timestamp mode\n datetime module is invalid: '{dt.__file__}'")
|
||||
|
||||
logging.error(f"[ComfyUI-Manager] fallback timestamp mode\n datetime module is invalid: '{datetime.__file__}'")
|
||||
def current_timestamp():
|
||||
return str(time.time()).split('.')[0]
|
||||
|
||||
security_check.security_check()
|
||||
|
||||
cm_global.pip_blacklist = {'torch', 'torchaudio', 'torchsde', 'torchvision'}
|
||||
cm_global.pip_downgrade_blacklist = ['torch', 'torchaudio', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
|
||||
manager_util.add_python_path_to_env()
|
||||
|
||||
cm_global.pip_blacklist = {'torch', 'torchsde', 'torchvision'}
|
||||
cm_global.pip_downgrade_blacklist = ['torch', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
|
||||
|
||||
|
||||
def skip_pip_spam(x):
|
||||
@@ -85,15 +83,7 @@ cm_global.register_api('cm.is_import_failed_extension', is_import_failed_extensi
|
||||
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
custom_nodes_base_path = folder_paths.get_folder_paths('custom_nodes')[0]
|
||||
|
||||
# Check for System User API availability (PR #10966)
|
||||
_has_system_user_api = hasattr(folder_paths, 'get_system_user_directory')
|
||||
|
||||
if _has_system_user_api:
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), '__manager'))
|
||||
else:
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
|
||||
|
||||
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
|
||||
manager_pip_overrides_path = os.path.join(manager_files_path, "pip_overrides.json")
|
||||
manager_pip_blacklist_path = os.path.join(manager_files_path, "pip_blacklist.list")
|
||||
restore_snapshot_path = os.path.join(manager_files_path, "startup-scripts", "restore-snapshot.json")
|
||||
@@ -126,14 +116,14 @@ def check_file_logging():
|
||||
|
||||
read_config()
|
||||
read_uv_mode()
|
||||
security_check.security_check()
|
||||
check_file_logging()
|
||||
|
||||
cm_global.pip_overrides = {}
|
||||
|
||||
cm_global.pip_overrides = {'numpy': 'numpy<2', 'ultralytics': 'ultralytics==8.3.40'}
|
||||
if os.path.exists(manager_pip_overrides_path):
|
||||
with open(manager_pip_overrides_path, 'r', encoding="UTF-8", errors="ignore") as json_file:
|
||||
cm_global.pip_overrides = json.load(json_file)
|
||||
cm_global.pip_overrides['numpy'] = 'numpy<2'
|
||||
cm_global.pip_overrides['ultralytics'] = 'ultralytics==8.3.40' # for security
|
||||
|
||||
|
||||
if os.path.exists(manager_pip_blacklist_path):
|
||||
@@ -346,12 +336,7 @@ try:
|
||||
log_file.write(message)
|
||||
else:
|
||||
log_file.write(f"[{timestamp}] {message}")
|
||||
|
||||
try:
|
||||
log_file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_file.flush()
|
||||
self.last_char = message if message == '' else message[-1]
|
||||
|
||||
if not file_only:
|
||||
@@ -364,19 +349,13 @@ try:
|
||||
original_stderr.flush()
|
||||
|
||||
def flush(self):
|
||||
try:
|
||||
log_file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
log_file.flush()
|
||||
|
||||
with std_log_lock:
|
||||
try:
|
||||
if self.is_stdout:
|
||||
original_stdout.flush()
|
||||
else:
|
||||
original_stderr.flush()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if self.is_stdout:
|
||||
original_stdout.flush()
|
||||
else:
|
||||
original_stderr.flush()
|
||||
|
||||
def close(self):
|
||||
self.flush()
|
||||
@@ -455,32 +434,16 @@ except Exception as e:
|
||||
|
||||
|
||||
def ensure_dependencies():
|
||||
requirements_path = os.path.join(os.path.dirname(__file__), "requirements.txt")
|
||||
try:
|
||||
import git # noqa: F401
|
||||
import toml # noqa: F401
|
||||
import rich # noqa: F401
|
||||
import chardet # noqa: F401
|
||||
from importlib.metadata import version
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
# Check the declared version before loading the native extension, so an
|
||||
# older nh3 can be upgraded without loading its binary into this process.
|
||||
with open(requirements_path) as requirements:
|
||||
for line in requirements:
|
||||
line = re.split(r'\s+#', line.strip(), maxsplit=1)[0]
|
||||
if not line or line.startswith(('#', '-')):
|
||||
continue
|
||||
nh3_requirement = Requirement(line)
|
||||
if nh3_requirement.name.lower() == 'nh3':
|
||||
break
|
||||
else:
|
||||
raise ValueError('requirements.txt does not declare nh3')
|
||||
if version('nh3') not in nh3_requirement.specifier:
|
||||
raise ModuleNotFoundError(str(nh3_requirement))
|
||||
import nh3 # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
print("## ComfyUI-Manager: installing dependencies.")
|
||||
my_path = os.path.dirname(__file__)
|
||||
requirements_path = os.path.join(my_path, "requirements.txt")
|
||||
|
||||
print("## ComfyUI-Manager: installing dependencies. (GitPython)")
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -488,7 +451,7 @@ def ensure_dependencies():
|
||||
try:
|
||||
subprocess.check_output(manager_util.make_pip_cmd(['install', '--user', '-r', requirements_path]))
|
||||
except subprocess.CalledProcessError:
|
||||
print("## [ERROR] ComfyUI-Manager: Failed to install required packages in the correct Python environment. Please install requirements.txt manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
|
||||
print("## [ERROR] ComfyUI-Manager: Failed to install the GitPython package in the correct Python environment. Please install it manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
|
||||
|
||||
try:
|
||||
print("## ComfyUI-Manager: installing dependencies done.")
|
||||
@@ -543,8 +506,7 @@ check_bypass_ssl()
|
||||
|
||||
# Perform install
|
||||
processed_install = set()
|
||||
# Use manager_files_path for consistency (fixes path inconsistency bug)
|
||||
script_list_path = os.path.join(manager_files_path, "startup-scripts", "install-scripts.txt")
|
||||
script_list_path = os.path.join(folder_paths.user_directory, "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
|
||||
|
||||
@@ -629,12 +591,6 @@ if os.path.exists(restore_snapshot_path):
|
||||
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
|
||||
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
|
||||
|
||||
from comfy.cli_args import args
|
||||
|
||||
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
|
||||
new_env['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = str(
|
||||
allow_flagged or manager_util.is_loopback_listener(args.listen)
|
||||
).lower()
|
||||
cmd_str = [sys.executable, cm_cli_path, 'restore-snapshot', restore_snapshot_path]
|
||||
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
|
||||
|
||||
@@ -662,7 +618,6 @@ def execute_lazy_install_script(repo_path, executable):
|
||||
lines = manager_util.robust_readlines(requirements_path)
|
||||
for line in lines:
|
||||
package_name = remap_pip_package(line.strip())
|
||||
package_name = package_name.split('#')[0].strip()
|
||||
if package_name and not is_installed(package_name):
|
||||
if '--index-url' in package_name:
|
||||
s = package_name.split('--index-url')
|
||||
@@ -683,18 +638,9 @@ 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, status=''):
|
||||
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path):
|
||||
import uuid
|
||||
import shutil
|
||||
from comfy.cli_args import args
|
||||
|
||||
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
|
||||
if not manager_util.is_cnr_install_allowed(status or 'NodeVersionStatusFlagged', allow_flagged, args.listen):
|
||||
if not status:
|
||||
logging.error("Cannot execute reserved CNR switch for '%s': stored Registry status is missing. Request the installation again so its current status can be checked.", target)
|
||||
else:
|
||||
logging.error(manager_util.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
|
||||
return False
|
||||
|
||||
# 1. download
|
||||
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
|
||||
@@ -742,7 +688,13 @@ 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
|
||||
|
||||
def execute_migration(moves):
|
||||
import shutil
|
||||
for x in moves:
|
||||
if os.path.exists(x[0]) and not os.path.exists(x[1]):
|
||||
shutil.move(x[0], x[1])
|
||||
print(f"[ComfyUI-Manager] MIGRATION: '{x[0]}' -> '{x[1]}'")
|
||||
|
||||
|
||||
script_executed = False
|
||||
@@ -799,9 +751,11 @@ def execute_startup_script():
|
||||
execute_lazy_install_script(script[0], script[2])
|
||||
|
||||
elif script[1] == "#LAZY-CNR-SWITCH-SCRIPT":
|
||||
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])
|
||||
execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6])
|
||||
execute_lazy_install_script(script[3], script[7])
|
||||
|
||||
elif script[1] == "#LAZY-MIGRATION":
|
||||
execute_migration(script[2])
|
||||
|
||||
elif script[1] == "#LAZY-DELETE-NODEPACK":
|
||||
execute_lazy_delete(script[2])
|
||||
@@ -839,11 +793,7 @@ def execute_startup_script():
|
||||
|
||||
|
||||
# Check if script_list_path exists
|
||||
# Block startup-scripts on old ComfyUI (security measure)
|
||||
if not _has_system_user_api:
|
||||
if os.path.exists(script_list_path):
|
||||
print("[ComfyUI-Manager] Startup scripts blocked on old ComfyUI version.")
|
||||
elif os.path.exists(script_list_path):
|
||||
if os.path.exists(script_list_path):
|
||||
execute_startup_script()
|
||||
|
||||
|
||||
|
||||
+3
-4
@@ -1,14 +1,13 @@
|
||||
[project]
|
||||
name = "comfyui-manager"
|
||||
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
|
||||
version = "3.42"
|
||||
version = "3.30.9"
|
||||
license = { file = "LICENSE.txt" }
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet", "packaging", "nh3>=0.3.7"]
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-client==0.4.0", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
|
||||
# Used by Comfy Registry https://registry.comfy.org
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "drltdata"
|
||||
|
||||
+2
-4
@@ -1,13 +1,11 @@
|
||||
GitPython
|
||||
PyGithub
|
||||
matrix-nio
|
||||
matrix-client==0.4.0
|
||||
transformers
|
||||
huggingface-hub
|
||||
huggingface-hub>0.20
|
||||
typer
|
||||
rich
|
||||
typing-extensions
|
||||
toml
|
||||
uv
|
||||
chardet
|
||||
packaging
|
||||
nh3>=0.3.7
|
||||
|
||||
+85
-1211
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ git clone https://github.com/ltdrdata/ComfyUI-Manager comfyui-manager
|
||||
cd ..
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu121
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r custom_nodes/comfyui-manager/requirements.txt
|
||||
cd ..
|
||||
|
||||
@@ -4,7 +4,7 @@ git clone https://github.com/ltdrdata/ComfyUI-Manager comfyui-manager
|
||||
cd ..
|
||||
python -m venv venv
|
||||
call venv/Scripts/activate
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu121
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r custom_nodes/comfyui-manager/requirements.txt
|
||||
cd ..
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
{
|
||||
"note": "JS<->Python sanitize_url differential case matrix. STRING inputs only: Python str() and JS String() disagree for non-strings, so feeding them would report divergence for something that is not a defect. See the docstring of tests/test_url_sanitize_parity.py.",
|
||||
"provenance": "Handed over by gm3-review: 35 cases built to clear the WI-112 model-manager port, plus 22 built for the WI-120 review (scheme grammar, protocol-relative with userinfo, colons after a delimiter, unicode spaces and RTL override, degenerate schemes, a 500-char path). All 57 agreed on both implementations when handed over -- they are coverage, not known failures.",
|
||||
"type_coercion_warning": {
|
||||
"why": "Python str() and JS String() are NOT equivalent for non-strings. Feeding non-strings into the differential matrix produces FALSE divergence - a guard RED for a non-bug.",
|
||||
"measured": {
|
||||
"1.0": {
|
||||
"py": "1.0",
|
||||
"js": "1"
|
||||
},
|
||||
"True": {
|
||||
"py": "True",
|
||||
"js": "true"
|
||||
},
|
||||
"False": {
|
||||
"py": "False",
|
||||
"js": "false"
|
||||
},
|
||||
"[1,2]": {
|
||||
"py": "[1, 2]",
|
||||
"js": "1,2"
|
||||
},
|
||||
"{}": {
|
||||
"py": "{}",
|
||||
"js": "[object Object]"
|
||||
}
|
||||
},
|
||||
"aligned": {
|
||||
"0": "both '0'",
|
||||
"None/null": "both '#' - explicit null handling in both impls"
|
||||
},
|
||||
"recommendation": "Scope the matrix to STRING inputs and say so in the guard docstring; url is always a string off the JSON payload in practice."
|
||||
},
|
||||
"shared_matrix": [
|
||||
"javascript:a",
|
||||
"JaVaScRiPt:a",
|
||||
"java\tscript:a",
|
||||
"java\nscript:a",
|
||||
"\u0001javascript:a",
|
||||
" javascript:a",
|
||||
"data:text/html,x",
|
||||
"vbscript:a",
|
||||
"file:///etc/passwd",
|
||||
"about:blank",
|
||||
"blob:http://x",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"http://e.com",
|
||||
"https://e.com/a?b=1&c=2",
|
||||
"HTTPS://E.COM",
|
||||
"/rel/path",
|
||||
"rel",
|
||||
"#anchor",
|
||||
"//proto/x",
|
||||
"a&b/c",
|
||||
"x&y?z=1",
|
||||
"e.com:8080/p",
|
||||
"foo:bar",
|
||||
":alert(1)",
|
||||
"",
|
||||
" ",
|
||||
"https://e.com/x\" onmouseover=\"y",
|
||||
"https://evil.example/a onmouseover=alert(1) x",
|
||||
"\\\\evil.com/x",
|
||||
"/\\evil.com",
|
||||
"\u0000javascript:a",
|
||||
"j\u0000avascript:a",
|
||||
"coap+tcp://h/x",
|
||||
"x-scheme://h",
|
||||
"a.b://h",
|
||||
"a-b://h",
|
||||
"HTTP+S://h",
|
||||
"//user:pass@evil.example/x",
|
||||
"#a:b",
|
||||
"?a=b:c",
|
||||
"/p:q",
|
||||
"http://h/#f:g",
|
||||
"http ://h",
|
||||
"javascript :a",
|
||||
"javascript:a",
|
||||
"http://h",
|
||||
"http://h/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"http://h/evil",
|
||||
"http:",
|
||||
"https:",
|
||||
"http:/h",
|
||||
":",
|
||||
"::",
|
||||
"a::b",
|
||||
" /rel/path ",
|
||||
" rel ",
|
||||
"\t#anchor\n",
|
||||
" http://h/x ",
|
||||
"\thttps://h/x\t",
|
||||
"http://h/x",
|
||||
"/rel/path",
|
||||
"https://h/x",
|
||||
"",
|
||||
" /rel ",
|
||||
"http://h/x
",
|
||||
"
http://h/x",
|
||||
"http://h/x\u001c",
|
||||
"http://h/x\u001d",
|
||||
"http://h/x\u001e",
|
||||
"http://h/x\u001f",
|
||||
"/rel/path
",
|
||||
"/rel/path\u001c",
|
||||
"https://h/x
",
|
||||
"http://h/x ",
|
||||
" http://h/x",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x
",
|
||||
"http://h/x
",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
" /rel",
|
||||
" /rel "
|
||||
],
|
||||
"gap_cases_found_by_enforcement": {
|
||||
"why": "Found by tests/test_url_sanitize_parity.py's own perturbation test, not by review: perturbing the JS scheme-less branch from `return raw.trim()` to `return raw` produced ZERO divergences against the handed-over 57, because every scheme-less case in that set is already trim-clean. The two trim call sites in sanitize_url were therefore unpinned on both sides.",
|
||||
"cases": [
|
||||
" /rel/path ",
|
||||
" rel ",
|
||||
"\t#anchor\n",
|
||||
" http://h/x ",
|
||||
"\thttps://h/x\t"
|
||||
]
|
||||
},
|
||||
"unicode_whitespace_divergence": {
|
||||
"why": "The 62-case matrix had no BOM or unicode-whitespace input, so the two trim call sites in sanitize_url were compared only over inputs whose edges are ASCII. They are NOT equivalent: String.prototype.trim() and str.strip() disagree on six codepoints.",
|
||||
"measured": {
|
||||
"js_strips_python_keeps": [
|
||||
"U+FEFF"
|
||||
],
|
||||
"python_strips_js_keeps": [
|
||||
"U+001C",
|
||||
"U+001D",
|
||||
"U+001E",
|
||||
"U+001F",
|
||||
"U+0085"
|
||||
],
|
||||
"method": "enumerated every codepoint below U+110000 on both sides: chr(c).isspace() in Python vs /\\s/-equivalent String.trim() in node; 24 shared, 6 divergent"
|
||||
},
|
||||
"example": "'http://h/x<U+FEFF>' -> python 'http://h/x<U+FEFF>', js 'http://h/x'",
|
||||
"resolution": "ALIGNED, not accepted. glob/manager_util.py is the declared source of truth for this pair (see test_the_guard_goes_red_when_either_allow_list_gains_a_scheme, which names it), so js/common.js stopped delegating to trim() and spells Python's str.strip() set out as URL_TRIM. The divergence is therefore a REGRESSION SHAPE this matrix now catches, not a gap_case.",
|
||||
"scope": "The trim only shapes the RETURNED string for an already-accepted URL. Scheme acceptance is decided on `probe`, which both sides build identically, so no case here changes what is allowed."
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Test-runner guard for the GOAL #32 tests/ modules.
|
||||
|
||||
WHY THIS FILE EXISTS (collection hazard, not test logic):
|
||||
|
||||
The repo root contains ``__init__.py`` — the ComfyUI plugin entrypoint —
|
||||
which at import time appends ``glob/`` to sys.path and imports
|
||||
``manager_server`` (which needs ``folder_paths`` / ``comfy.cli_args`` /
|
||||
a constructed ``PromptServer``). pytest 8 collects any ancestor
|
||||
directory that carries an ``__init__.py`` as a ``Package`` node and
|
||||
IMPORTS that ``__init__.py`` during test setup (observed module name:
|
||||
``__init__``). Outside a live ComfyUI process that import can never
|
||||
succeed, so EVERY test under tests/ errors at setup — including the
|
||||
pre-existing tests/test_csrf_content_type_helper.py — whenever pytest's
|
||||
rootdir ends up at or above the repo root (e.g. running inside a git
|
||||
worktree nested under the parent checkout).
|
||||
|
||||
The guard below pre-seeds ``sys.modules`` with an inert stub whose
|
||||
``__file__`` matches the real path, so pytest's
|
||||
``import_path(<repo-root>/__init__.py)`` resolves to the stub without
|
||||
executing the plugin entrypoint. Conftest files load before the setup
|
||||
phase, so the stub is always in place in time. This does NOT touch
|
||||
production code and does NOT alter what the tests import themselves
|
||||
(they use AST-extraction / subprocess isolation per the
|
||||
tests/test_csrf_content_type_helper.py precedent — ``glob/`` is never
|
||||
added to the runner's sys.path).
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_ROOT_INIT = _REPO_ROOT / "__init__.py"
|
||||
|
||||
if _ROOT_INIT.exists() and "__init__" not in sys.modules:
|
||||
_stub = types.ModuleType("__init__")
|
||||
_stub.__file__ = str(_ROOT_INIT)
|
||||
_stub.__doc__ = (
|
||||
"Inert stand-in for the ComfyUI-Manager plugin entrypoint; "
|
||||
"see tests/conftest.py for rationale."
|
||||
)
|
||||
sys.modules["__init__"] = _stub
|
||||
@@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup_e2e_env.sh — E2E environment builder for GOAL #60 (T1, spec §2).
|
||||
#
|
||||
# Builds the DISPOSABLE test ComfyUI root used by
|
||||
# tests/e2e/test_e2e_install_flags.py. ENV BUILD ONLY: donor steps 4-5
|
||||
# (editable pip install of the Manager + custom_nodes symlink) are
|
||||
# deliberately DROPPED — the Manager is mounted via `git worktree add`
|
||||
# by the `mount_worktree` session fixture in the test module, which is
|
||||
# the SOLE owner of mount create/reuse/teardown (spec §2 T1
|
||||
# single-ownership rule). This script never touches the Manager repo.
|
||||
#
|
||||
# Idempotent: re-run is a no-op when the marker + key artifacts exist
|
||||
# (E2E-SC-01).
|
||||
#
|
||||
# Input env vars:
|
||||
# E2E_COMFYUI_ROOT — target directory (default: mktemp -d)
|
||||
# COMFYUI_BRANCH — ComfyUI clone ref (default: master; Q-2)
|
||||
# PYTHON — python executable for version probe (default: python3)
|
||||
#
|
||||
# Output (last line of stdout):
|
||||
# E2E_COMFYUI_ROOT=/path/to/environment
|
||||
#
|
||||
# Exit: 0=success, 1=failure
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMFYUI_REPO="https://github.com/comfyanonymous/ComfyUI.git"
|
||||
PYTORCH_CPU_INDEX="https://download.pytorch.org/whl/cpu"
|
||||
# Minimal seed config. use_uv=false: the venv is seeded with pip
|
||||
# (`uv venv --seed`), so the Manager's make_pip_cmd resolves to
|
||||
# `<venv-python> -m pip` and the suite's pip-uninstall hygiene helpers
|
||||
# work without uv on PATH at server runtime. The install flags are NOT
|
||||
# seeded here — stage_flags.sh stages them per launch identity (SC-06).
|
||||
CONFIG_INI_CONTENT="[default]
|
||||
file_logging = false
|
||||
use_uv = false"
|
||||
|
||||
log() { echo "[setup_e2e] $*"; }
|
||||
err() { echo "[setup_e2e] ERROR: $*" >&2; }
|
||||
die() { err "$@"; exit 1; }
|
||||
|
||||
validate_prerequisites() {
|
||||
local py="${PYTHON:-python3}"
|
||||
local missing=()
|
||||
command -v git >/dev/null 2>&1 || missing+=("git")
|
||||
command -v uv >/dev/null 2>&1 || missing+=("uv")
|
||||
command -v "$py" >/dev/null 2>&1 || missing+=("$py")
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
die "Missing prerequisites: ${missing[*]}"
|
||||
fi
|
||||
local py_version major minor
|
||||
py_version=$("$py" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
||||
major="${py_version%%.*}"
|
||||
minor="${py_version##*.}"
|
||||
if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 9 ]]; }; then
|
||||
die "Python 3.9+ required, found $py_version"
|
||||
fi
|
||||
log "Prerequisites OK (python=$py_version)"
|
||||
}
|
||||
|
||||
check_already_setup() {
|
||||
local root="$1"
|
||||
if [[ -f "$root/.e2e_setup_complete" ]] \
|
||||
&& [[ -d "$root/comfyui" ]] \
|
||||
&& [[ -d "$root/venv" ]] \
|
||||
&& [[ -f "$root/comfyui/user/__manager/config.ini" ]]; then
|
||||
log "Environment already set up at $root (marker exists). Skipping. (E2E-SC-01 idempotence)"
|
||||
echo "E2E_COMFYUI_ROOT=$root"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
verify_setup() {
|
||||
local root="$1"
|
||||
local venv_py="$root/venv/bin/python"
|
||||
local errors=0
|
||||
log "Running verification checks..."
|
||||
[[ -f "$root/comfyui/main.py" ]] || { err "Verification FAIL: comfyui/main.py not found"; ((errors+=1)); }
|
||||
[[ -x "$venv_py" ]] || { err "Verification FAIL: venv python not executable"; ((errors+=1)); }
|
||||
[[ -f "$root/comfyui/user/__manager/config.ini" ]] || { err "Verification FAIL: config.ini not found"; ((errors+=1)); }
|
||||
# venv must carry pip (uv venv --seed) — the suite's hygiene helpers
|
||||
# and the Manager's reservation-consuming boot both call `-m pip`.
|
||||
if ! "$venv_py" -m pip --version >/dev/null 2>&1; then
|
||||
err "Verification FAIL: venv pip not available"
|
||||
((errors+=1))
|
||||
fi
|
||||
# comfy is a local package inside the ComfyUI checkout
|
||||
if ! PYTHONPATH="$root/comfyui" "$venv_py" -c "import comfy" 2>/dev/null; then
|
||||
err "Verification FAIL: 'import comfy' failed"
|
||||
((errors+=1))
|
||||
fi
|
||||
# [D2] half-check at setup time: the Manager must NOT be pip-installed
|
||||
# into this venv (the worktree mount is the only delivery mechanism).
|
||||
if "$venv_py" -m pip show comfyui-manager >/dev/null 2>&1 \
|
||||
|| "$venv_py" -m pip show ComfyUI-Manager >/dev/null 2>&1; then
|
||||
err "Verification FAIL: a pip-installed Manager exists in the venv (wrong layout for [D2])"
|
||||
((errors+=1))
|
||||
fi
|
||||
if [[ "$errors" -gt 0 ]]; then
|
||||
die "Verification failed with $errors error(s)"
|
||||
fi
|
||||
log "Verification OK: all checks passed"
|
||||
}
|
||||
|
||||
# ===== Main =====
|
||||
validate_prerequisites
|
||||
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
COMFYUI_BRANCH="${COMFYUI_BRANCH:-master}"
|
||||
|
||||
CREATED_BY_US=false
|
||||
if [[ -z "${E2E_COMFYUI_ROOT:-}" ]]; then
|
||||
E2E_COMFYUI_ROOT="$(mktemp -d -t e2e_comfyui_XXXXXX)"
|
||||
CREATED_BY_US=true
|
||||
log "Created E2E_COMFYUI_ROOT=$E2E_COMFYUI_ROOT"
|
||||
else
|
||||
mkdir -p "$E2E_COMFYUI_ROOT"
|
||||
log "Using E2E_COMFYUI_ROOT=$E2E_COMFYUI_ROOT"
|
||||
fi
|
||||
|
||||
check_already_setup "$E2E_COMFYUI_ROOT"
|
||||
|
||||
cleanup_on_failure() {
|
||||
local exit_code=$?
|
||||
if [[ "$exit_code" -ne 0 ]] && [[ "$CREATED_BY_US" == "true" ]]; then
|
||||
err "Setup failed. Cleaning up $E2E_COMFYUI_ROOT"
|
||||
rm -rf "$E2E_COMFYUI_ROOT"
|
||||
fi
|
||||
}
|
||||
trap cleanup_on_failure EXIT
|
||||
|
||||
log "Step 1/5: Cloning ComfyUI (branch=$COMFYUI_BRANCH)..."
|
||||
if [[ -d "$E2E_COMFYUI_ROOT/comfyui/.git" ]]; then
|
||||
log " ComfyUI already cloned, skipping"
|
||||
else
|
||||
git clone --depth=1 --branch "$COMFYUI_BRANCH" "$COMFYUI_REPO" "$E2E_COMFYUI_ROOT/comfyui"
|
||||
fi
|
||||
|
||||
log "Step 2/5: Creating virtual environment (seeded with pip)..."
|
||||
if [[ -d "$E2E_COMFYUI_ROOT/venv" ]]; then
|
||||
log " venv already exists, skipping"
|
||||
else
|
||||
uv venv --seed "$E2E_COMFYUI_ROOT/venv"
|
||||
fi
|
||||
VENV_PY="$E2E_COMFYUI_ROOT/venv/bin/python"
|
||||
|
||||
log "Step 3/5: Installing ComfyUI dependencies (CPU-only torch index)..."
|
||||
uv pip install \
|
||||
--python "$VENV_PY" \
|
||||
-r "$E2E_COMFYUI_ROOT/comfyui/requirements.txt" \
|
||||
--extra-index-url "$PYTORCH_CPU_INDEX"
|
||||
|
||||
log "Step 4/5: Writing seed config.ini + HOME isolation dirs..."
|
||||
mkdir -p "$E2E_COMFYUI_ROOT/comfyui/user/__manager"
|
||||
echo "$CONFIG_INI_CONTENT" > "$E2E_COMFYUI_ROOT/comfyui/user/__manager/config.ini"
|
||||
mkdir -p "$E2E_COMFYUI_ROOT/home/.config"
|
||||
mkdir -p "$E2E_COMFYUI_ROOT/home/.local/share"
|
||||
mkdir -p "$E2E_COMFYUI_ROOT/logs"
|
||||
mkdir -p "$E2E_COMFYUI_ROOT/comfyui/custom_nodes"
|
||||
|
||||
log "Step 5/5: Verifying setup..."
|
||||
verify_setup "$E2E_COMFYUI_ROOT"
|
||||
|
||||
# Marker written ONLY after verification passes (E2E-SC-01)
|
||||
date -Iseconds > "$E2E_COMFYUI_ROOT/.e2e_setup_complete"
|
||||
|
||||
trap - EXIT
|
||||
log "Setup complete."
|
||||
echo "E2E_COMFYUI_ROOT=$E2E_COMFYUI_ROOT"
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# stage_flags.sh — Per-launch-identity config staging (T4, spec §1.4).
|
||||
#
|
||||
# Analog of the donor's start_comfyui_strict.sh config patching, split
|
||||
# out as a pure staging script (launch is a separate step; the pytest
|
||||
# fixture owns restore+delete of the backup at teardown — donor
|
||||
# symmetry, spec §1.4).
|
||||
#
|
||||
# Modes (arg $1):
|
||||
# deny — REMOVE both flag keys (L-D: flags ABSENT also live-proves
|
||||
# "missing key reads false")
|
||||
# allow — set allow_git_url_install = true AND allow_pip_install = true
|
||||
# (L-A / L-P)
|
||||
#
|
||||
# Backup: config.ini.before-flags, created ONLY if not already present
|
||||
# (crashed-run-safe — preserves the true baseline across crashed runs).
|
||||
# Restore + DELETE of the backup happens in the pytest fixture teardown,
|
||||
# NOT here (E2E-SC-06/07).
|
||||
#
|
||||
# Input env vars:
|
||||
# E2E_COMFYUI_ROOT — (required)
|
||||
#
|
||||
# Exit: 0=staged, 1=failure
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-}"
|
||||
|
||||
log() { echo "[stage_flags] $*"; }
|
||||
err() { echo "[stage_flags] ERROR: $*" >&2; }
|
||||
die() { err "$@"; exit 1; }
|
||||
|
||||
[[ -n "${E2E_COMFYUI_ROOT:-}" ]] || die "E2E_COMFYUI_ROOT is not set"
|
||||
[[ "$MODE" == "deny" || "$MODE" == "allow" ]] || die "usage: stage_flags.sh deny|allow"
|
||||
|
||||
CONFIG="$E2E_COMFYUI_ROOT/comfyui/user/__manager/config.ini"
|
||||
BACKUP="$CONFIG.before-flags"
|
||||
|
||||
[[ -f "$CONFIG" ]] || die "config not found at $CONFIG (run setup_e2e_env.sh first)"
|
||||
|
||||
# Backup ONLY if absent (crashed-run-safe; SC-06)
|
||||
if [[ ! -f "$BACKUP" ]]; then
|
||||
cp "$CONFIG" "$BACKUP"
|
||||
log "Backed up original config to $BACKUP"
|
||||
else
|
||||
log "Backup already present at $BACKUP (preserving original baseline)"
|
||||
fi
|
||||
|
||||
stage_key() {
|
||||
local key="$1" value="$2"
|
||||
if grep -qE "^${key}\s*=" "$CONFIG"; then
|
||||
sed -i -E "s|^${key}\s*=.*|${key} = ${value}|" "$CONFIG"
|
||||
else
|
||||
# The append-after target MUST exist, otherwise the sed below is a
|
||||
# silent no-op and the flag never lands (false-PASS for the allow arm).
|
||||
grep -qE "^\[default\]" "$CONFIG" \
|
||||
|| die "no [default] section in $CONFIG — cannot stage '${key}' (would silently no-op)"
|
||||
sed -i -E "/^\[default\]/a ${key} = ${value}" "$CONFIG"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_key() {
|
||||
local key="$1"
|
||||
sed -i -E "/^${key}\s*=/d" "$CONFIG"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
deny)
|
||||
remove_key "allow_git_url_install"
|
||||
remove_key "allow_pip_install"
|
||||
log "Staged DENY config (both flags ABSENT — missing key reads false)"
|
||||
;;
|
||||
allow)
|
||||
stage_key "allow_git_url_install" "true"
|
||||
stage_key "allow_pip_install" "true"
|
||||
log "Staged ALLOW config (both flags = true)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# The staged value takes effect ONLY on the NEXT launch (restart-only
|
||||
# cached_config — by construction, no hot-reload assumption; SC-06).
|
||||
log "Staged config at $CONFIG (effective on next launch)"
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# start_comfyui.sh — Foreground-blocking ComfyUI launcher (T2, spec §1.3).
|
||||
#
|
||||
# Starts ComfyUI in the background and blocks until the server answers
|
||||
# GET /system_stats (or timeout). Deltas vs the donor script (binding,
|
||||
# spec §1.3):
|
||||
# - NO --enable-manager: the Manager is a custom-node-style plugin
|
||||
# activated by ComfyUI's custom_nodes scan of the worktree mount.
|
||||
# - NO COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS: that belt does not
|
||||
# exist in this repo (Q-6 verified); watchdog row E2E-SC-42 covers
|
||||
# the residual.
|
||||
# - --listen is ALWAYS passed explicitly (LISTEN env): the predicate
|
||||
# under test is request-time `flag AND is_loopback(args.listen)` —
|
||||
# the listener value is LOAD-BEARING.
|
||||
# - Per-launch log isolation (E2E-SC-04, MANDATORY): each launch
|
||||
# identity writes a FRESH log file comfyui.<port>.<launch-id>.log,
|
||||
# so a deny-copy substring from L-D is unfindable by L-A/R-A log
|
||||
# assertions (stale-substring false-PASS class).
|
||||
# - Readiness = poll GET /system_stats == 200; child exit code 0
|
||||
# during the wait = Manager-triggered restart -> KEEP polling;
|
||||
# non-zero exit -> fail fast with log tail.
|
||||
#
|
||||
# Input env vars:
|
||||
# E2E_COMFYUI_ROOT — (required) root from setup_e2e_env.sh
|
||||
# PORT — listen port (default: 8189)
|
||||
# TIMEOUT — max seconds to readiness (default: 120)
|
||||
# LISTEN — listener address (default: 127.0.0.1)
|
||||
# LAUNCH_ID — launch identity tag for the log file (default: default)
|
||||
#
|
||||
# Output (last line on success):
|
||||
# COMFYUI_PID=<pid> PORT=<port> LOG_FILE=<path>
|
||||
#
|
||||
# Exit: 0=ready, 1=timeout/failure
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-8189}"
|
||||
TIMEOUT="${TIMEOUT:-120}"
|
||||
LISTEN="${LISTEN:-127.0.0.1}"
|
||||
LAUNCH_ID="${LAUNCH_ID:-default}"
|
||||
|
||||
log() { echo "[start_comfyui] $*"; }
|
||||
err() { echo "[start_comfyui] ERROR: $*" >&2; }
|
||||
die() { err "$@"; exit 1; }
|
||||
|
||||
[[ -n "${E2E_COMFYUI_ROOT:-}" ]] || die "E2E_COMFYUI_ROOT is not set"
|
||||
[[ -d "$E2E_COMFYUI_ROOT/comfyui" ]] || die "ComfyUI not found at $E2E_COMFYUI_ROOT/comfyui"
|
||||
[[ -x "$E2E_COMFYUI_ROOT/venv/bin/python" ]] || die "venv python not found"
|
||||
[[ -f "$E2E_COMFYUI_ROOT/.e2e_setup_complete" ]] || die "Setup marker not found. Run setup_e2e_env.sh first."
|
||||
|
||||
PY="$E2E_COMFYUI_ROOT/venv/bin/python"
|
||||
COMFY_DIR="$E2E_COMFYUI_ROOT/comfyui"
|
||||
LOG_DIR="$E2E_COMFYUI_ROOT/logs"
|
||||
LOG_FILE="$LOG_DIR/comfyui.${PORT}.${LAUNCH_ID}.log"
|
||||
# Port-namespaced PID file (donor WI-CC incident: shared PID file caused
|
||||
# a cross-port kill).
|
||||
PID_FILE="$LOG_DIR/comfyui.${PORT}.pid"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# --- Pre-launch port clear ---
|
||||
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
|
||||
log "Port $PORT is in use. Attempting to stop existing process..."
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
OLD_PID="$(cat "$PID_FILE")"
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
|
||||
pkill -f "main\\.py.*--port $PORT" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
|
||||
die "Port $PORT is still in use after cleanup attempt"
|
||||
fi
|
||||
log "Port $PORT cleared."
|
||||
fi
|
||||
|
||||
# --- Launch (FRESH per-launch log file) ---
|
||||
log "Starting ComfyUI on port $PORT (listen=$LISTEN, launch_id=$LAUNCH_ID)..."
|
||||
: > "$LOG_FILE"
|
||||
|
||||
PYTHONUNBUFFERED=1 \
|
||||
HOME="$E2E_COMFYUI_ROOT/home" \
|
||||
nohup "$PY" -u "$COMFY_DIR/main.py" \
|
||||
--cpu \
|
||||
--port "$PORT" \
|
||||
--listen "$LISTEN" \
|
||||
> "$LOG_FILE" 2>&1 &
|
||||
COMFYUI_PID=$!
|
||||
|
||||
echo "$COMFYUI_PID" > "$PID_FILE"
|
||||
log "ComfyUI PID=$COMFYUI_PID, log=$LOG_FILE"
|
||||
|
||||
# --- Block until ready: poll /system_stats (restart-tolerant) ---
|
||||
log "Waiting up to ${TIMEOUT}s for ComfyUI readiness (GET /system_stats)..."
|
||||
DEADLINE=$(( $(date +%s) + TIMEOUT ))
|
||||
READY=0
|
||||
while [[ "$(date +%s)" -lt "$DEADLINE" ]]; do
|
||||
if curl -sf --max-time 2 "http://127.0.0.1:${PORT}/system_stats" >/dev/null 2>&1; then
|
||||
READY=1
|
||||
break
|
||||
fi
|
||||
# Child exit handling: exit 0 = Manager-triggered restart -> keep
|
||||
# polling (a restarted process will bind the port); non-zero -> fail fast.
|
||||
if ! kill -0 "$COMFYUI_PID" 2>/dev/null; then
|
||||
if wait "$COMFYUI_PID" 2>/dev/null; then
|
||||
: # exit 0 — restart class, keep polling
|
||||
else
|
||||
RC=$?
|
||||
err "ComfyUI exited with code $RC. Last 30 lines of log:"
|
||||
tail -n 30 "$LOG_FILE" >&2
|
||||
rm -f "$PID_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "$READY" -ne 1 ]]; then
|
||||
err "Timeout (${TIMEOUT}s) waiting for ComfyUI. Last 30 lines of log:"
|
||||
tail -n 30 "$LOG_FILE" >&2
|
||||
kill "$COMFYUI_PID" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A Manager-triggered restart (child exit 0 above) leaves COMFYUI_PID pointing
|
||||
# at the dead original child while a FRESH process now owns the port. Re-resolve
|
||||
# the live listener PID so PID_FILE (consumed by stop_comfyui.sh) targets the
|
||||
# process that must actually be killed at teardown.
|
||||
LISTENER_PID="$(ss -tlnp 2>/dev/null | grep ":${PORT}\b" | grep -oE 'pid=[0-9]+' | head -n1 | cut -d= -f2)"
|
||||
if [[ -n "$LISTENER_PID" && "$LISTENER_PID" != "$COMFYUI_PID" ]]; then
|
||||
log "Listener PID $LISTENER_PID differs from launched PID $COMFYUI_PID (restart). Updating PID file."
|
||||
COMFYUI_PID="$LISTENER_PID"
|
||||
echo "$COMFYUI_PID" > "$PID_FILE"
|
||||
fi
|
||||
|
||||
log "ComfyUI is ready."
|
||||
echo "COMFYUI_PID=$COMFYUI_PID PORT=$PORT LOG_FILE=$LOG_FILE"
|
||||
@@ -1,101 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# stop_comfyui.sh — Graceful ComfyUI shutdown (T3, spec §1.3 stop contract).
|
||||
#
|
||||
# Donor mirror: SIGTERM -> 10s grace -> SIGKILL -> port-pattern pkill
|
||||
# fallback -> port-free verify (incl. the legacy-PID-file warning from
|
||||
# the donor WI-CC cross-port-kill incident).
|
||||
#
|
||||
# Input env vars:
|
||||
# E2E_COMFYUI_ROOT — (required) path to the E2E environment
|
||||
# PORT — ComfyUI port (default: 8189)
|
||||
#
|
||||
# Exit: 0=stopped, 1=failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-8189}"
|
||||
GRACE_PERIOD=10
|
||||
|
||||
log() { echo "[stop_comfyui] $*"; }
|
||||
err() { echo "[stop_comfyui] ERROR: $*" >&2; }
|
||||
die() { err "$@"; exit 1; }
|
||||
|
||||
[[ -n "${E2E_COMFYUI_ROOT:-}" ]] || die "E2E_COMFYUI_ROOT is not set"
|
||||
|
||||
# Ownership guard: only signal a PID whose cmdline references THIS E2E root.
|
||||
# On shared runners a bare "main.py --port N" pattern (or a reused PID) could
|
||||
# otherwise match an unrelated process; the launcher invokes
|
||||
# "$E2E_COMFYUI_ROOT/venv/bin/python $E2E_COMFYUI_ROOT/comfyui/main.py", so the
|
||||
# root path is always present in our process's cmdline.
|
||||
belongs_to_root() {
|
||||
local pid="$1"
|
||||
[[ -n "$pid" && -r "/proc/$pid/cmdline" ]] || return 1
|
||||
tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -qF "$E2E_COMFYUI_ROOT"
|
||||
}
|
||||
|
||||
# PIDs currently listening on PORT (deduped).
|
||||
listener_pids() {
|
||||
ss -tlnp 2>/dev/null | grep ":${PORT}\b" | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u
|
||||
}
|
||||
|
||||
PID_FILE="$E2E_COMFYUI_ROOT/logs/comfyui.${PORT}.pid"
|
||||
LEGACY_PID_FILE="$E2E_COMFYUI_ROOT/logs/comfyui.pid"
|
||||
if [[ -f "$LEGACY_PID_FILE" ]] && [[ ! -f "$PID_FILE" ]]; then
|
||||
log "WARN: found legacy unported PID file $LEGACY_PID_FILE but no ${PID_FILE}. Cross-port risk — ignoring legacy file."
|
||||
fi
|
||||
|
||||
COMFYUI_PID=""
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
COMFYUI_PID="$(cat "$PID_FILE")"
|
||||
log "Read PID=$COMFYUI_PID from $PID_FILE"
|
||||
fi
|
||||
|
||||
if [[ -n "$COMFYUI_PID" ]] && kill -0 "$COMFYUI_PID" 2>/dev/null; then
|
||||
if belongs_to_root "$COMFYUI_PID"; then
|
||||
log "Sending SIGTERM to PID $COMFYUI_PID..."
|
||||
kill "$COMFYUI_PID" 2>/dev/null || true
|
||||
elapsed=0
|
||||
while kill -0 "$COMFYUI_PID" 2>/dev/null && [[ "$elapsed" -lt "$GRACE_PERIOD" ]]; do
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
if kill -0 "$COMFYUI_PID" 2>/dev/null; then
|
||||
log "Process still alive after ${GRACE_PERIOD}s. Sending SIGKILL..."
|
||||
kill -9 "$COMFYUI_PID" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
else
|
||||
log "WARN: PID $COMFYUI_PID does NOT belong to $E2E_COMFYUI_ROOT (reused/stale PID). Refusing to kill it."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: kill the port listener(s) (covers Manager-restarted processes whose
|
||||
# PID differs from the recorded one) — but ONLY those verified to belong to this
|
||||
# E2E root, never a broad pattern pkill that could hit unrelated CI processes.
|
||||
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
|
||||
log "Port $PORT still in use. Killing verified-own listener(s)..."
|
||||
for pid in $(listener_pids); do
|
||||
if belongs_to_root "$pid"; then
|
||||
log "Sending SIGTERM to listener PID $pid..."
|
||||
kill "$pid" 2>/dev/null || true
|
||||
else
|
||||
log "WARN: PID $pid on port $PORT is not part of $E2E_COMFYUI_ROOT — leaving it alone."
|
||||
fi
|
||||
done
|
||||
sleep 2
|
||||
for pid in $(listener_pids); do
|
||||
if belongs_to_root "$pid"; then
|
||||
log "Listener PID $pid still alive. Sending SIGKILL..."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
rm -f "$PID_FILE"
|
||||
|
||||
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
|
||||
die "Port $PORT is still in use after shutdown"
|
||||
fi
|
||||
|
||||
log "ComfyUI stopped."
|
||||
@@ -1,400 +0,0 @@
|
||||
"""Real ComfyUI HTTP/queue/install tests with a local CNR service and harmless ZIPs.
|
||||
|
||||
Run with E2E_ROOT pointing to the existing ComfyUI + venv fixture. Each server
|
||||
uses an isolated base directory; no installed nodes or user config are changed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
E2E_ROOT = Path(os.environ.get('E2E_ROOT', '/nonexistent'))
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (E2E_ROOT / 'venv/bin/python').is_file(), reason='E2E_ROOT is required',
|
||||
)
|
||||
PACKS = ['fixture-active', 'fixture-flagged', 'fixture-pending', 'fixture-banned', 'fixture-latest', 'fixture-switch', 'fixture-reinstall', 'fixture-update', 'fixture-snapshot']
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def registry():
|
||||
calls = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
calls.append(parsed.path + ('?' + parsed.query if parsed.query else ''))
|
||||
parts = parsed.path.strip('/').split('/')
|
||||
version = parse_qs(parsed.query).get('version', ['2.0.0'])[0]
|
||||
node = parts[1] if len(parts) > 1 else ''
|
||||
base = f'http://127.0.0.1:{self.server.server_port}'
|
||||
|
||||
def metadata(node, version):
|
||||
status = {'1.0.0': 'Active', '1.1.0': 'Active', '2.0.0': 'Flagged', '3.0.0': 'Pending'}[version]
|
||||
return {'id': f'{node}-{version}', 'node_id': node, 'version': version,
|
||||
'status': 'NodeVersionStatus' + status, 'dependencies': [],
|
||||
'downloadUrl': f'{base}/zip/{node}/{version}'}
|
||||
|
||||
status = 200
|
||||
if parts[0] == 'zip':
|
||||
node, version = parts[1:]
|
||||
archive = io.BytesIO()
|
||||
with zipfile.ZipFile(archive, 'w') as z:
|
||||
z.writestr('__init__.py', 'NODE_CLASS_MAPPINGS = {}\n')
|
||||
z.writestr('pyproject.toml', f'[project]\nname = "{node}"\nversion = "{version}"\n')
|
||||
z.writestr('install.py', 'from pathlib import Path\n'
|
||||
f'Path("installed.txt").write_text("{version}")\n')
|
||||
body = archive.getvalue()
|
||||
elif parsed.path == '/nodes':
|
||||
body = json.dumps({'nodes': [
|
||||
{'id': name, 'name': name, 'description': '', 'status': 'NodeStatusActive',
|
||||
'repository': f'https://example.invalid/{name}',
|
||||
'publisher': {'id': 'fixture', 'name': 'Fixture'},
|
||||
'latest_version': metadata(name, '2.0.0')}
|
||||
for name in PACKS], 'totalPages': 1}).encode()
|
||||
elif len(parts) == 3 and parts[0] == 'nodes' and parts[2] == 'install':
|
||||
if node == 'fixture-banned':
|
||||
status, body = 404, b'{"message":"Not found"}'
|
||||
else:
|
||||
body = json.dumps(metadata(node, version)).encode()
|
||||
else:
|
||||
status, body = 404, b'{}'
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Content-Type', 'application/zip' if parts[0] == 'zip' else 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield SimpleNamespace(url=f'http://127.0.0.1:{server.server_port}', calls=calls)
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
# Redirect only the external Registry in both ComfyUI and its restore subprocess.
|
||||
REGISTRY_BOOTSTRAP = '''
|
||||
import asyncio, json, os, sys, urllib.request
|
||||
sys.path.insert(0, os.environ['CM_E2E_MANAGER_GLOB'])
|
||||
import manager_core, cnr_utils, manager_util
|
||||
cnr_utils.base_url = os.environ['CM_E2E_REGISTRY']
|
||||
with urllib.request.urlopen(cnr_utils.base_url + '/nodes') as response:
|
||||
manager_util.save_to_cache(cnr_utils.base_url + '/nodes', json.load(response))
|
||||
asyncio.run(manager_core.unified_manager.reload('cache', dont_wait=False))
|
||||
manager_core.unified_manager.custom_node_map_cache[(manager_core.normalize_channel('default'), 'cache')] = manager_core.NormalizedKeyDict()
|
||||
'''
|
||||
BOOTSTRAP = '''
|
||||
import os, runpy, sys
|
||||
sys.argv = [os.environ['CM_E2E_MAIN'], *sys.argv[1:]]
|
||||
import comfy.options
|
||||
comfy.options.enable_args_parsing()
|
||||
''' + REGISTRY_BOOTSTRAP + "\nrunpy.run_path(sys.argv[0], run_name='__main__')\n"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def running_server(root, listen, override, registry, security='normal'):
|
||||
custom_nodes = root / 'custom_nodes'
|
||||
custom_nodes.mkdir(exist_ok=True)
|
||||
mount = custom_nodes / 'comfyui-manager'
|
||||
if not mount.exists():
|
||||
mount.symlink_to(REPO_ROOT, target_is_directory=True)
|
||||
config_dir = root / 'user/__manager'
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / 'config.ini').write_text(
|
||||
'[default]\nnetwork_mode = offline\nuse_uv = false\n'
|
||||
f'security_level = {security}\nallow_flagged_nodepack_install = {override}\n'
|
||||
)
|
||||
# cm-cli.py inherits this test-only IO redirection from its real parent.
|
||||
hook = root / 'sitecustomize.py'
|
||||
hook.write_text("import os, sys\nif os.path.basename(sys.argv[0]) == 'cm-cli.py':\n"
|
||||
" from comfy.cli_args import args\n"
|
||||
" args.base_directory = os.environ['CM_E2E_BASE']\n"
|
||||
+ '\n'.join(' ' + line for line in REGISTRY_BOOTSTRAP.splitlines()))
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
env = dict(os.environ, PYTHONUNBUFFERED='1',
|
||||
PYTHONPATH=os.pathsep.join([str(root), str(E2E_ROOT / 'comfyui')]),
|
||||
COMFYUI_PATH=str(E2E_ROOT / 'comfyui'), COMFYUI_FOLDERS_BASE_PATH=str(root),
|
||||
CM_E2E_BASE=str(root), CM_E2E_MAIN=str(E2E_ROOT / 'comfyui/main.py'),
|
||||
CM_E2E_MANAGER_GLOB=str(REPO_ROOT / 'glob'), CM_E2E_REGISTRY=registry.url)
|
||||
command = [str(E2E_ROOT / 'venv/bin/python'), '-c', BOOTSTRAP, '--cpu',
|
||||
'--base-directory', str(root), '--listen', listen, '--port', str(port),
|
||||
'--database-url', f'sqlite:///{root / "comfyui.db"}']
|
||||
log_path = root / ('server-' + uuid.uuid4().hex + '.log')
|
||||
with log_path.open('w') as log:
|
||||
process = subprocess.Popen(command, env=env, cwd=E2E_ROOT / 'comfyui', stdout=log, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
assert process.poll() is None, log_path.read_text()[-6000:]
|
||||
try:
|
||||
if requests.get(base + '/system_stats', timeout=1).status_code == 200:
|
||||
break
|
||||
except requests.RequestException:
|
||||
pass
|
||||
time.sleep(.2)
|
||||
else:
|
||||
pytest.fail(log_path.read_text()[-6000:])
|
||||
assert requests.get(base + '/manager/version', timeout=5).status_code == 200, log_path.read_text()[-6000:]
|
||||
assert 'PRESTARTUP FAILED' not in log_path.read_text(), log_path.read_text()
|
||||
yield SimpleNamespace(root=root, base=base, listen=listen, override=override,
|
||||
security=security, registry=registry, log=log_path, env=env)
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['install', 'restore-snapshot'])
|
||||
def test_direct_cli_installs_flagged_without_server_options(tmp_path, registry, operation):
|
||||
with running_server(tmp_path, '0.0.0.0', False, registry) as server:
|
||||
env = server.env
|
||||
command = [str(E2E_ROOT / 'venv/bin/python'), str(REPO_ROOT / 'cm-cli.py'), operation]
|
||||
if operation == 'install':
|
||||
command += ['fixture-flagged@2.0.0', '--mode', 'cache']
|
||||
else:
|
||||
snapshot = tmp_path / 'direct-snapshot.json'
|
||||
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-flagged': '2.0.0'},
|
||||
'git_custom_nodes': {}, 'file_custom_nodes': []}))
|
||||
command.append(str(snapshot))
|
||||
command += ['--user-directory', str(tmp_path / 'user')]
|
||||
before = len(registry.calls)
|
||||
|
||||
result = subprocess.run(command, env=env, cwd=E2E_ROOT / 'comfyui',
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
assert result.returncode == 0, output
|
||||
installed = tmp_path / 'custom_nodes/fixture-flagged'
|
||||
assert 'version = "2.0.0"' in (installed / 'pyproject.toml').read_text(), output
|
||||
if operation == 'install':
|
||||
assert (installed / 'installed.txt').read_text() == '2.0.0', output
|
||||
assert sum('/install' in call for call in registry.calls[before:]) == 1
|
||||
assert 'Installation of this flagged CNR version is blocked' not in output
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=[
|
||||
('127.0.0.1', False, 'normal'), ('0.0.0.0', False, 'normal'),
|
||||
('0.0.0.0', True, 'normal'), ('0.0.0.0', True, 'strong'),
|
||||
('127.0.0.1', False, 'strong'),
|
||||
], ids=lambda row: '-'.join(map(str, row)))
|
||||
def comfy_server(request, tmp_path_factory, registry):
|
||||
listen, override, security = request.param
|
||||
root = tmp_path_factory.mktemp('flagged-v3')
|
||||
with running_server(root, listen, override, registry, security) as server:
|
||||
yield server
|
||||
|
||||
|
||||
def install(server, node, version, operation='install'):
|
||||
async def request_and_wait():
|
||||
ui_id = uuid.uuid4().hex
|
||||
params = {'id': node, 'version': '1.0.0', 'selected_version': version,
|
||||
'mode': 'cache', 'channel': 'default', 'ui_id': ui_id,
|
||||
'repository': 'https://example.invalid/fixture'}
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=40)) as session:
|
||||
# Subscribe first: a fast worker can emit completion before POST returns.
|
||||
async with session.ws_connect(server.base + '/ws?clientId=' + ui_id) as ws:
|
||||
async with session.post(server.base + '/manager/queue/' + operation, json=params) as response:
|
||||
if response.status != 200:
|
||||
return {'http': response.status, 'message': await response.text()}
|
||||
async with session.post(server.base + '/manager/queue/start') as response:
|
||||
response.raise_for_status()
|
||||
while True:
|
||||
event = await ws.receive_json(timeout=30)
|
||||
data = event.get('data', {})
|
||||
if event.get('type') == 'cm-queue-status' and data.get('status') == 'done':
|
||||
if ui_id in data.get('nodepack_result', {}):
|
||||
return {'http': 200, 'message': data['nodepack_result'][ui_id]}
|
||||
return asyncio.run(request_and_wait())
|
||||
|
||||
|
||||
def assert_terminal_guidance(log):
|
||||
for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]',
|
||||
'allow_flagged_nodepack_install = true', 'config.ini',
|
||||
'trusted private network', 'Restart ComfyUI'):
|
||||
assert detail in log
|
||||
|
||||
|
||||
def assert_flagged_denial(result, log):
|
||||
assert result['message'] == ('This action is not allowed by the current security configuration. '
|
||||
'See the terminal for details.'), result
|
||||
assert_terminal_guidance(log)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('node,version,flagged', [
|
||||
('fixture-active', '1.0.0', False), ('fixture-flagged', '2.0.0', True),
|
||||
('fixture-pending', '3.0.0', False), ('fixture-banned', '1.0.0', False),
|
||||
('fixture-latest', 'latest', True),
|
||||
])
|
||||
def test_install_policy(comfy_server, node, version, flagged):
|
||||
server = comfy_server
|
||||
log_before = len(server.log.read_text())
|
||||
before = len(server.registry.calls)
|
||||
result = install(server, node, version)
|
||||
calls = server.registry.calls[before:]
|
||||
strong = server.security == 'strong'
|
||||
allowed = not strong and node != 'fixture-banned' and (
|
||||
not flagged or server.override or server.listen == '127.0.0.1')
|
||||
marker = server.root / 'custom_nodes' / node / 'installed.txt'
|
||||
assert marker.exists() is allowed, (result, server.log.read_text()[-6000:])
|
||||
assert sum('/install' in call for call in calls) == (0 if strong else 1), calls
|
||||
assert any(call.startswith('/zip/') for call in calls) is allowed
|
||||
if allowed:
|
||||
assert result['message'] == 'success', result
|
||||
assert marker.read_text() == ('2.0.0' if version == 'latest' else version)
|
||||
elif strong:
|
||||
assert result['http'] == 403, result
|
||||
elif flagged:
|
||||
assert_flagged_denial(result, server.log.read_text()[log_before:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['install', 'reinstall', 'update'])
|
||||
def test_existing_pack_policy(comfy_server, operation):
|
||||
server = comfy_server
|
||||
if server.security == 'strong':
|
||||
pytest.skip('Existing-pack transitions require an installed pack')
|
||||
node = {'install': 'fixture-switch', 'reinstall': 'fixture-reinstall', 'update': 'fixture-update'}[operation]
|
||||
assert install(server, node, '1.0.0')['message'] == 'success'
|
||||
marker = server.root / 'custom_nodes' / node / 'installed.txt'
|
||||
log_before = len(server.log.read_text())
|
||||
before = len(server.registry.calls)
|
||||
result = install(server, node, '2.0.0', operation)
|
||||
calls = server.registry.calls[before:]
|
||||
allowed = server.override or server.listen == '127.0.0.1'
|
||||
assert sum('/install' in call for call in calls) == 1, calls
|
||||
if allowed:
|
||||
assert result['message'] == 'success', result
|
||||
if operation == 'reinstall':
|
||||
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
|
||||
else:
|
||||
assert marker.read_text() == '1.0.0'
|
||||
assert 'NodeVersionStatusFlagged' in (server.root / 'user/__manager/startup-scripts/install-scripts.txt').read_text()
|
||||
else:
|
||||
assert_flagged_denial(result, server.log.read_text()[log_before:])
|
||||
assert marker.read_text() == '1.0.0'
|
||||
assert any(call.startswith('/zip/') for call in calls) is (allowed and operation == 'reinstall')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('restart_listen,restart_override,allowed', [
|
||||
('0.0.0.0', False, False), ('0.0.0.0', True, True), ('127.0.0.1', False, True),
|
||||
])
|
||||
@pytest.mark.parametrize('stored_status', [True, False])
|
||||
def test_restart_uses_current_policy(tmp_path, registry, restart_listen, restart_override, allowed, stored_status):
|
||||
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
|
||||
assert install(server, 'fixture-switch', '1.0.0')['message'] == 'success'
|
||||
assert install(server, 'fixture-switch', '2.0.0')['message'] == 'success'
|
||||
if not stored_status:
|
||||
reservation = tmp_path / 'user/__manager/startup-scripts/install-scripts.txt'
|
||||
record = ast.literal_eval(reservation.read_text().strip())
|
||||
assert record[1] == '#LAZY-CNR-SWITCH-SCRIPT'
|
||||
assert len(record) == 9
|
||||
reservation.write_text(repr(record[:8]) + '\n')
|
||||
marker = tmp_path / 'custom_nodes/fixture-switch/installed.txt'
|
||||
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
|
||||
before = len(registry.calls)
|
||||
with running_server(tmp_path, restart_listen, restart_override, registry) as server:
|
||||
calls = registry.calls[before:]
|
||||
assert not any('/install' in call for call in calls), calls
|
||||
assert any(call.startswith('/zip/') for call in calls) is allowed
|
||||
if allowed:
|
||||
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
|
||||
else:
|
||||
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
|
||||
if stored_status:
|
||||
assert_terminal_guidance(server.log.read_text())
|
||||
else:
|
||||
assert 'stored Registry status is missing' in server.log.read_text()
|
||||
assert 'Request the installation again' in server.log.read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('surface,payload,flag', [
|
||||
('git_url', {'url': 'https://example.invalid/fixture'}, 'allow_git_url_install'),
|
||||
('pip', {'packages': 'fixture-do-not-install'}, 'allow_pip_install'),
|
||||
])
|
||||
def test_other_install_flags_remain_required(comfy_server, surface, payload, flag):
|
||||
server = comfy_server
|
||||
before = len(server.registry.calls)
|
||||
response = requests.post(server.base + '/customnode/install/' + surface, json=payload, timeout=10)
|
||||
assert response.status_code == 403, response.text
|
||||
assert response.json() == {'error': flag}
|
||||
assert server.registry.calls[before:] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing', [False, True])
|
||||
@pytest.mark.parametrize('version,listen,override,allowed', [
|
||||
('1.1.0', '0.0.0.0', False, True),
|
||||
('2.0.0', '0.0.0.0', False, False),
|
||||
('2.0.0', '0.0.0.0', True, True),
|
||||
('2.0.0', '127.0.0.1', False, True),
|
||||
])
|
||||
def test_scheduled_snapshot_inherits_parent_policy(tmp_path, registry, existing, version, listen, override, allowed):
|
||||
installed = tmp_path / 'custom_nodes/fixture-snapshot'
|
||||
marker = installed / 'installed.txt'
|
||||
reservation = tmp_path / 'user/__manager/startup-scripts/restore-snapshot.json'
|
||||
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
|
||||
if existing:
|
||||
assert install(server, 'fixture-snapshot', '1.0.0')['message'] == 'success'
|
||||
assert marker.read_text() == '1.0.0'
|
||||
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
|
||||
snapshot = tmp_path / 'user/__manager/snapshots/flagged-policy.json'
|
||||
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-snapshot': version},
|
||||
'git_custom_nodes': {}, 'file_custom_nodes': []}))
|
||||
before = len(registry.calls)
|
||||
response = requests.post(server.base + '/snapshot/restore',
|
||||
json={'target': snapshot.stem}, timeout=10)
|
||||
assert response.status_code == 200, response.text
|
||||
assert reservation.read_bytes() == snapshot.read_bytes()
|
||||
assert registry.calls[before:] == []
|
||||
if existing:
|
||||
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
|
||||
else:
|
||||
assert not installed.exists()
|
||||
|
||||
before = len(registry.calls)
|
||||
with running_server(tmp_path, listen, override, registry) as server:
|
||||
log = server.log.read_text()
|
||||
calls = registry.calls[before:]
|
||||
assert 'Restore snapshot done.' in log, log[-6000:]
|
||||
assert 'Restore snapshot failed.' not in log, log[-6000:]
|
||||
assert 'Error in sitecustomize' not in log, log[-6000:]
|
||||
assert not reservation.exists()
|
||||
assert sum('/install' in call for call in calls) == 1, (calls, log[-6000:])
|
||||
assert any(call.startswith('/zip/') for call in calls) is allowed
|
||||
if allowed:
|
||||
assert f'version = "{version}"' in (installed / 'pyproject.toml').read_text(), log[-6000:]
|
||||
assert 'allow_flagged_nodepack_install' not in log
|
||||
else:
|
||||
assert_terminal_guidance(log)
|
||||
if existing:
|
||||
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
|
||||
assert '1.0.0' in (installed / 'pyproject.toml').read_text()
|
||||
else:
|
||||
assert not installed.exists()
|
||||
@@ -1,752 +0,0 @@
|
||||
"""GOAL #60 — Real-server E2E for the dedicated install flags (worktree-mounted Manager).
|
||||
|
||||
Boots a REAL ComfyUI server from a disposable test root
|
||||
(`E2E_COMFYUI_ROOT`, built by tests/e2e/scripts/setup_e2e_env.sh) with
|
||||
the Manager mounted via `git worktree add --detach` (NEVER pip-installed
|
||||
— [D2]) and exercises both dedicated-flag surfaces over live HTTP.
|
||||
|
||||
Usage:
|
||||
bash tests/e2e/scripts/setup_e2e_env.sh # once (E2E-SC-01)
|
||||
E2E_COMFYUI_ROOT=/path/to/root pytest tests/e2e/test_e2e_install_flags.py -v
|
||||
|
||||
Per-row map (goal60-scenarios.md, 24 rows — spec §3 BINDING):
|
||||
SC-01 setup_e2e_env.sh (pre-suite script; idempotent build + marker — not a pytest test)
|
||||
SC-02 mount_worktree fixture create path (SHA pin, .git-file, no-pip, printed SHA)
|
||||
SC-03 mount_worktree fixture reuse path + path-prefix scoping invariant
|
||||
SC-04 _start_server via start_comfyui.sh (readiness poll, restart tolerance,
|
||||
per-launch log comfyui.<port>.<launch-id>.log)
|
||||
SC-05 test_00_smoke_manager_version in EVERY server-up class (+abort guard)
|
||||
SC-06 _stage via stage_flags.sh (backup-if-absent; restart-only by construction)
|
||||
SC-07 class fixture finalizers (stop + port-free + config restore + backup DELETE)
|
||||
+ mount_worktree finalizer (unmount + prune + absence assert)
|
||||
SC-10 TestDenyArms.test_01_sa_deny SC-11 TestDenyArms.test_02_sb_deny
|
||||
SC-12 TestAllowArms.test_01_git_url_allow
|
||||
SC-13 TestAllowArms.test_02_pip_allow_reserved (anti-false-PASS — VERBATIM)
|
||||
SC-14 TestAllowArms.test_03_restart_consumes_reservation (R-A through the holder)
|
||||
SC-20 _pre_guards in both class fixtures (before any request)
|
||||
SC-21 TestAllowArms.test_04_clone_residual_cleanup (+ installed-index cross-check)
|
||||
SC-22 TestAllowArms.test_05_pip_residual_uninstall
|
||||
SC-23 _reservation_guard — UNCONDITIONAL fixture-teardown guard (failure path)
|
||||
SC-24 TestZeroResidual.test_99_zero_residual_sweep (unmount half lives in the
|
||||
mount_worktree finalizer — it cannot be asserted from inside the session)
|
||||
SC-30 module-level pytestmark (env unset -> all SKIP; unit suite unaffected)
|
||||
SC-31 module-level pytestmark (marker absent -> all SKIP)
|
||||
SC-32 needs_network marker on fixture-dependent (allow-arm / public) rows
|
||||
SC-33 collection safety by construction: stdlib + pytest + requests
|
||||
(via pytest.importorskip) ONLY — no glob/ imports, no server imports,
|
||||
HTTP only at test time
|
||||
SC-40 TestPublicListener (opt-in E2E_PUBLIC_LISTEN=1; L-P @ 0.0.0.0)
|
||||
SC-41 batch S-C/S-C' E2E — DEFERRED (Q-5; spec FREEZE item 3; recorded here)
|
||||
SC-42 TestAllowArms.test_06_requirements_watchdog (L-A launch log)
|
||||
|
||||
Fixture-lifecycle ownership (spec §3 BINDING block):
|
||||
- every class server fixture DECLARES mount_worktree (mount-before-launch);
|
||||
- process handle lives in a MUTABLE ServerHolder owned by the fixture;
|
||||
teardown stops the CURRENT holder content (whatever launch identity is live);
|
||||
- SC-14 restarts THROUGH the holder (stop L-A -> launch R-A -> replace handle);
|
||||
- stop-before-next-class is structural (pytest class-fixture scoping);
|
||||
- `requests` is imported via pytest.importorskip (absence degrades to SKIP).
|
||||
|
||||
T6 note: no tests/e2e/conftest.py — all fixtures are single-module, so the
|
||||
optional T6 file is not demanded (spec §2 T6 condition not met).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip gates (E2E-SC-30/31) — BEFORE anything env-dependent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
E2E_ROOT = os.environ.get("E2E_COMFYUI_ROOT", "")
|
||||
_MARKER_OK = bool(E2E_ROOT) and os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete"))
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _MARKER_OK,
|
||||
reason="E2E_COMFYUI_ROOT not set or E2E environment not ready (.e2e_setup_complete missing)",
|
||||
)
|
||||
|
||||
# requests: test-extra — absence degrades to SKIP, never a collection error
|
||||
# (spec §3 binding block item 5; [D4]).
|
||||
requests = pytest.importorskip("requests")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants / paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PORT = int(os.environ.get("PORT", "8189"))
|
||||
TIMEOUT = int(os.environ.get("TIMEOUT", "120"))
|
||||
BASE_URL = f"http://127.0.0.1:{PORT}"
|
||||
|
||||
THIS_DIR = Path(__file__).resolve().parent
|
||||
SCRIPTS_DIR = THIS_DIR / "scripts"
|
||||
MANAGER_REPO = THIS_DIR.parents[1] # repo root of the checkout running the suite
|
||||
|
||||
ROOT = Path(E2E_ROOT) if E2E_ROOT else Path(".")
|
||||
COMFY_DIR = ROOT / "comfyui"
|
||||
CN_DIR = COMFY_DIR / "custom_nodes"
|
||||
MOUNT = CN_DIR / "comfyui-manager"
|
||||
CFG = COMFY_DIR / "user" / "__manager" / "config.ini"
|
||||
CFG_BACKUP = Path(str(CFG) + ".before-flags")
|
||||
SCRIPTS_FILE = COMFY_DIR / "user" / "__manager" / "startup-scripts" / "install-scripts.txt"
|
||||
LOGS_DIR = ROOT / "logs"
|
||||
VENV_PY = ROOT / "venv" / "bin" / "python"
|
||||
|
||||
# Owned fixtures ONLY (goal60-scenarios.md Conventions; [D3])
|
||||
NODEPACK_URL = "https://github.com/ltdrdata/nodepack-test1-do-not-install"
|
||||
PACK_NAME = "nodepack-test1-do-not-install"
|
||||
# pip stimulus uses the git+ scheme: pip/uv require it for VCS URLs — a
|
||||
# plain GitHub repo URL serves HTML and cannot install (verified by probe;
|
||||
# spec amendment requested via leader pushback 2026-06-08; the SC-13/14
|
||||
# oracle itself is encoded VERBATIM).
|
||||
PIP_URL = "git+https://github.com/ltdrdata/pip-test1-do-not-install"
|
||||
PIP_PKG = "pip-test1-do-not-install"
|
||||
PIP_IMPORT = "pip_test1_do_not_install"
|
||||
PIP_MARKER = "pip-test1-do-not-install:ok"
|
||||
# Amendment A2 (live-run finding, leader-approved): the S-A nodepack fixture
|
||||
# is deliberately NOT zero-dep — it pins python-slugify==8.0.4 in its
|
||||
# requirements as the invariant-4 ride-along proof vehicle. The SC-42
|
||||
# watchdog therefore allowlists exactly that requirement, and the
|
||||
# transitive-dep residual class is swept at allow-class teardown + SC-24.
|
||||
# (The S-B pip fixture IS zero-dep as documented.)
|
||||
NODEPACK_PINNED_REQ = "python-slugify==8.0.4"
|
||||
TRANSITIVE_DEPS = ("python-slugify", "text-unidecode")
|
||||
|
||||
POLL_TIMEOUT = 60
|
||||
POLL_INTERVAL = 1.0
|
||||
|
||||
# Distinctive substrings of the flag-naming denial constants @ d45c8e6b
|
||||
DENY_COPY_GIT = "'allow_git_url_install = true' in config.ini"
|
||||
DENY_COPY_PIP = "'allow_pip_install = true' in config.ini"
|
||||
# Old security_level-attributing copy (must NOT appear on flag denials)
|
||||
OLD_COPY_GENERAL = "is not allowed in this security_level"
|
||||
OLD_COPY_NORMAL_MINUS = "set the security level to"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network probe (E2E-SC-32) — evaluated ONLY when the env gate is open, so
|
||||
# collection without the env performs no network IO (SC-33).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _network_available() -> bool:
|
||||
try:
|
||||
with socket.create_connection(("github.com", 443), timeout=5):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
_NETWORK = _network_available() if _MARKER_OK else False
|
||||
needs_network = pytest.mark.skipif(
|
||||
not _NETWORK,
|
||||
reason="github.com unreachable — network-dependent fixture row skipped (E2E-SC-32)",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run(cmd, check=False, timeout=180, env=None, cwd=None):
|
||||
return subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=timeout, check=check,
|
||||
env=env, cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def _script(name: str) -> str:
|
||||
return str(SCRIPTS_DIR / name)
|
||||
|
||||
|
||||
def _script_env(**extra) -> dict:
|
||||
env = {**os.environ, "E2E_COMFYUI_ROOT": str(ROOT), "PORT": str(PORT),
|
||||
"TIMEOUT": str(TIMEOUT)}
|
||||
env.update({k: str(v) for k, v in extra.items()})
|
||||
return env
|
||||
|
||||
|
||||
def _pack_dir(name: str = PACK_NAME) -> Path:
|
||||
return CN_DIR / name
|
||||
|
||||
|
||||
def _pack_exists(name: str = PACK_NAME) -> bool:
|
||||
return _pack_dir(name).is_dir()
|
||||
|
||||
|
||||
def _remove_pack(name: str = PACK_NAME) -> None:
|
||||
"""Donor _remove_pack pattern: rmtree 3-retry + rename-to-.trash_ fallback."""
|
||||
path = _pack_dir(name)
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
return
|
||||
if not path.is_dir():
|
||||
return
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
return
|
||||
except OSError:
|
||||
if attempt < 2:
|
||||
time.sleep(1)
|
||||
trash = CN_DIR / f".trash_{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
os.rename(path, trash)
|
||||
shutil.rmtree(trash, ignore_errors=True)
|
||||
except OSError:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=POLL_TIMEOUT, interval=POLL_INTERVAL) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(interval)
|
||||
return False
|
||||
|
||||
|
||||
def _pip_import_rc() -> int:
|
||||
return _run([str(VENV_PY), "-c", f"import {PIP_IMPORT}"]).returncode
|
||||
|
||||
|
||||
def _pip_marker_rc() -> "subprocess.CompletedProcess":
|
||||
return _run([
|
||||
str(VENV_PY), "-c",
|
||||
f"import {PIP_IMPORT} as m; assert m.MARKER == '{PIP_MARKER}'",
|
||||
])
|
||||
|
||||
|
||||
def _pip_uninstall() -> "subprocess.CompletedProcess":
|
||||
return _run([str(VENV_PY), "-m", "pip", "uninstall", "-y", PIP_PKG])
|
||||
|
||||
|
||||
def _scripts_clean() -> bool:
|
||||
"""True when the reservation file is absent OR carries no pip-test1 line."""
|
||||
if not SCRIPTS_FILE.exists():
|
||||
return True
|
||||
return "pip-test1" not in SCRIPTS_FILE.read_text(errors="ignore")
|
||||
|
||||
|
||||
def _reservation_guard() -> None:
|
||||
"""E2E-SC-23 — UNCONDITIONAL teardown guard for the unconsumed-reservation
|
||||
leak class: a leaked line would pip-install on ANY next boot of this root."""
|
||||
if SCRIPTS_FILE.exists() and "pip-test1" in SCRIPTS_FILE.read_text(errors="ignore"):
|
||||
SCRIPTS_FILE.unlink()
|
||||
assert _scripts_clean(), "reservation guard failed to clear pip-test1 line (SC-23)"
|
||||
|
||||
|
||||
def _restore_config() -> None:
|
||||
"""E2E-SC-07: restore from backup, then DELETE the backup and assert absence
|
||||
(a surviving stale backup would silently restore an outdated config at a
|
||||
FUTURE run's teardown via the create-only-if-absent rule)."""
|
||||
if CFG_BACKUP.exists():
|
||||
shutil.copyfile(CFG_BACKUP, CFG)
|
||||
CFG_BACKUP.unlink()
|
||||
assert not CFG_BACKUP.exists(), "config backup must be DELETED after restore (SC-07/24)"
|
||||
|
||||
|
||||
def _port_free() -> bool:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.settimeout(1)
|
||||
return s.connect_ex(("127.0.0.1", PORT)) != 0
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def _pre_guards() -> None:
|
||||
"""E2E-SC-20 — before EACH arm's matrix rows; assert all three."""
|
||||
_remove_pack(PACK_NAME)
|
||||
assert not _pack_exists(PACK_NAME), (
|
||||
f"pre-guard: failed to clean {PACK_NAME} (file locks?)"
|
||||
)
|
||||
_pip_uninstall() # ignore rc: not-installed is fine
|
||||
assert _pip_import_rc() != 0, "pre-guard: pip fixture importable before test"
|
||||
_reservation_guard()
|
||||
assert _scripts_clean(), "pre-guard: stale pip-test1 reservation present"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server lifecycle (E2E-SC-04 + spec §3 binding holder contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ServerHolder:
|
||||
"""Mutable process-handle holder owned by the class server fixture.
|
||||
|
||||
The holder always points at the CURRENT launch identity; SC-14 replaces
|
||||
its content when it restarts through it, so class teardown stops
|
||||
whatever is live — no orphan."""
|
||||
|
||||
def __init__(self):
|
||||
self.launch_id: str | None = None
|
||||
self.log_path: Path | None = None
|
||||
self.live = False
|
||||
self.smoke_ok = False
|
||||
|
||||
|
||||
def _stage(mode: str) -> None:
|
||||
r = _run(["bash", _script("stage_flags.sh"), mode], env=_script_env(), check=False)
|
||||
assert r.returncode == 0, f"stage_flags.sh {mode} failed:\n{r.stdout}\n{r.stderr}"
|
||||
assert CFG_BACKUP.exists(), "backup must exist after staging (SC-06)"
|
||||
|
||||
|
||||
def _start_server(holder: ServerHolder, launch_id: str, listen: str = "127.0.0.1") -> None:
|
||||
r = _run(
|
||||
["bash", _script("start_comfyui.sh")],
|
||||
env=_script_env(LISTEN=listen, LAUNCH_ID=launch_id),
|
||||
timeout=TIMEOUT + 90,
|
||||
)
|
||||
assert r.returncode == 0, (
|
||||
f"start_comfyui.sh failed for launch {launch_id}:\n{r.stdout}\n{r.stderr}"
|
||||
)
|
||||
holder.launch_id = launch_id
|
||||
holder.log_path = LOGS_DIR / f"comfyui.{PORT}.{launch_id}.log"
|
||||
holder.live = True
|
||||
assert holder.log_path.is_file(), "per-launch log file missing (SC-04)"
|
||||
|
||||
|
||||
def _stop_server(holder: ServerHolder) -> None:
|
||||
if not holder.live:
|
||||
return
|
||||
r = _run(["bash", _script("stop_comfyui.sh")], env=_script_env(), timeout=120)
|
||||
assert r.returncode == 0, f"stop_comfyui.sh failed:\n{r.stdout}\n{r.stderr}"
|
||||
holder.live = False
|
||||
assert _port_free(), "port still bound after stop (SC-07)"
|
||||
|
||||
|
||||
def _launch_log(holder: ServerHolder) -> str:
|
||||
assert holder.log_path is not None and holder.log_path.is_file()
|
||||
return holder.log_path.read_text(errors="ignore")
|
||||
|
||||
|
||||
def _named_log(launch_id: str) -> str:
|
||||
p = LOGS_DIR / f"comfyui.{PORT}.{launch_id}.log"
|
||||
assert p.is_file(), f"launch log for {launch_id} missing"
|
||||
return p.read_text(errors="ignore")
|
||||
|
||||
|
||||
def _require_smoke(holder: ServerHolder) -> None:
|
||||
"""SC-05 abort semantics: matrix rows refuse to run after a smoke failure
|
||||
so a mount/activation problem cannot produce misleading 404 results."""
|
||||
if not holder.smoke_ok:
|
||||
pytest.fail(
|
||||
"aborting matrix row: smoke (GET /manager/version) has not passed "
|
||||
"for this launch — mount/activation problem or Q-2 bundled-manager "
|
||||
"collision (E2E-SC-05)"
|
||||
)
|
||||
|
||||
|
||||
def _smoke(holder: ServerHolder) -> None:
|
||||
r = requests.get(f"{BASE_URL}/manager/version", timeout=10)
|
||||
assert r.status_code == 200, (
|
||||
f"smoke FAILED: GET /manager/version -> {r.status_code}; the "
|
||||
f"worktree-mounted plugin did not register its routes (E2E-SC-05). "
|
||||
f"Log tail:\n{_launch_log(holder)[-2000:]}"
|
||||
)
|
||||
assert r.text.strip(), "smoke: /manager/version body empty"
|
||||
holder.smoke_ok = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def mount_worktree():
|
||||
"""E2E-SC-02/03 — SOLE owner of mount create / reuse-verify / teardown."""
|
||||
ref = os.environ.get("E2E_MANAGER_REF", "HEAD")
|
||||
r = _run(["git", "-C", str(MANAGER_REPO), "rev-parse", f"{ref}^{{commit}}"], check=True)
|
||||
sha = r.stdout.strip()
|
||||
|
||||
# Scoping invariant (SC-03): the mount path is {ROOT}-prefixed and is
|
||||
# NEVER under the members' .claude/worktrees tree. Every mount/teardown
|
||||
# command below references ONLY this path.
|
||||
mount = MOUNT.resolve()
|
||||
assert ".claude/worktrees" not in str(mount).replace(os.sep, "/"), (
|
||||
"mount path must never live under member worktrees (SC-03 scoping)"
|
||||
)
|
||||
assert str(mount).startswith(str(ROOT.resolve())), (
|
||||
"mount path must be {ROOT}-prefixed (SC-03 scoping)"
|
||||
)
|
||||
|
||||
porcelain = _run(["git", "-C", str(MANAGER_REPO), "worktree", "list", "--porcelain"]).stdout
|
||||
if f"worktree {mount}" in porcelain:
|
||||
# Reuse path (SC-03)
|
||||
head = _run(["git", "-C", str(mount), "rev-parse", "HEAD"]).stdout.strip()
|
||||
if head != sha:
|
||||
_run(["git", "-C", str(mount), "checkout", "--detach", sha], check=True)
|
||||
else:
|
||||
# Create path (SC-02)
|
||||
_run(["git", "-C", str(MANAGER_REPO), "worktree", "add", "--detach",
|
||||
str(mount), sha], check=True)
|
||||
|
||||
# From here a worktree exists at `mount`. Any failure between now and the
|
||||
# yield (the verify asserts below) must STILL run teardown — otherwise a
|
||||
# failed setup leaks an orphaned worktree into the next session (review
|
||||
# follow-up). Hence the try/finally wraps verify + yield, not just yield.
|
||||
try:
|
||||
# Verify (every session)
|
||||
head = _run(["git", "-C", str(mount), "rev-parse", "HEAD"]).stdout.strip()
|
||||
assert head == sha, f"mount HEAD {head} != expected {sha} (SC-02)"
|
||||
print(f"[mount_worktree] Manager mounted at {mount} @ SHA {sha}") # [D2] traceability
|
||||
assert (mount / ".git").is_file(), (
|
||||
".git in the mount must be a FILE (gitdir pointer) — worktree layout (SC-02)"
|
||||
)
|
||||
# [D2] other half: no pip-installed Manager in the venv; per MM §2.2 no
|
||||
# assertion anywhere relies on the mounted Manager's OWN version/remote
|
||||
# self-report (.git-file degradation accepted by design — spec R5).
|
||||
for dist in ("comfyui-manager", "ComfyUI-Manager"):
|
||||
rc = _run([str(VENV_PY), "-m", "pip", "show", dist]).returncode
|
||||
assert rc != 0, f"pip-installed Manager '{dist}' found in venv — violates [D2]"
|
||||
|
||||
yield {"path": mount, "sha": sha}
|
||||
finally:
|
||||
if os.environ.get("E2E_KEEP_MOUNT"):
|
||||
print(f"[mount_worktree] E2E_KEEP_MOUNT set — keeping {mount}")
|
||||
else:
|
||||
# Exception-safe: prune + absence asserts run even when remove fails
|
||||
# (review iter-2 — crash residue must still be surfaced honestly).
|
||||
try:
|
||||
_run(["git", "-C", str(MANAGER_REPO), "worktree", "remove", "--force", str(mount)],
|
||||
check=True)
|
||||
finally:
|
||||
_run(["git", "-C", str(MANAGER_REPO), "worktree", "prune"])
|
||||
porcelain = _run(["git", "-C", str(MANAGER_REPO), "worktree", "list", "--porcelain"]).stdout
|
||||
assert f"worktree {mount}" not in porcelain, "mount still listed after remove (SC-07)"
|
||||
assert not mount.exists(), "mount dir still present after remove (SC-07)"
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def deny_server(mount_worktree):
|
||||
"""L-D: both flags ABSENT (live 'missing key reads false'), loopback."""
|
||||
_pre_guards() # SC-20
|
||||
_stage("deny") # SC-06
|
||||
holder = ServerHolder()
|
||||
_start_server(holder, "L-D") # SC-04
|
||||
yield holder
|
||||
# Exception-safe teardown chain (review iter-2 must-fix): a failing
|
||||
# stop is exactly the crashed-run shape SC-23 exists for — the guard
|
||||
# and the config restore MUST run regardless.
|
||||
try:
|
||||
_stop_server(holder) # SC-07 (current handle, whatever is live)
|
||||
finally:
|
||||
try:
|
||||
_reservation_guard() # SC-23 — UNCONDITIONAL
|
||||
finally:
|
||||
_restore_config() # SC-07: restore + DELETE backup
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def allow_server(mount_worktree):
|
||||
"""L-A: both flags true, loopback. SC-14 mutates the holder to R-A."""
|
||||
_pre_guards() # SC-20 (re-guards before the allow arm)
|
||||
_stage("allow") # SC-06
|
||||
holder = ServerHolder()
|
||||
_start_server(holder, "L-A")
|
||||
yield holder
|
||||
# Exception-safe teardown chain (review iter-2 must-fix): every
|
||||
# residual guard runs even when the stop (or an earlier sweep step)
|
||||
# raises — SC-23 is contractually UNCONDITIONAL (R3 leak class).
|
||||
try:
|
||||
_stop_server(holder) # stops the CURRENT identity (L-A or R-A)
|
||||
finally:
|
||||
try:
|
||||
_remove_pack(PACK_NAME) # defensive re-sweep (primary assert is SC-21)
|
||||
_pip_uninstall() # defensive (primary assert is SC-22)
|
||||
# Amendment A2: sweep the S-A fixture's transitive-dep residual
|
||||
# class (python-slugify + text-unidecode ride the git
|
||||
# transaction; verified NOT in ComfyUI's own requirements).
|
||||
_run([str(VENV_PY), "-m", "pip", "uninstall", "-y", *TRANSITIVE_DEPS])
|
||||
finally:
|
||||
try:
|
||||
_reservation_guard() # SC-23 — UNCONDITIONAL (failure path cover)
|
||||
finally:
|
||||
_restore_config()
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def public_server(mount_worktree):
|
||||
"""L-P (opt-in, Q-7): both flags true, 0.0.0.0 listener."""
|
||||
_pre_guards()
|
||||
_stage("allow")
|
||||
holder = ServerHolder()
|
||||
_start_server(holder, "L-P", listen="0.0.0.0")
|
||||
yield holder
|
||||
# Exception-safe teardown chain (review iter-2 must-fix).
|
||||
try:
|
||||
_stop_server(holder)
|
||||
finally:
|
||||
try:
|
||||
_reservation_guard()
|
||||
finally:
|
||||
_restore_config()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classes — definition order IS execution order (deny first on the fresh env)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDenyArms:
|
||||
"""L-D launch: SC-05 smoke, SC-10, SC-11 (deny rows are offline-safe —
|
||||
denial happens before any network access)."""
|
||||
|
||||
def test_00_smoke_manager_version(self, deny_server):
|
||||
_smoke(deny_server) # SC-05
|
||||
|
||||
def test_01_sa_deny(self, deny_server):
|
||||
"""E2E-SC-10: S-A deny — 403 + exact flag token + no artifact + honest log."""
|
||||
_require_smoke(deny_server)
|
||||
r = requests.post(f"{BASE_URL}/customnode/install/git_url",
|
||||
json={"url": NODEPACK_URL}, timeout=30)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"error": "allow_git_url_install"}, (
|
||||
f"deny body must carry the flag token, got {r.text!r}"
|
||||
)
|
||||
assert not _pack_exists(PACK_NAME), "clone artifact created on DENY (SC-10)"
|
||||
log = _launch_log(deny_server)
|
||||
assert DENY_COPY_GIT in log, "flag-naming denial copy missing from L-D log"
|
||||
assert OLD_COPY_GENERAL not in log and OLD_COPY_NORMAL_MINUS not in log, (
|
||||
"denial attributed to security_level — honest-copy violation (SC-10)"
|
||||
)
|
||||
|
||||
def test_02_sb_deny(self, deny_server):
|
||||
"""E2E-SC-11: S-B deny — 403 + flag token + no reservation + not importable."""
|
||||
_require_smoke(deny_server)
|
||||
r = requests.post(f"{BASE_URL}/customnode/install/pip",
|
||||
json={"packages": PIP_URL}, timeout=30)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"error": "allow_pip_install"}
|
||||
assert _scripts_clean(), "reservation recorded on DENY (SC-11)"
|
||||
assert _pip_import_rc() != 0, "pip fixture importable after DENY (SC-11)"
|
||||
log = _launch_log(deny_server)
|
||||
assert DENY_COPY_PIP in log, "flag-naming denial copy missing from L-D log"
|
||||
|
||||
|
||||
class TestAllowArms:
|
||||
"""L-A launch + R-A restart. ORDERED methods (donor sequential-class
|
||||
precedent): SC-12 -> SC-13 -> SC-14 -> SC-21 -> SC-22 -> SC-42."""
|
||||
|
||||
def test_00_smoke_manager_version(self, allow_server):
|
||||
_smoke(allow_server) # SC-05 (re-smoke on the new launch)
|
||||
|
||||
@needs_network
|
||||
def test_01_git_url_allow(self, allow_server):
|
||||
"""E2E-SC-12: S-A allow — 200 + real clone + clone-target proof."""
|
||||
_require_smoke(allow_server)
|
||||
r = requests.post(f"{BASE_URL}/customnode/install/git_url",
|
||||
json={"url": NODEPACK_URL}, timeout=120)
|
||||
assert r.status_code == 200, f"S-A allow expected 200, got {r.status_code}: {r.text!r}"
|
||||
assert _wait_for(lambda: _pack_exists(PACK_NAME)), (
|
||||
f"{PACK_NAME} not cloned within {POLL_TIMEOUT}s (SC-12)"
|
||||
)
|
||||
git_dir = _pack_dir() / ".git"
|
||||
assert git_dir.is_dir(), "no .git DIRECTORY — not a real clone (SC-12)"
|
||||
# Donor clone-target proof: .git/config [remote "origin"] url matches
|
||||
# the requested URL modulo the .git suffix.
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(git_dir / "config")
|
||||
section = 'remote "origin"'
|
||||
assert section in cp, f'[{section}] missing from .git/config: {cp.sections()!r}'
|
||||
remote_url = cp[section].get("url", "").rstrip("/")
|
||||
expected = NODEPACK_URL.rstrip("/")
|
||||
assert remote_url in (expected, expected + ".git"), (
|
||||
f"clone targeted the WRONG repo: {remote_url!r} != {expected!r} (SC-12)"
|
||||
)
|
||||
|
||||
@needs_network
|
||||
def test_02_pip_allow_reserved(self, allow_server):
|
||||
"""E2E-SC-13 (VERBATIM anti-false-PASS oracle): 200 = RESERVED, NOT
|
||||
INSTALLED. Asserting import success here would be the exact false-PASS
|
||||
the MM correction exists to prevent."""
|
||||
_require_smoke(allow_server)
|
||||
r = requests.post(f"{BASE_URL}/customnode/install/pip",
|
||||
json={"packages": PIP_URL}, timeout=30)
|
||||
assert r.status_code == 200, f"S-B allow expected 200, got {r.status_code}: {r.text!r}"
|
||||
assert SCRIPTS_FILE.is_file(), "no reservation file after S-B allow (SC-13)"
|
||||
content = SCRIPTS_FILE.read_text(errors="ignore")
|
||||
reserved_lines = [
|
||||
ln for ln in content.splitlines()
|
||||
if "'#FORCE'" in ln and PIP_PKG in ln
|
||||
]
|
||||
assert reserved_lines, (
|
||||
f"no reservation line with '#FORCE' + {PIP_PKG!r} in {SCRIPTS_FILE}:\n{content}"
|
||||
)
|
||||
# MANDATORY: the package is NOT installed at this point.
|
||||
assert _pip_import_rc() != 0, (
|
||||
"pip fixture importable right after the 200 — reservation semantics "
|
||||
"violated, or a previous run leaked state (SC-13 anti-false-PASS)"
|
||||
)
|
||||
|
||||
@needs_network
|
||||
def test_03_restart_consumes_reservation(self, allow_server):
|
||||
"""E2E-SC-14: R-A restart THROUGH the holder; the consuming boot
|
||||
executes + removes the reservation; MARKER import proves field-level."""
|
||||
_require_smoke(allow_server)
|
||||
assert SCRIPTS_FILE.is_file(), "precondition: reservation must exist (SC-13 first)"
|
||||
# Restart THROUGH the holder (spec §3 binding item 3): stop the live
|
||||
# L-A process, relaunch as R-A with the SAME staged config, replace
|
||||
# the handle — class teardown then stops R-A.
|
||||
_stop_server(allow_server)
|
||||
_start_server(allow_server, "R-A")
|
||||
_smoke(allow_server)
|
||||
# Field-level positive proof (not just exit code):
|
||||
marker = _pip_marker_rc()
|
||||
assert marker.returncode == 0, (
|
||||
f"MARKER import failed after the consuming restart (SC-14):\n"
|
||||
f"{marker.stderr}\nR-A log tail:\n{_named_log('R-A')[-3000:]}"
|
||||
)
|
||||
assert not SCRIPTS_FILE.exists(), (
|
||||
"install-scripts.txt NOT removed by the consuming boot (SC-14 self-clean)"
|
||||
)
|
||||
ra_log = _named_log("R-A")
|
||||
assert "## ComfyUI-Manager: EXECUTE =>" in ra_log and PIP_PKG in ra_log, (
|
||||
"R-A log lacks the startup-script execution block (SC-14)"
|
||||
)
|
||||
assert "Startup script completed." in ra_log, (
|
||||
"R-A log lacks the startup-script completion line (SC-14)"
|
||||
)
|
||||
|
||||
@needs_network
|
||||
def test_04_clone_residual_cleanup(self, allow_server):
|
||||
"""E2E-SC-21: clone-dir hygiene; FS-absence primary + installed-index
|
||||
cross-check while the server is still up (defensive, donor pattern)."""
|
||||
_remove_pack(PACK_NAME)
|
||||
assert not _pack_exists(PACK_NAME), "clone dir still present (SC-21 primary)"
|
||||
try:
|
||||
r = requests.get(f"{BASE_URL}/customnode/installed", timeout=15)
|
||||
if r.status_code == 200:
|
||||
installed = r.json()
|
||||
assert PACK_NAME not in installed, (
|
||||
f"{PACK_NAME} still in /customnode/installed after removal (SC-21)"
|
||||
)
|
||||
for key, pkg in installed.items():
|
||||
if isinstance(pkg, dict):
|
||||
assert pkg.get("cnr_id") != PACK_NAME and pkg.get("aux_id") != PACK_NAME, (
|
||||
f"installed entry {key!r} still references {PACK_NAME!r} (SC-21)"
|
||||
)
|
||||
except (ValueError, requests.RequestException):
|
||||
# Spec SC-21: if the response schema proves awkward, FS-absence
|
||||
# alone satisfies this row.
|
||||
pass
|
||||
|
||||
@needs_network
|
||||
def test_05_pip_residual_uninstall(self, allow_server):
|
||||
"""E2E-SC-22: S-B residual class 1 (venv package)."""
|
||||
r = _pip_uninstall()
|
||||
assert r.returncode == 0, f"pip uninstall failed (SC-22):\n{r.stdout}\n{r.stderr}"
|
||||
assert _pip_import_rc() != 0, "pip fixture importable after uninstall (SC-22)"
|
||||
|
||||
@needs_network
|
||||
def test_06_requirements_watchdog(self, allow_server):
|
||||
"""E2E-SC-42 (Q-6 watchdog, amendment A2): every management-script
|
||||
EXECUTE in the L-A launch log must be attributable to the owned
|
||||
fixture's OWN pinned requirements (python-slugify==8.0.4 — the
|
||||
nodepack fixture's deliberate invariant-4 ride-along requirement).
|
||||
Any other EXECUTE (e.g. a Manager-requirements install — the Q-6
|
||||
risk this row guards) FAILS the watchdog.
|
||||
|
||||
The allowlisted line doubles as LIVE proof of the invariant-4
|
||||
ride-along class: a dependency pip install executed inside the
|
||||
git-URL transaction without consulting allow_pip_install."""
|
||||
la_log = _named_log("L-A")
|
||||
banner = "## ComfyUI-Manager: EXECUTE =>"
|
||||
idx = 0
|
||||
execs = []
|
||||
while True:
|
||||
idx = la_log.find(banner, idx)
|
||||
if idx < 0:
|
||||
break
|
||||
execs.append(la_log[idx: idx + 600])
|
||||
idx += len(banner)
|
||||
# Non-vacuity (review iter-2 / A2 positive half): the ride-along
|
||||
# MUST have happened — exactly ONE management-script execution,
|
||||
# the fixture's single pinned requirement.
|
||||
assert len(execs) == 1, (
|
||||
f"expected exactly 1 management-script execution during L-A "
|
||||
f"(the fixture's pinned requirement ride-along), found "
|
||||
f"{len(execs)} (SC-42 / A2)"
|
||||
)
|
||||
window = execs[0]
|
||||
assert NODEPACK_PINNED_REQ in window, (
|
||||
"unexpected management-script execution during L-A — not "
|
||||
"attributable to the fixture's pinned requirement "
|
||||
f"({NODEPACK_PINNED_REQ}) (SC-42 watchdog):\n{window}"
|
||||
)
|
||||
# Line-level shape: it must be a pip-install command, not an
|
||||
# arbitrary script that merely mentions the requirement string.
|
||||
assert "'pip'" in window and "'install'" in window, (
|
||||
f"EXECUTE block is not a pip-install command (SC-42):\n{window}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("E2E_PUBLIC_LISTEN"),
|
||||
reason="public-listener row is opt-in (E2E_PUBLIC_LISTEN=1) — Q-7 default-off",
|
||||
)
|
||||
class TestPublicListener:
|
||||
"""E2E-SC-40 (opt-in): flags=true + 0.0.0.0 -> still 403 on both surfaces.
|
||||
Live proof of invariant 2 (predicate = flag AND loopback at REQUEST time)."""
|
||||
|
||||
def test_00_smoke_manager_version(self, public_server):
|
||||
_smoke(public_server)
|
||||
|
||||
def test_01_sa_public_deny(self, public_server):
|
||||
_require_smoke(public_server)
|
||||
r = requests.post(f"{BASE_URL}/customnode/install/git_url",
|
||||
json={"url": NODEPACK_URL}, timeout=30)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"error": "allow_git_url_install"}
|
||||
assert not _pack_exists(PACK_NAME)
|
||||
|
||||
def test_02_sb_public_deny(self, public_server):
|
||||
_require_smoke(public_server)
|
||||
r = requests.post(f"{BASE_URL}/customnode/install/pip",
|
||||
json={"packages": PIP_URL}, timeout=30)
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"error": "allow_pip_install"}
|
||||
assert _scripts_clean()
|
||||
|
||||
|
||||
class TestZeroResidual:
|
||||
"""E2E-SC-24: the complete [D3] residual inventory in one assertion block.
|
||||
Runs AFTER the server classes (their class-scoped fixtures have finalized:
|
||||
server stopped, config restored, backup deleted). The unmount half of the
|
||||
inventory is asserted by the mount_worktree finalizer itself — it cannot
|
||||
be asserted from inside the session while the mount is still live."""
|
||||
|
||||
def test_99_zero_residual_sweep(self, mount_worktree):
|
||||
# custom_nodes clean (incl. .trash_ fallback leftovers)
|
||||
assert not _pack_exists(PACK_NAME), "nodepack residue in custom_nodes (SC-24)"
|
||||
leftovers = [p.name for p in CN_DIR.iterdir()
|
||||
if p.name.startswith((".trash_", PACK_NAME))]
|
||||
assert not leftovers, f"residual entries in custom_nodes: {leftovers} (SC-24)"
|
||||
# venv clean
|
||||
assert _pip_import_rc() != 0, "pip fixture still importable (SC-24)"
|
||||
# Amendment A2: transitive-dep residual class swept
|
||||
for dep in TRANSITIVE_DEPS:
|
||||
rc = _run([str(VENV_PY), "-m", "pip", "show", dep]).returncode
|
||||
assert rc != 0, f"transitive dep {dep!r} survived the sweep (SC-24 / A2)"
|
||||
# reservation clean
|
||||
assert _scripts_clean(), "pip-test1 reservation residue (SC-24)"
|
||||
# config restored + backup DELETED
|
||||
assert CFG.is_file(), "config.ini missing after restore (SC-24)"
|
||||
cfg_text = CFG.read_text(errors="ignore")
|
||||
assert "allow_git_url_install" not in cfg_text, "staged flag leaked into restored config (SC-24)"
|
||||
assert "allow_pip_install" not in cfg_text, "staged flag leaked into restored config (SC-24)"
|
||||
assert not CFG_BACKUP.exists(), "stale config backup survived (SC-24 / peer R2)"
|
||||
# port free
|
||||
assert _port_free(), f"port {PORT} still bound (SC-24)"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Lift real production JS out of the shipped files and execute it in node.
|
||||
|
||||
WHY LIFT RATHER THAN IMPORT: the manager's front-end modules pull in
|
||||
``../../scripts/app.js`` and touch ``document`` at module scope, so node cannot
|
||||
import them. WHY LIFT RATHER THAN RETYPE: a retyped copy of a formatter stops
|
||||
tracking the formatter the moment someone edits it, and then the guard passes
|
||||
while the shipped code regresses. Lifting keeps the executed bytes the shipped
|
||||
bytes — if the production function changes, so does the text under test.
|
||||
|
||||
This mirrors tests/test_manager_markdown_escaping.py, which AST-extracts
|
||||
``convert_markdown_to_html`` rather than importing manager_server.
|
||||
|
||||
Consumers point ``JsSource`` at a directory of .js files; an override directory
|
||||
(e.g. one populated with ``git show <base>:js/foo.js``) makes the RED half of a
|
||||
fix reproducible in one command.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _skip_literal(source: str, pos: int) -> int:
|
||||
"""If a literal or comment starts at pos, return the index just past it.
|
||||
|
||||
Returns pos unchanged when nothing starts there. Template literals are
|
||||
consumed whole, which is safe for brace counting because a `${...}`
|
||||
substitution is itself balanced. A lone `/` is read as a regex literal —
|
||||
sanitizeHTML's own `.replace(/'/g, ...)` hides an apostrophe inside one, and
|
||||
no scanned range uses `/` as division. Were that to change, the assertion
|
||||
below fails loudly rather than returning a wrong slice.
|
||||
"""
|
||||
ch = source[pos]
|
||||
nxt = source[pos + 1] if pos + 1 < len(source) else ""
|
||||
if ch == "/" and nxt == "/":
|
||||
return source.index("\n", pos)
|
||||
if ch == "/" and nxt == "*":
|
||||
return source.index("*/", pos) + 2
|
||||
if ch not in "\"'`/":
|
||||
return pos
|
||||
cursor = pos + 1
|
||||
while cursor < len(source):
|
||||
if source[cursor] == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if source[cursor] == ch:
|
||||
return cursor + 1
|
||||
cursor += 1
|
||||
raise AssertionError("unterminated literal at offset %d" % (pos,))
|
||||
|
||||
|
||||
def slice_braced(source: str, start_marker: str) -> str:
|
||||
"""Return start_marker plus the balanced {...} block that follows it."""
|
||||
start = source.index(start_marker)
|
||||
pos = source.index("{", start + len(start_marker))
|
||||
depth = 0
|
||||
while pos < len(source):
|
||||
skipped = _skip_literal(source, pos)
|
||||
if skipped != pos:
|
||||
pos = skipped
|
||||
continue
|
||||
if source[pos] == "{":
|
||||
depth += 1
|
||||
elif source[pos] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start:pos + 1]
|
||||
pos += 1
|
||||
raise AssertionError("unbalanced braces after %r" % (start_marker,))
|
||||
|
||||
|
||||
def slice_object_entry(source: str, marker: str, indent: str = "\t\t") -> str:
|
||||
"""Return one object literal out of a `}, {`-chained array, by id marker.
|
||||
|
||||
These objects are literals inside an array, so the first `{` after the
|
||||
marker belongs to the NEXT entry — brace matching cannot be used. The entry
|
||||
instead ends at the first `}` sitting at the array's own indent.
|
||||
"""
|
||||
start = source.index(marker)
|
||||
end = source.index("\n%s}" % indent, start)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def line_containing(source: str, marker: str) -> str:
|
||||
"""Return the single source line that holds `marker` (fails if absent)."""
|
||||
start = source.index(marker)
|
||||
line_start = source.rfind("\n", 0, start) + 1
|
||||
return source[line_start:source.index("\n", start)]
|
||||
|
||||
|
||||
class JsSource:
|
||||
"""The shipped .js files under test, read from `js_dir`."""
|
||||
|
||||
def __init__(self, js_dir):
|
||||
self.js_dir = Path(js_dir)
|
||||
self._cache = {}
|
||||
|
||||
def text(self, filename: str) -> str:
|
||||
if filename not in self._cache:
|
||||
self._cache[filename] = (self.js_dir / filename).read_text(encoding="utf-8")
|
||||
return self._cache[filename]
|
||||
|
||||
def lift_declaration(self, filename: str, marker: str) -> str:
|
||||
"""Lift an `export function`/`export const` declaration, minus `export`."""
|
||||
return slice_braced(self.text(filename), marker).replace("export ", "", 1)
|
||||
|
||||
def lift_formatter(self, filename: str, anchor: str) -> str:
|
||||
"""Lift the `formatter: ...` at `anchor` as a bare function expression."""
|
||||
block = slice_braced(self.text(filename), anchor)
|
||||
marker = "formatter:"
|
||||
return block[block.index(marker) + len(marker):].strip()
|
||||
|
||||
def lift_span(self, filename: str, start_marker: str, end_marker: str) -> str:
|
||||
"""Lift the source between two markers, end_marker included."""
|
||||
source = self.text(filename)
|
||||
start = source.index(start_marker)
|
||||
end = source.index(end_marker, start) + len(end_marker)
|
||||
return source[start:end]
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
"""Execute an ES-module script and parse the single JSON object it prints."""
|
||||
assert NODE is not None, "node is required to execute the lifted production JS"
|
||||
result = subprocess.run(
|
||||
[NODE, "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, "node failed:\n%s" % (result.stderr,)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
class Collector(HTMLParser):
|
||||
"""Collects the tags and attributes a browser would actually build."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tags = []
|
||||
self.attrs = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.tags.append(tag)
|
||||
self.attrs.extend(attrs)
|
||||
|
||||
handle_startendtag = handle_starttag
|
||||
|
||||
|
||||
def parse(markup: str) -> Collector:
|
||||
collector = Collector()
|
||||
collector.feed(markup)
|
||||
collector.close()
|
||||
return collector
|
||||
|
||||
|
||||
def event_handlers(markup: str):
|
||||
"""Every on* attribute a browser would attach for this markup."""
|
||||
return [name for name, _ in parse(markup).attrs if name.lower().startswith("on")]
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Load Python escaping code without importing the ComfyUI server.
|
||||
|
||||
MANAGER_GLOB_DIR can point to another revision for regression checks. Loading
|
||||
manager_util by path avoids shadowing the standard library's glob module.
|
||||
"""
|
||||
import ast
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (Path(__file__).resolve().parent.parent / "glob"))
|
||||
|
||||
|
||||
def load_manager_util():
|
||||
path = GLOB_DIR / "manager_util.py"
|
||||
spec = importlib.util.spec_from_file_location("_manager_util_under_test", path)
|
||||
assert spec is not None and spec.loader is not None, f"cannot load {path}"
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def load_markdown_functions(manager_util):
|
||||
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
|
||||
names = {"convert_markdown_to_html", "populate_markdown"}
|
||||
functions = [node for node in ast.parse(source).body
|
||||
if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||
assert len(functions) == len(names) and {node.name for node in functions} == names
|
||||
namespace = {"re": re, "manager_util": manager_util}
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), "<markdown-extract>", "exec"), namespace)
|
||||
return namespace
|
||||
@@ -1,121 +0,0 @@
|
||||
"""AC verification for wi-001 (B2): Content-Type rejection helper.
|
||||
|
||||
Validates _reject_simple_form_content_type against the 5-item curl matrix
|
||||
from the WI's acceptance criteria using aiohttp test client. The test
|
||||
builds a minimal aiohttp app that mirrors the helper's wiring into a
|
||||
no-body POST handler, so we exercise the real request.content_type
|
||||
parsing path rather than a mock.
|
||||
|
||||
AC matrix:
|
||||
form-url → 400
|
||||
multipart → 400
|
||||
text/plain → 400
|
||||
no-CT → 200
|
||||
application/json → 200
|
||||
"""
|
||||
import asyncio
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
|
||||
# Parse the helper from manager_server.py without importing it, to avoid
|
||||
# pulling in the full ComfyUI/PromptServer stack. Note: we intentionally do
|
||||
# NOT add the `glob/` directory to sys.path — the dir name would shadow
|
||||
# Python's stdlib `glob` module and break pytest collection.
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _load_helper():
|
||||
"""Parse manager_server.py and execute only the helper definition."""
|
||||
import ast
|
||||
|
||||
source = (REPO_ROOT / "glob" / "manager_server.py").read_text()
|
||||
tree = ast.parse(source)
|
||||
wanted = {"_SIMPLE_FORM_CONTENT_TYPES", "_reject_simple_form_content_type"}
|
||||
nodes = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in wanted:
|
||||
nodes.append(node)
|
||||
elif isinstance(node, ast.FunctionDef) and node.name in wanted:
|
||||
nodes.append(node)
|
||||
module = ast.Module(body=nodes, type_ignores=[])
|
||||
ns = {"web": web, "frozenset": frozenset}
|
||||
exec(compile(module, "manager_server_helpers", "exec"), ns)
|
||||
return ns["_reject_simple_form_content_type"]
|
||||
|
||||
|
||||
_reject_simple_form_content_type = _load_helper()
|
||||
|
||||
|
||||
async def _handler(request):
|
||||
resp = _reject_simple_form_content_type(request)
|
||||
if resp is not None:
|
||||
return resp
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
class ContentTypeRejectionTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(cls.loop)
|
||||
app = web.Application()
|
||||
app.router.add_post("/noop", _handler)
|
||||
cls.server = TestServer(app, loop=cls.loop)
|
||||
cls.client = TestClient(cls.server, loop=cls.loop)
|
||||
cls.loop.run_until_complete(cls.client.start_server())
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.loop.run_until_complete(cls.client.close())
|
||||
cls.loop.close()
|
||||
|
||||
def _post(self, headers):
|
||||
async def go():
|
||||
return await self.client.post("/noop", headers=headers, data=b"")
|
||||
|
||||
return self.loop.run_until_complete(go())
|
||||
|
||||
def test_form_urlencoded_rejected(self):
|
||||
r = self._post({"Content-Type": "application/x-www-form-urlencoded"})
|
||||
self.assertEqual(r.status, 400)
|
||||
|
||||
def test_multipart_form_data_rejected(self):
|
||||
# aiohttp requires a boundary for multipart; helper should still reject
|
||||
# based on the primary mimetype.
|
||||
r = self._post({"Content-Type": "multipart/form-data; boundary=xyz"})
|
||||
self.assertEqual(r.status, 400)
|
||||
|
||||
def test_text_plain_rejected(self):
|
||||
r = self._post({"Content-Type": "text/plain"})
|
||||
self.assertEqual(r.status, 400)
|
||||
|
||||
def test_no_content_type_allowed(self):
|
||||
# Explicitly strip Content-Type: aiohttp client may add a default,
|
||||
# so we use a raw request to ensure absence is tested.
|
||||
async def go():
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
self.client.make_url("/noop"),
|
||||
data=None,
|
||||
skip_auto_headers=["Content-Type"],
|
||||
) as resp:
|
||||
return resp.status
|
||||
|
||||
status = self.loop.run_until_complete(go())
|
||||
self.assertEqual(status, 200)
|
||||
|
||||
def test_application_json_allowed(self):
|
||||
r = self._post({"Content-Type": "application/json"})
|
||||
self.assertEqual(r.status, 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Behavioral checks against real v3 modules in an isolated ComfyUI runtime."""
|
||||
|
||||
import pytest
|
||||
|
||||
from test_install_flags_config import _run_child
|
||||
|
||||
|
||||
PRELUDE = '''
|
||||
import asyncio, io, logging, pathlib, zipfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
import cnr_utils, manager_util
|
||||
from comfy.cli_args import args
|
||||
args.listen = '0.0.0.0'
|
||||
core = manager_core
|
||||
core.manager_funcs.is_flagged_install_allowed = lambda: False
|
||||
config = core.get_config()
|
||||
config['allow_flagged_nodepack_install'] = False
|
||||
nodes = pathlib.Path(tmp) / 'custom_nodes'
|
||||
nodes.mkdir()
|
||||
core.get_default_custom_nodes_path = lambda: str(nodes)
|
||||
manager = core.UnifiedManager()
|
||||
core.unified_manager = manager
|
||||
terminal = io.StringIO()
|
||||
logging.getLogger().addHandler(logging.StreamHandler(terminal))
|
||||
status = 'NodeVersionStatusFlagged'
|
||||
http = 200
|
||||
get = Mock(side_effect=lambda *a, **k: SimpleNamespace(status_code=http, json=lambda: {
|
||||
'id': 'version-id', 'node_id': 'fixture', 'version': '2.0.0', 'status': status,
|
||||
'downloadUrl': 'https://example.invalid/fixture.zip',
|
||||
}))
|
||||
cnr_utils.requests.get = get
|
||||
def download(url, directory, name):
|
||||
with zipfile.ZipFile(pathlib.Path(directory) / name, 'w') as archive:
|
||||
archive.writestr('__init__.py', 'VERSION = "2.0.0"')
|
||||
archive.writestr('pyproject.toml', '[project]\\nname = "fixture"\\nversion = "2.0.0"\\n')
|
||||
core.manager_downloader.download_url = Mock(side_effect=download)
|
||||
core.manager_downloader.basic_download_url = core.manager_downloader.download_url
|
||||
manager.execute_install_script = Mock(return_value=True)
|
||||
'''
|
||||
|
||||
|
||||
def run(body):
|
||||
stub = '''
|
||||
comfy = types.ModuleType('comfy')
|
||||
cli_args = types.ModuleType('comfy.cli_args')
|
||||
cli_args.args = types.SimpleNamespace(listen='127.0.0.1')
|
||||
sys.modules['comfy'] = comfy
|
||||
sys.modules['comfy.cli_args'] = cli_args
|
||||
'''
|
||||
return _run_child(stub + PRELUDE + body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('permission,allowed', [(None, True), ('true', True), ('false', False)])
|
||||
def test_cli_install_uses_only_parent_permission(permission, allowed):
|
||||
data = run(f'''
|
||||
permission = {permission!r}
|
||||
os.environ.pop('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', None)
|
||||
if permission is not None:
|
||||
os.environ['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = permission
|
||||
core.manager_funcs = core.ManagerFuncs()
|
||||
args.listen = '127.0.0.1' if permission == 'false' else '0.0.0.0'
|
||||
config['allow_flagged_nodepack_install'] = permission == 'false'
|
||||
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
|
||||
print(json.dumps({{'ok': result.result, 'queries': get.call_count,
|
||||
'downloads': core.manager_downloader.download_url.call_count}}))
|
||||
''')
|
||||
assert data == {'ok': allowed, 'queries': 1, 'downloads': int(allowed)}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status,permission,allowed', [
|
||||
('Active', False, True), ('Pending', False, True),
|
||||
('Flagged', False, False), ('Flagged', True, True),
|
||||
])
|
||||
def test_install_policy_before_download(status, permission, allowed):
|
||||
data = run(f'''
|
||||
status = {'NodeVersionStatus' + status!r}
|
||||
core.manager_funcs.is_flagged_install_allowed = lambda: {permission!r}
|
||||
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
|
||||
print(json.dumps({{'ok': result.result, 'message': result.msg,
|
||||
'terminal': terminal.getvalue(),
|
||||
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count,
|
||||
'installed': (nodes / 'fixture/__init__.py').exists()}}))
|
||||
''')
|
||||
assert data['ok'] is allowed, data
|
||||
assert data['installed'] is allowed, data
|
||||
assert data['downloads'] == int(allowed), data
|
||||
assert data['queries'] == 1, data
|
||||
if not allowed:
|
||||
assert data['message'] == ('This action is not allowed by the current security configuration. '
|
||||
'See the terminal for details.')
|
||||
for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]',
|
||||
'allow_flagged_nodepack_install = true', 'config.ini',
|
||||
'trusted private network', 'Restart ComfyUI'):
|
||||
assert detail in data['terminal'], data
|
||||
else:
|
||||
assert 'allow_flagged_nodepack_install' not in data['terminal']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('http', [403, 404, 500])
|
||||
def test_override_does_not_bypass_registry_refusal(http):
|
||||
data = run(f'''
|
||||
http = {http}
|
||||
core.manager_funcs.is_flagged_install_allowed = lambda: True
|
||||
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
|
||||
print(json.dumps({{'ok': result.result, 'queries': get.call_count,
|
||||
'downloads': core.manager_downloader.download_url.call_count}}))
|
||||
''')
|
||||
assert data == {'ok': False, 'queries': 1, 'downloads': 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['install', 'lazy', 'instant'])
|
||||
@pytest.mark.parametrize('return_postinstall', [False, True])
|
||||
def test_direct_cnr_execution_and_postinstall(operation, return_postinstall):
|
||||
data = run(f'''
|
||||
status = 'NodeVersionStatusActive'
|
||||
operation = {operation!r}
|
||||
return_postinstall = {return_postinstall!r}
|
||||
installed = nodes / 'fixture'
|
||||
if operation != 'install':
|
||||
installed.mkdir()
|
||||
(installed / '__init__.py').write_text('original')
|
||||
(installed / '.tracking').write_text('__init__.py')
|
||||
manager.active_nodes['fixture'] = ('1.0.0', str(installed))
|
||||
manager.reserve_cnr_switch = Mock(return_value=True)
|
||||
if operation == 'install':
|
||||
result = manager.cnr_install('fixture', instant_execution=True,
|
||||
no_deps=True, return_postinstall=return_postinstall)
|
||||
else:
|
||||
result = manager.cnr_switch_version('fixture', instant_execution=operation == 'instant',
|
||||
no_deps=True, return_postinstall=return_postinstall)
|
||||
assert result.result, result.msg
|
||||
assert result.target == ('2.0.0' if operation == 'instant' else None)
|
||||
effect = manager.reserve_cnr_switch if operation == 'lazy' else manager.execute_install_script
|
||||
before = effect.call_count
|
||||
if return_postinstall:
|
||||
assert result.postinstall()
|
||||
if operation == 'lazy':
|
||||
assert effect.call_args.args[1] == 'https://example.invalid/fixture.zip'
|
||||
assert effect.call_args.args[-2:] == (True, 'NodeVersionStatusActive')
|
||||
else:
|
||||
assert effect.call_args.kwargs == {{'instant_execution': True, 'no_deps': True}}
|
||||
print(json.dumps({{'queries': get.call_count,
|
||||
'downloads': core.manager_downloader.download_url.call_count,
|
||||
'before': before, 'after': effect.call_count,
|
||||
'content': (installed / '__init__.py').read_text()}}))
|
||||
''')
|
||||
assert data == {'queries': 1, 'downloads': int(operation != 'lazy'),
|
||||
'before': int(not return_postinstall), 'after': 1,
|
||||
'content': 'original' if operation == 'lazy' else 'VERSION = "2.0.0"'}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['install', 'reinstall', 'lazy', 'instant', 'snapshot'])
|
||||
def test_denial_preserves_installed_version(operation):
|
||||
data = run(f'''
|
||||
installed = nodes / 'fixture'
|
||||
installed.mkdir()
|
||||
(installed / '__init__.py').write_text('VERSION = "1.0.0"')
|
||||
(installed / '.tracking').write_text('__init__.py')
|
||||
manager.active_nodes['fixture'] = ('1.0.0', str(installed))
|
||||
manager.reserve_cnr_switch = Mock(return_value=True)
|
||||
operation = {operation!r}
|
||||
if operation == 'snapshot':
|
||||
manager.reload = AsyncMock()
|
||||
manager.get_custom_nodes = AsyncMock(return_value={{}})
|
||||
core.manager_util.restore_pip_snapshot = Mock()
|
||||
snapshot = pathlib.Path(tmp) / 'snapshot.json'
|
||||
snapshot.write_text(json.dumps({{'cnr_custom_nodes': {{'fixture': '2.0.0'}},
|
||||
'git_custom_nodes': {{}}, 'file_custom_nodes': []}}))
|
||||
asyncio.run(core.restore_snapshot(str(snapshot)))
|
||||
elif operation in ('lazy', 'instant'):
|
||||
result = manager.cnr_switch_version('fixture', '2.0.0', instant_execution=operation == 'instant')
|
||||
assert not result.result, result.msg
|
||||
elif operation == 'reinstall':
|
||||
result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0'))
|
||||
assert not result.result, result.msg
|
||||
else:
|
||||
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
|
||||
assert not result.result, result.msg
|
||||
print(json.dumps({{'files': (installed / '__init__.py').read_text(),
|
||||
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count,
|
||||
'reservations': manager.reserve_cnr_switch.call_count}}))
|
||||
''')
|
||||
assert data == {'files': 'VERSION = "1.0.0"', 'queries': 1, 'downloads': 0, 'reservations': 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('listen,allowed', [
|
||||
('127.0.0.1', True), ('::1', True), ('127.0.0.1,::1', True),
|
||||
('0.0.0.0', False), ('::', False), ('0.0.0.0,::', False),
|
||||
('192.168.1.2', False), ('127.0.0.1,0.0.0.0', False), ('', False),
|
||||
])
|
||||
def test_all_listener_addresses_must_be_loopback(listen, allowed):
|
||||
data = run(f'''
|
||||
print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, {listen!r})))
|
||||
''')
|
||||
assert data is allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize('addresses,allowed', [
|
||||
(['127.0.0.1', '::1'], True), (['127.0.0.1', '192.168.1.2'], False),
|
||||
([], False), (None, False),
|
||||
])
|
||||
def test_hostname_must_resolve_exclusively_to_loopback(addresses, allowed):
|
||||
data = run(f'''
|
||||
addresses = {addresses!r}
|
||||
if addresses is None:
|
||||
manager_util.socket.getaddrinfo = Mock(side_effect=OSError('unresolvable'))
|
||||
else:
|
||||
manager_util.socket.getaddrinfo = Mock(return_value=[
|
||||
(0, 0, 0, '', (ip, 0)) for ip in addresses])
|
||||
print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, 'host.test')))
|
||||
''')
|
||||
assert data is allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize('state', ['nightly', 'disabled'])
|
||||
def test_denial_preserves_enabled_state(state):
|
||||
data = run(f'''
|
||||
state = {state!r}
|
||||
installed = nodes / ('fixture' if state == 'nightly' else '.disabled/fixture@1_0_0')
|
||||
installed.mkdir(parents=True)
|
||||
(installed / '__init__.py').write_text('original')
|
||||
if state == 'nightly':
|
||||
manager.active_nodes['fixture'] = ('nightly', str(installed))
|
||||
else:
|
||||
manager.cnr_inactive_nodes['fixture'] = {{'1.0.0': str(installed)}}
|
||||
before = repr((manager.active_nodes, manager.cnr_inactive_nodes))
|
||||
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
|
||||
print(json.dumps({{'ok': result.result, 'original': (installed / '__init__.py').read_text(),
|
||||
'unchanged': before == repr((manager.active_nodes, manager.cnr_inactive_nodes)),
|
||||
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count}}))
|
||||
''')
|
||||
assert data == {'ok': False, 'original': 'original', 'unchanged': True, 'queries': 1, 'downloads': 0}
|
||||
|
||||
|
||||
def test_exact_installed_version_enables_without_registry_lookup():
|
||||
data = run('''
|
||||
installed = nodes / '.disabled/fixture@2_0_0'
|
||||
installed.mkdir(parents=True)
|
||||
(installed / '__init__.py').write_text('already installed')
|
||||
manager.cnr_inactive_nodes['fixture'] = {'2.0.0': str(installed)}
|
||||
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
|
||||
assert manager.active_nodes['fixture'] == ('2.0.0', str(nodes / 'fixture'))
|
||||
assert asyncio.run(manager.install_by_id('fixture', '2.0.0')).action == 'skip'
|
||||
print(json.dumps({'ok': result.result, 'old_path': installed.exists(),
|
||||
'content': (nodes / 'fixture/__init__.py').read_text(), 'queries': get.call_count,
|
||||
'downloads': core.manager_downloader.download_url.call_count,
|
||||
'scripts': manager.execute_install_script.call_count}))
|
||||
''')
|
||||
assert data == {'ok': True, 'old_path': False, 'content': 'already installed',
|
||||
'queries': 0, 'downloads': 0, 'scripts': 0}
|
||||
|
||||
|
||||
def test_failed_uninstall_stops_reinstall():
|
||||
data = run('''
|
||||
status = 'NodeVersionStatusActive'
|
||||
manager.unified_uninstall = Mock(return_value=core.ManagedResult('uninstall').fail('cannot remove'))
|
||||
result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0'))
|
||||
print(json.dumps({'ok': result.result, 'message': result.msg, 'queries': get.call_count,
|
||||
'downloads': core.manager_downloader.download_url.call_count}))
|
||||
''')
|
||||
assert data == {'ok': False, 'message': 'cannot remove', 'queries': 1, 'downloads': 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('version', ['nightly', 'unknown'])
|
||||
def test_git_reinstall_replaces_pack_and_reruns_script(version):
|
||||
data = run(f'''
|
||||
version = {version!r}
|
||||
installed = nodes / 'fixture'
|
||||
installed.mkdir()
|
||||
(installed / 'original.txt').write_text('original')
|
||||
repo_url = 'https://example.invalid/fixture'
|
||||
if version == 'nightly':
|
||||
manager.active_nodes['fixture'] = ('nightly', str(installed))
|
||||
else:
|
||||
manager.unknown_active_nodes['fixture'] = (repo_url, str(installed))
|
||||
manager.get_custom_nodes = AsyncMock(return_value={{
|
||||
'fixture': {{'repository': repo_url, 'files': [repo_url]}},
|
||||
}})
|
||||
get.side_effect = AssertionError('Git needs no CNR install query')
|
||||
manager.processed_install.add(str(installed / 'install.py'))
|
||||
manager.execute_install_script = core.UnifiedManager.execute_install_script.__get__(manager)
|
||||
core.try_install_script = Mock(return_value=True)
|
||||
def clone(url, path, **kwargs):
|
||||
assert url == repo_url
|
||||
path = pathlib.Path(path)
|
||||
assert not path.exists()
|
||||
path.mkdir()
|
||||
(path / 'replacement.txt').write_text('installed')
|
||||
(path / 'install.py').write_text('')
|
||||
assert manager.execute_install_script(url, str(path))
|
||||
return core.ManagedResult('install-git')
|
||||
manager.repo_install = Mock(side_effect=clone)
|
||||
result = asyncio.run(manager.reinstall_by_id('fixture', version))
|
||||
print(json.dumps({{'ok': result.result, 'original': (installed / 'original.txt').exists(),
|
||||
'replacement': (installed / 'replacement.txt').read_text(),
|
||||
'clones': manager.repo_install.call_count, 'scripts': core.try_install_script.call_count}}))
|
||||
''')
|
||||
assert data == {'ok': True, 'original': False, 'replacement': 'installed', 'clones': 1, 'scripts': 1}
|
||||
|
||||
|
||||
def test_config_defaults_and_manual_edits_survive_settings_save():
|
||||
data = _run_child('''
|
||||
write_ini('[default]\\nsecurity_level = normal\\n')
|
||||
first = fresh_read()['allow_flagged_nodepack_install']
|
||||
write_ini('[default]\\nsecurity_level = normal\\nallow_flagged_nodepack_install = true\\n')
|
||||
manager_core.get_config()['db_mode'] = 'local'
|
||||
manager_core.write_config()
|
||||
cached = manager_core.get_config()['allow_flagged_nodepack_install']
|
||||
fresh = fresh_read()['allow_flagged_nodepack_install']
|
||||
print(json.dumps([first, cached, fresh]))
|
||||
''')
|
||||
assert data == [False, False, True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw,expected', [('TRUE', True), ('false', False), ('garbage', False)])
|
||||
def test_config_value_roundtrip(raw, expected):
|
||||
data = _run_child(f'''
|
||||
write_ini('[default]\\nallow_flagged_nodepack_install = {raw}\\n')
|
||||
loaded = fresh_read()['allow_flagged_nodepack_install']
|
||||
manager_core.get_config()['allow_flagged_nodepack_install'] = not loaded
|
||||
manager_core.write_config()
|
||||
print(json.dumps([loaded, fresh_read()['allow_flagged_nodepack_install']]))
|
||||
''')
|
||||
assert data == [expected, not expected]
|
||||
@@ -1,189 +0,0 @@
|
||||
"""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 / "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> & 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> <Flux>'}};
|
||||
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 <Flux> <img src=x onerror=window.__probe=1>'},
|
||||
{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 <Flux>", "<flux>", "<flux>"),
|
||||
("custom-nodes-manager.js", "description", "Notes <Flux>", "<flux>", "<flux>"),
|
||||
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "<alias>"),
|
||||
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "<alias>"),
|
||||
("model-manager.js", "name", "Model <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "description", "<b>Notes</b> <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "filename", "model <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "type", "Type <Flux>", "<flux>", "<flux>"),
|
||||
])
|
||||
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])")
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Unit tests for the dedicated-install-flag predicate.
|
||||
|
||||
Covers `is_dedicated_install_allowed(flag_value, listen_address)` in
|
||||
glob/manager_server.py:
|
||||
|
||||
- Truth table: allowed iff flag is true AND the listener is loopback.
|
||||
- REPLACE-by-construction: the 2-arg signature has no security_level /
|
||||
network_mode parameter and the body references no config machinery,
|
||||
so security_level cannot influence the outcome in either direction.
|
||||
- Cross-flag isolation: a single flag_value input cannot consult the
|
||||
other flag.
|
||||
- Request-time evaluation: the body must not read the import-time
|
||||
`is_local_mode` snapshot (callers pass args.listen per request).
|
||||
|
||||
Harness: glob/manager_server.py is not importable under the test runner
|
||||
(`from comfy.cli_args import args`, PromptServer), so we AST-parse the
|
||||
file and exec only the wanted pure defs — `glob/` is never added to
|
||||
sys.path (the dir name shadows the stdlib `glob`).
|
||||
"""
|
||||
import ast
|
||||
import inspect
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MANAGER_SERVER_PATH = REPO_ROOT / "glob" / "manager_server.py"
|
||||
|
||||
_WANTED = {"is_loopback", "is_dedicated_install_allowed"}
|
||||
|
||||
|
||||
def _load_predicates():
|
||||
"""Parse manager_server.py; exec only the wanted pure function defs."""
|
||||
source = MANAGER_SERVER_PATH.read_text()
|
||||
tree = ast.parse(source)
|
||||
nodes = []
|
||||
node_by_name = {}
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in _WANTED:
|
||||
nodes.append(node)
|
||||
node_by_name[node.name] = node
|
||||
missing = _WANTED - node_by_name.keys()
|
||||
assert not missing, f"expected pure defs missing from manager_server.py: {missing}"
|
||||
module = ast.Module(body=nodes, type_ignores=[])
|
||||
ns: dict = {"bool": bool}
|
||||
exec(compile(module, "manager_server_predicates", "exec"), ns)
|
||||
return ns, node_by_name
|
||||
|
||||
|
||||
_NS, _NODES = _load_predicates()
|
||||
IS_LOOPBACK: Any = _NS["is_loopback"]
|
||||
PREDICATE: Any = _NS["is_dedicated_install_allowed"]
|
||||
PREDICATE_NODE = _NODES["is_dedicated_install_allowed"]
|
||||
|
||||
|
||||
class IsLoopbackBehaviorTest(unittest.TestCase):
|
||||
"""Pins the loopback term the predicate composes."""
|
||||
|
||||
def test_ipv4_loopback(self):
|
||||
self.assertTrue(IS_LOOPBACK("127.0.0.1"))
|
||||
|
||||
def test_public_address(self):
|
||||
self.assertFalse(IS_LOOPBACK("0.0.0.0"))
|
||||
|
||||
def test_ipv6_loopback(self):
|
||||
self.assertTrue(IS_LOOPBACK("::1"))
|
||||
|
||||
def test_invalid_address_reads_false(self):
|
||||
# Non-IP strings deny-by-default (ValueError path).
|
||||
self.assertFalse(IS_LOOPBACK("localhost"))
|
||||
self.assertFalse(IS_LOOPBACK(""))
|
||||
|
||||
|
||||
class DedicatedInstallPredicateTest(unittest.TestCase):
|
||||
"""P-direct truth table + REPLACE-by-construction."""
|
||||
|
||||
def test_truth_table(self):
|
||||
"""allowed iff flag AND loopback."""
|
||||
cases = [
|
||||
# (flag_value, listen_address, expected)
|
||||
(True, "127.0.0.1", True),
|
||||
(False, "127.0.0.1", False),
|
||||
(True, "0.0.0.0", False),
|
||||
(False, "0.0.0.0", False),
|
||||
(True, "::1", True),
|
||||
(True, "not-an-ip", False), # invalid listen -> deny
|
||||
]
|
||||
for flag_value, listen, expected in cases:
|
||||
with self.subTest(flag=flag_value, listen=listen):
|
||||
result = PREDICATE(flag_value, listen)
|
||||
self.assertIsInstance(result, bool)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_falsy_flag_values_deny(self):
|
||||
"""Secure-by-default: any falsy flag never allows."""
|
||||
for falsy in (False, None, 0, ""):
|
||||
with self.subTest(flag=falsy):
|
||||
self.assertFalse(PREDICATE(falsy, "127.0.0.1"))
|
||||
|
||||
def test_signature_has_no_security_level(self):
|
||||
"""Exactly (flag_value, listen_address) — no security_level term."""
|
||||
params = list(inspect.signature(PREDICATE).parameters)
|
||||
self.assertEqual(params, ["flag_value", "listen_address"])
|
||||
for name in params:
|
||||
self.assertNotIn("security", name)
|
||||
self.assertNotIn("network_mode", name)
|
||||
|
||||
def test_body_free_of_config_machinery(self):
|
||||
"""Body references no security_level plumbing, config reader, or the
|
||||
import-time `is_local_mode` snapshot (request-time evaluation)."""
|
||||
forbidden = {
|
||||
"is_allowed_security_level",
|
||||
"security_level",
|
||||
"get_config",
|
||||
"core",
|
||||
"is_local_mode",
|
||||
"network_mode",
|
||||
"args",
|
||||
}
|
||||
seen = set()
|
||||
for node in ast.walk(PREDICATE_NODE):
|
||||
if isinstance(node, ast.Name):
|
||||
seen.add(node.id)
|
||||
elif isinstance(node, ast.Attribute):
|
||||
seen.add(node.attr)
|
||||
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
seen.add(node.value)
|
||||
self.assertEqual(
|
||||
seen & forbidden, set(),
|
||||
"predicate body must stay config-import-free",
|
||||
)
|
||||
|
||||
def test_cross_flag_isolation_by_construction(self):
|
||||
"""A single flag_value input cannot consult the other flag."""
|
||||
seen_strings = {
|
||||
node.value
|
||||
for node in ast.walk(PREDICATE_NODE)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
}
|
||||
self.assertNotIn("allow_git_url_install", seen_strings)
|
||||
self.assertNotIn("allow_pip_install", seen_strings)
|
||||
self.assertTrue(PREDICATE(True, "127.0.0.1"))
|
||||
self.assertFalse(PREDICATE(False, "127.0.0.1"))
|
||||
|
||||
def test_purity_deterministic(self):
|
||||
"""Pure predicate — repeat calls identical."""
|
||||
for _ in range(3):
|
||||
self.assertTrue(PREDICATE(True, "127.0.0.1"))
|
||||
self.assertFalse(PREDICATE(True, "0.0.0.0"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,242 +0,0 @@
|
||||
"""Config-contract tests for the dedicated install flags.
|
||||
|
||||
Drives the real glob/manager_core config reader/writer through a
|
||||
subprocess-isolated harness and pins: missing keys read False
|
||||
(secure-by-default), only case-insensitive "true" is truthy, write
|
||||
round-trips losslessly, edits need a restart (cached_config), the
|
||||
exception-fallback path supplies False, no auto-migration seeds the
|
||||
flags from a legacy security_level, and the get_bool missing->False
|
||||
quirk the flags rely on stays frozen.
|
||||
|
||||
Harness: the child process injects a stub `folder_paths` (routing
|
||||
import-time side effects into a tmpdir, and making has_system_user_api()
|
||||
True so force_security_level_if_needed does not force 'strong'), prepends
|
||||
`glob/` to ITS OWN sys.path (shadowing of stdlib `glob` confined to the
|
||||
child), points manager_core.manager_config_path at a tmp config.ini,
|
||||
resets cached_config, runs the scenario, and prints one JSON line for the
|
||||
parent to assert.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
_CHILD_PREAMBLE = textwrap.dedent(
|
||||
"""
|
||||
import sys, types, tempfile, os, json
|
||||
tmp = tempfile.mkdtemp(prefix="cm_flags_cfg_")
|
||||
stub = types.ModuleType("folder_paths")
|
||||
stub.get_user_directory = lambda: tmp
|
||||
stub.get_system_user_directory = lambda *a, **k: os.path.join(tmp, "sysuser")
|
||||
sys.modules["folder_paths"] = stub
|
||||
sys.path.insert(0, {glob_path!r})
|
||||
import manager_core
|
||||
CONFIG_PATH = os.path.join(tmp, "config.ini")
|
||||
manager_core.manager_config_path = CONFIG_PATH
|
||||
manager_core.cached_config = None
|
||||
|
||||
def write_ini(text):
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
def fresh_read():
|
||||
manager_core.cached_config = None
|
||||
return manager_core.get_config()
|
||||
|
||||
def flag_view(cfg):
|
||||
return {{
|
||||
"git": cfg.get("allow_git_url_install", "<ABSENT>"),
|
||||
"pip": cfg.get("allow_pip_install", "<ABSENT>"),
|
||||
}}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _run_child(body):
|
||||
"""Run a scenario body in the isolated child; return its JSON payload."""
|
||||
script = _CHILD_PREAMBLE.format(glob_path=str(REPO_ROOT / "glob")) + textwrap.dedent(body)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(
|
||||
"config-harness child failed (rc=%d). stderr tail:\n%s"
|
||||
% (proc.returncode, "\n".join(proc.stderr.strip().splitlines()[-8:]))
|
||||
)
|
||||
lines = proc.stdout.strip().splitlines()
|
||||
if not lines:
|
||||
raise AssertionError(
|
||||
"config-harness child exited 0 but produced no stdout. stderr tail:\n%s"
|
||||
% "\n".join(proc.stderr.strip().splitlines()[-8:])
|
||||
)
|
||||
last_line = lines[-1]
|
||||
try:
|
||||
return json.loads(last_line)
|
||||
except json.JSONDecodeError as e:
|
||||
raise AssertionError(
|
||||
"config-harness child emitted a non-JSON last line: %r\nfull stdout:\n%s"
|
||||
% (last_line, proc.stdout)
|
||||
) from e
|
||||
|
||||
|
||||
class InstallFlagsConfigContractTest(unittest.TestCase):
|
||||
def test_sc17_missing_keys_read_false(self):
|
||||
"""Both keys absent from config.ini -> both flags read False
|
||||
(secure-by-default)."""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini("[default]\\nsecurity_level = normal\\n")
|
||||
print(json.dumps(flag_view(fresh_read())))
|
||||
"""
|
||||
)
|
||||
self.assertIs(payload["git"], False)
|
||||
self.assertIs(payload["pip"], False)
|
||||
|
||||
def test_sc18_malformed_and_case_matrix(self):
|
||||
"""Only case-insensitive "true" is truthy; malformed -> False."""
|
||||
payload = _run_child(
|
||||
"""
|
||||
out = {}
|
||||
for raw in ["1", "yes", "TRUE", "true ", "true"]:
|
||||
write_ini("[default]\\nallow_git_url_install = %s\\nallow_pip_install = %s\\n" % (raw, raw))
|
||||
cfg = fresh_read()
|
||||
out[raw] = flag_view(cfg)
|
||||
print(json.dumps(out))
|
||||
"""
|
||||
)
|
||||
expected = {
|
||||
"1": False, # malformed: numeric truthiness NOT honored
|
||||
"yes": False, # malformed: yes/no NOT honored
|
||||
"TRUE": True, # case-insensitive read (:1724)
|
||||
"true ": True, # configparser strips surrounding whitespace
|
||||
"true": True,
|
||||
}
|
||||
for raw, want in expected.items():
|
||||
with self.subTest(value=raw):
|
||||
self.assertIs(payload[raw]["git"], want)
|
||||
self.assertIs(payload[raw]["pip"], want)
|
||||
|
||||
def test_sc19_write_round_trip(self):
|
||||
"""write_config persists str(bool); round-trip is lossless."""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini("[default]\\nsecurity_level = normal\\n")
|
||||
cfg = fresh_read()
|
||||
cfg["allow_git_url_install"] = True
|
||||
cfg["allow_pip_install"] = False
|
||||
manager_core.write_config()
|
||||
raw = open(CONFIG_PATH).read()
|
||||
reread = flag_view(fresh_read())
|
||||
print(json.dumps({
|
||||
"raw_has_git_true": "allow_git_url_install = True" in raw,
|
||||
"raw_has_pip_false": "allow_pip_install = False" in raw,
|
||||
"reread": reread,
|
||||
}))
|
||||
"""
|
||||
)
|
||||
self.assertTrue(
|
||||
payload["raw_has_git_true"],
|
||||
"write_config must persist allow_git_url_install = True in [default]",
|
||||
)
|
||||
self.assertTrue(
|
||||
payload["raw_has_pip_false"],
|
||||
"write_config must persist allow_pip_install = False in [default]",
|
||||
)
|
||||
self.assertIs(payload["reread"]["git"], True)
|
||||
self.assertIs(payload["reread"]["pip"], False)
|
||||
|
||||
def test_sc20_restart_only_activation(self):
|
||||
"""Editing config.ini without restart has NO effect (cache wins);
|
||||
a reset (== restart) picks up the change."""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini("[default]\\nallow_git_url_install = false\\n")
|
||||
first = manager_core.get_config() # populates cached_config
|
||||
before_edit = flag_view(first)
|
||||
write_ini("[default]\\nallow_git_url_install = true\\n")
|
||||
cached = flag_view(manager_core.get_config()) # NO reset: cache must win
|
||||
after_restart = flag_view(fresh_read()) # reset == restart
|
||||
print(json.dumps({
|
||||
"before_edit": before_edit,
|
||||
"cached_after_edit": cached,
|
||||
"after_restart": after_restart,
|
||||
}))
|
||||
"""
|
||||
)
|
||||
self.assertIs(payload["before_edit"]["git"], False)
|
||||
self.assertIs(
|
||||
payload["cached_after_edit"]["git"],
|
||||
False,
|
||||
"cached_config must NOT hot-reload the edited flag",
|
||||
)
|
||||
self.assertIs(payload["after_restart"]["git"], True)
|
||||
|
||||
def test_sc21_exception_fallback_supplies_false(self):
|
||||
"""Corrupted config.ini -> exception-fallback dict supplies flags False."""
|
||||
payload = _run_child(
|
||||
"""
|
||||
# No [default] section header -> read_config raises inside try,
|
||||
# lands in the exception-fallback dict.
|
||||
write_ini("allow_git_url_install = true\\ngarbage without section\\n")
|
||||
cfg = fresh_read()
|
||||
print(json.dumps({
|
||||
"flags": flag_view(cfg),
|
||||
"fallback_marker_file_logging": cfg.get("file_logging"),
|
||||
}))
|
||||
"""
|
||||
)
|
||||
# file_logging True proves the FALLBACK dict was used (the parse
|
||||
# path would yield False for a missing file_logging key).
|
||||
self.assertIs(
|
||||
payload["fallback_marker_file_logging"],
|
||||
True,
|
||||
"corrupted ini must route through the exception-fallback dict",
|
||||
)
|
||||
self.assertIs(payload["flags"]["git"], False)
|
||||
self.assertIs(payload["flags"]["pip"], False)
|
||||
|
||||
def test_sc28_no_auto_migration_from_weak(self):
|
||||
"""Legacy `security_level=weak` does NOT seed the flags (no auto-migration)."""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini("[default]\\nsecurity_level = weak\\n")
|
||||
cfg = fresh_read()
|
||||
print(json.dumps({
|
||||
"flags": flag_view(cfg),
|
||||
"security_level": cfg.get("security_level"),
|
||||
}))
|
||||
"""
|
||||
)
|
||||
self.assertEqual(payload["security_level"], "weak")
|
||||
self.assertIs(payload["flags"]["git"], False, "no auto-seed from weak")
|
||||
self.assertIs(payload["flags"]["pip"], False, "no auto-seed from weak")
|
||||
|
||||
def test_sc42_get_bool_quirk_guard(self):
|
||||
"""get_bool ignores its default param: missing `file_logging` reads
|
||||
False despite a True default. The flags rely on this missing->False
|
||||
quirk; this guard pins it."""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini("[default]\\nsecurity_level = normal\\n")
|
||||
cfg = fresh_read()
|
||||
print(json.dumps({"file_logging": cfg.get("file_logging", "<ABSENT>")}))
|
||||
"""
|
||||
)
|
||||
self.assertIs(
|
||||
payload["file_logging"],
|
||||
False,
|
||||
"get_bool quirk changed: missing key no longer reads False — "
|
||||
"new flags rely on missing->False",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,461 +0,0 @@
|
||||
"""Handler-gate tests for the dedicated install flags.
|
||||
|
||||
Three layers, each covering what the others can't:
|
||||
|
||||
1. SCInstallGateMirrorTest — a slim mirror of the batch-install handler
|
||||
(`install_custom_node`, S-C) wired to the REAL extracted gate
|
||||
primitives. Covers the handler *composition* that the pure predicate
|
||||
test cannot: risky-level routing, the load-bearing public canary
|
||||
(the entry gate has no network term, so the deny must come from the
|
||||
predicate's loopback term), block-arm unconditionality, the
|
||||
security_level entry gate, and the CNR/middle false-pass guards.
|
||||
2. DenialConstantsTest — content of the flag denial constants and the
|
||||
`security_403_response` precedence, asserted directly (no server).
|
||||
3. BindingProofTest — AST proof that the REAL handlers (S-A/S-B/S-C) are
|
||||
wired to the predicate and that the old `is_allowed_security_level('high')`
|
||||
gate is gone from S-A/S-B (closes the mirror-vs-real gap).
|
||||
|
||||
The direct S-A/S-B allow/deny behavior is covered by the binding proof
|
||||
(wiring) plus the real-server E2E suite (behavior); the mirror here
|
||||
focuses on S-C, whose multi-arm branching is the genuine logic risk.
|
||||
|
||||
Harness: glob/manager_server.py is not importable under the runner
|
||||
(`from comfy.cli_args import args`, PromptServer), so we AST-extract the
|
||||
gate primitives and exec them into a stub namespace — `glob/` is never
|
||||
added to sys.path (the dir name shadows stdlib glob).
|
||||
"""
|
||||
import ast
|
||||
import asyncio
|
||||
import contextlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MANAGER_SERVER_PATH = REPO_ROOT / "glob" / "manager_server.py"
|
||||
|
||||
_WANTED_FUNCS = {
|
||||
"is_loopback",
|
||||
"is_dedicated_install_allowed",
|
||||
"is_allowed_security_level",
|
||||
"security_403_response",
|
||||
}
|
||||
_WANTED_CONSTS = {
|
||||
"SECURITY_MESSAGE_MIDDLE_OR_BELOW",
|
||||
"SECURITY_MESSAGE_NORMAL_MINUS",
|
||||
"SECURITY_MESSAGE_GENERAL",
|
||||
"SECURITY_MESSAGE_FLAG_GIT_URL",
|
||||
"SECURITY_MESSAGE_FLAG_PIP",
|
||||
}
|
||||
_HANDLER_NAMES = {
|
||||
"install_custom_node_git_url", # S-A
|
||||
"install_custom_node_pip", # S-B
|
||||
"_queue_node_install", # S-C
|
||||
}
|
||||
|
||||
|
||||
class _MigrationStub:
|
||||
def __init__(self):
|
||||
self.system_user_api = True
|
||||
|
||||
def has_system_user_api(self):
|
||||
return self.system_user_api
|
||||
|
||||
|
||||
class _CoreStub:
|
||||
"""Stand-in for `core` consulted by is_allowed_security_level."""
|
||||
|
||||
def __init__(self):
|
||||
self.security_level = "normal"
|
||||
|
||||
def get_config(self):
|
||||
return {"security_level": self.security_level}
|
||||
|
||||
|
||||
def _load_surfaces():
|
||||
source = MANAGER_SERVER_PATH.read_text()
|
||||
tree = ast.parse(source)
|
||||
exec_nodes = []
|
||||
handler_nodes = {}
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if node.name in _WANTED_FUNCS:
|
||||
node.decorator_list = [] # exec needs no aiohttp routing context
|
||||
exec_nodes.append(node)
|
||||
if node.name in _HANDLER_NAMES:
|
||||
handler_nodes[node.name] = node
|
||||
elif isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id in _WANTED_CONSTS:
|
||||
exec_nodes.append(node)
|
||||
module = ast.Module(body=exec_nodes, type_ignores=[])
|
||||
ns: dict = {
|
||||
"web": web,
|
||||
"bool": bool,
|
||||
"manager_migration": _MigrationStub(),
|
||||
"core": _CoreStub(),
|
||||
"is_local_mode": True,
|
||||
}
|
||||
exec(compile(module, "manager_server_gate_surfaces", "exec"), ns)
|
||||
# Feature is implemented — these must resolve, else the extraction or
|
||||
# the production code regressed.
|
||||
for name in _WANTED_FUNCS | _WANTED_CONSTS:
|
||||
assert ns.get(name) is not None, "missing gate primitive: %s" % name
|
||||
for name in _HANDLER_NAMES:
|
||||
assert name in handler_nodes, "missing handler: %s" % name
|
||||
return ns, handler_nodes
|
||||
|
||||
|
||||
NS, HANDLERS = _load_surfaces()
|
||||
PREDICATE: Any = NS["is_dedicated_install_allowed"]
|
||||
IS_LOOPBACK: Any = NS["is_loopback"]
|
||||
IAS: Any = NS["is_allowed_security_level"]
|
||||
SEC_403: Any = NS["security_403_response"]
|
||||
|
||||
CATALOG_URL = "https://github.com/catalog/listed-node"
|
||||
UNKNOWN_URL = "https://github.com/x/not-in-catalog"
|
||||
CATALOG_PIP = "torch"
|
||||
UNKNOWN_PIP = "definitely-not-in-catalog-pkg"
|
||||
|
||||
|
||||
def _body_unknown(files=None, pip=None):
|
||||
"""version=='unknown' ingestion arm."""
|
||||
return {
|
||||
"version": "unknown",
|
||||
"selected_version": "unknown",
|
||||
"files": files or [UNKNOWN_URL],
|
||||
"pip": pip or [],
|
||||
"channel": "default",
|
||||
"mode": "cache",
|
||||
"ui_id": "test-row",
|
||||
}
|
||||
|
||||
|
||||
def _body_cnr_latest():
|
||||
"""non-nightly CNR arm — risky='low' set statically."""
|
||||
return {
|
||||
"version": "1.0.0",
|
||||
"selected_version": "latest",
|
||||
"id": "catalog-cnr-pack",
|
||||
"channel": "default",
|
||||
"mode": "cache",
|
||||
"ui_id": "test-row",
|
||||
}
|
||||
|
||||
|
||||
class _TrackingFlags(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.reads = []
|
||||
|
||||
def __getitem__(self, key):
|
||||
self.reads.append(key)
|
||||
return super().__getitem__(key)
|
||||
|
||||
|
||||
class GateEnv:
|
||||
"""Injectable per-row environment for the mirror app."""
|
||||
|
||||
def __init__(self, git=False, pip=False, listen="127.0.0.1", security_level="normal"):
|
||||
self.flags = _TrackingFlags(
|
||||
{"allow_git_url_install": git, "allow_pip_install": pip}
|
||||
)
|
||||
self.listen = listen
|
||||
self.security_level = security_level
|
||||
self.task_queue = []
|
||||
self.risky_calls = 0
|
||||
self.catalog_urls = {CATALOG_URL}
|
||||
self.catalog_pips = {CATALOG_PIP}
|
||||
|
||||
def get_risky_level(self, files, pip_packages):
|
||||
"""Mirror of get_risky_level: URL check precedes pip check."""
|
||||
self.risky_calls += 1
|
||||
for x in files or []:
|
||||
if x not in self.catalog_urls:
|
||||
return "high"
|
||||
for p in pip_packages or []:
|
||||
if p not in self.catalog_pips:
|
||||
return "block"
|
||||
return "middle"
|
||||
|
||||
|
||||
_SC_DENY_TEXT = "A security error has occurred. Please check the terminal logs"
|
||||
|
||||
|
||||
def _apply_env(env):
|
||||
"""Retained gates use the is_local_mode snapshot + config stub."""
|
||||
NS["is_local_mode"] = IS_LOOPBACK(env.listen)
|
||||
NS["core"].security_level = env.security_level
|
||||
|
||||
|
||||
def _make_sc_install(env):
|
||||
"""Slim mirror of install_custom_node (S-C) in its post-change gate
|
||||
shape: security_level entry gate, then risky-level routing where the
|
||||
'high' (unknown-URL) arm goes through the dedicated predicate and the
|
||||
retained arms keep is_allowed_security_level."""
|
||||
|
||||
async def sc_install(request):
|
||||
# ENTRY gate — UNCHANGED (security_level-governed)
|
||||
if not IAS("middle"):
|
||||
logging.error(NS["SECURITY_MESSAGE_MIDDLE_OR_BELOW"])
|
||||
return web.Response(status=403, text=_SC_DENY_TEXT)
|
||||
json_data = await request.json()
|
||||
risky_level = None
|
||||
git_url = None
|
||||
selected_version = json_data.get("selected_version")
|
||||
if json_data["version"] != "unknown" and selected_version != "unknown":
|
||||
if selected_version != "nightly":
|
||||
risky_level = "low" # static — get_risky_level NOT called
|
||||
else:
|
||||
git_url = [json_data.get("repository")]
|
||||
else:
|
||||
git_url = json_data.get("files")
|
||||
if risky_level is None:
|
||||
risky_level = env.get_risky_level(git_url, json_data.get("pip", []))
|
||||
if risky_level == "high":
|
||||
# unknown-URL arm -> dedicated predicate (flag AND loopback)
|
||||
if not PREDICATE(env.flags["allow_git_url_install"], env.listen):
|
||||
logging.error(NS["SECURITY_MESSAGE_FLAG_GIT_URL"])
|
||||
return web.Response(status=404, text=_SC_DENY_TEXT)
|
||||
elif not IAS(risky_level):
|
||||
# 'block' is always False -> unconditional deny; 'middle'/'low'
|
||||
# retained UNCHANGED.
|
||||
logging.error(NS["SECURITY_MESSAGE_GENERAL"])
|
||||
return web.Response(status=404, text=_SC_DENY_TEXT)
|
||||
env.task_queue.append(("install", json_data.get("ui_id")))
|
||||
return web.Response(status=200)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_post("/manager/queue/install", sc_install)
|
||||
return app
|
||||
|
||||
|
||||
class _LogCapture(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.messages = []
|
||||
|
||||
def emit(self, record):
|
||||
self.messages.append(record.getMessage())
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _capture_logs():
|
||||
handler = _LogCapture()
|
||||
root = logging.getLogger()
|
||||
old_level = root.level
|
||||
root.addHandler(handler)
|
||||
root.setLevel(logging.DEBUG)
|
||||
try:
|
||||
yield handler.messages
|
||||
finally:
|
||||
root.removeHandler(handler)
|
||||
root.setLevel(old_level)
|
||||
|
||||
|
||||
class SCInstallGateMirrorTest(unittest.TestCase):
|
||||
"""Batch-install (S-C) gate composition via a slim handler mirror."""
|
||||
|
||||
def setUp(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
|
||||
def tearDown(self):
|
||||
self.loop.close()
|
||||
|
||||
def _post(self, env, body):
|
||||
_apply_env(env)
|
||||
|
||||
async def go():
|
||||
server = TestServer(_make_sc_install(env))
|
||||
client = TestClient(server)
|
||||
await client.start_server()
|
||||
try:
|
||||
resp = await client.post("/manager/queue/install", json=body)
|
||||
return resp.status, await resp.text()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
return self.loop.run_until_complete(go())
|
||||
|
||||
def _installs(self, env):
|
||||
return [item for item in env.task_queue if item[0] == "install"]
|
||||
|
||||
def _has(self, logs, const_name):
|
||||
return any(NS[const_name] in m for m in logs)
|
||||
|
||||
def test_high_arm_allow(self):
|
||||
env = GateEnv(git=True, listen="127.0.0.1")
|
||||
with _capture_logs() as logs:
|
||||
status, _ = self._post(env, _body_unknown())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(self._installs(env)), 1)
|
||||
self.assertFalse(self._has(logs, "SECURITY_MESSAGE_FLAG_GIT_URL"))
|
||||
|
||||
def test_high_arm_flag_deny(self):
|
||||
env = GateEnv(git=False, listen="127.0.0.1")
|
||||
with _capture_logs() as logs:
|
||||
status, text = self._post(env, _body_unknown())
|
||||
self.assertEqual(status, 404) # risky-position deny shape kept
|
||||
self.assertIn("A security error has occurred", text)
|
||||
self.assertEqual(self._installs(env), [])
|
||||
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_FLAG_GIT_URL"))
|
||||
self.assertFalse(self._has(logs, "SECURITY_MESSAGE_NORMAL_MINUS"))
|
||||
|
||||
def test_load_bearing_public_canary(self):
|
||||
"""Entry gate passes on a public listener; the deny MUST come from
|
||||
the predicate's loopback term (the 'middle' set has no network
|
||||
term). 404 (risky deny), not 403 (entry deny)."""
|
||||
env = GateEnv(git=True, listen="0.0.0.0", security_level="normal")
|
||||
with _capture_logs() as logs:
|
||||
status, _ = self._post(env, _body_unknown())
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual(self._installs(env), [])
|
||||
self.assertFalse(
|
||||
self._has(logs, "SECURITY_MESSAGE_MIDDLE_OR_BELOW"),
|
||||
"entry gate must PASS here — deny must come from the predicate",
|
||||
)
|
||||
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_FLAG_GIT_URL"))
|
||||
|
||||
def test_unknown_pip_block_unconditional(self):
|
||||
"""Unknown-pip 'block' stays unconditional regardless of both flags
|
||||
(catalog URL + non-catalog pip; URL check precedes pip check)."""
|
||||
env = GateEnv(git=True, pip=True, listen="127.0.0.1")
|
||||
body = _body_unknown(files=[CATALOG_URL], pip=[UNKNOWN_PIP])
|
||||
with _capture_logs() as logs:
|
||||
status, _ = self._post(env, body)
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual(self._installs(env), [])
|
||||
self.assertEqual(env.risky_calls, 1)
|
||||
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_GENERAL"))
|
||||
self.assertEqual(env.flags.reads, [], "block arm must not consult the flags")
|
||||
|
||||
def test_entry_gate_strong_denies_despite_flags(self):
|
||||
"""The security_level entry gate stays in force; flags do NOT bypass it."""
|
||||
env = GateEnv(git=True, pip=True, listen="127.0.0.1", security_level="strong")
|
||||
with _capture_logs() as logs:
|
||||
status, _ = self._post(env, _body_unknown())
|
||||
self.assertEqual(status, 403)
|
||||
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_MIDDLE_OR_BELOW"))
|
||||
self.assertEqual(self._installs(env), [])
|
||||
self.assertEqual(env.flags.reads, [], "entry deny must not consult the flags")
|
||||
|
||||
def test_cnr_latest_arm_never_consults_flags(self):
|
||||
"""non-nightly CNR sets risky='low' statically — get_risky_level and
|
||||
the flags are never consulted (false-pass guard)."""
|
||||
env = GateEnv(git=False, pip=False, listen="127.0.0.1")
|
||||
status, _ = self._post(env, _body_cnr_latest())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(self._installs(env)), 1)
|
||||
self.assertEqual(env.risky_calls, 0)
|
||||
self.assertEqual(env.flags.reads, [], "flags must NOT be consulted on the CNR arm")
|
||||
|
||||
def test_middle_arm_retained(self):
|
||||
"""all-catalog body -> risky='middle'; consults security_level
|
||||
(UNCHANGED), not the flags."""
|
||||
env = GateEnv(git=False, pip=False, listen="127.0.0.1")
|
||||
body = _body_unknown(files=[CATALOG_URL], pip=[CATALOG_PIP])
|
||||
status, _ = self._post(env, body)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(self._installs(env)), 1)
|
||||
self.assertEqual(env.risky_calls, 1)
|
||||
self.assertEqual(env.flags.reads, [], "flags must NOT be consulted on the middle arm")
|
||||
|
||||
|
||||
class DenialConstantsTest(unittest.TestCase):
|
||||
"""Denial-copy honesty + security_403_response precedence (no server)."""
|
||||
|
||||
def _assert_honest_copy(self, const, flag_name):
|
||||
self.assertIn(flag_name, const, "constant must name the responsible flag")
|
||||
self.assertIn("config.ini", const, "constant must name config.ini")
|
||||
for cause_phrasing in (
|
||||
"is not allowed in this security_level",
|
||||
"set the security level",
|
||||
"a security_level of",
|
||||
"security level configuration",
|
||||
):
|
||||
self.assertNotIn(cause_phrasing, const)
|
||||
self.assertNotEqual(const, NS["SECURITY_MESSAGE_NORMAL_MINUS"])
|
||||
self.assertNotEqual(const, NS["SECURITY_MESSAGE_GENERAL"])
|
||||
|
||||
def test_flag_constants_content(self):
|
||||
self._assert_honest_copy(NS["SECURITY_MESSAGE_FLAG_GIT_URL"], "allow_git_url_install")
|
||||
self._assert_honest_copy(NS["SECURITY_MESSAGE_FLAG_PIP"], "allow_pip_install")
|
||||
|
||||
def test_security_403_precedence(self):
|
||||
"""outdated branch FIRST; flag_token names the flag; no-arg callers
|
||||
stay byte-identical."""
|
||||
self.assertIn("flag_token", inspect.signature(SEC_403).parameters)
|
||||
NS["manager_migration"].system_user_api = False
|
||||
try:
|
||||
resp = SEC_403(flag_token="allow_git_url_install")
|
||||
self.assertEqual(
|
||||
json.loads(resp.text), {"error": "comfyui_outdated"},
|
||||
"comfyui_outdated must take PRECEDENCE over flag_token",
|
||||
)
|
||||
finally:
|
||||
NS["manager_migration"].system_user_api = True
|
||||
resp = SEC_403(flag_token="allow_git_url_install")
|
||||
self.assertEqual(json.loads(resp.text), {"error": "allow_git_url_install"})
|
||||
resp = SEC_403()
|
||||
self.assertEqual(
|
||||
json.loads(resp.text), {"error": "security_level"},
|
||||
"no-arg callers must stay byte-identical",
|
||||
)
|
||||
|
||||
|
||||
class BindingProofTest(unittest.TestCase):
|
||||
"""AST proof that the REAL handlers are wired to the predicate (closes
|
||||
the mirror-vs-real gap)."""
|
||||
|
||||
@staticmethod
|
||||
def _ias_literal_calls(node):
|
||||
out = []
|
||||
for sub in ast.walk(node):
|
||||
if (
|
||||
isinstance(sub, ast.Call)
|
||||
and isinstance(sub.func, ast.Name)
|
||||
and sub.func.id == "is_allowed_security_level"
|
||||
and sub.args
|
||||
):
|
||||
arg = sub.args[0]
|
||||
out.append(arg.value if isinstance(arg, ast.Constant) else None)
|
||||
return out
|
||||
|
||||
def test_handlers_bind_predicate(self):
|
||||
"""S-A, S-B, S-C all gate via is_dedicated_install_allowed with the
|
||||
right flag + args.listen (request-time); S-C keeps the 'middle'
|
||||
entry gate and a variable-arg retained is_allowed_security_level path."""
|
||||
for name, flag in (
|
||||
("install_custom_node_git_url", "allow_git_url_install"),
|
||||
("install_custom_node_pip", "allow_pip_install"),
|
||||
("_queue_node_install", "allow_git_url_install"),
|
||||
):
|
||||
with self.subTest(handler=name):
|
||||
src = ast.unparse(HANDLERS[name])
|
||||
self.assertIn("is_dedicated_install_allowed(", src)
|
||||
self.assertIn(flag, src)
|
||||
self.assertIn("args.listen", src)
|
||||
sc_literals = self._ias_literal_calls(HANDLERS["_queue_node_install"])
|
||||
self.assertIn("middle", sc_literals, "entry gate must stay UNCHANGED")
|
||||
self.assertIn(None, sc_literals, "a variable-arg retained path must remain")
|
||||
|
||||
def test_replace_no_high_literal_at_sa_sb(self):
|
||||
"""REPLACE proof: no is_allowed_security_level('high') remains at
|
||||
S-A / S-B (the flag fully replaced the old security_level gate)."""
|
||||
for name in ("install_custom_node_git_url", "install_custom_node_pip"):
|
||||
with self.subTest(handler=name):
|
||||
self.assertNotIn(
|
||||
"high", self._ias_literal_calls(HANDLERS[name]),
|
||||
"%s still gates via is_allowed_security_level('high')" % name,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Structural (grep/AST) guards for the dedicated install flags.
|
||||
|
||||
Cheap source-level guards that complement the behavioral tests:
|
||||
|
||||
- Frontend 403 copy: both install surfaces in js/common.js name their
|
||||
responsible flag, and the generic fallback copy stays unchanged.
|
||||
- No new HTTP install surface is added.
|
||||
- cm-cli stays an ungated local operator tool.
|
||||
- The migration module never references the flags (no auto-seed —
|
||||
explicit opt-in only).
|
||||
|
||||
Harness: read/grep + AST over glob/*.py, cm-cli.py and js/*.js. No
|
||||
imports of `glob/` modules (the dir name shadows stdlib glob).
|
||||
"""
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MANAGER_SERVER_PATH = REPO_ROOT / "glob" / "manager_server.py"
|
||||
MANAGER_MIGRATION_PATH = REPO_ROOT / "glob" / "manager_migration.py"
|
||||
CM_CLI_PATH = REPO_ROOT / "cm-cli.py"
|
||||
JS_COMMON_PATH = REPO_ROOT / "js" / "common.js"
|
||||
|
||||
GENERIC_403_COPY = "This action is not allowed with this security level configuration."
|
||||
FLAG_TOKENS = ("allow_git_url_install", "allow_pip_install")
|
||||
|
||||
|
||||
def _js_function_block(source, func_name):
|
||||
"""Slice an `export async function <name>` block (up to the next
|
||||
export or EOF)."""
|
||||
start = source.find("export async function %s" % func_name)
|
||||
if start < 0:
|
||||
raise AssertionError("function %s not found in js source" % func_name)
|
||||
next_export = source.find("export ", start + 1)
|
||||
return source[start: next_export if next_export > 0 else len(source)]
|
||||
|
||||
|
||||
def _handle403_call_args(source):
|
||||
"""All handle403Response(...) CALL argument strings (def/import lines
|
||||
excluded)."""
|
||||
calls = []
|
||||
for match in re.finditer(r"handle403Response\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)", source):
|
||||
line_start = source.rfind("\n", 0, match.start()) + 1
|
||||
line = source[line_start: source.find("\n", match.start())]
|
||||
if "function handle403Response" in line or line.lstrip().startswith("import"):
|
||||
continue
|
||||
calls.append(match.group(1).strip())
|
||||
return calls
|
||||
|
||||
|
||||
class JsCopyStructuralTest(unittest.TestCase):
|
||||
"""Frontend honest-copy contract."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.common_src = JS_COMMON_PATH.read_text(encoding="utf-8")
|
||||
|
||||
def test_surface_messages_name_their_flag(self):
|
||||
"""Both install 403 branches pass a flag-naming defaultMessage."""
|
||||
for func, flag in (
|
||||
("install_via_git_url", "allow_git_url_install"),
|
||||
("install_pip", "allow_pip_install"),
|
||||
):
|
||||
with self.subTest(func=func):
|
||||
block = _js_function_block(self.common_src, func)
|
||||
two_arg_calls = [a for a in _handle403_call_args(block) if "," in a]
|
||||
self.assertTrue(
|
||||
two_arg_calls,
|
||||
"%s must call handle403Response with a defaultMessage" % func,
|
||||
)
|
||||
self.assertIn(flag, block)
|
||||
self.assertIn("config.ini", block)
|
||||
|
||||
def test_generic_fallback_and_frozen_callers_unchanged(self):
|
||||
"""The generic fallback copy stays (exactly its two occurrences in
|
||||
handle403Response), and no other handle403Response caller across
|
||||
js/ gains a defaultMessage."""
|
||||
# Count inside handle403Response rather than file-wide. The same
|
||||
# sentence is legitimately reused elsewhere in common.js to build a
|
||||
# batch-uninstall error message, which is not a caller of
|
||||
# handle403Response and so is outside this guard's contract.
|
||||
handle_block = _js_function_block(self.common_src, "handle403Response")
|
||||
self.assertEqual(handle_block.count(GENERIC_403_COPY), 2)
|
||||
surface_blocks = "".join(
|
||||
_js_function_block(self.common_src, name)
|
||||
for name in ("install_pip", "install_via_git_url")
|
||||
)
|
||||
allowed_two_arg = {a for a in _handle403_call_args(surface_blocks) if "," in a}
|
||||
for js_file in sorted((REPO_ROOT / "js").glob("*.js")):
|
||||
source = js_file.read_text(encoding="utf-8")
|
||||
for args in _handle403_call_args(source):
|
||||
if "," in args:
|
||||
self.assertIn(
|
||||
args, allowed_two_arg,
|
||||
"frozen handle403Response caller in %s gained a "
|
||||
"defaultMessage: handle403Response(%s)" % (js_file.name, args),
|
||||
)
|
||||
|
||||
|
||||
class StructuralSecurityGuardsTest(unittest.TestCase):
|
||||
"""Source-level guards against scope bleed."""
|
||||
|
||||
def test_no_new_install_route_surface(self):
|
||||
"""No new HTTP surface for git-URL/pip install."""
|
||||
source = MANAGER_SERVER_PATH.read_text(encoding="utf-8")
|
||||
routes = set(re.findall(r"@routes\.post\(\"([^\"]+)\"\)", source))
|
||||
expected_surfaces = {
|
||||
"/customnode/install/git_url",
|
||||
"/customnode/install/pip",
|
||||
"/manager/queue/install",
|
||||
"/manager/queue/reinstall",
|
||||
}
|
||||
self.assertTrue(expected_surfaces.issubset(routes))
|
||||
install_like = {r for r in routes if "install" in r}
|
||||
self.assertEqual(
|
||||
install_like,
|
||||
expected_surfaces
|
||||
| {"/manager/queue/uninstall", "/manager/queue/install_model"},
|
||||
"install-like route set drifted — no new install surface allowed",
|
||||
)
|
||||
|
||||
def test_cm_cli_ungated(self):
|
||||
"""cm-cli stays a local operator tool — no gate, no flag lookup."""
|
||||
source = CM_CLI_PATH.read_text(encoding="utf-8")
|
||||
for token in FLAG_TOKENS + ("is_allowed_security_level", "is_dedicated_install_allowed"):
|
||||
self.assertNotIn(token, source, "cm-cli.py must stay ungated")
|
||||
|
||||
def test_no_autoseed_in_migration(self):
|
||||
"""The migration module never references the flags (explicit
|
||||
opt-in only — no auto-seed from security_level)."""
|
||||
source = MANAGER_MIGRATION_PATH.read_text(encoding="utf-8")
|
||||
for token in FLAG_TOKENS:
|
||||
self.assertNotIn(
|
||||
token, source,
|
||||
"manager_migration.py must not seed/translate the new flags",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,331 +0,0 @@
|
||||
"""RED->GREEN guard for the unescaped-innerHTML regression in the legacy Manager UI.
|
||||
|
||||
A node pack `description` is markdown-transformed server-side and then written to
|
||||
innerHTML by the client. `convert_markdown_to_html`'s `replace_a` interpolated the
|
||||
captured URL RAW into a single-quoted href, and the URL pattern is `([^)]+)` — so a
|
||||
URL carrying a single quote closed the attribute and injected an event handler.
|
||||
No `<` or `>` is needed, which is why the upstream `<`/`>` escaping did not stop it.
|
||||
|
||||
IMPORT DISCIPLINE (load-bearing, see tests/conftest.py):
|
||||
- `glob/` has NO `__init__.py`, so `import glob.manager_util` is not a package
|
||||
import, and putting `glob/` on sys.path would SHADOW the stdlib `glob` module
|
||||
and break pytest itself. manager_util is therefore loaded by FILE LOCATION
|
||||
under a private module name — it is the real module, really executed.
|
||||
- `glob/manager_server.py` starts a network thread at import and needs the
|
||||
ComfyUI stack, so it is NEVER imported. `convert_markdown_to_html` is
|
||||
AST-extracted and executed in an isolated namespace instead — the same
|
||||
technique tests/test_csrf_content_type_helper.py already uses in this repo.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from manager_test_utils import load_manager_util, load_markdown_functions
|
||||
|
||||
|
||||
def _parse_anchors(html):
|
||||
"""Return one dict of attributes per <a> tag, using a real HTML parser."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class _Collector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.anchors = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "a":
|
||||
self.anchors.append(dict(attrs))
|
||||
|
||||
collector = _Collector()
|
||||
collector.feed(html)
|
||||
collector.close()
|
||||
return collector.anchors
|
||||
|
||||
|
||||
def _parse_element_names(html):
|
||||
"""Return the tag name of every element the HTML actually produces."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class _Collector(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.names = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.names.append(tag)
|
||||
|
||||
collector = _Collector()
|
||||
collector.feed(html)
|
||||
collector.close()
|
||||
return collector.names
|
||||
|
||||
|
||||
MANAGER_UTIL = load_manager_util()
|
||||
CONVERT = load_markdown_functions(MANAGER_UTIL)["convert_markdown_to_html"]
|
||||
|
||||
# Synthetic stand-in for the crafted payload. It keeps a LEGITIMATE https scheme
|
||||
# on purpose: a scheme allow-list alone does not stop this one, so the test pins
|
||||
# the attribute ESCAPING rather than the allow-list.
|
||||
BREAKOUT_URL = "https://example.com/x' onmouseover='alert`1`"
|
||||
BREAKOUT_MD = f"[a/click me]({BREAKOUT_URL})"
|
||||
|
||||
|
||||
class TestAttributeBreakout(unittest.TestCase):
|
||||
def test_quote_in_url_cannot_close_the_href_attribute(self):
|
||||
html = CONVERT(BREAKOUT_MD)
|
||||
self.assertNotIn(
|
||||
"' onmouseover='", html,
|
||||
f"URL quote escaped the href attribute and injected a handler: {html!r}",
|
||||
)
|
||||
|
||||
def test_transformed_anchor_exposes_no_event_handler_attribute(self):
|
||||
# Parsed, not regex-matched: after the fix the payload text still LIVES
|
||||
# inside the href value (harmlessly, as an odd URL), so a naive
|
||||
# /\son[a-z]+=/ scan would flag the fixed output too. What actually
|
||||
# matters is which ATTRIBUTES the anchor ends up carrying.
|
||||
html = CONVERT(BREAKOUT_MD)
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1, f"expected one anchor, got {html!r}")
|
||||
names = set(anchors[0])
|
||||
self.assertEqual(
|
||||
names, {"href", "target", "rel"},
|
||||
f"unexpected attribute(s) on the anchor: {sorted(names)} in {html!r}",
|
||||
)
|
||||
|
||||
def test_legit_link_still_renders_a_usable_anchor(self):
|
||||
html = CONVERT("see [a/the docs](https://example.com/guide?a=1&b=2) please")
|
||||
self.assertIn("<a href='https://example.com/guide?a=1&b=2'", html)
|
||||
self.assertIn(">the docs</a>", html)
|
||||
self.assertIn("target='_blank'", html)
|
||||
|
||||
def test_link_text_is_escaped_once(self):
|
||||
for label in ["a <b> tag", "a <b> tag"]:
|
||||
with self.subTest(label=label):
|
||||
html = CONVERT(f"[a/{label}](https://example.com)")
|
||||
self.assertIn("<b>", html)
|
||||
self.assertNotIn("&lt;", html)
|
||||
|
||||
def test_prose_and_link_labels_are_safe_inside_formatted_notes(self):
|
||||
item = {"description": '<img src=x> [w/Read [a/**<Flux>**](https://example.com/?q=<Flux>)]\n<img src=y>'}
|
||||
load_markdown_functions(MANAGER_UTIL)["populate_markdown"](item)
|
||||
html = item["description"]
|
||||
self.assertEqual(_parse_element_names(html), ["p", "a", "b", "br"])
|
||||
self.assertIn("<B><Flux></B>", html)
|
||||
self.assertIn("<img src=x>", html)
|
||||
self.assertIn("<img src=y>", html)
|
||||
self.assertEqual(_parse_anchors(html)[0]["href"], "https://example.com/?q=<Flux>")
|
||||
|
||||
|
||||
class TestDescriptionUrlRoundTrip(unittest.TestCase):
|
||||
def test_description_urls_are_escaped_once(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
cases = [
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?a=1&b=2", "https://example.com/?a=1&b=2"),
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?a=1¬ebook=2©=3", "https://example.com/?a=1¬ebook=2©=3"),
|
||||
("https://example.com/?q=¬it;", "https://example.com/?q=¬it;"),
|
||||
("https://example.com/?q=&lt;", "https://example.com/?q=<"),
|
||||
("https://example.com/a**b**c?x=1&y=2", "https://example.com/a**b**c?x=1&y=2"),
|
||||
]
|
||||
for url, expected in cases:
|
||||
with self.subTest(url=url):
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
anchors = _parse_anchors(item["description"])
|
||||
self.assertEqual(anchors[0]["href"], expected)
|
||||
self.assertEqual(_parse_element_names(item["description"]), ["a"])
|
||||
|
||||
def test_decoded_entities_cannot_bypass_url_or_attribute_checks(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
for url in ["javascript:alert`1`", "javascript:alert`1`", "java	script:alert`1`"]:
|
||||
with self.subTest(url=url):
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
self.assertEqual(_parse_anchors(item["description"])[0]["href"], "#")
|
||||
|
||||
url = "https://example.com/x' onmouseover='alert`1`"
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
anchor = _parse_anchors(item["description"])[0]
|
||||
self.assertEqual(anchor["href"], "https://example.com/x' onmouseover='alert`1`")
|
||||
self.assertEqual(set(anchor), {"href", "target", "rel"})
|
||||
|
||||
|
||||
class TestEscapedHrefSurvivesTheLaterPasses(unittest.TestCase):
|
||||
"""The escaper's output must still be intact by the time it is returned.
|
||||
|
||||
replace_a runs before the %%..%% / **..** / [w/..] / [i/..] passes, and those
|
||||
passes used to rewrite the INSIDE of the href it had just secured — putting
|
||||
<font>/<B>/<p> markup and fresh single quotes back into the attribute. That
|
||||
both corrupted legitimate URLs and undid the escaping.
|
||||
"""
|
||||
|
||||
def test_percent_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/%%A%%)")
|
||||
self.assertIn("href='https://e.com/%%A%%'", html)
|
||||
self.assertNotIn("<font", html)
|
||||
|
||||
def test_bold_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/a**b**c)")
|
||||
self.assertIn("href='https://e.com/a**b**c'", html)
|
||||
self.assertNotIn("<B>", html)
|
||||
|
||||
def test_note_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/[w/A])")
|
||||
self.assertIn("href='https://e.com/[w/A]'", html)
|
||||
self.assertNotIn("cm-warn-note", html)
|
||||
|
||||
def test_no_element_leaks_out_of_a_url(self):
|
||||
# Parsed rather than string-matched: the anchor must be the ONLY element.
|
||||
html = CONVERT("[a/t](https://e.com/%%A%%and**B**)")
|
||||
self.assertEqual(_parse_element_names(html), ["a"])
|
||||
|
||||
def test_nested_markdown_in_the_LINK_TEXT_still_renders(self):
|
||||
# Protecting the href must not cost the label. Moving the anchor
|
||||
# substitution last would have escaped these into visible mojibake.
|
||||
self.assertIn("<B>bold</B>", CONVERT("[a/**bold** text](https://e.com)"))
|
||||
self.assertIn("<font color='white'>hi</font>", CONVERT("[a/%%hi%% there](https://e.com)"))
|
||||
|
||||
def test_markup_outside_a_link_is_unaffected(self):
|
||||
html = CONVERT("%%white%% and **bold** and [w/warn]")
|
||||
self.assertIn("<font color='white'>white</font>", html)
|
||||
self.assertIn("<B>bold</B>", html)
|
||||
self.assertIn("cm-warn-note", html)
|
||||
|
||||
def test_the_newline_pass_cannot_reach_inside_the_href(self):
|
||||
# The newline substitution is the LAST thing convert_markdown_to_html
|
||||
# does, so restoring the href before it let '<BR>' be injected into the
|
||||
# attribute the escaper had secured. Not exploitable — '<BR>' carries no
|
||||
# quote — but it is the same class of breach this function exists to fix,
|
||||
# so the invariant is pinned all the way to the returned string.
|
||||
html = CONVERT("[a/t](https://e.com/a\nb)")
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1)
|
||||
self.assertNotIn("<BR>", anchors[0]["href"])
|
||||
self.assertEqual(_parse_element_names(html), ["a"])
|
||||
|
||||
def test_newlines_outside_a_link_still_become_line_breaks(self):
|
||||
html = CONVERT("first\nsecond")
|
||||
self.assertIn("<BR>", html)
|
||||
|
||||
def test_a_forged_placeholder_in_the_input_cannot_hijack_restoration(self):
|
||||
html = CONVERT("\x00H0\x00 [a/t](https://e.com/real)")
|
||||
self.assertIn("href='https://e.com/real'", html)
|
||||
self.assertEqual(html.count("https://e.com/real"), 1)
|
||||
|
||||
|
||||
class TestUrlSchemeAllowlist(unittest.TestCase):
|
||||
def test_helpers_are_public_on_manager_util(self):
|
||||
self.assertTrue(callable(getattr(MANAGER_UTIL, "escape_html_attribute", None)))
|
||||
self.assertTrue(callable(getattr(MANAGER_UTIL, "sanitize_url", None)))
|
||||
|
||||
def test_dangerous_schemes_are_rejected(self):
|
||||
for bad in [
|
||||
"javascript:alert`1`",
|
||||
"JaVaScRiPt:alert`1`",
|
||||
" javascript:alert`1`",
|
||||
"java\tscript:alert`1`",
|
||||
"java\nscript:alert`1`",
|
||||
"\x01javascript:alert`1`",
|
||||
"data:text/html;base64,PHNjcmlwdD4=",
|
||||
"vbscript:msgbox",
|
||||
]:
|
||||
with self.subTest(bad=bad):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_url(bad), "#")
|
||||
|
||||
def test_safe_urls_pass_through(self):
|
||||
for good in [
|
||||
"https://example.com/a?b=1",
|
||||
"http://example.com",
|
||||
"HTTPS://Example.COM/Path",
|
||||
"/relative/path",
|
||||
"relative/path",
|
||||
"#in-page-anchor",
|
||||
"//protocol-relative/path",
|
||||
# A relative link may legitimately contain '&'. An earlier version
|
||||
# rejected this shape while guarding against an entity-hidden colon;
|
||||
# the guarantee comes from the escape layer instead (below), so the
|
||||
# rejection was redundant and only broke real links.
|
||||
"a&b/c",
|
||||
"docs/page?x=1&y=2",
|
||||
]:
|
||||
with self.subTest(good=good):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_url(good), good)
|
||||
|
||||
def test_entity_hidden_colon_is_inert_after_escaping(self):
|
||||
# Asserted on the POST-ESCAPE href, because that is where the safety
|
||||
# actually comes from: sanitize_url returns these unchanged (they have no
|
||||
# real colon, so they read as relative), and escape_html_attribute then
|
||||
# escapes the '&'. Entity decoding in an attribute is single-pass, so
|
||||
# what the browser ends up with is a literal string, never a scheme.
|
||||
for hidden in ["javascript:alert`1`", "javascript:alert`1`",
|
||||
"javascript:alert`1`"]:
|
||||
with self.subTest(hidden=hidden):
|
||||
href = MANAGER_UTIL.escape_html_attribute(MANAGER_UTIL.sanitize_url(hidden))
|
||||
self.assertIn("&", href)
|
||||
self.assertNotIn(":", href)
|
||||
self.assertNotIn("j", href)
|
||||
self.assertNotIn("j", href)
|
||||
|
||||
def test_entity_hidden_colon_in_markdown_produces_no_live_scheme(self):
|
||||
html = CONVERT("[a/click](javascript:alert`1`)")
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1)
|
||||
# The parser decodes entities exactly as a browser would; after that the
|
||||
# href must NOT be a javascript: URL.
|
||||
self.assertFalse(anchors[0]["href"].lower().startswith("javascript:"))
|
||||
|
||||
def test_dangerous_scheme_in_markdown_does_not_reach_the_href(self):
|
||||
html = CONVERT("[a/click](javascript:alert`1`)")
|
||||
self.assertNotIn("javascript:", html.lower())
|
||||
self.assertIn("href='#'", html)
|
||||
|
||||
|
||||
class TestAttributeEscaper(unittest.TestCase):
|
||||
def test_escapes_all_five_characters(self):
|
||||
self.assertEqual(
|
||||
MANAGER_UTIL.escape_html_attribute("""&<>"'"""),
|
||||
"&<>"'",
|
||||
)
|
||||
|
||||
def test_ampersand_is_escaped_first_and_only_once(self):
|
||||
self.assertEqual(MANAGER_UTIL.escape_html_attribute("a&b"), "a&b")
|
||||
|
||||
def test_non_string_input_is_coerced(self):
|
||||
self.assertEqual(MANAGER_UTIL.escape_html_attribute(None), "None")
|
||||
|
||||
|
||||
class TestServerTitleTransformIsTheOnlyUpstreamControl(unittest.TestCase):
|
||||
"""Pin the server-side facts that the bare flyover title sinks rest on.
|
||||
|
||||
js/custom-nodes-manager.js says, at both flyover pack-title sinks, that the
|
||||
title is already escaped by populate_markdown and so must NOT be escaped
|
||||
again at the client (a second escape would double-escape a legitimate
|
||||
<>-title into mojibake). That comment is a load-bearing claim about THIS
|
||||
file's subject: it is the whole reason those two sinks are bare. These
|
||||
cases make the claim machine-checked, so if the server side ever stops
|
||||
covering `title` the suite says so, instead of leaving a comment asserting
|
||||
a protection that is gone.
|
||||
"""
|
||||
|
||||
def test_populate_markdown_escapes_the_title_before_it_reaches_the_client(self):
|
||||
# The specific link the sink comments name. This is the case that fails
|
||||
# if someone drops `title` from populate_markdown, which is the drift
|
||||
# the bare sinks are actually exposed to.
|
||||
populate_markdown = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
pack = {"title": "Nodes for <Flux> workflows"}
|
||||
populate_markdown(pack)
|
||||
self.assertEqual(pack["title"], "Nodes for <Flux> workflows")
|
||||
|
||||
def test_sanitize_tag_escapes_angle_brackets(self):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_tag("<img>"), "<img>")
|
||||
|
||||
def test_sanitize_tag_does_not_escape_quotes_or_ampersand(self):
|
||||
# Documented limit, not a defect: both title interpolations are in
|
||||
# element-text position, where quotes are inert. It IS the reason a
|
||||
# title moving into attribute position would need escaping.
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_tag("\"a\" & 'b'"), "\"a\" & 'b'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,487 +0,0 @@
|
||||
"""Inventory the current direct message-sink calls and reviewed error builders.
|
||||
|
||||
These sinks accept HTML because some callers render buttons and server-escaped
|
||||
metadata. Unknown arguments or assignments require a provenance review. Runtime
|
||||
rendering tests separately verify that raw text stays inert and escaped titles
|
||||
remain readable; this source inventory is not a general JavaScript dataflow check.
|
||||
"""
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from js_lift import _skip_literal
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_DIR = REPO_ROOT / "js"
|
||||
|
||||
SINK_FILES = [
|
||||
"common.js",
|
||||
"custom-nodes-manager.js",
|
||||
"model-manager.js",
|
||||
"node-usage-analyzer.js",
|
||||
]
|
||||
|
||||
SINK_METHODS = r"showStatus|showMessage|showError|showSelection"
|
||||
|
||||
# Capture receivers so a new alias is reviewed instead of silently omitted.
|
||||
CALL_RE = re.compile(
|
||||
r"(?<![\w$.])([\w$]+(?:\??\.[\w$]+)*)"
|
||||
rf"\??\.({SINK_METHODS})\("
|
||||
)
|
||||
SINK_CALL_RE = re.compile(
|
||||
rf"(?:\.(?:{SINK_METHODS})|\[\s*['\"](?:{SINK_METHODS})['\"]\s*\])"
|
||||
r"\s*(?:\?\.\s*)?\("
|
||||
)
|
||||
|
||||
PINNED_RECEIVERS = frozenset({"this", "self", "this.ui"})
|
||||
|
||||
# Destructuring would hide calls from the receiver-based inventory.
|
||||
DESTRUCTURE_RE = re.compile(
|
||||
r"(?:const|let|var)\s*\{[^}]*\b"
|
||||
rf"(?:{SINK_METHODS})"
|
||||
r"\b[^}]*\}"
|
||||
)
|
||||
|
||||
SERVER_ESCAPED = "server-escaped" # already display-ready; caller must NOT escape
|
||||
RAW = "raw" # not display-ready; caller MUST escape
|
||||
NO_DATA = "no-data" # no channel/registry value interpolated
|
||||
FORWARDED = "forwarded" # showError's own delegation; see HtmlByDesignSinkTest
|
||||
|
||||
|
||||
def _argument(source: str, start: int) -> str:
|
||||
"""Read a complete argument, including multiline templates."""
|
||||
depth = 1
|
||||
end = start
|
||||
while depth:
|
||||
skipped = _skip_literal(source, end)
|
||||
if skipped != end:
|
||||
end = skipped
|
||||
continue
|
||||
if source[end] == "(":
|
||||
depth += 1
|
||||
elif source[end] == ")":
|
||||
depth -= 1
|
||||
end += 1
|
||||
if source[end:end + 1] == ";":
|
||||
end += 1
|
||||
return re.sub(r"\s+", " ", source[start:end].strip())
|
||||
|
||||
|
||||
def _call_sites():
|
||||
"""Return (file, line, receiver, method, argument) for direct calls."""
|
||||
sites = []
|
||||
for name in SINK_FILES:
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
matches = list(CALL_RE.finditer(source))
|
||||
parsed = {match.end() for match in matches}
|
||||
for candidate in SINK_CALL_RE.finditer(source):
|
||||
if candidate.end() not in parsed:
|
||||
lineno = source.count("\n", 0, candidate.start()) + 1
|
||||
raise AssertionError(f"Unsupported message-sink call at {name}:{lineno}")
|
||||
for match in matches:
|
||||
lineno = source.count("\n", 0, match.start()) + 1
|
||||
sites.append((name, lineno, match.group(1), match.group(2),
|
||||
_argument(source, match.end())))
|
||||
return sites
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE REGISTRY. Keyed by (file, method, argument-source). Every showStatus and
|
||||
# showMessage call site must appear here with a provenance verdict and a reason.
|
||||
# ---------------------------------------------------------------------------
|
||||
REGISTRY = {
|
||||
# --- custom-nodes-manager.js -------------------------------------------
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`${prevViewRowsLength.toLocaleString()} custom nodes`);"):
|
||||
(NO_DATA, "a formatted row count"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading node mappings (${mode}) ...`);"):
|
||||
(NO_DATA, "`mode` is a datasrc-combo value chosen in the UI, not channel data"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`${label} ${item.title} ...`);"):
|
||||
(SERVER_ESCAPED,
|
||||
"item.title comes from /customnode/getlist, where populate_markdown -> "
|
||||
"sanitize_tag has already escaped it; escaping again shows entities"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`${label} ${Object.keys(result).length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading missing nodes (${mode}) ...`);"):
|
||||
(NO_DATA, "UI-chosen mode"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading alternatives (${mode}) ...`);"):
|
||||
(NO_DATA, "UI-chosen mode"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading workflow usage analysis ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("custom-nodes-manager.js", "showStatus",
|
||||
"`Loading custom nodes (${mode}) ...`);"):
|
||||
(NO_DATA, "UI-chosen mode"),
|
||||
("custom-nodes-manager.js", "showMessage",
|
||||
"`To apply the installed/updated/disabled/enabled custom node, please restart "
|
||||
"ComfyUI. And refresh browser.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("custom-nodes-manager.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- model-manager.js ---------------------------------------------------
|
||||
("model-manager.js", "showStatus",
|
||||
"`${grid.viewRows.length.toLocaleString()} external models`);"):
|
||||
(NO_DATA, "a formatted row count"),
|
||||
("model-manager.js", "showStatus", "`Install ${item.name} ...`);"):
|
||||
(SERVER_ESCAPED,
|
||||
"item.name comes from /externalmodel/getlist, where populate_markdown -> "
|
||||
"sanitize_tag has already escaped it. WI-112 added a caller escape here "
|
||||
"and it rendered '<Flux>' to the user; removing it is the fix"),
|
||||
("model-manager.js", "showStatus", "`Install ${result.length} models successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("model-manager.js", "showStatus", "`Loading external model list ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("model-manager.js", "showMessage",
|
||||
"`To apply the installed model, please click the 'Refresh' button.`, \"red\")"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("model-manager.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- node-usage-analyzer.js --------------------------------------------
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`${grid.viewRows.length.toLocaleString()} installed packages`);"):
|
||||
(NO_DATA, "a formatted row count"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Install ${sanitizeHTML(String(item.name))} ...`);"):
|
||||
(RAW,
|
||||
"item.name is packKey (loadData sets `name: packKey`) — a raw dict key no "
|
||||
"server transform touches, so this caller escapes it. The ONLY raw-provenance "
|
||||
"showStatus caller in the four files"),
|
||||
("node-usage-analyzer.js", "showStatus", "msg);"):
|
||||
(SERVER_ESCAPED,
|
||||
"uninstallNodes preserves the escaped title or escapes the raw name fallback; "
|
||||
"test_node_usage_escaping executes that path through the panel sink"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Uninstalled ${targets.length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of targets"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Install ${Object.keys(result).length} models successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Uninstall ${Object.keys(result).length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("node-usage-analyzer.js", "showStatus", "`Analyzing node usage ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage",
|
||||
"`To apply the uninstalled custom nodes, please restart ComfyUI and refresh "
|
||||
"browser.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage",
|
||||
"`To apply the installed model, please click the 'Refresh' button.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage", "\"No workflows were found for analysis.\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- showError's own delegation, in each of the three classes -----------
|
||||
# Not an application caller: this IS `showError(err) { this.showMessage(err,
|
||||
# "red"); }`. Whether `err` is display-ready is the showError CALLER's
|
||||
# responsibility, which HtmlByDesignSinkTest below enforces separately. It is
|
||||
# registered rather than pattern-excluded so that if showError ever stops
|
||||
# forwarding — or starts forwarding something else — this entry goes stale
|
||||
# and the guard says so.
|
||||
("custom-nodes-manager.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
("model-manager.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
("node-usage-analyzer.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
}
|
||||
|
||||
DISPLAY_READY_METHODS = ("showStatus", "showMessage")
|
||||
|
||||
# A caller escape is spelled this way throughout the UI files.
|
||||
ESCAPE_MARKER = "sanitizeHTML"
|
||||
|
||||
|
||||
class MessageSinkRegistryTest(unittest.TestCase):
|
||||
"""The registry must account for every display-ready-sink call site."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sites = _call_sites()
|
||||
|
||||
def test_the_four_sink_implementations_all_exist(self):
|
||||
# If a file stops defining its own sink (e.g. it starts delegating to
|
||||
# createUIStateManager) the registry's routing assumptions change and
|
||||
# this guard must be re-derived rather than silently kept.
|
||||
own = {
|
||||
"custom-nodes-manager.js": ".cn-manager-status",
|
||||
"model-manager.js": ".cmm-manager-status",
|
||||
"node-usage-analyzer.js": ".nu-manager-status",
|
||||
}
|
||||
for name, selector in own.items():
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
self.assertIn("showStatus(msg, color)", source, "%s lost its own sink" % name)
|
||||
self.assertIn(selector, source)
|
||||
common = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
self.assertIn("export function createUIStateManager(", common)
|
||||
self.assertIn("showStatus: (msg, color)", common)
|
||||
|
||||
def test_every_caller_is_classified(self):
|
||||
"""FAIL-CLOSED: an unregistered call site breaks the build."""
|
||||
observed = Counter((name, method, arg) for name, _, _, method, arg in self.sites
|
||||
if method in DISPLAY_READY_METHODS)
|
||||
duplicates = {
|
||||
('custom-nodes-manager.js', 'showStatus', '`Loading workflow usage analysis ...`);'),
|
||||
('node-usage-analyzer.js', 'showMessage',
|
||||
'`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");'),
|
||||
}
|
||||
expected = Counter({key: 1 + (key in duplicates) for key in REGISTRY})
|
||||
self.assertEqual(observed, expected, 'Changed message calls require a provenance review')
|
||||
|
||||
def test_the_observed_receivers_are_the_pinned_set(self):
|
||||
observed = {receiver for _name, _lineno, receiver, _m, _a in self.sites}
|
||||
self.assertEqual(
|
||||
observed, set(PINNED_RECEIVERS),
|
||||
"the set of receivers calling the message sinks changed: %s. These "
|
||||
"sinks assign innerHTML and DO NOT escape, so every route to them "
|
||||
"must be accounted for. Classify the new receiver's call sites in "
|
||||
"REGISTRY and add it to PINNED_RECEIVERS — do not narrow CALL_RE to "
|
||||
"make this pass." % (sorted(observed ^ set(PINNED_RECEIVERS)),),
|
||||
)
|
||||
|
||||
def test_no_sink_method_is_destructured_out_of_its_receiver(self):
|
||||
for name in SINK_FILES:
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
with self.subTest(file=name):
|
||||
self.assertEqual(
|
||||
DESTRUCTURE_RE.findall(source), [],
|
||||
"%s destructures a message-sink method out of its receiver. "
|
||||
"The resulting bare calls are invisible to this guard; call "
|
||||
"them through their receiver instead." % (name,),
|
||||
)
|
||||
|
||||
def test_unsupported_sink_calls_require_review(self):
|
||||
source = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
for call in ('this["showMessage"](raw);',
|
||||
'createUIStateManager(element).showError(raw);'):
|
||||
with self.subTest(call=call), patch(__name__ + ".SINK_FILES", ["common.js"]):
|
||||
with patch.object(Path, "read_text", return_value=source + "\n" + call):
|
||||
with self.assertRaisesRegex(AssertionError, "Unsupported message-sink call"):
|
||||
_call_sites()
|
||||
|
||||
def test_the_registry_has_no_stale_entries(self):
|
||||
"""A registry entry whose call site is gone or whose argument changed."""
|
||||
live = {
|
||||
(name, method, arg)
|
||||
for name, _lineno, _receiver, method, arg in self.sites
|
||||
if method in DISPLAY_READY_METHODS
|
||||
}
|
||||
stale = sorted(key for key in REGISTRY if key not in live)
|
||||
self.assertEqual(
|
||||
stale, [],
|
||||
"REGISTRY entries no longer match any call site — the caller was removed "
|
||||
"or its argument changed, so its provenance verdict is unverified:\n %s"
|
||||
% "\n ".join("%s %s(%s" % key for key in stale),
|
||||
)
|
||||
|
||||
def test_server_escaped_callers_do_not_escape_again(self):
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != SERVER_ESCAPED:
|
||||
continue
|
||||
with self.subTest(file=name, method=method, arg=arg):
|
||||
self.assertNotIn(
|
||||
ESCAPE_MARKER, arg,
|
||||
"%s %s escapes an ALREADY-escaped value — the user reads "
|
||||
"'<Flux>' instead of '<Flux>'. Reason on file: %s"
|
||||
% (name, method, reason),
|
||||
)
|
||||
|
||||
def test_raw_callers_escape(self):
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != RAW:
|
||||
continue
|
||||
with self.subTest(file=name, method=method, arg=arg):
|
||||
self.assertIn(
|
||||
ESCAPE_MARKER, arg,
|
||||
"%s %s passes a RAW value into a sink that does not escape. "
|
||||
"Reason on file: %s" % (name, method, reason),
|
||||
)
|
||||
|
||||
def test_no_data_callers_interpolate_no_channel_value(self):
|
||||
# Interpolations are allowed, but only of things that cannot carry
|
||||
# channel/registry text: counts, lengths, and the UI-chosen mode.
|
||||
allowed = re.compile(
|
||||
r"^\$\{(?:[A-Za-z_.]*\.length|Object\.keys\([A-Za-z_.]+\)\.length"
|
||||
r"|[A-Za-z_.]+\.toLocaleString\(\)|mode|label"
|
||||
r"|[A-Za-z_.]*\.length\.toLocaleString\(\))\}$"
|
||||
)
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != NO_DATA:
|
||||
continue
|
||||
for expr in re.findall(r"\$\{[^}]*\}", arg):
|
||||
with self.subTest(file=name, method=method, expr=expr):
|
||||
self.assertRegex(
|
||||
expr, allowed,
|
||||
"%s %s is classified %s but interpolates %s, which is not a "
|
||||
"count or a UI-chosen value. Re-classify it. Reason on file: %s"
|
||||
% (name, method, NO_DATA, expr, reason),
|
||||
)
|
||||
|
||||
|
||||
class HtmlByDesignSinkTest(unittest.TestCase):
|
||||
"""showSelection / showError render real markup and are NOT display-ready sinks."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sites = _call_sites()
|
||||
|
||||
def test_show_selection_receives_no_channel_data(self):
|
||||
expected = {
|
||||
"custom-nodes-manager.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
# Unknown labels in this builder have behavioral coverage.
|
||||
'list.join(""));',
|
||||
],
|
||||
"model-manager.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
'`<span>Selected <b>${selectedList.length}</b> models <button '
|
||||
'class="cmm-btn-install p-button p-component" mode="install">Install</button>`);',
|
||||
],
|
||||
"node-usage-analyzer.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
'`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);',
|
||||
'` <div class="nu-selected-buttons"> <span>Selected <b>${installedSelected.length}</b> '
|
||||
'installed packages</span> <button class="nu-btn-uninstall" mode="uninstall">'
|
||||
'Uninstall Selected</button> </div> `);',
|
||||
],
|
||||
}
|
||||
actual = Counter((name, arg) for name, _, _, method, arg in self.sites if method == "showSelection")
|
||||
self.assertEqual(actual, Counter((name, arg) for name, args in expected.items() for arg in args))
|
||||
|
||||
def test_show_error_arguments_are_escaped_or_literal(self):
|
||||
# showError forwards to showMessage, which does not escape, AND its
|
||||
# errorMsg is separately handed to ComfyUI-core show_message(). So every
|
||||
# non-literal argument must already be escaped at the call site.
|
||||
escaped = {
|
||||
"custom-nodes-manager.js": {
|
||||
'`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);',
|
||||
'`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);',
|
||||
'`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);',
|
||||
},
|
||||
"node-usage-analyzer.js": {'sanitizeHTML(String(result.error)));'},
|
||||
}
|
||||
for name, lineno, _receiver, method, arg in self.sites:
|
||||
if method != "showError":
|
||||
continue
|
||||
is_literal = re.fullmatch(
|
||||
r'''(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\);''', arg,
|
||||
) is not None
|
||||
# These direct arguments are checked by the per-file guards below.
|
||||
is_error_var = re.fullmatch(r"errorMsg\s*\);", arg) is not None
|
||||
with self.subTest(file=name, line=lineno, arg=arg):
|
||||
if is_literal or is_error_var:
|
||||
continue
|
||||
self.assertIn(
|
||||
arg, escaped.get(name, set()),
|
||||
"%s:%d passes an unescaped non-literal into showError, which does "
|
||||
"not escape and whose text also reaches core show_message()"
|
||||
% (name, lineno),
|
||||
)
|
||||
|
||||
def test_every_error_message_builder_escapes_its_server_text(self):
|
||||
common = {
|
||||
'errorMsg = "";',
|
||||
"errorMsg += `This action is not allowed with this security level configuration.\\n`;",
|
||||
"errorMsg += sanitizeHTML(await res.text()) + '\\n';",
|
||||
}
|
||||
queue_error = "errorMsg += sanitizeHTML(String(v)) + '\\n';"
|
||||
outdated = "errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\\n`;"
|
||||
expected = {
|
||||
"custom-nodes-manager.js": common | {
|
||||
"errorMsg = `'${item.title}': `;",
|
||||
"errorMsg = `Not found custom node: ${hash}`;",
|
||||
queue_error, outdated,
|
||||
},
|
||||
"model-manager.js": common | {
|
||||
"errorMsg = `'${item.name}': `;", queue_error, outdated,
|
||||
},
|
||||
"node-usage-analyzer.js": common | {
|
||||
"errorMsg = `'${sanitizeHTML(String(item.name))}': `;", queue_error,
|
||||
},
|
||||
"common.js": common | {"errorMsg = `'${displayTitle}': `;"},
|
||||
}
|
||||
mutations = re.compile(r"(?<![\w$.])errorMsg\s*(?:[+*/%&|^?-]*=(?!=)|\+\+|--)[^;]*;")
|
||||
for name, statements in expected.items():
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
actual = {re.sub(r"\s+", " ", match) for match in mutations.findall(source)}
|
||||
with self.subTest(file=name):
|
||||
self.assertEqual(actual, statements)
|
||||
|
||||
def test_error_guard_rejects_unclassified_arguments(self):
|
||||
for argument in (
|
||||
"err);", "errText);", "errorMsg$raw);", "err + extra);", "err.detail);",
|
||||
'"prefix " + errText);', "'prefix ' + errText);",
|
||||
"sanitizeHTML(err) + extra);",
|
||||
):
|
||||
with self.subTest(argument=argument):
|
||||
guard = HtmlByDesignSinkTest("test_show_error_arguments_are_escaped_or_literal")
|
||||
guard.sites = [("custom-nodes-manager.js", 1, "this", "showError", argument)]
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertEqual(len(result.failures), 1)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
def test_html_guards_reject_unreviewed_source(self):
|
||||
cases = [
|
||||
("custom-nodes-manager.js", 'this.showSelection(list.join(""));',
|
||||
'this.showSelection(selectedList[0].title);',
|
||||
"test_show_selection_receives_no_channel_data"),
|
||||
("node-usage-analyzer.js", "${installedSelected.length}</b>",
|
||||
"${installedSelected[0].title}</b>",
|
||||
"test_show_selection_receives_no_channel_data"),
|
||||
("custom-nodes-manager.js", 'let errorMsg = "";',
|
||||
'let errorMsg = "";\nerrorMsg += response.message;',
|
||||
"test_every_error_message_builder_escapes_its_server_text"),
|
||||
("model-manager.js", "errorMsg += sanitizeHTML(String(v)) + '\\n';",
|
||||
"errorMsg += sanitizeHTML(String(v)) + response.message + '\\n';",
|
||||
"test_every_error_message_builder_escapes_its_server_text"),
|
||||
]
|
||||
for name, before, after, test in cases:
|
||||
with self.subTest(file=name, replacement=after), tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for filename in SINK_FILES:
|
||||
source = (JS_DIR / filename).read_text(encoding="utf-8")
|
||||
if filename == name:
|
||||
self.assertIn(before, source)
|
||||
source = source.replace(before, after, 1)
|
||||
(directory / filename).write_text(source, encoding="utf-8")
|
||||
with patch(__name__ + ".JS_DIR", directory):
|
||||
guard = HtmlByDesignSinkTest(test)
|
||||
guard.sites = _call_sites()
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertTrue(result.failures)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
def test_duplicate_calls_require_review(self):
|
||||
for cls, test, argument in (
|
||||
(MessageSinkRegistryTest, 'test_every_caller_is_classified', 'msg);'),
|
||||
(HtmlByDesignSinkTest, 'test_show_selection_receives_no_channel_data', 'list.join(""));'),
|
||||
):
|
||||
with self.subTest(test=test):
|
||||
guard = cls(test)
|
||||
guard.sites = _call_sites()
|
||||
guard.sites.append(next(site for site in guard.sites if site[-1] == argument))
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertTrue(result.failures)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,412 +0,0 @@
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import NODE, JsSource, parse as _parse, run_node as _run_node, slice_braced
|
||||
from manager_test_utils import load_manager_util, load_markdown_functions
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_DIR = Path(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
JS = JsSource(JS_DIR)
|
||||
MANAGER_UTIL = load_manager_util()
|
||||
|
||||
|
||||
MODEL_MANAGER_SRC = (JS_DIR / "model-manager.js").read_text(encoding="utf-8")
|
||||
COMMON_SRC = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _lift_sanitize_html() -> str:
|
||||
return JS.lift_declaration("common.js", "export function sanitizeHTML(")
|
||||
|
||||
|
||||
def _lift_icons() -> str:
|
||||
"""The real `icons` constant — the url formatter embeds icons.download."""
|
||||
return JS.lift_declaration("common.js", "export const icons = ")
|
||||
|
||||
|
||||
def _lift_url_helpers() -> str:
|
||||
"""sanitizeUrl + safeHref, which live in common.js since the consolidation.
|
||||
|
||||
Returns '' on a pre-consolidation tree so the MANAGER_JS_DIR reproduce still
|
||||
works against an older revision: there the helpers were local to
|
||||
model-manager.js and _lift_model_manager_helpers picks them up instead.
|
||||
"""
|
||||
if "export function sanitizeUrl" not in COMMON_SRC:
|
||||
return ""
|
||||
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
return span.replace("export ", "")
|
||||
|
||||
|
||||
def _lift_model_manager_helpers() -> str:
|
||||
"""The escaping helpers that are still local to model-manager.js.
|
||||
|
||||
escapeCell and renderOptions stay in the client file — they are specific to
|
||||
its grid and its filter dropdowns. On a pre-consolidation tree this block
|
||||
also still contains sanitizeUrl/safeHref, which is why the slice starts at
|
||||
whichever marker that revision actually has.
|
||||
"""
|
||||
if "const escapeCell" not in MODEL_MANAGER_SRC:
|
||||
return ""
|
||||
marker = ("const SAFE_URL_SCHEMES" if "const SAFE_URL_SCHEMES" in MODEL_MANAGER_SRC
|
||||
else "const escapeCell")
|
||||
start = MODEL_MANAGER_SRC.index(marker)
|
||||
end = MODEL_MANAGER_SRC.index("const renderOptions")
|
||||
end = MODEL_MANAGER_SRC.index('}).join("");', end) + len('}).join("");')
|
||||
return MODEL_MANAGER_SRC[start:end]
|
||||
|
||||
|
||||
def _slice_column(column_id: str) -> str:
|
||||
"""Return one column-definition object out of the `}, {`-chained columns array.
|
||||
|
||||
These objects are literals inside an array, so the first `{` after the id
|
||||
marker belongs to the NEXT column — brace matching cannot be used. The
|
||||
object instead ends at the first `}` sitting at the array's own indent.
|
||||
"""
|
||||
start = MODEL_MANAGER_SRC.index("id: %s," % column_id)
|
||||
end = MODEL_MANAGER_SRC.index("\n\t\t}", start)
|
||||
return MODEL_MANAGER_SRC[start:end]
|
||||
|
||||
|
||||
def _lift_formatter(anchor: str) -> str:
|
||||
"""Lift the ``formatter: ...`` at ``anchor`` as a bare function expression."""
|
||||
block = slice_braced(MODEL_MANAGER_SRC, anchor)
|
||||
marker = "formatter:"
|
||||
return block[block.index(marker) + len(marker):].strip()
|
||||
|
||||
|
||||
NAME_FORMATTER_ANCHOR = "formatter: function(name, rowItem, columnItem, cellNode)"
|
||||
URL_FORMATTER_ANCHOR = "formatter: (url, rowItem, columnItem)"
|
||||
|
||||
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
|
||||
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
|
||||
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
ATTR_BREAKOUT = '" onfocus=alert(1) x="'
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ModelManagerSinkEscapingTest(unittest.TestCase):
|
||||
"""Every sink is exercised through the REAL lifted code, never a copy."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.preamble = "\n".join([
|
||||
_lift_sanitize_html(),
|
||||
_lift_icons(),
|
||||
_lift_url_helpers(),
|
||||
_lift_model_manager_helpers(),
|
||||
])
|
||||
|
||||
def _eval(self, expressions: dict) -> dict:
|
||||
body = ",".join("%s: %s" % (json.dumps(k), v) for k, v in expressions.items())
|
||||
return _run_node(
|
||||
"%s\n"
|
||||
"const nameFormatter = %s;\n"
|
||||
"const urlFormatter = %s;\n"
|
||||
"console.log(JSON.stringify({%s}));\n"
|
||||
% (self.preamble,
|
||||
_lift_formatter(NAME_FORMATTER_ANCHOR),
|
||||
_lift_formatter(URL_FORMATTER_ANCHOR),
|
||||
body)
|
||||
)
|
||||
|
||||
def _assert_inert(self, markup: str, allowed_tags):
|
||||
"""No element outside the allow-list, and no event-handler attribute."""
|
||||
parsed = _parse(markup)
|
||||
self.assertEqual(
|
||||
[t for t in parsed.tags if t not in allowed_tags], [],
|
||||
"live element injected into: %r" % (markup,),
|
||||
)
|
||||
handlers = [name for name, _ in parsed.attrs if name.lower().startswith("on")]
|
||||
self.assertEqual(handlers, [], "event-handler attribute injected into: %r" % (markup,))
|
||||
|
||||
# --- D-IV: name column, previously UNQUOTED href ------------------------
|
||||
def test_d4_name_href_survives_breakout_attempts(self):
|
||||
out = self._eval({
|
||||
"space": "nameFormatter('Model', {reference: %s})" % json.dumps(BREAKOUT_HREF),
|
||||
"quote": "nameFormatter('Model', {reference: %s})" % json.dumps(QUOTE_BREAKOUT),
|
||||
})
|
||||
for markup in out.values():
|
||||
self._assert_inert(markup, allowed_tags={"a", "b"})
|
||||
self.assertEqual(
|
||||
sorted(name for name, _ in _parse(markup).attrs),
|
||||
["href", "rel", "target"],
|
||||
"unexpected attributes on the anchor: %r" % (markup,),
|
||||
)
|
||||
|
||||
def test_d4_name_href_rejects_dangerous_schemes(self):
|
||||
out = self._eval({
|
||||
"js": "nameFormatter('Model', {reference: 'javascript:alert(1)'})",
|
||||
"obfuscated": r"nameFormatter('Model', {reference: 'java\tscript:alert(1)'})",
|
||||
"entity": "nameFormatter('Model', {reference: 'javascript:alert(1)'})",
|
||||
"data": "nameFormatter('Model', {reference: 'data:text/html,<script>alert(1)</script>'})",
|
||||
"ok": "nameFormatter('Model', {reference: 'https://huggingface.co/repo'})",
|
||||
})
|
||||
for key in ("js", "obfuscated", "data"):
|
||||
href = dict(_parse(out[key]).attrs)["href"]
|
||||
self.assertEqual(href, "#", "%s: dangerous scheme survived as %r" % (key, href))
|
||||
# The entity form is defeated by the attribute escape, not the scheme
|
||||
# check: `&` becomes `&`, so `:` reaches the browser as text.
|
||||
self.assertIn("&#58;", out["entity"])
|
||||
self.assertEqual(dict(_parse(out["ok"]).attrs)["href"], "https://huggingface.co/repo")
|
||||
|
||||
# --- D-V: url column, quoted but previously unescaped -------------------
|
||||
def test_d5_url_href_escaped_inside_its_quotes(self):
|
||||
out = self._eval({
|
||||
"quote": "urlFormatter(%s)" % json.dumps(QUOTE_BREAKOUT),
|
||||
"attr": "urlFormatter(%s)" % json.dumps(ATTR_BREAKOUT),
|
||||
"js": "urlFormatter('javascript:alert(1)')",
|
||||
})
|
||||
self._assert_inert(out["quote"], allowed_tags={"a", "svg", "path"})
|
||||
self._assert_inert(out["attr"], allowed_tags={"a", "svg", "path"})
|
||||
self.assertEqual(dict(_parse(out["js"]).attrs)["href"], "#")
|
||||
|
||||
# --- D-III: the four previously formatter-less columns ------------------
|
||||
def test_d3_columns_bind_the_escaping_formatter(self):
|
||||
for column in ("'type'", "'base'", '"save_path"', "'filename'"):
|
||||
block = _slice_column(column)
|
||||
self.assertIn(
|
||||
"formatter: escapeCell", block,
|
||||
"column %s still renders RAW model-list.json into innerHTML" % column,
|
||||
)
|
||||
|
||||
def test_d3_escape_cell_neutralises_payload_and_keeps_empty_cells(self):
|
||||
out = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"payload: escapeCell(%s),"
|
||||
"nul: escapeCell(null), undef: escapeCell(undefined),"
|
||||
"num: escapeCell(42), plain: escapeCell('checkpoints')"
|
||||
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD))
|
||||
)
|
||||
self._assert_inert(out["payload"], allowed_tags=set())
|
||||
self.assertEqual(html.unescape(out["payload"]), TEXT_PAYLOAD,
|
||||
"the payload must survive as visible text")
|
||||
# null/undefined pass through so turbogrid keeps rendering an empty cell.
|
||||
self.assertIsNone(out["nul"])
|
||||
self.assertNotIn("undef", out)
|
||||
self.assertEqual(out["num"], "42")
|
||||
self.assertEqual(out["plain"], "checkpoints")
|
||||
|
||||
# --- D-VI: the showStatus message sink ----------------------------------
|
||||
def test_d6_show_status_renders_item_name_inert(self):
|
||||
"""item.name must reach .cmm-manager-status innerHTML inert.
|
||||
|
||||
This used to assert that the CALL SITE carried a sanitizeHTML. That was
|
||||
wrong in a way the assertion could not see: ``item.name`` arrives from
|
||||
``/externalmodel/getlist`` ALREADY escaped by ``populate_markdown`` ->
|
||||
``sanitize_tag``, so escaping it again rendered ``<Flux>`` to the
|
||||
user. The call-site escape was removed for that reason, and the sink
|
||||
deliberately does not escape either -- see
|
||||
tests/test_message_sink_provenance.py for why a blanket sink escape
|
||||
cannot be correct across these callers.
|
||||
|
||||
The GUARANTEE is unchanged, and is now asserted end-to-end on the value
|
||||
the caller ACTUALLY receives: take the server's own output for a hostile
|
||||
name, push it through the REAL lifted sink, and read innerHTML. That is
|
||||
strictly stronger than the old form, which passed whether or not the
|
||||
rendered result was inert.
|
||||
"""
|
||||
anchor = "this.showStatus(`Install "
|
||||
line = MODEL_MANAGER_SRC[MODEL_MANAGER_SRC.index(anchor):]
|
||||
line = line[:line.index("\n")]
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML", line,
|
||||
"item.name is already server-escaped; escaping here is the mojibake "
|
||||
"bug this call site used to have: %r" % (line,),
|
||||
)
|
||||
# What populate_markdown -> sanitize_tag puts on the wire for the payload.
|
||||
server_value = TEXT_PAYLOAD.replace("<", "<").replace(">", ">")
|
||||
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\tshowStatus(msg, color)")
|
||||
rendered = _run_node(
|
||||
"%s\nconst sink = %s;\n"
|
||||
"const item = {name: %s};\n"
|
||||
"const el = { innerHTML: '' };\n"
|
||||
"const ctx = { element: { querySelector: () => el } };\n"
|
||||
"sink.call(ctx, `Install ${item.name} ...`);\n"
|
||||
"console.log(JSON.stringify({msg: el.innerHTML}));\n"
|
||||
% (_lift_sanitize_html(), lifted, json.dumps(server_value))
|
||||
)
|
||||
self._assert_inert(rendered["msg"], allowed_tags=set())
|
||||
# ...and the user reads the payload as text, not as entity soup.
|
||||
self.assertEqual(
|
||||
html.unescape(rendered["msg"]), "Install %s ..." % TEXT_PAYLOAD
|
||||
)
|
||||
|
||||
# --- filter dropdowns: same raw type/base values, different route -------
|
||||
def test_filter_dropdown_options_escape_label_and_value(self):
|
||||
out = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"label: renderOptions([{label: %s, value: 'x'}], ''),"
|
||||
"value: renderOptions([{label: 'x', value: %s}], ''),"
|
||||
"plain: renderOptions([{label: 'checkpoints', value: 'checkpoints'}], 'checkpoints')"
|
||||
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD), json.dumps(ATTR_BREAKOUT))
|
||||
)
|
||||
self._assert_inert(out["label"], allowed_tags={"option"})
|
||||
self._assert_inert(out["value"], allowed_tags={"option"})
|
||||
# Selection still matches on the RAW value, and benign data is untouched.
|
||||
self.assertIn(" selected", out["plain"])
|
||||
self.assertIn(">checkpoints</option>", out["plain"])
|
||||
|
||||
def test_update_filter_routes_all_three_dropdowns_through_render_options(self):
|
||||
block = slice_braced(MODEL_MANAGER_SRC, "updateFilter()")
|
||||
for dropdown in ("$filter", "$type", "$base"):
|
||||
self.assertIn(
|
||||
"%s.innerHTML = renderOptions(" % dropdown, block,
|
||||
"%s still builds <option> markup inline" % dropdown,
|
||||
)
|
||||
|
||||
# --- description: server-transformed HTML, must NOT be double-escaped ---
|
||||
def test_description_column_is_not_re_escaped(self):
|
||||
block = _slice_column("'description'")
|
||||
self.assertNotIn(
|
||||
"formatter", block,
|
||||
"description is server-transformed HTML (populate_markdown); re-escaping "
|
||||
"it renders visible mojibake such as 'Nodes for <Flux> workflows'",
|
||||
)
|
||||
|
||||
def test_description_server_value_still_reads_cleanly(self):
|
||||
# What populate_markdown -> sanitize_tag actually emits for "<Flux>".
|
||||
server_value = "Nodes for <Flux> workflows"
|
||||
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
|
||||
# Run through the client escaper as well, the user would see the entity
|
||||
# text instead of the angle brackets — that is what this guard forbids.
|
||||
double = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({v: sanitizeHTML(%s)}));\n"
|
||||
% (_lift_sanitize_html(), json.dumps(server_value))
|
||||
)["v"]
|
||||
self.assertEqual(html.unescape(double), server_value)
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ModelManagerMessageSinkTest(unittest.TestCase):
|
||||
"""model-manager's OWN message sinks — it does not use createUIStateManager.
|
||||
|
||||
This class replaces an earlier ``test_turbogrid_bundle_untouched`` guard,
|
||||
which asserted that ``js/turbogrid.esm.js`` was unchanged relative to a
|
||||
fixed base commit. That was an INTEGRATION ARTIFACT, not a property of this
|
||||
file: it encoded "only model-manager was touched by the work item that
|
||||
added these tests". In the integrated tree the vendored grid IS changed on
|
||||
purpose, so the assertion reported a cross-file fact that says nothing
|
||||
about whether model-manager's own sinks are safe. What this file can
|
||||
actually guarantee is asserted instead — and it is strictly more coverage,
|
||||
because the sinks below were not covered by anything before.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sanitize = _lift_sanitize_html()
|
||||
|
||||
def _call_sink(self, method: str, msg, color=None) -> str:
|
||||
"""Run one lifted sink against a stub element; return the real innerHTML."""
|
||||
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\t%s(msg, color)" % method)
|
||||
args = json.dumps(msg) + ("" if color is None else ", " + json.dumps(color))
|
||||
return _run_node(
|
||||
"%s\n"
|
||||
"const sink = %s;\n"
|
||||
"const el = { innerHTML: '' };\n"
|
||||
"const ctx = { element: { querySelector: () => el } };\n"
|
||||
"sink.call(ctx, %s);\n"
|
||||
"console.log(JSON.stringify({v: el.innerHTML}));\n"
|
||||
% (self.sanitize, lifted, args)
|
||||
)["v"]
|
||||
|
||||
# --- the sinks take DISPLAY-READY text and do NOT escape ---------------
|
||||
def test_the_sinks_deliberately_do_not_escape(self):
|
||||
# Structural, and load-bearing: a well-meaning sanitizeHTML added here
|
||||
# would double-escape the server-escaped values these sinks actually
|
||||
# receive. tests/test_message_sink_provenance.py is what keeps that safe
|
||||
# by classifying every caller.
|
||||
for method in ("showStatus(msg, color)", "showMessage(msg, color)"):
|
||||
body = slice_braced(MODEL_MANAGER_SRC, "\t" + method)
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML", body,
|
||||
"%s escapes at the sink; its callers pass ALREADY-escaped server "
|
||||
"values, so this renders '<Flux>' to the user" % method,
|
||||
)
|
||||
self.assertIn("innerHTML", body)
|
||||
|
||||
def test_a_server_escaped_value_renders_inert_through_the_sink(self):
|
||||
server_value = "Install %s ..." % TEXT_PAYLOAD.replace(
|
||||
"<", "<").replace(">", ">")
|
||||
rendered = self._call_sink("showStatus", server_value)
|
||||
self.assertNotIn("img", _parse(rendered).tags)
|
||||
|
||||
def test_a_benign_angle_bracket_name_is_not_double_escaped(self):
|
||||
# THE regression this arrangement exists to prevent. The server sends
|
||||
# 'Model for <Flux> workflows'; the user must read the angle
|
||||
# brackets, not the entities.
|
||||
server_value = "Install Model for <Flux> workflows ..."
|
||||
rendered = self._call_sink("showStatus", server_value)
|
||||
self.assertEqual(
|
||||
html.unescape(rendered), "Install Model for <Flux> workflows ..."
|
||||
)
|
||||
self.assertNotIn("&lt;", rendered)
|
||||
|
||||
def test_the_font_wrapper_still_renders(self):
|
||||
rendered = self._call_sink("showMessage", "plain text", "red")
|
||||
collector = _parse(rendered)
|
||||
self.assertIn("font", collector.tags)
|
||||
self.assertIn(("color", "red"), collector.attrs)
|
||||
|
||||
# --- showError: HTML BY CONTRACT, escaped by its callers ---------------
|
||||
def test_show_error_forwards_to_the_non_escaping_show_message(self):
|
||||
# showError is HTML by contract: its errorMsg is built escaped because
|
||||
# the SAME string also goes to ComfyUI-core show_message(). Forwarding
|
||||
# to a NON-escaping showMessage keeps that at exactly one escape.
|
||||
body = slice_braced(MODEL_MANAGER_SRC, "\tshowError(err)")
|
||||
self.assertIn("this.showMessage(err, \"red\")", body)
|
||||
|
||||
# --- the caller side ---------------------------------------------------
|
||||
def test_the_install_status_caller_passes_the_server_value_untouched(self):
|
||||
self.assertIn("this.showStatus(`Install ${item.name} ...`)", MODEL_MANAGER_SRC)
|
||||
|
||||
def test_the_install_error_message_escapes_only_the_raw_response(self):
|
||||
# The name is already escaped by the server; the error response is raw.
|
||||
self.assertIn(
|
||||
"errorMsg = `'${item.name}': `", MODEL_MANAGER_SRC
|
||||
)
|
||||
self.assertIn("errorMsg += sanitizeHTML(await res.text())", MODEL_MANAGER_SRC)
|
||||
|
||||
def test_install_failure_preserves_server_escaped_names_in_both_sinks(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
methods = ",\n".join(slice_braced(MODEL_MANAGER_SRC, marker) for marker in [
|
||||
"async installModels(list, btn)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)"
|
||||
])
|
||||
response_text = '<img src=x onerror="window.__probe=1"> download failed'
|
||||
for name in ["Model <Flux>", '<img src=x onerror="window.__probe=1">']:
|
||||
for status in [403, 500]:
|
||||
with self.subTest(name=name, status=status):
|
||||
item = {"name": name, "originalData": {}, "hash": "model"}
|
||||
populate(item)
|
||||
rendered = _run_node("""
|
||||
%s
|
||||
const messages = {};
|
||||
const elements = {};
|
||||
const api = {fetchApi: async (url) => url.endsWith('/status')
|
||||
? {json: async () => ({is_processing: false})}
|
||||
: {status: %d, json: async () => ({}), text: async () => %s}};
|
||||
const show_message = (message) => messages.dialog = message;
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: (selector) => elements[selector] ||= {innerHTML: ''}},
|
||||
grid: {scrollRowIntoView() {}, updateCell() {}},
|
||||
focusInstall() {return true;}
|
||||
};
|
||||
await ctx.installModels([%s], {classList: {add() {}}});
|
||||
messages.panel = elements['.cmm-manager-message'].innerHTML;
|
||||
console.log(JSON.stringify(messages));
|
||||
""" % (self.sanitize, status, json.dumps(response_text), methods, json.dumps(item)))
|
||||
self.assertEqual(set(rendered), {"panel", "dialog"})
|
||||
for message in rendered.values():
|
||||
self.assertIn("'%s': " % name, html.unescape(message))
|
||||
self.assertNotIn("img", _parse(message).tags)
|
||||
if status == 500:
|
||||
self.assertIn(response_text, html.unescape(message))
|
||||
|
||||
def test_the_queue_result_error_text_is_escaped_by_the_caller(self):
|
||||
self.assertIn("errorMsg += sanitizeHTML(String(v))", MODEL_MANAGER_SRC)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,286 +0,0 @@
|
||||
"""RED->GREEN guard for the js/node-usage-analyzer.js innerHTML sinks.
|
||||
|
||||
The analyzer writes pack data and workflow data into innerHTML through turbogrid
|
||||
cells, a flyover, and the status/message elements. Before the fix: the title
|
||||
column built an UNQUOTED href (a bare space breaks out of the attribute); the
|
||||
install-progress showStatus interpolated a raw pack key; the usage-details
|
||||
flyover interpolated a raw workflow filename; and the install error path
|
||||
interpolated both a raw pack key and raw server text.
|
||||
|
||||
One value needed fixing at its SOURCE rather than at the sink. The title column
|
||||
is built as `pack.title || packKey`: /customnode/getlist runs populate_markdown
|
||||
over each pack, so `pack.title` is server-escaped, but `packKey` is a dict key
|
||||
that no server transform touches. Escaping at the formatter would have
|
||||
double-escaped the common path into visible entity text ("Nodes for <Flux>
|
||||
workflows"); leaving it bare would have shipped the raw fallback. The fix
|
||||
normalises the fallback so the column carries ONE provenance, which is what
|
||||
makes the bare formatter correct by construction.
|
||||
|
||||
This grid uses cellResizeObserver only — no rowFilter, no
|
||||
highlightKeywordsFilter — so the keyword-highlight re-render does not reach it.
|
||||
The two searchable managers use js/manager-grid.js to secure that path without
|
||||
modifying the vendored TurboGrid bundle.
|
||||
|
||||
The status/message sinks take DISPLAY-READY text and do NOT escape, because
|
||||
their callers carry mixed provenance; every caller is classified in
|
||||
tests/test_message_sink_provenance.py. This analyzer is the only file that
|
||||
reaches BOTH sink implementations — the shared one from common.js via `this.ui`
|
||||
and its own class methods.
|
||||
|
||||
REPRODUCE THE RED HALF against the pre-fix revision — one command:
|
||||
mkdir -p /some/dir
|
||||
git show <base>:js/node-usage-analyzer.js > /some/dir/node-usage-analyzer.js
|
||||
git show <base>:js/common.js > /some/dir/common.js
|
||||
MANAGER_JS_DIR=/some/dir pytest tests/test_node_usage_escaping.py
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import (
|
||||
NODE,
|
||||
JsSource,
|
||||
event_handlers,
|
||||
parse,
|
||||
run_node,
|
||||
slice_braced,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
|
||||
ANALYZER = "node-usage-analyzer.js"
|
||||
COMMON = "common.js"
|
||||
TITLE_FORMATTER_ANCHOR = "formatter: function (name, rowItem, columnItem, cellNode)"
|
||||
|
||||
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
|
||||
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
|
||||
# An ordinary pack reference, for the legs that ask what a CORRECT link
|
||||
# looks like rather than what a hostile one cannot do.
|
||||
BENIGN_REFERENCE = "https://github.com/owner/repo"
|
||||
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
|
||||
|
||||
def _url_helper_home() -> str:
|
||||
"""Which file currently owns sanitizeUrl/safeHref, or '' on a pre-fix tree.
|
||||
|
||||
They were local to the analyzer when this guard was written and now live in
|
||||
common.js, imported by every client file. Resolving the home rather than
|
||||
hard-coding it keeps the MANAGER_JS_DIR reproduce working against both
|
||||
revisions, and makes a THIRD home fail loudly instead of being lifted twice.
|
||||
"""
|
||||
for filename in (COMMON, ANALYZER):
|
||||
if "SAFE_URL_SCHEMES" in JS.text(filename):
|
||||
return filename
|
||||
return ""
|
||||
|
||||
|
||||
def _preamble() -> str:
|
||||
parts = [JS.lift_declaration(COMMON, "export function sanitizeHTML(")]
|
||||
home = _url_helper_home()
|
||||
if home:
|
||||
span = JS.lift_span(
|
||||
home, "SAFE_URL_SCHEMES", "safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
parts.append("const " + span.replace("export ", ""))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class NodeUsageSinkEscapingTest(unittest.TestCase):
|
||||
"""Every sink is exercised through the REAL lifted code, never a copy."""
|
||||
|
||||
def _assert_inert(self, markup: str, allowed_tags):
|
||||
"""No element outside the allow-list, and no event-handler attribute."""
|
||||
parsed = parse(markup)
|
||||
self.assertEqual(
|
||||
[t for t in parsed.tags if t not in allowed_tags], [],
|
||||
"live element injected into: %r" % (markup,),
|
||||
)
|
||||
self.assertEqual(
|
||||
event_handlers(markup), [],
|
||||
"event-handler attribute injected into: %r" % (markup,),
|
||||
)
|
||||
|
||||
# --- C2: title column, previously UNQUOTED href -------------------------
|
||||
def _render_title(self, reference: str, name: str = "My Pack") -> str:
|
||||
return run_node(
|
||||
"%s\nconst titleFormatter = %s;\n"
|
||||
"console.log(JSON.stringify({markup: titleFormatter(%s, {reference: %s})}));\n"
|
||||
% (_preamble(),
|
||||
JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR),
|
||||
json.dumps(name), json.dumps(reference))
|
||||
)["markup"]
|
||||
|
||||
def test_c2_href_survives_breakout_attempts(self):
|
||||
for reference in (BREAKOUT_HREF, QUOTE_BREAKOUT):
|
||||
markup = self._render_title(reference)
|
||||
self._assert_inert(markup, allowed_tags={"a", "b"})
|
||||
self.assertEqual(
|
||||
sorted(name for name, _ in parse(markup).attrs),
|
||||
["href", "rel", "target"],
|
||||
"unexpected attributes on the anchor: %r" % (markup,),
|
||||
)
|
||||
|
||||
def test_c2_title_anchor_severs_the_opener(self):
|
||||
"""The pack chooses this host, so the page it opens must not keep us.
|
||||
|
||||
`reference` is channel data, so the pack author picks the page that
|
||||
opens; without rel that page holds a live window.opener back into the
|
||||
Manager UI. Membership on the SPLIT attribute, not a literal match:
|
||||
'noreferrer noopener' is the same thing, and pinning token order would
|
||||
report a correct fix as a defect.
|
||||
"""
|
||||
markup = self._render_title(BENIGN_REFERENCE)
|
||||
attrs = dict(parse(markup).attrs)
|
||||
self.assertEqual(
|
||||
attrs.get("target"), "_blank",
|
||||
"this guard is about _blank anchors; the target changed, so "
|
||||
"re-derive it rather than deleting the assertion: %r" % (markup,))
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"the title anchor opens a pack-chosen page in a new tab without "
|
||||
"rel, so that page keeps a reference to this window: %r" % (markup,))
|
||||
tokens = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", tokens, markup)
|
||||
self.assertIn("noreferrer", tokens, markup)
|
||||
|
||||
def test_c2_href_rejects_dangerous_schemes(self):
|
||||
for reference in ("javascript:alert(1)", "java\tscript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>"):
|
||||
href = dict(parse(self._render_title(reference)).attrs)["href"]
|
||||
self.assertEqual(href, "#", "%r survived as %r" % (reference, href))
|
||||
|
||||
def test_c2_benign_reference_and_title_are_untouched(self):
|
||||
markup = self._render_title(BENIGN_REFERENCE, name="My Pack")
|
||||
self.assertEqual(dict(parse(markup).attrs)["href"], BENIGN_REFERENCE)
|
||||
self.assertIn("<b>My Pack</b>", markup)
|
||||
|
||||
def test_c2_title_is_not_double_escaped_at_the_formatter(self):
|
||||
# The column value is already escaped HTML text by the time it arrives,
|
||||
# so re-escaping here would put entity text on screen.
|
||||
block = JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR)
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML(String(name))", block,
|
||||
"title is normalised at its source; escaping it again renders '<' to the user",
|
||||
)
|
||||
server_value = "Nodes for <Flux> workflows"
|
||||
markup = self._render_title("https://example.com/x", name=server_value)
|
||||
self.assertIn(server_value, markup)
|
||||
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
|
||||
|
||||
def _run_panel(self, action, packs=None, status=500):
|
||||
"""Run the production data, operation and rendering methods with stubbed I/O."""
|
||||
methods = ",\n".join(slice_braced(JS.text(ANALYZER), "\n\t" + marker) for marker in [
|
||||
"async loadData()", "async installModels(list, btn)", "async uninstallModels(list, btn)",
|
||||
"showUsageDetails(rowItem)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
|
||||
])
|
||||
return run_node("""
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
const elements = {};
|
||||
const messages = {};
|
||||
const posts = [];
|
||||
const manager_instance = {datasrc_combo: {value: 'default'}};
|
||||
const fetchData = async () => ({data: {channel: 'default', node_packs: %s}});
|
||||
const analyzeWorkflowUsage = async () => ({success: true});
|
||||
const api = {fetchApi: async (url, options) => {
|
||||
if (options?.body) posts.push(JSON.parse(options.body));
|
||||
return {status: %d, json: async () => ({is_processing: false}), text: async () => %s};
|
||||
}};
|
||||
const customConfirm = async () => true;
|
||||
const md5 = () => 'hash';
|
||||
const show_message = message => messages.dialog = message;
|
||||
const btn = {classList: {add() {}, remove() {}}};
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: selector => elements[selector] ||= {innerHTML: '', style: {}}},
|
||||
grid: {scrollRowIntoView() {}, updateCell() {}, updateRow() {}},
|
||||
flyover: {show: (title, body) => Object.assign(messages, {title, body})},
|
||||
showLoading() {}, hideLoading() {}, renderGrid() {}, getModelList(rows) {return rows;}
|
||||
};
|
||||
ctx.ui = createUIStateManager(ctx.element, {
|
||||
status: '.nu-manager-status', message: '.nu-manager-message'
|
||||
});
|
||||
%s
|
||||
console.log(JSON.stringify({elements, messages, posts, rows: ctx.modelList}));
|
||||
""" % (_preamble(),
|
||||
JS.lift_declaration(COMMON, "export function createUIStateManager(element, selectors)"),
|
||||
JS.lift_declaration(COMMON, "export async function uninstallNodes(nodeList, options = {})"),
|
||||
json.dumps(packs or {}), status, json.dumps(TEXT_PAYLOAD), methods, action))
|
||||
|
||||
def test_title_fallback_is_escaped_at_its_source(self):
|
||||
out = self._run_panel("await ctx.loadData();", {TEXT_PAYLOAD: {"state": "enabled"}})
|
||||
row = out["rows"][0]
|
||||
self._assert_inert(row["title"], allowed_tags=set())
|
||||
self.assertEqual(html.unescape(row["title"]), TEXT_PAYLOAD)
|
||||
self.assertEqual(row["name"], TEXT_PAYLOAD)
|
||||
|
||||
def test_install_progress_and_errors_escape_raw_values(self):
|
||||
for status in (403, 500):
|
||||
with self.subTest(status=status):
|
||||
out = self._run_panel("await ctx.loadData(); await ctx.installModels(ctx.modelList, btn);",
|
||||
{TEXT_PAYLOAD: {"state": "enabled"}}, status)
|
||||
progress = out["elements"][".nu-manager-status"]["innerHTML"]
|
||||
self._assert_inert(progress, allowed_tags=set())
|
||||
self.assertEqual(html.unescape(progress), f"Install {TEXT_PAYLOAD} ...")
|
||||
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
|
||||
for error in errors:
|
||||
self._assert_inert(error, allowed_tags={"font"})
|
||||
self.assertIn(f"'{TEXT_PAYLOAD}': ", html.unescape(error))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
|
||||
|
||||
def test_flyover_escapes_workflow_filename(self):
|
||||
for filename in (TEXT_PAYLOAD, "workflow <Flux> & friends.json"):
|
||||
with self.subTest(filename=filename):
|
||||
out = self._run_panel("ctx.showUsageDetails(%s);" % json.dumps({
|
||||
"title": "My Pack", "workflowDetails": [{"filename": filename, "nodeCount": 2}],
|
||||
}))
|
||||
body = out["messages"]["body"]
|
||||
self._assert_inert(body, allowed_tags={"div"})
|
||||
self.assertIn(filename, html.unescape(body))
|
||||
self.assertIn("2 nodes", body)
|
||||
|
||||
def test_uninstall_failure_keeps_normalized_titles_readable(self):
|
||||
for name in ("Pack <Flux> & friends", TEXT_PAYLOAD):
|
||||
for has_title in (False, True):
|
||||
for status in (403, 500):
|
||||
with self.subTest(name=name, has_title=has_title, status=status):
|
||||
pack = {"state": "enabled"}
|
||||
if has_title:
|
||||
pack["title"] = html.escape(name)
|
||||
out = self._run_panel("await ctx.loadData(); await ctx.uninstallModels(ctx.modelList, btn);",
|
||||
{name: pack}, status)
|
||||
progress = out["elements"][".nu-manager-status"]["innerHTML"]
|
||||
self.assertEqual(html.unescape(progress), f"Uninstall {name} ...")
|
||||
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
|
||||
for error in errors:
|
||||
self._assert_inert(error, allowed_tags={"font"})
|
||||
self.assertIn(f"'{name}': ", html.unescape(error))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
|
||||
|
||||
def test_uninstall_helper_escapes_a_raw_name_fallback(self):
|
||||
out = self._run_panel("""
|
||||
await uninstallNodes([{name: %s}], {
|
||||
onProgress: msg => messages.progress = msg,
|
||||
onError: msg => messages.error = msg
|
||||
});
|
||||
""" % json.dumps(TEXT_PAYLOAD))
|
||||
for message in out["messages"].values():
|
||||
self._assert_inert(message, allowed_tags=set())
|
||||
self.assertIn(TEXT_PAYLOAD, html.unescape(message))
|
||||
self.assertEqual(out["posts"][0]["name"], TEXT_PAYLOAD)
|
||||
|
||||
def test_grid_has_no_keyword_highlight_path(self):
|
||||
# The premise the row states: this grid cannot hit the highlight defect.
|
||||
source = JS.text(ANALYZER)
|
||||
self.assertNotIn("highlightKeywordsFilter", source)
|
||||
self.assertNotIn("rowFilter:", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Exercise notice dependency installation without starting ComfyUI or pip."""
|
||||
import ast
|
||||
import builtins
|
||||
import importlib.metadata
|
||||
import itertools
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, mock_open, patch
|
||||
|
||||
import toml
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
from manager_test_utils import load_manager_util
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class NoticeDependencyTest(unittest.TestCase):
|
||||
def test_util_can_load_before_nh3_is_installed(self):
|
||||
with patch.dict(sys.modules, {'nh3': None}):
|
||||
util = load_manager_util()
|
||||
self.assertEqual(util.escape_html_attribute('<text>'), '<text>')
|
||||
with self.assertRaises(ModuleNotFoundError):
|
||||
util.sanitize_html_fragment('<p>notice</p>')
|
||||
|
||||
def test_bootstrap_installs_missing_or_outdated_nh3_before_importing_it(self):
|
||||
manifest = (ROOT / 'requirements.txt').read_text()
|
||||
commented_manifest = '\n# Dependencies\n--index-url https://pypi.org/simple\n' + manifest.replace(
|
||||
'nh3>=0.3.7', ' nh3>=0.3.7 # notice sanitizer',
|
||||
)
|
||||
for installed_version, requirements in itertools.product(
|
||||
(None, '0.3.0', '0.3.7rc1', '0.3.7', '0.4.0'), (manifest, commented_manifest),
|
||||
):
|
||||
with self.subTest(installed_version=installed_version, commented=requirements == commented_manifest):
|
||||
source = (ROOT / 'prestartup_script.py').read_text()
|
||||
node = next(n for n in ast.parse(source).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == 'ensure_dependencies')
|
||||
installer = Mock()
|
||||
namespace = {
|
||||
'__file__': str(ROOT / 'prestartup_script.py'),
|
||||
'os': os,
|
||||
're': re,
|
||||
'subprocess': types.SimpleNamespace(
|
||||
check_output=installer, CalledProcessError=subprocess.CalledProcessError,
|
||||
),
|
||||
'manager_util': types.SimpleNamespace(make_pip_cmd=lambda args: args),
|
||||
}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), '<bootstrap>', 'exec'), namespace)
|
||||
needs_install = installed_version in (None, '0.3.0', '0.3.7rc1')
|
||||
modules = {name: types.ModuleType(name) for name in ('git', 'toml', 'rich', 'chardet')}
|
||||
# An old native module must not be imported before pip upgrades it.
|
||||
modules['nh3'] = None if needs_install else types.ModuleType('nh3')
|
||||
failure = importlib.metadata.PackageNotFoundError('nh3') if installed_version is None else None
|
||||
with patch.dict(sys.modules, modules), patch(
|
||||
'importlib.metadata.version', return_value=installed_version, side_effect=failure,
|
||||
), patch('builtins.open', mock_open(read_data=requirements)), patch('builtins.print'), patch(
|
||||
'builtins.__import__', wraps=builtins.__import__,
|
||||
) as imports:
|
||||
namespace['ensure_dependencies']()
|
||||
nh3_imports = [call for call in imports.call_args_list if call.args[0] == 'nh3']
|
||||
self.assertEqual(len(nh3_imports), 0 if needs_install else 1)
|
||||
if needs_install:
|
||||
installer.assert_called_once_with(['install', '-r', str(ROOT / 'requirements.txt')])
|
||||
else:
|
||||
installer.assert_not_called()
|
||||
|
||||
def test_dependency_manifests_agree_on_notice_requirements(self):
|
||||
lines = (re.split(r'\s+#', line.strip(), maxsplit=1)[0]
|
||||
for line in (ROOT / 'requirements.txt').read_text().splitlines())
|
||||
pip_requirements = {
|
||||
req.name: req for req in map(Requirement, (line for line in lines if line and not line.startswith(('#', '-'))))
|
||||
}
|
||||
project_requirements = {
|
||||
req.name: req for req in map(Requirement, toml.load(ROOT / 'pyproject.toml')['project']['dependencies'])
|
||||
}
|
||||
for name in ('nh3', 'packaging'):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(str(pip_requirements[name]), str(project_requirements[name]))
|
||||
@@ -1,372 +0,0 @@
|
||||
"""RED->GREEN guard for the pack-grid column sinks in js/custom-nodes-manager.js.
|
||||
|
||||
WHY THESE THREE VALUES
|
||||
----------------------
|
||||
The Custom Nodes grid renders every cell through turbogrid, which hands a column
|
||||
formatter the RAW row value and assigns the formatter's return through
|
||||
``innerHTML``. Whatever a formatter returns unescaped is therefore live markup on
|
||||
FIRST RENDER, before any click, for every row the list contains.
|
||||
|
||||
Three values reached that sink unescaped, and all three carry the same provenance
|
||||
as ``author`` (which the neighbouring formatter in the same column array already
|
||||
escapes):
|
||||
|
||||
``stars`` and ``last_update`` are written by ``populate_github_stats`` in
|
||||
glob/manager_core.py straight off github-stats.json, with no transform.
|
||||
``populate_markdown`` -- the only server-side escape on this payload -- covers
|
||||
``description``, ``name`` and ``title`` and NOTHING else, and
|
||||
``populate_github_stats`` swallows a broken row with a bare ``except:``, so a
|
||||
channel-supplied ``stars`` / ``last_update`` survives untouched when
|
||||
``v['reference']`` raises. That double fact is pinned in
|
||||
``PopulateProvenanceTest`` below, because it is what makes escaping HERE
|
||||
correct rather than a double-escape.
|
||||
|
||||
``channel`` is the resolved channel alias from /customnode/getlist -- a key out
|
||||
of the channel-list config file, passed through untransformed into the
|
||||
"Channel: ..." banner's ``innerHTML``.
|
||||
|
||||
WHY THE OLD GUARDS DID NOT STOP THEM
|
||||
------------------------------------
|
||||
Neither ``stars`` guard was a guard. ``stars < 0`` is a NaN comparison for a
|
||||
string ('<img ...>' < 0 is false), and ``typeof stars === 'number'`` only chooses
|
||||
the toLocaleString branch -- so a string payload fell through to the bare
|
||||
``return stars;``. ``last_update`` looked defended by ``.split(' ')[0]``, but a
|
||||
space is not the only attribute separator a browser accepts: ``/`` works, and so
|
||||
do tab and newline, so ``<img/src=x/onerror=alert(1)>`` passes through the split
|
||||
whole. Escaping is what closes both; a smarter split would not.
|
||||
|
||||
REPRODUCE THE RED HALF against the pre-fix revision -- one command:
|
||||
mkdir -p /some/dir
|
||||
git show <base>:js/custom-nodes-manager.js > /some/dir/custom-nodes-manager.js
|
||||
git show <base>:js/common.js > /some/dir/common.js
|
||||
MANAGER_JS_DIR=/some/dir pytest tests/test_pack_column_escaping.py
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import (
|
||||
NODE,
|
||||
JsSource,
|
||||
event_handlers,
|
||||
line_containing,
|
||||
parse,
|
||||
slice_object_entry,
|
||||
slice_braced,
|
||||
)
|
||||
from js_lift import run_node
|
||||
from manager_test_utils import load_manager_util, load_markdown_functions
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (REPO_ROOT / "glob"))
|
||||
|
||||
MANAGER = "custom-nodes-manager.js"
|
||||
COMMON = "common.js"
|
||||
|
||||
STARS_ANCHOR = "id: 'stars',"
|
||||
LAST_UPDATE_ANCHOR = "id: 'last_update',"
|
||||
CHANNEL_ANCHOR = '.cn-manager-channel").innerHTML'
|
||||
|
||||
# A space-separated payload for the columns that never split, and a
|
||||
# SLASH-separated one for last_update, whose `.split(' ')[0]` used to read as a
|
||||
# defence. Neither needs `<` to be dangerous in an attribute, but both are
|
||||
# element injections here because the sink is element text.
|
||||
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
NO_SPACE_PAYLOAD = "<img/src=x/onerror=alert(1)>"
|
||||
TAB_PAYLOAD = "<img\tsrc=x\tonerror=alert(1)>"
|
||||
|
||||
|
||||
def _lift_get_time_ago() -> str:
|
||||
"""getTimeAgo, lifted by SPAN rather than by brace matching.
|
||||
|
||||
js_lift's brace scanner reads a lone ``/`` as a regex literal -- correct for
|
||||
every range it was written for -- and getTimeAgo DIVIDES
|
||||
(``Math.abs(diff) / divisor``), so ``slice_braced`` raises on it by design
|
||||
rather than handing back a wrong slice. The span is bounded by the
|
||||
function's own last statement instead, so a change to that statement fails
|
||||
loudly here rather than lifting a truncated body.
|
||||
"""
|
||||
span = JS.lift_span(COMMON, "export function getTimeAgo(", "'in a year', diff < 0);")
|
||||
return span.replace("export ", "", 1) + "\n}"
|
||||
|
||||
|
||||
def _preamble() -> str:
|
||||
"""sanitizeHTML and getTimeAgo, lifted from the shipped common.js."""
|
||||
return "\n".join([
|
||||
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
_lift_get_time_ago(),
|
||||
])
|
||||
|
||||
|
||||
def _render(formatter_source: str, value) -> str:
|
||||
return run_node(
|
||||
"%s\nconst formatter = %s;\n"
|
||||
"console.log(JSON.stringify({markup: String(formatter(%s))}));\n"
|
||||
% (_preamble(), formatter_source, json.dumps(value))
|
||||
)["markup"]
|
||||
|
||||
|
||||
class _InertAssertions(unittest.TestCase):
|
||||
def assert_inert(self, markup: str, allowed_tags):
|
||||
parsed = parse(markup)
|
||||
self.assertEqual(
|
||||
[t for t in parsed.tags if t not in allowed_tags], [],
|
||||
"live element injected into: %r" % (markup,),
|
||||
)
|
||||
self.assertEqual(
|
||||
event_handlers(markup), [],
|
||||
"event-handler attribute injected into: %r" % (markup,),
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class StarsColumnTest(_InertAssertions):
|
||||
"""The ★ column formatter, lifted and executed as shipped."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.formatter = JS.lift_formatter(MANAGER, STARS_ANCHOR)
|
||||
|
||||
def test_a_string_payload_renders_inert(self):
|
||||
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._markup(payload)
|
||||
self.assert_inert(markup, allowed_tags=set())
|
||||
self.assertEqual(
|
||||
html.unescape(markup), payload,
|
||||
"the payload must survive as READABLE TEXT, not be dropped",
|
||||
)
|
||||
|
||||
def _markup(self, value):
|
||||
return _render(self.formatter, value)
|
||||
|
||||
def test_a_number_still_renders_through_toLocaleString(self):
|
||||
"""The behaviour the fix must not disturb: a real star count is grouped.
|
||||
|
||||
Compared against node's OWN toLocaleString rather than a hard-coded
|
||||
"1,234", so the assertion does not encode the runtime's default locale.
|
||||
"""
|
||||
expected = run_node(
|
||||
"console.log(JSON.stringify({v: (1234567).toLocaleString()}));"
|
||||
)["v"]
|
||||
markup = self._markup(1234567)
|
||||
self.assertEqual(markup, expected)
|
||||
self.assertNotIn("&", markup, "a number must not come back entity-escaped")
|
||||
|
||||
def test_a_negative_count_is_still_na(self):
|
||||
# -1 is populate_github_stats' "no stats for this pack" sentinel.
|
||||
self.assertEqual(self._markup(-1), "N/A")
|
||||
|
||||
def test_zero_is_not_treated_as_missing(self):
|
||||
self.assertEqual(self._markup(0), "0")
|
||||
|
||||
def test_a_missing_value_still_renders_an_empty_cell(self):
|
||||
"""null / undefined pass through, as at every other escaped column.
|
||||
|
||||
turbogrid's renderNodeContent does `void 0===e&&(e="")` and then assigns
|
||||
`innerHTML`, which is [LegacyNullToEmptyString] -- so a formatter that
|
||||
passes null/undefined THROUGH yields an empty cell, while one that
|
||||
stringifies them first puts the literal words "undefined" / "null" on
|
||||
screen. That is the shape spelled out at the id formatter in this same
|
||||
file and at model-manager's escapeCell, both of which say so in a comment.
|
||||
|
||||
It is reachable on exactly the malformed-row path this file is about:
|
||||
populate_github_stats swallows a broken entry with a bare `except:`, so
|
||||
`stars` is simply ABSENT for a pack whose `reference` lookup raises.
|
||||
|
||||
Asserted through renderNodeContent's own semantics rather than on the
|
||||
formatter's return alone, because "returns undefined" and "renders an
|
||||
empty cell" are different claims and it is the second one the user sees.
|
||||
"""
|
||||
out = run_node(
|
||||
"%s\nconst formatter = %s;\n"
|
||||
"const cell = (v) => { let e = formatter(v); if (void 0 === e) e = '';\n"
|
||||
" return e === null ? '' : String(e); };\n"
|
||||
"console.log(JSON.stringify({\n"
|
||||
" undefined_passthrough: formatter(undefined) === undefined,\n"
|
||||
" null_passthrough: formatter(null) === null,\n"
|
||||
" undefined_cell: cell(undefined), null_cell: cell(null)}));\n"
|
||||
% (_preamble(), self.formatter)
|
||||
)
|
||||
self.assertTrue(
|
||||
out["undefined_passthrough"],
|
||||
"a missing star count is stringified instead of passed through, so "
|
||||
"the cell reads 'undefined' where it used to be empty",
|
||||
)
|
||||
self.assertTrue(out["null_passthrough"], "null is stringified, not passed through")
|
||||
self.assertEqual(out["undefined_cell"], "")
|
||||
self.assertEqual(out["null_cell"], "")
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class LastUpdateColumnTest(_InertAssertions):
|
||||
"""The Last Update column formatter, lifted and executed as shipped."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.formatter = JS.lift_formatter(MANAGER, LAST_UPDATE_ANCHOR)
|
||||
|
||||
def _markup(self, value):
|
||||
return _render(self.formatter, value)
|
||||
|
||||
def test_a_slash_separated_payload_renders_inert(self):
|
||||
"""The case `.split(' ')[0]` cannot touch: no space anywhere in it."""
|
||||
markup = self._markup(NO_SPACE_PAYLOAD)
|
||||
self.assert_inert(markup, allowed_tags={"span"})
|
||||
|
||||
def test_separator_variants_render_inert(self):
|
||||
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
|
||||
with self.subTest(payload=payload):
|
||||
self.assert_inert(self._markup(payload), allowed_tags={"span"})
|
||||
|
||||
def test_the_title_attribute_cannot_be_broken_out_of(self):
|
||||
# `ago` is not escaped, deliberately: getTimeAgo returns "" or a phrase
|
||||
# off a fixed table and never echoes its input. This pins that premise
|
||||
# behaviourally rather than trusting it.
|
||||
markup = self._markup('x" onmouseover=alert(1) y')
|
||||
self.assertEqual(event_handlers(markup), [], markup)
|
||||
self.assertEqual(
|
||||
sorted(name for name, _ in parse(markup).attrs), ["title"], markup,
|
||||
)
|
||||
|
||||
def test_a_normal_date_still_renders_its_short_form(self):
|
||||
markup = self._markup("2024-01-02 03:04:05")
|
||||
self.assertIn(">2024-01-02<", markup)
|
||||
self.assertEqual([t for t in parse(markup).tags], ["span"])
|
||||
|
||||
def test_a_negative_timestamp_is_still_na(self):
|
||||
self.assertEqual(self._markup(-1), "N/A")
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ChannelBannerTest(_InertAssertions):
|
||||
"""The "Channel: ..." banner, rendered through the shipped source line."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.line = line_containing(JS.text(MANAGER), CHANNEL_ANCHOR)
|
||||
|
||||
def _markup(self, channel):
|
||||
expression = self.line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
return run_node(
|
||||
"%s\nconst self = {channel: %s};\n"
|
||||
"console.log(JSON.stringify({markup: %s}));\n"
|
||||
% (_preamble(), json.dumps(channel),
|
||||
expression.replace("this.channel", "self.channel"))
|
||||
)["markup"]
|
||||
|
||||
def test_a_payload_in_the_channel_alias_renders_inert(self):
|
||||
markup = self._markup(TEXT_PAYLOAD)
|
||||
self.assert_inert(markup, allowed_tags=set())
|
||||
self.assertIn("Channel: ", markup)
|
||||
|
||||
def test_a_benign_alias_is_still_readable(self):
|
||||
# No server transform touches this value, so escaping it once is
|
||||
# correct and cannot show entity text for an ordinary alias.
|
||||
self.assertIn("Channel: dev (Incomplete list)", self._markup("dev"))
|
||||
|
||||
|
||||
class ColumnShapeTest(unittest.TestCase):
|
||||
"""The formatters must keep EXISTING, or the grid's default takes over.
|
||||
|
||||
turbogrid's default formatter is a pass-through, so deleting either
|
||||
formatter silently restores the raw-value-into-innerHTML path these tests
|
||||
exist to close -- with every behavioural test above still passing, because
|
||||
they lift the formatter and would simply stop being able to find one.
|
||||
"""
|
||||
|
||||
def test_both_columns_declare_a_formatter(self):
|
||||
source = JS.text(MANAGER)
|
||||
for anchor in (STARS_ANCHOR, LAST_UPDATE_ANCHOR):
|
||||
with self.subTest(column=anchor):
|
||||
entry = slice_object_entry(source, anchor)
|
||||
self.assertIn(
|
||||
"formatter:", entry,
|
||||
"%s lost its formatter; the grid default renders the raw "
|
||||
"value into innerHTML" % (anchor,),
|
||||
)
|
||||
self.assertIn("sanitizeHTML(", entry)
|
||||
|
||||
|
||||
class PopulateProvenanceTest(unittest.TestCase):
|
||||
"""WHY escaping here is single-escaping, not double-escaping.
|
||||
|
||||
If the server ever started escaping these two fields, the formatters would
|
||||
render entity text to the user and this file would be the thing to change.
|
||||
The premise is therefore asserted rather than assumed.
|
||||
"""
|
||||
|
||||
def test_populate_markdown_does_not_cover_stars_or_last_update(self):
|
||||
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
|
||||
body = source[source.index("def populate_markdown("):]
|
||||
body = body[:body.index("\ndef ", 1)] if "\ndef " in body[1:] else body
|
||||
for field in ("'description'", "'name'", "'title'"):
|
||||
self.assertIn("if %s in x:" % field, body)
|
||||
for field in ("stars", "last_update"):
|
||||
self.assertNotIn(
|
||||
"'%s'" % field, body,
|
||||
"populate_markdown now touches %s -- if it escapes it, the "
|
||||
"formatter must STOP escaping or the user reads entity text"
|
||||
% (field,),
|
||||
)
|
||||
|
||||
def test_the_stats_fields_are_written_untransformed(self):
|
||||
source = (GLOB_DIR / "manager_core.py").read_text(encoding="utf-8")
|
||||
body = source[source.index("def populate_github_stats("):]
|
||||
body = body[:body.index("\ndef ", 1)]
|
||||
self.assertIn("v['stars'] = json_obj_github[url]['stars']", body)
|
||||
self.assertIn("v['last_update'] = json_obj_github[url]['last_update']", body)
|
||||
self.assertIn(
|
||||
"except:", body,
|
||||
"the bare except is what leaves a channel-supplied stars/last_update "
|
||||
"in place when reference lookup raises; if it is gone, re-derive the "
|
||||
"provenance argument in this file's docstring",
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class PackOperationErrorTest(_InertAssertions):
|
||||
def test_failed_operations_keep_server_escaped_titles_readable(self):
|
||||
populate = load_markdown_functions(load_manager_util())["populate_markdown"]
|
||||
methods = ",\n".join(slice_braced(JS.text(MANAGER), "\n\t" + marker) for marker in [
|
||||
"async installNodes(list, btn, title, selected_version)",
|
||||
"showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
|
||||
])
|
||||
for title in ("Pack <Flux> & friends", TEXT_PAYLOAD):
|
||||
item = {"title": title, "originalData": {}, "hash": "pack"}
|
||||
populate(item)
|
||||
for mode in ("install", "uninstall"):
|
||||
for status in (403, 500):
|
||||
with self.subTest(title=title, mode=mode, status=status):
|
||||
out = run_node("""
|
||||
%s
|
||||
const item = %s;
|
||||
const elements = {};
|
||||
const messages = {};
|
||||
const api = {fetchApi: async () => ({status: %d,
|
||||
json: async () => ({is_processing: false}), text: async () => %s})};
|
||||
const show_message = message => messages.dialog = message;
|
||||
const customConfirm = async () => true;
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: selector => elements[selector] ||= {innerHTML: ''}},
|
||||
grid: {getRowItemBy: () => item, scrollRowIntoView() {}, updateCell() {}},
|
||||
focusInstall() {return true;}
|
||||
};
|
||||
await ctx.installNodes(['pack'], {target: {classList: {add() {}}},
|
||||
label: 'Operation', mode: %s}, '', null);
|
||||
messages.panel = elements['.cn-manager-message'].innerHTML;
|
||||
console.log(JSON.stringify(messages));
|
||||
""" % (_preamble(), json.dumps(item), status, json.dumps(TEXT_PAYLOAD), methods, json.dumps(mode)))
|
||||
for message in out.values():
|
||||
self.assert_inert(message, allowed_tags={"font"})
|
||||
self.assertIn(f"'{title}': ", html.unescape(message))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(message))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,590 +0,0 @@
|
||||
"""Check remote notice HTML, sharing URLs, snapshot names and node selection UI.
|
||||
|
||||
Execute shipped Python and JavaScript with isolated external boundaries. Rendering
|
||||
checks cover hostile inputs and readable controls; link checks preserve opener isolation.
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import NODE, JsSource, run_node
|
||||
from manager_test_utils import GLOB_DIR, load_manager_util, load_markdown_functions
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
|
||||
COMMON = "common.js"
|
||||
SHARE_COMMON = "comfyui-share-common.js"
|
||||
SHARE_OPENART = "comfyui-share-openart.js"
|
||||
SHARE_COPUS = "comfyui-share-copus.js"
|
||||
SHARE_YOUML = "comfyui-share-youml.js"
|
||||
SNAPSHOT = "snapshot.js"
|
||||
MANAGER = "custom-nodes-manager.js"
|
||||
|
||||
# A scheme that executes on click, and a scheme that carries its own document.
|
||||
# Both are strings a third party can put in a URL field; neither is a host.
|
||||
SCHEME_PAYLOAD = "javascript:window.__probe=1"
|
||||
DATA_PAYLOAD = "data:text/html,<b>x</b>"
|
||||
BENIGN_URL = "https://example.com/w/123?a=1&b=2"
|
||||
# An http URL that ALSO closes the attribute it lands in. A service-chosen URL
|
||||
# is not "either a bad scheme or a quote" -- it is one string that can be both,
|
||||
# so a scheme allow-list alone leaves the quote. One variant per quoting style,
|
||||
# because the call sites differ.
|
||||
BREAKOUT_SINGLE_QUOTED = "https://example.com/x' onmouseover='window.__probe=1"
|
||||
BREAKOUT_DOUBLE_QUOTED = 'https://example.com/x" onmouseover="window.__probe=1'
|
||||
# Element text, for the sinks that render a name rather than a link.
|
||||
TEXT_PAYLOAD = '<img src=x onerror="window.__probe=1">'
|
||||
|
||||
|
||||
def _function_source(name: str, filename: str = "manager_server.py") -> str:
|
||||
"""The source text of one top-level function, WITHOUT importing its module.
|
||||
|
||||
Used for the wiring legs, where the question is "does this call site pass
|
||||
its value through the normaliser" -- a question about the call site, which a
|
||||
behavioural test on the normaliser alone cannot answer.
|
||||
"""
|
||||
source = (GLOB_DIR / filename).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
wanted = [
|
||||
node for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
|
||||
]
|
||||
assert len(wanted) == 1, "expected exactly one %s, got %d" % (name, len(wanted))
|
||||
return ast.get_source_segment(source, wanted[0]) or ""
|
||||
|
||||
|
||||
class _Collector(HTMLParser):
|
||||
"""What a browser would actually build from a fragment."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.elements = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.elements.append((tag, dict(attrs)))
|
||||
|
||||
handle_startendtag = handle_starttag
|
||||
|
||||
|
||||
def parse(markup: str) -> _Collector:
|
||||
collector = _Collector()
|
||||
collector.feed(markup)
|
||||
collector.close()
|
||||
return collector
|
||||
|
||||
|
||||
def tags(markup: str):
|
||||
return [tag for tag, _ in parse(markup).elements]
|
||||
|
||||
|
||||
def handler_attrs(markup: str):
|
||||
return [
|
||||
"%s@%s" % (tag, name)
|
||||
for tag, attrs in parse(markup).elements
|
||||
for name in attrs
|
||||
if name.lower().startswith("on")
|
||||
]
|
||||
|
||||
|
||||
def hrefs(markup: str):
|
||||
return [
|
||||
attrs["href"] for tag, attrs in parse(markup).elements
|
||||
if tag == "a" and "href" in attrs
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D1] the relayed wiki fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
class NoticeFragmentSanitizerTest(unittest.TestCase):
|
||||
"""Notice sanitization must remove executable content while preserving formatting."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manager_util = load_manager_util()
|
||||
|
||||
def _sanitize(self, fragment: str) -> str:
|
||||
fn = getattr(self.manager_util, "sanitize_html_fragment", None)
|
||||
self.assertIsNotNone(
|
||||
fn,
|
||||
"glob/manager_util.py exposes no sanitize_html_fragment(fragment). "
|
||||
"The notice fragment is remote HTML the server relays verbatim and "
|
||||
"the client assigns to innerHTML by design, so the server is the "
|
||||
"only boundary that can constrain it.",
|
||||
)
|
||||
return fn(fragment)
|
||||
|
||||
def test_a_script_element_does_not_survive(self):
|
||||
out = self._sanitize('<p>before</p><script>window.__probe=1</script><p>after</p>')
|
||||
self.assertNotIn("script", tags(out), out)
|
||||
self.assertIn("p", tags(out), "ordinary markup was destroyed along with it: %r" % (out,))
|
||||
|
||||
def test_a_handler_attribute_does_not_survive(self):
|
||||
out = self._sanitize('<img src="https://example.com/i.png" onerror="window.__probe=1">')
|
||||
self.assertEqual(handler_attrs(out), [], out)
|
||||
|
||||
def test_a_handler_attribute_does_not_survive_on_any_element(self):
|
||||
# The allow-list is over ATTRIBUTES, not over one element that happened
|
||||
# to be tested: a deny-list keyed to <img> leaves <div onmouseover=...>.
|
||||
for markup in (
|
||||
'<div onmouseover="window.__probe=1">hover</div>',
|
||||
'<a href="https://example.com/" onclick="window.__probe=1">click</a>',
|
||||
'<b ONERROR="window.__probe=1">bold</b>',
|
||||
):
|
||||
with self.subTest(markup=markup):
|
||||
self.assertEqual(handler_attrs(self._sanitize(markup)), [], markup)
|
||||
|
||||
def test_an_executing_scheme_does_not_survive_in_an_href(self):
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD):
|
||||
with self.subTest(payload=payload):
|
||||
out = self._sanitize('<a href="%s">click</a>' % (payload,))
|
||||
self.assertEqual(
|
||||
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [],
|
||||
"the scheme survived into an href: %r" % (out,),
|
||||
)
|
||||
|
||||
def test_ordinary_formatting_still_displays(self):
|
||||
"""The half a strip-everything sanitizer would fail.
|
||||
|
||||
The notice is formatted prose. If the fix returns text, the feature is
|
||||
gone and no other test in this file would say so.
|
||||
"""
|
||||
fragment = (
|
||||
'<p><b>bold</b> and <i>italic</i></p>'
|
||||
'<ul><li>one</li><li>two</li></ul>'
|
||||
'<a href="https://example.com/x">link</a>'
|
||||
'<img src="https://example.com/i.png">'
|
||||
)
|
||||
out = self._sanitize(fragment)
|
||||
for tag in ("p", "b", "i", "ul", "li", "a", "img"):
|
||||
with self.subTest(tag=tag):
|
||||
self.assertIn(tag, tags(out), "%r was dropped: %r" % (tag, out))
|
||||
self.assertIn("https://example.com/x", hrefs(out), out)
|
||||
self.assertIn("one", out)
|
||||
self.assertIn("two", out)
|
||||
|
||||
def test_an_unclosed_element_does_not_blank_the_rest_of_the_notice(self):
|
||||
"""Malformed elements must not discard unrelated notice content."""
|
||||
for wrapper in ("div", "blockquote", "section", "article", "figure",
|
||||
"main", "header", "nav", "aside"):
|
||||
with self.subTest(wrapper=wrapper):
|
||||
out = self._sanitize(
|
||||
"<%s><svg></%s><p>after</p>" % (wrapper, wrapper))
|
||||
self.assertIn(
|
||||
"after", out,
|
||||
"an unclosed element inside <%s> swallowed the rest of the "
|
||||
"notice: %r" % (wrapper, out))
|
||||
self.assertNotIn("svg", tags(out), out)
|
||||
|
||||
def test_an_unterminated_script_still_fails_closed(self):
|
||||
"""HTML script/style contents must not be emitted as notice markup."""
|
||||
for fragment in ("<section><script></section><p>after</p>",
|
||||
"<div><style></div><p>after</p>"):
|
||||
with self.subTest(fragment=fragment):
|
||||
out = self._sanitize(fragment)
|
||||
# NOT `out == ""`: a wrapper's OPEN tag is emitted before the
|
||||
# skip window starts, so `<div><style>...` legitimately keeps
|
||||
# its `<div>`. The property is that nothing AFTER the
|
||||
# unterminated element comes back -- as markup or as text.
|
||||
self.assertNotIn(
|
||||
"after", out,
|
||||
"text after an unterminated script/style came back: %r"
|
||||
% (out,))
|
||||
self.assertEqual(
|
||||
[t for t in tags(out) if t != "div"], [],
|
||||
"markup after an unterminated script/style came back: %r"
|
||||
% (out,))
|
||||
|
||||
def test_foreign_content_does_not_hide_safe_following_prose(self):
|
||||
# HTML5 parsing closes the SVG context before this paragraph. The old
|
||||
# HTMLParser-based sanitizer treated the rest as script text and lost it.
|
||||
out = self._sanitize('<svg><script></svg><p>after</p>')
|
||||
self.assertEqual(tags(out), ['p'])
|
||||
self.assertIn('after', out)
|
||||
|
||||
def test_notice_formatting_attributes_survive(self):
|
||||
out = self._sanitize(
|
||||
'<p class="notice" title="Release" align="center">'
|
||||
'<font color="red">Update</font></p>'
|
||||
'<table width="400"><tr><td colspan="2" rowspan="3">Data</td></tr></table>'
|
||||
'<img src="https://example.com/i.png" alt="Preview" width="100" height="80">'
|
||||
)
|
||||
elements = dict(parse(out).elements)
|
||||
self.assertEqual(elements['p'], {'class': 'notice', 'title': 'Release', 'align': 'center'})
|
||||
self.assertEqual(elements['font'], {'color': 'red'})
|
||||
self.assertEqual(elements['table']['width'], '400')
|
||||
self.assertEqual(elements['td'], {'colspan': '2', 'rowspan': '3'})
|
||||
self.assertEqual(elements['img'], {
|
||||
'src': 'https://example.com/i.png', 'alt': 'Preview', 'width': '100', 'height': '80',
|
||||
})
|
||||
|
||||
def test_notice_links_open_safely_regardless_of_input_attribute_order(self):
|
||||
for attrs in (
|
||||
'href="https://example.com/?a=1&b=2" target="named" rel="opener"',
|
||||
'title="Docs" rel="opener" target="named" href="https://example.com/?a=1&b=2"',
|
||||
):
|
||||
with self.subTest(attrs=attrs):
|
||||
out = self._sanitize(f'<a {attrs}>Docs</a>')
|
||||
anchor = dict(parse(out).elements)['a']
|
||||
self.assertEqual(anchor['href'], 'https://example.com/?a=1&b=2')
|
||||
self.assertEqual(anchor['target'], '_blank')
|
||||
self.assertEqual(set(anchor['rel'].split()), {'noopener', 'noreferrer'})
|
||||
|
||||
def test_notice_url_schemes_remain_limited_to_http_https_and_relative(self):
|
||||
for url in ('ftp://example.com', 'mailto:a@example.com', 'java	script:alert`1`'):
|
||||
with self.subTest(url=url):
|
||||
out = self._sanitize(f'<a href="{url}">Link</a><img src="{url}">')
|
||||
for _, attrs in parse(out).elements:
|
||||
self.assertNotIn('href', attrs)
|
||||
self.assertNotIn('src', attrs)
|
||||
for url in ('http://example.com', 'https://example.com', '/relative', '#section'):
|
||||
with self.subTest(url=url):
|
||||
self.assertEqual(hrefs(self._sanitize(f'<a href="{url}">Link</a>')), [url])
|
||||
|
||||
def test_hidden_element_contents_are_not_exposed_as_notice_text(self):
|
||||
for tag in ('script', 'style', 'iframe', 'object', 'svg', 'math',
|
||||
'template', 'noscript', 'textarea', 'title'):
|
||||
with self.subTest(tag=tag):
|
||||
out = self._sanitize(f'<{tag}>hidden</{tag}><p>after</p>')
|
||||
self.assertNotIn('hidden', out)
|
||||
self.assertIn('after', out)
|
||||
|
||||
def test_a_mixed_fragment_keeps_the_good_and_drops_the_bad(self):
|
||||
"""One fragment carrying both, since a real wiki page would."""
|
||||
out = self._sanitize(
|
||||
'<p>Release <b>1.2</b></p>'
|
||||
'<script>window.__probe=1</script>'
|
||||
'<a href="https://example.com/notes">notes</a>'
|
||||
'<a href="%s">click</a>'
|
||||
'<img src="https://example.com/i.png" onerror="window.__probe=1">'
|
||||
% (SCHEME_PAYLOAD,)
|
||||
)
|
||||
self.assertNotIn("script", tags(out), out)
|
||||
self.assertEqual(handler_attrs(out), [], out)
|
||||
self.assertEqual(
|
||||
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [], out)
|
||||
self.assertIn("https://example.com/notes", hrefs(out), out)
|
||||
self.assertIn("b", tags(out), out)
|
||||
self.assertIn("img", tags(out), "the benign image was dropped: %r" % (out,))
|
||||
|
||||
|
||||
class NoticeWiringTest(unittest.TestCase):
|
||||
"""Check the notice route applies the sanitizer to its captured remote HTML."""
|
||||
|
||||
def test_get_notice_passes_the_captured_fragment_through_the_sanitizer(self):
|
||||
"""Verify the captured HTML is sanitized, not merely that the function is mentioned."""
|
||||
body = _function_source("get_notice")
|
||||
self.assertIn(
|
||||
"match.group(1)", body,
|
||||
"get_notice no longer captures the fragment the way this guard "
|
||||
"expects; re-derive it against the new shape rather than deleting "
|
||||
"the assertion.",
|
||||
)
|
||||
self.assertRegex(
|
||||
body, r"sanitize_html_fragment\s*\(\s*match\.group\(1\)\s*\)",
|
||||
"get_notice relays the captured wiki fragment without passing it "
|
||||
"through an allow-list normaliser. The client assigns the response "
|
||||
"body to innerHTML, so whatever the wiki page contains is what the "
|
||||
"browser builds. The call has to take the capture as its argument; "
|
||||
"naming the function elsewhere in the body is not wiring.",
|
||||
)
|
||||
self.assertNotRegex(
|
||||
body, re.compile(r"^\s*markdown_content\s*=\s*match\.group\(1\)\s*$",
|
||||
re.MULTILINE),
|
||||
"the raw capture is still assigned directly; the sanitizer must sit "
|
||||
"on this assignment, not beside it.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D2] share-service URLs
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ShareDialogUrlTest(unittest.TestCase):
|
||||
"""Share-service URLs must use safe schemes and quoted attributes."""
|
||||
|
||||
def _preamble(self) -> str:
|
||||
span = JS.lift_span(
|
||||
COMMON, "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
return "\n".join([
|
||||
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
span.replace("export ", ""),
|
||||
])
|
||||
|
||||
def _render(self, expression: str, bindings: str) -> str:
|
||||
return run_node(
|
||||
"%s\n%s\nconsole.log(JSON.stringify({markup: %s}));\n"
|
||||
% (self._preamble(), bindings, expression)
|
||||
)["markup"]
|
||||
|
||||
def _assert_scheme_rejected(self, markup: str, label: str):
|
||||
bad = [h for h in hrefs(markup) if h.startswith(("javascript:", "data:"))]
|
||||
self.assertEqual(
|
||||
bad, [],
|
||||
"%s emits a service-chosen scheme as a clickable href: %r" % (label, markup),
|
||||
)
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
# --- comfyui-share-common.js, the fully service-controlled URL ----------
|
||||
def _common_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_COMMON),
|
||||
'this.final_message.innerHTML = "Your art has been shared:')
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_share_common_rejects_an_executing_scheme(self):
|
||||
# The href here is SINGLE-quoted, so the breakout variant uses `'`.
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_SINGLE_QUOTED):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._render(
|
||||
self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(payload),))
|
||||
self._assert_scheme_rejected(markup, "comfyui-share-common.js")
|
||||
|
||||
def test_share_common_still_links_an_ordinary_url(self):
|
||||
"""The leg a refuse-everything fix would fail."""
|
||||
markup = self._render(
|
||||
self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),))
|
||||
self.assertIn(
|
||||
BENIGN_URL, hrefs(markup),
|
||||
"an ordinary https URL must still become a working link: %r" % (markup,))
|
||||
|
||||
def test_every_share_anchor_severs_the_opener(self):
|
||||
"""Check opener isolation without imposing an order on rel tokens."""
|
||||
cases = [
|
||||
("comfyui-share-common.js", self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-youml.js", self._youml_expression(),
|
||||
"const messagePrefix = 'shared.';\nconst recipePageUrl = %s;" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-openart.js", self._openart_expression(),
|
||||
"const url = %s;" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-copus.js", self._copus_expression(),
|
||||
"const url = %s;" % (_json(BENIGN_URL),)),
|
||||
]
|
||||
for name, expression, bindings in cases:
|
||||
with self.subTest(file=name):
|
||||
markup = self._render(expression, bindings)
|
||||
anchors = [attrs for tag, attrs in parse(markup).elements if tag == "a"]
|
||||
self.assertTrue(anchors, "no anchor produced by %s: %r" % (name, markup))
|
||||
for attrs in anchors:
|
||||
self.assertEqual(
|
||||
attrs.get("target"), "_blank",
|
||||
"%s: this guard is about _blank anchors; the target "
|
||||
"changed, so re-derive it: %r" % (name, markup))
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"%s opens a service-chosen page in a new tab without "
|
||||
"rel, so that page keeps a reference to this window: %r"
|
||||
% (name, markup))
|
||||
rel = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", rel, markup)
|
||||
self.assertIn("noreferrer", rel, markup)
|
||||
|
||||
# --- comfyui-share-youml.js, also fully service-controlled --------------
|
||||
def _youml_expression(self) -> str:
|
||||
span = JS.lift_span(SHARE_YOUML, "this.message.innerHTML = `${messagePrefix}",
|
||||
"visit it on YouML</a>`;")
|
||||
return span.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_share_youml_rejects_an_executing_scheme(self):
|
||||
# The href here is DOUBLE-quoted, so the breakout variant uses `"`.
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_DOUBLE_QUOTED):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._render(
|
||||
self._youml_expression(),
|
||||
"const messagePrefix = 'Workflow has been shared.';\n"
|
||||
"const recipePageUrl = %s;" % (_json(payload),))
|
||||
self._assert_scheme_rejected(markup, "comfyui-share-youml.js")
|
||||
|
||||
# --- openart / copus: an id is interpolated into a URL PATH -------------
|
||||
# The scheme is hardcoded, so these cannot carry javascript:. What they CAN
|
||||
# carry is a quote: the id is a service-returned string dropped inside a
|
||||
# double-quoted attribute, so `"` closes it and the rest is markup. That is a
|
||||
# different failure from the two above and is asserted as such.
|
||||
def _openart_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_OPENART),
|
||||
"this.message.innerHTML = `Workflow has been shared successfully.")
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def _copus_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_COPUS),
|
||||
"this.message.innerHTML = `Workflow has been shared successfully.")
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_openart_id_cannot_break_out_of_the_href_attribute(self):
|
||||
markup = self._render(
|
||||
self._openart_expression(),
|
||||
'const url = "https://openart.ai/workflows/-/-/" + '
|
||||
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
def test_copus_id_cannot_break_out_of_the_href_attribute(self):
|
||||
markup = self._render(
|
||||
self._copus_expression(),
|
||||
'const url = "https://example.invalid/work/" + '
|
||||
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D3] the snapshot name
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class SnapshotNameTest(unittest.TestCase):
|
||||
"""Snapshot filenames must remain inert text in status messages."""
|
||||
|
||||
def _render(self, name: str) -> str:
|
||||
line = _unique_line(JS.text(SNAPSHOT), "self.updateMessage(`<BR><font color=")
|
||||
expression = _call_argument(line, "self.updateMessage(")
|
||||
return run_node(
|
||||
"%s\nconst target = {name: %s};\n"
|
||||
"console.log(JSON.stringify({markup: %s}));\n"
|
||||
% (JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
_json(name), expression)
|
||||
)["markup"]
|
||||
|
||||
def test_a_crafted_snapshot_name_renders_as_text(self):
|
||||
markup = self._render(TEXT_PAYLOAD)
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
self.assertNotIn("img", tags(markup), markup)
|
||||
|
||||
def test_an_ordinary_snapshot_name_still_reads_normally(self):
|
||||
markup = self._render("2026-09-07_backup")
|
||||
self.assertIn("2026-09-07_backup", markup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D4] rel=noopener, and the defensive escapes
|
||||
# ---------------------------------------------------------------------------
|
||||
class ServerAnchorRelTest(unittest.TestCase):
|
||||
"""Converted [a/text](url) links must isolate the opener and remain usable."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manager_util = load_manager_util()
|
||||
# staticmethod, or attribute access binds it and passes `self`
|
||||
# as the markdown argument.
|
||||
cls.convert = staticmethod(load_markdown_functions(cls.manager_util)["convert_markdown_to_html"])
|
||||
|
||||
def test_a_markdown_link_carries_rel_noopener_noreferrer(self):
|
||||
out = self.convert("see [a/the docs](https://example.com/docs)")
|
||||
anchors = [attrs for tag, attrs in parse(out).elements if tag == "a"]
|
||||
self.assertTrue(anchors, "no anchor was produced at all: %r" % (out,))
|
||||
for attrs in anchors:
|
||||
with self.subTest(attrs=attrs):
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"the anchor opens a new tab without rel: %r. The opened page "
|
||||
"keeps a reference to this window." % (out,),
|
||||
)
|
||||
rel = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", rel, out)
|
||||
self.assertIn("noreferrer", rel, out)
|
||||
|
||||
def test_the_link_still_works(self):
|
||||
out = self.convert("see [a/the docs](https://example.com/docs)")
|
||||
self.assertIn("https://example.com/docs", hrefs(out), out)
|
||||
|
||||
|
||||
class ClientAnchorAndStateTest(unittest.TestCase):
|
||||
"""External-link isolation and rendering of unrecognised pack states."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.source = JS.text(MANAGER)
|
||||
|
||||
def test_the_title_link_severs_the_opener(self):
|
||||
marker = "link.target = '_blank';"
|
||||
index = _unique_index(self.source, marker)
|
||||
# max(0, ...) because a negative start would wrap to the END of the
|
||||
# string and silently hand back an unrelated window.
|
||||
window = self.source[max(0, index - 400):index + 400]
|
||||
self.assertRegex(
|
||||
window, r"link\.rel\s*=",
|
||||
"the title link opens a new tab without setting rel, so the pack's "
|
||||
"own repository page keeps a reference to this window.",
|
||||
)
|
||||
rel = re.search(r"link\.rel\s*=\s*['\"]([^'\"]*)['\"]", window)
|
||||
self.assertIsNotNone(rel, "link.rel is set from a non-literal: %r" % (window,))
|
||||
tokens = rel.group(1).lower().split()
|
||||
# Membership, not token order: 'noreferrer noopener' is the same thing,
|
||||
# and demanding one spelling would false-RED a correct fix. Matches how
|
||||
# ServerAnchorRelTest checks the server-side anchor.
|
||||
self.assertIn("noopener", tokens, window)
|
||||
self.assertIn("noreferrer", tokens, window)
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required")
|
||||
def test_selection_survives_unrecognised_and_prototype_names(self):
|
||||
sanitize = JS.lift_declaration(COMMON, "export function sanitizeHTML(")
|
||||
buttons = JS.lift_declaration(MANAGER, "\n\tgetActionButtons(")
|
||||
render = JS.lift_declaration(MANAGER, "\n\trenderSelected()")
|
||||
for state in ['not-a-known-install-group', 'constructor', '__proto__', TEXT_PAYLOAD, 'enabled']:
|
||||
with self.subTest(state=state):
|
||||
result = run_node(sanitize + "\nconst manager = {" + buttons + "," + render + """,
|
||||
grid: {hasMask: false, getSelectedRows: () => [{action: STATE, hash: 'fixture'}]},
|
||||
getFilterItem: () => null,
|
||||
showSelection(html) { this.html = html; }
|
||||
};
|
||||
manager.renderSelected();
|
||||
console.log(JSON.stringify({html: manager.html,
|
||||
buttons: manager.getActionButtons(STATE, {version: 'unknown'}, true)}));
|
||||
""".replace('STATE', _json(state)))
|
||||
self.assertIn('Selected <b>1</b>', result['html'])
|
||||
self.assertIn(state, unescape(result['html']))
|
||||
self.assertFalse(handler_attrs(result['html']))
|
||||
self.assertNotIn('img', tags(result['html']))
|
||||
if state == 'enabled':
|
||||
self.assertIn('<button', result['buttons'])
|
||||
else:
|
||||
self.assertEqual(result['buttons'], '')
|
||||
|
||||
|
||||
def _json(value) -> str:
|
||||
import json
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def _unique_index(source: str, marker: str) -> int:
|
||||
"""Index of `marker`, refusing to guess when it is not unique.
|
||||
|
||||
js_lift.line_containing takes the FIRST occurrence silently. Every marker
|
||||
here is unique today; this makes a future second occurrence fail loudly
|
||||
instead of pinning whichever copy happens to come first.
|
||||
"""
|
||||
count = source.count(marker)
|
||||
assert count == 1, "marker %r occurs %d times; disambiguate it" % (marker, count)
|
||||
return source.index(marker)
|
||||
|
||||
|
||||
def _unique_line(source: str, marker: str) -> str:
|
||||
"""The single source line holding `marker`, with the same uniqueness rule."""
|
||||
index = _unique_index(source, marker)
|
||||
start = source.rfind("\n", 0, index) + 1
|
||||
return source[start:source.index("\n", index)]
|
||||
|
||||
|
||||
def _call_argument(line: str, call_marker: str) -> str:
|
||||
"""The argument text of `call_marker(...)`, dropping exactly one `)` and `;`.
|
||||
|
||||
NOT `.rstrip(");")`: rstrip takes a CHARACTER SET, so it eats every trailing
|
||||
`)`, `;` and `"`. A fix spelled `updateMessage(escape(`...`));` would lose a
|
||||
closing paren it needs and become a node syntax error -- which pytest would
|
||||
show as this guard failing, i.e. the fix would look like the defect.
|
||||
"""
|
||||
text = line.split(call_marker, 1)[1].rstrip()
|
||||
if text.endswith(";"):
|
||||
text = text[:-1].rstrip()
|
||||
assert text.endswith(")"), "unexpected call shape: %r" % (line.strip(),)
|
||||
return text[:-1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Render actual update-completion handlers in an isolated browser."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from js_lift import JsSource
|
||||
|
||||
JS = JsSource(Path(__file__).resolve().parent.parent / "js")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["success", "failed"])
|
||||
def test_update_results_preserve_links_titles_and_missing_url_labels(status):
|
||||
playwright = pytest.importorskip("playwright.sync_api")
|
||||
helpers = JS.lift_declaration("common.js", "export function sanitizeHTML(")
|
||||
helpers += JS.lift_span(
|
||||
"common.js", "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));",
|
||||
).replace("export ", "")
|
||||
helpers += JS.lift_declaration("common.js", "export function show_message(")
|
||||
handler = JS.lift_declaration("comfyui-manager.js", "async function onQueueStatus(")
|
||||
url = "https://example.test/guide?q=O'Reilly&lang=en"
|
||||
title = "Guide <draft> & notes"
|
||||
fallback_id = "Pack <draft>"
|
||||
|
||||
with playwright.sync_playwright() as driver:
|
||||
if not Path(driver.chromium.executable_path).exists():
|
||||
pytest.skip("install Chromium with: playwright install chromium")
|
||||
browser = driver.chromium.launch()
|
||||
page = browser.new_page()
|
||||
page.route("**/*", lambda route: route.abort())
|
||||
page.set_content('<div id="dialog"></div>')
|
||||
page.add_script_tag(content="""
|
||||
let is_updating = true;
|
||||
function reset_action_buttons() {}
|
||||
function setNeedRestart() {}
|
||||
function infoToast() {}
|
||||
const app = {ui: {dialog: {
|
||||
element: document.getElementById('dialog'),
|
||||
show(message) { this.element.innerHTML = message; }
|
||||
}}};
|
||||
""" + helpers + handler)
|
||||
page.evaluate("event => onQueueStatus(event)", {"detail": {
|
||||
"status": "done", "nodepack_result": {
|
||||
"comfyui": "skip",
|
||||
"pack-link": {"url": url, "title": title, "msg": status},
|
||||
fallback_id: {"msg": status},
|
||||
},
|
||||
}})
|
||||
items = page.locator("#dialog li")
|
||||
assert items.all_text_contents() == [title, fallback_id]
|
||||
assert items.nth(0).locator("a").get_attribute("href") == url
|
||||
assert set(items.nth(0).locator("a").evaluate("node => node.getAttributeNames()")) == {
|
||||
"href", "target", "rel",
|
||||
}
|
||||
assert items.nth(0).locator("a").get_attribute("rel") == "noopener noreferrer"
|
||||
assert items.nth(1).locator("a").count() == 0
|
||||
assert page.locator("#dialog draft").count() == 0
|
||||
browser.close()
|
||||
@@ -1,410 +0,0 @@
|
||||
"""Drift guard: the JS sanitizeUrl must agree with glob/manager_util.py::sanitize_url.
|
||||
|
||||
There are two implementations of one URL allow-list — ``sanitizeUrl`` in
|
||||
js/common.js and ``sanitize_url`` in glob/manager_util.py. They agree today.
|
||||
Nothing structural keeps them agreeing: a change to either side has no failing
|
||||
test on the other, and the JS side is what stands between channel data and an
|
||||
href. This module is that missing coupling. It runs BOTH real implementations
|
||||
over a shared case list and fails on any divergence, naming the input and both
|
||||
answers.
|
||||
|
||||
WHAT IT COMPARES: the real shipped code on both sides. The Python function is
|
||||
loaded by file location (``glob/`` has no ``__init__.py``, and putting it on
|
||||
sys.path would shadow the stdlib ``glob``); the JS function is lifted out of
|
||||
js/common.js by tests/js_lift and executed in node. Neither is a copy, so a
|
||||
perturbation of either implementation turns this guard RED.
|
||||
|
||||
CASES: the shared matrix at ``cases/url_sanitize_cases.json`` (57 inputs handed
|
||||
over by gm3-review from the WI-112 and WI-122 reviews, since extended) PLUS a
|
||||
generated grid that composes scheme x in-scheme-noise x delimiter. The generated
|
||||
half matters because a fixed list can only catch divergence it happens to
|
||||
contain, and the implementations are what is under test, not the list.
|
||||
|
||||
TWO AXES ARE COMPARED, not one. Behaviour over sampled inputs cannot see a
|
||||
difference no sample happens to hold, so the two pieces of CONFIGURATION the
|
||||
implementations branch on are compared directly as well: the scheme allow-list
|
||||
(``test_the_scheme_allow_lists_are_identical``) and the whitespace set each side
|
||||
trims off an accepted URL (``test_the_trim_sets_are_identical``). The trim axis
|
||||
was added after the matrix was found to contain no BOM or unicode-whitespace
|
||||
input at all — ``String.prototype.trim()`` and ``str.strip()`` disagree on six
|
||||
codepoints (U+FEFF one way; U+001C-001F and U+0085 the other), and every case in
|
||||
the matrix had ASCII edges, so the divergence sat unguarded. js/common.js now
|
||||
spells Python's set out rather than delegating to ``trim()``; see the
|
||||
``unicode_whitespace_divergence`` block in the case file.
|
||||
|
||||
⛔ STRING INPUTS ONLY, deliberately. Python ``str()`` and JS ``String()`` are
|
||||
not equivalent for non-strings — 1.0 -> '1.0' vs '1', True -> 'True' vs 'true',
|
||||
[1,2] -> '[1, 2]' vs '1,2', {} -> '{}' vs '[object Object]' — so feeding
|
||||
non-strings would report divergence for something that is not a defect. In
|
||||
practice ``url`` arrives as a string off the JSON payload. The two aligned
|
||||
non-string cases that DO matter are pinned separately in
|
||||
``test_null_and_undefined_agree`` rather than left as an undocumented gap; the
|
||||
right response to a coercion mismatch is to narrow the domain, never to loosen
|
||||
the comparison, because a loosened comparison stops catching real drift.
|
||||
"""
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import NODE, JsSource, run_node
|
||||
from manager_test_utils import load_manager_util
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
CASES_PATH = Path(__file__).resolve().parent / "cases" / "url_sanitize_cases.json"
|
||||
|
||||
# Every codepoint ``str.strip()`` removes, measured by RUNNING it rather than by
|
||||
# restating a table. sanitize_url returns ``raw.strip()``, so this is the set the
|
||||
# JS side has to match; test_the_trim_sets_are_identical asserts that linkage
|
||||
# still holds before comparing.
|
||||
PYTHON_TRIM_SET = frozenset(c for c in range(0x110000) if chr(c).strip() == "")
|
||||
|
||||
|
||||
def _generated_cases(allow_listed):
|
||||
"""Compose scheme x in-scheme noise x delimiter into extra inputs.
|
||||
|
||||
A fixed list only catches the divergence it already contains. This grid is
|
||||
derived from the axes the two implementations actually branch on — the
|
||||
scheme allow-list, the control-char strip, and the head-delimiter cut — so
|
||||
it explores combinations nobody wrote down.
|
||||
|
||||
``allow_listed`` is the UNION of the two live allow-lists, not a literal, so
|
||||
a scheme added to either side is exercised behaviourally without anyone
|
||||
remembering to add a case for it. That is a second layer under
|
||||
test_the_scheme_allow_lists_are_identical, which is what catches an
|
||||
allow-list divergence definitively; this layer only makes the behavioural
|
||||
sampling track the configuration instead of drifting away from it.
|
||||
"""
|
||||
schemes = sorted(set(allow_listed) | {
|
||||
"HTTP", "javascript", "data", "vbscript", "file", "a-b", "a.b",
|
||||
})
|
||||
noises = ["", "\t", "\n", "\x00", "\x01", " ", "\x7f"]
|
||||
delimiters = ["", "/", "?", "#", "//host/p", "?q=1", "#frag"]
|
||||
for scheme in schemes:
|
||||
for noise in noises:
|
||||
head = scheme[:2] + noise + scheme[2:]
|
||||
for delimiter in delimiters:
|
||||
yield "%s:%s" % (head, delimiter)
|
||||
yield " %s:%s" % (head, delimiter)
|
||||
|
||||
|
||||
def _all_cases(allow_listed):
|
||||
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
|
||||
cases = list(matrix["shared_matrix"])
|
||||
cases.extend(_generated_cases(allow_listed))
|
||||
# De-duplicate while preserving order so a failure names a stable index.
|
||||
seen = set()
|
||||
ordered = []
|
||||
for case in cases:
|
||||
if case not in seen:
|
||||
seen.add(case)
|
||||
ordered.append(case)
|
||||
return ordered
|
||||
|
||||
|
||||
def _lift_sanitize_url() -> str:
|
||||
"""The REAL sanitizeUrl and the two constants it closes over, from common.js.
|
||||
|
||||
Lifted as one contiguous span rather than three declarations: SAFE_URL_SCHEMES
|
||||
is an array literal, so a brace-matching lift would run past it into the
|
||||
function body and declare the constants twice.
|
||||
"""
|
||||
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
# safeHref needs sanitizeHTML, which this guard does not exercise; drop the
|
||||
# trailing line rather than pulling an unrelated dependency into the script.
|
||||
span = span[:span.index("export const safeHref")]
|
||||
return span.replace("export ", "")
|
||||
|
||||
|
||||
_JS_ALLOW_LIST_LITERAL = re.compile(r"(SAFE_URL_SCHEMES\s*=\s*)\[[^\]]*\]")
|
||||
|
||||
|
||||
def _js_allow_list(lifted_js=None):
|
||||
"""The JS SAFE_URL_SCHEMES as the running code actually sees it.
|
||||
|
||||
Evaluated in node rather than parsed out of the source, so a change to how
|
||||
the list is spelled cannot make this silently read the old value.
|
||||
"""
|
||||
return run_node(
|
||||
"%s\nconsole.log(JSON.stringify({v: SAFE_URL_SCHEMES}));\n"
|
||||
% (lifted_js if lifted_js is not None else _lift_sanitize_url())
|
||||
)["v"]
|
||||
|
||||
|
||||
def _js_trim_set(lifted_js=None):
|
||||
"""Every codepoint the JS side strips off the edges of an accepted URL.
|
||||
|
||||
Evaluated in node, for the same reason ``_js_allow_list`` is: a set parsed
|
||||
out of the source text stops describing the running code the moment the
|
||||
spelling changes.
|
||||
|
||||
Shimmed on a PRE-FIX tree, where sanitizeUrl called ``raw.trim()`` inline and
|
||||
no ``trimUrl`` exists. Without the shim this guard would raise on the old
|
||||
revision instead of reporting the divergence, and the RED half of the fix
|
||||
could not be demonstrated with the guard that is supposed to catch it.
|
||||
"""
|
||||
lifted = _lift_sanitize_url() if lifted_js is None else lifted_js
|
||||
shim = "" if "trimUrl" in lifted else "const trimUrl = (value) => value.trim();\n"
|
||||
return set(run_node(
|
||||
"%s\n%sconst out = [];\n"
|
||||
"for (let c = 0; c < 0x110000; c += 1) {\n"
|
||||
" if (trimUrl(String.fromCodePoint(c)) === '') out.push(c);\n"
|
||||
"}\n"
|
||||
"console.log(JSON.stringify({v: out}));\n" % (lifted, shim)
|
||||
)["v"])
|
||||
|
||||
|
||||
def _perturb_js_allow_list(lifted_js, extra_scheme):
|
||||
"""Add a scheme to the lifted JS allow-list, whatever it currently holds.
|
||||
|
||||
Rewriting the array by PATTERN rather than by a hard-coded literal matters:
|
||||
a literal-match perturbation stops applying the moment someone edits the
|
||||
allow-list, and then the harness's own did-it-apply self-test fires instead
|
||||
of the parity comparison — a red that looks like coverage and is not.
|
||||
"""
|
||||
perturbed, count = _JS_ALLOW_LIST_LITERAL.subn(
|
||||
lambda m: "%s%s" % (m.group(1), json.dumps(sorted(
|
||||
set(_js_allow_list(lifted_js)) | {extra_scheme}))),
|
||||
lifted_js, count=1,
|
||||
)
|
||||
assert count == 1, "could not locate the JS SAFE_URL_SCHEMES array to perturb"
|
||||
return perturbed
|
||||
|
||||
|
||||
def _js_results(cases):
|
||||
"""Run the REAL js/common.js sanitizeUrl over every case, in node."""
|
||||
lifted = _lift_sanitize_url()
|
||||
return run_node(
|
||||
"%s\nconst cases = %s;\n"
|
||||
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
|
||||
% (lifted, json.dumps(cases))
|
||||
)["results"]
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class UrlSanitizeParityTest(unittest.TestCase):
|
||||
"""Both real implementations, one case list, zero tolerated divergence."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manager_util = load_manager_util()
|
||||
cls.allow_lists = {
|
||||
"python": set(cls.manager_util.SAFE_URL_SCHEMES),
|
||||
"js": set(_js_allow_list()),
|
||||
}
|
||||
cls.cases = _all_cases(cls.allow_lists["python"] | cls.allow_lists["js"])
|
||||
cls.js = _js_results(cls.cases)
|
||||
|
||||
def test_case_matrix_is_actually_loaded(self):
|
||||
# A guard that silently ran zero cases would pass forever.
|
||||
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
|
||||
self.assertGreaterEqual(len(matrix["shared_matrix"]), 57)
|
||||
self.assertGreater(len(self.cases), 400)
|
||||
self.assertEqual(len(self.js), len(self.cases))
|
||||
|
||||
def test_the_scheme_allow_lists_are_identical(self):
|
||||
"""Compare the CONFIGURATION, not only the behaviour it drives.
|
||||
|
||||
Comparing behaviour over sampled inputs cannot see a scheme added to one
|
||||
allow-list unless that scheme happens to be in the sample — adding 'ftp'
|
||||
or 'mailto' or 'ws' to either side changes what is accepted and produces
|
||||
no divergence at all. That is the most plausible future drift for this
|
||||
pair (someone adds a scheme for a real reason and forgets the other
|
||||
side), so it is closed definitively here rather than by sampling.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.allow_lists["js"], self.allow_lists["python"],
|
||||
"the URL scheme allow-lists have diverged — js/common.js accepts %r "
|
||||
"and glob/manager_util.py accepts %r. A scheme added to one side and "
|
||||
"not the other silently changes what reaches an href. Fix the lists, "
|
||||
"do NOT relax this comparison."
|
||||
% (sorted(self.allow_lists["js"]), sorted(self.allow_lists["python"])),
|
||||
)
|
||||
|
||||
def test_the_trim_sets_are_identical(self):
|
||||
"""Compare the trim CONFIGURATION, the way the allow-lists are compared.
|
||||
|
||||
Both implementations end an accepted URL with a trim, and the two trims
|
||||
are NOT the same function: ``String.prototype.trim()`` strips U+FEFF,
|
||||
which ``str.strip()`` keeps, and leaves U+001C-001F and U+0085, which
|
||||
``str.strip()`` strips. Sampling cannot be relied on to find that — the
|
||||
handed-over matrix had ASCII edges on all 62 inputs and reported no
|
||||
divergence for years of cases. The cases added alongside this test do
|
||||
catch it, but the set comparison is what closes the axis DEFINITIVELY,
|
||||
exactly as test_the_scheme_allow_lists_are_identical does for schemes.
|
||||
|
||||
The fix direction is fixed, not free: glob/manager_util.py is the
|
||||
declared source of truth for this pair, so the JS side matches Python.
|
||||
"""
|
||||
source = inspect.getsource(self.manager_util.sanitize_url)
|
||||
self.assertIn(
|
||||
"raw.strip()", source,
|
||||
"sanitize_url no longer returns raw.strip(), so PYTHON_TRIM_SET is "
|
||||
"measuring the wrong function — re-derive it from whatever the "
|
||||
"Python side trims with now.",
|
||||
)
|
||||
js = _js_trim_set()
|
||||
self.assertEqual(
|
||||
js, set(PYTHON_TRIM_SET),
|
||||
"the URL trim sets have diverged. js/common.js strips %s that "
|
||||
"glob/manager_util.py keeps, and keeps %s that it strips. Both sides "
|
||||
"must trim the SAME set or the two implementations answer differently "
|
||||
"for the same accepted URL; fix js/common.js's URL_TRIM, do NOT relax "
|
||||
"this comparison."
|
||||
% (sorted("U+%04X" % c for c in js - PYTHON_TRIM_SET) or "nothing",
|
||||
sorted("U+%04X" % c for c in PYTHON_TRIM_SET - js) or "nothing"),
|
||||
)
|
||||
|
||||
def test_js_and_python_agree_on_every_case(self):
|
||||
divergences = []
|
||||
for case, js_answer in zip(self.cases, self.js):
|
||||
py_answer = self.manager_util.sanitize_url(case)
|
||||
if py_answer != js_answer:
|
||||
divergences.append(
|
||||
" input=%r\n python=%r\n js =%r" % (case, py_answer, js_answer)
|
||||
)
|
||||
self.assertEqual(
|
||||
divergences, [],
|
||||
"js/common.js sanitizeUrl has drifted from "
|
||||
"glob/manager_util.py::sanitize_url over %d case(s):\n%s\n"
|
||||
"Fix the implementations so they agree again — do NOT loosen this "
|
||||
"comparison, which is the only thing coupling them."
|
||||
% (len(divergences), "\n".join(divergences)),
|
||||
)
|
||||
|
||||
def test_null_and_undefined_agree(self):
|
||||
"""The two non-string inputs both sides handle explicitly.
|
||||
|
||||
Everything else non-string is out of domain — see the module docstring.
|
||||
"""
|
||||
out = run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"nul: sanitizeUrl(null), undef: sanitizeUrl(undefined)}));\n"
|
||||
% _lift_sanitize_url()
|
||||
)
|
||||
self.assertEqual(out["nul"], self.manager_util.sanitize_url(None))
|
||||
self.assertEqual(out["undef"], "#")
|
||||
|
||||
def _divergences_against(self, lifted_js):
|
||||
"""Run the SAME comparison test_js_and_python_agree_on_every_case runs."""
|
||||
js = run_node(
|
||||
"%s\nconst cases = %s;\n"
|
||||
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
|
||||
% (lifted_js, json.dumps(self.cases))
|
||||
)["results"]
|
||||
return [
|
||||
case for case, answer in zip(self.cases, js)
|
||||
if self.manager_util.sanitize_url(case) != answer
|
||||
]
|
||||
|
||||
def test_the_guard_goes_red_on_each_perturbation_of_the_js_side(self):
|
||||
"""Prove the comparison has teeth rather than asserting that it does.
|
||||
|
||||
Each perturbation is a shape a real regression takes — a scheme added to
|
||||
the allow-list, the control-char strip losing its /g flag so only the
|
||||
FIRST noise byte is removed, the scheme-less branch changing its answer,
|
||||
and the empty-input fallback changing. Every one must be caught by the
|
||||
case list as it stands, so a guard whose lift silently returned a stub,
|
||||
or whose matrix had rotted into irrelevance, cannot look healthy.
|
||||
"""
|
||||
base = _lift_sanitize_url()
|
||||
perturbations = {
|
||||
# By PATTERN, not by literal: a literal-match perturbation stops
|
||||
# applying the moment someone edits the allow-list, and the
|
||||
# did-it-apply assertion below then fires INSTEAD of the parity
|
||||
# comparison — a red that reads like coverage and is not.
|
||||
"allow-list gains a scheme": _perturb_js_allow_list(base, "javascript"),
|
||||
"noise strip loses its /g flag":
|
||||
base.replace("/[\\x00-\\x20\\x7f]/g", "/[\\x00-\\x20\\x7f]/", 1),
|
||||
"scheme-less branch stops trimming":
|
||||
base.replace("return trimUrl(raw);\n}", "return raw;\n}", 1),
|
||||
# The regression this fix closed: delegating back to String.trim(),
|
||||
# which strips U+FEFF that str.strip() keeps and keeps U+001C-001F
|
||||
# and U+0085 that str.strip() strips. Caught only by the unicode
|
||||
# cases added with the fix — before them the matrix had ASCII edges
|
||||
# throughout and this perturbation produced zero divergence.
|
||||
"trim delegates back to String.trim()":
|
||||
base.replace("trimUrl(raw)", "raw.trim()"),
|
||||
"empty-input fallback changes": base.replace("return '#';", "return '';", 1),
|
||||
}
|
||||
for label, lifted in perturbations.items():
|
||||
with self.subTest(perturbation=label):
|
||||
self.assertNotEqual(
|
||||
lifted, base,
|
||||
"perturbation %r did not apply — this is the HARNESS "
|
||||
"self-test, NOT the parity comparison. Do not read it as "
|
||||
"coverage: fix the perturbation so the comparison is "
|
||||
"actually exercised." % (label,),
|
||||
)
|
||||
caught = self._divergences_against(lifted)
|
||||
self.assertNotEqual(
|
||||
caught, [],
|
||||
"perturbing the JS side (%s) produced NO divergence — the case "
|
||||
"list cannot detect this drift, so the guard is weaker than it "
|
||||
"looks. Add a case that distinguishes it." % (label,),
|
||||
)
|
||||
|
||||
def test_the_guard_goes_red_when_either_allow_list_gains_a_scheme(self):
|
||||
"""The perturbation that was NOT caught before this test existed.
|
||||
|
||||
Both directions are exercised, because the drift can start on either
|
||||
side and the guard is only worth having if it is symmetric. 'ftp' is used
|
||||
deliberately: it is absent from the shared matrix, so nothing but the
|
||||
allow-list comparison itself can catch it.
|
||||
"""
|
||||
with self.subTest(side="python (the declared SoT)"):
|
||||
real = self.manager_util.SAFE_URL_SCHEMES
|
||||
try:
|
||||
setattr(self.manager_util, "SAFE_URL_SCHEMES", frozenset(set(real) | {"ftp"}))
|
||||
self.assertNotEqual(
|
||||
set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["js"],
|
||||
"adding a scheme to the PYTHON allow-list produced no "
|
||||
"divergence — the configuration axis is unguarded again",
|
||||
)
|
||||
finally:
|
||||
setattr(self.manager_util, "SAFE_URL_SCHEMES", real)
|
||||
self.assertEqual(set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["python"])
|
||||
|
||||
with self.subTest(side="js"):
|
||||
perturbed = _js_allow_list(_perturb_js_allow_list(_lift_sanitize_url(), "ftp"))
|
||||
self.assertIn("ftp", perturbed, "the JS perturbation did not apply")
|
||||
self.assertNotEqual(
|
||||
set(perturbed), self.allow_lists["python"],
|
||||
"adding a scheme to the JS allow-list produced no divergence",
|
||||
)
|
||||
|
||||
def test_the_guard_goes_red_on_a_perturbed_python_side(self):
|
||||
"""The coupling has to run both ways, not just JS-drifts-from-Python."""
|
||||
real = self.manager_util.sanitize_url
|
||||
try:
|
||||
setattr(self.manager_util, "sanitize_url", lambda url: "#")
|
||||
caught = self._divergences_against(_lift_sanitize_url())
|
||||
finally:
|
||||
setattr(self.manager_util, "sanitize_url", real)
|
||||
self.assertNotEqual(
|
||||
caught, [],
|
||||
"perturbing the PYTHON side produced no divergence — the comparison "
|
||||
"is not reading the Python implementation it claims to",
|
||||
)
|
||||
|
||||
def test_sanitize_url_has_exactly_one_client_side_home(self):
|
||||
"""The consolidation this guard exists to protect."""
|
||||
js_dir = JS.js_dir
|
||||
homes = sorted(
|
||||
path.name for path in js_dir.glob("*.js")
|
||||
if "function sanitizeUrl" in path.read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
homes, ["common.js"],
|
||||
"sanitizeUrl must live only in common.js; every other client file "
|
||||
"imports it. A second copy is the drift this guard cannot see, "
|
||||
"because it only compares the copy in common.js.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,470 +0,0 @@
|
||||
"""GOAL #25 RED — write_config() clobbers hand-maintained config.ini content.
|
||||
|
||||
Reproduces the three clobber classes the frozen MM
|
||||
(`scratch/gm3-team/g25-mm.md`) records, as born-RED pytest nodes, plus two
|
||||
born-GREEN regression guards. Node ids are the RED contract: the GREEN step
|
||||
must drive these SAME ids to PASS.
|
||||
|
||||
| node | MM row | AC | born |
|
||||
|----------------------------------------|--------|------|-------|
|
||||
| test_t1_hand_edited_flag_survives | T1 | [D1] | RED |
|
||||
| test_t3_unwritten_key_survives | T3 | [D3] | RED |
|
||||
| test_t4_non_default_section_survives | T4 | [D3] | RED |
|
||||
| test_t2_each_caller_key_persists | T2 | [D2] | GREEN |
|
||||
| test_t5_bootstrap_writes_defaults | T5 | S4 | GREEN |
|
||||
|
||||
GOAL #53 adds one more node to the same harness:
|
||||
|
||||
| node | AC | born |
|
||||
|----------------------------------------|------|-------|
|
||||
| test_t7_silent_read_failure_is_loud | [D1] | RED |
|
||||
|
||||
T7 — `config.read()` SILENTLY suppresses OSError. A config.ini that exists
|
||||
but cannot be read leaves the parser empty, so the merge falls into the
|
||||
BOOTSTRAP branch and rewrites the file from defaults, destroying every
|
||||
foreign key and non-`[default]` section. The failure must be loud, and
|
||||
the file must be left untouched.
|
||||
|
||||
Why they fail today (MM State Model, measured at 30bd355e):
|
||||
T1 — `write_config()` sources 17 of its 18 keys from `get_config()`, the
|
||||
process-lifetime startup snapshot (`manager_core.py:1685-1704`), so a
|
||||
hand edit made after startup is overwritten with the stale value.
|
||||
T3 — `read_config()` returns 20 keys, `write_config()` persists 18;
|
||||
`http_channel_enabled` and `default_cache_as_channel_url` are read but
|
||||
never written, so every write deletes them.
|
||||
T4 — `write_config()` builds a FRESH `ConfigParser`, assigns only
|
||||
`config['default']`, and opens the file `'w'` (`:1715`), erasing every
|
||||
other section.
|
||||
|
||||
Harness: the subprocess-isolated child from `tests/test_install_flags_config.py`
|
||||
— stub `folder_paths`, `glob/` on the CHILD's sys.path only, `manager_core`
|
||||
imported (NEVER `manager_server`: module level starts a network thread at
|
||||
`:2093`), `manager_config_path` pointed at a tmp file, `cached_config` reset.
|
||||
|
||||
`force_security_level_if_needed` (MM HAZARD C-1) is NOT stubbed — it runs on
|
||||
the real `read_config` path. The `folder_paths` stub supplies a system-user
|
||||
directory, so `has_system_user_api()` is True and the function takes its
|
||||
no-force branch and returns without mutating. The alias-mutation branch is
|
||||
therefore present but not exercised here; the GREEN step must keep it in view
|
||||
when it pins the dirty-tracking wrap point.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The real mutation shape every settings caller uses (MM Action Map): a
|
||||
#: subscript assignment on the cached dict, then `write_config()`. Verified
|
||||
#: exhaustive by AST at 30bd355e — 6 sites, no other shape.
|
||||
_CALLER_KEYS = [
|
||||
("db_mode", "local"), # manager_server.py:1779 -> :244
|
||||
("component_policy", "higher"), # manager_server.py:1793 -> :238
|
||||
("update_policy", "nightly-comfyui"), # manager_server.py:1806 -> :241
|
||||
("channel_url", "https://example.invalid/ch"), # manager_server.py:1833 -> :1832
|
||||
("share_option", "none"), # share_3rdparty.py:60 -> :59
|
||||
]
|
||||
|
||||
_CHILD_PREAMBLE = textwrap.dedent(
|
||||
"""
|
||||
import sys, types, tempfile, os, json, configparser
|
||||
tmp = tempfile.mkdtemp(prefix="cm_wcfg_")
|
||||
stub = types.ModuleType("folder_paths")
|
||||
stub.get_user_directory = lambda: tmp
|
||||
stub.get_system_user_directory = lambda *a, **k: os.path.join(tmp, "sysuser")
|
||||
sys.modules["folder_paths"] = stub
|
||||
sys.path.insert(0, {glob_path!r})
|
||||
import manager_core
|
||||
CONFIG_PATH = os.path.join(tmp, "config.ini")
|
||||
manager_core.manager_config_path = CONFIG_PATH
|
||||
manager_core.cached_config = None
|
||||
|
||||
def write_ini(text):
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
f.write(text)
|
||||
|
||||
def read_raw():
|
||||
with open(CONFIG_PATH) as f:
|
||||
return f.read()
|
||||
|
||||
def parse_disk():
|
||||
cp = configparser.ConfigParser(strict=False)
|
||||
cp.read(CONFIG_PATH)
|
||||
return cp
|
||||
|
||||
def boot():
|
||||
\"\"\"Simulate process start: read config.ini once into the cache.\"\"\"
|
||||
manager_core.cached_config = None
|
||||
return manager_core.get_config()
|
||||
|
||||
def settings_change(key, value):
|
||||
\"\"\"The real caller shape: mutate the cached dict, then write.\"\"\"
|
||||
manager_core.get_config()[key] = value
|
||||
manager_core.write_config()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _run_child(body):
|
||||
"""Run a scenario body in the isolated child; return its JSON payload."""
|
||||
script = _CHILD_PREAMBLE.format(glob_path=str(REPO_ROOT / "glob")) + textwrap.dedent(body)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise AssertionError(
|
||||
"write-config-harness child failed (rc=%d). stderr tail:\n%s"
|
||||
% (proc.returncode, "\n".join(proc.stderr.strip().splitlines()[-8:]))
|
||||
)
|
||||
lines = proc.stdout.strip().splitlines()
|
||||
if not lines:
|
||||
raise AssertionError(
|
||||
"write-config-harness child exited 0 but produced no stdout. stderr tail:\n%s"
|
||||
% "\n".join(proc.stderr.strip().splitlines()[-8:])
|
||||
)
|
||||
try:
|
||||
return json.loads(lines[-1])
|
||||
except json.JSONDecodeError as e:
|
||||
raise AssertionError(
|
||||
"write-config-harness child emitted a non-JSON last line: %r\nfull stdout:\n%s"
|
||||
% (lines[-1], proc.stdout)
|
||||
) from e
|
||||
|
||||
|
||||
class WriteConfigPersistenceTest(unittest.TestCase):
|
||||
"""MM Test Scenario Map T1-T5 (`scratch/gm3-team/g25-mm.md`)."""
|
||||
|
||||
def test_t1_hand_edited_flag_survives(self):
|
||||
"""T1 / [D1] — a hand edit made while running survives a settings change.
|
||||
|
||||
BORN-RED. The user follows GOAL #8's denial copy, edits config.ini,
|
||||
then touches any Manager setting; `write_config()` re-writes the flag
|
||||
from the startup snapshot and the edit is gone.
|
||||
"""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini("[default]\\nsecurity_level = normal\\nallow_git_url_install = false\\n")
|
||||
boot() # startup snapshot: flag False
|
||||
# user hand-edits config.ini while the server is UP
|
||||
write_ini("[default]\\nsecurity_level = normal\\nallow_git_url_install = true\\n")
|
||||
settings_change("db_mode", "local") # ordinary Manager settings change
|
||||
cp = parse_disk()
|
||||
print(json.dumps({
|
||||
"flag_on_disk": cp["default"].get("allow_git_url_install", "<ABSENT>"),
|
||||
"raw": read_raw(),
|
||||
}))
|
||||
"""
|
||||
)
|
||||
self.assertEqual(
|
||||
"true", str(payload["flag_on_disk"]).lower(),
|
||||
"T1 [D1]: the hand-edited allow_git_url_install=true was reverted to "
|
||||
"%r by an unrelated settings change. write_config() sources 17 of 18 "
|
||||
"keys from the startup snapshot (manager_core.py:1685-1704), so the "
|
||||
"user is denied again after restarting despite following the denial "
|
||||
"message exactly. On-disk file:\n%s"
|
||||
% (payload["flag_on_disk"], payload["raw"]),
|
||||
)
|
||||
|
||||
def test_t3_unwritten_key_survives(self):
|
||||
"""T3 / [D3] — a key read_config reads but write_config never writes.
|
||||
|
||||
BORN-RED. `http_channel_enabled` gates the security warning at
|
||||
`manager_core.py:1793`; every write deletes it from the file.
|
||||
"""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini(
|
||||
"[default]\\nsecurity_level = normal\\n"
|
||||
"http_channel_enabled = true\\ndefault_cache_as_channel_url = true\\n"
|
||||
)
|
||||
boot()
|
||||
settings_change("db_mode", "local")
|
||||
cp = parse_disk()
|
||||
print(json.dumps({
|
||||
"http_channel_enabled": cp["default"].get("http_channel_enabled", "<ABSENT>"),
|
||||
"default_cache_as_channel_url": cp["default"].get(
|
||||
"default_cache_as_channel_url", "<ABSENT>"),
|
||||
"raw": read_raw(),
|
||||
}))
|
||||
"""
|
||||
)
|
||||
# NOT subTest: pytest-subtests reports a SUBFAIL while leaving the
|
||||
# NODE status PASSED, which would break the node-id RED contract this
|
||||
# WI pins — the GREEN step keys on these ids. An aggregate assertion
|
||||
# keeps the per-key detail in the message AND fails the node.
|
||||
lost = [
|
||||
key for key in ("http_channel_enabled", "default_cache_as_channel_url")
|
||||
if payload[key] == "<ABSENT>"
|
||||
]
|
||||
self.assertEqual(
|
||||
[], lost,
|
||||
"T3 [D3]: %r were present in config.ini and are GONE after an "
|
||||
"unrelated settings change. read_config() returns 20 keys, "
|
||||
"write_config() persists 18 — these keys are read but never "
|
||||
"written, so every write deletes them. On-disk file:\n%s"
|
||||
% (lost, payload["raw"]),
|
||||
)
|
||||
|
||||
def test_t4_non_default_section_survives(self):
|
||||
"""T4 / [D3] — a non-`default` section survives a settings change.
|
||||
|
||||
BORN-RED. `write_config()` builds a fresh ConfigParser, assigns only
|
||||
`config['default']`, and opens `'w'` (`manager_core.py:1715`).
|
||||
"""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini(
|
||||
"[default]\\nsecurity_level = normal\\n\\n"
|
||||
"[my_section]\\nmy_key = keepme\\n"
|
||||
)
|
||||
boot()
|
||||
settings_change("db_mode", "local")
|
||||
cp = parse_disk()
|
||||
print(json.dumps({
|
||||
"sections": cp.sections(),
|
||||
"my_key": cp["my_section"].get("my_key", "<ABSENT>")
|
||||
if cp.has_section("my_section") else "<SECTION GONE>",
|
||||
"raw": read_raw(),
|
||||
}))
|
||||
"""
|
||||
)
|
||||
self.assertIn(
|
||||
"my_section", payload["sections"],
|
||||
"T4 [D3]: the user's [my_section] was erased by an unrelated "
|
||||
"settings change — write_config() writes only config['default'] "
|
||||
"into a fresh parser with mode 'w'. Sections left on disk: %r\n%s"
|
||||
% (payload["sections"], payload["raw"]),
|
||||
)
|
||||
self.assertEqual("keepme", payload["my_key"])
|
||||
|
||||
def test_t2_each_caller_key_persists(self):
|
||||
"""T2 / [D2] — regression guard, expected born-GREEN.
|
||||
|
||||
Each settings caller's OWN key must reach disk with its new value. This
|
||||
is what a fix must not break; it is exercised at the manager_core layer
|
||||
with the same mutation + `write_config()` the handlers make, because
|
||||
importing `manager_server` starts a network thread (`:2093`).
|
||||
"""
|
||||
payload = _run_child(
|
||||
"""
|
||||
import json as _j
|
||||
cases = %s
|
||||
out = {}
|
||||
for key, value in cases:
|
||||
write_ini("[default]\\nsecurity_level = normal\\n")
|
||||
boot()
|
||||
settings_change(key, value)
|
||||
out[key] = parse_disk()["default"].get(key, "<ABSENT>")
|
||||
print(_j.dumps(out))
|
||||
""" % json.dumps(_CALLER_KEYS)
|
||||
)
|
||||
# NOT subTest — same reason as T3: a regression guard whose node
|
||||
# reports PASSED while a subtest fails cannot signal the regression
|
||||
# it exists to catch.
|
||||
wrong = {
|
||||
key: payload[key] for key, value in _CALLER_KEYS
|
||||
if payload[key] != value
|
||||
}
|
||||
self.assertEqual(
|
||||
{}, wrong,
|
||||
"T2 [D2]: these caller keys did not persist their new value: %r — "
|
||||
"a fix must not regress the write path it is narrowing."
|
||||
% (wrong,),
|
||||
)
|
||||
|
||||
def test_t5_bootstrap_writes_defaults(self):
|
||||
"""T5 / S4 — regression guard, expected born-GREEN.
|
||||
|
||||
The 7th `write_config()` caller (`manager_server.py:2098`) is guarded by
|
||||
`if not os.path.exists(...)` (`:2096`) and runs only when config.ini is
|
||||
absent, so it cannot clobber. Its bootstrap write must still produce a
|
||||
usable defaults file.
|
||||
"""
|
||||
payload = _run_child(
|
||||
"""
|
||||
if os.path.exists(CONFIG_PATH):
|
||||
os.remove(CONFIG_PATH)
|
||||
manager_core.get_config() # the bootstrap pair at :2097-2098
|
||||
manager_core.write_config()
|
||||
cp = parse_disk()
|
||||
print(json.dumps({
|
||||
"exists": os.path.exists(CONFIG_PATH),
|
||||
"security_level": cp["default"].get("security_level", "<ABSENT>"),
|
||||
"allow_git_url_install": cp["default"].get(
|
||||
"allow_git_url_install", "<ABSENT>"),
|
||||
"allow_flagged_nodepack_install": cp["default"].get(
|
||||
"allow_flagged_nodepack_install", "<ABSENT>"),
|
||||
}))
|
||||
"""
|
||||
)
|
||||
self.assertTrue(payload["exists"], "T5: bootstrap did not create config.ini")
|
||||
self.assertEqual("normal", payload["security_level"])
|
||||
self.assertEqual(
|
||||
"False", payload["allow_git_url_install"],
|
||||
"T5: the install flag must bootstrap secure-by-default",
|
||||
)
|
||||
self.assertEqual(
|
||||
"False", payload["allow_flagged_nodepack_install"],
|
||||
"T5: flagged installs must bootstrap with the override disabled",
|
||||
)
|
||||
|
||||
def test_t6_persisted_key_is_not_reclobbered(self):
|
||||
"""T6 / [D1] — a key stays writable by hand AFTER the UI has set it.
|
||||
|
||||
BORN-RED. `dirty_keys` is add-only: `write_config()` never removes the
|
||||
keys it just persisted. So a key the user once changed through the UI
|
||||
remains dirty for the life of the process, and the NEXT unrelated
|
||||
settings write overlays its stale cached value back over whatever the
|
||||
user has since edited on disk — the same clobber class the merge-on-
|
||||
write fix removes, just deferred by one settings change.
|
||||
|
||||
This is the residual case: T1 covers a key the UI never touched, which
|
||||
is why T1 passes while this fails.
|
||||
"""
|
||||
payload = _run_child(
|
||||
"""
|
||||
write_ini("[default]\\nsecurity_level = normal\\ndb_mode = cache\\n")
|
||||
boot()
|
||||
# 1. the user changes db_mode through the UI -> persisted, and the
|
||||
# key is now marked dirty
|
||||
settings_change("db_mode", "local")
|
||||
after_ui = parse_disk()["default"].get("db_mode", "<ABSENT>")
|
||||
|
||||
# 2. the user hand-edits THAT SAME key on disk, touching nothing else
|
||||
raw = read_raw()
|
||||
assert "db_mode = local" in raw, raw
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
f.write(raw.replace("db_mode = local", "db_mode = remote"))
|
||||
|
||||
# 3. one UNRELATED settings change
|
||||
settings_change("component_policy", "higher")
|
||||
|
||||
cp = parse_disk()
|
||||
print(json.dumps({
|
||||
"after_ui": after_ui,
|
||||
"db_mode_final": cp["default"].get("db_mode", "<ABSENT>"),
|
||||
"component_policy": cp["default"].get("component_policy", "<ABSENT>"),
|
||||
"raw": read_raw(),
|
||||
}))
|
||||
"""
|
||||
)
|
||||
# Guard against a vacuous pass: step 1 must really have persisted, and
|
||||
# step 3's own key must really have landed. Without these, a
|
||||
# write_config() that wrote nothing at all would satisfy the assertion
|
||||
# below for the wrong reason.
|
||||
self.assertEqual("local", payload["after_ui"], "T6 precondition: the UI change did not persist")
|
||||
self.assertEqual(
|
||||
"higher", payload["component_policy"],
|
||||
"T6 precondition: the unrelated settings change did not persist",
|
||||
)
|
||||
self.assertEqual(
|
||||
"remote", payload["db_mode_final"],
|
||||
"T6 [D1]: the hand-edited db_mode=remote was reverted to %r by an "
|
||||
"unrelated component_policy change. `db_mode` stayed in dirty_keys "
|
||||
"after write_config() persisted it, so the stale cached value was "
|
||||
"overlaid onto the re-read file. Any key the user has ever changed "
|
||||
"through the UI becomes permanently un-hand-editable for the life "
|
||||
"of the process. On-disk file:\\n%s"
|
||||
% (payload["db_mode_final"], payload["raw"]),
|
||||
)
|
||||
|
||||
def test_t7_silent_read_failure_is_loud(self):
|
||||
"""T7 / GOAL #53 [D1] — an UNREADABLE config.ini must not be wiped.
|
||||
|
||||
BORN-RED. `configparser.ConfigParser.read()` opens each filename inside
|
||||
a `try/except OSError` and just SKIPS the ones it could not open, so a
|
||||
file that EXISTS but denies reads leaves `read()` returning normally
|
||||
with an EMPTY parser. `write_config()` then sees no `[default]`, takes
|
||||
the BOOTSTRAP branch, and rewrites the file from the default set —
|
||||
silently destroying the foreign key and the user's `[my_section]`,
|
||||
while the settings endpoint answers 200.
|
||||
|
||||
The scenario is gm3-solver's measured boundary (GOAL #52 D2): reads
|
||||
denied but writes allowed. When BOTH are denied the write itself
|
||||
raises, so the failure is already loud and the file already survives;
|
||||
this node pins the asymmetric case that is not.
|
||||
"""
|
||||
payload = _run_child(
|
||||
"""
|
||||
import builtins
|
||||
|
||||
write_ini(
|
||||
"[default]\\nsecurity_level = normal\\ndb_mode = cache\\n"
|
||||
"http_channel_enabled = true\\n\\n"
|
||||
"[my_section]\\nmy_key = keepme\\n"
|
||||
)
|
||||
boot()
|
||||
before = read_raw()
|
||||
|
||||
# Deny READS of config.ini while leaving WRITES allowed — the
|
||||
# asymmetric permission shape gm3-solver reproduced live. Reads go
|
||||
# through builtins.open, which is what configparser.read() calls
|
||||
# and whose OSError it swallows.
|
||||
_real_open = builtins.open
|
||||
deny = {"on": False}
|
||||
|
||||
def guarded_open(file, mode="r", *args, **kwargs):
|
||||
is_read = "r" in mode and "w" not in mode and "a" not in mode
|
||||
if deny["on"] and is_read and os.path.abspath(str(file)) == os.path.abspath(CONFIG_PATH):
|
||||
raise PermissionError(13, "Permission denied", str(file))
|
||||
return _real_open(file, mode, *args, **kwargs)
|
||||
|
||||
builtins.open = guarded_open
|
||||
raised = None
|
||||
try:
|
||||
deny["on"] = True
|
||||
settings_change("db_mode", "local")
|
||||
except Exception as e:
|
||||
raised = "%s: %s" % (type(e).__name__, e)
|
||||
finally:
|
||||
deny["on"] = False
|
||||
builtins.open = _real_open
|
||||
|
||||
after = read_raw()
|
||||
cp = parse_disk()
|
||||
print(json.dumps({
|
||||
"raised": raised,
|
||||
"unchanged": before == after,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"sections": cp.sections(),
|
||||
"http_channel_enabled": cp["default"].get(
|
||||
"http_channel_enabled", "<ABSENT>")
|
||||
if cp.has_section("default") else "<NO DEFAULT>",
|
||||
}))
|
||||
"""
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
payload["raised"],
|
||||
"T7 [D1]: write_config() returned SUCCESSFULLY on a config.ini it "
|
||||
"could not read. config.read() suppressed the PermissionError, so "
|
||||
"the empty parser routed the write into the bootstrap branch and "
|
||||
"the settings change reports success while destroying the file. "
|
||||
"Sections left on disk: %r\nOn-disk file after the write:\n%s"
|
||||
% (payload["sections"], payload["after"]),
|
||||
)
|
||||
self.assertTrue(
|
||||
payload["unchanged"],
|
||||
"T7 [D1]: config.ini was MODIFIED by a settings change that could "
|
||||
"not read it. A write that cannot see the current contents must "
|
||||
"leave them alone.\n--- before ---\n%s\n--- after ---\n%s"
|
||||
% (payload["before"], payload["after"]),
|
||||
)
|
||||
# Spell out the two losses the byte-comparison above rolls up, so a
|
||||
# failure names the user-visible damage and not just "bytes differ".
|
||||
self.assertIn("my_section", payload["sections"], "T7 [D1]: [my_section] was erased")
|
||||
self.assertEqual(
|
||||
"true", str(payload["http_channel_enabled"]).lower(),
|
||||
"T7 [D1]: http_channel_enabled was erased",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user