Compare commits

...
564 Commits
Author SHA1 Message Date
Dr.Lt.Data 946ef8fe65 bump version to 3.42 2026-09-19 08:38:42 +09:00
Dr.Lt.Data 572f363696 v3: Prevent HTML and URL injection in the Manager UI (#3296) 2026-09-19 08:22:26 +09:00
Dr.Lt.Data ea7534fae8 v3: Restrict Flagged CNR installs on non-local listeners (#3298) 2026-09-19 07:47:20 +09:00
Dr.Lt.Data 21ab2b78c2 update DB 2026-09-15 23:48:43 +09:00
Dr.Lt.Data 97f123ef44 fix(custom-node-list): relocate merged git-clone entries above copy/unzip zone (Expert-Wildcard, PreFlight, LoadImageCrop, LoadVideoCrop; author-adjacent for existing authors) + node_db/new sync 2026-09-13 20:01:25 +09:00
Dr.Lt.Data fe43918863 Merge pull request #3271 from MajoorWaldi/feat/add-majoor-h3-guidemaster
Add Majoor H3 GuideMaster to custom-node-list.json
2026-09-13 19:55:02 +09:00
Dr.Lt.Data b4eebf2b8e Merge pull request #3267 from 0xBeycan/main
added PreFlight
2026-09-13 19:54:56 +09:00
Dr.Lt.Data 41600906a5 Merge pull request #3265 from kaan-trusapien/add-comfyui-trusapien
Add ComfyUI-Trusapien custom node entry
2026-09-13 19:54:50 +09:00
Dr.Lt.Data 87258e4465 Merge pull request #3240 from v3rm1ll1on/main
feat(custom-node-list): add ComfyUI Expert Wildcard Prompt
2026-09-13 19:54:35 +09:00
Dr.Lt.Data eed2d54b8f update DB 2026-09-13 18:50:58 +09:00
Ewald c8fb99ff5f Add Majoor H3 GuideMaster to custom-node-list.json
Registers ComfyUI-Majoor-H3-GuideMaster (comfyui-majoor-h3-guidemaster on
the Comfy Registry) so it appears in the Manager install dialog.
2026-09-11 16:52:23 +02:00
BeycanDeveloper 53daaef541 added PreFlight 2026-09-11 13:38:49 +08:00
Dr.Lt.Data 9d4cceea13 feat(custom-node-list): relocate MSCH Nodes (#3247) above copy/unzip zone + node_db/new sync 2026-09-11 07:42:42 +09:00
Dr.Lt.Data 1c8a34d1a7 Merge pull request #3247 from mariobilly/register-msch-nodes
Add MSCH Nodes as one unified custom-node pack
2026-09-11 07:41:39 +09:00
Dr.Lt.Data d0fd6da37d feat(custom-node-list): relocate Autocomplete++ (#3241) above copy/unzip zone + node_db/new sync (Autocomplete++, Perfect Video Resolution) 2026-09-11 07:41:06 +09:00
Dr.Lt.Data 9ef6d2ec83 Merge pull request #3241 from michikora/add-node
Add Autocomplete++ to custom node list
2026-09-11 07:39:09 +09:00
Dr.Lt.Data b62e30f2d0 Merge pull request #3251 from huchukato/update-huchukato-nodes
Add PerfectVideoResolution node, rename TagComplete to TagForge, and align entries with Comfy Registry
2026-09-11 07:37:49 +09:00
kaan-trusapien 1048c958b9 Add ComfyUI-Trusapien custom node entry
### Custom Node Details

* **Title**: ComfyUI-Trusapien
* **Repository**: https://github.com/trusapien/ComfyUI-Trusapien
* **Author**: trusapien
* **Install Type**: `git-clone`
* **Description**: Enterprise creative validation, consumer-neuroscience attention heatmaps, and campaign impact scoring for generated creative assets.

### Verification Checklist

- [x] Node repository contains `__init__.py` with valid `NODE_CLASS_MAPPINGS`.
- [x] Dependencies listed in `requirements.txt`.
- [x] Tested locally on ComfyUI Desktop / standard installation.
- [x] Open-source MIT License included.
2026-09-10 17:58:15 +03:00
Dr.Lt.Data 6c120969f1 fix(custom-node-list): dedup ComfyUI-FEnodes (#3248) — remove merge-inserted duplicate at array head, keep single entry at author position with the PR's description 2026-09-10 22:10:38 +09:00
Dr.Lt.Data 32fed18c89 Merge pull request #3248 from FugitiveExpert01/add-comfyui-fenodes
Register ComfyUI-FEnodes custom node
2026-09-10 22:09:07 +09:00
Dr.Lt.Data 6833ab2b6e feat(custom-node-list): register 5 verified-safe nodepacks (DiffSynth-ComfyUI, Smooth Organizer, Prompt-Weaver, CFG-Sculptor, MiniMax H3 Novel Pipeline) + relocate DiffSynth above copy/unzip zone + node_db/new sync 2026-09-10 18:28:53 +09:00
Dr.Lt.Data 4064519adc Merge pull request #3253 from yjy415/plugin
Add: custom nodes of DiffSynth-Studio
2026-09-10 18:23:53 +09:00
Dr.Lt.Data d972e92c60 update DB 2026-09-10 18:21:35 +09:00
huchukato 85124357c7 Fix PerfectVideoResolution description: list only implemented models (WAN 2.2, MiniMax H3, LTX) 2026-09-08 10:51:44 +02:00
yjy415 f6d38f4ce8 add: custom nodes of DiffSynth-Studio 2026-09-08 16:41:23 +08:00
huchukato 53cd0381b7 Update huchukato nodes: align with CNR registry, rename TagComplete to TagForge, add PerfectVideoResolution 2026-09-08 00:11:35 +02:00
FugitiveExpert01 d000c1173a Register ComfyUI-FEnodes custom node 2026-09-07 12:08:04 +02:00
mariobilly 9c2ceda51f Keep existing Manager entries byte-for-byte unchanged 2026-09-06 19:14:04 +03:00
mariobilly 05b84d270c Preserve unrelated Manager database formatting 2026-09-06 19:11:34 +03:00
mariobilly 8f91f5791f Replace separate MSCH entries with one unified node pack 2026-09-06 19:06:32 +03:00
mariobilly e704f26391 Add MSCH custom node packages 2026-09-06 16:08:22 +03:00
michikora 8c3a210c91 chore: add autocomplete++ 2026-09-04 21:29:28 +09:00
Dr.Lt.Data f82970b7cb feat(custom-node-list): register 5 verified-safe nodepacks (WeeLLM, Meta SAM-Audio, PixelPanda, Free Model and Node Cache, ComfyUI-Auth) + node_db/new sync 2026-09-04 19:41:22 +09:00
Dr.Lt.Data db65fda766 update DB 2026-09-04 19:23:41 +09:00
Dr.Lt.Data 6e7fdb7802 fix(custom-node-list): place BatchPromptLoader entry in git-clone body above copy/unzip zone + node_db/new sync 2026-09-04 19:08:43 +09:00
Dr.Lt.Data e252bb97b8 Merge branch 'main' of github.com:Comfy-Org/ComfyUI-Manager 2026-09-04 19:07:12 +09:00
Dr.Lt.Data 896151d8dd Merge pull request #3183 from ReSerendipity/add-batchpromptloader
Add ComfyUI-BatchPromptLoader to custom node list
2026-09-04 19:07:08 +09:00
Dr.Lt.Data ee63fdc136 fix(custom-node-list): place SSG Smart Suite entry in git-clone body above copy/unzip zone + node_db/new sync 2026-09-04 19:06:56 +09:00
Dr.Lt.Data a62c4e561b Merge pull request #3233 from SgtSauv/Add-ComfyUI-SSG-Smart_Suite
Add SSG Smart Suite to custom-node-list
2026-09-04 19:04:03 +09:00
V3rm1ll1on 4caa942c5f feat(custom-node-list): add ComfyUI Expert Wildcard Prompt 2026-09-04 08:40:08 +02:00
SgtSauv f9b9d846ff Add SSG Smart Suite to custom-node-list 2026-09-01 20:36:57 -04:00
Dr.Lt.Data b75fc664ec feat(custom-node-list): register 5 verified-safe nodepacks + relocate H3 Prompt IDE
Register artttaku/ComfyUI-BatchVideoLoader (#3179),
rattskr/ComfyUI_CivitaiAdvanceLoader (#3218),
Winlensky/comfyui_compvis_ldm (#3229) and two entropicnoise MiniMax-H3
packs (#3185, #3202) at the end of the git-clone body, above the
copy/unzip tail zone. Relocate the already-merged ethanfel H3 Prompt IDE
(#3186) from array head to the ethanfel author group. Sync all six into
node_db/new. All six passed verify-security (SAFE), locale (English UI),
and quality gates.
2026-09-01 21:39:04 +09:00
Dr.Lt.Data 3b165d265f fix(custom-node-list): relocate 600 mis-appended git-clone entries above the copy/unzip tail zone
The array tail is the copy/unzip special zone; over many sessions new
git-clone entries were wrongly appended after it, burying the zone under
~600 entries. Re-place each into the git-clone body (author-adjacent for
existing authors, grouped-at-body-end for new authors) so the array ends
with the copy/unzip zone again. Pure reorder: entry count, reference set,
and per-entry formatting all preserved.
2026-09-01 21:36:46 +09:00
Dr.Lt.Data a73048da1e Merge pull request #3186 from ethanfel/register-h3-prompt-ide
Add H3 Prompt IDE
2026-09-01 21:13:32 +09:00
Dr.Lt.Data f6dd36ed9e fix(custom-node-list): adopt canonical title/description for Newaiguy/ComfyUI-h3_sage_amd (#3144)
Base carried the scanner-abbreviated title 'h3_sage_amd' and a one-line
description; replace with the author's canonical title and description
from PR #3144 in both the root list and node_db/new.
2026-09-01 21:09:01 +09:00
Dr.Lt.Data d81cc6bf0d Merge pull request #3214 from Zoltar358-ComfyUI/update-universal-model-loader-107
Update Universal Model Loader listing for metadata hotfix
2026-09-01 21:06:24 +09:00
Dr.Lt.Data 36b878b192 update DB 2026-09-01 19:47:40 +09:00
Dariusz Dębicki fc052d4a5f Update Universal Model Loader 1.0.8 listing 2026-08-26 19:11:46 +02:00
Dariusz Dębicki 5feefbaa21 Update Universal Model Loader metadata fix listing 2026-08-26 17:50:28 +02:00
ethanfel 40d96759f1 Add H3 Prompt IDE 2026-08-22 11:43:04 +02:00
ReSerendipity 8d1d221aa7 Add ComfyUI-BatchPromptLoader to custom node list 2026-08-21 19:58:54 +08:00
Dr.Lt.Data f39cbd56fe fix(custom-node-list): demote mickmumpitz/ComfyUI-SplatKit from default to dev channel (security hold)
Post-registration full re-verify (§14.7) found an unauthenticated CSRF-reachable
arbitrary-directory delete: DatasetProject.make(reset=True) runs shutil.rmtree on an
uncontained os.path.join(output_dir, dataset_name) (nodes/common.py:169-173) — an
absolute or ..-containing name escapes the output dir. Removed from default + node_db/new,
added to node_db/dev with [SECURITY ISSUE] suffix (reversible install-block, §4.7-H mechanic).
Restore to default on author fix (realpath+commonpath containment) + re-verify pass.
2026-08-19 08:43:25 +09:00
Dr.Lt.Data d243fda858 update DB 2026-08-19 08:29:05 +09:00
Dr.Lt.Data abaad8e28b fix(custom-node-list): register ketle-man/comfyui-chat-te (Chat TE, #3177) after last git-clone block + node_db/new sync
PR conflicted on the list tail; manually registered. Deep-verify SAFE (unauth routes are inference/model-load only, no side-effect sink), Non-English PASS.
2026-08-19 07:27:17 +09:00
Dr.Lt.Data d5992a117e update DB 2026-08-18 12:49:34 +09:00
Dr.Lt.Data 4f56cf3dfa Merge pull request #3163 from Zoltar358-ComfyUI/update-universal-model-loader-106
Update Universal Model Loader listing for MiniMax Music3
2026-08-15 13:28:36 +09:00
Dr.Lt.Data 8970a942a8 fix(custom-node-list): register DenRakEiw/ComfyGotchi (#3166) after last git-clone block + node_db/new sync
PR conflicted on the list tail; manually registered. Weak sink (fixed-path
game-state only, no data-loss), SAFE per deep-verify.
2026-08-15 13:22:24 +09:00
Dr.Lt.Data 1b765ac340 fix(custom-node-list): register H3-wave nodepacks after last git-clone block + node_db/new sync
Relocate ANe5s/ComfyUI-MiniMax-H3-Hybrid (#3154) from array head to tail.
Manually register Flow-Wrangler (#3142), minimax-h3-latent (#3151),
buqi-minimax-h3-multigpu (#3158), H3-Continuum (#3159) — their PRs
conflicted on the list tail after the batch merges. buqi-minimax-h3-multigpu
description trimmed to English-only (author's zh restatement dropped, §4.7-D desc policy).
Sync all 7 into node_db/new (newest-first).
2026-08-15 13:06:57 +09:00
Dr.Lt.Data fe4d5d434c Merge pull request #3154 from ANe5s/codex/add-minimax-h3-hybrid
Add ComfyUI MiniMax H3 Hybrid
2026-08-15 13:00:05 +09:00
Dr.Lt.Data 67ec5f5c36 Merge pull request #3152 from CocyNoric/main
Add ComfyUI-ToriiGate-Reforged
2026-08-15 12:59:58 +09:00
Dr.Lt.Data d7c376bb88 Merge pull request #3162 from mickmumpitz/main
Add ComfyUI-SplatKit
2026-08-15 12:59:48 +09:00
Dr.Lt.Data 7e08e10ec1 Merge pull request #3161 from Comfy-Org/fix/issue-3128-c1
fix(ui): show the server's reason for a batch 404, not a false channel message (#3128)
2026-08-15 11:09:17 +09:00
Dr.Lt.Data 98be899011 harden(ui): escape untrusted text before the batch error dialog (#3128)
The dialog renders HTML, so run the server body and the pack title
(registry-derived) through the existing sanitizeHTML() at both sites. The
title is wrapped in String() so a missing title can't throw inside the error
handler; rendered text is unchanged for ordinary values.
2026-08-15 08:16:23 +09:00
Dr.Lt.Data 4fe65b088e fix(ui): surface the server's reason for a batch 404 (#3128)
The batch install/uninstall paths replaced the server's 404 body with a
false "default channel" lead. Drop the 404 branch so a 404 falls through to
the existing server-text path; uninstall never returns 404 anyway. Fixes
#3128 (section 1), reported by plz12345.
2026-08-15 08:16:23 +09:00
Dariusz Dębicki 8950d89648 Update Universal Model Loader listing for MiniMax Music3 2026-08-14 16:47:58 +02:00
mickmumpitz 00c273dbdc Add ComfyUI-SplatKit 2026-08-14 11:59:30 +02:00
Dr.Lt.Data 14de630433 Merge pull request #3157 from Comfy-Org/fix/issue-3128
fix(#3128): state the real install gate in denial messages + stop write_config() clobbering hand-edited config.ini
2026-08-14 08:03:12 +09:00
Dr.Lt.Data aaed26a5a4 fix(config): merge write_config() onto disk instead of rebuilding from the startup snapshot (#3128)
write_config() rebuilt config.ini's [default] section entirely from the
startup snapshot into a fresh parser opened 'w'. Any Manager settings
change therefore silently reverted values hand-edited while ComfyUI was
running (the same config['default'] flags those messages tell users to
edit), deleted the two read-but-never-written keys (http_channel_enabled,
default_cache_as_channel_url), and erased every non-[default] section.

get_config() now tracks which keys callers actually change, and
write_config() re-reads config.ini and overlays only those keys plus the
live preview_method. After a successful write the just-persisted keys are
cleared from the tracker, so a key changed once through the UI does not
stay dirty for the life of the process and re-clobber a later hand-edit of
that same key on the next unrelated write. Hand-edited values, unknown
keys, and foreign sections survive; CRLF sanitization still applies to
every value written; an unparsable config.ini falls back to a full rewrite
instead of failing every settings endpoint.

configparser.read() suppresses OSError, so a config.ini that exists but
cannot be read comes back as an empty parser rather than an exception,
which would silently route the merge into the bootstrap branch and rewrite
the file from defaults. write_config() now detects that case (the file
exists but read() loaded nothing) and refuses the write with a raised
error, leaving the file untouched, rather than destroying its contents
while reporting success.

tests/test_write_config_persistence.py pins the three loss classes
(hand-edit revert, key deletion, section erasure), the settings
round-trip, the persisted-key re-clobber guard, and the silent-read-failure
refusal.
2026-08-14 06:31:38 +09:00
Alexis Rolland 2f3fc41a13 Merge pull request #3153 from adamoster/feat/ltx-2.5-models
feat(models): Add LTX-2.5 model family and LTX-2.3 IC-LoRAs.
2026-08-13 00:30:51 -07:00
Dr.Lt.Data ffb2f03e92 fix(security-messages): state the actual gate in install-denial messages (#3128)
The install-denial diagnostics named the wrong gate, sending users to
settings that could not resolve their denial:

- SECURITY_MESSAGE_FLAG_GIT_URL / _FLAG_PIP omitted the loopback half of
  the flag-AND-loopback predicate, telling users to enable a flag that
  was often already enabled. They now state both conditions, print the
  live --listen value at every emission site, and explain that both
  values are read once at startup (stop the server, edit, then start).
- SECURITY_MESSAGE_MIDDLE_OR_BELOW instructed setting security_level to
  'middle', a value that does not exist; it now names only real values
  (any level other than 'strong': normal, normal-, weak).
- The blocked-risk arm reused SECURITY_MESSAGE_GENERAL, blaming
  security_level although no value of it can help there; it now emits a
  dedicated SECURITY_MESSAGE_BLOCKED_RISK stating this is not a
  configuration problem.
- SECURITY_MESSAGE_NORMAL_MINUS_MODEL now names its loopback-listener
  condition and the same restart guidance.
- The js/common.js dedicated-403 messages mirror the loopback clause.
2026-08-13 12:38:11 +09:00
{name} 8c46ea2921 Add MiniMax H3 Hybrid node 2026-08-12 20:55:48 +08:00
Adam OsterandCursor 7d2e499d85 feat(models): Add LTX-2.3 22B IC-LoRA suite.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 12:55:18 +03:00
Adam OsterandCursor 55746905d0 feat(models): Add LTX-2.5 22B model family.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 12:46:16 +03:00
CauchyInno ae31058747 Add ComfyUI-ToriiGate-Reforged 2026-08-12 13:44:59 +08:00
Dr.Lt.Data 97421c4965 update DB 2026-08-10 02:32:47 +09:00
Dr.Lt.Data 67acc56924 sync(node_db/new): add ComfyUI MiniMax H3 Context IR Agent (#3145) 2026-08-10 01:56:38 +09:00
Dr.Lt.Data d5d48e7c18 Merge pull request #3145 from JerryZRic/add-minimax-h3-context-ir-agent
Add MiniMax H3 Context IR Agent
2026-08-10 01:55:27 +09:00
JerryZRic 6ab1d9dd93 Add MiniMax H3 Context IR Agent 2026-08-09 20:13:32 +08:00
Dr.Lt.Data 8c5e640f83 docs(custom-node-list): update descriptions for Universal Model Loader (#3123) and Anomalous Model Browser (#3130); drop overbroad nodename_pattern '.*' 2026-08-08 01:26:52 +09:00
Dr.Lt.Data 62441d65dd update DB 2026-08-08 01:20:04 +09:00
Dr.Lt.Data 78bc5df653 feat(custom-node-list): register ComfyUI-Quick-Merge (#3102) — manual add (PR conflicting) after author security fix + node_db/new sync 2026-08-07 12:40:28 +09:00
Dr.Lt.Data 150f23c210 fix(custom-node-list): place ComfyUI-Qwen-FrameKit after last git-clone block + node_db/new sync (#3131, #3135) 2026-08-07 09:00:47 +09:00
Dr.Lt.Data 4814b5d67a Merge pull request #3135 from uron83/register-qwen-framekit
Register ComfyUI-Qwen-FrameKit
2026-08-07 08:55:54 +09:00
Dr.Lt.Data 97399264a2 Merge pull request #3131 from DumiFlex/add-wildcard-pipeline
Add Wildcard Pipeline to custom-node-list.json
2026-08-07 08:55:29 +09:00
Elian Gabriel Hernandez db1aa8d73a Register ComfyUI-Qwen-FrameKit 2026-08-06 16:05:27 +10:00
DumiFlex c975c0ae74 Add Wildcard Pipeline to custom-node-list.json 2026-08-04 21:57:03 +03:00
Dr.Lt.Data d47c934619 update DB 2026-08-04 12:53:08 +09:00
Dr.Lt.Data fe1193c0c8 Relist ComfyUI-Pixaroma to custom-node-list.json
Re-add pixaroma/ComfyUI-Pixaroma after verifying the v1.4.82 security
fixes (PR #3118): the four reported filesystem-access issues are now
contained through a shared path guard. Registered to the default list.

Claude-Session: https://claude.ai/code/session_01VKv6FcDs7S642dM1if3vxD
2026-08-03 20:19:56 +09:00
Dr.Lt.Data 1c3031d62f update DB 2026-08-03 12:49:18 +09:00
Dr.Lt.Data d404e6234a updatevDB 2026-08-02 22:15:31 +09:00
Dhevenddra K G f124e5cfc6 test: fix the install-flags structural guard failing on main (#3116)
Two defects, the second hidden behind the first. CI runs ruff only, so
neither shows up there.

The generic-403 guard counted the copy across all of js/common.js while its
docstring describes exactly the two occurrences inside handle403Response. A
third, unrelated use at line 722 builds the batch-uninstall error message, so
the file-wide count tripped on it. Scoped the count to the handle403Response
block via the existing _js_function_block helper. This part is not
platform-specific; the count is 3 on Linux and Windows alike.

With that passing, the test reached a loop calling js_file.read_text() with no
encoding. That uses the locale encoding, so on cp1252 Windows it raises
UnicodeDecodeError on the four js files containing emoji. Passed
encoding="utf-8" explicitly, and did the same for the other four read_text
calls in the file, which read ASCII sources today and so pass by luck.
2026-08-02 15:08:30 +09:00
Rvage 25f519ed0c Fix: tooltip global listener (#3114)
* remove noise

* fix: remove global tooltip listener
2026-08-02 15:08:27 +09:00
Dr.Lt.Data 2b40deba7d update DB 2026-07-30 02:19:23 +09:00
Dr.Lt.Data 89bf85f0a7 update DB 2026-07-30 01:35:14 +09:00
Dr.Lt.Data 522a437bd8 update DB 2026-07-29 20:48:56 +09:00
Dr.Lt.Data 1a445fa44c update DB 2026-07-29 20:36:58 +09:00
Dr.Lt.Data 314814b336 update DB 2026-07-29 18:40:34 +09:00
Dr.Lt.Data 3af9708ee1 update DB 2026-07-29 08:23:26 +09:00
Dr.Lt.Data 8daa17d9dd update DB 2026-07-29 07:34:04 +09:00
Dr.Lt.Data 4eea3a377b update DB 2026-07-29 07:33:21 +09:00
Dr.Lt.Data 64e2f1d04a update DB 2026-07-29 07:32:22 +09:00
Dr.Lt.Data 35b1ad3410 Merge pull request #3108 from Zoltar358-ComfyUI/add-universal-model-loader
Add ComfyUI-Universal-Model-Loader
2026-07-29 07:31:53 +09:00
Dr.Lt.Data ebe429346a update DB 2026-07-29 06:33:55 +09:00
Dr.Lt.Data e4bc691b94 Merge pull request #3104 from kkukshtel/add-mood-site-upload
Add mood.site Upload (kkukshtel/comfyui_mood)
2026-07-29 06:32:23 +09:00
Zoltar358-ComfyUI 21dfd92181 Add ComfyUI-Universal-Model-Loader 2026-07-26 23:33:38 +02:00
Dr.Lt.Data 9463115e78 update DB 2026-07-26 23:02:48 +09:00
Dr.Lt.Data 4d29f36979 update DB 2026-07-26 22:46:12 +09:00
Dr.Lt.Data aa6dc09d67 Merge pull request #3106 from muerrilla/add-tab-cycle
Add ComfyUI-Tab-Cycle
2026-07-26 22:45:34 +09:00
Dr.Lt.Data fc131473bc update DB 2026-07-26 22:44:59 +09:00
Dr.Lt.Data 616de87e96 Merge pull request #3105 from 0xBeycan/main
Added PostFx and SocialMediaExport
2026-07-26 22:41:58 +09:00
Dr.Lt.Data 2ce7556b3f update DB 2026-07-26 21:40:53 +09:00
zahand 7a66661e78 Fix trailing comma 2026-07-25 01:23:25 +03:30
zahand 370e11487e Add ComfyUI-Tab-Cycle
updated node list to add ComfyUI-Tab-Cycle
2026-07-25 00:49:59 +03:30
BeycanDeveloper 7b60d5f988 added PostFx and SocialMediaExport 2026-07-24 21:46:47 +08:00
BeycanDeveloper 6bd3d83db9 added to list 2026-07-24 21:45:55 +08:00
Dr.Lt.Data c4ed394ec8 update DB 2026-07-24 19:14:31 +09:00
Dr.Lt.Data 7cb06a6890 Merge pull request #2924 from designloves2/main
Add ComfyUI-TJ_NODE to custom-node-list
2026-07-24 19:12:18 +09:00
Dr.Lt.Data 3dbb7bddb8 update DB 2026-07-24 18:38:58 +09:00
Dr.Lt.Data b709d6c05b Merge pull request #2992 from rookiestar28/add-scail-pose2-node
Add ComfyUI-SCAIL-Pose2 custom node
2026-07-24 18:37:28 +09:00
Dr.Lt.Data 8ca022718f update DB 2026-07-24 18:31:30 +09:00
Kyle Kukshtel 25c1b3d9a3 Add mood.site Upload (kkukshtel/comfyui_mood) 2026-07-23 13:58:46 -04:00
Dr.Lt.Data 7955e638db fix(custom-node-list): §4.3 relocate #3091 sni10/DWA entry out of header zone to array tail 2026-07-23 04:32:07 +09:00
Dr.Lt.Data 9a7fe5a9e8 Merge pull request #3033 from nnnnkatsu/add-seedance2-comfyui-byteplus
Add Seedance 2.0 BytePlus custom node
2026-07-23 04:09:37 +09:00
Dr.Lt.Data 13f068ae6c Merge pull request #3087 from yha9806/codex/update-vulca-comfyui-url
Update VULCA ComfyUI repository URL
2026-07-23 03:52:14 +09:00
Dr.Lt.Data 35204b1521 Merge pull request #3099 from DemonAlone/JS-addon
Add DemonAlone-JS_Addon-ComfyUI
2026-07-23 03:22:19 +09:00
Dr.Lt.Data 9db9d43f28 Merge pull request #3100 from Dehypnotic/main
Update custom-node-list.json
2026-07-23 03:08:27 +09:00
Dr.Lt.Data 579bf48edf fix(custom-node-list): MERGE_DEDUP remove pre-registered duplicate (#3091 sni10/DWA) 2026-07-23 01:27:39 +09:00
Dr.Lt.Data 04d3d0f25a Merge pull request #3091 from sni10/sni10/add-da-sender
add ComfyUI DeviantArt Sender to custom node list
2026-07-23 01:26:21 +09:00
Dr.Lt.Data c638fc20f4 update DB 2026-07-23 01:25:48 +09:00
Dr.Lt.Data 3f159c5f65 feat(custom-node-list): register ComfyUI-Bernini-PromptEnhancer (§4.3 manual, fix malformed JSON from #3098) 2026-07-22 08:48:24 +09:00
Dr.Lt.Data d0d8611866 update DB 2026-07-21 21:08:08 +09:00
dehypnotic e634489a97 Update manager_server.py 2026-07-20 23:45:17 +02:00
dehypnotic 8a9ce02959 Update custom-node-list.json 2026-07-20 22:32:19 +02:00
DemonAlone 2a81de6e89 Update custom-node-list.json 2026-07-20 20:44:12 +03:00
Dr.Lt.Data 2112b6a569 fix(custom-node-list): MERGE_DEDUP remove pre-registered duplicates (#2152 #3090 #2977 #3043) 2026-07-20 20:16:51 +09:00
Dr.Lt.Data faa719aaeb Merge pull request #3043 from ukr8b3g-cmyk/add-krea2-bbox-prompter-suite
Add Krea2 BBOX Prompter Suite
2026-07-20 20:13:17 +09:00
Dr.Lt.Data 32284a7100 Merge pull request #2977 from yunqiankuangyu/main
Add comfyui-anima-lora-comparison to node list
2026-07-20 20:13:13 +09:00
Dr.Lt.Data be017c40b8 Merge pull request #3090 from designloves2/add-itda
Add ITDA to custom-node-list
2026-07-20 20:13:09 +09:00
Dr.Lt.Data 79dd5e2358 Merge pull request #2152 from jfcantu/main
New feature: Node Usage Analyzer
2026-07-20 20:13:04 +09:00
Dr.Lt.Data c5c1e1bbc6 update DB 2026-07-20 12:29:09 +09:00
Dr.Lt.Data 065070a32a update DB 2026-07-20 03:29:09 +09:00
Dr.Lt.Data a4a9dd54c5 update DB 2026-07-19 02:29:27 +09:00
Dr.Lt.Data 9c9b882e51 update DB 2026-07-18 14:22:28 +09:00
sni10 7465cebf47 add ComfyUI DeviantArt Sender to custom node list 2026-07-17 16:09:59 +03:00
Dr.Lt.Data 11f983fa36 update DB 2026-07-17 21:59:20 +09:00
Dr.Lt.Data 9e73b6b41d update DB 2026-07-17 16:51:27 +09:00
Dr.Lt.Data bfae406a10 register 4 default-channel nodes (#3083 #3086 #3088 #3089) 2026-07-17 16:45:29 +09:00
Designloves2 7b0ff1afb4 Add ITDA to custom-node-list 2026-07-17 15:09:06 +09:00
designloves2 d5989d8a66 Merge branch 'Comfy-Org:main' into main 2026-07-17 15:07:38 +09:00
yhryzy a217097707 chore: update VULCA repository URL 2026-07-16 17:12:24 +01:00
ukr8b3g-cmyk 1f288b2f3c Fix existing publisher placeholder 2026-07-16 23:09:01 +09:00
ukr8b3g-cmyk 93f694a9ee Merge origin/main into add-krea2-bbox-prompter 2026-07-16 16:41:22 +09:00
Dr.Lt.Data bd88cffa71 update DB 2026-07-16 12:41:54 +09:00
Dr.Lt.Data e3ea3e154a update DB 2026-07-15 09:45:26 +09:00
Dr.Lt.Data 4ee1ab7991 update DB 2026-07-14 18:39:35 +09:00
Dr.Lt.Data 8a55772ece fix(custom-node-list): relocate ComfyUI Model Resolver to end (new author) + node_db/new sync 2026-07-14 18:38:28 +09:00
Dr.Lt.Data 071688ebb1 Merge pull request #3081 from Azornes/add-comfyui-model-resolver
Add ComfyUI Model Resolver
2026-07-14 18:38:07 +09:00
Dr.Lt.Data 05a75a10b5 update DB 2026-07-14 18:38:01 +09:00
Dr.Lt.Data c58bf0d49c update DB 2026-07-14 15:36:12 +09:00
Dr.Lt.Data 66a408ebb4 update DB 2026-07-14 15:33:16 +09:00
Dr.Lt.Data 997b9e45f4 fix(custom-node-list): relocate ComfyUI-LinkRouter to end (new author) + node_db/new sync 2026-07-14 15:32:01 +09:00
Dr.Lt.Data b669845f5e Merge pull request #3054 from 90-RED/main
Add ComfyUI-LinkRouter
2026-07-14 15:30:32 +09:00
Dr.Lt.Data d192cb62c1 update DB 2026-07-14 15:28:25 +09:00
Dariusz L 54014b770d Add ComfyUI Model Resolver 2026-07-14 00:30:17 +02:00
Dr.Lt.Data 351d9c62c4 update DB 2026-07-12 00:55:29 +09:00
Dr.Lt.Data 297b0b96e9 fix(custom-node-list): relocate Anima-Tools to end (new author) + node_db/new sync 2026-07-11 20:23:59 +09:00
Dr.Lt.Data df761bcf27 Merge pull request #3049 from nregret/codex/register-anima-tools
[codex] Register Anima-Tools custom node
2026-07-11 20:23:06 +09:00
Dr.Lt.Data ba2516dc48 update DB 2026-07-11 20:09:49 +09:00
Dr.Lt.Data 377f01ee2f update DB 2026-07-11 20:09:28 +09:00
Dr.Lt.Data 347e37a376 update DB 2026-07-11 20:08:40 +09:00
Dr.Lt.Data 7c2b54ff04 update DB 2026-07-11 20:07:24 +09:00
Dr.Lt.Data 5154e3c070 fix(custom-node-list): relocate Ultimate Lora Loader to end (new author WaitWut) + node_db/new sync 2026-07-11 20:06:03 +09:00
Dr.Lt.Data ef8fa07a7d Merge pull request #3070 from WaitWut/add-ultimate-lora-loader
Add Ultimate Lora Loader
2026-07-11 20:05:27 +09:00
Dr.Lt.Data 3c5a948239 fix(custom-node-list): register ComfyUI-ComfySidebar to root (new author) + normalize node_db/new entry 2026-07-11 20:01:33 +09:00
Dr.Lt.Data 15204133a3 Merge pull request #3055 from m0rtus59/main
Add new custom node 'ComfyUI-ComfySidebar'
2026-07-11 20:00:27 +09:00
Dr.Lt.Data c7b880c46c fix(custom-node-list): relocate ComfyUI-Supertonic3TTS next to existing Anonymzx block + node_db/new sync 2026-07-11 19:59:12 +09:00
Dr.Lt.Data 842bbd5104 Merge pull request #3052 from Anonymzx/main
add ComfyUI-Supertonic3TTS
2026-07-11 19:56:40 +09:00
Dr.Lt.Data ec3f3030f9 updatebD DB 2026-07-11 19:51:09 +09:00
Dr.Lt.Data 619eae0cd6 update DB 2026-07-11 09:52:47 +09:00
Dr.Lt.Data 7ccc7742c7 update DB 2026-07-10 12:47:51 +09:00
Eric WilsonandClaude Opus 4.8 f2af615314 Add Ultimate Lora Loader
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:28:09 -04:00
Dr.Lt.Data 7aef461132 update db 2026-07-09 21:33:04 +09:00
yunqiankuangyu 90ba854b1e Merge upstream and keep comfyui-anima-lora-comparison entry 2026-07-09 16:59:00 +08:00
Dr.Lt.Data a2a90adc72 update DB 2026-07-09 02:36:49 +09:00
Dr.Lt.Data e11e9034ce Merge pull request #3058 from kgilper/update-krea-reference-sliders
Update ComfyUI Krea Reference description: Concept Slider nodes
2026-07-07 14:23:10 +09:00
Kevin Gilper fb75c048bd Update ComfyUI Krea Reference description: Concept Slider nodes added 2026-07-07 01:01:16 -04:00
Dr.Lt.Data f7fd701e19 update DB 2026-07-07 04:49:31 +09:00
Dr.Lt.Data cf883eb762 Merge pull request #3057 from Azornes/azornes-resolutionmaster-preemption
Add ResolutionMaster preemption for Comfyui-Resolution-Master
2026-07-07 03:49:16 +09:00
Dariusz L 45891097bb Add ResolutionMaster preemption for Comfyui-Resolution-Master 2026-07-06 19:14:59 +02:00
m0rtus59 54bd1c3861 Add new custom node 'ComfyUI-ComfySidebar'
Added a new custom node 'ComfyUI-ComfySidebar'
2026-07-06 20:56:54 +05:00
Dr.Lt.Data 037987b917 fix(custom-node-list): §4.7-C-bis #2958 younestft/Comfy_EverAnimate — manual register (CONFLICTING) 2026-07-06 19:48:37 +09:00
Dr.Lt.Data 8076c200fc fix(custom-node-list): §4.7-C-bis-variant #3017 Moon-NE/MoonNe_Nodes — manual register (DESTRUCTIVE + CONFLICTING + non-standard schema) 2026-07-06 19:38:11 +09:00
Dr.Lt.Data 69bad851e0 fix(custom-node-list): §4.7-C-bis #2939 TechnoWarrior2 2-pack — manual register (CONFLICTING) + node_db/new sync
- ComfyUI Image Picker / ComfyUI Live Preview
- New author, appended at end of git-clone block
- live-preview code-scan verified CLEAN (no security surface, English UI, WEB_DIRECTORY only)
- Prepend both to node_db/new (newest-first)
2026-07-06 19:27:05 +09:00
Dr.Lt.Data 47df9c702c fix(custom-node-list): §4.2 MERGE_RELOCATE #2965 shootthesound 4-pack — root relocate + node_db/new sync
- Lighthouse / CleanFreak / Finding LoRA / Image of the Day
- Relocate 4 entries from array head to adjacent to existing shootthesound block
- Prepend 4 entries to node_db/new (newest-first)
2026-07-06 19:25:33 +09:00
Dr.Lt.Data d6df033a21 Merge pull request #2965 from shootthesound/add-shootthesound-nodes-batch
Add Lighthouse, CleanFreak, Finding LoRA, and Image of the Day
2026-07-06 19:24:00 +09:00
Dr.Lt.Data d72007d1a8 update DB 2026-07-06 19:22:15 +09:00
Dr.Lt.Data 79a5258dd2 update DB 2026-07-06 08:48:46 +09:00
90-RED 80db5bd605 Add ComfyUI-LinkRouter 2026-07-06 05:58:56 +08:00
Anonymzx 14fe358337 add ComfyUI-Supertonic3TTS 2026-07-06 00:53:37 +07:00
Dr.Lt.Data 16a212aa70 update db 2026-07-05 21:16:42 +09:00
Dr.Lt.Data 6ad8a24845 fix(custom-node-list): §4.7-C-bis #3050 artokun/comfyui-mcp-panel — manual register (CONFLICTING) + node_db/new sync 2026-07-05 04:39:56 +09:00
Dr.Lt.Data ab8ce7814a update DB 2026-07-05 03:57:17 +09:00
Dr.Lt.Data 99d3e6ddac fix(custom-node-list): §4.2 MERGE_RELOCATE #3022 designloves2/ComfyUI-TJ_NODE_STUDIO_ONE — root relocate + node_db/new sync
- gh pr merge succeeded (MERGEABLE/UNSTABLE); entry landed at root idx 0
- Relocated to canonical position: after last git-clone (idx 5186, new author)
- node_db/new: prepended (top, chronological newest-first)
- Manual verification (post commented_with_response) confirmed:
  * Path Traversal @ nodes.py:576, 1150 FIXED (Path().resolve() + parent check via _safe_resolve_path helper)
  * Command Injection FIXED (subprocess.Popen shell=False + shutil.which)
  * Korean tooltip translated to English
  * Non-English sanity scan CLEAN
2026-07-05 03:19:42 +09:00
Dr.Lt.Data 84c889b026 Merge branch 'main' of github.com:Comfy-Org/ComfyUI-Manager 2026-07-05 03:18:32 +09:00
Dr.Lt.Data 966813eebf fix(custom-node-list): §4.7-C-bis #3003 A1-multiply/ComfyUI-LatentSaver — manual register, PR CONFLICTING
- CONFLICTING per gh — added directly via §4.7-C-bis
- Author responded to §4.7-D-code comment (2026-06-30): "changed all UI strings to English" @ dfec799
- Manual verification confirmed Korean tooltips at latent_saver.py:51/:55/:136 fully translated + repo-wide non-ASCII scan clean
- root: new author, appended after last git-clone (idx 5185)
- node_db/new: prepended (top)
2026-07-05 03:18:29 +09:00
Dr.Lt.Data c305db27fe Merge pull request #3022 from designloves2/add-comfyui-tj-node-studio-one
Add ComfyUI-TJ_NODE_STUDIO_ONE to custom-node-list
2026-07-05 03:18:07 +09:00
Dr.Lt.Data 62cb417cc1 fix(custom-node-list): §4.5 PATCH_BASE_CLOSE #2966 + #2978 — adopt PR descriptions
- #2966 ShootTheSound/comfyUI-Realtime-Lora: description update (adds Z-Image/Qwen/FLUX Klein/SD 1.5 + layer analyzers, selective block loaders, debiasing)
- #2978 AntiMatterComfy/antimatter-nodes: description update (adds video batch, LM Studio agents, text file appender)
- title/reference/files/install_type unchanged for both
- extension-node-map.json changes from #2978 not applied (Layer 1 out of scope)
2026-07-05 02:47:06 +09:00
Dr.Lt.Data bdab3680c4 fix(custom-node-list): §4.5 PATCH_BASE_CLOSE #3045 kgilper/krea-reference — adopt PR title/desc
- title: comfyui-krea-reference → ComfyUI Krea Reference
- description: PR wording (guide cards, per-image roles, stack encoder details)
- author (Kevin Gilper) / reference / files / install_type unchanged
- root + node_db/new sync
2026-07-05 01:16:09 +09:00
Dr.Lt.Data 293ea3a8c5 fix(custom-node-list): §4.7-C-bis #3042 Archerkattri (HiCache + TRELLIS2-HiCache) — manual register, PR CONFLICTING
- Both entries added adjacent to existing archerkattri author cluster (root idx 5109-5110)
- node_db/new: both prepended (top, chronological newest-first)
- HiCache: previously flagged Chinese tooltip resolved (1:1 code scan PASS, safe pool)
- TRELLIS2: verified_safe + code scan PASS
2026-07-05 01:15:02 +09:00
Dr.Lt.Data 5e6a02590b fix(custom-node-list): §4.2 MERGE_RELOCATE #3034 Stillfront/comfyui-sf-nodes — root relocate + node_db/new sync
- Root: relocated from mid-file (idx 354) to after last git-clone (idx 5180, new author)
- node_db/new: prepended (top, chronological newest-first)
- Verify SAFE per verification_results.json:safe_repos (deep verify manual-confirmed all static flags as false positives)
2026-07-05 01:12:37 +09:00
Dr.Lt.Data 0f4df5347c Merge pull request #3034 from ivorstrelarsf/main
Add SF ComfyUI Nodes by Stillfront
2026-07-05 01:11:45 +09:00
Dr.Lt.Data 9d7a3adc8a update DB 2026-07-05 01:10:15 +09:00
YunLong 977ba03dbd Register Anima-Tools custom node 2026-07-04 13:20:45 +08:00
Dr.Lt.Data 5cb9c828ad update DB 2026-07-04 00:53:33 +09:00
Dr.Lt.Data 28855800cd fix(custom-node-list): §4.2 MERGE_RELOCATE #3036 DutchyDutch/ComfyUI_Local_Wildcards — root add + node_db/new relocate
- Root: added at index 5182 (after last git-clone, new author)
- node_db/new: relocated from bottom to top (chronological newest-first, §4.2 Step 9), indent 6→4 space normalized
- Merge commit: 87882134 (gh pr merge)
2026-07-03 04:36:49 +09:00
Dr.Lt.Data 87882134e4 Merge pull request #3036 from DutchyDutch/add-comfyui-local-wildcards
Add ComfyUI Local Wildcards custom node
2026-07-03 04:34:37 +09:00
Dr.Lt.Data c903f8be4a update DB 2026-07-03 04:20:52 +09:00
Dr.Lt.Data 267449c55d fix(custom-node-list): §4.5 PATCH_BASE_CLOSE #3027 darth-veitcher/comfydv — adopt PR title/desc
- title: Comfy DV → Comfy DV Nodes
- description: PR wording (dynamic string formatting, seed-controlled random, workflow circuit-breaker)
- author/reference/files/install_type/id unchanged
- root only (not in node_db/new)
2026-07-03 04:14:45 +09:00
Dr.Lt.Data 6178d27b01 fix(custom-node-list): §4.5 PATCH_BASE_CLOSE #3029 gh055/ComfyUI-Dynamic-Combo — adopt PR title/desc
- title: Dynamic Text-to-Combo Generator → ComfyUI Dynamic Text-to-Combo Generator
- description: PR wording (Set/Get node setups)
- author/reference/files/install_type unchanged
- root + node_db/new sync
2026-07-03 01:57:15 +09:00
Dr.Lt.Data 4a21c02967 update DB 2026-07-03 01:46:05 +09:00
ukr8b3g-cmyk 71fd0affad Add Krea2 BBOX Prompter Suite 2026-07-02 23:36:14 +09:00
Dr.Lt.Data 8e06ee6552 update DB 2026-07-02 04:42:34 +09:00
DutchyDutch 19e0bd6592 Add ComfyUI Local Wildcards custom node 2026-06-30 20:02:34 +02:00
Dr.Lt.Data c4d775079b update DB 2026-07-01 02:51:31 +09:00
Dr.Lt.Data 7e80e39fdb fix(custom-node-list): place ketle-man/comfyui-mask-editor-one entry adjacent to existing ketle-man author block + sync to node_db/new (PR #2922) 2026-07-01 01:59:00 +09:00
Dr.Lt.Data 30e055f4f6 Merge pull request #2922 from ketle-man/add-comfyui-mask-editor-one
Add Mask Editor One
2026-07-01 01:57:59 +09:00
Dr.Lt.Data 8183e23858 update DB 2026-07-01 01:54:22 +09:00
Dr.Lt.Data e99a2b7f4c feat(custom-node-list): §4.7-C-bis manual register 4 CONFLICTING PRs (#3032/#3016/#2956/#2935) + sync 5 to node_db/new (PRs #3032/#3016/#3006/#2956/#2935) 2026-07-01 01:47:46 +09:00
Dr.Lt.Data ceac3168e4 Merge pull request #3006 from Nekodificador/fix/nkd-preview-tools-rename
Fix NKD Preview Tools entry: repo renamed + dedupe
2026-07-01 01:45:36 +09:00
Dr.Lt.Data c55592b332 update DB 2026-07-01 01:42:00 +09:00
Ivor Strelar afbc217f03 Add SF ComfyUI Nodes by Stillfront 2026-06-30 10:34:49 +02:00
楠 葛 94080278a5 Add Seedance 2.0 BytePlus custom node 2026-06-30 16:34:56 +09:00
Dr.Lt.Data 94a3ae8abd update DB 2026-06-30 05:17:52 +09:00
Dr.Lt.Data 66b3266488 fix(custom-node-list): place juangea/bs-comfyui-workflow-manager entry after last git-clone block + sync to node_db/new (PR #3015) 2026-06-30 05:06:51 +09:00
Dr.Lt.Data 9fa5b6918d Merge pull request #3015 from juangea/add-bone-studio-workflow-manager
Add Bone-Studio Workflow Manager
2026-06-30 05:05:19 +09:00
Dr.Lt.Data dbe14a93b9 update DB 2026-06-30 05:04:59 +09:00
Dr.Lt.Data 675440b387 update DB 2026-06-30 04:23:30 +09:00
Dr.Lt.Data dba1b7742b fix(scanner): fail-fast skip on GitHub rate limit (plain urllib3 Retry)
Replace PyGithub's default GithubRetry (which sleeps until rate-limit
reset and retries up to 10x) with a plain urllib3 Retry. A rate-limit
403/429 is no longer in status_forcelist, so it raises
RateLimitExceededException immediately; renew_stat's except-block
catches it and skips. Transient 5xx errors remain retried (D2).

[#1-sub-2]
2026-06-28 23:45:43 +09:00
Dr.Lt.Data c352b16bb1 update DB 2026-06-28 04:36:55 +09:00
designloves2 a2274577a1 Add ComfyUI-TJ_NODE_STUDIO_ONE to custom node list 2026-06-27 17:05:13 +09:00
Dr.Lt.Data 8da299d978 fix(custom-node-list): §4.3 MERGE_DEDUP #3007 (relocate + remove old base) and #3009 (replace with PR entry + id field) 2026-06-27 02:24:52 +09:00
Dr.Lt.Data 36aa47f8bf Merge pull request #3007 from monkeykim111/add-comfyui-image-safety-gate
Add ComfyUI-Image-Safety-Gate
2026-06-27 02:22:27 +09:00
Dr.Lt.Data d35a732216 update DB 2026-06-27 02:01:29 +09:00
Dr.Lt.Data fedd2112e0 feat(custom-node-list): §4.7-C-bis manual register 4 CONFLICTING PRs + sync 5 to node_db/new (PRs #3018/#3001/#2995/#2912/#2989) 2026-06-27 01:42:52 +09:00
Dr.Lt.Data 6dc448c813 Merge pull request #3018 from kwokkakiu233/main
Add LoRA Txt Loader node
2026-06-27 01:39:02 +09:00
Dr.Lt.Data 673f4ac4f5 update DB 2026-06-26 20:39:39 +09:00
kwokkakiu233 918919c6dc Add LoRA Txt Loader node 2026-06-25 19:58:04 +08:00
Juan GeaandClaude Opus 4.8 77baaecb76 Add Bone-Studio Workflow Manager to custom-node-list.json
A published Comfy Registry node (publisher: bonestudio), same author as the
existing "Bone-Studio Model Manager". Organizes ComfyUI workflows for production
(folder manager + projects, bulk export, optional Git), GPLv3.

Repo: https://github.com/juangea/bs-comfyui-workflow-manager
Registry: https://registry.comfy.org/nodes/bs-comfyui-workflow-manager

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:07:14 +02:00
Dr.Lt.Data 049a53f3d5 update DB 2026-06-25 02:46:23 +09:00
Dr.Lt.Data 8ff2ba8ffe update db 2026-06-24 03:40:11 +09:00
Dr.Lt.Data b6db1ac854 update DB 2026-06-22 02:56:24 +09:00
Dr.Lt.Data f697116029 Merge pull request #3011 from alexanderar/add-ltx-2.3-models
feat(models): Add LTX-2.3 22B model family.
2026-06-22 02:00:20 +09:00
aartyomov 03861f48c9 feat(models): add LTX-2.3 22B model family
Adds 11 models from Lightricks LTX-2.3:
- 5 checkpoints: dev, distilled 1.1, dev fp8, distilled fp8, dev nvfp4
- 3 LoRAs: distilled lora 1.1, IC-LoRA union control, IC-LoRA motion track
- 3 upscalers: spatial x2 1.1, spatial x1.5, temporal x2
2026-06-21 11:13:53 +00:00
Dr.Lt.Data 579c4f9493 update DB 2026-06-20 17:07:35 +09:00
Dr.Lt.Data 9e3a5695a7 update DB 2026-06-20 03:06:51 +09:00
jonathan f92ce59d4b Add ComfyUI-Image-Safety-Gate 2026-06-18 16:22:31 +09:00
Nekodificador d6c6ac2559 Fix NKD Preview Tools entry: repo renamed + dedupe
The repo github.com/Nekodificador/ComfyUI-NKD-Popup-Preview was renamed to
ComfyUI-NKD-Preview-Tools (GitHub redirects old→new). The list still
referenced the old name and had a duplicate entry, so older Manager
installs tracked the orphaned name and stopped seeing updates.

- Point reference/files to the current repo URL
- Update title/description to match the published package
- Remove the duplicate entry

The node is also published on the Comfy Registry as
`comfyui-nkd-preview-tools`.
2026-06-17 17:57:05 +02:00
Dr.Lt.Data 2e93040db5 feat(custom-node-list): §4.7-C-bis manual register 6 CONFLICTING PRs (#2976/#2971/#2964/#2961/#2953/#2941) 2026-06-17 02:05:11 +09:00
Dr.Lt.Data 6165515a08 fix(custom-node-list): relocate 3 new-author entries after last git-clone block + sync 6 entries to node_db/new (PRs #2986/#2983/#2990/#2975/#2968) 2026-06-17 02:01:40 +09:00
Dr.Lt.Data 0fde8b155b Merge pull request #2968 from sorryhyun/add-anima-pid-easycontrol
add Anima PiD and EasyControl node
2026-06-17 01:59:22 +09:00
Dr.Lt.Data 062a164e47 Merge pull request #2975 from rookiestar28/add-longcat-avatar-node
Add ComfyUI LongCat Avatar node
2026-06-17 01:59:16 +09:00
Dr.Lt.Data e7ab2f5858 Merge pull request #2983 from elowbe/main
Add comfyui-save-text
2026-06-17 01:59:11 +09:00
Dr.Lt.Data 92e3fa5062 Merge pull request #2986 from vantang/add-comfyui-mediahub
Register ComfyUI MediaHub
2026-06-17 01:59:07 +09:00
Dr.Lt.Data 90fdb64a67 Merge pull request #2990 from hypnichorse/main
Added Hypnodes to the registry/node list
2026-06-17 01:57:50 +09:00
Dr.Lt.Data b4cdeb2297 update DB 2026-06-17 01:22:35 +09:00
Dr.Lt.Data 3a48b149bb feat(custom-node-list): register 4 SAFE candidates (skbv0/palettedirector, akatz-ai/seamless-tiling, 1girluniversity/scail2-longvideocontext, systms-ai/timeslice-nodes) 2026-06-17 01:15:50 +09:00
Dr.Lt.Data e886d03d31 fix(custom-node-list): archive 2 inaccessible nodes (CrazyDashTool/Combine-video-and-audio-comfyUI, MichaelMaxAgent/comfyui_ML_nodes) 2026-06-17 01:14:40 +09:00
Dr.Lt.Data 73e2f53d2c feat(custom-node-list): register bs-comfyui-model-manager (verified SAFE) 2026-06-17 00:09:15 +09:00
Dr.Lt.Data d6b1e4980a feat(node_db/dev): register bs-comfyui-model-manager (NO NODES, verified SAFE) 2026-06-17 00:07:21 +09:00
Dr.Lt.Data e4c5401dd5 feat(security): dedicated install flags decoupled from security_level (#2991)
* feat(security): add dedicated install flags decoupled from security_level

Gate 'install via git URL' and 'install via pip' with dedicated opt-in
boolean flags (allow_git_url_install / allow_pip_install) in config.ini
[default], fully replacing the security_level term on those surfaces
(REPLACE, not AND — a strict level no longer denies when the flag is on;
a weak level no longer allows when the flag is off).

- glob/manager_server.py: pure predicate is_dedicated_install_allowed
  (flag AND loopback, request-time args.listen); REPLACE gates at
  /customnode/install/git_url and /customnode/install/pip; batch
  unknown-URL arm routes through the same full predicate at the risky
  position (loopback term is load-bearing — the middle entry gate has
  no network-position term; the entry gate itself stays in force);
  unknown-pip in batch stays unconditionally blocked; new
  SECURITY_MESSAGE_FLAG_* denial constants name the responsible flag;
  security_403_response gains flag_token (comfyui_outdated keeps precedence)
- glob/manager_core.py: register both keys (read via get_bool default-false,
  write list, exception fallback); "true"-only truthy; restart-only activation
- js/common.js: 403 dialog copy names the responsible flag at the two
  install call sites
- README.md: security-policy docs for both flags (per-surface scope incl.
  the batch entry-gate qualifier, REPLACE decoupling, loopback bound,
  opt-in config snippet, default-deny + migration note); stale tier lists
  corrected against the actual gates
- CHANGELOG.md: opt-in migration note + accepted residual risk (flags
  bypass the forced-strong outdated-ComfyUI hardening on loopback,
  opt-in only), decoupling claim qualified for the batch entry gate

Tests: unit suite (predicate truth table, REPLACE litmus both directions,
AST binding-proofs against live handlers, subprocess-isolated config
contract) plus a real-server E2E suite that mounts the Manager-under-test
via git worktree (exact-SHA pin, detached) against a real ComfyUI and
exercises both flag surfaces and both arms — deny arms (403 + flag-naming
body/log + no install artifact), git-URL allow arm (real clone), pip allow
arm as a two-phase reservation oracle — with zero-residual self-clean.
Module skips without E2E_COMFYUI_ROOT; unit suite unaffected.

The manager-v4 branch ships the identical policy (shared invariants +
config contract); this tree uses the degraded predicate 'flag AND
loopback' (no personal_cloud-equivalent mode here).

* bump version to v3.41
2026-06-16 03:34:10 +09:00
Dr.Lt.Data 88a7c52410 update DB 2026-06-16 02:15:13 +09:00
rookiestar28 40aa097706 Add ComfyUI-SCAIL-Pose2 custom node 2026-06-15 09:17:11 +08:00
hypnichorse de560aed84 Fixed typo 2026-06-14 21:39:12 +02:00
hypnichorse dc15dcf83c Fixed Array 2026-06-14 21:34:51 +02:00
hypnichorse c780c600b5 Added Hypnodes To Node-list 2026-06-14 19:34:20 +02:00
hypnichorse eb479ff891 Added Hypnodes to the registry 2026-06-14 19:22:11 +02:00
Dr.Lt.Data 3772432847 update DB 2026-06-14 23:52:11 +09:00
Van/攻城狮鹿鸣君 86a7d88270 Register ComfyUI MediaHub 2026-06-13 23:22:26 +08:00
Elowbe Software 89809b29d3 Update custom-node-list.json 2026-06-11 19:25:45 -04:00
Terry JiaandTerry Jia 194bcedcfc fix(graphToPrompt): forward arguments to the wrapped function (#2982)
Co-authored-by: Terry Jia <terry@comfy.org>
2026-06-12 07:05:30 +09:00
yunqiankuangyu 4ab3d1631e Add comfyui-anima-lora-comparison to node list 2026-06-11 17:34:59 +08:00
Dr.Lt.Data d60e603467 update DB 2026-06-11 01:41:30 +09:00
rookiestar28 6428a1009d Add ComfyUI LongCat Avatar node 2026-06-10 23:13:28 +08:00
designloves2 efa2a46689 Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	custom-node-list.json
2026-06-10 18:00:16 +09:00
Seunghyun Ji c4daf1dffd add Anima PiD and EasyControl node 2026-06-08 19:41:41 +09:00
shootthesound 396308854d Add Lighthouse, CleanFreak, Finding LoRA, Image of the Day to custom-node-list 2026-06-07 19:29:39 +01:00
Dr.Lt.Data 4a1c06b90a update DB 2026-06-07 02:51:09 +09:00
Dr.Lt.Data 395bb24427 update DB 2026-06-05 06:08:54 +09:00
Dr.Lt.Data 25c1cc0921 feat(node_db/dev): register 4 NAME CONFLICT candidates (verified SAFE) 2026-06-05 05:54:28 +09:00
Dr.Lt.Data 629ccba819 fix(custom-node-list:new): sync comfyui-daz-tools entry 2026-06-05 03:47:38 +09:00
Dr.Lt.Data 3d01524ee9 fix(custom-node-list): place comfyui-daz-tools entry after last git-clone block 2026-06-05 03:47:07 +09:00
Dr.Lt.Data 81c3c3d62c Merge pull request #2947 from denyazzolin/add-comfyui-daz-tools
Adding ComfyUI-Daz-Tools to Custom Node List
2026-06-05 03:45:42 +09:00
Dr.Lt.Data 1aa6112c57 update DB 2026-06-05 03:44:10 +09:00
Dr.Lt.Data c571cf3ca9 update DB 2026-06-05 00:49:06 +09:00
denyazzolin 4085957308 Add comfyui-daz-tools to custom node list 2026-06-03 21:22:46 -04:00
Dr.Lt.Data 7ddad11d28 update DB 2026-06-04 03:05:11 +09:00
Dr.Lt.Data 142f4ce365 update DB 2026-06-04 01:59:12 +09:00
Dr.Lt.Data 3ec831a99b update DB 2026-06-03 07:27:35 +09:00
Dr.Lt.Data 2d373448be update DB 2026-06-01 20:16:09 +09:00
Dr.Lt.Data 56a54117ab update DB 2026-06-01 03:22:14 +09:00
Dr.Lt.Data dfa45a67e0 update DB 2026-06-01 03:16:10 +09:00
Dr.Lt.Data 0ccaf67ed9 Merge pull request #2902 from robomello/add-nvml-monitor
Add ComfyUI-NVML-Monitor
2026-06-01 03:08:37 +09:00
Dr.Lt.Data 73686b372b Merge pull request #2073 from millerlight/millerlight-patch-1
Millerlight patch 1
2026-06-01 03:08:34 +09:00
Dr.Lt.Data cb46064d25 update DB 2026-06-01 02:34:46 +09:00
Dr.Lt.Data a0f4d401ec update DB 2026-06-01 02:26:10 +09:00
Dr.Lt.Data d6f480c911 update DB 2026-05-30 03:25:33 +09:00
designloves2 5976e39d80 Update custom-node-list.json 2026-05-28 15:41:30 +09:00
Dr.Lt.Data 7d611c051e update DB 2026-05-28 02:15:37 +09:00
ketle-man 65cf3dbf5c Add Mask Editor One (ketle-man/comfyui-mask-editor-one) 2026-05-27 18:38:07 +09:00
Dr.Lt.Data 66617e061c update DB 2026-05-27 02:16:39 +09:00
Dr.Lt.Data c72bc4e171 update DB 2026-05-26 08:56:59 +09:00
Dr.Lt.Data ef470d0119 update DB 2026-05-26 07:50:12 +09:00
Dr.Lt.Data e633858794 update DB 2026-05-25 08:02:37 +09:00
Dr.Lt.Data 765e64ff1d update DB 2026-05-25 07:51:37 +09:00
Dr.Lt.Data 1bed21771f update DB 2026-05-25 07:41:09 +09:00
Dr.Lt.Data 36144e5608 update DB 2026-05-25 07:31:55 +09:00
Dr.Lt.Data 10f23c1d19 update DB 2026-05-25 07:28:47 +09:00
Dr.Lt.Data 5b17ff01bd Merge pull request #2884 from SOLRICKS/remove-legacy-msxyz-listing
Remove legacy MSXYZ node listing
2026-05-25 07:14:26 +09:00
Dr.Lt.Data 8814191992 update DB 2026-05-24 21:12:10 +09:00
Dr.Lt.Data b717647e9c Merge pull request #2898 from Anonymzx/main
Update BangtrixToolkit entry, tag version and description
2026-05-24 21:11:31 +09:00
Dr.Lt.Data 0151751ab5 Merge pull request #2892 from AntiMatterComfy/codex/update-antimatter-batch-loader-description
Update AntiMatter Nodes description
2026-05-24 21:11:18 +09:00
Dr.Lt.Data 4bab2faa5e update DB 2026-05-24 03:29:27 +09:00
robomello a0c4c78e9e Add ComfyUI-NVML-Monitor 2026-05-22 19:51:26 -05:00
Anonymzx bfec5e4ca8 Update BangtrixToolkit entry and description 2026-05-22 00:58:49 +07:00
Dr.Lt.Data bf5c346428 update DB 2026-05-21 05:53:45 +09:00
AntiMatterComfy eb57f6f6b5 Map AntiMatter batch loader node 2026-05-20 10:37:00 +03:00
AntiMatterComfy 1d4d74f745 Update AntiMatter Nodes description 2026-05-20 10:35:58 +03:00
Dr.Lt.Data 928870f598 update DB 2026-05-20 08:39:29 +09:00
Dr.Lt.Data 7110ec1eaa update DB 2026-05-20 08:16:23 +09:00
Dr.Lt.Data 2483471541 fix(custom-node-list): dedup antimatter-nodes (#2891), keep PR entry with id field and enriched description 2026-05-20 08:15:42 +09:00
Dr.Lt.Data aa747eec52 Merge pull request #2891 from AntiMatterComfy/codex/add-antimatter-nodes
Add AntiMatter Nodes
2026-05-20 08:14:48 +09:00
Dr.Lt.Data da873b5f3b update DB 2026-05-20 00:35:20 +09:00
AntiMatterComfy ade66eb61d Update AntiMatter Nodes description 2026-05-19 14:29:58 +03:00
AntiMatterComfy 703438251b Add AntiMatter Nodes 2026-05-19 14:26:00 +03:00
Dr.Lt.Data 7cbd62c8bd update DB 2026-05-19 18:50:44 +09:00
Dr.Lt.Data d0cc0e4fe9 update DB 2026-05-19 13:01:16 +09:00
Dr.Lt.Data 9ad16c0188 update DB 2026-05-18 05:39:11 +09:00
Dr.Lt.Data a2c41a2a21 update DB 2026-05-17 10:10:59 +09:00
Dr.Lt.Data 9690eed4d1 fix(custom-node-list): place ComfyUI-Pixal3D entry after last git-clone block 2026-05-17 10:10:47 +09:00
Dr.Lt.Data 5d1d287735 Merge pull request #2874 from dreamrec/add-comfyui-pixal3d
Add ComfyUI-Pixal3D to custom-node-list.json
2026-05-17 10:10:18 +09:00
Dr.Lt.Data 65789a6c9d update DB 2026-05-17 03:47:43 +09:00
Dr.Lt.Data 871a646fd7 update DB 2026-05-16 22:10:31 +09:00
Dr.Lt.Data 27f81a058e fix(custom-node-list): place PlagueKind-Nodes entry after last git-clone block 2026-05-16 22:10:16 +09:00
Dr.Lt.Data af11571dd7 Merge pull request #2875 from PlagueKind/main
Add PlagueKind Nodes to ComfyUI-Manager registry
2026-05-16 22:09:39 +09:00
Dr.Lt.Data f59dd4bdc6 update DB 2026-05-16 21:42:09 +09:00
Dr.Lt.Data 90b87db05c fix(custom-node-list): dedup nicehero/comfyui-conditioning-saver (#2883), keep PR entry with id field and proper-case title 2026-05-16 21:41:10 +09:00
Dr.Lt.Data d6a9d6544d Merge pull request #2883 from nicehero/main
Add [comfyui-conditioning-saver](https://github.com/nicehero/comfyui-conditioning-saver) to the custom node list.
2026-05-16 21:40:50 +09:00
Dr.Lt.Data 42b028362e Merge pull request #2882 from ketle-man/add-model-and-prompt-from-metadata
Add Model and Prompt from Metadata node
2026-05-16 21:40:17 +09:00
Dr.Lt.Data dcf04578b8 fix(custom-node-list): place ComfyUI-Magos-Nodes entry after last git-clone block 2026-05-16 21:39:55 +09:00
Dr.Lt.Data 3eceec5bfa Merge pull request #2878 from MagosDigitalStudio/main
Add ComfyUI-Magos-Nodes
2026-05-16 21:39:19 +09:00
Dr.Lt.Data 2c0d665929 fix(custom-node-list): place BangtrixToolkit entry after last git-clone block 2026-05-16 21:38:56 +09:00
Dr.Lt.Data 7fa009e9da Merge pull request #2877 from Anonymzx/main
add BangtrixToolkit to custom nodes list
2026-05-16 21:38:24 +09:00
Dr.Lt.Data 7cf155b2fe Merge pull request #2868 from pmarmotte2/register-comfyui-pick-any
Add Comfyui-Pick_Any to custom node list
2026-05-16 21:37:55 +09:00
Dr.Lt.Data 350ff2d044 fix(custom-node-list): place XB_ToolBox entry after last git-clone block 2026-05-16 21:37:31 +09:00
Dr.Lt.Data f64b73fc57 Merge pull request #2807 from wjluoxiao/main
Add custom node: XB_ToolBox
2026-05-16 21:36:47 +09:00
Dr.Lt.Data 1958957d36 fix(custom-node-list): place ComfyUI-PuLID-Flux-Chroma entry after last git-clone block 2026-05-16 21:35:49 +09:00
Dr.Lt.Data f3236c3031 Merge pull request #1935 from PaoloC68/main
Add ComfyUI-PuLID-Flux-Chroma - First PuLID implementation for Chroma models
2026-05-16 21:32:03 +09:00
Dr.Lt.Data fcbad506bb Merge pull request #1913 from flrngel/patch-1
Add ComfyUI_rgbx_xrgb_Wrapper
2026-05-16 21:31:21 +09:00
Dr.Lt.Data 934fd2e091 update DB 2026-05-16 19:27:39 +09:00
ketle-man 5de92e4f92 Merge upstream/main - resolve conflict 2026-05-16 14:36:37 +09:00
Dr.Lt.Data 3ae1fe6ea6 update DB 2026-05-16 10:17:57 +09:00
Mustafa Sahin 19f33695ff chore: remove legacy MSXYZ listing 2026-05-15 20:50:27 +03:00
nicehero 8ea1cf7aa7 add comfyui-conditioning-saver 2026-05-15 13:55:48 +08:00
ketle-manandClaude Sonnet 4.6 936dc09f22 Add Model and Prompt from Metadata (ketle-man)
- Add new entry: model-and-prompt-from-metadata
- Remove duplicate comfyui-2dpose-editor entry (leftover from previous closed PR)
- Add node mappings to extension-node-map.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 11:03:50 +09:00
ketle-man 5a19558c01 Merge branch 'Comfy-Org:main' into main 2026-05-15 10:58:06 +09:00
MagosDigitalStudio 2513b3bff1 Add ComfyUI-Magos-Nodes 2026-05-14 12:58:28 +03:00
Anonymzx 919c07f1e1 add BangtrixToolkit to custom nodes list 2026-05-14 16:20:30 +07:00
PlagueKind 8f646e92a0 Add PlagueKind Nodes 2026-05-14 02:09:09 +02:00
silviu 8d9eeb93f4 Add ComfyUI-Pixal3D — Pixal3D image-to-3D for Windows + RTX 30/40/50
ComfyUI custom node wrapping Tencent's SIGGRAPH 2026 Pixal3D pipeline.
Registers 3 nodes (Pixal3DLoadPipeline, Pixal3DImageToMesh, Pixal3DFreePipeline)
in extension-node-map.json so the Manager's "Install Missing Custom Nodes"
flow can find them.

Repo: https://github.com/dreamrec/ComfyUI-Pixal3D
2026-05-13 22:47:29 +03:00
Dr.Lt.Data 4f7f26da3b update DB 2026-05-14 04:21:55 +09:00
ketle-man 299976330f Merge branch 'Comfy-Org:main' into main 2026-05-12 13:04:58 +09:00
Dr.Lt.Data 3b2f6fd149 fix(custom-node-list): move locale-pending entries (ComfyUI-VolcEngine, comfyui-image-feeder) from default to dev channel pending English localization 2026-05-12 01:20:18 +09:00
Pmarmotte ea3dcde0ca Add Comfyui-Pick_Any custom node 2026-05-11 14:37:29 +02:00
Dr.Lt.Data c2a33d2efc fix(custom-node-list): place ComfyUI Image Feeder entry next to existing ketle-man author block 2026-05-11 20:40:26 +09:00
Dr.Lt.Data d9ac595785 update DB 2026-05-11 20:39:43 +09:00
Dr.Lt.Data b15c12ca4e update DB 2026-05-11 20:39:13 +09:00
Dr.Lt.Data dc0f66f042 update DB 2026-05-11 20:38:32 +09:00
Dr.Lt.Data 3d1210aa52 fix(custom-node-list): place ComfyUI-VolcEngine entry after last git-clone block 2026-05-11 20:37:52 +09:00
Dr.Lt.Data d16ba306be Merge pull request #2801 from gzsiang/main
Add ComfyUI-VolcEngine to custom node list
2026-05-11 20:37:12 +09:00
Dr.Lt.Data 1e409cdd11 fix(custom-node-list): place ComfyUI-MaskToTransparent entry next to existing nekodificador author block 2026-05-11 20:36:57 +09:00
Dr.Lt.Data 40e27f8eb4 Merge pull request #2837 from Warningning/main
Add ComfyUI-MaskToTransparent node entry
2026-05-11 20:36:09 +09:00
Dr.Lt.Data 6cc7f3106b fix(custom-node-list): place ComfyUI-BodyRatioMapper entry after last git-clone block 2026-05-11 20:35:19 +09:00
Dr.Lt.Data e7e36be084 Merge pull request #2864 from wuwukaka/main
Add ComfyUI-BodyRatioMapper custom node
2026-05-11 20:34:28 +09:00
Dr.Lt.Data 6c9dd4be42 fix(custom-node-list): place ComfyUI-DCW entry after last git-clone block 2026-05-11 20:34:18 +09:00
Dr.Lt.Data 8f4e7b089b Merge pull request #2830 from namemechan/main
Add: ComfyUI-DCW (SNR-t bias correction)
2026-05-11 20:33:37 +09:00
Dr.Lt.Data 02dcb1dd60 fix(custom-node-list): correct oath-studio files[0] to repo URL and place Claude CLI entry after last git-clone block 2026-05-11 20:32:34 +09:00
Dr.Lt.Data c0d5b4e720 Merge pull request #2813 from RandyHaylor/add-comfyui-claude-cli-vision-text-node
Add ComfyUI Claude CLI (Vision+Text) node
2026-05-11 20:30:07 +09:00
Dr.Lt.Data 72db8aed73 fix(custom-node-list): place oath-studio entries after last git-clone block 2026-05-11 20:29:56 +09:00
Dr.Lt.Data 0802a88354 Merge pull request #2725 from wes-kay/adding-nodes
Adding nodes
2026-05-11 20:28:12 +09:00
Dr.Lt.Data 2b7aa428b0 update DB 2026-05-11 20:14:16 +09:00
Dr.Lt.Data d61ef2eaba Merge branch 'main' of github.com:Comfy-Org/ComfyUI-Manager 2026-05-11 20:13:18 +09:00
Dr.Lt.Data fcc429e686 update DB 2026-05-11 20:13:11 +09:00
Dr.Lt.Data d255e36ab6 Merge pull request #2862 from machinepainting/add-machinepainting-pack
Add MachinePainting Nodes to custom-node-list.json
2026-05-11 20:12:58 +09:00
Dr.Lt.Data 3e5c680f38 Merge pull request #2856 from SOLRICKS/main
Update SOLRICKS node listing
2026-05-11 20:07:31 +09:00
Dr.Lt.Data cba745327f update DB 2026-05-11 20:03:01 +09:00
Dr.Lt.Data 8079db221d update DB 2026-05-11 04:18:53 +09:00
Dr.Lt.Data 5727dc7c55 update DB 2026-05-11 03:59:36 +09:00
Dr.Lt.Data c06ff81821 fix(custom-node-list): restore xss + Image2Halftone entries and place comfymodeldownloader entry after last git-clone block 2026-05-11 03:58:07 +09:00
Dr.Lt.Data 10d48c2670 Merge pull request #2866 from thomaskippster/main
Add ComfyUI Model Downloader Bridge to custom-node-list.json
2026-05-11 03:56:43 +09:00
Dr.Lt.Data b824811bc7 fix(custom-node-list): place BizyAirPlus entry after last git-clone block 2026-05-11 03:56:29 +09:00
Dr.Lt.Data f9603624a9 Merge pull request #2803 from hahalfx/add-bizyairplus
Update custom-node-list.json for adding new custom-node BizyAirPlus
2026-05-11 03:55:55 +09:00
Dr.Lt.Data cb17d1c848 fix(custom-node-list): place ComfyUI-Styler-Pipeline entry next to existing andreszs author block 2026-05-11 03:55:40 +09:00
Dr.Lt.Data 244421458d Merge pull request #2800 from andreszs/add-styler-pipeline
Add ComfyUI-Styler-Pipeline to custom-node-list.json
2026-05-11 03:55:10 +09:00
Dr.Lt.Data 49218fdb57 fix(custom-node-list): place OpenPose-Studio + Ultralytics-Studio entries next to existing andreszs author block 2026-05-11 03:54:53 +09:00
Dr.Lt.Data df1505efda Merge pull request #2798 from andreszs/add-openpose-ultralytics-studio
Add ComfyUI-OpenPose-Studio and ComfyUI-Ultralytics-Studio to custom-node-list.json
2026-05-11 03:54:10 +09:00
Dr.Lt.Data 96ae4e0c9f fix(custom-node-list): place ComfyUI-AnimaTool entry next to existing Moeblack author block 2026-05-11 03:53:55 +09:00
Dr.Lt.Data f94902966a Merge pull request #2573 from Moeblack/add-comfyui-animatool
Add ComfyUI-AnimaTool
2026-05-11 03:53:07 +09:00
Dr.Lt.Data b4d5d972de update DB 2026-05-11 03:31:37 +09:00
Dr.Lt.Data 3d7f7d2439 fix(custom-node-list): place ComfyUI-MotifVideo2B entry after last git-clone block 2026-05-11 03:27:55 +09:00
Dr.Lt.Data 2ae10236b9 Merge pull request #2829 from MotifTechnologies/feat/add-motifvideo2b
Add ComfyUI-MotifVideo2B to custom-node-list.json
2026-05-11 03:27:22 +09:00
Dr.Lt.Data 6ffead7ba9 fix(custom-node-list): place ComfyUI-Pixaroma entry after last git-clone block 2026-05-11 03:27:08 +09:00
Dr.Lt.Data 9a17c96da6 Merge pull request #2821 from pixaroma/add-pixaroma
Add ComfyUI-Pixaroma to custom-node-list.json
2026-05-11 03:26:36 +09:00
Dr.Lt.Data 39dfa902d5 fix(custom-node-list): place Kai Civitai LoRA Loader entry after last git-clone block 2026-05-11 03:26:23 +09:00
Dr.Lt.Data e4fadee042 Merge pull request #2814 from kenyonxu/main
Add Kai Civitai LoRA Loader to custom node list
2026-05-11 03:25:49 +09:00
Dr.Lt.Data d9f1ff34a7 fix(custom-node-list): place ComfyUI-SBTools entry after last git-clone block 2026-05-11 03:25:34 +09:00
Dr.Lt.Data 9aa2ca09b8 Merge pull request #2806 from Amatsukast/add-sbtools
Add ComfyUI-SBTools
2026-05-11 03:25:01 +09:00
Dr.Lt.Data 81605d31db fix(custom-node-list): remove trailing comma after last entry 2026-05-11 03:24:39 +09:00
Dr.Lt.Data bdd6111d9d fix(custom-node-list): place Z-Fuse entry after last git-clone block 2026-05-11 03:24:08 +09:00
Dr.Lt.Data a4b6a589d7 Merge pull request #2440 from destinyfaux/main
Z-Fuse
2026-05-11 03:23:30 +09:00
Dr.Lt.Data 611532491f fix(custom-node-list): place ComfyUI-Workflow-Studio entry next to existing ketle-man author block 2026-05-11 01:33:43 +09:00
Dr.Lt.Data ed8baf3e79 Merge pull request #2706 from ketle-man/add-workflow-studio
Add ComfyUI-Workflow-Studio
2026-05-11 01:33:09 +09:00
Dr.Lt.Data 111e4d38f4 fix(custom-node-list): place ComfyUI-PulseOfMotion entry after last git-clone block 2026-05-11 01:32:43 +09:00
Dr.Lt.Data e6693e0b9f Merge pull request #2742 from akashzeno/add-pulse-of-motion
Add ComfyUI-PulseOfMotion to custom node list
2026-05-11 01:32:04 +09:00
Dr.Lt.Data 0c8cdf9aa8 update DB 2026-05-11 01:15:02 +09:00
Dr.Lt.Data 8d2d37f1f7 fix(custom-node-list): place Happyhorse-Wrapper + Account-Manager entries next to existing magicwang1111 author block 2026-05-11 01:03:31 +09:00
Dr.Lt.Data bc334c7aa5 Merge pull request #2835 from magicwang1111/codex/register-magicwang-nodes
add  ComfyUI-Happyhorse-Wrapper and ComfyUI-Account-Manager
2026-05-11 01:01:56 +09:00
Dr.Lt.Data 33d4b29422 update DB 2026-05-11 00:52:13 +09:00
Dr.Lt.Data 20d352c2b5 update DB 2026-05-11 00:16:15 +09:00
Thomas 117227e2fc Add ComfyUI Model Downloader Bridge to custom-node-list.json 2026-05-09 20:53:50 +02:00
Dr.Lt.Data 7a8fc44f90 update DB 2026-05-08 19:07:12 +09:00
wuwukasi 49b038bf69 Add ComfyUI-BodyRatioMapper custom node 2026-05-08 14:42:47 +08:00
MachinePainting 1eda1d65ad Add MachinePainting Nodes to custom-node-list.json 2026-05-07 21:18:28 -07:00
pixaroma 10ff7d091c Update ComfyUI-Pixaroma description to current feature set 2026-05-06 06:53:21 +03:00
Mustafa Sahin 282025ed0e Remove editor settings from PR 2026-05-06 00:56:09 +03:00
Mustafa Sahin 2bcc052700 Update SOLRICKS node listing 2026-05-06 00:46:04 +03:00
Mustafa Sahin 30454742b3 Update MSYNTRIX node listing 2026-05-05 14:12:08 +03:00
ketle-man 5162e1d601 Merge branch 'Comfy-Org:main' into main 2026-05-02 21:23:26 +09:00
Dr.Lt.Data 8d5c12037f fix(custom-node-list): place DemonAlone-StyleSelector entry next to existing DemonAlone author block 2026-05-01 05:01:14 +09:00
Dr.Lt.Data d370403e2d Merge pull request #2827 from DemonAlone/Styler
Add DemonAlone-StyleSelector-ComfyUI  to custom-node-list.json
2026-05-01 05:00:39 +09:00
Dr.Lt.Data e67e35795e fix(custom-node-list): place JH-PixelPro entry after last git-clone block 2026-05-01 05:00:29 +09:00
Dr.Lt.Data ccf6d98457 Merge pull request #2809 from jetthuangai/add-comfyui-jh-pixelpro
Add ComfyUI-JH-PixelPro
2026-05-01 04:59:51 +09:00
Dr.Lt.Data 113324728e fix(custom-node-list): place Emiewn-Nodes entry after last git-clone block 2026-05-01 04:59:31 +09:00
Dr.Lt.Data f2a3d39e26 Merge pull request #2805 from emiewnn/emiewnnodes
Add ComfyUI-Emiewn-Nodes
2026-05-01 04:58:21 +09:00
Dr.Lt.Data ec72d983ad update DB 2026-05-01 04:45:57 +09:00
Dr.Lt.Data a3aabfd72c Merge pull request #2811 from Kyreo/main
Add ComfyUI-CharacterPromptBuffer by Kyreo
2026-05-01 04:42:19 +09:00
Dr.Lt.Data 03272b1f70 update DB 2026-04-30 02:24:55 +09:00
Warningning 9686bae08a Add ComfyUI-MaskToTransparent node entry
A simple mask-based transparency tool for ComfyUI. Turns black mask areas into fully transparent pixels while preserving white areas, with edge feathering support. Perfect for object extraction after semantic segmentation with SAM 3.1 models.

✨ Features
✅ Automatically makes black mask areas transparent, preserves original image in white areas
✅ Customizable Gaussian edge feathering to remove jagged edges
✅ Supports preserving or overwriting original alpha channel
✅ Fully compatible with RGB & RGBA images
✅ Ideal for extracting segmented objects after using SAM 3.1 models, removing unwanted background pixels
2026-04-29 15:38:34 +08:00
magicwang1111 9b8f651061 Register magicwang1111 custom nodes 2026-04-28 18:11:01 +08:00
Dr.Lt.Data 66108ccdbc update DB 2026-04-27 05:28:49 +09:00
namemechan d8095b527f Add: ComfyUI-DCW (SNR-t bias correction) 2026-04-26 11:55:29 +09:00
Minsu Ha 80efc5dbc0 Add ComfyUI-MotifVideo2B to custom-node-list.json 2026-04-25 11:03:17 +00:00
DemonAlone abf9c654b3 AddDemonAlone-StyleSelector-ComfyUI
to custom-node-list.json
2026-04-24 16:24:32 +03:00
pixaroma 5656002524 Add ComfyUI-Pixaroma to custom-node-list.json 2026-04-22 09:48:32 +03:00
pixaroma 484bb2fe4c Add ComfyUI-Pixaroma to custom-node-list.json 2026-04-22 09:47:16 +03:00
wjluoxiao a129cc6321 style: finalize internationalization for title and description 2026-04-22 06:52:05 +08:00
Dr.Lt.Data 491f847bbc fix(security): harden CSRF with Content-Type gate and OpenAPI sync (#2819)
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 5 no-body POST handlers (snapshot/save,
manager/queue/{reset,start,update_comfyui}, manager/reboot) to block
<form method=POST> CSRF that bypasses method-only gating. Convert 10 pure
state-changing endpoints (fetch_updates, queue/{update_all,reset,start,
update_comfyui}, snapshot/{remove,restore,save}, comfyui_switch_version,
reboot) from GET to POST and split 5 config endpoints
(db_mode/preview_method/channel_url_list/policy/{component,update}) into
GET(read) + POST(write, JSON body). Emit the in_progress + done event pair
from the /manager/queue/install sync-enable fast-path so client UI
finalizes (previously only queue/start's empty worker done fired, leaving
item.restart unset and the Enable button visible after a successful enable).
Harden js/custom-nodes-manager.js completion path: await onQueueCompleted
with try/catch (surfaces silent turbogrid stale-item throws), replace the
{}.length == 0 no-op empty guard, set install_context before queue/install
to avoid a sync-completion race, wrap classList/updateCell in try/catch.
Resynchronize openapi.yaml with the converted routes (method → post, query
params → requestBody JSON schema, sibling post on 5 split endpoints).
Update 31 JS fetchApi call sites across 7 files; add
tests/test_csrf_content_type_helper.py covering 5 Content-Type cases via
aiohttp TestClient.

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:07 +09:00
kenyonxu 57972a5689 Add Kai Civitai LoRA Loader to custom node list
A ComfyUI custom node that extends the built-in LoRA Loader to fetch and
display Civitai metadata including trigger words and preview images.

Features:
- Trigger words output from Civitai metadata
- Preview images with pagination and NSFW blur
- Local caching with 7-day TTL
- Concurrent request deduplication

Repository: https://github.com/kenyonxu/ComfyUI_KaiUtilities
2026-04-21 15:38:32 +08:00
Randy Haylor b71e239f70 Add ComfyUI Claude CLI (Vision+Text) node 2026-04-21 03:01:04 -04:00
Dr.Lt.Data d57c142019 update DB 2026-04-21 05:22:40 +09:00
gzsiang 5e475f6883 Merge upstream/main into fix-pr2801 — resolve conflicts 2026-04-20 21:43:17 +08:00
Dr.Lt.Data 6a26409bcb update DB 2026-04-20 02:54:48 +09:00
Kyreo bbc223a76c Add ComfyUI-CharacterPromptBuffer by Kyreo 2026-04-19 19:35:02 +02:00
Kyreo aba857a350 Revert "Add ComfyUI-CharacterPromptBuffer"
This reverts commit 74c2518894.
2026-04-19 19:31:16 +02:00
Kyreo 74c2518894 Add ComfyUI-CharacterPromptBuffer 2026-04-19 19:30:37 +02:00
jetthuangai 98e0098ddd Add ComfyUI-JH-PixelPro 2026-04-18 13:10:08 +07:00
Amatsukast 23da19ea90 Add ComfyUI-SBTools
Adding ComfyUI-SBTools - workflow toolkit with background removal (BiRefNet), prompt/image variable system for batch generation, and chroma key tools.
2026-04-17 21:25:55 +09:00
emiewnn ed56212fa0 Add ComfyUI-Emiewn-Nodes 2026-04-17 02:28:20 +02:00
Dr.Lt.Data 22fc850853 update DB 2026-04-17 03:08:37 +09:00
Andrés Zsögön 15fd6dbe37 Add Lora Pipeline to Manager custom node list (#2799) 2026-04-17 03:07:54 +09:00
Dr.Lt.Data f1863994f4 update DB 2026-04-17 02:49:19 +09:00
Pasu 7c0fa1c581 Added Mayo Nodes (#2784) 2026-04-17 02:48:50 +09:00
Dr.Lt.Data dcb15d4dc9 update DB 2026-04-17 02:48:04 +09:00
诶-阿伟哥 6be5491d9e Add ComfyUI-Prompt-Assistant custom node (#2781)
* Add ComfyUI-Prompt-Assistant custom node

* Add ComfyUI-Prompt-Assistant custom node
2026-04-17 02:47:26 +09:00
Dr.Lt.Data 1e8956f42e update DB 2026-04-17 02:46:36 +09:00
d7adab215b Add Overtli Studio Suite to custom-node-list.json (#2777)
Agent-Logs-Url: https://github.com/OvertliDS/ComfyUI-Manager/sessions/f43bab28-2dc5-4853-945c-bdb891b85551

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: OvertliDS <188361908+OvertliDS@users.noreply.github.com>
2026-04-17 02:45:47 +09:00
Lei Fengxiang 6a895c7225 Update custom-node-list.json
added custom-node bizyairplus
2026-04-16 16:29:26 +08:00
gzsiang e6d2e947c3 Add ComfyUI-VolcEngine node to registry 2026-04-16 02:29:59 +08:00
Andrés Zsögön 1e8ebfe3db Add Styler Pipeline to Manager custom node list 2026-04-15 12:12:45 -03:00
Andrés Zsögön c30dd182d3 Add OpenPose Studio and Ultralytics Studio to Manager custom node list 2026-04-15 12:01:57 -03:00
Dr.Lt.Data 15ec9a901b update DB 2026-04-14 00:36:37 +09:00
rookiestar28 e9fd8a3078 Add ComfyUI-RookieUI and ComfyUI-OpenClaw entries (#2786) 2026-04-14 00:33:25 +09:00
Dr.Lt.Data 980f8f58af update DB 2026-04-14 00:27:22 +09:00
Matt HanrahanandClaude Sonnet 4.6 9b37dcfe42 Add ComfyUI Batch Blend custom node (#2783)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:34 +09:00
Dr.Lt.Data 2f1d02b688 update DB 2026-04-13 23:25:19 +09:00
knottttt 685dca9749 Add ComfyUI TinyPNG node (#2778) 2026-04-13 23:24:39 +09:00
Dr.Lt.Data 03b54019ff update DB 2026-04-13 23:22:01 +09:00
magictutandmagicwang1111 92a47f59cf Register ComfyUI-Nanobanana (#2779)
Co-authored-by: magicwang1111 <magicwang1111@users.noreply.github.com>
2026-04-13 23:20:25 +09:00
Dr.Lt.Data bae0ac11c5 update DB 2026-04-13 23:00:17 +09:00
Juan Treminio f016eadd64 Add ComfyUI-ConnectTheDots (#2775) 2026-04-13 22:59:32 +09:00
Dr.Lt.Data 0f13355b08 update DB 2026-04-13 22:58:48 +09:00
magictut bf5efd72ff Add Comfyui-Kling-Wrapper to custom node list (#2773) 2026-04-13 22:57:14 +09:00
Dr.Lt.Data 0fc4649d23 update DB 2026-04-13 22:54:30 +09:00
artyclaw 3842e70fd9 Add artyclaw-comfy nodes (#2765) 2026-04-13 22:53:17 +09:00
Dr.Lt.Data 0952dd1686 update DB 2026-04-13 21:39:00 +09:00
Dr.Lt.Data a7fb42be01 update DB 2026-04-13 21:23:44 +09:00
Guillaume-127 ee0f446ad0 Add Post Processing PRO (#2774) 2026-04-13 21:22:17 +09:00
Dr.Lt.Data b220733094 update DB 2026-04-13 12:58:42 +09:00
Dr.Lt.Data 5079365034 update DB 2026-04-13 12:55:57 +09:00
Yang Zhang 454f24145e Add ComfyUI-LLM-Prompt-Tagger to custom-node-list.json (#2771) 2026-04-13 12:55:16 +09:00
Dr.Lt.Data 9d838d302f update DB 2026-04-13 12:54:23 +09:00
MSXYZ 928cc80088 Added MSXYZ custom node (#2770) 2026-04-13 12:53:28 +09:00
Dr.Lt.Data a54ab14c37 update DB 2026-04-13 12:48:45 +09:00
zaochuan5854 ba5aca7122 Add ComfyUI-TensorRT-Reforge (#2767) 2026-04-13 12:47:56 +09:00
Dr.Lt.Data 715c6c2426 update DB 2026-04-13 12:46:54 +09:00
e51a83fc8c Add FLUXNATION fused spike attention CUDA kernel for FLUX.1 (#2766)
Co-authored-by: ULT7RA <ULT7RA@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:46:16 +09:00
wjluoxiao 1fc7b34f4a Add files via upload 2026-04-13 03:35:02 +08:00
wjluoxiao ba33ebe2dc Delete custom-node-list.json 2026-04-13 03:34:43 +08:00
wjluoxiao d98ca19ef5 Add files via upload 2026-04-13 03:32:02 +08:00
MSXYZ a9f7a6167e Added MSXYZ custom node 2026-04-07 11:19:55 +03:00
Dr.Lt.Data bbafbb1290 update DB 2026-04-06 01:55:26 +09:00
PGCRTandClaude Sonnet 4.6 f08075c04d Add CRT nodes to default channel and fix CRT-Nodes reference (#2759)
- Add CRT-HeartMuLa, ComfyUI-OmniVoice_CRT, ComfyUI-QWEN3_TTS
- Fix CRT-Nodes: restore author/title fields, update plugcrypt -> PGCRT

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 01:49:51 +09:00
Jeremie Louvaert 246d715838 Add 6 jeremieLouvaert custom node packs (#2747)
Adding the following node packs to the custom node list:

- ComfyUI-Darkroom: 29-node professional color grading & film emulation suite
- ComfyUI-Gemini-Direct: Direct Google Gemini image generation
- ComfyUI-API-Optimizer: API cost tracking and caching
- ComfyUI-Gemini-Conversation-Canvas: Multi-turn conversational image editing
- comfyui-wireless-link-simple: Wireless data transmission nodes
- ComfyUI-Higgsfield-Direct: Direct Higgsfield API integration

All repos are public, have proper __init__.py with NODE_CLASS_MAPPINGS,
and are published on the Comfy Registry under @akurate.
2026-04-06 01:45:55 +09:00
Dr.Lt.Data 2a55999552 update DB 2026-04-06 01:04:03 +09:00
Atsushi Nonaka c7da60732e Add ComfyUI-MediaPreview custom node (#2749)
* Update custom-node-list.json

* Add files via upload

* Delete custom-node-list.json

* Rename custom-node-list.json 3.txt to custom-node-list.json

* Delete custom-node-list.json

* Add files via upload

* Delete custom-node-list.json

* Add files via upload
2026-04-06 01:03:01 +09:00
Dr.Lt.Data d0e3336804 update DB 2026-04-06 01:02:26 +09:00
zinigo-creations 49f294bac5 Add Prompt Builder custom node (#2755) 2026-04-06 00:56:43 +09:00
Dr.Lt.Data 347b13b01a update DB 2026-04-06 00:56:07 +09:00
darksidewalkerandDarksidewalker 07c0155929 Added DaSiWa Nodes (#2756)
Co-authored-by: Darksidewalker <you@example.com>
2026-04-06 00:55:31 +09:00
Inoland 7a0beec47e Update custom-node-list.json (#2760) 2026-04-06 00:52:09 +09:00
Dr.Lt.Data 32c5f26838 update DB 2026-04-06 00:46:36 +09:00
Patrick Kasteel 0b7669a283 Add ComfyUI-Image-to-Prompt-Abacus.AI- custom node entry (kplkasteel) (#2764) 2026-04-06 00:45:02 +09:00
Dr.Lt.Data a1ade259ef update DB 2026-04-06 00:39:20 +09:00
Dr.Lt.Data 3615af1560 update DB 2026-04-01 02:24:34 +09:00
Dr.Lt.Data 8aca0751d1 update DB 2026-03-31 08:50:38 +09:00
Dr.Lt.Data c8a50de72c update DB 2026-03-31 03:37:20 +09:00
Allan.M cbf8068515 Add ComfyUI-XPUSYS-Monitor (#2701) 2026-03-31 03:36:25 +09:00
Dr.Lt.Data 0e0b371417 update DB 2026-03-31 01:45:47 +09:00
ketle-manandClaude Sonnet 4.6 4669d4bca9 feat: add ComfyUI VRM Pose Editor 3D (#2744)
* Add ComfyUI 2D Pose Editor node

* feat: add ComfyUI VRM Pose Editor 3D

Interactive 3D pose editor node supporting VRM/GLB/GLTF models.
All Three.js dependencies are bundled locally (no CDN required).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 01:44:48 +09:00
Dr.Lt.Data 59ed61722b update DB 2026-03-31 01:43:48 +09:00
cozdx1 8c14349f97 Add ComfyUI-Dynamic-Sigmas (#2743) 2026-03-31 01:42:21 +09:00
Dr.Lt.Data 14352e2c00 update DB 2026-03-30 04:32:13 +09:00
Dr.Lt.Data 5a4e346f06 update DB 2026-03-30 04:11:38 +09:00
vegaflowltdandVega Flow 152bd7b4f7 Add Vega Flow V8.9 — Temporal Stabilisation node (#2739)
* Add Vega Flow V8.9 Temporal Stabilisation node

* Add Vega Flow V8.9 Temporal Stabilisation node

---------

Co-authored-by: Vega Flow <vegaflowltd@users.noreply.github.com>
2026-03-30 04:10:44 +09:00
Dr.Lt.Data d966c4ea67 update DB 2026-03-30 04:03:07 +09:00
Joon JungandYour Name 846fddccb9 Add ComfyUI-MVNT: AI dance choreography from music (#2723)
Adds MVNT (https://github.com/mvnt-app/ComfyUI-MVNT) to the custom
node list. MVNT generates full-body dance motion from audio using a
diffusion model trained with 100+ professional choreographers.

Nodes: MVNT Generate Dance, MVNT Generate Character, MVNT Export Video,
MVNT Preview BVH, MVNT List Styles, MVNT Estimate Cost, MVNT Load Motion.

Published on Comfy Registry as comfyui-mvnt (publisher: mvnt).

Made-with: Cursor

Co-authored-by: Your Name <your.email@example.com>
2026-03-30 04:02:26 +09:00
Dr.Lt.Data d69dc99d84 update DB 2026-03-30 04:01:18 +09:00
Stibo 12e6f66d92 Add comfyui-nifty-nodes (#2722) 2026-03-30 04:00:16 +09:00
Dr.Lt.Data 1d19a74ca0 update DB 2026-03-30 03:58:07 +09:00
HalfikChan 37cac20c4e Add ComfyUI-Prompt-DB (#2720) 2026-03-30 03:57:19 +09:00
Dr.Lt.Data ad9e73837f update DB 2026-03-30 03:55:12 +09:00
wrabit 00e81bb353 Add ComfyUI-ImgSlider (#2715)
* Add ComfyUI-ImgSlider node

* Add ComfyUI-ImgSlider node

* Update description
2026-03-30 03:54:34 +09:00
Dr.Lt.Data 3f5eb4dabb update DB 2026-03-30 03:53:25 +09:00
Krishnan Ramachandran 5462fe7acc Add ComfyUI Pulse MeshAudit custom node for 3D mesh auditing (#2714) 2026-03-30 03:52:24 +09:00
Akash Chowdhury 7427b1e3d7 Add ComfyUI-PulseOfMotion to custom node list 2026-03-30 00:05:34 +05:30
Dr.Lt.Data 8d750aac72 update DB 2026-03-30 03:26:13 +09:00
NRDX d85f035662 Add ComfyUI-BFSNodes (#2713) 2026-03-30 03:25:00 +09:00
t a2aace6a23 Adding comfy vllm, and comfy lora iterator nodes to custom node list 2026-03-21 23:12:30 -06:00
ketle-man 0e9c0baef9 Add ComfyUI-Workflow-Studio to custom node list
Rebase on latest main to resolve merge conflict.
2026-03-21 12:36:19 +09:00
t b770f27099 Adding 'comfy-vllm' custom node, title update 2026-03-20 15:02:05 -06:00
t 96ffcbd0be Adding 'comfy-vllm' custom node 2026-03-20 15:00:16 -06:00
ketle-man 04e12f4b3b Merge branch 'Comfy-Org:main' into main 2026-03-18 22:13:29 +09:00
ketle-man 23056a0eb8 Add ComfyUI 2D Pose Editor node 2026-03-14 22:45:52 +09:00
Nakano Kenji 794161d81d Add ComfyUI-AnimaTool 2026-02-03 16:33:13 +08:00
Dr.Lt.Data b2d6a4eefe Update custom-node-list.json 2025-12-28 08:09:06 +09:00
destinyfaux 216e8586bc Update custom-node-list.json
Fixed indentation issue caused in my prior Z-Image Z-Fuse Node.
2025-12-27 12:31:58 -07:00
destinyfaux 0cbbe25d89 Update custom-node-list.json
Adding my custom lora nodes for Z-Image. Combines and merges loras non-destructively to enable the usage of many loras without layer saturation.
2025-12-27 12:29:38 -07:00
John Cantu 1bdcd1bdbf Merge branch 'main' of https://github.com/jfcantu/ComfyUI-Manager 2025-12-14 18:11:34 -08:00
John Cantu e04ed0eda7 update README.md for node usage analyzeer 2025-09-14 23:10:10 -07:00
John Cantu 02aa67b541 Merge branch 'main' of https://github.com/jfcantu/ComfyUI-Manager 2025-09-14 22:52:41 -07:00
John Cantu 67d03530a3 Changes and new code for Node Usage Analyzer 2025-09-14 22:49:54 -07:00
millerlight a471263602 Merge branch 'Comfy-Org:main' into millerlight-patch-1 2025-08-11 16:24:36 +02:00
millerlight a062f6f459 Update custom-node-list.json
In v1.0.0 I now strictly enforce a registry-backed whitelist:

    POST /kvtools/peek only serves values for (file_name, key) that exist in kv_registry.json (basename-only, path traversal blocked via abspath().startswith(BASE_DIR)).

    Image serving is scoped to the allowed root (optional extension whitelist), no arbitrary paths.

    UI changes no longer trigger automatic runs.
2025-08-11 04:59:50 +02:00
Paolo Calvi 6d87c3e981 Add ComfyUI-PuLID-Flux-Chroma - First PuLID implementation for Chroma models. 2025-06-16 00:20:47 +02:00
Derrick acc11e1bda Add ComfyUI_rgbx_xrgb_Wrapper 2025-06-07 20:55:40 -07:00
64 changed files with 108169 additions and 17370 deletions
+3 -1
View File
@@ -16,5 +16,7 @@ comfyworkflows_sharekey
github-stats-cache.json
pip_overrides.json
*.json
# Track JSON test fixtures.
!tests/cases/*.json
check2.sh
/venv/
/venv/
+57
View File
@@ -0,0 +1,57 @@
# Changelog
## Unreleased
### Security policy: dedicated install flags (`allow_git_url_install` / `allow_pip_install`)
Two new boolean keys in `config.ini` (`[default]` section), both defaulting to
`false`, now govern the arbitrary-install surfaces:
| Flag | Governs |
|------|---------|
| `allow_git_url_install` | `POST /customnode/install/git_url` and the unknown-git-URL arm of `POST /manager/queue/install` (incl. reinstall delegation) — the entire install transaction, transitive dependency pip installs included. On the batch queue path the flag applies **in addition to** the queue's `security_level` entry gate (see below) |
| `allow_pip_install` | `POST /customnode/install/pip` only |
These surfaces additionally require a **loopback listener** (`--listen` on a
loopback IP such as `127.0.0.1` or `::1` — not a general LAN/private address);
the flags never open a non-loopback deployment. On the two
**direct** endpoints (`POST /customnode/install/git_url` and
`POST /customnode/install/pip`), the flags fully **decouple** the surface
from `security_level`: it no longer has any effect in either direction — a
strict level cannot deny them when the flag is `true`, and a weak level
cannot allow them when the flag is `false`. On the **batch queue path**
(`POST /manager/queue/install`), the flag is **necessary but not
sufficient**: it gates the unknown-git-URL arm at the risky position, while
the queue's normal `security_level` entry gate (`middle`) remains in force —
at `security_level = strong`, batch unknown-URL installs stay denied even
with the flag set to `true`. `security_level` continues to govern every
other gated endpoint unchanged. Only the case-insensitive string `true`
enables a flag; a missing or malformed key reads as `false`.
#### Migration note (no auto-seed)
There is **no automatic migration** from `security_level`. Users who
previously relied on `security_level = weak` (or `normal-`) to use
install-via-git-URL / install-pip must now **opt in explicitly** by adding to
`config.ini`:
```ini
[default]
allow_git_url_install = true
allow_pip_install = true
```
Changes take effect after a **restart** (no hot reload).
#### Residual-risk note — outdated ComfyUI behavior change
On outdated ComfyUI versions (no system-user API), the manager previously
forced `security_level = strong`, which unconditionally denied the
git-URL/pip install surfaces. After this change those surfaces are governed
by the new flags instead: an operator who explicitly sets a flag to `true`
on a **loopback** listener can now perform installs on outdated ComfyUI
where the forced-strong policy previously denied them. This is an accepted,
deliberate trade-off: it requires explicit operator opt-in, remains bounded
to loopback listeners, and the flag-deny path on outdated ComfyUI still
surfaces the `comfyui_outdated` notice. If you operate an outdated ComfyUI
deployment, leave both flags at their default `false` and update ComfyUI.
+108 -3
View File
@@ -384,19 +384,124 @@ When you run the `scan.sh` script:
* all feature is available
* `high` level risky features
* `Install via git url`, `pip install`
* Installation of custom nodes registered not in the `default channel`.
* Fix custom nodes
* Downloading models that are not in `.safetensors` format and not
registered in the `default channel` model list
* NOTE: `Install via git url`, `pip install`, and installation of custom nodes
not registered in the `default channel` are **no longer governed by
`security_level`** — they are governed by the dedicated install flags
described below.
* `middle` level risky features
* Uninstall/Update
* Installation of custom nodes registered in the `default channel`.
* Fix custom nodes
* Restore/Remove Snapshot
* Restart
* `low` level risky features
* Update ComfyUI
### Dedicated install flags: `allow_git_url_install` / `allow_pip_install`
The two arbitrary-install surfaces are governed by dedicated boolean keys in
`config.ini` (`[default]` section), fully **decoupled** from `security_level`:
* `allow_git_url_install`
* governs `Install via Git URL` (`POST /customnode/install/git_url`) **and**
the unknown-git-URL arm of the batch install queue
(`POST /manager/queue/install`, including reinstall delegation) — i.e.
installing any custom node from a git URL that is not registered in the
`default channel` catalog
* on the **batch queue path**, the flag is **necessary but not
sufficient**: the queue's normal `security_level` entry gate (`middle`)
must ALSO pass — at `security_level = strong`, batch unknown-URL
installs stay denied even with the flag set to `true` (only the direct
`Install via Git URL` endpoint is fully independent of `security_level`)
* covers the **entire install transaction** it starts, including the
pack's transitive dependency pip installs
* `allow_pip_install`
* governs **only** the standalone `pip install` feature
(`POST /customnode/install/pip`)
Key properties:
* **Decoupled from `security_level` (replace, not and)** — on the two
**direct endpoints** (`Install via Git URL` and `pip install`),
`security_level` no longer has any effect in either direction: a strict
level cannot deny them when the flag is `true`, and a weak level cannot
allow them when the flag is `false`. (The batch queue path keeps its
`security_level` entry gate in ADDITION to the flag — see the scope bullet
above.) Every other gated feature remains governed by `security_level` as
described above.
* **Loopback only** — the flags take effect **only** when the server listens
on a loopback address (e.g. `--listen 127.0.0.1`). On a non-loopback
listener these surfaces stay denied regardless of the flags; the flags
never widen the exposure of a public deployment.
* **Default deny / explicit opt-in** — both flags default to `false`. Only
the case-insensitive string `true` enables a flag; a missing or malformed
key reads as `false`.
To opt in, edit `config.ini`:
```ini
[default]
allow_git_url_install = true
allow_pip_install = true
```
Changes take effect after a **restart** (no hot reload).
> **Migration note**: there is no automatic migration from `security_level`.
> If you previously relied on `security_level = weak` (or `normal-`) to use
> install-via-git-URL / pip install, you must opt in explicitly with the flags
> above. See `CHANGELOG.md` for details, including a behavior note for
> outdated ComfyUI deployments.
### Flagged CNR versions: `allow_flagged_nodepack_install`
Manager checks the version status in the existing CNR install response; no
additional Registry request is needed. Active versions continue to install
normally, subject to the existing security policy. Registry refusals (including
HTTP 404) still prevent installation.
Flagged versions must also satisfy the existing security policy and Registry
checks. In addition, they require either **all** `--listen` addresses to be
loopback, such as `127.0.0.1` or `::1`, or the explicit override below.
A mixed loopback/non-loopback listener is non-local.
For a **trusted private network**, administrators can explicitly allow flagged
versions on non-loopback listeners:
```ini
[default]
allow_flagged_nodepack_install = true
```
The default is `false`; only the case-insensitive value `true` enables it.
Restart ComfyUI after editing the setting. This option does not detect private
networks and is independent of `network_mode`. It does not bypass
`security_level`, `allow_git_url_install`, or `allow_pip_install`.
When the flagged-version check denies an action, the UI reports that the current
security configuration does not allow it and directs users to the terminal.
Only the terminal provides the loopback-only `--listen` examples, the private
network override setting, and the instruction to restart ComfyUI after changes.
Enabling an already installed version does not query the Registry or reinstall it.
Older deferred switches without a stored status require loopback-only listeners
or the override. Otherwise they are rejected without changing the installed pack;
request the installation again so Manager can check its current Registry status.
The check applies to server-initiated installation, reinstallation, version switches, updates,
and snapshot restores. A policy refusal preserves the existing nodepack.
Scheduled version switches save the returned status and check the current
listener and setting again at startup, before downloading or running scripts.
Server-scheduled snapshot restores inherit the permission computed from the
server's current listener and setting. Standalone `cm-cli` commands remain local
administrator operations and do not use the server listener policy.
Saved statuses are not refreshed from the Registry.
# Disclaimer
+17480 -1353
View File
File diff suppressed because it is too large Load Diff
+23634 -1998
View File
File diff suppressed because it is too large Load Diff
+15704 -7799
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -77,8 +77,8 @@ async def _get_cnr_data(cache_mode=True, dont_wait=True):
for x in sub_json_obj['nodes']:
full_nodes[x['id']] = x
if page % 5 == 0:
print(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
# if page % 5 == 0:
# print(f"FETCH ComfyRegistry Data: {page}/{sub_json_obj['totalPages']}")
page += 1
time.sleep(0.5)
@@ -129,6 +129,7 @@ class NodeVersion:
id: str
version: str
download_url: str
status: str = ''
def map_node_version(api_node_version):
@@ -160,6 +161,7 @@ def map_node_version(api_node_version):
download_url=api_node_version.get(
"downloadUrl", ""
), # Provide a default value if 'downloadUrl' is missing
status=api_node_version.get('status', ''),
)
@@ -181,7 +183,6 @@ def install_node(node_id, version=None):
response = requests.get(url, verify=not manager_util.bypass_ssl)
if response.status_code == 200:
# Convert the API response to a NodeVersion object
return map_node_version(response.json())
else:
return None
+300 -92
View File
@@ -44,7 +44,7 @@ import manager_migration
from node_package import InstalledNodePackage
version_code = [3, 39, 2]
version_code = [3, 42]
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
@@ -953,10 +953,10 @@ class UnifiedManager:
return res
def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps):
def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps, status=''):
script_path = os.path.join(manager_startup_script_path, "install-scripts.txt")
with open(script_path, "a") as file:
obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable]
obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable, status]
file.write(f"{obj}\n")
print(f"Installation reserved: {target}")
@@ -982,35 +982,45 @@ class UnifiedManager:
return result
def _get_cnr_install_info(self, node_id, version_spec):
node_info = cnr_utils.install_node(node_id, version_spec)
if node_info is not None and node_info.status == 'NodeVersionStatusFlagged' and not manager_funcs.is_flagged_install_allowed():
logging.error(manager_util.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
raise PermissionError(manager_util.FLAGGED_NODEPACK_INSTALL_ERROR)
return node_info
def cnr_switch_version(self, node_id, version_spec=None, instant_execution=False, no_deps=False, return_postinstall=False):
if instant_execution:
return self.cnr_switch_version_instant(node_id, version_spec, instant_execution, no_deps, return_postinstall)
else:
return self.cnr_switch_version_lazy(node_id, version_spec, no_deps, return_postinstall)
def cnr_switch_version_lazy(self, node_id, version_spec=None, no_deps=False, return_postinstall=False):
"""
switch between cnr version (lazy mode)
"""
result = ManagedResult('switch-cnr')
node_info = cnr_utils.install_node(node_id, version_spec)
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return result.fail(str(exc))
if node_info is None or not node_info.download_url:
return result.fail(f'not available node: {node_id}@{version_spec}')
version_spec = node_info.version
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
if self.active_nodes[node_id][0] == version_spec:
def _cnr_switch_version(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False):
"""Execute a switch using the checked CNR response."""
if self.active_nodes[node_id][0] == node_info.version:
return ManagedResult('skip').with_msg("Up to date")
if instant_execution:
return self._cnr_switch_version_instant(node_id, node_info, no_deps, return_postinstall)
return self._cnr_switch_version_lazy(node_id, node_info, no_deps, return_postinstall)
def _cnr_switch_version_lazy(self, node_id, node_info, no_deps=False, return_postinstall=False):
"""Reserve a switch using the validated install response."""
result = ManagedResult('switch-cnr')
version_spec = node_info.version
zip_url = node_info.download_url
from_path = self.active_nodes[node_id][1]
target = node_id
to_path = os.path.join(get_default_custom_nodes_path(), target)
def postinstall():
return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps)
return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps, node_info.status)
if return_postinstall:
return result.with_postinstall(postinstall)
@@ -1020,23 +1030,12 @@ class UnifiedManager:
return result
def cnr_switch_version_instant(self, node_id, version_spec=None, instant_execution=True, no_deps=False, return_postinstall=False):
"""
switch between cnr version
"""
# 1. download
def _cnr_switch_version_instant(self, node_id, node_info, no_deps=False, return_postinstall=False):
"""Apply a switch using the validated install response."""
result = ManagedResult('switch-cnr')
node_info = cnr_utils.install_node(node_id, version_spec)
if node_info is None or not node_info.download_url:
return result.fail(f'not available node: {node_id}@{version_spec}')
version_spec = node_info.version
if self.active_nodes[node_id][0] == version_spec:
return ManagedResult('skip').with_msg("Up to date")
# 1. download
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
download_path = os.path.join(get_default_custom_nodes_path(), archive_name)
manager_downloader.basic_download_url(node_info.download_url, get_default_custom_nodes_path(), archive_name)
@@ -1081,7 +1080,7 @@ class UnifiedManager:
result.target = version_spec
def postinstall():
res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=instant_execution, no_deps=no_deps)
res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=True, no_deps=no_deps)
return res
if return_postinstall:
@@ -1298,10 +1297,24 @@ class UnifiedManager:
if 'comfyui-manager' in node_id.lower():
return result.fail(f"ignored: installing '{node_id}'")
node_info = cnr_utils.install_node(node_id, version_spec)
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return result.fail(str(exc))
if node_info is None or not node_info.download_url:
return result.fail(f'not available node: {node_id}@{version_spec}')
result = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall)
# Keep the requested version in the public result.
if result.target is not None:
result.target = version_spec
return result
def _cnr_install(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False):
"""Install using the checked CNR response."""
result = ManagedResult('install-cnr')
version_spec = node_info.version
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
download_path = os.path.join(get_default_custom_nodes_path(), archive_name)
@@ -1490,21 +1503,13 @@ class UnifiedManager:
else:
version_spec = self.resolve_unspecified_version(node_id)
if version_spec == 'unknown' or version_spec == 'nightly':
if version_spec in ('unknown', 'nightly'):
try:
custom_nodes = await self.get_custom_nodes(channel, mode)
except InvalidChannel as e:
return ManagedResult('fail').fail(f'Invalid channel is used: {e.channel}')
the_node = custom_nodes.get(node_id)
if the_node is not None:
if version_spec == 'unknown':
repo_url = the_node['files'][0]
else: # nightly
repo_url = the_node['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode)
except InvalidChannel as exc:
return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}')
except LookupError as exc:
return ManagedResult('install').fail(str(exc))
if self.is_enabled(node_id, version_spec):
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
@@ -1512,24 +1517,17 @@ class UnifiedManager:
elif self.is_disabled(node_id, version_spec):
return self.unified_enable(node_id, version_spec)
elif version_spec == 'unknown' or version_spec == 'nightly':
if version_spec == 'nightly':
# disable cnr nodes
if self.is_enabled(node_id, 'cnr'):
self.unified_disable(node_id, False)
elif version_spec in ('unknown', 'nightly'):
return self._install_git(node_id, version_spec, repo_url, instant_execution, no_deps, return_postinstall)
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
if res.result:
if version_spec == 'unknown':
self.unknown_active_nodes[node_id] = repo_url, to_path
elif version_spec == 'nightly':
cnr_utils.generate_cnr_id(to_path, node_id)
self.active_nodes[node_id] = 'nightly', to_path
else:
return res
return res.with_target(version_spec)
# Check before changing the existing pack, then reuse this response.
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return ManagedResult('install-cnr').fail(str(exc))
if node_info is None or not node_info.download_url:
return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}')
version_spec = node_info.version
if self.is_enabled(node_id, 'nightly'):
# disable nightly nodes
@@ -1542,17 +1540,77 @@ class UnifiedManager:
if self.is_disabled(node_id, "cnr"):
# enable and switch version if cnr is disabled (not specified version)
self.unified_enable(node_id, "cnr")
return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall)
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
if self.is_enabled(node_id, "cnr"):
return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall)
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
res = self.cnr_install(node_id, version_spec, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
res = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall)
if res.result:
self.active_nodes[node_id] = version_spec, res.to_path
return res
async def _get_git_install_url(self, node_id, version_spec, channel, mode):
custom_nodes = await self.get_custom_nodes(channel, mode)
node = custom_nodes.get(node_id)
if node is None:
raise LookupError(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
return node['files'][0] if version_spec == 'unknown' else node['repository']
def _install_git(self, node_id, version_spec, repo_url, instant_execution=False, no_deps=False, return_postinstall=False):
if version_spec == 'nightly':
# disable cnr nodes
if self.is_enabled(node_id, 'cnr'):
self.unified_disable(node_id, False)
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
if res.result:
if version_spec == 'unknown':
self.unknown_active_nodes[node_id] = repo_url, to_path
elif version_spec == 'nightly':
cnr_utils.generate_cnr_id(to_path, node_id)
self.active_nodes[node_id] = 'nightly', to_path
else:
return res
return res.with_target(version_spec)
async def reinstall_by_id(self, node_id, version_spec, channel=None, mode=None):
if 'comfyui-manager' in node_id.lower():
return ManagedResult('skip').fail(f"ignored: installing '{node_id}'")
if version_spec in ('unknown', 'nightly'):
try:
repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode)
except InvalidChannel as exc:
return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}')
except LookupError as exc:
return ManagedResult('install').fail(str(exc))
else:
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return ManagedResult('install-cnr').fail(str(exc))
if node_info is None or not node_info.download_url:
return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}')
removed = self.unified_uninstall(node_id, version_spec == 'unknown')
if not removed.result:
return removed
for item in removed.items:
path = item if version_spec == 'unknown' else item[1]
self.processed_install.discard(os.path.join(path, 'install.py'))
if version_spec in ('unknown', 'nightly'):
return self._install_git(node_id, version_spec, repo_url)
result = self._cnr_install(node_id, node_info)
if result.result:
self.active_nodes[node_id] = node_info.version, result.to_path
return result
unified_manager = UnifiedManager()
@@ -1663,6 +1721,10 @@ class ManagerFuncs:
def __init__(self):
pass
def is_flagged_install_allowed(self):
# Server-scheduled restore children inherit only the resolved permission.
return os.environ.get('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', 'true') == 'true'
def get_current_preview_method(self):
return "none"
@@ -1679,32 +1741,147 @@ class ManagerFuncs:
manager_funcs = ManagerFuncs()
#: The keys `write_config()` owns in `config.ini`'s `[default]` section.
#: Anything else in the file - other keys, other sections - is hand-maintained
#: content that this function must carry through untouched.
#: `preview_method` is the one key that is NOT snapshot-backed: it is always
#: written from the live `manager_funcs.get_current_preview_method()`.
WRITTEN_CONFIG_KEYS = (
'preview_method',
'git_exe',
'use_uv',
'channel_url',
'share_option',
'bypass_ssl',
'file_logging',
'component_policy',
'update_policy',
'windows_selector_event_loop_policy',
'model_download_by_agent',
'downgrade_blacklist',
'security_level',
'always_lazy_install',
'network_mode',
'db_mode',
'allow_git_url_install',
'allow_pip_install',
'allow_flagged_nodepack_install',
)
class DirtyTrackingConfig(dict):
"""Config mapping that records which keys were changed after it was built.
`write_config()` must persist ONLY the setting a caller actually changed,
otherwise it re-writes the other keys from the process-lifetime startup
snapshot and silently reverts whatever the user hand-edited in the
meantime. Every settings caller mutates the cached config the same way -
a subscript assignment such as `get_config()['db_mode'] = mode` - so
recording `__setitem__` is what makes "only what changed" knowable.
`update()` is covered too, so a future caller written that way cannot
lose its change silently.
The wrapper is applied in `get_config()` AFTER `read_config()` has
returned, and that ordering is load-bearing:
`manager_migration.force_security_level_if_needed()` mutates the config
through a parameter alias from inside BOTH `read_config()` branches. If
that mutation were recorded, `security_level` would be dirty before the
user does anything, and every write would push the forced value over the
user's on-disk one - the exact clobber this tracking exists to remove.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.dirty_keys = set()
def __setitem__(self, key, value):
self.dirty_keys.add(key)
super().__setitem__(key, value)
def update(self, *args, **kwargs):
incoming = dict(*args, **kwargs)
self.dirty_keys.update(incoming)
super().update(incoming)
def write_config():
"""Persist changed Manager settings without clobbering the rest of the file.
The write starts from what is CURRENTLY on disk and overlays only the keys
a caller actually changed (tracked by `DirtyTrackingConfig`) plus the live
`preview_method`. Consequences, in the order users notice them:
- a value hand-edited while the server is up is no longer reverted by an
unrelated settings change;
- keys this function does not own (`http_channel_enabled`,
`default_cache_as_channel_url`, anything a user added) survive;
- sections other than `[default]` survive.
Reads still come from the startup snapshot until the process restarts, so
the "stop -> edit -> start" advice in the install-denial messages stays
correct; what changes is that the edit is no longer destroyed in between.
"""
config = configparser.ConfigParser(strict=False)
try:
loaded = config.read(manager_config_path)
except Exception as e:
# An unparsable config.ini must not make a settings change raise. Fall
# back to the pre-merge behaviour and rewrite the file from scratch.
print(f"[ComfyUI-Manager] Warning: '{manager_config_path}' could not be parsed ({e}); rewriting it from the current settings.")
config = configparser.ConfigParser(strict=False)
else:
# `read()` opens each filename inside its own `try/except OSError` and
# SKIPS the ones it could not open, so a file that exists but denies
# reads comes back as a normal return with an EMPTY parser - not as the
# exception handled above. Merging from that parser would find no
# `[default]`, fall into the bootstrap branch below, and rewrite the
# file from defaults: every foreign key and every other section gone,
# with the settings change still reporting success.
#
# A write that cannot see the current contents has nothing to merge
# onto, so refuse it and leave the file alone. The caller surfaces the
# error (the settings endpoint answers 500), which is what the same
# permission problem already does when writes are denied too.
if not loaded and os.path.exists(manager_config_path):
raise OSError(f"'{manager_config_path}' exists but could not be read; refusing to overwrite it with default settings. Fix the file's permissions, then retry.")
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'],
'downgrade_blacklist': get_config()['downgrade_blacklist'],
'security_level': get_config()['security_level'],
'always_lazy_install': get_config()['always_lazy_install'],
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
}
cached = get_config()
# 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', '')
if config.has_section('default'):
# Ordinary settings change: persist only what this caller changed.
# The `getattr` fallback covers a `cached_config` that is a plain dict
# (nothing in this tree does that - it is here so a caller that
# bypasses get_config() degrades to the old full rewrite rather than
# writing nothing at all).
changed_keys = getattr(cached, 'dirty_keys', set(WRITTEN_CONFIG_KEYS))
# Intersecting with WRITTEN_CONFIG_KEYS means a key this function does
# not own is NOT persisted even when a caller dirties it - unchanged
# from before, since those keys were never written either. Two such
# keys are live in the config today (`http_channel_enabled`,
# `default_cache_as_channel_url`): an endpoint that wants to persist
# one must add it to WRITTEN_CONFIG_KEYS, or the write will silently
# do nothing.
keys = [key for key in WRITTEN_CONFIG_KEYS if key in changed_keys]
else:
# Bootstrap: no `[default]` on disk (fresh install - the caller at
# manager_server.py:2098 is guarded by `if not os.path.exists`), or a
# file that could not be parsed. Seed the full default set.
config['default'] = {}
keys = list(WRITTEN_CONFIG_KEYS)
updates = {key: cached[key] for key in keys}
# `preview_method` is written on EVERY write, from the live value rather
# than from disk - it is the one setting the running process, not the
# file, is authoritative for. So it is also the one key a hand edit does
# not survive; that is unchanged behaviour, not a gap in the merge.
updates['preview_method'] = manager_funcs.get_current_preview_method()
section = config['default']
for key, value in updates.items():
# Sanitize to prevent CRLF injection. Applied to the values written
# here; values already on disk are carried through verbatim, so the
# merge cannot corrupt hand-maintained content.
section[key] = str(value).replace('\r', '').replace('\n', '').replace('\x00', '')
directory = os.path.dirname(manager_config_path)
if not os.path.exists(directory):
@@ -1713,6 +1890,22 @@ def write_config():
with open(manager_config_path, 'w') as configfile:
config.write(configfile)
# The write succeeded, so these keys now MATCH the file and are no longer
# pending changes. Without this the set is add-only: a key the user once
# changed through the UI stays dirty for the life of the process, and the
# next unrelated write overlays its stale cached value back over whatever
# the user has hand-edited since - the same clobber this function exists to
# remove, just deferred by one settings change.
#
# Discard exactly the keys THIS write persisted, never the whole set: a
# caller may have dirtied a key that was not selected here - one this
# function does not own, and therefore one it never persists at all (the
# WRITTEN_CONFIG_KEYS note above). That marker is not this write's to
# drop; `clear()` would lose it. The hasattr guard mirrors the `getattr`
# above, for a cache that is a plain dict.
if hasattr(cached, 'dirty_keys'):
cached.dirty_keys.difference_update(keys)
def read_config():
try:
@@ -1745,6 +1938,9 @@ def read_config():
'network_mode': default_conf.get('network_mode', 'public').lower(),
'security_level': default_conf.get('security_level', 'normal').lower(),
'db_mode': default_conf.get('db_mode', 'cache').lower(),
'allow_git_url_install': get_bool('allow_git_url_install', False),
'allow_pip_install': get_bool('allow_pip_install', False),
'allow_flagged_nodepack_install': get_bool('allow_flagged_nodepack_install', False),
}
manager_migration.force_security_level_if_needed(result)
return result
@@ -1774,6 +1970,9 @@ def read_config():
'network_mode': 'public', # public | private | offline
'security_level': 'normal', # strong | normal | normal- | weak
'db_mode': 'cache', # local | cache | remote
'allow_git_url_install': False,
'allow_pip_install': False,
'allow_flagged_nodepack_install': False,
}
manager_migration.force_security_level_if_needed(result)
return result
@@ -1783,7 +1982,11 @@ def get_config():
global cached_config
if cached_config is None:
cached_config = read_config()
# Wrapped HERE, after read_config() has returned: the migration hook it
# calls internally mutates the config through a parameter alias, and
# that mutation must NOT count as a user change (see
# DirtyTrackingConfig).
cached_config = DirtyTrackingConfig(read_config())
if cached_config['http_channel_enabled']:
print("[ComfyUI-Manager] Warning: http channel enabled, make sure server in secure env")
@@ -3177,15 +3380,20 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
skip_node_packs.append(f"{x[0]}@{x[1]}")
elif not ps.result:
failed.append(f"{x[0]}@{x[1]}")
logging.error(ps.msg)
# install listed cnr nodes
# Do not retry processed switches as fresh installs.
checked_cnr = {node_id for node_id, _ in todo_checkout}
for k, v in cnr_info.items():
if 'comfyui-manager' in k:
if 'comfyui-manager' in k or k in checked_cnr:
continue
ps = await unified_manager.install_by_id(k, version_spec=v, instant_execution=True, return_postinstall=True)
if ps.action == 'install-cnr' and ps.result:
installed_node_packs.append(f"{k}@{v}")
elif not ps.result:
failed.append(f"{k}@{v}")
logging.error(ps.msg)
if ps is not None and ps.result:
if hasattr(ps, 'postinstall'):
+370 -123
View File
@@ -11,6 +11,8 @@ import threading
import re
import shutil
import git
import glob
import json
from datetime import datetime
from server import PromptServer
@@ -31,10 +33,13 @@ logging.info("[ComfyUI-Manager] network_mode: " + core.get_config()['network_mod
comfy_ui_hash = "-"
comfyui_tag = None
SECURITY_MESSAGE_MIDDLE_OR_BELOW = "ERROR: To use this action, a security_level of `middle or below` is required. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_MIDDLE_OR_BELOW = "ERROR: To use this action, security_level must be any value other than 'strong' (allowed: normal, normal-, weak). security_level is read once at ComfyUI startup, so changing it needs a restart, done with the server down: STOP ComfyUI, edit config.ini, then start it again. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_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."
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. To download this model, set security_level to 'weak' - or to 'normal-' if ComfyUI is listening on a loopback address (--listen 127.0.0.1 or ::1). Both security_level and the listen address are read once at ComfyUI startup, so changing either needs a restart, done with the server down: stop ComfyUI, edit config.ini, then start it again."
SECURITY_MESSAGE_FLAG_GIT_URL = "ERROR: This action requires BOTH: (1) 'allow_git_url_install = true' in config.ini ([default] section), AND (2) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1) - currently listening on {listen}. BOTH values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again. Both are independent of security_level. Reference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_FLAG_PIP = "ERROR: This action requires BOTH: (1) 'allow_pip_install = true' in config.ini ([default] section), AND (2) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1) - currently listening on {listen}. BOTH values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: STOP ComfyUI, change the setting, then start it again. Both are independent of security_level. Reference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_BLOCKED_RISK = "ERROR: This node pack is classified as blocked-risk and cannot be installed at any security_level. This is not a configuration problem - contact the administrator if you believe the classification is wrong.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
routes = PromptServer.instance.routes
@@ -82,6 +87,19 @@ def is_loopback(address):
except ValueError:
return False
def is_dedicated_install_allowed(flag_value: bool, listen_address: str) -> bool:
"""P-direct predicate (adopter-degraded form): flag AND loopback.
Pure helper for the dedicated install flags
(allow_git_url_install / allow_pip_install) — callers pass the
flag value from their own config read and the listener address
from the CLI arguments (request-time evaluation; the import-time
snapshot above is NOT consulted).
"""
return bool(flag_value) and is_loopback(listen_address)
is_local_mode = is_loopback(args.listen)
@@ -144,6 +162,9 @@ async def get_risky_level(files, pip_packages):
class ManagerFuncsInComfyUI(core.ManagerFuncs):
def is_flagged_install_allowed(self):
return core.get_config()['allow_flagged_nodepack_install'] or manager_util.is_loopback_listener(args.listen)
def get_current_preview_method(self):
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
return "auto"
@@ -286,7 +307,6 @@ print_comfyui_version()
core.check_invalid_nodes()
def setup_environment():
git_exe = core.get_config()['git_exe']
@@ -300,18 +320,76 @@ setup_environment()
from aiohttp import web
import aiohttp
import json
import zipfile
import urllib.request
def security_403_response():
"""Return appropriate 403 response based on ComfyUI version."""
def security_403_response(flag_token=None):
"""Return appropriate 403 response based on ComfyUI version.
When `flag_token` is given (dedicated install flag denials), the
body names the responsible flag instead of "security_level". The
`comfyui_outdated` branch stays the FIRST check regardless, and
no-arg callers keep today's body byte-identical.
"""
if not manager_migration.has_system_user_api():
return web.json_response({"error": "comfyui_outdated"}, status=403)
if flag_token is not None:
return web.json_response({"error": flag_token}, status=403)
return web.json_response({"error": "security_level"}, status=403)
# CORS "simple request" Content-Type set per Fetch spec §3.2.3. Browsers send
# <form method=POST> submissions with one of these three MIME types and do NOT
# trigger a CORS preflight, so a malicious cross-origin page can silently POST
# into state-changing endpoints if we only gate on HTTP method. Blocking these
# three Content-Types on no-body mutation endpoints forces any non-same-origin
# POST to use a non-simple Content-Type (e.g. application/json), which triggers
# a preflight that this server rejects by not advertising an Access-Control-
# Allow-Origin response.
_SIMPLE_FORM_CONTENT_TYPES = frozenset({
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
})
def _reject_simple_form_content_type(request):
"""Reject Content-Types that enable preflight-less <form method=POST> CSRF.
Applied ONLY to POST handlers that do not consume a request body (e.g.,
/snapshot/save, /manager/queue/{reset,start,update_comfyui},
/manager/reboot). These are vulnerable to cross-origin <form method=POST>
attacks because the handler accepts the request without parsing any body —
the attacker needs no ability to forge a valid payload, only to point a
hidden form at the URL.
Handlers that already read a body via ``await request.json()`` are NOT
gated here: a cross-origin <form method=POST> cannot forge a valid JSON
body because the browser refuses to send ``application/json`` without a
CORS preflight, which this server does not answer.
DO NOT add this gate to body-reading handlers — redundant and UX-breaking.
DO NOT remove this gate from no-body handlers — this is the bypass vector.
aiohttp's ``request.content_type`` normalizes the header (lower-cases,
strips parameters), so ``multipart/form-data; boundary=----X`` is compared
as ``multipart/form-data``.
Returns:
web.Response(status=400) when the request has a simple-form
Content-Type that must be rejected. None when the request is allowed
to proceed (no Content-Type, application/json, or any non-simple
Content-Type).
"""
if request.content_type in _SIMPLE_FORM_CONTENT_TYPES:
return web.Response(
status=400,
text='Invalid Content-Type for this endpoint. Use application/json or omit body.',
)
return None
def get_model_dir(data, show_log=False):
if 'download_model_base' in folder_paths.folder_names_and_paths:
models_base = folder_paths.folder_names_and_paths['download_model_base'][0][0]
@@ -442,7 +520,7 @@ async def task_worker():
global model_result
global tasks_in_progress
async def do_install(item) -> str:
async def do_install(item, operation) -> str:
ui_id, node_spec_str, channel, mode, skip_post_install = item
try:
@@ -452,7 +530,10 @@ async def task_worker():
return f"Cannot resolve install target: '{node_spec_str}'"
node_name, version_spec, is_specified = node_spec
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install)
if operation == 'reinstall':
res = await core.unified_manager.reinstall_by_id(node_name, version_spec, channel, mode)
else:
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install)
# discard post install if skip_post_install mode
if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']:
@@ -499,7 +580,7 @@ async def task_worker():
base_res['msg'] = 'success'
return base_res
base_res['msg'] = f"An error occurred while updating '{node_name}'."
base_res['msg'] = res.msg or f"An error occurred while updating '{node_name}'."
logging.error(f"\nERROR: An error occurred while updating '{node_name}'. (res.result={res.result}, res.action={res.action})")
return base_res
except Exception:
@@ -652,8 +733,8 @@ async def task_worker():
tasks_in_progress.add((kind, item[0]))
try:
if kind == 'install':
msg = await do_install(item)
if kind in ('install', 'reinstall'):
msg = await do_install(item, kind)
elif kind == 'install-model':
msg = await do_install_model(item)
elif kind == 'update':
@@ -737,16 +818,19 @@ async def fetch_customnode_mappings(request):
return web.json_response(json_obj, content_type='application/json')
@routes.get("/customnode/fetch_updates")
@routes.post("/customnode/fetch_updates")
async def fetch_updates(request):
try:
if request.rel_url.query["mode"] == "local":
json_data = await request.json()
mode = json_data.get("mode", "default")
if mode == "local":
channel = 'local'
else:
channel = core.get_config()['channel_url']
await core.unified_manager.reload(request.rel_url.query["mode"])
await core.unified_manager.get_custom_nodes(channel, request.rel_url.query["mode"])
await core.unified_manager.reload(mode)
await core.unified_manager.get_custom_nodes(channel, mode)
res = core.unified_manager.fetch_or_pull_git_repo(is_pull=False)
@@ -762,9 +846,89 @@ async def fetch_updates(request):
except:
traceback.print_exc()
return web.Response(status=400)
@routes.get("/customnode/get_node_types_in_workflows")
async def get_node_types_in_workflows(request):
try:
# get our username from the request header
user_id = PromptServer.instance.user_manager.get_request_user_id(request)
# get the base workflow directory (TODO: figure out if non-standard directories are possible, and how to find them)
workflow_files_base_path = os.path.abspath(os.path.join(folder_paths.get_user_directory(), user_id, "workflows"))
logging.debug(f"workflows base path: {workflow_files_base_path}")
# workflow directory doesn't actually exist, return 204 (No Content)
if not os.path.isdir(workflow_files_base_path):
logging.debug("workflows base path doesn't exist - nothing to do...")
return web.Response(status=204)
# get all JSON files under the workflow directory
workflow_file_relative_paths: list[str] = glob.glob(pathname="**/*.json", root_dir=workflow_files_base_path, recursive=True)
logging.debug(f"found the following workflows: {workflow_file_relative_paths}")
# set up our list of workflow/node-lists
workflow_node_mappings: list[dict[str, str | list[str]]] = []
# iterate over each found JSON file
for workflow_file_path in workflow_file_relative_paths:
try:
workflow_file_absolute_path = os.path.abspath(os.path.join(workflow_files_base_path, workflow_file_path))
logging.debug(f"starting work on {workflow_file_absolute_path}")
# load the JSON file
workflow_file_data = json.load(open(workflow_file_absolute_path, "r"))
# make sure there's a nodes key (otherwise this might not actually be a workflow file)
if "nodes" not in workflow_file_data:
logging.warning(f"{workflow_file_path} has no 'nodes' key (possibly invalid?) - skipping...")
# skip to next file
continue
# now this looks like a valid file, so let's get to work
new_mapping = {"workflow_file_name": workflow_file_path}
# we can't use an actual set, because you can't use dicts as set members
node_set = []
# iterate over each node in the workflow
for node in workflow_file_data["nodes"]:
if "id" not in node:
logging.warning("Found a node with no ID - possibly corrupt/invalid workflow?")
continue
# if there's no type, throw a warning
if "type" not in node:
logging.warning(f"Node type not found in {workflow_file_path} for node ID {node['id']}")
# skip to next node
continue
node_data_to_return = {"type": node["type"]}
if "properties" not in node:
logging.warning(f"Node ${node['id']} has no properties field - can't determine cnr_id")
else:
for property_key in ["cnr_id", "ver"]:
if property_key in node["properties"]:
node_data_to_return[property_key] = node["properties"][property_key]
# add it to the list for this workflow
if not node_data_to_return in node_set:
node_set.append(node_data_to_return)
# annoyingly, Python can't serialize sets to JSON
new_mapping["node_types"] = list(node_set)
workflow_node_mappings.append(new_mapping)
except Exception as e:
logging.warning(f"Couldn't open {workflow_file_path}: {e}")
return web.json_response(workflow_node_mappings, content_type='application/json')
except:
traceback.print_exc()
return web.Response(status=500)
@routes.get("/manager/queue/update_all")
@routes.post("/manager/queue/update_all")
async def update_all(request):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
@@ -774,16 +938,19 @@ async def update_all(request):
is_processing = task_worker_thread is not None and task_worker_thread.is_alive()
if is_processing:
return web.Response(status=401)
await core.save_snapshot_with_postfix('autosave')
if request.rel_url.query["mode"] == "local":
json_data = await request.json()
mode = json_data.get("mode", "default")
if mode == "local":
channel = 'local'
else:
channel = core.get_config()['channel_url']
await core.unified_manager.reload(request.rel_url.query["mode"])
await core.unified_manager.get_custom_nodes(channel, request.rel_url.query["mode"])
await core.unified_manager.reload(mode)
await core.unified_manager.get_custom_nodes(channel, mode)
for k, v in core.unified_manager.active_nodes.items():
if k == 'comfyui-manager':
@@ -807,14 +974,22 @@ async def update_all(request):
def convert_markdown_to_html(input_text):
"""Parse source markdown, escaping text and URLs at their HTML boundaries."""
pattern_a = re.compile(r'\[a/([^]]+)]\(([^)]+)\)')
pattern_w = re.compile(r'\[w/([^]]+)]')
pattern_i = re.compile(r'\[i/([^]]+)]')
pattern_bold = re.compile(r'\*\*([^*]+)\*\*')
pattern_white = re.compile(r'%%([^*]+)%%')
# Format link labels separately from URLs; escape URLs when inserted.
hrefs = []
def replace_a(match):
return f"<a href='{match.group(2)}' target='blank'>{match.group(1)}</a>"
# Entities written in the source URL (e.g. &amp;) retain their meaning.
url = manager_util.unescape_html_entities(match.group(2))
hrefs.append(manager_util.sanitize_url(url))
text = manager_util.escape_html_text(match.group(1))
return f"<a href='\x00H{len(hrefs) - 1}\x00' target='_blank' rel='noopener noreferrer'>{text}</a>"
def replace_w(match):
return f"<p class='cm-warn-note'>{match.group(1)}</p>"
@@ -828,20 +1003,35 @@ def convert_markdown_to_html(input_text):
def replace_white(match):
return f"<font color='white'>{match.group(1)}</font>"
input_text = input_text.replace('\\[', '&#91;').replace('\\]', '&#93;').replace('<', '&lt;').replace('>', '&gt;')
# NUL dropped first so the input cannot forge the href placeholders.
input_text = input_text.replace('\x00', '')
input_text = input_text.replace('\\[', '&#91;').replace('\\]', '&#93;')
result_text = re.sub(pattern_a, replace_a, input_text)
# Parse links before escaping prose, so generated text entities never enter URLs.
parts = []
start = 0
for match in pattern_a.finditer(input_text):
parts.append(manager_util.sanitize_tag(input_text[start:match.start()]))
parts.append(replace_a(match))
start = match.end()
parts.append(manager_util.sanitize_tag(input_text[start:]))
result_text = ''.join(parts)
result_text = re.sub(pattern_w, replace_w, result_text)
result_text = re.sub(pattern_i, replace_i, result_text)
result_text = re.sub(pattern_bold, replace_bold, result_text)
result_text = re.sub(pattern_white, replace_white, result_text)
result_text = result_text.replace("\n", "<BR>")
return result_text.replace("\n", "<BR>")
return re.sub(
r'\x00H(\d+)\x00',
lambda m: manager_util.escape_html_attribute(hrefs[int(m.group(1))]),
result_text,
)
def populate_markdown(x):
if 'description' in x:
x['description'] = convert_markdown_to_html(manager_util.sanitize_tag(x['description']))
x['description'] = convert_markdown_to_html(x['description'])
if 'name' in x:
x['name'] = manager_util.sanitize_tag(x['name'])
@@ -1006,14 +1196,15 @@ def get_safe_snapshot_path(target):
return os.path.join(core.manager_snapshot_path, f"{target}.json")
@routes.get("/snapshot/remove")
@routes.post("/snapshot/remove")
async def remove_snapshot(request):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return security_403_response()
try:
target = request.rel_url.query["target"]
json_data = await request.json()
target = json_data["target"]
path = get_safe_snapshot_path(target)
if path is None:
@@ -1028,14 +1219,15 @@ async def remove_snapshot(request):
return web.Response(status=400)
@routes.get("/snapshot/restore")
@routes.post("/snapshot/restore")
async def restore_snapshot(request):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return security_403_response()
try:
target = request.rel_url.query["target"]
json_data = await request.json()
target = json_data["target"]
path = get_safe_snapshot_path(target)
if path is None:
@@ -1066,8 +1258,11 @@ async def get_current_snapshot_api(request):
return web.Response(status=400)
@routes.get("/snapshot/save")
@routes.post("/snapshot/save")
async def save_snapshot(request):
resp = _reject_simple_form_content_type(request)
if resp is not None:
return resp
try:
await core.save_snapshot_with_postfix('snapshot')
return web.Response(status=200)
@@ -1224,12 +1419,14 @@ async def import_fail_info(request):
@routes.post("/manager/queue/reinstall")
async def reinstall_custom_node(request):
await uninstall_custom_node(request)
await install_custom_node(request)
return await _queue_node_install(request, "reinstall")
@routes.get("/manager/queue/reset")
@routes.post("/manager/queue/reset")
async def reset_queue(request):
resp = _reject_simple_form_content_type(request)
if resp is not None:
return resp
global task_queue
task_queue = queue.Queue()
return web.Response(status=200)
@@ -1252,16 +1449,20 @@ async def queue_count(request):
@routes.post("/manager/queue/install")
async def install_custom_node(request):
return await _queue_node_install(request, "install")
async def _queue_node_install(request, operation):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return web.Response(status=403, text="A security error has occurred. Please check the terminal logs")
json_data = await request.json()
# non-nightly cnr is safe
# CNR version status is checked by the worker before installation.
risky_level = None
cnr_id = json_data.get('id')
skip_post_install = json_data.get('skip_post_install')
skip_post_install = False if operation == 'reinstall' else json_data.get('skip_post_install')
git_url = None
@@ -1270,6 +1471,26 @@ async def install_custom_node(request):
if skip_post_install:
if cnr_id in core.unified_manager.nightly_inactive_nodes or cnr_id in core.unified_manager.cnr_inactive_nodes:
core.unified_manager.unified_enable(cnr_id)
# Mirror the pair of events (in_progress then done) that the async
# task_worker normally emits for queued operations. The in_progress
# event is what sets item.restart=true on the client row so the
# action cell re-renders as "Restart Required"; without it, the
# "Enable" button remains visible after successful enable. The done
# event drives the completion UI (toast, restart indicator, button
# loading clear).
ui_id = json_data.get('ui_id', cnr_id)
PromptServer.instance.send_sync(
"cm-queue-status",
{'status': 'in_progress',
'target': ui_id,
'ui_target': 'nodepack_manager',
'total_count': 1, 'done_count': 0})
PromptServer.instance.send_sync(
"cm-queue-status",
{'status': 'done',
'nodepack_result': {ui_id: 'success'},
'model_result': {},
'total_count': 1, 'done_count': 1})
return web.Response(status=200)
elif selected_version is None:
selected_version = 'latest'
@@ -1299,20 +1520,33 @@ async def install_custom_node(request):
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):
logging.error(SECURITY_MESSAGE_GENERAL)
if risky_level == 'high':
# unknown-URL arm: governed by the dedicated flag predicate
# (flag AND loopback, evaluated at request time). The loopback
# term is load-bearing here — the 'middle' entry gate above has
# no network-position term.
if not is_dedicated_install_allowed(core.get_config()['allow_git_url_install'], args.listen):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(listen=args.listen))
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
elif not is_allowed_security_level(risky_level):
# 'block' arm stays an unconditional deny (is_allowed_security_level
# returns False for 'block'); 'middle'/'low' arms unchanged.
logging.error(SECURITY_MESSAGE_BLOCKED_RISK)
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install
task_queue.put(("install", install_item))
task_queue.put((operation, install_item))
return web.Response(status=200)
task_worker_thread:threading.Thread = None
@routes.get("/manager/queue/start")
@routes.post("/manager/queue/start")
async def queue_start(request):
resp = _reject_simple_form_content_type(request)
if resp is not None:
return resp
global nodepack_result
global model_result
global task_worker_thread
@@ -1353,11 +1587,21 @@ async def fix_custom_node(request):
@routes.post("/customnode/install/git_url")
async def install_custom_node_git_url(request):
if not is_allowed_security_level('high'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
return security_403_response()
if not is_dedicated_install_allowed(core.get_config()['allow_git_url_install'], args.listen):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL.format(listen=args.listen))
return security_403_response(flag_token='allow_git_url_install')
url = await request.text()
# Read the body as JSON (not raw text): a cross-origin <form method=POST>
# cannot forge an application/json body without a CORS preflight, which this
# server does not answer — same body-handler convention as every other
# request.json() route (see _reject_simple_form_content_type docstring).
# A malformed body or a missing 'url' is a client error (400), not a 500:
# don't let JSONDecodeError/KeyError bubble up as a server fault.
try:
json_data = await request.json()
url = json_data['url']
except (KeyError, ValueError):
return web.Response(status=400, text="Invalid request body: expected JSON object with a 'url' field")
res = await core.gitclone_install(url)
if res.action == 'skip':
@@ -1373,11 +1617,18 @@ async def install_custom_node_git_url(request):
@routes.post("/customnode/install/pip")
async def install_custom_node_pip(request):
if not is_allowed_security_level('high'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
return security_403_response()
if not is_dedicated_install_allowed(core.get_config()['allow_pip_install'], args.listen):
logging.error(SECURITY_MESSAGE_FLAG_PIP.format(listen=args.listen))
return security_403_response(flag_token='allow_pip_install')
packages = await request.text()
# JSON body (not raw text) for the same preflight-forcing reason as
# /customnode/install/git_url above; malformed body or missing 'packages'
# is a 400, not a 500.
try:
json_data = await request.json()
packages = json_data['packages']
except (KeyError, ValueError):
return web.Response(status=400, text="Invalid request body: expected JSON object with a 'packages' field")
core.pip_install(packages.split(' '))
return web.Response(status=200)
@@ -1427,8 +1678,11 @@ async def update_custom_node(request):
return web.Response(status=200)
@routes.get("/manager/queue/update_comfyui")
@routes.post("/manager/queue/update_comfyui")
async def update_comfyui(request):
resp = _reject_simple_form_content_type(request)
if resp is not None:
return resp
is_stable = core.get_config()['update_policy'] != 'nightly-comfyui'
task_queue.put(("update-comfyui", ('comfyui', is_stable)))
return web.Response(status=200)
@@ -1445,11 +1699,12 @@ async def comfyui_versions(request):
return web.Response(status=400)
@routes.get("/comfyui_manager/comfyui_switch_version")
@routes.post("/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'])
json_data = await request.json()
if "ver" in json_data:
core.switch_comfyui(json_data['ver'])
return web.Response(status=200)
except Exception as e:
@@ -1526,99 +1781,89 @@ async def install_model(request):
@routes.get("/manager/preview_method")
async def preview_method(request):
# Setting change request
if "value" in request.rel_url.query:
# Reject setting change if per-queue preview feature is available
if COMFYUI_HAS_PER_QUEUE_PREVIEW:
return web.Response(text="DISABLED", status=403)
# Process normally if not available
set_preview_method(request.rel_url.query['value'])
core.write_config()
return web.Response(status=200)
# Status query request
else:
# Return DISABLED if per-queue preview feature is available
if COMFYUI_HAS_PER_QUEUE_PREVIEW:
return web.Response(text="DISABLED", status=200)
# Return current value if not available
return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200)
async def get_preview_method(request):
if COMFYUI_HAS_PER_QUEUE_PREVIEW:
return web.Response(text="DISABLED", status=200)
return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200)
@routes.get("/manager/db_mode")
async def db_mode(request):
if "value" in request.rel_url.query:
set_db_mode(request.rel_url.query['value'])
core.write_config()
else:
return web.Response(text=core.get_config()['db_mode'], status=200)
@routes.post("/manager/preview_method")
async def set_preview_method_handler(request):
if COMFYUI_HAS_PER_QUEUE_PREVIEW:
return web.Response(text="DISABLED", status=403)
json_data = await request.json()
set_preview_method(json_data['value'])
core.write_config()
return web.Response(status=200)
@routes.get("/manager/db_mode")
async def get_db_mode(request):
return web.Response(text=core.get_config()['db_mode'], status=200)
@routes.post("/manager/db_mode")
async def set_db_mode_handler(request):
json_data = await request.json()
set_db_mode(json_data['value'])
core.write_config()
return web.Response(status=200)
@routes.get("/manager/policy/component")
async def 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)
async def get_component_policy(request):
return web.Response(text=core.get_config()['component_policy'], status=200)
@routes.post("/manager/policy/component")
async def set_component_policy_handler(request):
json_data = await request.json()
set_component_policy(json_data['value'])
core.write_config()
return web.Response(status=200)
@routes.get("/manager/policy/update")
async def 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)
async def get_update_policy(request):
return web.Response(text=core.get_config()['update_policy'], status=200)
@routes.post("/manager/policy/update")
async def set_update_policy_handler(request):
json_data = await request.json()
set_update_policy(json_data['value'])
core.write_config()
return web.Response(status=200)
@routes.get("/manager/channel_url_list")
async def channel_url_list(request):
async def get_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)
@routes.post("/manager/channel_url_list")
async def set_channel_url_list(request):
json_data = await request.json()
channels = core.get_channel_dict()
channel_url = channels.get(json_data['value'])
if channel_url is not None:
core.get_config()['channel_url'] = channel_url
core.write_config()
return web.Response(status=200)
def add_target_blank(html_text):
pattern = r'(<a\s+href="[^"]*"\s*[^>]*)(>)'
def add_target(match):
if 'target=' not in match.group(1):
return match.group(1) + ' target="_blank"' + match.group(2)
return match.group(0)
modified_html = re.sub(pattern, add_target, html_text)
return modified_html
@routes.get("/manager/notice")
async def get_notice(request):
url = "github.com"
@@ -1634,7 +1879,8 @@ async def get_notice(request):
match = pattern.search(html_content)
if match:
markdown_content = match.group(1)
# Sanitize remote HTML before appending Manager's version text.
markdown_content = manager_util.sanitize_html_fragment(match.group(1))
version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__')
if version_tag is not None:
markdown_content += f"<HR>ComfyUI: {version_tag} [Desktop]"
@@ -1648,8 +1894,6 @@ async def get_notice(request):
# markdown_content += f"<BR>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;()"
markdown_content += f"<BR>Manager: {core.version_str}"
markdown_content = add_target_blank(markdown_content)
try:
if '__COMFYUI_DESKTOP_VERSION__' not in os.environ:
if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0):
@@ -1700,8 +1944,11 @@ async def get_startup_alerts(request):
return web.json_response(alerts)
@routes.get("/manager/reboot")
@routes.post("/manager/reboot")
def restart(self):
resp = _reject_simple_form_content_type(self)
if resp is not None:
return resp
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return security_403_response()
+127 -1
View File
@@ -3,6 +3,8 @@ description:
`manager_util` is the lightest module shared across the prestartup_script, main code, and cm-cli of ComfyUI-Manager.
"""
import traceback
import ipaddress
import socket
import aiohttp
import json
@@ -16,6 +18,8 @@ import logging
import platform
import shlex
from functools import lru_cache
from html import escape, unescape
from html.entities import html5
cache_lock = threading.Lock()
@@ -26,6 +30,50 @@ cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also up
use_uv = False
bypass_ssl = False
FLAGGED_NODEPACK_INSTALL_ERROR = (
'This action is not allowed by the current security configuration. '
'See the terminal for details.'
)
FLAGGED_NODEPACK_INSTALL_GUIDANCE = (
'Installation of this flagged CNR version is blocked. To satisfy the additional '
'flagged-version requirement, choose one of the following:\n'
'1. Run ComfyUI with loopback-only listeners, for example --listen 127.0.0.1 '
'or --listen ::1. All configured listen addresses must be loopback.\n'
' Do not use bare --listen or any non-loopback address, such as 0.0.0.0 or ::.\n'
'2. For a trusted private network, set allow_flagged_nodepack_install = true '
'in the [default] section of ComfyUI-Manager\'s config.ini.\n'
'Restart ComfyUI after changing the listener or configuration. '
'All other installation security checks still apply.'
)
def is_loopback_listener(listen_address: str) -> bool:
"""All addresses bound by --listen must resolve exclusively to loopback."""
if not isinstance(listen_address, str) or not listen_address:
return False
for address in listen_address.split(','):
address = address.strip()
if not address:
return False
try:
if ipaddress.ip_address(address).is_loopback:
continue
except ValueError:
pass
try:
resolved = socket.getaddrinfo(address, None, type=socket.SOCK_STREAM)
except OSError:
return False
if not resolved or any(not ipaddress.ip_address(item[4][0]).is_loopback for item in resolved):
return False
return True
def is_cnr_install_allowed(status: str, allow_flagged: bool, listen_address: str) -> bool:
"""Only flagged versions require loopback or the private-network opt-in."""
return status != 'NodeVersionStatusFlagged' or bool(allow_flagged) or is_loopback_listener(listen_address)
def add_python_path_to_env():
if platform.system() != "Windows":
sep = ':'
@@ -268,6 +316,84 @@ def sanitize_tag(x):
return x.replace('<', '&lt;').replace('>', '&gt;')
SAFE_URL_SCHEMES = frozenset({'http', 'https'})
_URL_SCHEME_NOISE = re.compile(r'[\x00-\x20\x7f]')
_URL_HEAD_DELIMITERS = ('/', '?', '#')
def escape_html_attribute(value):
"""Escape a value for a quoted HTML attribute."""
return escape(str(value), quote=True)
_HTML_ENTITY = re.compile(r'&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);')
_HTML_TEXT_TOKEN = re.compile(_HTML_ENTITY.pattern + "|[&<>\"']")
def unescape_html_entities(text):
"""Decode complete entities once, leaving query keys such as &notebook intact."""
def replace(match):
entity = match.group(1)
if entity.startswith('#'):
return unescape(match.group(0))
return html5.get(entity + ';', match.group(0))
return _HTML_ENTITY.sub(replace, text)
def escape_html_text(value):
"""Escape HTML text without double-escaping existing entities."""
return _HTML_TEXT_TOKEN.sub(
lambda m: m.group(0) if m.group(1) else escape_html_attribute(m.group(0)),
str(value).replace('\x00', ''),
)
def sanitize_url(url):
"""Allow HTTP(S), relative URLs and anchors.
HTML callers must also escape the result as an attribute."""
raw = '' if url is None else str(url)
probe = _URL_SCHEME_NOISE.sub('', raw)
if not probe:
return '#'
cut = len(probe)
for delimiter in _URL_HEAD_DELIMITERS:
found = probe.find(delimiter)
if found != -1:
cut = min(cut, found)
head = probe[:cut]
if ':' in head:
return raw.strip() if head.split(':', 1)[0].lower() in SAFE_URL_SCHEMES else '#'
return raw.strip()
def sanitize_html_fragment(fragment):
"""Sanitize notice HTML, preserving its formatting and opening links safely."""
# prestartup_script imports this module before installing dependencies.
import nh3
return nh3.clean(
'' if fragment is None else str(fragment),
tags=nh3.ALLOWED_TAGS | {'font'},
attributes={
**nh3.ALLOWED_ATTRIBUTES,
'*': {'class', 'title', 'align', 'width', 'height'},
'font': {'color'},
},
# These elements previously hid their contents from the notice.
clean_content_tags={
'script', 'style', 'iframe', 'object', 'embed', 'svg', 'math',
'template', 'noscript', 'textarea', 'title',
},
url_schemes=SAFE_URL_SCHEMES,
set_tag_attribute_values={'a': {'target': '_blank'}},
)
def extract_package_as_zip(file_path, extract_path):
import zipfile
try:
@@ -630,4 +756,4 @@ def restore_pip_snapshot(pips, options):
if res != 0:
failed.append(x)
print(f"Installation failed for pip packages: {failed}")
print(f"Installation failed for pip packages: {failed}")
+2
View File
@@ -9,6 +9,7 @@ This directory contains the JavaScript frontend implementation for ComfyUI-Manag
- **model-manager.js**: Handles the model management interface for downloading and organizing AI models.
- **components-manager.js**: Manages reusable workflow components system.
- **snapshot.js**: Implements the snapshot system for backing up and restoring installations.
- **node-usage-analyzer.js**: Implements the UI for analyzing node usage in workflows.
## Sharing Components
@@ -46,5 +47,6 @@ The frontend follows a modular component-based architecture:
CSS files are included for specific components:
- **custom-nodes-manager.css**: Styling for the node management UI
- **model-manager.css**: Styling for the model management UI
- **node-usage-analyzer.css**: Styling for the node usage analyzer UI
This frontend implementation provides a comprehensive yet user-friendly interface for managing the ComfyUI ecosystem.
+1 -1
View File
@@ -52,7 +52,7 @@ async function tryInstallCustomNode(event) {
}
}
let response = await api.fetchApi("/manager/reboot");
let response = await api.fetchApi("/manager/reboot", { method: 'POST' });
if(response.status == 403) {
await handle403Response(response);
return false;
+33 -21
View File
@@ -14,10 +14,11 @@ import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import {
free_models, install_pip, install_via_git_url, manager_instance,
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
infoToast, showTerminal, setNeedRestart, handle403Response
infoToast, showTerminal, setNeedRestart, handle403Response, sanitizeHTML, safeHref
} from "./common.js";
import { ComponentBuilderDialog, getPureName, load_components, set_component_policy } from "./components-manager.js";
import { CustomNodesManager } from "./custom-nodes-manager.js";
import { NodeUsageAnalyzer } from "./node-usage-analyzer.js";
import { ModelManager } from "./model-manager.js";
import { SnapshotManager } from "./snapshot.js";
import { buildGuiFrame, createSettingsCombo } from "./comfyui-gui-builder.js";
@@ -470,12 +471,12 @@ async function updateComfyUI() {
set_inprogress_mode();
const response = await api.fetchApi('/manager/queue/update_comfyui');
const response = await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
showTerminal();
is_updating = true;
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
}
function showVersionSelectorDialog(versions, current, onSelect) {
@@ -625,14 +626,14 @@ async function switchComfyUI() {
showVersionSelectorDialog(versions, obj.current, async (selected_version) => {
if(selected_version == 'nightly') {
update_policy_combo.value = 'nightly-comfyui';
api.fetchApi('/manager/policy/update?value=nightly-comfyui');
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'nightly-comfyui' }) });
}
else {
update_policy_combo.value = 'stable-comfyui';
api.fetchApi('/manager/policy/update?value=stable-comfyui');
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'stable-comfyui' }) });
}
let response = await api.fetchApi(`/comfyui_manager/comfyui_switch_version?ver=${selected_version}`, { cache: "no-store" });
let response = await api.fetchApi('/comfyui_manager/comfyui_switch_version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ver: selected_version }), cache: "no-store" });
if (response.status == 200) {
infoToast(`ComfyUI version is switched to ${selected_version}`);
}
@@ -712,12 +713,12 @@ async function onQueueStatus(event) {
for(let x in success_list) {
let k = success_list[x];
let url = event.detail.nodepack_result[k].url;
let title = event.detail.nodepack_result[k].title;
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
if(url) {
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
}
else {
msg += `<li>${k}</li>`;
msg += `<li>${sanitizeHTML(String(k))}</li>`;
}
}
msg += "</ul>";
@@ -731,12 +732,12 @@ async function onQueueStatus(event) {
for(let x in failed_list) {
let k = failed_list[x];
let url = event.detail.nodepack_result[k].url;
let title = event.detail.nodepack_result[k].title;
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
if(url) {
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
}
else {
msg += `<li>${k}</li>`;
msg += `<li>${sanitizeHTML(String(k))}</li>`;
}
}
@@ -769,10 +770,10 @@ async function updateAll(update_comfyui) {
if(update_comfyui) {
update_all_button.innerText = "Updating ComfyUI...";
await api.fetchApi('/manager/queue/update_comfyui');
await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
}
const response = await api.fetchApi(`/manager/queue/update_all?mode=${mode}`);
const response = await api.fetchApi('/manager/queue/update_all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: mode }) });
if (response.status == 403) {
await handle403Response(response);
@@ -784,7 +785,7 @@ async function updateAll(update_comfyui) {
}
else if(response.status == 200) {
is_updating = true;
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
}
}
@@ -813,7 +814,7 @@ function restartOrStop() {
rebootAPI();
}
else {
api.fetchApi('/manager/queue/reset');
api.fetchApi('/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
}
@@ -909,6 +910,17 @@ class ManagerMenuDialog extends ComfyDialog {
CustomNodesManager.instance.show(CustomNodesManager.ShowMode.IN_WORKFLOW);
}
}),
$el("button.cm-button", {
type: "button",
textContent: "Node Usage Analyzer",
onclick:
() => {
if(!NodeUsageAnalyzer.instance) {
NodeUsageAnalyzer.instance = new NodeUsageAnalyzer(app, self);
}
NodeUsageAnalyzer.instance.show(NodeUsageAnalyzer.SortMode.BY_PACKAGE);
}
}),
$el("div", {}, []),
$el("button.p-button.p-component.cm-button", {
@@ -967,7 +979,7 @@ class ManagerMenuDialog extends ComfyDialog {
.then(data => { this.datasrc_combo.value = data; });
this.datasrc_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/db_mode?value=${event.target.value}`);
api.fetchApi('/manager/db_mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
});
const dbRetrievalSetttingItem = createSettingsCombo("DB", this.datasrc_combo);
@@ -1043,7 +1055,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
// Normal operation
api.fetchApi(`/manager/preview_method?value=${event.target.value}`)
api.fetchApi('/manager/preview_method', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) })
.then(response => {
if (response.status === 403) {
// Feature transitioned to native
@@ -1087,7 +1099,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
channel_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/channel_url_list?value=${event.target.value}`);
api.fetchApi('/manager/channel_url_list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
});
channel_combo.value = data.selected;
@@ -1152,7 +1164,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
component_policy_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/policy/component?value=${event.target.value}`);
api.fetchApi('/manager/policy/component', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
set_component_policy(event.target.value);
});
@@ -1171,7 +1183,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
update_policy_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/policy/update?value=${event.target.value}`);
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
});
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
+2 -2
View File
@@ -4,7 +4,7 @@ import { $el, ComfyDialog } from "../../scripts/ui.js";
import { CopusShareDialog } from "./comfyui-share-copus.js";
import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import { YouMLShareDialog } from "./comfyui-share-youml.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref, sanitizeHTML } from "./common.js";
export const SUPPORTED_OUTPUT_NODE_TYPES = [
"PreviewImage",
@@ -937,7 +937,7 @@ export class ShareDialog extends ComfyDialog {
const response_json = await response.json();
if (response_json.comfyworkflows.url) {
this.final_message.innerHTML = "Your art has been shared: <a href='" + response_json.comfyworkflows.url + "' target='_blank'>" + response_json.comfyworkflows.url + "</a>";
this.final_message.innerHTML = "Your art has been shared: <a href='" + safeHref(response_json.comfyworkflows.url) + "' target='_blank' rel='noopener noreferrer'>" + sanitizeHTML(response_json.comfyworkflows.url) + "</a>";
if (response_json.matrix.success) {
this.final_message.innerHTML += "<br>Your art has been shared in the ComfyUI Matrix server's #share channel!";
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { $el, ComfyDialog } from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref } from "./common.js";
const env = "prod";
@@ -883,7 +883,7 @@ export class CopusShareDialog extends ComfyDialog {
const { data } = res.data;
if (data) {
const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
this.previewImage.src = "";
this.previewImage.style.display = "none";
this.uploadedImages = [];
+2 -2
View File
@@ -1,7 +1,7 @@
import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {ComfyDialog, $el} from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref } from "./common.js";
const LOCAL_STORAGE_KEY = "openart_comfy_workflow_key";
const DEFAULT_HOMEPAGE_URL = "https://openart.ai/workflows/dev?developer=true";
@@ -511,7 +511,7 @@ export class OpenArtShareDialog extends ComfyDialog {
const {workflow_id} = response.data;
if (workflow_id) {
const url = `https://openart.ai/workflows/-/-/${workflow_id}`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
this.previewImage.src = "";
this.previewImage.style.display = "none";
this.uploadedImages = [];
+2 -2
View File
@@ -1,7 +1,7 @@
import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {ComfyDialog, $el} from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref } from "./common.js";
const BASE_URL = "https://youml.com";
//const BASE_URL = "http://localhost:3000";
@@ -420,7 +420,7 @@ export class YouMLShareDialog extends ComfyDialog {
}
}
this.message.innerHTML = `${messagePrefix} To turn your workflow into an interactive app, ` +
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
`<a href="${safeHref(recipePageUrl)}" target="_blank" rel="noopener noreferrer">visit it on YouML</a>`;
this.uploadedImages = [];
this.nameInput.value = "";
+488 -64
View File
@@ -122,9 +122,9 @@ export async function customConfirm(message) {
let res = await
window['app'].extensionManager.dialog
.confirm({
title: 'Confirm',
message: message
});
title: 'Confirm',
message: message
});
return res;
}
@@ -164,9 +164,9 @@ export async function customPrompt(title, message) {
let res = await
window['app'].extensionManager.dialog
.prompt({
title: title,
message: message
});
title: title,
message: message
});
return res;
}
@@ -185,7 +185,7 @@ export async function rebootAPI() {
const isConfirmed = await customConfirm("Are you sure you'd like to reboot the server?");
if (isConfirmed) {
try {
const response = await api.fetchApi("/manager/reboot");
const response = await api.fetchApi("/manager/reboot", { method: 'POST' });
if (response.status == 403) {
await handle403Response(response);
return false;
@@ -223,16 +223,19 @@ function isValidURL(url) {
}
export async function install_pip(packages) {
if(packages.includes('&'))
if(packages.includes('&')) {
app.ui.dialog.show(`Invalid PIP package enumeration: '${packages}'`);
return;
}
const res = await api.fetchApi("/customnode/install/pip", {
method: "POST",
body: packages,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ packages: packages }),
});
if(res.status == 403) {
await handle403Response(res);
await handle403Response(res, "To use this feature you need BOTH: (1) 'allow_pip_install = true' in config.ini ([default] section), AND (2) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1). Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: stop ComfyUI, change the setting, then start it again.");
return;
}
@@ -263,11 +266,12 @@ export async function install_via_git_url(url, manager_dialog) {
const res = await api.fetchApi("/customnode/install/git_url", {
method: "POST",
body: url,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url }),
});
if(res.status == 403) {
await handle403Response(res);
await handle403Response(res, "To use this feature you need BOTH: (1) 'allow_git_url_install = true' in config.ini ([default] section), AND (2) ComfyUI launched with a loopback --listen (127.0.0.1 or ::1). Both values are read once at ComfyUI startup, so changing either one needs a restart, done with the server down: stop ComfyUI, change the setting, then start it again.");
return;
}
@@ -423,6 +427,40 @@ export function sanitizeHTML(str) {
.replace(/'/g, "&#039;");
}
// Keep URL handling aligned with glob/manager_util.py; covered by shared cases.
export const SAFE_URL_SCHEMES = ['http', 'https'];
const URL_SCHEME_NOISE = /[\x00-\x20\x7f]/g;
const URL_HEAD_DELIMITERS = ['/', '?', '#'];
// Match Python str.strip(); JavaScript trim() uses different Unicode whitespace.
const URL_TRIM = /^[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g;
const trimUrl = (value) => value.replace(URL_TRIM, '');
export function sanitizeUrl(url) {
const raw = (url === null || url === undefined) ? '' : String(url);
const probe = raw.replace(URL_SCHEME_NOISE, '');
if (!probe) {
return '#';
}
let cut = probe.length;
for (const delimiter of URL_HEAD_DELIMITERS) {
const found = probe.indexOf(delimiter);
if (found !== -1) {
cut = Math.min(cut, found);
}
}
const head = probe.slice(0, cut);
if (head.includes(':')) {
return SAFE_URL_SCHEMES.includes(head.split(':', 1)[0].toLowerCase()) ? trimUrl(raw) : '#';
}
return trimUrl(raw);
}
// Validate the URL, then escape it for a quoted href attribute.
export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));
export function showTerminal() {
try {
const panel = app.extensionManager.bottomPanel;
@@ -610,61 +648,447 @@ export function showPopover(target, text, className, options) {
});
}
let $tooltip;
export function hideTooltip(target) {
if ($tooltip) {
$tooltip.style.display = "none";
$tooltip.innerHTML = "";
$tooltip.style.top = "0px";
$tooltip.style.left = "0px";
}
}
export function showTooltip(target, text, className = 'cn-tooltip', styleMap = {}) {
if (!$tooltip) {
$tooltip = document.createElement("div");
$tooltip.className = className;
$tooltip.style.cssText = `
pointer-events: none;
position: fixed;
z-index: 10001;
padding: 20px;
color: #1e1e1e;
max-width: 350px;
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
${Object.keys(styleMap).map(k=>k+":"+styleMap[k]+";").join("")}
`;
document.body.appendChild($tooltip);
export async function uninstallNodes(nodeList, options = {}) {
const {
title = `${nodeList.length} custom nodes`,
onProgress = () => {},
onError = () => {},
onSuccess = () => {},
channel = 'default',
mode = 'default'
} = options;
// Check if queue is busy
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if (stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return { success: false, error: 'Queue is busy' };
}
$tooltip.innerHTML = text;
$tooltip.style.display = "block";
renderPopover($tooltip, target, {
positions: ['top', 'bottom', 'right', 'center'],
bgColor: "#ffffff",
borderColor: "#cccccc",
borderRadius: 5
// Confirmation dialog for uninstall
const confirmed = await customConfirm(`Are you sure uninstall ${title}?`);
if (!confirmed) {
return { success: false, error: 'User cancelled' };
}
let errorMsg = "";
let target_items = [];
await api.fetchApi('/manager/queue/reset');
for (const nodeItem of nodeList) {
target_items.push(nodeItem);
// Titles are escaped by the caller; the name fallback is a raw pack key.
const displayTitle = nodeItem.title || sanitizeHTML(String(nodeItem.name));
onProgress(`Uninstall ${displayTitle} ...`);
const data = nodeItem.originalData || nodeItem;
data.channel = channel;
data.mode = mode;
data.ui_id = nodeItem.hash || md5(nodeItem.name || nodeItem.title);
const res = await api.fetchApi(`/manager/queue/uninstall`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
errorMsg = `'${displayTitle}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
} else {
errorMsg += sanitizeHTML(await res.text()) + '\n';
}
break;
}
}
if (errorMsg) {
onError(errorMsg);
show_message("[Uninstall Errors]\n" + errorMsg);
return { success: false, error: errorMsg, targets: target_items };
} else {
await api.fetchApi('/manager/queue/start');
onSuccess(target_items);
showTerminal();
return { success: true, targets: target_items };
}
}
// ===========================================================================================
// Workflow Utilities Consolidation
export async function getWorkflowNodeTypes() {
try {
const res = await fetchData('/customnode/get_node_types_in_workflows');
if (res.status === 200) {
return { success: true, data: res.data };
} else if (res.status === 204) {
// No workflows found - return empty list
return { success: true, data: [] };
} else {
return { success: false, error: res.error };
}
} catch (error) {
return { success: false, error: error };
}
}
export function findPackageByCnrId(cnrId, nodePackages, installedOnly = true) {
if (!cnrId || !nodePackages) {
return null;
}
// Tier 1: Direct key match
if (nodePackages[cnrId]) {
const pack = nodePackages[cnrId];
if (!installedOnly || pack.state !== "not-installed") {
return { key: cnrId, pack: pack };
}
}
// Tier 2: Case-insensitive match
const cnrIdLower = cnrId.toLowerCase();
for (const packKey of Object.keys(nodePackages)) {
if (packKey.toLowerCase() === cnrIdLower) {
const pack = nodePackages[packKey];
if (!installedOnly || pack.state !== "not-installed") {
return { key: packKey, pack: pack };
}
}
}
// Tier 3: URL/reference contains match
for (const packKey of Object.keys(nodePackages)) {
const pack = nodePackages[packKey];
// Skip non-installed packages if installedOnly is true
if (installedOnly && pack.state === "not-installed") {
continue;
}
// Check if reference URL contains cnr_id
if (pack.reference && pack.reference.includes(cnrId)) {
return { key: packKey, pack: pack };
}
// Check if any file URL contains cnr_id
if (pack.files && Array.isArray(pack.files)) {
for (const fileUrl of pack.files) {
if (fileUrl.includes(cnrId)) {
return { key: packKey, pack: pack };
}
}
}
}
return null;
}
export async function analyzeWorkflowUsage(nodePackages) {
const result = await getWorkflowNodeTypes();
if (!result.success) {
return { success: false, error: result.error };
}
const workflowNodeList = result.data;
const usageMap = new Map();
const workflowDetailsMap = new Map();
if (workflowNodeList && Array.isArray(workflowNodeList)) {
const cnrIdCounts = new Map();
const cnrIdToWorkflows = new Map();
// Process each workflow
workflowNodeList.forEach((workflowObj, workflowIndex) => {
if (workflowObj.node_types && Array.isArray(workflowObj.node_types)) {
const workflowCnrIds = new Set();
// Get workflow filename
const workflowFilename = workflowObj.workflow_file_name ||
workflowObj.filename ||
workflowObj.file ||
workflowObj.name ||
workflowObj.path ||
`Workflow ${workflowIndex + 1}`;
// Count nodes per cnr_id in this workflow
const workflowCnrIdCounts = new Map();
workflowObj.node_types.forEach(nodeTypeObj => {
const cnrId = nodeTypeObj.cnr_id;
if (cnrId && cnrId !== "comfy-core") {
// Track unique cnr_ids per workflow
workflowCnrIds.add(cnrId);
// Count nodes per cnr_id in this specific workflow
const workflowNodeCount = workflowCnrIdCounts.get(cnrId) || 0;
workflowCnrIdCounts.set(cnrId, workflowNodeCount + 1);
}
});
// Record workflow details for each unique cnr_id found in this workflow
workflowCnrIds.forEach(cnrId => {
// Count occurrences of this cnr_id across all workflows
const currentCount = cnrIdCounts.get(cnrId) || 0;
cnrIdCounts.set(cnrId, currentCount + 1);
// Track workflow details
if (!cnrIdToWorkflows.has(cnrId)) {
cnrIdToWorkflows.set(cnrId, []);
}
cnrIdToWorkflows.get(cnrId).push({
filename: workflowFilename,
nodeCount: workflowCnrIdCounts.get(cnrId) || 0
});
});
}
});
// Map cnr_id to installed packages with workflow details
cnrIdCounts.forEach((count, cnrId) => {
const workflowDetails = cnrIdToWorkflows.get(cnrId) || [];
const foundPackage = findPackageByCnrId(cnrId, nodePackages, true);
if (foundPackage) {
usageMap.set(foundPackage.key, count);
workflowDetailsMap.set(foundPackage.key, workflowDetails);
}
});
}
return {
success: true,
usageMap: usageMap,
workflowDetailsMap: workflowDetailsMap
};
}
// Size formatting utilities - consolidated from model-manager.js and node-usage-analyzer.js
export function formatSize(v) {
const base = 1000;
const units = ['', 'K', 'M', 'G', 'T', 'P'];
const space = '';
const postfix = 'B';
if (v <= 0) {
return `0${space}${postfix}`;
}
for (let i = 0, l = units.length; i < l; i++) {
const min = Math.pow(base, i);
const max = Math.pow(base, i + 1);
if (v > min && v <= max) {
const unit = units[i];
if (unit) {
const n = v / min;
const nl = n.toString().split('.')[0].length;
const fl = Math.max(3 - nl, 1);
v = n.toFixed(fl);
}
v = v + space + unit + postfix;
break;
}
}
return v;
}
// for size sort
export function sizeToBytes(v) {
if (typeof v === "number") {
return v;
}
if (typeof v === "string") {
const n = parseFloat(v);
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
if (unit === "K") {
return n * 1000;
}
if (unit === "M") {
return n * 1000 * 1000;
}
if (unit === "G") {
return n * 1000 * 1000 * 1000;
}
if (unit === "T") {
return n * 1000 * 1000 * 1000 * 1000;
}
}
return v;
}
// Flyover component - consolidated from custom-nodes-manager.js and node-usage-analyzer.js
export function createFlyover(container, options = {}) {
const {
enableHover = false,
hoverHandler = null,
context = null
} = options;
const $flyover = document.createElement("div");
$flyover.className = "cn-flyover";
$flyover.innerHTML = `<div class="cn-flyover-header">
<div class="cn-flyover-close">${icons.arrowRight}</div>
<div class="cn-flyover-title"></div>
<div class="cn-flyover-close">${icons.close}</div>
</div>
<div class="cn-flyover-body"></div>`
container.appendChild($flyover);
const $flyoverTitle = $flyover.querySelector(".cn-flyover-title");
const $flyoverBody = $flyover.querySelector(".cn-flyover-body");
let width = '50%';
let visible = false;
let timeHide;
const closeHandler = (e) => {
if ($flyover === e.target || $flyover.contains(e.target)) {
return;
}
clearTimeout(timeHide);
timeHide = setTimeout(() => {
flyover.hide();
}, 100);
}
const displayHandler = () => {
if (visible) {
$flyover.classList.remove("cn-slide-in-right");
} else {
$flyover.classList.remove("cn-slide-out-right");
$flyover.style.width = '0px';
$flyover.style.display = "none";
}
}
const flyover = {
show: (titleHtml, bodyHtml) => {
clearTimeout(timeHide);
if (context && context.element) {
context.element.removeEventListener("click", closeHandler);
}
$flyoverTitle.innerHTML = titleHtml;
$flyoverBody.innerHTML = bodyHtml;
$flyover.style.display = "block";
$flyover.style.width = width;
if(!visible) {
$flyover.classList.add("cn-slide-in-right");
}
visible = true;
setTimeout(() => {
if (context && context.element) {
context.element.addEventListener("click", closeHandler);
}
}, 100);
},
hide: (now) => {
visible = false;
if (context && context.element) {
context.element.removeEventListener("click", closeHandler);
}
if(now) {
displayHandler();
return;
}
$flyover.classList.add("cn-slide-out-right");
}
}
$flyover.addEventListener("animationend", (e) => {
displayHandler();
});
// Add hover handlers if enabled
if (enableHover && hoverHandler) {
$flyover.addEventListener("mouseenter", hoverHandler, true);
$flyover.addEventListener("mouseleave", hoverHandler, true);
}
$flyover.addEventListener("click", (e) => {
if(e.target.classList.contains("cn-flyover-close")) {
flyover.hide();
return;
}
// Forward other click events to the provided handler or context
if (context && context.handleFlyoverClick) {
context.handleFlyoverClick(e);
}
});
return flyover;
}
function initTooltip () {
const mouseenterHandler = (e) => {
const target = e.target;
const text = target.getAttribute('tooltip');
if (text) {
showTooltip(target, text);
}
};
const mouseleaveHandler = (e) => {
const target = e.target;
const text = target.getAttribute('tooltip');
if (text) {
hideTooltip(target);
}
};
document.body.removeEventListener('mouseenter', mouseenterHandler, true);
document.body.removeEventListener('mouseleave', mouseleaveHandler, true);
document.body.addEventListener('mouseenter', mouseenterHandler, true);
document.body.addEventListener('mouseleave', mouseleaveHandler, true);
}
// Message methods accept HTML; callers escape raw data and preserve formatted content.
export function createUIStateManager(element, selectors) {
return {
showSelection: (msg) => {
const el = element.querySelector(selectors.selection);
if (el) el.innerHTML = msg;
},
initTooltip();
showError: (err) => {
const el = element.querySelector(selectors.message);
if (el) {
const msg = err ? `<font color="red">${err}</font>` : "";
el.innerHTML = msg;
}
},
showMessage: (msg, color) => {
const el = element.querySelector(selectors.message);
if (el) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
el.innerHTML = msg;
}
},
showStatus: (msg, color) => {
const el = element.querySelector(selectors.status);
if (el) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
el.innerHTML = msg;
}
},
showLoading: (grid) => {
if (grid) {
grid.showLoading();
grid.showMask({
opacity: 0.05
});
}
},
hideLoading: (grid) => {
if (grid) {
grid.hideLoading();
grid.hideMask();
}
},
showRefresh: () => {
const el = element.querySelector(selectors.refresh);
if (el) el.style.display = "block";
},
showStop: () => {
const el = element.querySelector(selectors.stop);
if (el) el.style.display = "block";
},
hideStop: () => {
const el = element.querySelector(selectors.stop);
if (el) el.style.display = "none";
}
};
}
+1 -1
View File
@@ -780,7 +780,7 @@ export function set_component_policy(v) {
let graphToPrompt = app.graphToPrompt;
app.graphToPrompt = async function () {
let p = await graphToPrompt.call(app);
let p = await graphToPrompt.apply(app, arguments);
try {
let groupNodes = p.workflow.extra?.groupNodes;
if(groupNodes) {
+222 -183
View File
@@ -6,13 +6,13 @@ import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-b
import {
manager_instance, rebootAPI, install_via_git_url,
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
sanitizeHTML, sanitizeUrl, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
showPopover, hidePopover, handle403Response
showPopover, hidePopover, getWorkflowNodeTypes, findPackageByCnrId, analyzeWorkflowUsage, createFlyover
} from "./common.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import ManagerGrid from "./manager-grid.js";
loadCss("./custom-nodes-manager.css");
@@ -42,6 +42,8 @@ const ShowMode = {
FAVORITES: "Favorites",
ALTERNATIVES: "Alternatives",
IN_WORKFLOW: "In Workflow",
USED_IN_ANY_WORKFLOW: "Used In Any Workflow",
NOT_USED_IN_ANY_WORKFLOW: "Installed and Unused",
};
export class CustomNodesManager {
@@ -271,6 +273,14 @@ export class CustomNodesManager {
label: "In Workflow",
value: ShowMode.IN_WORKFLOW,
hasData: false
}, {
label: "Used In Any Workflow",
value: ShowMode.USED_IN_ANY_WORKFLOW,
hasData: false
}, {
label: "Installed and Unused",
value: ShowMode.NOT_USED_IN_ANY_WORKFLOW,
hasData: false
}, {
label: "Missing",
value: ShowMode.MISSING,
@@ -364,18 +374,17 @@ export class CustomNodesManager {
}
let list = installGroups[action];
if (!Array.isArray(list)) {
return "";
}
if(is_selected_button || rowItem?.version === "unknown") {
list = list.filter(it => it !== "switch");
}
if (!list) {
return "";
}
return list.map(id => {
const bt = buttons[id];
return `<button class="cn-btn-${id} p-button p-component" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
return `<button class="cn-btn-${id} p-button p-component" group="${sanitizeHTML(String(action))}" mode="${bt.mode}">${bt.label}</button>`;
}).join("");
}
@@ -462,7 +471,7 @@ export class CustomNodesManager {
".cn-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset');
api.fetchApi('/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -518,10 +527,14 @@ export class CustomNodesManager {
initGrid() {
const container = this.element.querySelector(".cn-manager-grid");
const grid = new TG.Grid(container);
const grid = new ManagerGrid(container);
this.grid = grid;
this.flyover = this.createFlyover(container);
this.flyover = createFlyover(container, {
enableHover: true,
hoverHandler: this.handleFlyoverHover.bind(this),
context: this
});
let prevViewRowsLength = -1;
grid.bind('onUpdated', (e, d) => {
@@ -579,6 +592,9 @@ export class CustomNodesManager {
grid.setOption({
highlightKeywords: {
textGenerator: (row, column) => column === 'author' ? sanitizeHTML(String(row[column] ?? '')) : row[column]
},
theme: 'dark',
selectVisible: true,
selectMultiple: true,
@@ -684,7 +700,8 @@ export class CustomNodesManager {
id: 'id',
name: 'ID',
width: 50,
align: 'center'
align: 'center',
formatter: (value) => (value === null || value === undefined) ? value : sanitizeHTML(String(value))
}, {
id: 'title',
name: 'Title',
@@ -710,11 +727,13 @@ export class CustomNodesManager {
}
const link = document.createElement('a');
// DOM href takes a validated raw URL, without HTML attribute escaping.
if(rowItem.originalData.repository)
link.href = rowItem.originalData.repository;
link.href = sanitizeUrl(rowItem.originalData.repository);
else
link.href = rowItem.reference;
link.href = sanitizeUrl(rowItem.reference);
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.innerHTML = `<b>${title}</b>`;
link.title = rowItem.originalData.id;
container.appendChild(link);
@@ -732,13 +751,15 @@ export class CustomNodesManager {
if(!version) {
return;
}
const safeVersion = sanitizeHTML(String(version));
if(rowItem.cnr_latest && version != rowItem.cnr_latest) {
const safeLatest = sanitizeHTML(String(rowItem.cnr_latest));
if(version == 'nightly') {
return `<div>${version}</div><div>[${rowItem.cnr_latest}]</div>`;
return `<div>${safeVersion}</div><div>[${safeLatest}]</div>`;
}
return `<div>${version}</div><div>[↑${rowItem.cnr_latest}]</div>`;
return `<div>${safeVersion}</div><div>[↑${safeLatest}]</div>`;
}
return version;
return safeVersion;
}
}, {
id: 'action',
@@ -790,10 +811,11 @@ export class CustomNodesManager {
width: 120,
classMap: "cn-pack-author",
formatter: (author, rowItem, columnItem) => {
const safeAuthor = sanitizeHTML(String(author ?? ''));
if (rowItem.trust) {
return `<span tooltip="This author has been active for more than six months in GitHub">✅ ${author}</span>`;
return `<span title="This author has been active for more than six months in GitHub">✅ ${safeAuthor}</span>`;
}
return author;
return safeAuthor;
}
}, {
id: 'stars',
@@ -807,7 +829,7 @@ export class CustomNodesManager {
if (typeof stars === 'number') {
return stars.toLocaleString();
}
return stars;
return (stars === null || stars === undefined) ? stars : sanitizeHTML(String(stars));
}
}, {
id: 'last_update',
@@ -821,8 +843,8 @@ export class CustomNodesManager {
return 'N/A';
}
const ago = getTimeAgo(last_update);
const short = `${last_update}`.split(' ')[0];
return `<span tooltip="${ago}">${short}</span>`;
const short = sanitizeHTML(String(last_update).split(' ')[0]);
return `<span title="${ago}">${short}</span>`;
}
}];
@@ -1063,143 +1085,63 @@ export class CustomNodesManager {
hidePopover();
}
createFlyover(container) {
const $flyover = document.createElement("div");
$flyover.className = "cn-flyover";
$flyover.innerHTML = `<div class="cn-flyover-header">
<div class="cn-flyover-close">${icons.arrowRight}</div>
<div class="cn-flyover-title"></div>
<div class="cn-flyover-close">${icons.close}</div>
</div>
<div class="cn-flyover-body"></div>`
container.appendChild($flyover);
const $flyoverTitle = $flyover.querySelector(".cn-flyover-title");
const $flyoverBody = $flyover.querySelector(".cn-flyover-body");
let width = '50%';
let visible = false;
let timeHide;
const closeHandler = (e) => {
if ($flyover === e.target || $flyover.contains(e.target)) {
return;
}
clearTimeout(timeHide);
timeHide = setTimeout(() => {
flyover.hide();
}, 100);
}
const hoverHandler = (e) => {
if(e.type === "mouseenter") {
if(e.target.classList.contains("cn-nodes-name")) {
this.showNodePreview(e.target);
}
return;
}
this.hideNodePreview();
}
const displayHandler = () => {
if (visible) {
$flyover.classList.remove("cn-slide-in-right");
} else {
$flyover.classList.remove("cn-slide-out-right");
$flyover.style.width = '0px';
$flyover.style.display = "none";
}
}
const flyover = {
show: (titleHtml, bodyHtml) => {
clearTimeout(timeHide);
this.element.removeEventListener("click", closeHandler);
$flyoverTitle.innerHTML = titleHtml;
$flyoverBody.innerHTML = bodyHtml;
$flyover.style.display = "block";
$flyover.style.width = width;
if(!visible) {
$flyover.classList.add("cn-slide-in-right");
}
visible = true;
setTimeout(() => {
this.element.addEventListener("click", closeHandler);
}, 100);
},
hide: (now) => {
visible = false;
this.element.removeEventListener("click", closeHandler);
if(now) {
displayHandler();
return;
}
$flyover.classList.add("cn-slide-out-right");
}
}
$flyover.addEventListener("animationend", (e) => {
displayHandler();
});
$flyover.addEventListener("mouseenter", hoverHandler, true);
$flyover.addEventListener("mouseleave", hoverHandler, true);
$flyover.addEventListener("click", (e) => {
handleFlyoverHover(e) {
if(e.type === "mouseenter") {
if(e.target.classList.contains("cn-nodes-name")) {
const nodeName = e.target.innerText;
const nodeItem = this.nodeMap[nodeName];
if (!nodeItem) {
copyText(nodeName).then((res) => {
if (res) {
e.target.setAttribute("action", "Copied");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
});
return;
}
this.showNodePreview(e.target);
}
return;
}
this.hideNodePreview();
}
const [x, y, w, h] = app.canvas.ds.visible_area;
const dpi = Math.max(window.devicePixelRatio ?? 1, 1);
const node = window.LiteGraph?.createNode(
nodeItem.name,
nodeItem.display_name,
{
pos: [x + (w-300) / dpi / 2, y]
handleFlyoverClick(e) {
if(e.target.classList.contains("cn-nodes-name")) {
const nodeName = e.target.innerText;
const nodeItem = this.nodeMap[nodeName];
if (!nodeItem) {
copyText(nodeName).then((res) => {
if (res) {
e.target.setAttribute("action", "Copied");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
);
if (node) {
app.graph.add(node);
e.target.setAttribute("action", "Added to Workflow");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
});
return;
}
if(e.target.classList.contains("cn-nodes-pack")) {
const hash = e.target.getAttribute("hash");
const rowItem = this.grid.getRowItemBy("hash", hash);
//console.log(rowItem);
this.grid.scrollToRow(rowItem);
this.addHighlight(rowItem);
return;
}
if(e.target.classList.contains("cn-flyover-close")) {
flyover.hide();
return;
}
});
return flyover;
const [x, y, w, h] = app.canvas.ds.visible_area;
const dpi = Math.max(window.devicePixelRatio ?? 1, 1);
const node = window.LiteGraph?.createNode(
nodeItem.name,
nodeItem.display_name,
{
pos: [x + (w-300) / dpi / 2, y]
}
);
if (node) {
app.graph.add(node);
e.target.setAttribute("action", "Added to Workflow");
e.target.classList.add("action");
setTimeout(() => {
e.target.classList.remove("action");
e.target.removeAttribute("action");
}, 1000);
}
return;
}
if(e.target.classList.contains("cn-nodes-pack")) {
const hash = e.target.getAttribute("hash");
const rowItem = this.grid.getRowItemBy("hash", hash);
//console.log(rowItem);
this.grid.scrollToRow(rowItem);
this.addHighlight(rowItem);
return;
}
}
showNodes(d) {
@@ -1211,6 +1153,7 @@ export class CustomNodesManager {
const rowItem = d.rowItem;
const isNotInstalled = rowItem.action == "not-installed";
// Pack titles are already escaped by the server.
let titleHtml = `<div class="cn-nodes-pack" hash="${rowItem.hash}">${rowItem.title}</div>`;
if (isNotInstalled) {
titleHtml += '<div class="cn-pack-badge">Not Installed</div>'
@@ -1227,7 +1170,8 @@ export class CustomNodesManager {
list.push(`<div class="${rowClass}">`);
list.push(`<div class="cn-nodes-sn">${i+1}</div>`);
list.push(`<div class="cn-nodes-name">${it.name}</div>`);
// extName via /customnode/getmappings, no server sanitize: escaped at the sink.
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(it.name))}</div>`);
if (it.conflicts) {
list.push(`<div class="cn-conflicts-list"><div class="cn-nodes-conflict cn-icon">${icons.conflicts}</div><b>Conflict with</b>${it.conflicts.map(c => {
@@ -1349,7 +1293,7 @@ export class CustomNodesManager {
return;
}
const selectedMap = {};
const selectedMap = Object.create(null);
selectedList.forEach(item => {
let type = item.action;
if (item.restart) {
@@ -1368,7 +1312,7 @@ export class CustomNodesManager {
Object.keys(selectedMap).forEach(v => {
const filterItem = this.getFilterItem(v);
list.push(`<div class="cn-selected-buttons">
<span>Selected <b>${selectedMap[v].length}</b> ${filterItem ? filterItem.label : v}</span>
<span>Selected <b>${selectedMap[v].length}</b> ${sanitizeHTML(String(filterItem ? filterItem.label : v))}</span>
${this.grid.hasMask ? "" : this.getActionButtons(v, null, true)}
</div>`);
});
@@ -1476,9 +1420,15 @@ export class CustomNodesManager {
let needRestart = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset');
await api.fetchApi('/manager/queue/reset', { method: 'POST' });
// Set install_context BEFORE per-item queue enqueue calls so that any
// server-side synchronous completion (e.g., sync enable of an inactive
// node) that emits cm-queue-status before we return here still finds
// install_context populated in onQueueCompleted. target_items is shared
// by reference so further pushes below remain visible.
let target_items = [];
this.install_context = {btn: btn, targets: target_items};
for (const hash of list) {
const item = this.grid.getRowItemBy("hash", hash);
@@ -1527,6 +1477,7 @@ export class CustomNodesManager {
});
if (res.status != 200) {
// The title is already escaped by populate_markdown.
errorMsg = `'${item.title}': `;
if(res.status == 403) {
@@ -1540,18 +1491,14 @@ export class CustomNodesManager {
} catch {
errorMsg += `This action is not allowed with this security level configuration.\n`;
}
} else if(res.status == 404) {
errorMsg += `With the current security level configuration, only custom nodes from the <B>"default channel"</B> can be installed.\n`;
} else {
errorMsg += await res.text() + '\n';
errorMsg += sanitizeHTML(await res.text()) + '\n';
}
break;
}
}
this.install_context = {btn: btn, targets: target_items};
if(errorMsg) {
this.showError(errorMsg);
show_message("[Installation Errors]\n"+errorMsg);
@@ -1563,7 +1510,7 @@ export class CustomNodesManager {
}
}
else {
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
this.showStop();
showTerminal();
}
@@ -1576,6 +1523,10 @@ export class CustomNodesManager {
const item = self.grid.getRowItemBy("hash", hash);
if (!item) {
return;
}
item.restart = true;
self.restartMap[item.hash] = true;
self.grid.updateCell(item, "action");
@@ -1583,45 +1534,82 @@ export class CustomNodesManager {
}
else if(event.detail.status == 'done') {
self.hideStop();
self.onQueueCompleted(event.detail);
// Await + error logging so any unhandled rejection surfaces to the
// console instead of silently swallowing completion finalization
// (root cause of disable/enable button staying loading with no toast).
try {
await self.onQueueCompleted(event.detail);
} catch (e) {
console.error("[ComfyUI-Manager] onQueueCompleted failed:", e);
}
}
}
async onQueueCompleted(info) {
// `nodepack_result` is a dict serialized from a Python dict, not an array.
// `dict.length` is `undefined` and `undefined == 0` is `false`, so the
// previous `result.length == 0` guard was a no-op; switch to a correct
// empty-check that also tolerates null/undefined.
let result = info.nodepack_result;
if(result.length == 0) {
if (!result || Object.keys(result).length === 0) {
return;
}
let self = CustomNodesManager.instance;
if(!self.install_context) {
if (!self || !self.install_context) {
return;
}
const { target, label, mode } = self.install_context.btn;
target.classList.remove("cn-btn-loading");
const targets = self.install_context.targets || [];
// Compute errorMsg upfront so the downstream user-visible finalization
// (showRestart / showMessage / infoToast) fires regardless of whether
// any DOM-touching step below throws.
let errorMsg = "";
for(let hash in result){
for (let hash in result) {
let v = result[hash];
if(v != 'success' && v != 'skip')
errorMsg += v+'\n';
if (v != 'success' && v != 'skip') {
// Escape raw task errors for both HTML message destinations.
errorMsg += sanitizeHTML(String(v)) + '\n';
}
}
for(let k in self.install_context.targets) {
let item = self.install_context.targets[k];
self.grid.updateCell(item, "action");
// Defensive: `target` may be a detached DOM node (the in_progress
// handler's updateCell can re-render the row and replace the button
// element). classList.remove on a detached node is a no-op, but we
// still guard in case target was torn down entirely.
try {
if (target && target.classList) {
target.classList.remove("cn-btn-loading");
}
} catch (e) {
console.warn("[ComfyUI-Manager] Failed to clear button loading state:", e);
}
// Defensive: grid.updateCell can throw if the item was removed or the
// grid was re-rendered between in_progress and done. Do NOT let this
// loop abort the completion finalization below — that was the observed
// failure mode for disable/enable (no toast, no "restart required"
// message).
try {
for (let k in targets) {
let item = targets[k];
if (item) {
self.grid.updateCell(item, "action");
}
}
} catch (e) {
console.warn("[ComfyUI-Manager] Failed to refresh target cells after queue completion:", e);
}
if (errorMsg) {
self.showError(errorMsg);
show_message("Installation Error:\n"+errorMsg);
show_message("Installation Error:\n" + errorMsg);
} else {
self.showStatus(`${label} ${result.length} custom node(s) successfully`);
self.showStatus(`${label} ${Object.keys(result).length} custom node(s) successfully`);
}
self.showRestart();
@@ -1777,7 +1765,7 @@ export class CustomNodesManager {
this.showStatus(`Loading missing nodes (${mode}) ...`);
const res = await fetchData(`/customnode/getmappings?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get custom node mappings: ${res.error}`);
this.showError(`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);
return;
}
@@ -1874,7 +1862,10 @@ export class CustomNodesManager {
for(let k in allUsedNodes) {
var item;
if(allUsedNodes[k].properties.cnr_id) {
item = this.custom_nodes[allUsedNodes[k].properties.cnr_id];
const foundPackage = findPackageByCnrId(allUsedNodes[k].properties.cnr_id, this.custom_nodes, false);
if (foundPackage) {
item = foundPackage.pack;
}
}
else if(allUsedNodes[k].properties.aux_id) {
item = aux_id_to_pack[allUsedNodes[k].properties.aux_id];
@@ -1892,7 +1883,7 @@ export class CustomNodesManager {
this.showStatus(`Loading alternatives (${mode}) ...`);
const res = await fetchData(`/customnode/alternatives?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get alternatives: ${res.error}`);
this.showError(`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);
return [];
}
@@ -1908,8 +1899,9 @@ export class CustomNodesManager {
continue;
}
// tags (alter-list.json) is not server-transformed, so escape before innerHTML.
const tags = `${item.tags}`.split(",").map(tag => {
return `<div>${tag.trim()}</div>`;
return `<div>${sanitizeHTML(tag.trim())}</div>`;
}).join("");
hashMap[custom_node.hash] = {
@@ -1921,6 +1913,48 @@ export class CustomNodesManager {
return hashMap;
}
async getUsedInAnyWorkflow() {
this.showStatus(`Loading workflow usage analysis ...`);
const result = await analyzeWorkflowUsage(this.custom_nodes);
if (!result.success) {
this.showError(`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);
return {};
}
const hashMap = {};
// Convert usage map keys to hash map
result.usageMap.forEach((count, packageKey) => {
const pack = this.custom_nodes[packageKey];
if (pack && pack.hash) {
hashMap[pack.hash] = true;
}
});
return hashMap;
}
async getNotUsedInAnyWorkflow() {
this.showStatus(`Loading workflow usage analysis ...`);
// Get the used packages first using common utility
const usedHashMap = await this.getUsedInAnyWorkflow();
const notUsedHashMap = {};
// Find all installed packages that are NOT in the used list
for(let k in this.custom_nodes) {
let nodepack = this.custom_nodes[k];
// Only consider installed packages
if (nodepack.state !== "not-installed" && !usedHashMap[nodepack.hash]) {
notUsedHashMap[nodepack.hash] = true;
}
}
return notUsedHashMap;
}
async loadData(show_mode = ShowMode.NORMAL) {
const isElectron = 'electronAPI' in window;
@@ -1956,7 +1990,7 @@ export class CustomNodesManager {
this.custom_nodes = node_packs;
if(this.channel !== 'default') {
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${this.channel} (Incomplete list)`;
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${sanitizeHTML(String(this.channel))} (Incomplete list)`;
}
for (const k in node_packs) {
@@ -1990,6 +2024,10 @@ export class CustomNodesManager {
hashMap = await this.getFavorites();
} else if(this.show_mode == ShowMode.IN_WORKFLOW) {
hashMap = await this.getNodepackInWorkflow();
} else if(this.show_mode == ShowMode.USED_IN_ANY_WORKFLOW) {
hashMap = await this.getUsedInAnyWorkflow();
} else if(this.show_mode == ShowMode.NOT_USED_IN_ANY_WORKFLOW) {
hashMap = await this.getNotUsedInAnyWorkflow();
}
filterItem.hashMap = hashMap;
@@ -2084,6 +2122,7 @@ export class CustomNodesManager {
// ===========================================================================================
// Messages accept HTML; see createUIStateManager in common.js.
showSelection(msg) {
this.element.querySelector(".cn-manager-selection").innerHTML = msg;
}
@@ -2201,4 +2240,4 @@ export class CustomNodesManager {
get isVisible() {
return this.element?.style?.display !== "none";
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Grid } from "./turbogrid.esm.js";
// Keep Manager's search hardening outside the vendored TurboGrid bundle.
export default class ManagerGrid extends Grid {
highlightKeywordsFilter(rowItem, columns, value) {
const { textKey, textGenerator, highlightKey } = this.options.highlightKeywords;
for (const column of columns) {
rowItem[`${highlightKey}${column}`] = null;
}
const keywords = value ? String(value).trim().toLowerCase().split(/\s+/).filter(Boolean) : [];
if (!keywords.length) return true;
let matched = false;
for (const column of columns) {
const value = typeof textGenerator === "function" ? textGenerator(rowItem, column) : rowItem[column];
if (value === null || value === undefined) continue;
let text = String(value).trim();
if (!text) continue;
const key = `${textKey}${column}`;
if (rowItem[key] === null || rowItem[key] === undefined) {
// A detached element's innerHTML can still run image handlers.
rowItem[key] = new DOMParser().parseFromString(text, "text/html").body.textContent;
}
text = rowItem[key].toLowerCase();
let offset = 0;
const found = keywords.every(keyword => {
const index = text.indexOf(keyword, offset);
if (index === -1) return false;
offset = index + keyword.length;
return true;
});
if (found) {
rowItem[`${highlightKey}${column}`] = true;
this.highlightKeywords = keywords;
matched = true;
}
}
return matched;
}
highlightTextNodes(nodes, keywords) {
if (!keywords.length) return;
let keywordIndex = 0;
for (const node of nodes) {
const text = node.textContent;
const lower = text.toLowerCase();
const wrapper = document.createElement("span");
let offset = 0;
while (offset < text.length) {
const keyword = keywords[keywordIndex];
const index = lower.indexOf(keyword, offset);
if (index === -1) break;
wrapper.append(text.slice(offset, index));
const mark = document.createElement("mark");
mark.textContent = text.slice(index, index + keyword.length);
wrapper.append(mark);
offset = index + keyword.length;
keywordIndex = (keywordIndex + 1) % keywords.length;
}
if (wrapper.childNodes.length) {
// Decoded cell text stays in text nodes, including inside <mark>.
wrapper.append(text.slice(offset));
node.replaceWith(wrapper);
}
}
}
}
+41 -79
View File
@@ -3,18 +3,28 @@ import { $el } from "../../scripts/ui.js";
import {
manager_instance, rebootAPI,
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, handle403Response
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes,
sanitizeHTML, safeHref
} from "./common.js";
import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import ManagerGrid from "./manager-grid.js";
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
loadCss("./model-manager.css");
const gridId = "model";
// Escape raw cells while preserving empty values.
const escapeCell = (value) => (value === null || value === undefined) ? value : sanitizeHTML(String(value));
// Escape option markup while comparing the original values.
const renderOptions = (list, current) => list.map(item => {
const selected = item.value === current ? " selected" : "";
return `<option value="${sanitizeHTML(String(item.value))}"${selected}>${sanitizeHTML(String(item.label))}</option>`;
}).join("");
const pageHtml = `
<div class="cmm-manager cmm-manager-dark">
<div class="cmm-manager-grid"></div>
@@ -101,22 +111,13 @@ export class ModelManager {
updateFilter() {
const $filter = this.element.querySelector(".cmm-manager-filter");
$filter.innerHTML = this.filterList.map(item => {
const selected = item.value === this.filter ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$filter.innerHTML = renderOptions(this.filterList, this.filter);
const $type = this.element.querySelector(".cmm-manager-type");
$type.innerHTML = this.typeList.map(item => {
const selected = item.value === this.type ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$type.innerHTML = renderOptions(this.typeList, this.type);
const $base = this.element.querySelector(".cmm-manager-base");
$base.innerHTML = this.baseList.map(item => {
const selected = item.value === this.base ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$base.innerHTML = renderOptions(this.baseList, this.base);
}
@@ -170,7 +171,7 @@ export class ModelManager {
".cmm-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset');
api.fetchApi('/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -199,7 +200,7 @@ export class ModelManager {
initGrid() {
const container = this.element.querySelector(".cmm-manager-grid");
const grid = new TG.Grid(container);
const grid = new ManagerGrid(container);
this.grid = grid;
grid.bind('onUpdated', (e, d) => {
@@ -227,6 +228,9 @@ export class ModelManager {
});
grid.setOption({
highlightKeywords: {
textGenerator: (row, column) => ['name', 'description'].includes(column) ? row[column] : escapeCell(row[column])
},
theme: 'dark',
selectVisible: true,
@@ -325,7 +329,8 @@ export class ModelManager {
maxWidth: 500,
classMap: 'cmm-node-name',
formatter: function(name, rowItem, columnItem, cellNode) {
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
// Names are server-escaped; raw references need URL and attribute handling.
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
}
}, {
id: 'installed',
@@ -351,7 +356,7 @@ export class ModelManager {
sortable: false,
align: 'center',
formatter: (url, rowItem, columnItem) => {
return `<a class="cmm-btn-download" tooltip="Download file" href="${url}" target="_blank">${icons.download}</a>`;
return `<a class="cmm-btn-download" title="Download file" href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">${icons.download}</a>`;
}
}, {
id: 'size',
@@ -359,18 +364,21 @@ export class ModelManager {
width: 100,
formatter: (size) => {
if (typeof size === "number") {
return this.formatSize(size);
return formatSize(size);
}
return size;
}
}, {
id: 'type',
name: 'Type',
width: 100
width: 100,
formatter: escapeCell
}, {
id: 'base',
name: 'Base'
name: 'Base',
formatter: escapeCell
}, {
// Descriptions are formatted HTML from the server.
id: 'description',
name: 'Description',
width: 400,
@@ -379,11 +387,13 @@ export class ModelManager {
}, {
id: "save_path",
name: 'Save Path',
width: 200
width: 200,
formatter: escapeCell
}, {
id: 'filename',
name: 'Filename',
width: 200
width: 200,
formatter: escapeCell
}];
restoreColumnWidth(gridId, columns);
@@ -444,7 +454,7 @@ export class ModelManager {
let needRefresh = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset');
await api.fetchApi('/manager/queue/reset', { method: 'POST' });
let target_items = [];
@@ -458,6 +468,7 @@ export class ModelManager {
});
}
// Model names are already escaped by the server.
this.showStatus(`Install ${item.name} ...`);
const data = item.originalData;
@@ -483,7 +494,7 @@ export class ModelManager {
errorMsg += `This action is not allowed with this security level configuration.\n`;
}
} else {
errorMsg += await res.text() + '\n';
errorMsg += sanitizeHTML(await res.text()) + '\n';
}
break;
@@ -503,7 +514,7 @@ export class ModelManager {
}
}
else {
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
this.showStop();
showTerminal();
}
@@ -552,8 +563,9 @@ export class ModelManager {
for(let hash in result){
let v = result[hash];
// Escape raw task errors for both HTML message destinations.
if(v != 'success')
errorMsg += v + '\n';
errorMsg += sanitizeHTML(String(v)) + '\n';
}
for(let k in self.install_context.targets) {
@@ -582,7 +594,7 @@ export class ModelManager {
models.forEach((item, i) => {
const { type, base, name, reference, installed } = item;
item.originalData = JSON.parse(JSON.stringify(item));
item.size = this.sizeToBytes(item.size);
item.size = sizeToBytes(item.size);
item.hash = md5(name + reference);
item.id = i + 1;
@@ -659,7 +671,6 @@ export class ModelManager {
const { models } = res.data;
this.modelList = this.getModelList(models);
// console.log("models", this.modelList);
this.updateFilter();
@@ -671,56 +682,7 @@ export class ModelManager {
// ===========================================================================================
formatSize(v) {
const base = 1000;
const units = ['', 'K', 'M', 'G', 'T', 'P'];
const space = '';
const postfix = 'B';
if (v <= 0) {
return `0${space}${postfix}`;
}
for (let i = 0, l = units.length; i < l; i++) {
const min = Math.pow(base, i);
const max = Math.pow(base, i + 1);
if (v > min && v <= max) {
const unit = units[i];
if (unit) {
const n = v / min;
const nl = n.toString().split('.')[0].length;
const fl = Math.max(3 - nl, 1);
v = n.toFixed(fl);
}
v = v + space + unit + postfix;
break;
}
}
return v;
}
// for size sort
sizeToBytes(v) {
if (typeof v === "number") {
return v;
}
if (typeof v === "string") {
const n = parseFloat(v);
const unit = v.replace(/[0-9.B]+/g, "").trim().toUpperCase();
if (unit === "K") {
return n * 1000;
}
if (unit === "M") {
return n * 1000 * 1000;
}
if (unit === "G") {
return n * 1000 * 1000 * 1000;
}
if (unit === "T") {
return n * 1000 * 1000 * 1000 * 1000;
}
}
return v;
}
// Messages accept HTML; see createUIStateManager in common.js.
showSelection(msg) {
this.element.querySelector(".cmm-manager-selection").innerHTML = msg;
}
+699
View File
@@ -0,0 +1,699 @@
.nu-manager {
--grid-font: -apple-system, BlinkMacSystemFont, "Segue UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
z-index: 1099;
width: 80%;
height: 80%;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--fg-color);
font-family: arial, sans-serif;
text-underline-offset: 3px;
outline: none;
}
.nu-manager .nu-flex-auto {
flex: auto;
}
.nu-manager button {
font-size: 16px;
color: var(--input-text);
background-color: var(--comfy-input-bg);
border-radius: 8px;
border-color: var(--border-color);
border-style: solid;
margin: 0;
padding: 4px 8px;
min-width: 100px;
}
.nu-manager button:disabled,
.nu-manager input:disabled,
.nu-manager select:disabled {
color: gray;
}
.nu-manager button:disabled {
background-color: var(--comfy-input-bg);
}
.nu-manager .nu-manager-restart {
display: none;
background-color: #500000;
color: white;
}
.nu-manager .nu-manager-stop {
display: none;
background-color: #500000;
color: white;
}
.nu-manager .nu-manager-back {
align-items: center;
justify-content: center;
}
.arrow-icon {
height: 1em;
width: 1em;
margin-right: 5px;
transform: translateY(2px);
}
.cn-icon {
display: block;
width: 16px;
height: 16px;
}
.cn-icon svg {
display: block;
margin: 0;
pointer-events: none;
}
.nu-manager-header {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 0 5px;
}
.nu-manager-header label {
display: flex;
gap: 5px;
align-items: center;
}
.nu-manager-filter {
height: 28px;
line-height: 28px;
}
.nu-manager-keywords {
height: 28px;
line-height: 28px;
padding: 0 5px 0 26px;
background-size: 16px;
background-position: 5px center;
background-repeat: no-repeat;
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2024%2024%22%20width%3D%22100%25%22%20height%3D%22100%25%22%20pointer-events%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22%23888%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%222%22%20d%3D%22m21%2021-4.486-4.494M19%2010.5a8.5%208.5%200%201%201-17%200%208.5%208.5%200%200%201%2017%200%22%2F%3E%3C%2Fsvg%3E");
}
.nu-manager-status {
padding-left: 10px;
}
.nu-manager-grid {
flex: auto;
border: 1px solid var(--border-color);
overflow: hidden;
position: relative;
}
.nu-manager-selection {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.nu-manager-message {
position: relative;
}
.nu-manager-footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.nu-manager-grid .tg-turbogrid {
font-family: var(--grid-font);
font-size: 15px;
background: var(--bg-color);
}
.nu-manager-grid .tg-turbogrid .tg-highlight::after {
position: absolute;
top: 0;
left: 0;
content: "";
display: block;
width: 100%;
height: 100%;
box-sizing: border-box;
background-color: #80bdff11;
pointer-events: none;
}
.nu-manager-grid .nu-pack-name a {
color: skyblue;
text-decoration: none;
word-break: break-word;
}
.nu-manager-grid .cn-pack-desc a {
color: #5555FF;
font-weight: bold;
text-decoration: none;
}
.nu-manager-grid .tg-cell a:hover {
text-decoration: underline;
}
.nu-manager-grid .cn-pack-version {
line-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
gap: 5px;
}
.nu-manager-grid .cn-pack-nodes {
line-height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
cursor: pointer;
height: 100%;
}
.nu-manager-grid .cn-pack-nodes:hover {
text-decoration: underline;
}
.nu-manager-grid .cn-pack-conflicts {
color: orange;
}
.cn-popover {
position: fixed;
z-index: 10000;
padding: 20px;
color: #1e1e1e;
filter: drop-shadow(1px 5px 5px rgb(0 0 0 / 30%));
overflow: hidden;
}
.cn-flyover {
position: absolute;
top: 0;
right: 0;
z-index: 1000;
display: none;
width: 50%;
height: 100%;
background-color: var(--comfy-menu-bg);
animation-duration: 0.2s;
animation-fill-mode: both;
flex-direction: column;
}
.cn-flyover::before {
position: absolute;
top: 0;
content: "";
z-index: 10;
display: block;
width: 10px;
height: 100%;
pointer-events: none;
left: -10px;
background-image: linear-gradient(to left, rgb(0 0 0 / 20%), rgb(0 0 0 / 0%));
}
.cn-flyover-header {
height: 45px;
display: flex;
align-items: center;
gap: 5px;
border-bottom: 1px solid var(--border-color);
}
.cn-flyover-close {
display: flex;
align-items: center;
padding: 0 10px;
justify-content: center;
cursor: pointer;
opacity: 0.8;
height: 100%;
}
.cn-flyover-close:hover {
opacity: 1;
}
.cn-flyover-close svg {
display: block;
margin: 0;
pointer-events: none;
width: 20px;
height: 20px;
}
.cn-flyover-title {
display: flex;
align-items: center;
font-weight: bold;
gap: 10px;
flex: auto;
}
.cn-flyover-body {
height: calc(100% - 45px);
overflow-y: auto;
position: relative;
background-color: var(--comfy-menu-secondary-bg);
}
@keyframes cn-slide-in-right {
from {
visibility: visible;
transform: translate3d(100%, 0, 0);
}
to {
transform: translate3d(0, 0, 0);
}
}
.cn-slide-in-right {
animation-name: cn-slide-in-right;
}
@keyframes cn-slide-out-right {
from {
transform: translate3d(0, 0, 0);
}
to {
visibility: hidden;
transform: translate3d(100%, 0, 0);
}
}
.cn-slide-out-right {
animation-name: cn-slide-out-right;
}
.cn-nodes-list {
width: 100%;
}
.cn-nodes-row {
display: flex;
align-items: center;
gap: 10px;
}
.cn-nodes-row:nth-child(odd) {
background-color: rgb(0 0 0 / 5%);
}
.cn-nodes-row:hover {
background-color: rgb(0 0 0 / 10%);
}
.cn-nodes-sn {
text-align: right;
min-width: 35px;
color: var(--drag-text);
flex-shrink: 0;
font-size: 12px;
padding: 8px 5px;
}
.cn-nodes-name {
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
position: relative;
padding: 8px 5px;
}
.cn-nodes-name::after {
content: attr(action);
position: absolute;
pointer-events: none;
top: 50%;
left: 100%;
transform: translate(5px, -50%);
font-size: 12px;
color: var(--drag-text);
background-color: var(--comfy-input-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 3px 8px;
display: none;
}
.cn-nodes-name.action::after {
display: block;
}
.cn-nodes-name:hover {
text-decoration: underline;
}
.cn-nodes-conflict .cn-nodes-name,
.cn-nodes-conflict .cn-icon {
color: orange;
}
.cn-conflicts-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
padding: 5px 0;
}
.cn-conflicts-list b {
font-weight: normal;
color: var(--descrip-text);
}
.cn-nodes-pack {
cursor: pointer;
color: skyblue;
}
.cn-nodes-pack:hover {
text-decoration: underline;
}
.cn-pack-badge {
font-size: 12px;
font-weight: normal;
background-color: var(--comfy-input-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
padding: 3px 8px;
color: var(--error-text);
}
.cn-preview {
min-width: 300px;
max-width: 500px;
min-height: 120px;
overflow: hidden;
font-size: 12px;
pointer-events: none;
padding: 12px;
color: var(--fg-color);
}
.cn-preview-header {
display: flex;
gap: 8px;
align-items: center;
border-bottom: 1px solid var(--comfy-input-bg);
padding: 5px 10px;
}
.cn-preview-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: grey;
position: relative;
filter: drop-shadow(1px 2px 3px rgb(0 0 0 / 30%));
}
.cn-preview-dot.cn-preview-optional::after {
content: "";
position: absolute;
pointer-events: none;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--comfy-input-bg);
border-radius: 50%;
width: 3px;
height: 3px;
}
.cn-preview-dot.cn-preview-grid {
border-radius: 0;
}
.cn-preview-dot.cn-preview-grid::before {
content: '';
position: absolute;
border-left: 1px solid var(--comfy-input-bg);
border-right: 1px solid var(--comfy-input-bg);
width: 4px;
height: 100%;
left: 2px;
top: 0;
z-index: 1;
}
.cn-preview-dot.cn-preview-grid::after {
content: '';
position: absolute;
border-top: 1px solid var(--comfy-input-bg);
border-bottom: 1px solid var(--comfy-input-bg);
width: 100%;
height: 4px;
left: 0;
top: 2px;
z-index: 1;
}
.cn-preview-name {
flex: auto;
font-size: 14px;
}
.cn-preview-io {
display: flex;
justify-content: space-between;
padding: 10px 10px;
}
.cn-preview-column > div {
display: flex;
gap: 10px;
align-items: center;
height: 18px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.cn-preview-input {
justify-content: flex-start;
}
.cn-preview-output {
justify-content: flex-end;
}
.cn-preview-list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 10px 10px 10px;
}
.cn-preview-switch {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-color);
border: 2px solid var(--border-color);
border-radius: 10px;
text-wrap: nowrap;
padding: 2px 20px;
gap: 10px;
}
.cn-preview-switch::before,
.cn-preview-switch::after {
position: absolute;
pointer-events: none;
top: 50%;
transform: translate(0, -50%);
color: var(--fg-color);
opacity: 0.8;
}
.cn-preview-switch::before {
content: "◀";
left: 5px;
}
.cn-preview-switch::after {
content: "▶";
right: 5px;
}
.cn-preview-value {
color: var(--descrip-text);
}
.cn-preview-string {
min-height: 30px;
max-height: 300px;
background: var(--bg-color);
color: var(--descrip-text);
border-radius: 3px;
padding: 3px 5px;
overflow-y: auto;
overflow-x: hidden;
}
.cn-preview-description {
margin: 0px 10px 10px 10px;
padding: 6px;
background: var(--border-color);
color: var(--descrip-text);
border-radius: 5px;
font-style: italic;
word-break: break-word;
}
.cn-tag-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
margin-bottom: 5px;
}
.cn-tag-list > div {
background-color: var(--border-color);
border-radius: 5px;
padding: 0 5px;
}
.cn-install-buttons {
display: flex;
flex-direction: column;
gap: 3px;
padding: 3px;
align-items: center;
justify-content: center;
height: 100%;
}
.cn-selected-buttons {
display: flex;
gap: 5px;
align-items: center;
padding-right: 20px;
}
.nu-manager .cn-btn-enable {
background-color: #333399;
color: white;
}
.nu-manager .cn-btn-disable {
background-color: #442277;
color: white;
}
.nu-manager .cn-btn-update {
background-color: #1155AA;
color: white;
}
.nu-manager .cn-btn-try-update {
background-color: Gray;
color: white;
}
.nu-manager .cn-btn-try-fix {
background-color: #6495ED;
color: white;
}
.nu-manager .cn-btn-import-failed {
background-color: #AA1111;
font-size: 10px;
font-weight: bold;
color: white;
}
.nu-manager .cn-btn-install {
background-color: black;
color: white;
}
.nu-manager .cn-btn-try-install {
background-color: Gray;
color: white;
}
.nu-manager .cn-btn-uninstall {
background-color: #993333;
color: white;
}
.nu-manager .cn-btn-reinstall {
background-color: #993333;
color: white;
}
.nu-manager .cn-btn-switch {
background-color: #448833;
color: white;
}
@keyframes nu-btn-loading-bg {
0% {
left: 0;
}
100% {
left: -105px;
}
}
.nu-manager button.nu-btn-loading {
position: relative;
overflow: hidden;
border-color: rgb(0 119 207 / 80%);
background-color: var(--comfy-input-bg);
}
.nu-manager button.nu-btn-loading::after {
position: absolute;
top: 0;
left: 0;
content: "";
width: 500px;
height: 100%;
background-image: repeating-linear-gradient(
-45deg,
rgb(0 119 207 / 30%),
rgb(0 119 207 / 30%) 10px,
transparent 10px,
transparent 15px
);
animation: nu-btn-loading-bg 2s linear infinite;
}
.nu-manager-light .nu-pack-name a {
color: blue;
}
.nu-manager-light .cm-warn-note {
background-color: #ccc !important;
}
.nu-manager-light .cn-btn-install {
background-color: #333;
}
+750
View File
@@ -0,0 +1,750 @@
import { app } from "../../scripts/app.js";
import { $el } from "../../scripts/ui.js";
import {
manager_instance,
fetchData, md5, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, uninstallNodes,
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager,
sanitizeHTML, safeHref
} from "./common.js";
import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
loadCss("./node-usage-analyzer.css");
const gridId = "model";
const pageHtml = `
<div class="nu-manager-header">
<div class="nu-manager-status"></div>
<input type="text" class="nu-manager-keywords" placeholder="Filter keywords..." />
<div class="nu-flex-auto"></div>
</div>
<div class="nu-manager-grid"></div>
<div class="nu-manager-selection"></div>
<div class="nu-manager-message"></div>
<div class="nu-manager-footer">
<button class="nu-manager-back">
<svg class="arrow-icon" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 8H18M2 8L8 2M2 8L8 14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Back
</button>
<button class="nu-manager-refresh">Refresh</button>
<button class="nu-manager-stop">Stop</button>
<div class="nu-flex-auto"></div>
</div>
`;
export class NodeUsageAnalyzer {
static instance = null;
static SortMode = {
BY_PACKAGE: 'by_package'
};
constructor(app, manager_dialog) {
this.app = app;
this.manager_dialog = manager_dialog;
this.id = "nu-manager";
this.filter = '';
this.type = '';
this.base = '';
this.keywords = '';
this.init();
// Initialize shared UI state manager
this.ui = createUIStateManager(this.element, {
selection: ".nu-manager-selection",
message: ".nu-manager-message",
status: ".nu-manager-status",
refresh: ".nu-manager-refresh",
stop: ".nu-manager-stop"
});
api.addEventListener("cm-queue-status", this.onQueueStatus);
}
init() {
this.element = $el("div", {
parent: document.body,
className: "comfy-modal nu-manager"
});
this.element.innerHTML = pageHtml;
this.bindEvents();
this.initGrid();
}
bindEvents() {
const eventsMap = {
".nu-manager-selection": {
click: (e) => {
const target = e.target;
const mode = target.getAttribute("mode");
if (mode === "install") {
this.installModels(this.selectedModels, target);
} else if (mode === "uninstall") {
this.uninstallModels(this.selectedModels, target);
}
}
},
".nu-manager-refresh": {
click: () => {
app.refreshComboInNodes();
}
},
".nu-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset');
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
".nu-manager-back": {
click: (e) => {
this.close()
manager_instance.show();
}
}
};
Object.keys(eventsMap).forEach(selector => {
const target = this.element.querySelector(selector);
if (target) {
const events = eventsMap[selector];
if (events) {
Object.keys(events).forEach(type => {
target.addEventListener(type, events[type]);
});
}
}
});
}
// ===========================================================================================
initGrid() {
const container = this.element.querySelector(".nu-manager-grid");
const grid = new TG.Grid(container);
this.grid = grid;
this.flyover = createFlyover(container, { context: this });
grid.bind('onUpdated', (e, d) => {
this.ui.showStatus(`${grid.viewRows.length.toLocaleString()} installed packages`);
});
grid.bind('onSelectChanged', (e, changes) => {
this.renderSelected();
});
grid.bind("onColumnWidthChanged", (e, columnItem) => {
storeColumnWidth(gridId, columnItem)
});
grid.bind('onClick', (e, d) => {
const { rowItem } = d;
const target = d.e.target;
const mode = target.getAttribute("mode");
if (mode === "install") {
this.installModels([rowItem], target);
return;
}
if (mode === "uninstall") {
this.uninstallModels([rowItem], target);
return;
}
// Handle click on usage count
if (d.columnItem.id === "used_in_count" && rowItem.used_in_count > 0) {
this.showUsageDetails(rowItem);
return;
}
});
grid.setOption({
theme: 'dark',
selectVisible: true,
selectMultiple: true,
selectAllVisible: true,
textSelectable: true,
scrollbarRound: true,
frozenColumn: 1,
rowNotFound: "No Results",
rowHeight: 40,
bindWindowResize: true,
bindContainerResize: true,
cellResizeObserver: (rowItem, columnItem) => {
const autoHeightColumns = ['name', 'description'];
return autoHeightColumns.includes(columnItem.id)
}
});
}
renderGrid() {
// update theme
const colorPalette = this.app.ui.settings.settingsValues['Comfy.ColorPalette'];
Array.from(this.element.classList).forEach(cn => {
if (cn.startsWith("nu-manager-")) {
this.element.classList.remove(cn);
}
});
this.element.classList.add(`nu-manager-${colorPalette}`);
const options = {
theme: colorPalette === "light" ? "" : "dark"
};
const rows = this.modelList || [];
const columns = [{
id: 'title',
name: 'Title',
width: 200,
minWidth: 100,
maxWidth: 500,
classMap: 'nu-pack-name',
formatter: function (name, rowItem, columnItem, cellNode) {
// Names are escaped during loadData; references need URL and attribute handling.
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
}
}, {
id: 'used_in_count',
name: 'Used in',
width: 100,
formatter: function (usedCount, rowItem, columnItem) {
if (!usedCount || usedCount === 0) {
return '0';
}
const plural = usedCount > 1 ? 's' : '';
return `<div class="cn-pack-nodes" style="cursor: pointer;">${usedCount} workflow${plural}</div>`;
}
}, {
id: 'action',
name: 'Action',
width: 160,
minWidth: 140,
maxWidth: 200,
sortable: false,
align: 'center',
formatter: function (action, rowItem, columnItem) {
// Only show uninstall button for installed packages
if (rowItem.originalData && rowItem.originalData.state && rowItem.originalData.state !== "not-installed") {
return `<div class="cn-install-buttons"><button class="nu-btn-uninstall" mode="uninstall">Uninstall</button></div>`;
}
return '';
}
}];
restoreColumnWidth(gridId, columns);
this.grid.setData({
options,
rows,
columns
});
this.grid.render();
}
updateGrid() {
if (this.grid) {
this.grid.update();
}
}
showUsageDetails(rowItem) {
const workflowList = rowItem.workflowDetails;
if (!workflowList || workflowList.length === 0) {
return;
}
let titleHtml = `<div class="cn-nodes-pack">${rowItem.title}</div>`;
const list = [];
list.push(`<div class="cn-nodes-list">`);
workflowList.forEach((workflow, i) => {
list.push(`<div class="cn-nodes-row">`);
list.push(`<div class="cn-nodes-sn">${i + 1}</div>`);
// Workflow filenames are raw server data.
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(workflow.filename))}</div>`);
list.push(`<div class="cn-nodes-details">${workflow.nodeCount} node${workflow.nodeCount > 1 ? 's' : ''}</div>`);
list.push(`</div>`);
});
list.push("</div>");
const bodyHtml = list.join("");
this.flyover.show(titleHtml, bodyHtml);
}
renderSelected() {
const selectedList = this.grid.getSelectedRows();
if (!selectedList.length) {
this.ui.showSelection("");
return;
}
const installedSelected = selectedList.filter(item =>
item.originalData && item.originalData.state && item.originalData.state !== "not-installed"
);
if (installedSelected.length === 0) {
this.ui.showSelection(`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);
return;
}
this.selectedModels = installedSelected;
this.ui.showSelection(`
<div class="nu-selected-buttons">
<span>Selected <b>${installedSelected.length}</b> installed packages</span>
<button class="nu-btn-uninstall" mode="uninstall">Uninstall Selected</button>
</div>
`);
}
// ===========================================================================================
async installModels(list, btn) {
let stats = await api.fetchApi('/manager/queue/status');
stats = await stats.json();
if (stats.is_processing) {
customAlert(`[ComfyUI-Manager] There are already tasks in progress. Please try again after it is completed. (${stats.done_count}/${stats.total_count})`);
return;
}
btn.classList.add("nu-btn-loading");
this.ui.showError("");
let needRefresh = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset');
let target_items = [];
for (const item of list) {
this.grid.scrollRowIntoView(item);
target_items.push(item);
// Here item.name is a raw pack key, not a server-escaped title.
this.ui.showStatus(`Install ${sanitizeHTML(String(item.name))} ...`);
const data = item.originalData;
data.ui_id = item.hash;
const res = await api.fetchApi(`/manager/queue/install_model`, {
method: 'POST',
body: JSON.stringify(data)
});
if (res.status != 200) {
// Escape raw task errors for both HTML message destinations.
errorMsg = `'${sanitizeHTML(String(item.name))}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
} else {
errorMsg += sanitizeHTML(await res.text()) + '\n';
}
break;
}
}
this.install_context = { btn: btn, targets: target_items };
if (errorMsg) {
this.ui.showError(errorMsg);
show_message("[Installation Errors]\n" + errorMsg);
// reset
for (let k in target_items) {
const item = target_items[k];
this.grid.updateCell(item, "installed");
}
}
else {
await api.fetchApi('/manager/queue/start');
this.ui.showStop();
showTerminal();
}
}
async uninstallModels(list, btn) {
btn.classList.add("nu-btn-loading");
this.ui.showError("");
const result = await uninstallNodes(list, {
title: list.length === 1 ? list[0].title || list[0].name : `${list.length} custom nodes`,
channel: 'default',
mode: 'default',
onProgress: (msg) => {
this.showStatus(msg);
},
onError: (errorMsg) => {
this.showError(errorMsg);
},
onSuccess: (targets) => {
this.showStatus(`Uninstalled ${targets.length} custom node(s) successfully`);
this.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
// Update the grid to reflect changes
for (let item of targets) {
if (item.originalData) {
item.originalData.state = "not-installed";
}
this.grid.updateRow(item);
}
}
});
if (result.success) {
this.showStop();
}
btn.classList.remove("nu-btn-loading");
}
async onQueueStatus(event) {
let self = NodeUsageAnalyzer.instance;
if (event.detail.status == 'in_progress' && (event.detail.ui_target == 'model_manager' || event.detail.ui_target == 'nodepack_manager')) {
const hash = event.detail.target;
const item = self.grid.getRowItemBy("hash", hash);
if (item) {
item.refresh = true;
self.grid.setRowSelected(item, false);
item.selectable = false;
self.grid.updateRow(item);
}
}
else if (event.detail.status == 'done') {
self.hideStop();
self.onQueueCompleted(event.detail);
}
}
async onQueueCompleted(info) {
let result = info.model_result || info.nodepack_result;
if (!result || result.length == 0) {
return;
}
let self = NodeUsageAnalyzer.instance;
if (!self.install_context) {
return;
}
let btn = self.install_context.btn;
self.hideLoading();
btn.classList.remove("nu-btn-loading");
let errorMsg = "";
for (let hash in result) {
let v = result[hash];
// Escape raw task errors for both HTML message destinations.
if (v != 'success' && v != 'skip')
errorMsg += sanitizeHTML(String(v)) + '\n';
}
for (let k in self.install_context.targets) {
let item = self.install_context.targets[k];
if (info.model_result) {
self.grid.updateCell(item, "installed");
} else if (info.nodepack_result) {
// Handle uninstall completion
if (item.originalData) {
item.originalData.state = "not-installed";
}
self.grid.updateRow(item);
}
}
if (errorMsg) {
self.showError(errorMsg);
show_message("Operation Error:\n" + errorMsg);
} else {
if (info.model_result) {
self.showStatus(`Install ${Object.keys(result).length} models successfully`);
self.showRefresh();
self.showMessage(`To apply the installed model, please click the 'Refresh' button.`, "red");
} else if (info.nodepack_result) {
self.showStatus(`Uninstall ${Object.keys(result).length} custom node(s) successfully`);
self.showMessage(`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");
}
}
infoToast('Tasks done', `[ComfyUI-Manager] All tasks in the queue have been completed.\n${info.done_count}/${info.total_count}`);
self.install_context = undefined;
}
getModelList(models) {
const typeMap = new Map();
const baseMap = new Map();
models.forEach((item, i) => {
const { type, base, name, reference, installed } = item;
// CRITICAL FIX: Do NOT overwrite originalData - it contains the needed state field!
item.size = sizeToBytes(item.size);
item.hash = md5(name + reference);
if (installed === "True") {
item.selectable = false;
}
typeMap.set(type, type);
baseMap.set(base, base);
});
const typeList = [];
typeMap.forEach(type => {
typeList.push({
label: type,
value: type
});
});
typeList.sort((a, b) => {
const au = a.label.toUpperCase();
const bu = b.label.toUpperCase();
if (au !== bu) {
return au > bu ? 1 : -1;
}
return 0;
});
this.typeList = [{
label: "All",
value: ""
}].concat(typeList);
const baseList = [];
baseMap.forEach(base => {
baseList.push({
label: base,
value: base
});
});
baseList.sort((a, b) => {
const au = a.label.toUpperCase();
const bu = b.label.toUpperCase();
if (au !== bu) {
return au > bu ? 1 : -1;
}
return 0;
});
this.baseList = [{
label: "All",
value: ""
}].concat(baseList);
return models;
}
// ===========================================================================================
async loadData() {
this.showLoading();
this.showStatus(`Analyzing node usage ...`);
const mode = manager_instance.datasrc_combo.value;
const nodeListRes = await fetchData(`/customnode/getlist?mode=${mode}&skip_update=true`);
if (nodeListRes.error) {
this.showError("Failed to get custom node list.");
this.hideLoading();
return;
}
const { channel, node_packs } = nodeListRes.data;
delete node_packs['comfyui-manager'];
this.installed_custom_node_packs = node_packs;
// Use the consolidated workflow analysis utility
const result = await analyzeWorkflowUsage(node_packs);
if (!result.success) {
if (result.error.toString().includes('204')) {
this.showMessage("No workflows were found for analysis.");
} else {
this.showError(sanitizeHTML(String(result.error)));
this.hideLoading();
return;
}
}
// Transform node_packs into models format - ONLY INSTALLED PACKAGES
const models = [];
Object.keys(node_packs).forEach((packKey, index) => {
const pack = node_packs[packKey];
// Only include installed packages (filter out "not-installed" packages)
if (pack.state === "not-installed") {
return; // Skip non-installed packages
}
const usedCount = result.usageMap?.get(packKey) || 0;
const workflowDetails = result.workflowDetailsMap?.get(packKey) || [];
models.push({
// Preserve server-escaped titles; escape only the raw pack-key fallback.
title: pack.title || sanitizeHTML(String(packKey)),
reference: pack.reference || pack.files?.[0] || '#',
used_in_count: usedCount,
workflowDetails: workflowDetails,
name: packKey,
originalData: pack
});
});
// Sort by usage count (descending) then by title
models.sort((a, b) => {
if (b.used_in_count !== a.used_in_count) {
return b.used_in_count - a.used_in_count;
}
return a.title.localeCompare(b.title);
});
this.modelList = this.getModelList(models);
this.renderGrid();
this.hideLoading();
}
// ===========================================================================================
// Messages accept HTML; see createUIStateManager in common.js.
showSelection(msg) {
this.element.querySelector(".nu-manager-selection").innerHTML = msg;
}
showError(err) {
this.showMessage(err, "red");
}
showMessage(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".nu-manager-message").innerHTML = msg;
}
showStatus(msg, color) {
if (color) {
msg = `<font color="${color}">${msg}</font>`;
}
this.element.querySelector(".nu-manager-status").innerHTML = msg;
}
showLoading() {
// this.setDisabled(true);
if (this.grid) {
this.grid.showLoading();
this.grid.showMask({
opacity: 0.05
});
}
}
hideLoading() {
// this.setDisabled(false);
if (this.grid) {
this.grid.hideLoading();
this.grid.hideMask();
}
}
setDisabled(disabled) {
const $close = this.element.querySelector(".nu-manager-close");
const $refresh = this.element.querySelector(".nu-manager-refresh");
const $stop = this.element.querySelector(".nu-manager-stop");
const list = [
".nu-manager-header input",
".nu-manager-header select",
".nu-manager-footer button",
".nu-manager-selection button"
].map(s => {
return Array.from(this.element.querySelectorAll(s));
})
.flat()
.filter(it => {
return it !== $close && it !== $refresh && it !== $stop;
});
list.forEach($elem => {
if (disabled) {
$elem.setAttribute("disabled", "disabled");
} else {
$elem.removeAttribute("disabled");
}
});
Array.from(this.element.querySelectorAll(".nu-btn-loading")).forEach($elem => {
$elem.classList.remove("nu-btn-loading");
});
}
showRefresh() {
this.element.querySelector(".nu-manager-refresh").style.display = "block";
}
showStop() {
this.element.querySelector(".nu-manager-stop").style.display = "block";
}
hideStop() {
this.element.querySelector(".nu-manager-stop").style.display = "none";
}
setKeywords(keywords = "") {
this.keywords = keywords;
this.element.querySelector(".nu-manager-keywords").value = keywords;
}
show(sortMode) {
this.element.style.display = "flex";
this.setKeywords("");
this.showSelection("");
this.showMessage("");
this.loadData();
}
close() {
this.element.style.display = "none";
}
}
+6 -6
View File
@@ -1,7 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { manager_instance, rebootAPI, show_message, handle403Response, loadCss } from "./common.js";
import { manager_instance, rebootAPI, show_message, handle403Response, loadCss, sanitizeHTML } from "./common.js";
import { buildGuiFrame } from "./comfyui-gui-builder.js";
loadCss("./snapshot.css");
@@ -9,7 +9,7 @@ loadCss("./snapshot.css");
async function restore_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi(`/snapshot/restore?target=${target}`, { cache: "no-store" });
const response = await api.fetchApi('/snapshot/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
if(response.status == 403) {
await handle403Response(response);
@@ -37,7 +37,7 @@ async function restore_snapshot(target) {
async function remove_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi(`/snapshot/remove?target=${target}`, { cache: "no-store" });
const response = await api.fetchApi('/snapshot/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
if(response.status == 403) {
await handle403Response(response);
@@ -63,7 +63,7 @@ async function remove_snapshot(target) {
async function save_current_snapshot() {
try {
const response = await api.fetchApi('/snapshot/save', { cache: "no-store" });
const response = await api.fetchApi('/snapshot/save', { method: 'POST', cache: "no-store" });
app.ui.dialog.close();
return true;
}
@@ -133,7 +133,7 @@ export class SnapshotManager extends ComfyDialog {
startRestore(target) {
const self = SnapshotManager.instance;
self.updateMessage(`<BR><font color="green">Restore snapshot '${target.name}'</font>`);
self.updateMessage(`<BR><font color="green">Restore snapshot '${sanitizeHTML(target.name)}'</font>`);
for(let i in self.restore_buttons) {
self.restore_buttons[i].disabled = true;
@@ -214,7 +214,7 @@ export class SnapshotManager extends ComfyDialog {
data1.style.textAlign = "center";
data1.innerHTML = i+1;
var data2 = document.createElement('td');
data2.innerHTML = `&nbsp;${data}`;
data2.innerHTML = `&nbsp;${sanitizeHTML(data)}`;
var data_button = document.createElement('td');
data_button.style.textAlign = "center";
data_button.className = "data-btns";
+385
View File
@@ -5378,6 +5378,391 @@
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Static/resolve/main/ltx-2-19b-lora-camera-control-static.safetensors",
"size": "2.21GB"
},
{
"name": "LTX-2.3 22B Dev",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B development model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-22b-dev.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-dev.safetensors",
"size": "42.98GB"
},
{
"name": "LTX-2.3 22B Distilled 1.1",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B distilled model v1.1.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-22b-distilled-1.1.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-distilled-1.1.safetensors",
"size": "42.98GB"
},
{
"name": "LTX-2.3 22B Dev FP8",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B Dev FP8 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-fp8",
"filename": "ltx-2.3-22b-dev-fp8.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-fp8/resolve/main/ltx-2.3-22b-dev-fp8.safetensors",
"size": "27.14GB"
},
{
"name": "LTX-2.3 22B Distilled FP8",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B Distilled FP8 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-fp8",
"filename": "ltx-2.3-22b-distilled-fp8.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-fp8/resolve/main/ltx-2.3-22b-distilled-fp8.safetensors",
"size": "27.50GB"
},
{
"name": "LTX-2.3 22B Dev NVFP4",
"type": "checkpoint",
"base": "LTX-2.3",
"save_path": "checkpoints/LTX-2.3",
"description": "LTX-2.3 22B Dev NVFP4 model.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-nvfp4",
"filename": "ltx-2.3-22b-dev-nvfp4.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-nvfp4/resolve/main/ltx-2.3-22b-dev-nvfp4.safetensors",
"size": "20.21GB"
},
{
"name": "LTX-2.3 22B Distilled LoRA 1.1",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "A LoRA adapter that transforms the standard LTX-2.3 22B model into a distilled version when loaded.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-22b-distilled-lora-384-1.1.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-22b-distilled-lora-384-1.1.safetensors",
"size": "7.08GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Union Control",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for unified multi-condition control (canny, depth, pose) guided video generation.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control",
"filename": "ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Union-Control/resolve/main/ltx-2.3-22b-ic-lora-union-control-ref0.5.safetensors",
"size": "0.61GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Motion Track Control",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for motion track guided video generation.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control",
"filename": "ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Motion-Track-Control/resolve/main/ltx-2.3-22b-ic-lora-motion-track-control-ref0.5.safetensors",
"size": "0.30GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Ingredients",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for reference-sheet control, keeping characters, props, and locations consistent across generated videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Ingredients",
"filename": "ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Ingredients/resolve/main/ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors",
"size": "1.31GB"
},
{
"name": "LTX-2.3 22B IC-LoRA In-Outpainting",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for video inpainting and outpainting.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-In-Outpainting",
"filename": "ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-In-Outpainting/resolve/main/ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors",
"size": "1.31GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Pixel Spatial Upscaler x2",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for generative 2x pixel spatial upscaling of videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler",
"filename": "ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x2-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler/resolve/main/ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x2-0.9.safetensors",
"size": "0.65GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Pixel Spatial Upscaler x4",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for generative 4x pixel spatial upscaling of videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler",
"filename": "ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x4-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Pixel-Spatial-Upscaler/resolve/main/ltx-2.3-22b-ic-lora-pixel-spatial-upscaler-x4-0.9.safetensors",
"size": "0.65GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Deblur",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for deblurring videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Deblur",
"filename": "ltx-2.3-22b-ic-lora-deblur-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Deblur/resolve/main/ltx-2.3-22b-ic-lora-deblur-0.9.safetensors",
"size": "0.91GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Decompression",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for decompression / artifact cleanup of videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Decompression",
"filename": "ltx-2.3-22b-ic-lora-decompression-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Decompression/resolve/main/ltx-2.3-22b-ic-lora-decompression-0.9.safetensors",
"size": "0.91GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Colorization",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for video colorization.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Colorization",
"filename": "ltx-2.3-22b-ic-lora-colorization-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Colorization/resolve/main/ltx-2.3-22b-ic-lora-colorization-0.9.safetensors",
"size": "0.91GB"
},
{
"name": "LTX-2.3 22B IC-LoRA Clean Plate",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for clean-plate generation from videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Clean-Plate",
"filename": "ltx-2.3-22b-ic-lora-clean-plate-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-Clean-Plate/resolve/main/ltx-2.3-22b-ic-lora-clean-plate-1.0.safetensors",
"size": "0.33GB"
},
{
"name": "LTX-2.3 22B IC-LoRA DubIt",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for video dubbing / revoicing workflows.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-DubIt",
"filename": "ltx-2.3-22b-ic-lora-dubit-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-DubIt/resolve/main/ltx-2.3-22b-ic-lora-dubit-0.9.safetensors",
"size": "2.47GB"
},
{
"name": "LTX-2.3 22B IC-LoRA HDR",
"type": "lora",
"base": "LTX-2.3",
"save_path": "loras/ltxv/ltx2",
"description": "In-Context LoRA (IC LoRA) for 16-bit HDR video generation and SDR-to-HDR conversion.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-HDR",
"filename": "ltx-2.3-22b-ic-lora-hdr-0.9.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-HDR/resolve/main/ltx-2.3-22b-ic-lora-hdr-0.9.safetensors",
"size": "0.33GB"
},
{
"name": "LTX-2.3 Spatial Upscaler x2 1.1",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Spatial upscaler model for LTX-2.3. This model enhances the spatial resolution of generated videos by 2x.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
"size": "0.93GB"
},
{
"name": "LTX-2.3 Spatial Upscaler x1.5",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Spatial upscaler model for LTX-2.3. This model enhances the spatial resolution of generated videos by 1.5x.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-spatial-upscaler-x1.5-1.0.safetensors",
"size": "1.02GB"
},
{
"name": "LTX-2.3 Temporal Upscaler x2",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Temporal upscaler model for LTX-2.3. This model enhances the temporal resolution of generated videos.",
"reference": "https://huggingface.co/Lightricks/LTX-2.3",
"filename": "ltx-2.3-temporal-upscaler-x2-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.3/resolve/main/ltx-2.3-temporal-upscaler-x2-1.0.safetensors",
"size": "0.24GB"
},
{
"name": "LTX-2.5 22B Dev Transformer BF16",
"type": "diffusion_model",
"base": "LTX-2.5",
"save_path": "diffusion_models",
"description": "LTX-2.5 22B full / trainable DiT transformer (bf16).",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-22b-dev-transformer-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-dev-transformer-bf16.safetensors",
"size": "42.02GB"
},
{
"name": "LTX-2.5 22B Dev Transformer Comfy INT8",
"type": "diffusion_model",
"base": "LTX-2.5",
"save_path": "diffusion_models",
"description": "LTX-2.5 22B full DiT transformer (Comfy int8 + convrot). ComfyUI only.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-22b-dev-transformer-comfy-int8-convrot.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-dev-transformer-comfy-int8-convrot.safetensors",
"size": "21.50GB"
},
{
"name": "LTX-2.5 22B Distilled Transformer BF16",
"type": "diffusion_model",
"base": "LTX-2.5",
"save_path": "diffusion_models",
"description": "LTX-2.5 22B distilled DiT transformer (bf16). Fixed 8-step schedule, CFG=1.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-22b-distilled-transformer-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors",
"size": "42.02GB"
},
{
"name": "LTX-2.5 22B Distilled Transformer Comfy INT8",
"type": "diffusion_model",
"base": "LTX-2.5",
"save_path": "diffusion_models",
"description": "LTX-2.5 22B distilled DiT transformer (Comfy int8 + convrot). ComfyUI only.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors",
"size": "21.50GB"
},
{
"name": "LTX-2.5 22B Distilled Transformer NVFP4",
"type": "diffusion_model",
"base": "LTX-2.5",
"save_path": "diffusion_models",
"description": "LTX-2.5 22B distilled DiT transformer (NVFP4).",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-22b-distilled-transformer-nvfp4.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-distilled-transformer-nvfp4.safetensors",
"size": "18.72GB"
},
{
"name": "LTX-2.5 Gemma4 12B Text Encoder BF16",
"type": "clip",
"base": "LTX-2.5",
"save_path": "text_encoders",
"description": "Gemma4 12B text encoder with projections for LTX-2.5 (bf16).",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "gemma4-12b-with-proj-ltx-2.5-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors",
"size": "26.26GB"
},
{
"name": "LTX-2.5 Gemma4 12B Text Encoder Comfy INT8",
"type": "clip",
"base": "LTX-2.5",
"save_path": "text_encoders",
"description": "Gemma4 12B text encoder with projections for LTX-2.5 (Comfy int8 + convrot). ComfyUI only.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/text_encoders/gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors",
"size": "15.37GB"
},
{
"name": "LTX-2.5 Video VAE BF16",
"type": "VAE",
"base": "LTX-2.5",
"save_path": "vae",
"description": "LTX-2.5 DiffVAE video decoder (bf16). Higher quality, heavier.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-video-vae-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-video-vae-bf16.safetensors",
"size": "1.47GB"
},
{
"name": "LTX-2.5 Video VAE Conv BF16",
"type": "VAE",
"base": "LTX-2.5",
"save_path": "vae",
"description": "LTX-2.5 Conv VAE video decoder (bf16). Faster, lighter.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-video-vae-conv-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-video-vae-conv-bf16.safetensors",
"size": "1.45GB"
},
{
"name": "LTX-2.5 Audio VAE BF16",
"type": "VAE",
"base": "LTX-2.5",
"save_path": "vae",
"description": "LTX-2.5 audio VAE and vocoder (bf16).",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-audio-vae-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-audio-vae-bf16.safetensors",
"size": "0.36GB"
},
{
"name": "LTX-2.5 22B Distilled LoRA",
"type": "lora",
"base": "LTX-2.5",
"save_path": "loras",
"description": "A LoRA adapter that transforms the standard LTX-2.5 22B model into a distilled version when loaded.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-22b-distilled-lora-450-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/loras/ltx-2.5-22b-distilled-lora-450-bf16.safetensors",
"size": "8.90GB"
},
{
"name": "LTX-2.5 Duration Head BF16",
"type": "model_patch",
"base": "LTX-2.5",
"save_path": "model_patches",
"description": "Optional duration predictor head for LTX-2.5. Predicts clip length from the prompt when num_frames is omitted.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-duration-head-bf16.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/model_patches/ltx-2.5-duration-head-bf16.safetensors",
"size": "3.84MB"
},
{
"name": "LTX-2.5 Spatial Upscaler x2",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Spatial upscaler model for LTX-2.5. This model enhances the spatial resolution of generated videos by 2x.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors",
"size": "0.96GB"
},
{
"name": "LTX-2.5 Temporal Upscaler x2",
"type": "upscale",
"base": "upscale",
"save_path": "latent_upscale_models",
"description": "Temporal upscaler model for LTX-2.5. This model enhances the temporal resolution of generated videos by 2x.",
"reference": "https://huggingface.co/Lightricks/LTX-2.5",
"filename": "ltx-2.5-latent-temporal-upscaler-x2-bf16-1.0.safetensors",
"url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/latent_upscale_models/ltx-2.5-latent-temporal-upscaler-x2-bf16-1.0.safetensors",
"size": "0.26GB"
},
{
"name": "LTX-Video Spatial Upscaler v0.9.7",
"type": "upscale",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2135 -1645
View File
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
File diff suppressed because it is too large Load Diff
+212 -29
View File
@@ -191,11 +191,20 @@ paths:
description: Mapping of node packages to node classes
/customnode/fetch_updates:
get:
post:
summary: Check for updates
description: Fetches updates for custom nodes
parameters:
- $ref: '#/components/parameters/modeParam'
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
mode:
type: string
enum: [local, remote, default]
description: Source mode (e.g., "local", "remote")
responses:
'200':
description: No updates available
@@ -250,6 +259,30 @@ paths:
type: object
additionalProperties:
$ref: '#/components/schemas/NodePackageMetadata'
/customnode/get_node_types_in_workflows:
get:
summary: List node types used by all user workflows
description: Scan through all workflows in the Comfy user directory, and return a list of all node types used in each one.
responses:
'200':
description: Successful operation
content:
application/json:
schema:
type: array
items:
type: object
properties:
workflow_file_name:
type: string
node_types:
type: array
items:
type: string
'500':
description: Error occurred
/customnode/alternatives:
get:
@@ -423,13 +456,22 @@ paths:
# Queue Management Endpoints
/manager/queue/update_all:
get:
post:
summary: Update all custom nodes
description: Queues update operations for all installed custom nodes
security:
- securityLevel: []
parameters:
- $ref: '#/components/parameters/modeParam'
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
mode:
type: string
enum: [local, remote, default]
description: Source mode (e.g., "local", "remote")
responses:
'200':
description: Update queued successfully
@@ -439,7 +481,7 @@ paths:
description: Security policy violation
/manager/queue/reset:
get:
post:
summary: Reset queue
description: Resets the operation queue
responses:
@@ -479,7 +521,7 @@ paths:
description: Target node not found or security issue
/manager/queue/start:
get:
post:
summary: Start queue processing
description: Starts processing the operation queue
responses:
@@ -575,7 +617,7 @@ paths:
description: Disable operation queued successfully
/manager/queue/update_comfyui:
get:
post:
summary: Update ComfyUI
description: Queues an update operation for ComfyUI itself
responses:
@@ -621,13 +663,22 @@ paths:
$ref: '#/components/schemas/SnapshotItem'
/snapshot/remove:
get:
post:
summary: Remove snapshot
description: Removes a specified snapshot
security:
- securityLevel: []
parameters:
- $ref: '#/components/parameters/targetParam'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [target]
properties:
target:
type: string
description: Target identifier
responses:
'200':
description: Snapshot removed successfully
@@ -637,13 +688,22 @@ paths:
description: Security policy violation
/snapshot/restore:
get:
post:
summary: Restore snapshot
description: Restores a specified snapshot
security:
- securityLevel: []
parameters:
- $ref: '#/components/parameters/targetParam'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [target]
properties:
target:
type: string
description: Target identifier
responses:
'200':
description: Snapshot restoration scheduled
@@ -667,7 +727,7 @@ paths:
description: Error creating snapshot
/snapshot/save:
get:
post:
summary: Save snapshot
description: Saves the current system state as a new snapshot
responses:
@@ -699,15 +759,19 @@ paths:
description: Error retrieving versions
/comfyui_manager/comfyui_switch_version:
get:
post:
summary: Switch ComfyUI version
description: Switches to a specified ComfyUI version
parameters:
- name: ver
in: query
description: Target version
schema:
type: string
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
ver:
type: string
description: Target version
responses:
'200':
description: Version switch successful
@@ -715,7 +779,7 @@ paths:
description: Error switching version
/manager/reboot:
get:
post:
summary: Reboot ComfyUI
description: Restarts the ComfyUI server
security:
@@ -746,7 +810,32 @@ paths:
text/plain:
schema:
type: string
post:
summary: Set preview method
description: Sets the latent preview method (write-only; use GET to read)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value:
type: string
enum: [auto, latent2rgb, taesd, none]
description: New preview method
responses:
'200':
description: Setting updated
content:
text/plain:
schema:
type: string
'400':
description: Invalid value
/manager/db_mode:
get:
summary: Get or set database mode
@@ -766,7 +855,32 @@ paths:
text/plain:
schema:
type: string
post:
summary: Set database mode
description: Sets the database mode (write-only; use GET to read)
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
content:
text/plain:
schema:
type: string
'400':
description: Invalid value
/manager/policy/component:
get:
summary: Get or set component policy
@@ -785,7 +899,29 @@ paths:
text/plain:
schema:
type: string
post:
summary: Set component policy
description: Sets the component policy (write-only; use GET to read)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value:
type: string
description: New component policy
responses:
'200':
description: Setting updated
content:
text/plain:
schema:
type: string
/manager/policy/update:
get:
summary: Get or set update policy
@@ -805,7 +941,32 @@ paths:
text/plain:
schema:
type: string
post:
summary: Set update policy
description: Sets the update policy (write-only; use GET to read)
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
content:
text/plain:
schema:
type: string
'400':
description: Invalid value
/manager/channel_url_list:
get:
summary: Get or set channel URL
@@ -836,7 +997,29 @@ paths:
type: string
url:
type: string
post:
summary: Set channel URL
description: Sets the channel URL for custom node sources (write-only; use GET to read current + list)
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value:
type: string
description: New channel name
responses:
'200':
description: Setting updated
content:
text/plain:
schema:
type: string
# Component Management Endpoints
/manager/component/save:
post:
+42 -8
View File
@@ -455,16 +455,32 @@ except Exception as e:
def ensure_dependencies():
requirements_path = os.path.join(os.path.dirname(__file__), "requirements.txt")
try:
import git # noqa: F401
import toml # noqa: F401
import rich # noqa: F401
import chardet # noqa: F401
except ModuleNotFoundError:
my_path = os.path.dirname(__file__)
requirements_path = os.path.join(my_path, "requirements.txt")
from importlib.metadata import version
from packaging.requirements import Requirement
print("## ComfyUI-Manager: installing dependencies. (GitPython)")
# Check the declared version before loading the native extension, so an
# older nh3 can be upgraded without loading its binary into this process.
with open(requirements_path) as requirements:
for line in requirements:
line = re.split(r'\s+#', line.strip(), maxsplit=1)[0]
if not line or line.startswith(('#', '-')):
continue
nh3_requirement = Requirement(line)
if nh3_requirement.name.lower() == 'nh3':
break
else:
raise ValueError('requirements.txt does not declare nh3')
if version('nh3') not in nh3_requirement.specifier:
raise ModuleNotFoundError(str(nh3_requirement))
import nh3 # noqa: F401
except ModuleNotFoundError:
print("## ComfyUI-Manager: installing dependencies.")
try:
subprocess.check_output(manager_util.make_pip_cmd(['install', '-r', requirements_path]))
except subprocess.CalledProcessError:
@@ -472,7 +488,7 @@ def ensure_dependencies():
try:
subprocess.check_output(manager_util.make_pip_cmd(['install', '--user', '-r', requirements_path]))
except subprocess.CalledProcessError:
print("## [ERROR] ComfyUI-Manager: Failed to install the GitPython package in the correct Python environment. Please install it manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
print("## [ERROR] ComfyUI-Manager: Failed to install required packages in the correct Python environment. Please install requirements.txt manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
try:
print("## ComfyUI-Manager: installing dependencies done.")
@@ -613,6 +629,12 @@ if os.path.exists(restore_snapshot_path):
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
from comfy.cli_args import args
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
new_env['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = str(
allow_flagged or manager_util.is_loopback_listener(args.listen)
).lower()
cmd_str = [sys.executable, cm_cli_path, 'restore-snapshot', restore_snapshot_path]
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
@@ -661,9 +683,18 @@ def execute_lazy_install_script(repo_path, executable):
process_wrap(install_cmd, repo_path, env=new_env)
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path):
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path, status=''):
import uuid
import shutil
from comfy.cli_args import args
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
if not manager_util.is_cnr_install_allowed(status or 'NodeVersionStatusFlagged', allow_flagged, args.listen):
if not status:
logging.error("Cannot execute reserved CNR switch for '%s': stored Registry status is missing. Request the installation again so its current status can be checked.", target)
else:
logging.error(manager_util.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
return False
# 1. download
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
@@ -711,6 +742,8 @@ def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom
with open(tracking_info_file, "w", encoding='utf-8') as file:
file.write('\n'.join(list(extracted)))
return True
script_executed = False
@@ -766,8 +799,9 @@ def execute_startup_script():
execute_lazy_install_script(script[0], script[2])
elif script[1] == "#LAZY-CNR-SWITCH-SCRIPT":
execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6])
execute_lazy_install_script(script[3], script[7])
status = script[8] if len(script) > 8 else ''
if execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6], status):
execute_lazy_install_script(script[3], script[7])
elif script[1] == "#LAZY-DELETE-NODEPACK":
execute_lazy_delete(script[2])
+3 -2
View File
@@ -1,9 +1,10 @@
[project]
name = "comfyui-manager"
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
version = "3.39.3"
version = "3.42"
license = { file = "LICENSE.txt" }
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
requires-python = ">=3.9"
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet", "packaging", "nh3>=0.3.7"]
[project.urls]
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
+2
View File
@@ -9,3 +9,5 @@ typing-extensions
toml
uv
chardet
packaging
nh3>=0.3.7
+9 -1
View File
@@ -17,6 +17,7 @@ builtin_nodes = set()
import sys
from urllib.parse import urlparse
from urllib3.util.retry import Retry
from github import Github, Auth
from pathlib import Path
from typing import Set, Dict, Optional
@@ -1619,7 +1620,14 @@ if __name__ == "__main__":
if not skip_stat_update:
auth = Auth.Token(os.environ.get('GITHUB_TOKEN'))
g = Github(auth=auth)
# Use a plain urllib3 Retry (NOT PyGithub's default GithubRetry) so that
# a GitHub rate-limit response (403/429) raises RateLimitExceededException
# IMMEDIATELY instead of sleeping until the rate-limit reset and retrying
# up to 10x. The except-block in renew_stat() then catches it and skips
# (returns None). 403/429 are intentionally NOT in status_forcelist, so
# they fail fast; only transient 5xx errors are retried.
g = Github(auth=auth, retry=Retry(total=2, backoff_factor=0.5,
status_forcelist=[500, 502, 503, 504]))
else:
g = None
+153
View File
@@ -0,0 +1,153 @@
{
"note": "JS<->Python sanitize_url differential case matrix. STRING inputs only: Python str() and JS String() disagree for non-strings, so feeding them would report divergence for something that is not a defect. See the docstring of tests/test_url_sanitize_parity.py.",
"provenance": "Handed over by gm3-review: 35 cases built to clear the WI-112 model-manager port, plus 22 built for the WI-120 review (scheme grammar, protocol-relative with userinfo, colons after a delimiter, unicode spaces and RTL override, degenerate schemes, a 500-char path). All 57 agreed on both implementations when handed over -- they are coverage, not known failures.",
"type_coercion_warning": {
"why": "Python str() and JS String() are NOT equivalent for non-strings. Feeding non-strings into the differential matrix produces FALSE divergence - a guard RED for a non-bug.",
"measured": {
"1.0": {
"py": "1.0",
"js": "1"
},
"True": {
"py": "True",
"js": "true"
},
"False": {
"py": "False",
"js": "false"
},
"[1,2]": {
"py": "[1, 2]",
"js": "1,2"
},
"{}": {
"py": "{}",
"js": "[object Object]"
}
},
"aligned": {
"0": "both '0'",
"None/null": "both '#' - explicit null handling in both impls"
},
"recommendation": "Scope the matrix to STRING inputs and say so in the guard docstring; url is always a string off the JSON payload in practice."
},
"shared_matrix": [
"javascript:a",
"JaVaScRiPt:a",
"java\tscript:a",
"java\nscript:a",
"\u0001javascript:a",
" javascript:a",
"data:text/html,x",
"vbscript:a",
"file:///etc/passwd",
"about:blank",
"blob:http://x",
"javascript&#58;a",
"javascript&colon;a",
"&#x6a;avascript:a",
"&#106;avascript:a",
"http://e.com",
"https://e.com/a?b=1&c=2",
"HTTPS://E.COM",
"/rel/path",
"rel",
"#anchor",
"//proto/x",
"a&b/c",
"x&y?z=1",
"e.com:8080/p",
"foo:bar",
":alert(1)",
"",
" ",
"https://e.com/x\" onmouseover=\"y",
"https://evil.example/a onmouseover=alert(1) x",
"\\\\evil.com/x",
"/\\evil.com",
"\u0000javascript:a",
"j\u0000avascript:a",
"coap+tcp://h/x",
"x-scheme://h",
"a.b://h",
"a-b://h",
"HTTP+S://h",
"//user:pass@evil.example/x",
"#a:b",
"?a=b:c",
"/p:q",
"http://h/#f:g",
"http ://h",
"javascript :a",
"ja​vascript:a",
"http://h",
"http://h/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"http://h/‮evil",
"http:",
"https:",
"http:/h",
":",
"::",
"a::b",
" /rel/path ",
" rel ",
"\t#anchor\n",
" http://h/x ",
"\thttps://h/x\t",
"http://h/x",
"/rel/path",
"https://h/x",
"",
" /rel ",
"http://h/x…",
"…http://h/x",
"http://h/x\u001c",
"http://h/x\u001d",
"http://h/x\u001e",
"http://h/x\u001f",
"/rel/path…",
"/rel/path\u001c",
"https://h/x…",
"http://h/x ",
" http://h/x",
"http://h/x ",
"http://h/x ",
"http://h/x ",
"http://h/x
",
"http://h/x
",
"http://h/x ",
"http://h/x ",
"http://h/x ",
" /rel",
"  /rel  "
],
"gap_cases_found_by_enforcement": {
"why": "Found by tests/test_url_sanitize_parity.py's own perturbation test, not by review: perturbing the JS scheme-less branch from `return raw.trim()` to `return raw` produced ZERO divergences against the handed-over 57, because every scheme-less case in that set is already trim-clean. The two trim call sites in sanitize_url were therefore unpinned on both sides.",
"cases": [
" /rel/path ",
" rel ",
"\t#anchor\n",
" http://h/x ",
"\thttps://h/x\t"
]
},
"unicode_whitespace_divergence": {
"why": "The 62-case matrix had no BOM or unicode-whitespace input, so the two trim call sites in sanitize_url were compared only over inputs whose edges are ASCII. They are NOT equivalent: String.prototype.trim() and str.strip() disagree on six codepoints.",
"measured": {
"js_strips_python_keeps": [
"U+FEFF"
],
"python_strips_js_keeps": [
"U+001C",
"U+001D",
"U+001E",
"U+001F",
"U+0085"
],
"method": "enumerated every codepoint below U+110000 on both sides: chr(c).isspace() in Python vs /\\s/-equivalent String.trim() in node; 24 shared, 6 divergent"
},
"example": "'http://h/x<U+FEFF>' -> python 'http://h/x<U+FEFF>', js 'http://h/x'",
"resolution": "ALIGNED, not accepted. glob/manager_util.py is the declared source of truth for this pair (see test_the_guard_goes_red_when_either_allow_list_gains_a_scheme, which names it), so js/common.js stopped delegating to trim() and spells Python's str.strip() set out as URL_TRIM. The divergence is therefore a REGRESSION SHAPE this matrix now catches, not a gap_case.",
"scope": "The trim only shapes the RETURNED string for an already-accepted URL. Scheme acceptance is decided on `probe`, which both sides build identically, so no case here changes what is allowed."
}
}
+41
View File
@@ -0,0 +1,41 @@
"""Test-runner guard for the GOAL #32 tests/ modules.
WHY THIS FILE EXISTS (collection hazard, not test logic):
The repo root contains ``__init__.py`` — the ComfyUI plugin entrypoint —
which at import time appends ``glob/`` to sys.path and imports
``manager_server`` (which needs ``folder_paths`` / ``comfy.cli_args`` /
a constructed ``PromptServer``). pytest 8 collects any ancestor
directory that carries an ``__init__.py`` as a ``Package`` node and
IMPORTS that ``__init__.py`` during test setup (observed module name:
``__init__``). Outside a live ComfyUI process that import can never
succeed, so EVERY test under tests/ errors at setup — including the
pre-existing tests/test_csrf_content_type_helper.py — whenever pytest's
rootdir ends up at or above the repo root (e.g. running inside a git
worktree nested under the parent checkout).
The guard below pre-seeds ``sys.modules`` with an inert stub whose
``__file__`` matches the real path, so pytest's
``import_path(<repo-root>/__init__.py)`` resolves to the stub without
executing the plugin entrypoint. Conftest files load before the setup
phase, so the stub is always in place in time. This does NOT touch
production code and does NOT alter what the tests import themselves
(they use AST-extraction / subprocess isolation per the
tests/test_csrf_content_type_helper.py precedent — ``glob/`` is never
added to the runner's sys.path).
"""
import sys
import types
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parent.parent
_ROOT_INIT = _REPO_ROOT / "__init__.py"
if _ROOT_INIT.exists() and "__init__" not in sys.modules:
_stub = types.ModuleType("__init__")
_stub.__file__ = str(_ROOT_INIT)
_stub.__doc__ = (
"Inert stand-in for the ComfyUI-Manager plugin entrypoint; "
"see tests/conftest.py for rationale."
)
sys.modules["__init__"] = _stub
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# setup_e2e_env.sh — E2E environment builder for GOAL #60 (T1, spec §2).
#
# Builds the DISPOSABLE test ComfyUI root used by
# tests/e2e/test_e2e_install_flags.py. ENV BUILD ONLY: donor steps 4-5
# (editable pip install of the Manager + custom_nodes symlink) are
# deliberately DROPPED — the Manager is mounted via `git worktree add`
# by the `mount_worktree` session fixture in the test module, which is
# the SOLE owner of mount create/reuse/teardown (spec §2 T1
# single-ownership rule). This script never touches the Manager repo.
#
# Idempotent: re-run is a no-op when the marker + key artifacts exist
# (E2E-SC-01).
#
# Input env vars:
# E2E_COMFYUI_ROOT — target directory (default: mktemp -d)
# COMFYUI_BRANCH — ComfyUI clone ref (default: master; Q-2)
# PYTHON — python executable for version probe (default: python3)
#
# Output (last line of stdout):
# E2E_COMFYUI_ROOT=/path/to/environment
#
# Exit: 0=success, 1=failure
set -euo pipefail
COMFYUI_REPO="https://github.com/comfyanonymous/ComfyUI.git"
PYTORCH_CPU_INDEX="https://download.pytorch.org/whl/cpu"
# Minimal seed config. use_uv=false: the venv is seeded with pip
# (`uv venv --seed`), so the Manager's make_pip_cmd resolves to
# `<venv-python> -m pip` and the suite's pip-uninstall hygiene helpers
# work without uv on PATH at server runtime. The install flags are NOT
# seeded here — stage_flags.sh stages them per launch identity (SC-06).
CONFIG_INI_CONTENT="[default]
file_logging = false
use_uv = false"
log() { echo "[setup_e2e] $*"; }
err() { echo "[setup_e2e] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
validate_prerequisites() {
local py="${PYTHON:-python3}"
local missing=()
command -v git >/dev/null 2>&1 || missing+=("git")
command -v uv >/dev/null 2>&1 || missing+=("uv")
command -v "$py" >/dev/null 2>&1 || missing+=("$py")
if [[ ${#missing[@]} -gt 0 ]]; then
die "Missing prerequisites: ${missing[*]}"
fi
local py_version major minor
py_version=$("$py" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
major="${py_version%%.*}"
minor="${py_version##*.}"
if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 9 ]]; }; then
die "Python 3.9+ required, found $py_version"
fi
log "Prerequisites OK (python=$py_version)"
}
check_already_setup() {
local root="$1"
if [[ -f "$root/.e2e_setup_complete" ]] \
&& [[ -d "$root/comfyui" ]] \
&& [[ -d "$root/venv" ]] \
&& [[ -f "$root/comfyui/user/__manager/config.ini" ]]; then
log "Environment already set up at $root (marker exists). Skipping. (E2E-SC-01 idempotence)"
echo "E2E_COMFYUI_ROOT=$root"
exit 0
fi
}
verify_setup() {
local root="$1"
local venv_py="$root/venv/bin/python"
local errors=0
log "Running verification checks..."
[[ -f "$root/comfyui/main.py" ]] || { err "Verification FAIL: comfyui/main.py not found"; ((errors+=1)); }
[[ -x "$venv_py" ]] || { err "Verification FAIL: venv python not executable"; ((errors+=1)); }
[[ -f "$root/comfyui/user/__manager/config.ini" ]] || { err "Verification FAIL: config.ini not found"; ((errors+=1)); }
# venv must carry pip (uv venv --seed) — the suite's hygiene helpers
# and the Manager's reservation-consuming boot both call `-m pip`.
if ! "$venv_py" -m pip --version >/dev/null 2>&1; then
err "Verification FAIL: venv pip not available"
((errors+=1))
fi
# comfy is a local package inside the ComfyUI checkout
if ! PYTHONPATH="$root/comfyui" "$venv_py" -c "import comfy" 2>/dev/null; then
err "Verification FAIL: 'import comfy' failed"
((errors+=1))
fi
# [D2] half-check at setup time: the Manager must NOT be pip-installed
# into this venv (the worktree mount is the only delivery mechanism).
if "$venv_py" -m pip show comfyui-manager >/dev/null 2>&1 \
|| "$venv_py" -m pip show ComfyUI-Manager >/dev/null 2>&1; then
err "Verification FAIL: a pip-installed Manager exists in the venv (wrong layout for [D2])"
((errors+=1))
fi
if [[ "$errors" -gt 0 ]]; then
die "Verification failed with $errors error(s)"
fi
log "Verification OK: all checks passed"
}
# ===== Main =====
validate_prerequisites
PYTHON="${PYTHON:-python3}"
COMFYUI_BRANCH="${COMFYUI_BRANCH:-master}"
CREATED_BY_US=false
if [[ -z "${E2E_COMFYUI_ROOT:-}" ]]; then
E2E_COMFYUI_ROOT="$(mktemp -d -t e2e_comfyui_XXXXXX)"
CREATED_BY_US=true
log "Created E2E_COMFYUI_ROOT=$E2E_COMFYUI_ROOT"
else
mkdir -p "$E2E_COMFYUI_ROOT"
log "Using E2E_COMFYUI_ROOT=$E2E_COMFYUI_ROOT"
fi
check_already_setup "$E2E_COMFYUI_ROOT"
cleanup_on_failure() {
local exit_code=$?
if [[ "$exit_code" -ne 0 ]] && [[ "$CREATED_BY_US" == "true" ]]; then
err "Setup failed. Cleaning up $E2E_COMFYUI_ROOT"
rm -rf "$E2E_COMFYUI_ROOT"
fi
}
trap cleanup_on_failure EXIT
log "Step 1/5: Cloning ComfyUI (branch=$COMFYUI_BRANCH)..."
if [[ -d "$E2E_COMFYUI_ROOT/comfyui/.git" ]]; then
log " ComfyUI already cloned, skipping"
else
git clone --depth=1 --branch "$COMFYUI_BRANCH" "$COMFYUI_REPO" "$E2E_COMFYUI_ROOT/comfyui"
fi
log "Step 2/5: Creating virtual environment (seeded with pip)..."
if [[ -d "$E2E_COMFYUI_ROOT/venv" ]]; then
log " venv already exists, skipping"
else
uv venv --seed "$E2E_COMFYUI_ROOT/venv"
fi
VENV_PY="$E2E_COMFYUI_ROOT/venv/bin/python"
log "Step 3/5: Installing ComfyUI dependencies (CPU-only torch index)..."
uv pip install \
--python "$VENV_PY" \
-r "$E2E_COMFYUI_ROOT/comfyui/requirements.txt" \
--extra-index-url "$PYTORCH_CPU_INDEX"
log "Step 4/5: Writing seed config.ini + HOME isolation dirs..."
mkdir -p "$E2E_COMFYUI_ROOT/comfyui/user/__manager"
echo "$CONFIG_INI_CONTENT" > "$E2E_COMFYUI_ROOT/comfyui/user/__manager/config.ini"
mkdir -p "$E2E_COMFYUI_ROOT/home/.config"
mkdir -p "$E2E_COMFYUI_ROOT/home/.local/share"
mkdir -p "$E2E_COMFYUI_ROOT/logs"
mkdir -p "$E2E_COMFYUI_ROOT/comfyui/custom_nodes"
log "Step 5/5: Verifying setup..."
verify_setup "$E2E_COMFYUI_ROOT"
# Marker written ONLY after verification passes (E2E-SC-01)
date -Iseconds > "$E2E_COMFYUI_ROOT/.e2e_setup_complete"
trap - EXIT
log "Setup complete."
echo "E2E_COMFYUI_ROOT=$E2E_COMFYUI_ROOT"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# stage_flags.sh — Per-launch-identity config staging (T4, spec §1.4).
#
# Analog of the donor's start_comfyui_strict.sh config patching, split
# out as a pure staging script (launch is a separate step; the pytest
# fixture owns restore+delete of the backup at teardown — donor
# symmetry, spec §1.4).
#
# Modes (arg $1):
# deny — REMOVE both flag keys (L-D: flags ABSENT also live-proves
# "missing key reads false")
# allow — set allow_git_url_install = true AND allow_pip_install = true
# (L-A / L-P)
#
# Backup: config.ini.before-flags, created ONLY if not already present
# (crashed-run-safe — preserves the true baseline across crashed runs).
# Restore + DELETE of the backup happens in the pytest fixture teardown,
# NOT here (E2E-SC-06/07).
#
# Input env vars:
# E2E_COMFYUI_ROOT — (required)
#
# Exit: 0=staged, 1=failure
set -euo pipefail
MODE="${1:-}"
log() { echo "[stage_flags] $*"; }
err() { echo "[stage_flags] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
[[ -n "${E2E_COMFYUI_ROOT:-}" ]] || die "E2E_COMFYUI_ROOT is not set"
[[ "$MODE" == "deny" || "$MODE" == "allow" ]] || die "usage: stage_flags.sh deny|allow"
CONFIG="$E2E_COMFYUI_ROOT/comfyui/user/__manager/config.ini"
BACKUP="$CONFIG.before-flags"
[[ -f "$CONFIG" ]] || die "config not found at $CONFIG (run setup_e2e_env.sh first)"
# Backup ONLY if absent (crashed-run-safe; SC-06)
if [[ ! -f "$BACKUP" ]]; then
cp "$CONFIG" "$BACKUP"
log "Backed up original config to $BACKUP"
else
log "Backup already present at $BACKUP (preserving original baseline)"
fi
stage_key() {
local key="$1" value="$2"
if grep -qE "^${key}\s*=" "$CONFIG"; then
sed -i -E "s|^${key}\s*=.*|${key} = ${value}|" "$CONFIG"
else
# The append-after target MUST exist, otherwise the sed below is a
# silent no-op and the flag never lands (false-PASS for the allow arm).
grep -qE "^\[default\]" "$CONFIG" \
|| die "no [default] section in $CONFIG — cannot stage '${key}' (would silently no-op)"
sed -i -E "/^\[default\]/a ${key} = ${value}" "$CONFIG"
fi
}
remove_key() {
local key="$1"
sed -i -E "/^${key}\s*=/d" "$CONFIG"
}
case "$MODE" in
deny)
remove_key "allow_git_url_install"
remove_key "allow_pip_install"
log "Staged DENY config (both flags ABSENT — missing key reads false)"
;;
allow)
stage_key "allow_git_url_install" "true"
stage_key "allow_pip_install" "true"
log "Staged ALLOW config (both flags = true)"
;;
esac
# The staged value takes effect ONLY on the NEXT launch (restart-only
# cached_config — by construction, no hot-reload assumption; SC-06).
log "Staged config at $CONFIG (effective on next launch)"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# start_comfyui.sh — Foreground-blocking ComfyUI launcher (T2, spec §1.3).
#
# Starts ComfyUI in the background and blocks until the server answers
# GET /system_stats (or timeout). Deltas vs the donor script (binding,
# spec §1.3):
# - NO --enable-manager: the Manager is a custom-node-style plugin
# activated by ComfyUI's custom_nodes scan of the worktree mount.
# - NO COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS: that belt does not
# exist in this repo (Q-6 verified); watchdog row E2E-SC-42 covers
# the residual.
# - --listen is ALWAYS passed explicitly (LISTEN env): the predicate
# under test is request-time `flag AND is_loopback(args.listen)` —
# the listener value is LOAD-BEARING.
# - Per-launch log isolation (E2E-SC-04, MANDATORY): each launch
# identity writes a FRESH log file comfyui.<port>.<launch-id>.log,
# so a deny-copy substring from L-D is unfindable by L-A/R-A log
# assertions (stale-substring false-PASS class).
# - Readiness = poll GET /system_stats == 200; child exit code 0
# during the wait = Manager-triggered restart -> KEEP polling;
# non-zero exit -> fail fast with log tail.
#
# Input env vars:
# E2E_COMFYUI_ROOT — (required) root from setup_e2e_env.sh
# PORT — listen port (default: 8189)
# TIMEOUT — max seconds to readiness (default: 120)
# LISTEN — listener address (default: 127.0.0.1)
# LAUNCH_ID — launch identity tag for the log file (default: default)
#
# Output (last line on success):
# COMFYUI_PID=<pid> PORT=<port> LOG_FILE=<path>
#
# Exit: 0=ready, 1=timeout/failure
set -euo pipefail
PORT="${PORT:-8189}"
TIMEOUT="${TIMEOUT:-120}"
LISTEN="${LISTEN:-127.0.0.1}"
LAUNCH_ID="${LAUNCH_ID:-default}"
log() { echo "[start_comfyui] $*"; }
err() { echo "[start_comfyui] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
[[ -n "${E2E_COMFYUI_ROOT:-}" ]] || die "E2E_COMFYUI_ROOT is not set"
[[ -d "$E2E_COMFYUI_ROOT/comfyui" ]] || die "ComfyUI not found at $E2E_COMFYUI_ROOT/comfyui"
[[ -x "$E2E_COMFYUI_ROOT/venv/bin/python" ]] || die "venv python not found"
[[ -f "$E2E_COMFYUI_ROOT/.e2e_setup_complete" ]] || die "Setup marker not found. Run setup_e2e_env.sh first."
PY="$E2E_COMFYUI_ROOT/venv/bin/python"
COMFY_DIR="$E2E_COMFYUI_ROOT/comfyui"
LOG_DIR="$E2E_COMFYUI_ROOT/logs"
LOG_FILE="$LOG_DIR/comfyui.${PORT}.${LAUNCH_ID}.log"
# Port-namespaced PID file (donor WI-CC incident: shared PID file caused
# a cross-port kill).
PID_FILE="$LOG_DIR/comfyui.${PORT}.pid"
mkdir -p "$LOG_DIR"
# --- Pre-launch port clear ---
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
log "Port $PORT is in use. Attempting to stop existing process..."
if [[ -f "$PID_FILE" ]]; then
OLD_PID="$(cat "$PID_FILE")"
if kill -0 "$OLD_PID" 2>/dev/null; then
kill "$OLD_PID" 2>/dev/null || true
sleep 2
fi
fi
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
pkill -f "main\\.py.*--port $PORT" 2>/dev/null || true
sleep 2
fi
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
die "Port $PORT is still in use after cleanup attempt"
fi
log "Port $PORT cleared."
fi
# --- Launch (FRESH per-launch log file) ---
log "Starting ComfyUI on port $PORT (listen=$LISTEN, launch_id=$LAUNCH_ID)..."
: > "$LOG_FILE"
PYTHONUNBUFFERED=1 \
HOME="$E2E_COMFYUI_ROOT/home" \
nohup "$PY" -u "$COMFY_DIR/main.py" \
--cpu \
--port "$PORT" \
--listen "$LISTEN" \
> "$LOG_FILE" 2>&1 &
COMFYUI_PID=$!
echo "$COMFYUI_PID" > "$PID_FILE"
log "ComfyUI PID=$COMFYUI_PID, log=$LOG_FILE"
# --- Block until ready: poll /system_stats (restart-tolerant) ---
log "Waiting up to ${TIMEOUT}s for ComfyUI readiness (GET /system_stats)..."
DEADLINE=$(( $(date +%s) + TIMEOUT ))
READY=0
while [[ "$(date +%s)" -lt "$DEADLINE" ]]; do
if curl -sf --max-time 2 "http://127.0.0.1:${PORT}/system_stats" >/dev/null 2>&1; then
READY=1
break
fi
# Child exit handling: exit 0 = Manager-triggered restart -> keep
# polling (a restarted process will bind the port); non-zero -> fail fast.
if ! kill -0 "$COMFYUI_PID" 2>/dev/null; then
if wait "$COMFYUI_PID" 2>/dev/null; then
: # exit 0 — restart class, keep polling
else
RC=$?
err "ComfyUI exited with code $RC. Last 30 lines of log:"
tail -n 30 "$LOG_FILE" >&2
rm -f "$PID_FILE"
exit 1
fi
fi
sleep 1
done
if [[ "$READY" -ne 1 ]]; then
err "Timeout (${TIMEOUT}s) waiting for ComfyUI. Last 30 lines of log:"
tail -n 30 "$LOG_FILE" >&2
kill "$COMFYUI_PID" 2>/dev/null || true
rm -f "$PID_FILE"
exit 1
fi
# A Manager-triggered restart (child exit 0 above) leaves COMFYUI_PID pointing
# at the dead original child while a FRESH process now owns the port. Re-resolve
# the live listener PID so PID_FILE (consumed by stop_comfyui.sh) targets the
# process that must actually be killed at teardown.
LISTENER_PID="$(ss -tlnp 2>/dev/null | grep ":${PORT}\b" | grep -oE 'pid=[0-9]+' | head -n1 | cut -d= -f2)"
if [[ -n "$LISTENER_PID" && "$LISTENER_PID" != "$COMFYUI_PID" ]]; then
log "Listener PID $LISTENER_PID differs from launched PID $COMFYUI_PID (restart). Updating PID file."
COMFYUI_PID="$LISTENER_PID"
echo "$COMFYUI_PID" > "$PID_FILE"
fi
log "ComfyUI is ready."
echo "COMFYUI_PID=$COMFYUI_PID PORT=$PORT LOG_FILE=$LOG_FILE"
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# stop_comfyui.sh — Graceful ComfyUI shutdown (T3, spec §1.3 stop contract).
#
# Donor mirror: SIGTERM -> 10s grace -> SIGKILL -> port-pattern pkill
# fallback -> port-free verify (incl. the legacy-PID-file warning from
# the donor WI-CC cross-port-kill incident).
#
# Input env vars:
# E2E_COMFYUI_ROOT — (required) path to the E2E environment
# PORT — ComfyUI port (default: 8189)
#
# Exit: 0=stopped, 1=failed
set -euo pipefail
PORT="${PORT:-8189}"
GRACE_PERIOD=10
log() { echo "[stop_comfyui] $*"; }
err() { echo "[stop_comfyui] ERROR: $*" >&2; }
die() { err "$@"; exit 1; }
[[ -n "${E2E_COMFYUI_ROOT:-}" ]] || die "E2E_COMFYUI_ROOT is not set"
# Ownership guard: only signal a PID whose cmdline references THIS E2E root.
# On shared runners a bare "main.py --port N" pattern (or a reused PID) could
# otherwise match an unrelated process; the launcher invokes
# "$E2E_COMFYUI_ROOT/venv/bin/python $E2E_COMFYUI_ROOT/comfyui/main.py", so the
# root path is always present in our process's cmdline.
belongs_to_root() {
local pid="$1"
[[ -n "$pid" && -r "/proc/$pid/cmdline" ]] || return 1
tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null | grep -qF "$E2E_COMFYUI_ROOT"
}
# PIDs currently listening on PORT (deduped).
listener_pids() {
ss -tlnp 2>/dev/null | grep ":${PORT}\b" | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u
}
PID_FILE="$E2E_COMFYUI_ROOT/logs/comfyui.${PORT}.pid"
LEGACY_PID_FILE="$E2E_COMFYUI_ROOT/logs/comfyui.pid"
if [[ -f "$LEGACY_PID_FILE" ]] && [[ ! -f "$PID_FILE" ]]; then
log "WARN: found legacy unported PID file $LEGACY_PID_FILE but no ${PID_FILE}. Cross-port risk — ignoring legacy file."
fi
COMFYUI_PID=""
if [[ -f "$PID_FILE" ]]; then
COMFYUI_PID="$(cat "$PID_FILE")"
log "Read PID=$COMFYUI_PID from $PID_FILE"
fi
if [[ -n "$COMFYUI_PID" ]] && kill -0 "$COMFYUI_PID" 2>/dev/null; then
if belongs_to_root "$COMFYUI_PID"; then
log "Sending SIGTERM to PID $COMFYUI_PID..."
kill "$COMFYUI_PID" 2>/dev/null || true
elapsed=0
while kill -0 "$COMFYUI_PID" 2>/dev/null && [[ "$elapsed" -lt "$GRACE_PERIOD" ]]; do
sleep 1
elapsed=$((elapsed + 1))
done
if kill -0 "$COMFYUI_PID" 2>/dev/null; then
log "Process still alive after ${GRACE_PERIOD}s. Sending SIGKILL..."
kill -9 "$COMFYUI_PID" 2>/dev/null || true
sleep 1
fi
else
log "WARN: PID $COMFYUI_PID does NOT belong to $E2E_COMFYUI_ROOT (reused/stale PID). Refusing to kill it."
fi
fi
# Fallback: kill the port listener(s) (covers Manager-restarted processes whose
# PID differs from the recorded one) — but ONLY those verified to belong to this
# E2E root, never a broad pattern pkill that could hit unrelated CI processes.
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
log "Port $PORT still in use. Killing verified-own listener(s)..."
for pid in $(listener_pids); do
if belongs_to_root "$pid"; then
log "Sending SIGTERM to listener PID $pid..."
kill "$pid" 2>/dev/null || true
else
log "WARN: PID $pid on port $PORT is not part of $E2E_COMFYUI_ROOT — leaving it alone."
fi
done
sleep 2
for pid in $(listener_pids); do
if belongs_to_root "$pid"; then
log "Listener PID $pid still alive. Sending SIGKILL..."
kill -9 "$pid" 2>/dev/null || true
fi
done
sleep 1
fi
rm -f "$PID_FILE"
if ss -tlnp 2>/dev/null | grep -q ":${PORT}\b"; then
die "Port $PORT is still in use after shutdown"
fi
log "ComfyUI stopped."
+400
View File
@@ -0,0 +1,400 @@
"""Real ComfyUI HTTP/queue/install tests with a local CNR service and harmless ZIPs.
Run with E2E_ROOT pointing to the existing ComfyUI + venv fixture. Each server
uses an isolated base directory; no installed nodes or user config are changed.
"""
import asyncio
import ast
import io
import json
import os
import socket
import subprocess
import threading
import time
import uuid
import zipfile
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
import aiohttp
import pytest
import requests
E2E_ROOT = Path(os.environ.get('E2E_ROOT', '/nonexistent'))
REPO_ROOT = Path(__file__).resolve().parents[2]
pytestmark = pytest.mark.skipif(
not (E2E_ROOT / 'venv/bin/python').is_file(), reason='E2E_ROOT is required',
)
PACKS = ['fixture-active', 'fixture-flagged', 'fixture-pending', 'fixture-banned', 'fixture-latest', 'fixture-switch', 'fixture-reinstall', 'fixture-update', 'fixture-snapshot']
@pytest.fixture(scope='module')
def registry():
calls = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
parsed = urlparse(self.path)
calls.append(parsed.path + ('?' + parsed.query if parsed.query else ''))
parts = parsed.path.strip('/').split('/')
version = parse_qs(parsed.query).get('version', ['2.0.0'])[0]
node = parts[1] if len(parts) > 1 else ''
base = f'http://127.0.0.1:{self.server.server_port}'
def metadata(node, version):
status = {'1.0.0': 'Active', '1.1.0': 'Active', '2.0.0': 'Flagged', '3.0.0': 'Pending'}[version]
return {'id': f'{node}-{version}', 'node_id': node, 'version': version,
'status': 'NodeVersionStatus' + status, 'dependencies': [],
'downloadUrl': f'{base}/zip/{node}/{version}'}
status = 200
if parts[0] == 'zip':
node, version = parts[1:]
archive = io.BytesIO()
with zipfile.ZipFile(archive, 'w') as z:
z.writestr('__init__.py', 'NODE_CLASS_MAPPINGS = {}\n')
z.writestr('pyproject.toml', f'[project]\nname = "{node}"\nversion = "{version}"\n')
z.writestr('install.py', 'from pathlib import Path\n'
f'Path("installed.txt").write_text("{version}")\n')
body = archive.getvalue()
elif parsed.path == '/nodes':
body = json.dumps({'nodes': [
{'id': name, 'name': name, 'description': '', 'status': 'NodeStatusActive',
'repository': f'https://example.invalid/{name}',
'publisher': {'id': 'fixture', 'name': 'Fixture'},
'latest_version': metadata(name, '2.0.0')}
for name in PACKS], 'totalPages': 1}).encode()
elif len(parts) == 3 and parts[0] == 'nodes' and parts[2] == 'install':
if node == 'fixture-banned':
status, body = 404, b'{"message":"Not found"}'
else:
body = json.dumps(metadata(node, version)).encode()
else:
status, body = 404, b'{}'
self.send_response(status)
self.send_header('Content-Length', str(len(body)))
self.send_header('Content-Type', 'application/zip' if parts[0] == 'zip' else 'application/json')
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield SimpleNamespace(url=f'http://127.0.0.1:{server.server_port}', calls=calls)
server.shutdown()
server.server_close()
thread.join()
# Redirect only the external Registry in both ComfyUI and its restore subprocess.
REGISTRY_BOOTSTRAP = '''
import asyncio, json, os, sys, urllib.request
sys.path.insert(0, os.environ['CM_E2E_MANAGER_GLOB'])
import manager_core, cnr_utils, manager_util
cnr_utils.base_url = os.environ['CM_E2E_REGISTRY']
with urllib.request.urlopen(cnr_utils.base_url + '/nodes') as response:
manager_util.save_to_cache(cnr_utils.base_url + '/nodes', json.load(response))
asyncio.run(manager_core.unified_manager.reload('cache', dont_wait=False))
manager_core.unified_manager.custom_node_map_cache[(manager_core.normalize_channel('default'), 'cache')] = manager_core.NormalizedKeyDict()
'''
BOOTSTRAP = '''
import os, runpy, sys
sys.argv = [os.environ['CM_E2E_MAIN'], *sys.argv[1:]]
import comfy.options
comfy.options.enable_args_parsing()
''' + REGISTRY_BOOTSTRAP + "\nrunpy.run_path(sys.argv[0], run_name='__main__')\n"
@contextmanager
def running_server(root, listen, override, registry, security='normal'):
custom_nodes = root / 'custom_nodes'
custom_nodes.mkdir(exist_ok=True)
mount = custom_nodes / 'comfyui-manager'
if not mount.exists():
mount.symlink_to(REPO_ROOT, target_is_directory=True)
config_dir = root / 'user/__manager'
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / 'config.ini').write_text(
'[default]\nnetwork_mode = offline\nuse_uv = false\n'
f'security_level = {security}\nallow_flagged_nodepack_install = {override}\n'
)
# cm-cli.py inherits this test-only IO redirection from its real parent.
hook = root / 'sitecustomize.py'
hook.write_text("import os, sys\nif os.path.basename(sys.argv[0]) == 'cm-cli.py':\n"
" from comfy.cli_args import args\n"
" args.base_directory = os.environ['CM_E2E_BASE']\n"
+ '\n'.join(' ' + line for line in REGISTRY_BOOTSTRAP.splitlines()))
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
base = f'http://127.0.0.1:{port}'
env = dict(os.environ, PYTHONUNBUFFERED='1',
PYTHONPATH=os.pathsep.join([str(root), str(E2E_ROOT / 'comfyui')]),
COMFYUI_PATH=str(E2E_ROOT / 'comfyui'), COMFYUI_FOLDERS_BASE_PATH=str(root),
CM_E2E_BASE=str(root), CM_E2E_MAIN=str(E2E_ROOT / 'comfyui/main.py'),
CM_E2E_MANAGER_GLOB=str(REPO_ROOT / 'glob'), CM_E2E_REGISTRY=registry.url)
command = [str(E2E_ROOT / 'venv/bin/python'), '-c', BOOTSTRAP, '--cpu',
'--base-directory', str(root), '--listen', listen, '--port', str(port),
'--database-url', f'sqlite:///{root / "comfyui.db"}']
log_path = root / ('server-' + uuid.uuid4().hex + '.log')
with log_path.open('w') as log:
process = subprocess.Popen(command, env=env, cwd=E2E_ROOT / 'comfyui', stdout=log, stderr=subprocess.STDOUT)
try:
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
assert process.poll() is None, log_path.read_text()[-6000:]
try:
if requests.get(base + '/system_stats', timeout=1).status_code == 200:
break
except requests.RequestException:
pass
time.sleep(.2)
else:
pytest.fail(log_path.read_text()[-6000:])
assert requests.get(base + '/manager/version', timeout=5).status_code == 200, log_path.read_text()[-6000:]
assert 'PRESTARTUP FAILED' not in log_path.read_text(), log_path.read_text()
yield SimpleNamespace(root=root, base=base, listen=listen, override=override,
security=security, registry=registry, log=log_path, env=env)
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@pytest.mark.parametrize('operation', ['install', 'restore-snapshot'])
def test_direct_cli_installs_flagged_without_server_options(tmp_path, registry, operation):
with running_server(tmp_path, '0.0.0.0', False, registry) as server:
env = server.env
command = [str(E2E_ROOT / 'venv/bin/python'), str(REPO_ROOT / 'cm-cli.py'), operation]
if operation == 'install':
command += ['fixture-flagged@2.0.0', '--mode', 'cache']
else:
snapshot = tmp_path / 'direct-snapshot.json'
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-flagged': '2.0.0'},
'git_custom_nodes': {}, 'file_custom_nodes': []}))
command.append(str(snapshot))
command += ['--user-directory', str(tmp_path / 'user')]
before = len(registry.calls)
result = subprocess.run(command, env=env, cwd=E2E_ROOT / 'comfyui',
capture_output=True, text=True, timeout=60)
output = result.stdout + result.stderr
assert result.returncode == 0, output
installed = tmp_path / 'custom_nodes/fixture-flagged'
assert 'version = "2.0.0"' in (installed / 'pyproject.toml').read_text(), output
if operation == 'install':
assert (installed / 'installed.txt').read_text() == '2.0.0', output
assert sum('/install' in call for call in registry.calls[before:]) == 1
assert 'Installation of this flagged CNR version is blocked' not in output
@pytest.fixture(scope='module', params=[
('127.0.0.1', False, 'normal'), ('0.0.0.0', False, 'normal'),
('0.0.0.0', True, 'normal'), ('0.0.0.0', True, 'strong'),
('127.0.0.1', False, 'strong'),
], ids=lambda row: '-'.join(map(str, row)))
def comfy_server(request, tmp_path_factory, registry):
listen, override, security = request.param
root = tmp_path_factory.mktemp('flagged-v3')
with running_server(root, listen, override, registry, security) as server:
yield server
def install(server, node, version, operation='install'):
async def request_and_wait():
ui_id = uuid.uuid4().hex
params = {'id': node, 'version': '1.0.0', 'selected_version': version,
'mode': 'cache', 'channel': 'default', 'ui_id': ui_id,
'repository': 'https://example.invalid/fixture'}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=40)) as session:
# Subscribe first: a fast worker can emit completion before POST returns.
async with session.ws_connect(server.base + '/ws?clientId=' + ui_id) as ws:
async with session.post(server.base + '/manager/queue/' + operation, json=params) as response:
if response.status != 200:
return {'http': response.status, 'message': await response.text()}
async with session.post(server.base + '/manager/queue/start') as response:
response.raise_for_status()
while True:
event = await ws.receive_json(timeout=30)
data = event.get('data', {})
if event.get('type') == 'cm-queue-status' and data.get('status') == 'done':
if ui_id in data.get('nodepack_result', {}):
return {'http': 200, 'message': data['nodepack_result'][ui_id]}
return asyncio.run(request_and_wait())
def assert_terminal_guidance(log):
for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]',
'allow_flagged_nodepack_install = true', 'config.ini',
'trusted private network', 'Restart ComfyUI'):
assert detail in log
def assert_flagged_denial(result, log):
assert result['message'] == ('This action is not allowed by the current security configuration. '
'See the terminal for details.'), result
assert_terminal_guidance(log)
@pytest.mark.parametrize('node,version,flagged', [
('fixture-active', '1.0.0', False), ('fixture-flagged', '2.0.0', True),
('fixture-pending', '3.0.0', False), ('fixture-banned', '1.0.0', False),
('fixture-latest', 'latest', True),
])
def test_install_policy(comfy_server, node, version, flagged):
server = comfy_server
log_before = len(server.log.read_text())
before = len(server.registry.calls)
result = install(server, node, version)
calls = server.registry.calls[before:]
strong = server.security == 'strong'
allowed = not strong and node != 'fixture-banned' and (
not flagged or server.override or server.listen == '127.0.0.1')
marker = server.root / 'custom_nodes' / node / 'installed.txt'
assert marker.exists() is allowed, (result, server.log.read_text()[-6000:])
assert sum('/install' in call for call in calls) == (0 if strong else 1), calls
assert any(call.startswith('/zip/') for call in calls) is allowed
if allowed:
assert result['message'] == 'success', result
assert marker.read_text() == ('2.0.0' if version == 'latest' else version)
elif strong:
assert result['http'] == 403, result
elif flagged:
assert_flagged_denial(result, server.log.read_text()[log_before:])
@pytest.mark.parametrize('operation', ['install', 'reinstall', 'update'])
def test_existing_pack_policy(comfy_server, operation):
server = comfy_server
if server.security == 'strong':
pytest.skip('Existing-pack transitions require an installed pack')
node = {'install': 'fixture-switch', 'reinstall': 'fixture-reinstall', 'update': 'fixture-update'}[operation]
assert install(server, node, '1.0.0')['message'] == 'success'
marker = server.root / 'custom_nodes' / node / 'installed.txt'
log_before = len(server.log.read_text())
before = len(server.registry.calls)
result = install(server, node, '2.0.0', operation)
calls = server.registry.calls[before:]
allowed = server.override or server.listen == '127.0.0.1'
assert sum('/install' in call for call in calls) == 1, calls
if allowed:
assert result['message'] == 'success', result
if operation == 'reinstall':
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
else:
assert marker.read_text() == '1.0.0'
assert 'NodeVersionStatusFlagged' in (server.root / 'user/__manager/startup-scripts/install-scripts.txt').read_text()
else:
assert_flagged_denial(result, server.log.read_text()[log_before:])
assert marker.read_text() == '1.0.0'
assert any(call.startswith('/zip/') for call in calls) is (allowed and operation == 'reinstall')
@pytest.mark.parametrize('restart_listen,restart_override,allowed', [
('0.0.0.0', False, False), ('0.0.0.0', True, True), ('127.0.0.1', False, True),
])
@pytest.mark.parametrize('stored_status', [True, False])
def test_restart_uses_current_policy(tmp_path, registry, restart_listen, restart_override, allowed, stored_status):
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
assert install(server, 'fixture-switch', '1.0.0')['message'] == 'success'
assert install(server, 'fixture-switch', '2.0.0')['message'] == 'success'
if not stored_status:
reservation = tmp_path / 'user/__manager/startup-scripts/install-scripts.txt'
record = ast.literal_eval(reservation.read_text().strip())
assert record[1] == '#LAZY-CNR-SWITCH-SCRIPT'
assert len(record) == 9
reservation.write_text(repr(record[:8]) + '\n')
marker = tmp_path / 'custom_nodes/fixture-switch/installed.txt'
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
before = len(registry.calls)
with running_server(tmp_path, restart_listen, restart_override, registry) as server:
calls = registry.calls[before:]
assert not any('/install' in call for call in calls), calls
assert any(call.startswith('/zip/') for call in calls) is allowed
if allowed:
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
else:
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
if stored_status:
assert_terminal_guidance(server.log.read_text())
else:
assert 'stored Registry status is missing' in server.log.read_text()
assert 'Request the installation again' in server.log.read_text()
@pytest.mark.parametrize('surface,payload,flag', [
('git_url', {'url': 'https://example.invalid/fixture'}, 'allow_git_url_install'),
('pip', {'packages': 'fixture-do-not-install'}, 'allow_pip_install'),
])
def test_other_install_flags_remain_required(comfy_server, surface, payload, flag):
server = comfy_server
before = len(server.registry.calls)
response = requests.post(server.base + '/customnode/install/' + surface, json=payload, timeout=10)
assert response.status_code == 403, response.text
assert response.json() == {'error': flag}
assert server.registry.calls[before:] == []
@pytest.mark.parametrize('existing', [False, True])
@pytest.mark.parametrize('version,listen,override,allowed', [
('1.1.0', '0.0.0.0', False, True),
('2.0.0', '0.0.0.0', False, False),
('2.0.0', '0.0.0.0', True, True),
('2.0.0', '127.0.0.1', False, True),
])
def test_scheduled_snapshot_inherits_parent_policy(tmp_path, registry, existing, version, listen, override, allowed):
installed = tmp_path / 'custom_nodes/fixture-snapshot'
marker = installed / 'installed.txt'
reservation = tmp_path / 'user/__manager/startup-scripts/restore-snapshot.json'
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
if existing:
assert install(server, 'fixture-snapshot', '1.0.0')['message'] == 'success'
assert marker.read_text() == '1.0.0'
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
snapshot = tmp_path / 'user/__manager/snapshots/flagged-policy.json'
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-snapshot': version},
'git_custom_nodes': {}, 'file_custom_nodes': []}))
before = len(registry.calls)
response = requests.post(server.base + '/snapshot/restore',
json={'target': snapshot.stem}, timeout=10)
assert response.status_code == 200, response.text
assert reservation.read_bytes() == snapshot.read_bytes()
assert registry.calls[before:] == []
if existing:
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
else:
assert not installed.exists()
before = len(registry.calls)
with running_server(tmp_path, listen, override, registry) as server:
log = server.log.read_text()
calls = registry.calls[before:]
assert 'Restore snapshot done.' in log, log[-6000:]
assert 'Restore snapshot failed.' not in log, log[-6000:]
assert 'Error in sitecustomize' not in log, log[-6000:]
assert not reservation.exists()
assert sum('/install' in call for call in calls) == 1, (calls, log[-6000:])
assert any(call.startswith('/zip/') for call in calls) is allowed
if allowed:
assert f'version = "{version}"' in (installed / 'pyproject.toml').read_text(), log[-6000:]
assert 'allow_flagged_nodepack_install' not in log
else:
assert_terminal_guidance(log)
if existing:
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
assert '1.0.0' in (installed / 'pyproject.toml').read_text()
else:
assert not installed.exists()
+752
View File
@@ -0,0 +1,752 @@
"""GOAL #60 — Real-server E2E for the dedicated install flags (worktree-mounted Manager).
Boots a REAL ComfyUI server from a disposable test root
(`E2E_COMFYUI_ROOT`, built by tests/e2e/scripts/setup_e2e_env.sh) with
the Manager mounted via `git worktree add --detach` (NEVER pip-installed
— [D2]) and exercises both dedicated-flag surfaces over live HTTP.
Usage:
bash tests/e2e/scripts/setup_e2e_env.sh # once (E2E-SC-01)
E2E_COMFYUI_ROOT=/path/to/root pytest tests/e2e/test_e2e_install_flags.py -v
Per-row map (goal60-scenarios.md, 24 rows — spec §3 BINDING):
SC-01 setup_e2e_env.sh (pre-suite script; idempotent build + marker — not a pytest test)
SC-02 mount_worktree fixture create path (SHA pin, .git-file, no-pip, printed SHA)
SC-03 mount_worktree fixture reuse path + path-prefix scoping invariant
SC-04 _start_server via start_comfyui.sh (readiness poll, restart tolerance,
per-launch log comfyui.<port>.<launch-id>.log)
SC-05 test_00_smoke_manager_version in EVERY server-up class (+abort guard)
SC-06 _stage via stage_flags.sh (backup-if-absent; restart-only by construction)
SC-07 class fixture finalizers (stop + port-free + config restore + backup DELETE)
+ mount_worktree finalizer (unmount + prune + absence assert)
SC-10 TestDenyArms.test_01_sa_deny SC-11 TestDenyArms.test_02_sb_deny
SC-12 TestAllowArms.test_01_git_url_allow
SC-13 TestAllowArms.test_02_pip_allow_reserved (anti-false-PASS — VERBATIM)
SC-14 TestAllowArms.test_03_restart_consumes_reservation (R-A through the holder)
SC-20 _pre_guards in both class fixtures (before any request)
SC-21 TestAllowArms.test_04_clone_residual_cleanup (+ installed-index cross-check)
SC-22 TestAllowArms.test_05_pip_residual_uninstall
SC-23 _reservation_guard — UNCONDITIONAL fixture-teardown guard (failure path)
SC-24 TestZeroResidual.test_99_zero_residual_sweep (unmount half lives in the
mount_worktree finalizer — it cannot be asserted from inside the session)
SC-30 module-level pytestmark (env unset -> all SKIP; unit suite unaffected)
SC-31 module-level pytestmark (marker absent -> all SKIP)
SC-32 needs_network marker on fixture-dependent (allow-arm / public) rows
SC-33 collection safety by construction: stdlib + pytest + requests
(via pytest.importorskip) ONLY — no glob/ imports, no server imports,
HTTP only at test time
SC-40 TestPublicListener (opt-in E2E_PUBLIC_LISTEN=1; L-P @ 0.0.0.0)
SC-41 batch S-C/S-C' E2E — DEFERRED (Q-5; spec FREEZE item 3; recorded here)
SC-42 TestAllowArms.test_06_requirements_watchdog (L-A launch log)
Fixture-lifecycle ownership (spec §3 BINDING block):
- every class server fixture DECLARES mount_worktree (mount-before-launch);
- process handle lives in a MUTABLE ServerHolder owned by the fixture;
teardown stops the CURRENT holder content (whatever launch identity is live);
- SC-14 restarts THROUGH the holder (stop L-A -> launch R-A -> replace handle);
- stop-before-next-class is structural (pytest class-fixture scoping);
- `requests` is imported via pytest.importorskip (absence degrades to SKIP).
T6 note: no tests/e2e/conftest.py — all fixtures are single-module, so the
optional T6 file is not demanded (spec §2 T6 condition not met).
"""
from __future__ import annotations
import configparser
import os
import shutil
import socket
import subprocess
import sys
import time
import uuid
from pathlib import Path
import pytest
# ---------------------------------------------------------------------------
# Skip gates (E2E-SC-30/31) — BEFORE anything env-dependent
# ---------------------------------------------------------------------------
E2E_ROOT = os.environ.get("E2E_COMFYUI_ROOT", "")
_MARKER_OK = bool(E2E_ROOT) and os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete"))
pytestmark = pytest.mark.skipif(
not _MARKER_OK,
reason="E2E_COMFYUI_ROOT not set or E2E environment not ready (.e2e_setup_complete missing)",
)
# requests: test-extra — absence degrades to SKIP, never a collection error
# (spec §3 binding block item 5; [D4]).
requests = pytest.importorskip("requests")
# ---------------------------------------------------------------------------
# Constants / paths
# ---------------------------------------------------------------------------
PORT = int(os.environ.get("PORT", "8189"))
TIMEOUT = int(os.environ.get("TIMEOUT", "120"))
BASE_URL = f"http://127.0.0.1:{PORT}"
THIS_DIR = Path(__file__).resolve().parent
SCRIPTS_DIR = THIS_DIR / "scripts"
MANAGER_REPO = THIS_DIR.parents[1] # repo root of the checkout running the suite
ROOT = Path(E2E_ROOT) if E2E_ROOT else Path(".")
COMFY_DIR = ROOT / "comfyui"
CN_DIR = COMFY_DIR / "custom_nodes"
MOUNT = CN_DIR / "comfyui-manager"
CFG = COMFY_DIR / "user" / "__manager" / "config.ini"
CFG_BACKUP = Path(str(CFG) + ".before-flags")
SCRIPTS_FILE = COMFY_DIR / "user" / "__manager" / "startup-scripts" / "install-scripts.txt"
LOGS_DIR = ROOT / "logs"
VENV_PY = ROOT / "venv" / "bin" / "python"
# Owned fixtures ONLY (goal60-scenarios.md Conventions; [D3])
NODEPACK_URL = "https://github.com/ltdrdata/nodepack-test1-do-not-install"
PACK_NAME = "nodepack-test1-do-not-install"
# pip stimulus uses the git+ scheme: pip/uv require it for VCS URLs — a
# plain GitHub repo URL serves HTML and cannot install (verified by probe;
# spec amendment requested via leader pushback 2026-06-08; the SC-13/14
# oracle itself is encoded VERBATIM).
PIP_URL = "git+https://github.com/ltdrdata/pip-test1-do-not-install"
PIP_PKG = "pip-test1-do-not-install"
PIP_IMPORT = "pip_test1_do_not_install"
PIP_MARKER = "pip-test1-do-not-install:ok"
# Amendment A2 (live-run finding, leader-approved): the S-A nodepack fixture
# is deliberately NOT zero-dep — it pins python-slugify==8.0.4 in its
# requirements as the invariant-4 ride-along proof vehicle. The SC-42
# watchdog therefore allowlists exactly that requirement, and the
# transitive-dep residual class is swept at allow-class teardown + SC-24.
# (The S-B pip fixture IS zero-dep as documented.)
NODEPACK_PINNED_REQ = "python-slugify==8.0.4"
TRANSITIVE_DEPS = ("python-slugify", "text-unidecode")
POLL_TIMEOUT = 60
POLL_INTERVAL = 1.0
# Distinctive substrings of the flag-naming denial constants @ d45c8e6b
DENY_COPY_GIT = "'allow_git_url_install = true' in config.ini"
DENY_COPY_PIP = "'allow_pip_install = true' in config.ini"
# Old security_level-attributing copy (must NOT appear on flag denials)
OLD_COPY_GENERAL = "is not allowed in this security_level"
OLD_COPY_NORMAL_MINUS = "set the security level to"
# ---------------------------------------------------------------------------
# Network probe (E2E-SC-32) — evaluated ONLY when the env gate is open, so
# collection without the env performs no network IO (SC-33).
# ---------------------------------------------------------------------------
def _network_available() -> bool:
try:
with socket.create_connection(("github.com", 443), timeout=5):
return True
except OSError:
return False
_NETWORK = _network_available() if _MARKER_OK else False
needs_network = pytest.mark.skipif(
not _NETWORK,
reason="github.com unreachable — network-dependent fixture row skipped (E2E-SC-32)",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run(cmd, check=False, timeout=180, env=None, cwd=None):
return subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, check=check,
env=env, cwd=cwd,
)
def _script(name: str) -> str:
return str(SCRIPTS_DIR / name)
def _script_env(**extra) -> dict:
env = {**os.environ, "E2E_COMFYUI_ROOT": str(ROOT), "PORT": str(PORT),
"TIMEOUT": str(TIMEOUT)}
env.update({k: str(v) for k, v in extra.items()})
return env
def _pack_dir(name: str = PACK_NAME) -> Path:
return CN_DIR / name
def _pack_exists(name: str = PACK_NAME) -> bool:
return _pack_dir(name).is_dir()
def _remove_pack(name: str = PACK_NAME) -> None:
"""Donor _remove_pack pattern: rmtree 3-retry + rename-to-.trash_ fallback."""
path = _pack_dir(name)
if path.is_symlink():
path.unlink()
return
if not path.is_dir():
return
for attempt in range(3):
try:
shutil.rmtree(path)
return
except OSError:
if attempt < 2:
time.sleep(1)
trash = CN_DIR / f".trash_{uuid.uuid4().hex[:8]}"
try:
os.rename(path, trash)
shutil.rmtree(trash, ignore_errors=True)
except OSError:
shutil.rmtree(path, ignore_errors=True)
def _wait_for(predicate, timeout=POLL_TIMEOUT, interval=POLL_INTERVAL) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return False
def _pip_import_rc() -> int:
return _run([str(VENV_PY), "-c", f"import {PIP_IMPORT}"]).returncode
def _pip_marker_rc() -> "subprocess.CompletedProcess":
return _run([
str(VENV_PY), "-c",
f"import {PIP_IMPORT} as m; assert m.MARKER == '{PIP_MARKER}'",
])
def _pip_uninstall() -> "subprocess.CompletedProcess":
return _run([str(VENV_PY), "-m", "pip", "uninstall", "-y", PIP_PKG])
def _scripts_clean() -> bool:
"""True when the reservation file is absent OR carries no pip-test1 line."""
if not SCRIPTS_FILE.exists():
return True
return "pip-test1" not in SCRIPTS_FILE.read_text(errors="ignore")
def _reservation_guard() -> None:
"""E2E-SC-23 — UNCONDITIONAL teardown guard for the unconsumed-reservation
leak class: a leaked line would pip-install on ANY next boot of this root."""
if SCRIPTS_FILE.exists() and "pip-test1" in SCRIPTS_FILE.read_text(errors="ignore"):
SCRIPTS_FILE.unlink()
assert _scripts_clean(), "reservation guard failed to clear pip-test1 line (SC-23)"
def _restore_config() -> None:
"""E2E-SC-07: restore from backup, then DELETE the backup and assert absence
(a surviving stale backup would silently restore an outdated config at a
FUTURE run's teardown via the create-only-if-absent rule)."""
if CFG_BACKUP.exists():
shutil.copyfile(CFG_BACKUP, CFG)
CFG_BACKUP.unlink()
assert not CFG_BACKUP.exists(), "config backup must be DELETED after restore (SC-07/24)"
def _port_free() -> bool:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.settimeout(1)
return s.connect_ex(("127.0.0.1", PORT)) != 0
finally:
s.close()
def _pre_guards() -> None:
"""E2E-SC-20 — before EACH arm's matrix rows; assert all three."""
_remove_pack(PACK_NAME)
assert not _pack_exists(PACK_NAME), (
f"pre-guard: failed to clean {PACK_NAME} (file locks?)"
)
_pip_uninstall() # ignore rc: not-installed is fine
assert _pip_import_rc() != 0, "pre-guard: pip fixture importable before test"
_reservation_guard()
assert _scripts_clean(), "pre-guard: stale pip-test1 reservation present"
# ---------------------------------------------------------------------------
# Server lifecycle (E2E-SC-04 + spec §3 binding holder contract)
# ---------------------------------------------------------------------------
class ServerHolder:
"""Mutable process-handle holder owned by the class server fixture.
The holder always points at the CURRENT launch identity; SC-14 replaces
its content when it restarts through it, so class teardown stops
whatever is live — no orphan."""
def __init__(self):
self.launch_id: str | None = None
self.log_path: Path | None = None
self.live = False
self.smoke_ok = False
def _stage(mode: str) -> None:
r = _run(["bash", _script("stage_flags.sh"), mode], env=_script_env(), check=False)
assert r.returncode == 0, f"stage_flags.sh {mode} failed:\n{r.stdout}\n{r.stderr}"
assert CFG_BACKUP.exists(), "backup must exist after staging (SC-06)"
def _start_server(holder: ServerHolder, launch_id: str, listen: str = "127.0.0.1") -> None:
r = _run(
["bash", _script("start_comfyui.sh")],
env=_script_env(LISTEN=listen, LAUNCH_ID=launch_id),
timeout=TIMEOUT + 90,
)
assert r.returncode == 0, (
f"start_comfyui.sh failed for launch {launch_id}:\n{r.stdout}\n{r.stderr}"
)
holder.launch_id = launch_id
holder.log_path = LOGS_DIR / f"comfyui.{PORT}.{launch_id}.log"
holder.live = True
assert holder.log_path.is_file(), "per-launch log file missing (SC-04)"
def _stop_server(holder: ServerHolder) -> None:
if not holder.live:
return
r = _run(["bash", _script("stop_comfyui.sh")], env=_script_env(), timeout=120)
assert r.returncode == 0, f"stop_comfyui.sh failed:\n{r.stdout}\n{r.stderr}"
holder.live = False
assert _port_free(), "port still bound after stop (SC-07)"
def _launch_log(holder: ServerHolder) -> str:
assert holder.log_path is not None and holder.log_path.is_file()
return holder.log_path.read_text(errors="ignore")
def _named_log(launch_id: str) -> str:
p = LOGS_DIR / f"comfyui.{PORT}.{launch_id}.log"
assert p.is_file(), f"launch log for {launch_id} missing"
return p.read_text(errors="ignore")
def _require_smoke(holder: ServerHolder) -> None:
"""SC-05 abort semantics: matrix rows refuse to run after a smoke failure
so a mount/activation problem cannot produce misleading 404 results."""
if not holder.smoke_ok:
pytest.fail(
"aborting matrix row: smoke (GET /manager/version) has not passed "
"for this launch — mount/activation problem or Q-2 bundled-manager "
"collision (E2E-SC-05)"
)
def _smoke(holder: ServerHolder) -> None:
r = requests.get(f"{BASE_URL}/manager/version", timeout=10)
assert r.status_code == 200, (
f"smoke FAILED: GET /manager/version -> {r.status_code}; the "
f"worktree-mounted plugin did not register its routes (E2E-SC-05). "
f"Log tail:\n{_launch_log(holder)[-2000:]}"
)
assert r.text.strip(), "smoke: /manager/version body empty"
holder.smoke_ok = True
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def mount_worktree():
"""E2E-SC-02/03 — SOLE owner of mount create / reuse-verify / teardown."""
ref = os.environ.get("E2E_MANAGER_REF", "HEAD")
r = _run(["git", "-C", str(MANAGER_REPO), "rev-parse", f"{ref}^{{commit}}"], check=True)
sha = r.stdout.strip()
# Scoping invariant (SC-03): the mount path is {ROOT}-prefixed and is
# NEVER under the members' .claude/worktrees tree. Every mount/teardown
# command below references ONLY this path.
mount = MOUNT.resolve()
assert ".claude/worktrees" not in str(mount).replace(os.sep, "/"), (
"mount path must never live under member worktrees (SC-03 scoping)"
)
assert str(mount).startswith(str(ROOT.resolve())), (
"mount path must be {ROOT}-prefixed (SC-03 scoping)"
)
porcelain = _run(["git", "-C", str(MANAGER_REPO), "worktree", "list", "--porcelain"]).stdout
if f"worktree {mount}" in porcelain:
# Reuse path (SC-03)
head = _run(["git", "-C", str(mount), "rev-parse", "HEAD"]).stdout.strip()
if head != sha:
_run(["git", "-C", str(mount), "checkout", "--detach", sha], check=True)
else:
# Create path (SC-02)
_run(["git", "-C", str(MANAGER_REPO), "worktree", "add", "--detach",
str(mount), sha], check=True)
# From here a worktree exists at `mount`. Any failure between now and the
# yield (the verify asserts below) must STILL run teardown — otherwise a
# failed setup leaks an orphaned worktree into the next session (review
# follow-up). Hence the try/finally wraps verify + yield, not just yield.
try:
# Verify (every session)
head = _run(["git", "-C", str(mount), "rev-parse", "HEAD"]).stdout.strip()
assert head == sha, f"mount HEAD {head} != expected {sha} (SC-02)"
print(f"[mount_worktree] Manager mounted at {mount} @ SHA {sha}") # [D2] traceability
assert (mount / ".git").is_file(), (
".git in the mount must be a FILE (gitdir pointer) — worktree layout (SC-02)"
)
# [D2] other half: no pip-installed Manager in the venv; per MM §2.2 no
# assertion anywhere relies on the mounted Manager's OWN version/remote
# self-report (.git-file degradation accepted by design — spec R5).
for dist in ("comfyui-manager", "ComfyUI-Manager"):
rc = _run([str(VENV_PY), "-m", "pip", "show", dist]).returncode
assert rc != 0, f"pip-installed Manager '{dist}' found in venv — violates [D2]"
yield {"path": mount, "sha": sha}
finally:
if os.environ.get("E2E_KEEP_MOUNT"):
print(f"[mount_worktree] E2E_KEEP_MOUNT set — keeping {mount}")
else:
# Exception-safe: prune + absence asserts run even when remove fails
# (review iter-2 — crash residue must still be surfaced honestly).
try:
_run(["git", "-C", str(MANAGER_REPO), "worktree", "remove", "--force", str(mount)],
check=True)
finally:
_run(["git", "-C", str(MANAGER_REPO), "worktree", "prune"])
porcelain = _run(["git", "-C", str(MANAGER_REPO), "worktree", "list", "--porcelain"]).stdout
assert f"worktree {mount}" not in porcelain, "mount still listed after remove (SC-07)"
assert not mount.exists(), "mount dir still present after remove (SC-07)"
@pytest.fixture(scope="class")
def deny_server(mount_worktree):
"""L-D: both flags ABSENT (live 'missing key reads false'), loopback."""
_pre_guards() # SC-20
_stage("deny") # SC-06
holder = ServerHolder()
_start_server(holder, "L-D") # SC-04
yield holder
# Exception-safe teardown chain (review iter-2 must-fix): a failing
# stop is exactly the crashed-run shape SC-23 exists for — the guard
# and the config restore MUST run regardless.
try:
_stop_server(holder) # SC-07 (current handle, whatever is live)
finally:
try:
_reservation_guard() # SC-23 — UNCONDITIONAL
finally:
_restore_config() # SC-07: restore + DELETE backup
@pytest.fixture(scope="class")
def allow_server(mount_worktree):
"""L-A: both flags true, loopback. SC-14 mutates the holder to R-A."""
_pre_guards() # SC-20 (re-guards before the allow arm)
_stage("allow") # SC-06
holder = ServerHolder()
_start_server(holder, "L-A")
yield holder
# Exception-safe teardown chain (review iter-2 must-fix): every
# residual guard runs even when the stop (or an earlier sweep step)
# raises — SC-23 is contractually UNCONDITIONAL (R3 leak class).
try:
_stop_server(holder) # stops the CURRENT identity (L-A or R-A)
finally:
try:
_remove_pack(PACK_NAME) # defensive re-sweep (primary assert is SC-21)
_pip_uninstall() # defensive (primary assert is SC-22)
# Amendment A2: sweep the S-A fixture's transitive-dep residual
# class (python-slugify + text-unidecode ride the git
# transaction; verified NOT in ComfyUI's own requirements).
_run([str(VENV_PY), "-m", "pip", "uninstall", "-y", *TRANSITIVE_DEPS])
finally:
try:
_reservation_guard() # SC-23 — UNCONDITIONAL (failure path cover)
finally:
_restore_config()
@pytest.fixture(scope="class")
def public_server(mount_worktree):
"""L-P (opt-in, Q-7): both flags true, 0.0.0.0 listener."""
_pre_guards()
_stage("allow")
holder = ServerHolder()
_start_server(holder, "L-P", listen="0.0.0.0")
yield holder
# Exception-safe teardown chain (review iter-2 must-fix).
try:
_stop_server(holder)
finally:
try:
_reservation_guard()
finally:
_restore_config()
# ---------------------------------------------------------------------------
# Classes — definition order IS execution order (deny first on the fresh env)
# ---------------------------------------------------------------------------
class TestDenyArms:
"""L-D launch: SC-05 smoke, SC-10, SC-11 (deny rows are offline-safe —
denial happens before any network access)."""
def test_00_smoke_manager_version(self, deny_server):
_smoke(deny_server) # SC-05
def test_01_sa_deny(self, deny_server):
"""E2E-SC-10: S-A deny — 403 + exact flag token + no artifact + honest log."""
_require_smoke(deny_server)
r = requests.post(f"{BASE_URL}/customnode/install/git_url",
json={"url": NODEPACK_URL}, timeout=30)
assert r.status_code == 403
assert r.json() == {"error": "allow_git_url_install"}, (
f"deny body must carry the flag token, got {r.text!r}"
)
assert not _pack_exists(PACK_NAME), "clone artifact created on DENY (SC-10)"
log = _launch_log(deny_server)
assert DENY_COPY_GIT in log, "flag-naming denial copy missing from L-D log"
assert OLD_COPY_GENERAL not in log and OLD_COPY_NORMAL_MINUS not in log, (
"denial attributed to security_level — honest-copy violation (SC-10)"
)
def test_02_sb_deny(self, deny_server):
"""E2E-SC-11: S-B deny — 403 + flag token + no reservation + not importable."""
_require_smoke(deny_server)
r = requests.post(f"{BASE_URL}/customnode/install/pip",
json={"packages": PIP_URL}, timeout=30)
assert r.status_code == 403
assert r.json() == {"error": "allow_pip_install"}
assert _scripts_clean(), "reservation recorded on DENY (SC-11)"
assert _pip_import_rc() != 0, "pip fixture importable after DENY (SC-11)"
log = _launch_log(deny_server)
assert DENY_COPY_PIP in log, "flag-naming denial copy missing from L-D log"
class TestAllowArms:
"""L-A launch + R-A restart. ORDERED methods (donor sequential-class
precedent): SC-12 -> SC-13 -> SC-14 -> SC-21 -> SC-22 -> SC-42."""
def test_00_smoke_manager_version(self, allow_server):
_smoke(allow_server) # SC-05 (re-smoke on the new launch)
@needs_network
def test_01_git_url_allow(self, allow_server):
"""E2E-SC-12: S-A allow — 200 + real clone + clone-target proof."""
_require_smoke(allow_server)
r = requests.post(f"{BASE_URL}/customnode/install/git_url",
json={"url": NODEPACK_URL}, timeout=120)
assert r.status_code == 200, f"S-A allow expected 200, got {r.status_code}: {r.text!r}"
assert _wait_for(lambda: _pack_exists(PACK_NAME)), (
f"{PACK_NAME} not cloned within {POLL_TIMEOUT}s (SC-12)"
)
git_dir = _pack_dir() / ".git"
assert git_dir.is_dir(), "no .git DIRECTORY — not a real clone (SC-12)"
# Donor clone-target proof: .git/config [remote "origin"] url matches
# the requested URL modulo the .git suffix.
cp = configparser.ConfigParser()
cp.read(git_dir / "config")
section = 'remote "origin"'
assert section in cp, f'[{section}] missing from .git/config: {cp.sections()!r}'
remote_url = cp[section].get("url", "").rstrip("/")
expected = NODEPACK_URL.rstrip("/")
assert remote_url in (expected, expected + ".git"), (
f"clone targeted the WRONG repo: {remote_url!r} != {expected!r} (SC-12)"
)
@needs_network
def test_02_pip_allow_reserved(self, allow_server):
"""E2E-SC-13 (VERBATIM anti-false-PASS oracle): 200 = RESERVED, NOT
INSTALLED. Asserting import success here would be the exact false-PASS
the MM correction exists to prevent."""
_require_smoke(allow_server)
r = requests.post(f"{BASE_URL}/customnode/install/pip",
json={"packages": PIP_URL}, timeout=30)
assert r.status_code == 200, f"S-B allow expected 200, got {r.status_code}: {r.text!r}"
assert SCRIPTS_FILE.is_file(), "no reservation file after S-B allow (SC-13)"
content = SCRIPTS_FILE.read_text(errors="ignore")
reserved_lines = [
ln for ln in content.splitlines()
if "'#FORCE'" in ln and PIP_PKG in ln
]
assert reserved_lines, (
f"no reservation line with '#FORCE' + {PIP_PKG!r} in {SCRIPTS_FILE}:\n{content}"
)
# MANDATORY: the package is NOT installed at this point.
assert _pip_import_rc() != 0, (
"pip fixture importable right after the 200 — reservation semantics "
"violated, or a previous run leaked state (SC-13 anti-false-PASS)"
)
@needs_network
def test_03_restart_consumes_reservation(self, allow_server):
"""E2E-SC-14: R-A restart THROUGH the holder; the consuming boot
executes + removes the reservation; MARKER import proves field-level."""
_require_smoke(allow_server)
assert SCRIPTS_FILE.is_file(), "precondition: reservation must exist (SC-13 first)"
# Restart THROUGH the holder (spec §3 binding item 3): stop the live
# L-A process, relaunch as R-A with the SAME staged config, replace
# the handle — class teardown then stops R-A.
_stop_server(allow_server)
_start_server(allow_server, "R-A")
_smoke(allow_server)
# Field-level positive proof (not just exit code):
marker = _pip_marker_rc()
assert marker.returncode == 0, (
f"MARKER import failed after the consuming restart (SC-14):\n"
f"{marker.stderr}\nR-A log tail:\n{_named_log('R-A')[-3000:]}"
)
assert not SCRIPTS_FILE.exists(), (
"install-scripts.txt NOT removed by the consuming boot (SC-14 self-clean)"
)
ra_log = _named_log("R-A")
assert "## ComfyUI-Manager: EXECUTE =>" in ra_log and PIP_PKG in ra_log, (
"R-A log lacks the startup-script execution block (SC-14)"
)
assert "Startup script completed." in ra_log, (
"R-A log lacks the startup-script completion line (SC-14)"
)
@needs_network
def test_04_clone_residual_cleanup(self, allow_server):
"""E2E-SC-21: clone-dir hygiene; FS-absence primary + installed-index
cross-check while the server is still up (defensive, donor pattern)."""
_remove_pack(PACK_NAME)
assert not _pack_exists(PACK_NAME), "clone dir still present (SC-21 primary)"
try:
r = requests.get(f"{BASE_URL}/customnode/installed", timeout=15)
if r.status_code == 200:
installed = r.json()
assert PACK_NAME not in installed, (
f"{PACK_NAME} still in /customnode/installed after removal (SC-21)"
)
for key, pkg in installed.items():
if isinstance(pkg, dict):
assert pkg.get("cnr_id") != PACK_NAME and pkg.get("aux_id") != PACK_NAME, (
f"installed entry {key!r} still references {PACK_NAME!r} (SC-21)"
)
except (ValueError, requests.RequestException):
# Spec SC-21: if the response schema proves awkward, FS-absence
# alone satisfies this row.
pass
@needs_network
def test_05_pip_residual_uninstall(self, allow_server):
"""E2E-SC-22: S-B residual class 1 (venv package)."""
r = _pip_uninstall()
assert r.returncode == 0, f"pip uninstall failed (SC-22):\n{r.stdout}\n{r.stderr}"
assert _pip_import_rc() != 0, "pip fixture importable after uninstall (SC-22)"
@needs_network
def test_06_requirements_watchdog(self, allow_server):
"""E2E-SC-42 (Q-6 watchdog, amendment A2): every management-script
EXECUTE in the L-A launch log must be attributable to the owned
fixture's OWN pinned requirements (python-slugify==8.0.4 — the
nodepack fixture's deliberate invariant-4 ride-along requirement).
Any other EXECUTE (e.g. a Manager-requirements install — the Q-6
risk this row guards) FAILS the watchdog.
The allowlisted line doubles as LIVE proof of the invariant-4
ride-along class: a dependency pip install executed inside the
git-URL transaction without consulting allow_pip_install."""
la_log = _named_log("L-A")
banner = "## ComfyUI-Manager: EXECUTE =>"
idx = 0
execs = []
while True:
idx = la_log.find(banner, idx)
if idx < 0:
break
execs.append(la_log[idx: idx + 600])
idx += len(banner)
# Non-vacuity (review iter-2 / A2 positive half): the ride-along
# MUST have happened — exactly ONE management-script execution,
# the fixture's single pinned requirement.
assert len(execs) == 1, (
f"expected exactly 1 management-script execution during L-A "
f"(the fixture's pinned requirement ride-along), found "
f"{len(execs)} (SC-42 / A2)"
)
window = execs[0]
assert NODEPACK_PINNED_REQ in window, (
"unexpected management-script execution during L-A — not "
"attributable to the fixture's pinned requirement "
f"({NODEPACK_PINNED_REQ}) (SC-42 watchdog):\n{window}"
)
# Line-level shape: it must be a pip-install command, not an
# arbitrary script that merely mentions the requirement string.
assert "'pip'" in window and "'install'" in window, (
f"EXECUTE block is not a pip-install command (SC-42):\n{window}"
)
@pytest.mark.skipif(
not os.environ.get("E2E_PUBLIC_LISTEN"),
reason="public-listener row is opt-in (E2E_PUBLIC_LISTEN=1) — Q-7 default-off",
)
class TestPublicListener:
"""E2E-SC-40 (opt-in): flags=true + 0.0.0.0 -> still 403 on both surfaces.
Live proof of invariant 2 (predicate = flag AND loopback at REQUEST time)."""
def test_00_smoke_manager_version(self, public_server):
_smoke(public_server)
def test_01_sa_public_deny(self, public_server):
_require_smoke(public_server)
r = requests.post(f"{BASE_URL}/customnode/install/git_url",
json={"url": NODEPACK_URL}, timeout=30)
assert r.status_code == 403
assert r.json() == {"error": "allow_git_url_install"}
assert not _pack_exists(PACK_NAME)
def test_02_sb_public_deny(self, public_server):
_require_smoke(public_server)
r = requests.post(f"{BASE_URL}/customnode/install/pip",
json={"packages": PIP_URL}, timeout=30)
assert r.status_code == 403
assert r.json() == {"error": "allow_pip_install"}
assert _scripts_clean()
class TestZeroResidual:
"""E2E-SC-24: the complete [D3] residual inventory in one assertion block.
Runs AFTER the server classes (their class-scoped fixtures have finalized:
server stopped, config restored, backup deleted). The unmount half of the
inventory is asserted by the mount_worktree finalizer itself — it cannot
be asserted from inside the session while the mount is still live."""
def test_99_zero_residual_sweep(self, mount_worktree):
# custom_nodes clean (incl. .trash_ fallback leftovers)
assert not _pack_exists(PACK_NAME), "nodepack residue in custom_nodes (SC-24)"
leftovers = [p.name for p in CN_DIR.iterdir()
if p.name.startswith((".trash_", PACK_NAME))]
assert not leftovers, f"residual entries in custom_nodes: {leftovers} (SC-24)"
# venv clean
assert _pip_import_rc() != 0, "pip fixture still importable (SC-24)"
# Amendment A2: transitive-dep residual class swept
for dep in TRANSITIVE_DEPS:
rc = _run([str(VENV_PY), "-m", "pip", "show", dep]).returncode
assert rc != 0, f"transitive dep {dep!r} survived the sweep (SC-24 / A2)"
# reservation clean
assert _scripts_clean(), "pip-test1 reservation residue (SC-24)"
# config restored + backup DELETED
assert CFG.is_file(), "config.ini missing after restore (SC-24)"
cfg_text = CFG.read_text(errors="ignore")
assert "allow_git_url_install" not in cfg_text, "staged flag leaked into restored config (SC-24)"
assert "allow_pip_install" not in cfg_text, "staged flag leaked into restored config (SC-24)"
assert not CFG_BACKUP.exists(), "stale config backup survived (SC-24 / peer R2)"
# port free
assert _port_free(), f"port {PORT} still bound (SC-24)"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+159
View File
@@ -0,0 +1,159 @@
"""Lift real production JS out of the shipped files and execute it in node.
WHY LIFT RATHER THAN IMPORT: the manager's front-end modules pull in
``../../scripts/app.js`` and touch ``document`` at module scope, so node cannot
import them. WHY LIFT RATHER THAN RETYPE: a retyped copy of a formatter stops
tracking the formatter the moment someone edits it, and then the guard passes
while the shipped code regresses. Lifting keeps the executed bytes the shipped
bytes — if the production function changes, so does the text under test.
This mirrors tests/test_manager_markdown_escaping.py, which AST-extracts
``convert_markdown_to_html`` rather than importing manager_server.
Consumers point ``JsSource`` at a directory of .js files; an override directory
(e.g. one populated with ``git show <base>:js/foo.js``) makes the RED half of a
fix reproducible in one command.
"""
import json
import shutil
import subprocess
from html.parser import HTMLParser
from pathlib import Path
NODE = shutil.which("node")
def _skip_literal(source: str, pos: int) -> int:
"""If a literal or comment starts at pos, return the index just past it.
Returns pos unchanged when nothing starts there. Template literals are
consumed whole, which is safe for brace counting because a `${...}`
substitution is itself balanced. A lone `/` is read as a regex literal —
sanitizeHTML's own `.replace(/'/g, ...)` hides an apostrophe inside one, and
no scanned range uses `/` as division. Were that to change, the assertion
below fails loudly rather than returning a wrong slice.
"""
ch = source[pos]
nxt = source[pos + 1] if pos + 1 < len(source) else ""
if ch == "/" and nxt == "/":
return source.index("\n", pos)
if ch == "/" and nxt == "*":
return source.index("*/", pos) + 2
if ch not in "\"'`/":
return pos
cursor = pos + 1
while cursor < len(source):
if source[cursor] == "\\":
cursor += 2
continue
if source[cursor] == ch:
return cursor + 1
cursor += 1
raise AssertionError("unterminated literal at offset %d" % (pos,))
def slice_braced(source: str, start_marker: str) -> str:
"""Return start_marker plus the balanced {...} block that follows it."""
start = source.index(start_marker)
pos = source.index("{", start + len(start_marker))
depth = 0
while pos < len(source):
skipped = _skip_literal(source, pos)
if skipped != pos:
pos = skipped
continue
if source[pos] == "{":
depth += 1
elif source[pos] == "}":
depth -= 1
if depth == 0:
return source[start:pos + 1]
pos += 1
raise AssertionError("unbalanced braces after %r" % (start_marker,))
def slice_object_entry(source: str, marker: str, indent: str = "\t\t") -> str:
"""Return one object literal out of a `}, {`-chained array, by id marker.
These objects are literals inside an array, so the first `{` after the
marker belongs to the NEXT entry — brace matching cannot be used. The entry
instead ends at the first `}` sitting at the array's own indent.
"""
start = source.index(marker)
end = source.index("\n%s}" % indent, start)
return source[start:end]
def line_containing(source: str, marker: str) -> str:
"""Return the single source line that holds `marker` (fails if absent)."""
start = source.index(marker)
line_start = source.rfind("\n", 0, start) + 1
return source[line_start:source.index("\n", start)]
class JsSource:
"""The shipped .js files under test, read from `js_dir`."""
def __init__(self, js_dir):
self.js_dir = Path(js_dir)
self._cache = {}
def text(self, filename: str) -> str:
if filename not in self._cache:
self._cache[filename] = (self.js_dir / filename).read_text(encoding="utf-8")
return self._cache[filename]
def lift_declaration(self, filename: str, marker: str) -> str:
"""Lift an `export function`/`export const` declaration, minus `export`."""
return slice_braced(self.text(filename), marker).replace("export ", "", 1)
def lift_formatter(self, filename: str, anchor: str) -> str:
"""Lift the `formatter: ...` at `anchor` as a bare function expression."""
block = slice_braced(self.text(filename), anchor)
marker = "formatter:"
return block[block.index(marker) + len(marker):].strip()
def lift_span(self, filename: str, start_marker: str, end_marker: str) -> str:
"""Lift the source between two markers, end_marker included."""
source = self.text(filename)
start = source.index(start_marker)
end = source.index(end_marker, start) + len(end_marker)
return source[start:end]
def run_node(script: str) -> dict:
"""Execute an ES-module script and parse the single JSON object it prints."""
assert NODE is not None, "node is required to execute the lifted production JS"
result = subprocess.run(
[NODE, "--input-type=module", "-e", script],
capture_output=True, text=True, timeout=60,
)
assert result.returncode == 0, "node failed:\n%s" % (result.stderr,)
return json.loads(result.stdout)
class Collector(HTMLParser):
"""Collects the tags and attributes a browser would actually build."""
def __init__(self):
super().__init__()
self.tags = []
self.attrs = []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
self.attrs.extend(attrs)
handle_startendtag = handle_starttag
def parse(markup: str) -> Collector:
collector = Collector()
collector.feed(markup)
collector.close()
return collector
def event_handlers(markup: str):
"""Every on* attribute a browser would attach for this markup."""
return [name for name, _ in parse(markup).attrs if name.lower().startswith("on")]
+32
View File
@@ -0,0 +1,32 @@
"""Load Python escaping code without importing the ComfyUI server.
MANAGER_GLOB_DIR can point to another revision for regression checks. Loading
manager_util by path avoids shadowing the standard library's glob module.
"""
import ast
import importlib.util
import os
import re
from pathlib import Path
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (Path(__file__).resolve().parent.parent / "glob"))
def load_manager_util():
path = GLOB_DIR / "manager_util.py"
spec = importlib.util.spec_from_file_location("_manager_util_under_test", path)
assert spec is not None and spec.loader is not None, f"cannot load {path}"
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_markdown_functions(manager_util):
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
names = {"convert_markdown_to_html", "populate_markdown"}
functions = [node for node in ast.parse(source).body
if isinstance(node, ast.FunctionDef) and node.name in names]
assert len(functions) == len(names) and {node.name for node in functions} == names
namespace = {"re": re, "manager_util": manager_util}
exec(compile(ast.Module(body=functions, type_ignores=[]), "<markdown-extract>", "exec"), namespace)
return namespace
+121
View File
@@ -0,0 +1,121 @@
"""AC verification for wi-001 (B2): Content-Type rejection helper.
Validates _reject_simple_form_content_type against the 5-item curl matrix
from the WI's acceptance criteria using aiohttp test client. The test
builds a minimal aiohttp app that mirrors the helper's wiring into a
no-body POST handler, so we exercise the real request.content_type
parsing path rather than a mock.
AC matrix:
form-url → 400
multipart → 400
text/plain → 400
no-CT → 200
application/json → 200
"""
import asyncio
import unittest
from pathlib import Path
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
# Parse the helper from manager_server.py without importing it, to avoid
# pulling in the full ComfyUI/PromptServer stack. Note: we intentionally do
# NOT add the `glob/` directory to sys.path — the dir name would shadow
# Python's stdlib `glob` module and break pytest collection.
REPO_ROOT = Path(__file__).resolve().parent.parent
def _load_helper():
"""Parse manager_server.py and execute only the helper definition."""
import ast
source = (REPO_ROOT / "glob" / "manager_server.py").read_text()
tree = ast.parse(source)
wanted = {"_SIMPLE_FORM_CONTENT_TYPES", "_reject_simple_form_content_type"}
nodes = []
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id in wanted:
nodes.append(node)
elif isinstance(node, ast.FunctionDef) and node.name in wanted:
nodes.append(node)
module = ast.Module(body=nodes, type_ignores=[])
ns = {"web": web, "frozenset": frozenset}
exec(compile(module, "manager_server_helpers", "exec"), ns)
return ns["_reject_simple_form_content_type"]
_reject_simple_form_content_type = _load_helper()
async def _handler(request):
resp = _reject_simple_form_content_type(request)
if resp is not None:
return resp
return web.Response(status=200)
class ContentTypeRejectionTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.loop = asyncio.new_event_loop()
asyncio.set_event_loop(cls.loop)
app = web.Application()
app.router.add_post("/noop", _handler)
cls.server = TestServer(app, loop=cls.loop)
cls.client = TestClient(cls.server, loop=cls.loop)
cls.loop.run_until_complete(cls.client.start_server())
@classmethod
def tearDownClass(cls):
cls.loop.run_until_complete(cls.client.close())
cls.loop.close()
def _post(self, headers):
async def go():
return await self.client.post("/noop", headers=headers, data=b"")
return self.loop.run_until_complete(go())
def test_form_urlencoded_rejected(self):
r = self._post({"Content-Type": "application/x-www-form-urlencoded"})
self.assertEqual(r.status, 400)
def test_multipart_form_data_rejected(self):
# aiohttp requires a boundary for multipart; helper should still reject
# based on the primary mimetype.
r = self._post({"Content-Type": "multipart/form-data; boundary=xyz"})
self.assertEqual(r.status, 400)
def test_text_plain_rejected(self):
r = self._post({"Content-Type": "text/plain"})
self.assertEqual(r.status, 400)
def test_no_content_type_allowed(self):
# Explicitly strip Content-Type: aiohttp client may add a default,
# so we use a raw request to ensure absence is tested.
async def go():
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
self.client.make_url("/noop"),
data=None,
skip_auto_headers=["Content-Type"],
) as resp:
return resp.status
status = self.loop.run_until_complete(go())
self.assertEqual(status, 200)
def test_application_json_allowed(self):
r = self._post({"Content-Type": "application/json"})
self.assertEqual(r.status, 200)
if __name__ == "__main__":
unittest.main(verbosity=2)
+325
View File
@@ -0,0 +1,325 @@
"""Behavioral checks against real v3 modules in an isolated ComfyUI runtime."""
import pytest
from test_install_flags_config import _run_child
PRELUDE = '''
import asyncio, io, logging, pathlib, zipfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import cnr_utils, manager_util
from comfy.cli_args import args
args.listen = '0.0.0.0'
core = manager_core
core.manager_funcs.is_flagged_install_allowed = lambda: False
config = core.get_config()
config['allow_flagged_nodepack_install'] = False
nodes = pathlib.Path(tmp) / 'custom_nodes'
nodes.mkdir()
core.get_default_custom_nodes_path = lambda: str(nodes)
manager = core.UnifiedManager()
core.unified_manager = manager
terminal = io.StringIO()
logging.getLogger().addHandler(logging.StreamHandler(terminal))
status = 'NodeVersionStatusFlagged'
http = 200
get = Mock(side_effect=lambda *a, **k: SimpleNamespace(status_code=http, json=lambda: {
'id': 'version-id', 'node_id': 'fixture', 'version': '2.0.0', 'status': status,
'downloadUrl': 'https://example.invalid/fixture.zip',
}))
cnr_utils.requests.get = get
def download(url, directory, name):
with zipfile.ZipFile(pathlib.Path(directory) / name, 'w') as archive:
archive.writestr('__init__.py', 'VERSION = "2.0.0"')
archive.writestr('pyproject.toml', '[project]\\nname = "fixture"\\nversion = "2.0.0"\\n')
core.manager_downloader.download_url = Mock(side_effect=download)
core.manager_downloader.basic_download_url = core.manager_downloader.download_url
manager.execute_install_script = Mock(return_value=True)
'''
def run(body):
stub = '''
comfy = types.ModuleType('comfy')
cli_args = types.ModuleType('comfy.cli_args')
cli_args.args = types.SimpleNamespace(listen='127.0.0.1')
sys.modules['comfy'] = comfy
sys.modules['comfy.cli_args'] = cli_args
'''
return _run_child(stub + PRELUDE + body)
@pytest.mark.parametrize('permission,allowed', [(None, True), ('true', True), ('false', False)])
def test_cli_install_uses_only_parent_permission(permission, allowed):
data = run(f'''
permission = {permission!r}
os.environ.pop('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', None)
if permission is not None:
os.environ['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = permission
core.manager_funcs = core.ManagerFuncs()
args.listen = '127.0.0.1' if permission == 'false' else '0.0.0.0'
config['allow_flagged_nodepack_install'] = permission == 'false'
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count}}))
''')
assert data == {'ok': allowed, 'queries': 1, 'downloads': int(allowed)}
@pytest.mark.parametrize('status,permission,allowed', [
('Active', False, True), ('Pending', False, True),
('Flagged', False, False), ('Flagged', True, True),
])
def test_install_policy_before_download(status, permission, allowed):
data = run(f'''
status = {'NodeVersionStatus' + status!r}
core.manager_funcs.is_flagged_install_allowed = lambda: {permission!r}
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'message': result.msg,
'terminal': terminal.getvalue(),
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count,
'installed': (nodes / 'fixture/__init__.py').exists()}}))
''')
assert data['ok'] is allowed, data
assert data['installed'] is allowed, data
assert data['downloads'] == int(allowed), data
assert data['queries'] == 1, data
if not allowed:
assert data['message'] == ('This action is not allowed by the current security configuration. '
'See the terminal for details.')
for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]',
'allow_flagged_nodepack_install = true', 'config.ini',
'trusted private network', 'Restart ComfyUI'):
assert detail in data['terminal'], data
else:
assert 'allow_flagged_nodepack_install' not in data['terminal']
@pytest.mark.parametrize('http', [403, 404, 500])
def test_override_does_not_bypass_registry_refusal(http):
data = run(f'''
http = {http}
core.manager_funcs.is_flagged_install_allowed = lambda: True
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count}}))
''')
assert data == {'ok': False, 'queries': 1, 'downloads': 0}
@pytest.mark.parametrize('operation', ['install', 'lazy', 'instant'])
@pytest.mark.parametrize('return_postinstall', [False, True])
def test_direct_cnr_execution_and_postinstall(operation, return_postinstall):
data = run(f'''
status = 'NodeVersionStatusActive'
operation = {operation!r}
return_postinstall = {return_postinstall!r}
installed = nodes / 'fixture'
if operation != 'install':
installed.mkdir()
(installed / '__init__.py').write_text('original')
(installed / '.tracking').write_text('__init__.py')
manager.active_nodes['fixture'] = ('1.0.0', str(installed))
manager.reserve_cnr_switch = Mock(return_value=True)
if operation == 'install':
result = manager.cnr_install('fixture', instant_execution=True,
no_deps=True, return_postinstall=return_postinstall)
else:
result = manager.cnr_switch_version('fixture', instant_execution=operation == 'instant',
no_deps=True, return_postinstall=return_postinstall)
assert result.result, result.msg
assert result.target == ('2.0.0' if operation == 'instant' else None)
effect = manager.reserve_cnr_switch if operation == 'lazy' else manager.execute_install_script
before = effect.call_count
if return_postinstall:
assert result.postinstall()
if operation == 'lazy':
assert effect.call_args.args[1] == 'https://example.invalid/fixture.zip'
assert effect.call_args.args[-2:] == (True, 'NodeVersionStatusActive')
else:
assert effect.call_args.kwargs == {{'instant_execution': True, 'no_deps': True}}
print(json.dumps({{'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count,
'before': before, 'after': effect.call_count,
'content': (installed / '__init__.py').read_text()}}))
''')
assert data == {'queries': 1, 'downloads': int(operation != 'lazy'),
'before': int(not return_postinstall), 'after': 1,
'content': 'original' if operation == 'lazy' else 'VERSION = "2.0.0"'}
@pytest.mark.parametrize('operation', ['install', 'reinstall', 'lazy', 'instant', 'snapshot'])
def test_denial_preserves_installed_version(operation):
data = run(f'''
installed = nodes / 'fixture'
installed.mkdir()
(installed / '__init__.py').write_text('VERSION = "1.0.0"')
(installed / '.tracking').write_text('__init__.py')
manager.active_nodes['fixture'] = ('1.0.0', str(installed))
manager.reserve_cnr_switch = Mock(return_value=True)
operation = {operation!r}
if operation == 'snapshot':
manager.reload = AsyncMock()
manager.get_custom_nodes = AsyncMock(return_value={{}})
core.manager_util.restore_pip_snapshot = Mock()
snapshot = pathlib.Path(tmp) / 'snapshot.json'
snapshot.write_text(json.dumps({{'cnr_custom_nodes': {{'fixture': '2.0.0'}},
'git_custom_nodes': {{}}, 'file_custom_nodes': []}}))
asyncio.run(core.restore_snapshot(str(snapshot)))
elif operation in ('lazy', 'instant'):
result = manager.cnr_switch_version('fixture', '2.0.0', instant_execution=operation == 'instant')
assert not result.result, result.msg
elif operation == 'reinstall':
result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0'))
assert not result.result, result.msg
else:
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert not result.result, result.msg
print(json.dumps({{'files': (installed / '__init__.py').read_text(),
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count,
'reservations': manager.reserve_cnr_switch.call_count}}))
''')
assert data == {'files': 'VERSION = "1.0.0"', 'queries': 1, 'downloads': 0, 'reservations': 0}
@pytest.mark.parametrize('listen,allowed', [
('127.0.0.1', True), ('::1', True), ('127.0.0.1,::1', True),
('0.0.0.0', False), ('::', False), ('0.0.0.0,::', False),
('192.168.1.2', False), ('127.0.0.1,0.0.0.0', False), ('', False),
])
def test_all_listener_addresses_must_be_loopback(listen, allowed):
data = run(f'''
print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, {listen!r})))
''')
assert data is allowed
@pytest.mark.parametrize('addresses,allowed', [
(['127.0.0.1', '::1'], True), (['127.0.0.1', '192.168.1.2'], False),
([], False), (None, False),
])
def test_hostname_must_resolve_exclusively_to_loopback(addresses, allowed):
data = run(f'''
addresses = {addresses!r}
if addresses is None:
manager_util.socket.getaddrinfo = Mock(side_effect=OSError('unresolvable'))
else:
manager_util.socket.getaddrinfo = Mock(return_value=[
(0, 0, 0, '', (ip, 0)) for ip in addresses])
print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, 'host.test')))
''')
assert data is allowed
@pytest.mark.parametrize('state', ['nightly', 'disabled'])
def test_denial_preserves_enabled_state(state):
data = run(f'''
state = {state!r}
installed = nodes / ('fixture' if state == 'nightly' else '.disabled/fixture@1_0_0')
installed.mkdir(parents=True)
(installed / '__init__.py').write_text('original')
if state == 'nightly':
manager.active_nodes['fixture'] = ('nightly', str(installed))
else:
manager.cnr_inactive_nodes['fixture'] = {{'1.0.0': str(installed)}}
before = repr((manager.active_nodes, manager.cnr_inactive_nodes))
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'original': (installed / '__init__.py').read_text(),
'unchanged': before == repr((manager.active_nodes, manager.cnr_inactive_nodes)),
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count}}))
''')
assert data == {'ok': False, 'original': 'original', 'unchanged': True, 'queries': 1, 'downloads': 0}
def test_exact_installed_version_enables_without_registry_lookup():
data = run('''
installed = nodes / '.disabled/fixture@2_0_0'
installed.mkdir(parents=True)
(installed / '__init__.py').write_text('already installed')
manager.cnr_inactive_nodes['fixture'] = {'2.0.0': str(installed)}
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert manager.active_nodes['fixture'] == ('2.0.0', str(nodes / 'fixture'))
assert asyncio.run(manager.install_by_id('fixture', '2.0.0')).action == 'skip'
print(json.dumps({'ok': result.result, 'old_path': installed.exists(),
'content': (nodes / 'fixture/__init__.py').read_text(), 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count,
'scripts': manager.execute_install_script.call_count}))
''')
assert data == {'ok': True, 'old_path': False, 'content': 'already installed',
'queries': 0, 'downloads': 0, 'scripts': 0}
def test_failed_uninstall_stops_reinstall():
data = run('''
status = 'NodeVersionStatusActive'
manager.unified_uninstall = Mock(return_value=core.ManagedResult('uninstall').fail('cannot remove'))
result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0'))
print(json.dumps({'ok': result.result, 'message': result.msg, 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count}))
''')
assert data == {'ok': False, 'message': 'cannot remove', 'queries': 1, 'downloads': 0}
@pytest.mark.parametrize('version', ['nightly', 'unknown'])
def test_git_reinstall_replaces_pack_and_reruns_script(version):
data = run(f'''
version = {version!r}
installed = nodes / 'fixture'
installed.mkdir()
(installed / 'original.txt').write_text('original')
repo_url = 'https://example.invalid/fixture'
if version == 'nightly':
manager.active_nodes['fixture'] = ('nightly', str(installed))
else:
manager.unknown_active_nodes['fixture'] = (repo_url, str(installed))
manager.get_custom_nodes = AsyncMock(return_value={{
'fixture': {{'repository': repo_url, 'files': [repo_url]}},
}})
get.side_effect = AssertionError('Git needs no CNR install query')
manager.processed_install.add(str(installed / 'install.py'))
manager.execute_install_script = core.UnifiedManager.execute_install_script.__get__(manager)
core.try_install_script = Mock(return_value=True)
def clone(url, path, **kwargs):
assert url == repo_url
path = pathlib.Path(path)
assert not path.exists()
path.mkdir()
(path / 'replacement.txt').write_text('installed')
(path / 'install.py').write_text('')
assert manager.execute_install_script(url, str(path))
return core.ManagedResult('install-git')
manager.repo_install = Mock(side_effect=clone)
result = asyncio.run(manager.reinstall_by_id('fixture', version))
print(json.dumps({{'ok': result.result, 'original': (installed / 'original.txt').exists(),
'replacement': (installed / 'replacement.txt').read_text(),
'clones': manager.repo_install.call_count, 'scripts': core.try_install_script.call_count}}))
''')
assert data == {'ok': True, 'original': False, 'replacement': 'installed', 'clones': 1, 'scripts': 1}
def test_config_defaults_and_manual_edits_survive_settings_save():
data = _run_child('''
write_ini('[default]\\nsecurity_level = normal\\n')
first = fresh_read()['allow_flagged_nodepack_install']
write_ini('[default]\\nsecurity_level = normal\\nallow_flagged_nodepack_install = true\\n')
manager_core.get_config()['db_mode'] = 'local'
manager_core.write_config()
cached = manager_core.get_config()['allow_flagged_nodepack_install']
fresh = fresh_read()['allow_flagged_nodepack_install']
print(json.dumps([first, cached, fresh]))
''')
assert data == [False, False, True]
@pytest.mark.parametrize('raw,expected', [('TRUE', True), ('false', False), ('garbage', False)])
def test_config_value_roundtrip(raw, expected):
data = _run_child(f'''
write_ini('[default]\\nallow_flagged_nodepack_install = {raw}\\n')
loaded = fresh_read()['allow_flagged_nodepack_install']
manager_core.get_config()['allow_flagged_nodepack_install'] = not loaded
manager_core.write_config()
print(json.dumps([loaded, fresh_read()['allow_flagged_nodepack_install']]))
''')
assert data == [expected, not expected]
+189
View File
@@ -0,0 +1,189 @@
"""Exercise Manager's search overrides with the shipped TurboGrid in Chromium.
Browser cases require Playwright and its Chromium install. All requests are
intercepted locally; no ComfyUI server or external services are used.
"""
import os
from pathlib import Path
import pytest
JS = Path(os.environ.get("MANAGER_JS_DIR") or (Path(__file__).resolve().parent.parent / "js"))
@pytest.mark.parametrize("filename", ["custom-nodes-manager.js", "model-manager.js"])
def test_searching_managers_use_the_safe_grid(filename):
source = (JS / filename).read_text()
assert 'import ManagerGrid from "./manager-grid.js"' in source
assert "new ManagerGrid(container)" in source
assert 'from "./turbogrid.esm.js"' not in source
@pytest.fixture(scope="module")
def browser():
playwright = pytest.importorskip("playwright.sync_api")
with playwright.sync_playwright() as driver:
if not Path(driver.chromium.executable_path).exists():
pytest.skip("install Chromium with: playwright install chromium")
instance = driver.chromium.launch()
yield instance
instance.close()
@pytest.fixture
def page(browser):
page = browser.new_page()
def route(request):
name = request.request.url.rsplit("/", 1)[-1]
if name in {"manager-grid.js", "turbogrid.esm.js"}:
request.fulfill(path=str(JS / name), content_type="text/javascript")
elif request.request.url == "http://manager.test/":
request.fulfill(
body='<div id="grid" style="width:800px;height:300px"></div>',
content_type="text/html",
)
else:
request.abort()
page.route("**/*", route)
page.goto("http://manager.test/")
page.evaluate("""async () => {
const {default: ManagerGrid} = await import('/manager-grid.js');
window.grid = new ManagerGrid(document.getElementById('grid'));
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: []});
grid.render();
}""")
page.wait_for_function("window.grid.options !== undefined")
yield page
page.close()
@pytest.mark.parametrize("keyword", ["before", "img", "after"])
def test_highlighting_keeps_all_text_inert(page, keyword):
text = 'before <img src="/bad.png" onerror="window.__probe=1"> & after'
result = page.evaluate("""({text, keyword}) => {
const cell = document.createElement('div');
cell.id = 'cell';
cell.textContent = text;
document.body.append(cell);
grid.highlightKeywordsSync([cell], [keyword]);
return {text: cell.textContent, marks: [...cell.querySelectorAll('mark')].map(x => x.textContent),
images: cell.querySelectorAll('img').length};
}""", {"text": text, "keyword": keyword})
assert result == {"text": text, "marks": [keyword], "images": 0}
assert page.evaluate("window.__probe === undefined")
def test_keyword_order_continues_across_text_nodes(page):
result = page.evaluate("""() => {
const cell = document.createElement('div');
cell.innerHTML = '<b>Alpha</b> beta Alpha beta <svg><text>Alpha</text></svg><textarea>beta</textarea>';
grid.highlightKeywordsSync([cell], ['alpha', 'beta']);
return [...cell.querySelectorAll('mark')].map(x => x.textContent);
}""")
assert result == ["Alpha", "beta", "Alpha", "beta"]
def test_search_cache_does_not_execute_html(page):
result = page.evaluate("""() => {
const row = {description: '<img src="/bad.png" onerror="window.__probe=1"><b>Alpha</b> &amp; Beta'};
const matched = grid.highlightKeywordsFilter(row, ['description'], 'alpha & beta');
return {matched, text: row.tg_text_description, highlight: row.tg_highlight_description};
}""")
assert result == {"matched": True, "text": "Alpha & Beta", "highlight": True}
page.wait_for_timeout(50)
assert page.evaluate("window.__probe === undefined")
def test_search_preserves_order_columns_and_empty_query(page):
result = page.evaluate("""() => {
const row = {name: 'Alpha Beta', description: 'Gamma', missing: null, count: 0};
const columns = ['name', 'description', 'missing', 'count'];
const ordered = grid.highlightKeywordsFilter(row, columns, ' ALPHA beta ');
const reversed = grid.highlightKeywordsFilter(row, columns, 'beta alpha');
const acrossColumns = grid.highlightKeywordsFilter(row, columns, 'alpha gamma');
const zero = grid.highlightKeywordsFilter(row, columns, '0');
const empty = grid.highlightKeywordsFilter(row, columns, '');
return {ordered, reversed, acrossColumns, zero, empty,
flags: columns.map(column => row['tg_highlight_' + column])};
}""")
assert result == {"ordered": True, "reversed": False, "acrossColumns": False,
"zero": True, "empty": True, "flags": [None] * 4}
def test_custom_text_generator_and_cache_keys(page):
result = page.evaluate("""() => {
Object.assign(grid.options.highlightKeywords, {
textKey: 'text_', highlightKey: 'mark_',
textGenerator: (row, column) => row[column].label
});
const row = {name: {label: '<b>Model</b> &lt;Flux&gt;'}};
const matched = grid.highlightKeywordsFilter(row, ['name'], 'model <flux>');
return {matched, text: row.text_name, highlight: row.mark_name};
}""")
assert result == {"matched": True, "text": "Model <Flux>", "highlight": True}
def test_grid_render_search_and_clear_keep_names_readable(page):
page.evaluate("""() => {
window.query = 'flux';
grid.setOption({rowFilter: row => grid.highlightKeywordsFilter(row, ['name'], window.query)});
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: [
{name: 'Model &lt;Flux&gt; &lt;img src=x onerror=window.__probe=1&gt;'},
{name: 'Other Model'}
]});
grid.render();
}""")
page.wait_for_function("document.querySelectorAll('#grid mark').length === 1")
assert page.locator("#grid mark").all_text_contents() == ["Flux"]
assert page.evaluate("grid.viewRows.length") == 1
assert page.locator("#grid img").count() == 0
assert 'Model <Flux> <img src=x onerror=window.__probe=1>' in page.locator("#grid").inner_text()
page.evaluate("window.query = 'other'; grid.update()")
page.wait_for_function("document.querySelector('#grid mark')?.textContent === 'Other'")
assert page.evaluate("grid.viewRows.length") == 1
page.evaluate("window.query = ''; grid.update()")
page.wait_for_function("grid.viewRows.length === 2 && !document.querySelector('#grid mark')")
assert page.locator("#grid img").count() == 0
assert page.evaluate("window.__probe === undefined")
@pytest.mark.parametrize("filename,column,value,query,nonmatching", [
("custom-nodes-manager.js", "title", "Pack &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("custom-nodes-manager.js", "description", "Notes &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("custom-nodes-manager.js", "author", "Writer &lt;Alias&gt;", "&lt;alias&gt;", "<alias>"),
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "&lt;alias&gt;"),
("model-manager.js", "name", "Model &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("model-manager.js", "description", "<b>Notes</b> &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("model-manager.js", "filename", "model &lt;Flux&gt;", "&lt;flux&gt;", "<flux>"),
("model-manager.js", "type", "Type <Flux>", "<flux>", "&lt;flux&gt;"),
])
def test_manager_search_matches_visible_text(page, filename, column, value, query, nonmatching):
source = (JS / filename).read_text()
init_body = source.split("\tinitGrid() {", 1)[1].split("\n\t}\n", 1)[0]
common = (JS / "common.js").read_text()
sanitizer = common.split("export function sanitizeHTML(str) {", 1)[1].split("\n}", 1)[0]
escape_cell = "undefined"
if filename == "model-manager.js":
escape_cell = source.split("const escapeCell = ", 1)[1].split(";", 1)[0]
page.evaluate("""({init_body, sanitizer, escape_cell, column, value, query}) => {
const sanitizeHTML = new Function('str', sanitizer);
const escapeCell = new Function('sanitizeHTML', `return (${escape_cell})`)(sanitizeHTML);
const initGrid = new Function('ManagerGrid', 'createFlyover', 'gridId', 'sanitizeHTML', 'escapeCell',
`return function initGrid() {${init_body}}`)(grid.constructor, () => ({}), 'review', sanitizeHTML, escapeCell);
const element = document.createElement('div');
element.innerHTML = '<div id="tested-grid" class="cn-manager-grid cmm-manager-grid" style="width:800px;height:300px"></div>';
document.body.append(element);
window.manager = {element, keywords: query, showStatus() {}, handleFlyoverHover() {}, createFlyover: () => ({}), hasAlternatives: () => false};
initGrid.call(manager);
manager.grid.setData({columns: [{id: column, name: column}], rows: [{[column]: value}]});
manager.grid.render();
}""", {"init_body": init_body, "sanitizer": sanitizer, "escape_cell": escape_cell,
"column": column, "value": value, "query": query})
page.wait_for_function("manager.grid.viewRows !== undefined")
assert page.evaluate("manager.grid.viewRows.length") == 1
page.evaluate("query => {manager.keywords=query;}", nonmatching)
assert not page.evaluate("manager.grid.options.rowFilter(manager.grid.data.rows[0])")
+153
View File
@@ -0,0 +1,153 @@
"""Unit tests for the dedicated-install-flag predicate.
Covers `is_dedicated_install_allowed(flag_value, listen_address)` in
glob/manager_server.py:
- Truth table: allowed iff flag is true AND the listener is loopback.
- REPLACE-by-construction: the 2-arg signature has no security_level /
network_mode parameter and the body references no config machinery,
so security_level cannot influence the outcome in either direction.
- Cross-flag isolation: a single flag_value input cannot consult the
other flag.
- Request-time evaluation: the body must not read the import-time
`is_local_mode` snapshot (callers pass args.listen per request).
Harness: glob/manager_server.py is not importable under the test runner
(`from comfy.cli_args import args`, PromptServer), so we AST-parse the
file and exec only the wanted pure defs — `glob/` is never added to
sys.path (the dir name shadows the stdlib `glob`).
"""
import ast
import inspect
import unittest
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
MANAGER_SERVER_PATH = REPO_ROOT / "glob" / "manager_server.py"
_WANTED = {"is_loopback", "is_dedicated_install_allowed"}
def _load_predicates():
"""Parse manager_server.py; exec only the wanted pure function defs."""
source = MANAGER_SERVER_PATH.read_text()
tree = ast.parse(source)
nodes = []
node_by_name = {}
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in _WANTED:
nodes.append(node)
node_by_name[node.name] = node
missing = _WANTED - node_by_name.keys()
assert not missing, f"expected pure defs missing from manager_server.py: {missing}"
module = ast.Module(body=nodes, type_ignores=[])
ns: dict = {"bool": bool}
exec(compile(module, "manager_server_predicates", "exec"), ns)
return ns, node_by_name
_NS, _NODES = _load_predicates()
IS_LOOPBACK: Any = _NS["is_loopback"]
PREDICATE: Any = _NS["is_dedicated_install_allowed"]
PREDICATE_NODE = _NODES["is_dedicated_install_allowed"]
class IsLoopbackBehaviorTest(unittest.TestCase):
"""Pins the loopback term the predicate composes."""
def test_ipv4_loopback(self):
self.assertTrue(IS_LOOPBACK("127.0.0.1"))
def test_public_address(self):
self.assertFalse(IS_LOOPBACK("0.0.0.0"))
def test_ipv6_loopback(self):
self.assertTrue(IS_LOOPBACK("::1"))
def test_invalid_address_reads_false(self):
# Non-IP strings deny-by-default (ValueError path).
self.assertFalse(IS_LOOPBACK("localhost"))
self.assertFalse(IS_LOOPBACK(""))
class DedicatedInstallPredicateTest(unittest.TestCase):
"""P-direct truth table + REPLACE-by-construction."""
def test_truth_table(self):
"""allowed iff flag AND loopback."""
cases = [
# (flag_value, listen_address, expected)
(True, "127.0.0.1", True),
(False, "127.0.0.1", False),
(True, "0.0.0.0", False),
(False, "0.0.0.0", False),
(True, "::1", True),
(True, "not-an-ip", False), # invalid listen -> deny
]
for flag_value, listen, expected in cases:
with self.subTest(flag=flag_value, listen=listen):
result = PREDICATE(flag_value, listen)
self.assertIsInstance(result, bool)
self.assertEqual(result, expected)
def test_falsy_flag_values_deny(self):
"""Secure-by-default: any falsy flag never allows."""
for falsy in (False, None, 0, ""):
with self.subTest(flag=falsy):
self.assertFalse(PREDICATE(falsy, "127.0.0.1"))
def test_signature_has_no_security_level(self):
"""Exactly (flag_value, listen_address) — no security_level term."""
params = list(inspect.signature(PREDICATE).parameters)
self.assertEqual(params, ["flag_value", "listen_address"])
for name in params:
self.assertNotIn("security", name)
self.assertNotIn("network_mode", name)
def test_body_free_of_config_machinery(self):
"""Body references no security_level plumbing, config reader, or the
import-time `is_local_mode` snapshot (request-time evaluation)."""
forbidden = {
"is_allowed_security_level",
"security_level",
"get_config",
"core",
"is_local_mode",
"network_mode",
"args",
}
seen = set()
for node in ast.walk(PREDICATE_NODE):
if isinstance(node, ast.Name):
seen.add(node.id)
elif isinstance(node, ast.Attribute):
seen.add(node.attr)
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
seen.add(node.value)
self.assertEqual(
seen & forbidden, set(),
"predicate body must stay config-import-free",
)
def test_cross_flag_isolation_by_construction(self):
"""A single flag_value input cannot consult the other flag."""
seen_strings = {
node.value
for node in ast.walk(PREDICATE_NODE)
if isinstance(node, ast.Constant) and isinstance(node.value, str)
}
self.assertNotIn("allow_git_url_install", seen_strings)
self.assertNotIn("allow_pip_install", seen_strings)
self.assertTrue(PREDICATE(True, "127.0.0.1"))
self.assertFalse(PREDICATE(False, "127.0.0.1"))
def test_purity_deterministic(self):
"""Pure predicate — repeat calls identical."""
for _ in range(3):
self.assertTrue(PREDICATE(True, "127.0.0.1"))
self.assertFalse(PREDICATE(True, "0.0.0.0"))
if __name__ == "__main__":
unittest.main(verbosity=2)
+242
View File
@@ -0,0 +1,242 @@
"""Config-contract tests for the dedicated install flags.
Drives the real glob/manager_core config reader/writer through a
subprocess-isolated harness and pins: missing keys read False
(secure-by-default), only case-insensitive "true" is truthy, write
round-trips losslessly, edits need a restart (cached_config), the
exception-fallback path supplies False, no auto-migration seeds the
flags from a legacy security_level, and the get_bool missing->False
quirk the flags rely on stays frozen.
Harness: the child process injects a stub `folder_paths` (routing
import-time side effects into a tmpdir, and making has_system_user_api()
True so force_security_level_if_needed does not force 'strong'), prepends
`glob/` to ITS OWN sys.path (shadowing of stdlib `glob` confined to the
child), points manager_core.manager_config_path at a tmp config.ini,
resets cached_config, runs the scenario, and prints one JSON line for the
parent to assert.
"""
import json
import subprocess
import sys
import textwrap
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
_CHILD_PREAMBLE = textwrap.dedent(
"""
import sys, types, tempfile, os, json
tmp = tempfile.mkdtemp(prefix="cm_flags_cfg_")
stub = types.ModuleType("folder_paths")
stub.get_user_directory = lambda: tmp
stub.get_system_user_directory = lambda *a, **k: os.path.join(tmp, "sysuser")
sys.modules["folder_paths"] = stub
sys.path.insert(0, {glob_path!r})
import manager_core
CONFIG_PATH = os.path.join(tmp, "config.ini")
manager_core.manager_config_path = CONFIG_PATH
manager_core.cached_config = None
def write_ini(text):
with open(CONFIG_PATH, "w") as f:
f.write(text)
def fresh_read():
manager_core.cached_config = None
return manager_core.get_config()
def flag_view(cfg):
return {{
"git": cfg.get("allow_git_url_install", "<ABSENT>"),
"pip": cfg.get("allow_pip_install", "<ABSENT>"),
}}
"""
)
def _run_child(body):
"""Run a scenario body in the isolated child; return its JSON payload."""
script = _CHILD_PREAMBLE.format(glob_path=str(REPO_ROOT / "glob")) + textwrap.dedent(body)
proc = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=180,
cwd=str(REPO_ROOT),
)
if proc.returncode != 0:
raise AssertionError(
"config-harness child failed (rc=%d). stderr tail:\n%s"
% (proc.returncode, "\n".join(proc.stderr.strip().splitlines()[-8:]))
)
lines = proc.stdout.strip().splitlines()
if not lines:
raise AssertionError(
"config-harness child exited 0 but produced no stdout. stderr tail:\n%s"
% "\n".join(proc.stderr.strip().splitlines()[-8:])
)
last_line = lines[-1]
try:
return json.loads(last_line)
except json.JSONDecodeError as e:
raise AssertionError(
"config-harness child emitted a non-JSON last line: %r\nfull stdout:\n%s"
% (last_line, proc.stdout)
) from e
class InstallFlagsConfigContractTest(unittest.TestCase):
def test_sc17_missing_keys_read_false(self):
"""Both keys absent from config.ini -> both flags read False
(secure-by-default)."""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = normal\\n")
print(json.dumps(flag_view(fresh_read())))
"""
)
self.assertIs(payload["git"], False)
self.assertIs(payload["pip"], False)
def test_sc18_malformed_and_case_matrix(self):
"""Only case-insensitive "true" is truthy; malformed -> False."""
payload = _run_child(
"""
out = {}
for raw in ["1", "yes", "TRUE", "true ", "true"]:
write_ini("[default]\\nallow_git_url_install = %s\\nallow_pip_install = %s\\n" % (raw, raw))
cfg = fresh_read()
out[raw] = flag_view(cfg)
print(json.dumps(out))
"""
)
expected = {
"1": False, # malformed: numeric truthiness NOT honored
"yes": False, # malformed: yes/no NOT honored
"TRUE": True, # case-insensitive read (:1724)
"true ": True, # configparser strips surrounding whitespace
"true": True,
}
for raw, want in expected.items():
with self.subTest(value=raw):
self.assertIs(payload[raw]["git"], want)
self.assertIs(payload[raw]["pip"], want)
def test_sc19_write_round_trip(self):
"""write_config persists str(bool); round-trip is lossless."""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = normal\\n")
cfg = fresh_read()
cfg["allow_git_url_install"] = True
cfg["allow_pip_install"] = False
manager_core.write_config()
raw = open(CONFIG_PATH).read()
reread = flag_view(fresh_read())
print(json.dumps({
"raw_has_git_true": "allow_git_url_install = True" in raw,
"raw_has_pip_false": "allow_pip_install = False" in raw,
"reread": reread,
}))
"""
)
self.assertTrue(
payload["raw_has_git_true"],
"write_config must persist allow_git_url_install = True in [default]",
)
self.assertTrue(
payload["raw_has_pip_false"],
"write_config must persist allow_pip_install = False in [default]",
)
self.assertIs(payload["reread"]["git"], True)
self.assertIs(payload["reread"]["pip"], False)
def test_sc20_restart_only_activation(self):
"""Editing config.ini without restart has NO effect (cache wins);
a reset (== restart) picks up the change."""
payload = _run_child(
"""
write_ini("[default]\\nallow_git_url_install = false\\n")
first = manager_core.get_config() # populates cached_config
before_edit = flag_view(first)
write_ini("[default]\\nallow_git_url_install = true\\n")
cached = flag_view(manager_core.get_config()) # NO reset: cache must win
after_restart = flag_view(fresh_read()) # reset == restart
print(json.dumps({
"before_edit": before_edit,
"cached_after_edit": cached,
"after_restart": after_restart,
}))
"""
)
self.assertIs(payload["before_edit"]["git"], False)
self.assertIs(
payload["cached_after_edit"]["git"],
False,
"cached_config must NOT hot-reload the edited flag",
)
self.assertIs(payload["after_restart"]["git"], True)
def test_sc21_exception_fallback_supplies_false(self):
"""Corrupted config.ini -> exception-fallback dict supplies flags False."""
payload = _run_child(
"""
# No [default] section header -> read_config raises inside try,
# lands in the exception-fallback dict.
write_ini("allow_git_url_install = true\\ngarbage without section\\n")
cfg = fresh_read()
print(json.dumps({
"flags": flag_view(cfg),
"fallback_marker_file_logging": cfg.get("file_logging"),
}))
"""
)
# file_logging True proves the FALLBACK dict was used (the parse
# path would yield False for a missing file_logging key).
self.assertIs(
payload["fallback_marker_file_logging"],
True,
"corrupted ini must route through the exception-fallback dict",
)
self.assertIs(payload["flags"]["git"], False)
self.assertIs(payload["flags"]["pip"], False)
def test_sc28_no_auto_migration_from_weak(self):
"""Legacy `security_level=weak` does NOT seed the flags (no auto-migration)."""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = weak\\n")
cfg = fresh_read()
print(json.dumps({
"flags": flag_view(cfg),
"security_level": cfg.get("security_level"),
}))
"""
)
self.assertEqual(payload["security_level"], "weak")
self.assertIs(payload["flags"]["git"], False, "no auto-seed from weak")
self.assertIs(payload["flags"]["pip"], False, "no auto-seed from weak")
def test_sc42_get_bool_quirk_guard(self):
"""get_bool ignores its default param: missing `file_logging` reads
False despite a True default. The flags rely on this missing->False
quirk; this guard pins it."""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = normal\\n")
cfg = fresh_read()
print(json.dumps({"file_logging": cfg.get("file_logging", "<ABSENT>")}))
"""
)
self.assertIs(
payload["file_logging"],
False,
"get_bool quirk changed: missing key no longer reads False — "
"new flags rely on missing->False",
)
if __name__ == "__main__":
unittest.main(verbosity=2)
+461
View File
@@ -0,0 +1,461 @@
"""Handler-gate tests for the dedicated install flags.
Three layers, each covering what the others can't:
1. SCInstallGateMirrorTest — a slim mirror of the batch-install handler
(`install_custom_node`, S-C) wired to the REAL extracted gate
primitives. Covers the handler *composition* that the pure predicate
test cannot: risky-level routing, the load-bearing public canary
(the entry gate has no network term, so the deny must come from the
predicate's loopback term), block-arm unconditionality, the
security_level entry gate, and the CNR/middle false-pass guards.
2. DenialConstantsTest — content of the flag denial constants and the
`security_403_response` precedence, asserted directly (no server).
3. BindingProofTest — AST proof that the REAL handlers (S-A/S-B/S-C) are
wired to the predicate and that the old `is_allowed_security_level('high')`
gate is gone from S-A/S-B (closes the mirror-vs-real gap).
The direct S-A/S-B allow/deny behavior is covered by the binding proof
(wiring) plus the real-server E2E suite (behavior); the mirror here
focuses on S-C, whose multi-arm branching is the genuine logic risk.
Harness: glob/manager_server.py is not importable under the runner
(`from comfy.cli_args import args`, PromptServer), so we AST-extract the
gate primitives and exec them into a stub namespace — `glob/` is never
added to sys.path (the dir name shadows stdlib glob).
"""
import ast
import asyncio
import contextlib
import inspect
import json
import logging
import unittest
from pathlib import Path
from typing import Any
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
REPO_ROOT = Path(__file__).resolve().parent.parent
MANAGER_SERVER_PATH = REPO_ROOT / "glob" / "manager_server.py"
_WANTED_FUNCS = {
"is_loopback",
"is_dedicated_install_allowed",
"is_allowed_security_level",
"security_403_response",
}
_WANTED_CONSTS = {
"SECURITY_MESSAGE_MIDDLE_OR_BELOW",
"SECURITY_MESSAGE_NORMAL_MINUS",
"SECURITY_MESSAGE_GENERAL",
"SECURITY_MESSAGE_FLAG_GIT_URL",
"SECURITY_MESSAGE_FLAG_PIP",
}
_HANDLER_NAMES = {
"install_custom_node_git_url", # S-A
"install_custom_node_pip", # S-B
"_queue_node_install", # S-C
}
class _MigrationStub:
def __init__(self):
self.system_user_api = True
def has_system_user_api(self):
return self.system_user_api
class _CoreStub:
"""Stand-in for `core` consulted by is_allowed_security_level."""
def __init__(self):
self.security_level = "normal"
def get_config(self):
return {"security_level": self.security_level}
def _load_surfaces():
source = MANAGER_SERVER_PATH.read_text()
tree = ast.parse(source)
exec_nodes = []
handler_nodes = {}
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.name in _WANTED_FUNCS:
node.decorator_list = [] # exec needs no aiohttp routing context
exec_nodes.append(node)
if node.name in _HANDLER_NAMES:
handler_nodes[node.name] = node
elif isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id in _WANTED_CONSTS:
exec_nodes.append(node)
module = ast.Module(body=exec_nodes, type_ignores=[])
ns: dict = {
"web": web,
"bool": bool,
"manager_migration": _MigrationStub(),
"core": _CoreStub(),
"is_local_mode": True,
}
exec(compile(module, "manager_server_gate_surfaces", "exec"), ns)
# Feature is implemented — these must resolve, else the extraction or
# the production code regressed.
for name in _WANTED_FUNCS | _WANTED_CONSTS:
assert ns.get(name) is not None, "missing gate primitive: %s" % name
for name in _HANDLER_NAMES:
assert name in handler_nodes, "missing handler: %s" % name
return ns, handler_nodes
NS, HANDLERS = _load_surfaces()
PREDICATE: Any = NS["is_dedicated_install_allowed"]
IS_LOOPBACK: Any = NS["is_loopback"]
IAS: Any = NS["is_allowed_security_level"]
SEC_403: Any = NS["security_403_response"]
CATALOG_URL = "https://github.com/catalog/listed-node"
UNKNOWN_URL = "https://github.com/x/not-in-catalog"
CATALOG_PIP = "torch"
UNKNOWN_PIP = "definitely-not-in-catalog-pkg"
def _body_unknown(files=None, pip=None):
"""version=='unknown' ingestion arm."""
return {
"version": "unknown",
"selected_version": "unknown",
"files": files or [UNKNOWN_URL],
"pip": pip or [],
"channel": "default",
"mode": "cache",
"ui_id": "test-row",
}
def _body_cnr_latest():
"""non-nightly CNR arm — risky='low' set statically."""
return {
"version": "1.0.0",
"selected_version": "latest",
"id": "catalog-cnr-pack",
"channel": "default",
"mode": "cache",
"ui_id": "test-row",
}
class _TrackingFlags(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.reads = []
def __getitem__(self, key):
self.reads.append(key)
return super().__getitem__(key)
class GateEnv:
"""Injectable per-row environment for the mirror app."""
def __init__(self, git=False, pip=False, listen="127.0.0.1", security_level="normal"):
self.flags = _TrackingFlags(
{"allow_git_url_install": git, "allow_pip_install": pip}
)
self.listen = listen
self.security_level = security_level
self.task_queue = []
self.risky_calls = 0
self.catalog_urls = {CATALOG_URL}
self.catalog_pips = {CATALOG_PIP}
def get_risky_level(self, files, pip_packages):
"""Mirror of get_risky_level: URL check precedes pip check."""
self.risky_calls += 1
for x in files or []:
if x not in self.catalog_urls:
return "high"
for p in pip_packages or []:
if p not in self.catalog_pips:
return "block"
return "middle"
_SC_DENY_TEXT = "A security error has occurred. Please check the terminal logs"
def _apply_env(env):
"""Retained gates use the is_local_mode snapshot + config stub."""
NS["is_local_mode"] = IS_LOOPBACK(env.listen)
NS["core"].security_level = env.security_level
def _make_sc_install(env):
"""Slim mirror of install_custom_node (S-C) in its post-change gate
shape: security_level entry gate, then risky-level routing where the
'high' (unknown-URL) arm goes through the dedicated predicate and the
retained arms keep is_allowed_security_level."""
async def sc_install(request):
# ENTRY gate — UNCHANGED (security_level-governed)
if not IAS("middle"):
logging.error(NS["SECURITY_MESSAGE_MIDDLE_OR_BELOW"])
return web.Response(status=403, text=_SC_DENY_TEXT)
json_data = await request.json()
risky_level = None
git_url = None
selected_version = json_data.get("selected_version")
if json_data["version"] != "unknown" and selected_version != "unknown":
if selected_version != "nightly":
risky_level = "low" # static — get_risky_level NOT called
else:
git_url = [json_data.get("repository")]
else:
git_url = json_data.get("files")
if risky_level is None:
risky_level = env.get_risky_level(git_url, json_data.get("pip", []))
if risky_level == "high":
# unknown-URL arm -> dedicated predicate (flag AND loopback)
if not PREDICATE(env.flags["allow_git_url_install"], env.listen):
logging.error(NS["SECURITY_MESSAGE_FLAG_GIT_URL"])
return web.Response(status=404, text=_SC_DENY_TEXT)
elif not IAS(risky_level):
# 'block' is always False -> unconditional deny; 'middle'/'low'
# retained UNCHANGED.
logging.error(NS["SECURITY_MESSAGE_GENERAL"])
return web.Response(status=404, text=_SC_DENY_TEXT)
env.task_queue.append(("install", json_data.get("ui_id")))
return web.Response(status=200)
app = web.Application()
app.router.add_post("/manager/queue/install", sc_install)
return app
class _LogCapture(logging.Handler):
def __init__(self):
super().__init__()
self.messages = []
def emit(self, record):
self.messages.append(record.getMessage())
@contextlib.contextmanager
def _capture_logs():
handler = _LogCapture()
root = logging.getLogger()
old_level = root.level
root.addHandler(handler)
root.setLevel(logging.DEBUG)
try:
yield handler.messages
finally:
root.removeHandler(handler)
root.setLevel(old_level)
class SCInstallGateMirrorTest(unittest.TestCase):
"""Batch-install (S-C) gate composition via a slim handler mirror."""
def setUp(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
def tearDown(self):
self.loop.close()
def _post(self, env, body):
_apply_env(env)
async def go():
server = TestServer(_make_sc_install(env))
client = TestClient(server)
await client.start_server()
try:
resp = await client.post("/manager/queue/install", json=body)
return resp.status, await resp.text()
finally:
await client.close()
return self.loop.run_until_complete(go())
def _installs(self, env):
return [item for item in env.task_queue if item[0] == "install"]
def _has(self, logs, const_name):
return any(NS[const_name] in m for m in logs)
def test_high_arm_allow(self):
env = GateEnv(git=True, listen="127.0.0.1")
with _capture_logs() as logs:
status, _ = self._post(env, _body_unknown())
self.assertEqual(status, 200)
self.assertEqual(len(self._installs(env)), 1)
self.assertFalse(self._has(logs, "SECURITY_MESSAGE_FLAG_GIT_URL"))
def test_high_arm_flag_deny(self):
env = GateEnv(git=False, listen="127.0.0.1")
with _capture_logs() as logs:
status, text = self._post(env, _body_unknown())
self.assertEqual(status, 404) # risky-position deny shape kept
self.assertIn("A security error has occurred", text)
self.assertEqual(self._installs(env), [])
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_FLAG_GIT_URL"))
self.assertFalse(self._has(logs, "SECURITY_MESSAGE_NORMAL_MINUS"))
def test_load_bearing_public_canary(self):
"""Entry gate passes on a public listener; the deny MUST come from
the predicate's loopback term (the 'middle' set has no network
term). 404 (risky deny), not 403 (entry deny)."""
env = GateEnv(git=True, listen="0.0.0.0", security_level="normal")
with _capture_logs() as logs:
status, _ = self._post(env, _body_unknown())
self.assertEqual(status, 404)
self.assertEqual(self._installs(env), [])
self.assertFalse(
self._has(logs, "SECURITY_MESSAGE_MIDDLE_OR_BELOW"),
"entry gate must PASS here — deny must come from the predicate",
)
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_FLAG_GIT_URL"))
def test_unknown_pip_block_unconditional(self):
"""Unknown-pip 'block' stays unconditional regardless of both flags
(catalog URL + non-catalog pip; URL check precedes pip check)."""
env = GateEnv(git=True, pip=True, listen="127.0.0.1")
body = _body_unknown(files=[CATALOG_URL], pip=[UNKNOWN_PIP])
with _capture_logs() as logs:
status, _ = self._post(env, body)
self.assertEqual(status, 404)
self.assertEqual(self._installs(env), [])
self.assertEqual(env.risky_calls, 1)
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_GENERAL"))
self.assertEqual(env.flags.reads, [], "block arm must not consult the flags")
def test_entry_gate_strong_denies_despite_flags(self):
"""The security_level entry gate stays in force; flags do NOT bypass it."""
env = GateEnv(git=True, pip=True, listen="127.0.0.1", security_level="strong")
with _capture_logs() as logs:
status, _ = self._post(env, _body_unknown())
self.assertEqual(status, 403)
self.assertTrue(self._has(logs, "SECURITY_MESSAGE_MIDDLE_OR_BELOW"))
self.assertEqual(self._installs(env), [])
self.assertEqual(env.flags.reads, [], "entry deny must not consult the flags")
def test_cnr_latest_arm_never_consults_flags(self):
"""non-nightly CNR sets risky='low' statically — get_risky_level and
the flags are never consulted (false-pass guard)."""
env = GateEnv(git=False, pip=False, listen="127.0.0.1")
status, _ = self._post(env, _body_cnr_latest())
self.assertEqual(status, 200)
self.assertEqual(len(self._installs(env)), 1)
self.assertEqual(env.risky_calls, 0)
self.assertEqual(env.flags.reads, [], "flags must NOT be consulted on the CNR arm")
def test_middle_arm_retained(self):
"""all-catalog body -> risky='middle'; consults security_level
(UNCHANGED), not the flags."""
env = GateEnv(git=False, pip=False, listen="127.0.0.1")
body = _body_unknown(files=[CATALOG_URL], pip=[CATALOG_PIP])
status, _ = self._post(env, body)
self.assertEqual(status, 200)
self.assertEqual(len(self._installs(env)), 1)
self.assertEqual(env.risky_calls, 1)
self.assertEqual(env.flags.reads, [], "flags must NOT be consulted on the middle arm")
class DenialConstantsTest(unittest.TestCase):
"""Denial-copy honesty + security_403_response precedence (no server)."""
def _assert_honest_copy(self, const, flag_name):
self.assertIn(flag_name, const, "constant must name the responsible flag")
self.assertIn("config.ini", const, "constant must name config.ini")
for cause_phrasing in (
"is not allowed in this security_level",
"set the security level",
"a security_level of",
"security level configuration",
):
self.assertNotIn(cause_phrasing, const)
self.assertNotEqual(const, NS["SECURITY_MESSAGE_NORMAL_MINUS"])
self.assertNotEqual(const, NS["SECURITY_MESSAGE_GENERAL"])
def test_flag_constants_content(self):
self._assert_honest_copy(NS["SECURITY_MESSAGE_FLAG_GIT_URL"], "allow_git_url_install")
self._assert_honest_copy(NS["SECURITY_MESSAGE_FLAG_PIP"], "allow_pip_install")
def test_security_403_precedence(self):
"""outdated branch FIRST; flag_token names the flag; no-arg callers
stay byte-identical."""
self.assertIn("flag_token", inspect.signature(SEC_403).parameters)
NS["manager_migration"].system_user_api = False
try:
resp = SEC_403(flag_token="allow_git_url_install")
self.assertEqual(
json.loads(resp.text), {"error": "comfyui_outdated"},
"comfyui_outdated must take PRECEDENCE over flag_token",
)
finally:
NS["manager_migration"].system_user_api = True
resp = SEC_403(flag_token="allow_git_url_install")
self.assertEqual(json.loads(resp.text), {"error": "allow_git_url_install"})
resp = SEC_403()
self.assertEqual(
json.loads(resp.text), {"error": "security_level"},
"no-arg callers must stay byte-identical",
)
class BindingProofTest(unittest.TestCase):
"""AST proof that the REAL handlers are wired to the predicate (closes
the mirror-vs-real gap)."""
@staticmethod
def _ias_literal_calls(node):
out = []
for sub in ast.walk(node):
if (
isinstance(sub, ast.Call)
and isinstance(sub.func, ast.Name)
and sub.func.id == "is_allowed_security_level"
and sub.args
):
arg = sub.args[0]
out.append(arg.value if isinstance(arg, ast.Constant) else None)
return out
def test_handlers_bind_predicate(self):
"""S-A, S-B, S-C all gate via is_dedicated_install_allowed with the
right flag + args.listen (request-time); S-C keeps the 'middle'
entry gate and a variable-arg retained is_allowed_security_level path."""
for name, flag in (
("install_custom_node_git_url", "allow_git_url_install"),
("install_custom_node_pip", "allow_pip_install"),
("_queue_node_install", "allow_git_url_install"),
):
with self.subTest(handler=name):
src = ast.unparse(HANDLERS[name])
self.assertIn("is_dedicated_install_allowed(", src)
self.assertIn(flag, src)
self.assertIn("args.listen", src)
sc_literals = self._ias_literal_calls(HANDLERS["_queue_node_install"])
self.assertIn("middle", sc_literals, "entry gate must stay UNCHANGED")
self.assertIn(None, sc_literals, "a variable-arg retained path must remain")
def test_replace_no_high_literal_at_sa_sb(self):
"""REPLACE proof: no is_allowed_security_level('high') remains at
S-A / S-B (the flag fully replaced the old security_level gate)."""
for name in ("install_custom_node_git_url", "install_custom_node_pip"):
with self.subTest(handler=name):
self.assertNotIn(
"high", self._ias_literal_calls(HANDLERS[name]),
"%s still gates via is_allowed_security_level('high')" % name,
)
if __name__ == "__main__":
unittest.main(verbosity=2)
+141
View File
@@ -0,0 +1,141 @@
"""Structural (grep/AST) guards for the dedicated install flags.
Cheap source-level guards that complement the behavioral tests:
- Frontend 403 copy: both install surfaces in js/common.js name their
responsible flag, and the generic fallback copy stays unchanged.
- No new HTTP install surface is added.
- cm-cli stays an ungated local operator tool.
- The migration module never references the flags (no auto-seed —
explicit opt-in only).
Harness: read/grep + AST over glob/*.py, cm-cli.py and js/*.js. No
imports of `glob/` modules (the dir name shadows stdlib glob).
"""
import re
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
MANAGER_SERVER_PATH = REPO_ROOT / "glob" / "manager_server.py"
MANAGER_MIGRATION_PATH = REPO_ROOT / "glob" / "manager_migration.py"
CM_CLI_PATH = REPO_ROOT / "cm-cli.py"
JS_COMMON_PATH = REPO_ROOT / "js" / "common.js"
GENERIC_403_COPY = "This action is not allowed with this security level configuration."
FLAG_TOKENS = ("allow_git_url_install", "allow_pip_install")
def _js_function_block(source, func_name):
"""Slice an `export async function <name>` block (up to the next
export or EOF)."""
start = source.find("export async function %s" % func_name)
if start < 0:
raise AssertionError("function %s not found in js source" % func_name)
next_export = source.find("export ", start + 1)
return source[start: next_export if next_export > 0 else len(source)]
def _handle403_call_args(source):
"""All handle403Response(...) CALL argument strings (def/import lines
excluded)."""
calls = []
for match in re.finditer(r"handle403Response\s*\(([^()]*(?:\([^()]*\)[^()]*)*)\)", source):
line_start = source.rfind("\n", 0, match.start()) + 1
line = source[line_start: source.find("\n", match.start())]
if "function handle403Response" in line or line.lstrip().startswith("import"):
continue
calls.append(match.group(1).strip())
return calls
class JsCopyStructuralTest(unittest.TestCase):
"""Frontend honest-copy contract."""
@classmethod
def setUpClass(cls):
cls.common_src = JS_COMMON_PATH.read_text(encoding="utf-8")
def test_surface_messages_name_their_flag(self):
"""Both install 403 branches pass a flag-naming defaultMessage."""
for func, flag in (
("install_via_git_url", "allow_git_url_install"),
("install_pip", "allow_pip_install"),
):
with self.subTest(func=func):
block = _js_function_block(self.common_src, func)
two_arg_calls = [a for a in _handle403_call_args(block) if "," in a]
self.assertTrue(
two_arg_calls,
"%s must call handle403Response with a defaultMessage" % func,
)
self.assertIn(flag, block)
self.assertIn("config.ini", block)
def test_generic_fallback_and_frozen_callers_unchanged(self):
"""The generic fallback copy stays (exactly its two occurrences in
handle403Response), and no other handle403Response caller across
js/ gains a defaultMessage."""
# Count inside handle403Response rather than file-wide. The same
# sentence is legitimately reused elsewhere in common.js to build a
# batch-uninstall error message, which is not a caller of
# handle403Response and so is outside this guard's contract.
handle_block = _js_function_block(self.common_src, "handle403Response")
self.assertEqual(handle_block.count(GENERIC_403_COPY), 2)
surface_blocks = "".join(
_js_function_block(self.common_src, name)
for name in ("install_pip", "install_via_git_url")
)
allowed_two_arg = {a for a in _handle403_call_args(surface_blocks) if "," in a}
for js_file in sorted((REPO_ROOT / "js").glob("*.js")):
source = js_file.read_text(encoding="utf-8")
for args in _handle403_call_args(source):
if "," in args:
self.assertIn(
args, allowed_two_arg,
"frozen handle403Response caller in %s gained a "
"defaultMessage: handle403Response(%s)" % (js_file.name, args),
)
class StructuralSecurityGuardsTest(unittest.TestCase):
"""Source-level guards against scope bleed."""
def test_no_new_install_route_surface(self):
"""No new HTTP surface for git-URL/pip install."""
source = MANAGER_SERVER_PATH.read_text(encoding="utf-8")
routes = set(re.findall(r"@routes\.post\(\"([^\"]+)\"\)", source))
expected_surfaces = {
"/customnode/install/git_url",
"/customnode/install/pip",
"/manager/queue/install",
"/manager/queue/reinstall",
}
self.assertTrue(expected_surfaces.issubset(routes))
install_like = {r for r in routes if "install" in r}
self.assertEqual(
install_like,
expected_surfaces
| {"/manager/queue/uninstall", "/manager/queue/install_model"},
"install-like route set drifted — no new install surface allowed",
)
def test_cm_cli_ungated(self):
"""cm-cli stays a local operator tool — no gate, no flag lookup."""
source = CM_CLI_PATH.read_text(encoding="utf-8")
for token in FLAG_TOKENS + ("is_allowed_security_level", "is_dedicated_install_allowed"):
self.assertNotIn(token, source, "cm-cli.py must stay ungated")
def test_no_autoseed_in_migration(self):
"""The migration module never references the flags (explicit
opt-in only — no auto-seed from security_level)."""
source = MANAGER_MIGRATION_PATH.read_text(encoding="utf-8")
for token in FLAG_TOKENS:
self.assertNotIn(
token, source,
"manager_migration.py must not seed/translate the new flags",
)
if __name__ == "__main__":
unittest.main(verbosity=2)
+331
View File
@@ -0,0 +1,331 @@
"""RED->GREEN guard for the unescaped-innerHTML regression in the legacy Manager UI.
A node pack `description` is markdown-transformed server-side and then written to
innerHTML by the client. `convert_markdown_to_html`'s `replace_a` interpolated the
captured URL RAW into a single-quoted href, and the URL pattern is `([^)]+)` — so a
URL carrying a single quote closed the attribute and injected an event handler.
No `<` or `>` is needed, which is why the upstream `<`/`>` escaping did not stop it.
IMPORT DISCIPLINE (load-bearing, see tests/conftest.py):
- `glob/` has NO `__init__.py`, so `import glob.manager_util` is not a package
import, and putting `glob/` on sys.path would SHADOW the stdlib `glob` module
and break pytest itself. manager_util is therefore loaded by FILE LOCATION
under a private module name — it is the real module, really executed.
- `glob/manager_server.py` starts a network thread at import and needs the
ComfyUI stack, so it is NEVER imported. `convert_markdown_to_html` is
AST-extracted and executed in an isolated namespace instead — the same
technique tests/test_csrf_content_type_helper.py already uses in this repo.
"""
import unittest
from manager_test_utils import load_manager_util, load_markdown_functions
def _parse_anchors(html):
"""Return one dict of attributes per <a> tag, using a real HTML parser."""
from html.parser import HTMLParser
class _Collector(HTMLParser):
def __init__(self):
super().__init__()
self.anchors = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.anchors.append(dict(attrs))
collector = _Collector()
collector.feed(html)
collector.close()
return collector.anchors
def _parse_element_names(html):
"""Return the tag name of every element the HTML actually produces."""
from html.parser import HTMLParser
class _Collector(HTMLParser):
def __init__(self):
super().__init__()
self.names = []
def handle_starttag(self, tag, attrs):
self.names.append(tag)
collector = _Collector()
collector.feed(html)
collector.close()
return collector.names
MANAGER_UTIL = load_manager_util()
CONVERT = load_markdown_functions(MANAGER_UTIL)["convert_markdown_to_html"]
# Synthetic stand-in for the crafted payload. It keeps a LEGITIMATE https scheme
# on purpose: a scheme allow-list alone does not stop this one, so the test pins
# the attribute ESCAPING rather than the allow-list.
BREAKOUT_URL = "https://example.com/x' onmouseover='alert`1`"
BREAKOUT_MD = f"[a/click me]({BREAKOUT_URL})"
class TestAttributeBreakout(unittest.TestCase):
def test_quote_in_url_cannot_close_the_href_attribute(self):
html = CONVERT(BREAKOUT_MD)
self.assertNotIn(
"' onmouseover='", html,
f"URL quote escaped the href attribute and injected a handler: {html!r}",
)
def test_transformed_anchor_exposes_no_event_handler_attribute(self):
# Parsed, not regex-matched: after the fix the payload text still LIVES
# inside the href value (harmlessly, as an odd URL), so a naive
# /\son[a-z]+=/ scan would flag the fixed output too. What actually
# matters is which ATTRIBUTES the anchor ends up carrying.
html = CONVERT(BREAKOUT_MD)
anchors = _parse_anchors(html)
self.assertEqual(len(anchors), 1, f"expected one anchor, got {html!r}")
names = set(anchors[0])
self.assertEqual(
names, {"href", "target", "rel"},
f"unexpected attribute(s) on the anchor: {sorted(names)} in {html!r}",
)
def test_legit_link_still_renders_a_usable_anchor(self):
html = CONVERT("see [a/the docs](https://example.com/guide?a=1&b=2) please")
self.assertIn("<a href='https://example.com/guide?a=1&amp;b=2'", html)
self.assertIn(">the docs</a>", html)
self.assertIn("target='_blank'", html)
def test_link_text_is_escaped_once(self):
for label in ["a <b> tag", "a &lt;b&gt; tag"]:
with self.subTest(label=label):
html = CONVERT(f"[a/{label}](https://example.com)")
self.assertIn("&lt;b&gt;", html)
self.assertNotIn("&amp;lt;", html)
def test_prose_and_link_labels_are_safe_inside_formatted_notes(self):
item = {"description": '<img src=x> [w/Read [a/**<Flux>**](https://example.com/?q=<Flux>)]\n<img src=y>'}
load_markdown_functions(MANAGER_UTIL)["populate_markdown"](item)
html = item["description"]
self.assertEqual(_parse_element_names(html), ["p", "a", "b", "br"])
self.assertIn("<B>&lt;Flux&gt;</B>", html)
self.assertIn("&lt;img src=x&gt;", html)
self.assertIn("&lt;img src=y&gt;", html)
self.assertEqual(_parse_anchors(html)[0]["href"], "https://example.com/?q=<Flux>")
class TestDescriptionUrlRoundTrip(unittest.TestCase):
def test_description_urls_are_escaped_once(self):
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
cases = [
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
("https://example.com/?a=1&amp;b=2", "https://example.com/?a=1&b=2"),
("https://example.com/?q=&lt;Flux&gt;", "https://example.com/?q=<Flux>"),
("https://example.com/?q=&#60;Flux&#x3e;", "https://example.com/?q=<Flux>"),
("https://example.com/?a=1&notebook=2&copy=3", "https://example.com/?a=1&notebook=2&copy=3"),
("https://example.com/?q=&notit;", "https://example.com/?q=&notit;"),
("https://example.com/?q=&amp;lt;", "https://example.com/?q=&lt;"),
("https://example.com/a**b**c?x=1&amp;y=2", "https://example.com/a**b**c?x=1&y=2"),
]
for url, expected in cases:
with self.subTest(url=url):
item = {"description": f"[a/link]({url})"}
populate(item)
anchors = _parse_anchors(item["description"])
self.assertEqual(anchors[0]["href"], expected)
self.assertEqual(_parse_element_names(item["description"]), ["a"])
def test_decoded_entities_cannot_bypass_url_or_attribute_checks(self):
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
for url in ["javascript&#58;alert`1`", "&#106;avascript:alert`1`", "java&Tab;script:alert`1`"]:
with self.subTest(url=url):
item = {"description": f"[a/link]({url})"}
populate(item)
self.assertEqual(_parse_anchors(item["description"])[0]["href"], "#")
url = "https://example.com/x&#39; onmouseover=&#39;alert`1`"
item = {"description": f"[a/link]({url})"}
populate(item)
anchor = _parse_anchors(item["description"])[0]
self.assertEqual(anchor["href"], "https://example.com/x' onmouseover='alert`1`")
self.assertEqual(set(anchor), {"href", "target", "rel"})
class TestEscapedHrefSurvivesTheLaterPasses(unittest.TestCase):
"""The escaper's output must still be intact by the time it is returned.
replace_a runs before the %%..%% / **..** / [w/..] / [i/..] passes, and those
passes used to rewrite the INSIDE of the href it had just secured — putting
<font>/<B>/<p> markup and fresh single quotes back into the attribute. That
both corrupted legitimate URLs and undid the escaping.
"""
def test_percent_markers_in_a_url_leave_the_href_intact(self):
html = CONVERT("[a/t](https://e.com/%%A%%)")
self.assertIn("href='https://e.com/%%A%%'", html)
self.assertNotIn("<font", html)
def test_bold_markers_in_a_url_leave_the_href_intact(self):
html = CONVERT("[a/t](https://e.com/a**b**c)")
self.assertIn("href='https://e.com/a**b**c'", html)
self.assertNotIn("<B>", html)
def test_note_markers_in_a_url_leave_the_href_intact(self):
html = CONVERT("[a/t](https://e.com/[w/A])")
self.assertIn("href='https://e.com/[w/A]'", html)
self.assertNotIn("cm-warn-note", html)
def test_no_element_leaks_out_of_a_url(self):
# Parsed rather than string-matched: the anchor must be the ONLY element.
html = CONVERT("[a/t](https://e.com/%%A%%and**B**)")
self.assertEqual(_parse_element_names(html), ["a"])
def test_nested_markdown_in_the_LINK_TEXT_still_renders(self):
# Protecting the href must not cost the label. Moving the anchor
# substitution last would have escaped these into visible mojibake.
self.assertIn("<B>bold</B>", CONVERT("[a/**bold** text](https://e.com)"))
self.assertIn("<font color='white'>hi</font>", CONVERT("[a/%%hi%% there](https://e.com)"))
def test_markup_outside_a_link_is_unaffected(self):
html = CONVERT("%%white%% and **bold** and [w/warn]")
self.assertIn("<font color='white'>white</font>", html)
self.assertIn("<B>bold</B>", html)
self.assertIn("cm-warn-note", html)
def test_the_newline_pass_cannot_reach_inside_the_href(self):
# The newline substitution is the LAST thing convert_markdown_to_html
# does, so restoring the href before it let '<BR>' be injected into the
# attribute the escaper had secured. Not exploitable — '<BR>' carries no
# quote — but it is the same class of breach this function exists to fix,
# so the invariant is pinned all the way to the returned string.
html = CONVERT("[a/t](https://e.com/a\nb)")
anchors = _parse_anchors(html)
self.assertEqual(len(anchors), 1)
self.assertNotIn("<BR>", anchors[0]["href"])
self.assertEqual(_parse_element_names(html), ["a"])
def test_newlines_outside_a_link_still_become_line_breaks(self):
html = CONVERT("first\nsecond")
self.assertIn("<BR>", html)
def test_a_forged_placeholder_in_the_input_cannot_hijack_restoration(self):
html = CONVERT("\x00H0\x00 [a/t](https://e.com/real)")
self.assertIn("href='https://e.com/real'", html)
self.assertEqual(html.count("https://e.com/real"), 1)
class TestUrlSchemeAllowlist(unittest.TestCase):
def test_helpers_are_public_on_manager_util(self):
self.assertTrue(callable(getattr(MANAGER_UTIL, "escape_html_attribute", None)))
self.assertTrue(callable(getattr(MANAGER_UTIL, "sanitize_url", None)))
def test_dangerous_schemes_are_rejected(self):
for bad in [
"javascript:alert`1`",
"JaVaScRiPt:alert`1`",
" javascript:alert`1`",
"java\tscript:alert`1`",
"java\nscript:alert`1`",
"\x01javascript:alert`1`",
"data:text/html;base64,PHNjcmlwdD4=",
"vbscript:msgbox",
]:
with self.subTest(bad=bad):
self.assertEqual(MANAGER_UTIL.sanitize_url(bad), "#")
def test_safe_urls_pass_through(self):
for good in [
"https://example.com/a?b=1",
"http://example.com",
"HTTPS://Example.COM/Path",
"/relative/path",
"relative/path",
"#in-page-anchor",
"//protocol-relative/path",
# A relative link may legitimately contain '&'. An earlier version
# rejected this shape while guarding against an entity-hidden colon;
# the guarantee comes from the escape layer instead (below), so the
# rejection was redundant and only broke real links.
"a&b/c",
"docs/page?x=1&y=2",
]:
with self.subTest(good=good):
self.assertEqual(MANAGER_UTIL.sanitize_url(good), good)
def test_entity_hidden_colon_is_inert_after_escaping(self):
# Asserted on the POST-ESCAPE href, because that is where the safety
# actually comes from: sanitize_url returns these unchanged (they have no
# real colon, so they read as relative), and escape_html_attribute then
# escapes the '&'. Entity decoding in an attribute is single-pass, so
# what the browser ends up with is a literal string, never a scheme.
for hidden in ["javascript&#58;alert`1`", "&#x6a;avascript:alert`1`",
"&#106;avascript:alert`1`"]:
with self.subTest(hidden=hidden):
href = MANAGER_UTIL.escape_html_attribute(MANAGER_UTIL.sanitize_url(hidden))
self.assertIn("&amp;", href)
self.assertNotIn("&#58;", href)
self.assertNotIn("&#x6a;", href)
self.assertNotIn("&#106;", href)
def test_entity_hidden_colon_in_markdown_produces_no_live_scheme(self):
html = CONVERT("[a/click](javascript&#58;alert`1`)")
anchors = _parse_anchors(html)
self.assertEqual(len(anchors), 1)
# The parser decodes entities exactly as a browser would; after that the
# href must NOT be a javascript: URL.
self.assertFalse(anchors[0]["href"].lower().startswith("javascript:"))
def test_dangerous_scheme_in_markdown_does_not_reach_the_href(self):
html = CONVERT("[a/click](javascript:alert`1`)")
self.assertNotIn("javascript:", html.lower())
self.assertIn("href='#'", html)
class TestAttributeEscaper(unittest.TestCase):
def test_escapes_all_five_characters(self):
self.assertEqual(
MANAGER_UTIL.escape_html_attribute("""&<>"'"""),
"&amp;&lt;&gt;&quot;&#x27;",
)
def test_ampersand_is_escaped_first_and_only_once(self):
self.assertEqual(MANAGER_UTIL.escape_html_attribute("a&b"), "a&amp;b")
def test_non_string_input_is_coerced(self):
self.assertEqual(MANAGER_UTIL.escape_html_attribute(None), "None")
class TestServerTitleTransformIsTheOnlyUpstreamControl(unittest.TestCase):
"""Pin the server-side facts that the bare flyover title sinks rest on.
js/custom-nodes-manager.js says, at both flyover pack-title sinks, that the
title is already escaped by populate_markdown and so must NOT be escaped
again at the client (a second escape would double-escape a legitimate
<>-title into mojibake). That comment is a load-bearing claim about THIS
file's subject: it is the whole reason those two sinks are bare. These
cases make the claim machine-checked, so if the server side ever stops
covering `title` the suite says so, instead of leaving a comment asserting
a protection that is gone.
"""
def test_populate_markdown_escapes_the_title_before_it_reaches_the_client(self):
# The specific link the sink comments name. This is the case that fails
# if someone drops `title` from populate_markdown, which is the drift
# the bare sinks are actually exposed to.
populate_markdown = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
pack = {"title": "Nodes for <Flux> workflows"}
populate_markdown(pack)
self.assertEqual(pack["title"], "Nodes for &lt;Flux&gt; workflows")
def test_sanitize_tag_escapes_angle_brackets(self):
self.assertEqual(MANAGER_UTIL.sanitize_tag("<img>"), "&lt;img&gt;")
def test_sanitize_tag_does_not_escape_quotes_or_ampersand(self):
# Documented limit, not a defect: both title interpolations are in
# element-text position, where quotes are inert. It IS the reason a
# title moving into attribute position would need escaping.
self.assertEqual(MANAGER_UTIL.sanitize_tag("\"a\" & 'b'"), "\"a\" & 'b'")
if __name__ == "__main__":
unittest.main()
+487
View File
@@ -0,0 +1,487 @@
"""Inventory the current direct message-sink calls and reviewed error builders.
These sinks accept HTML because some callers render buttons and server-escaped
metadata. Unknown arguments or assignments require a provenance review. Runtime
rendering tests separately verify that raw text stays inert and escaped titles
remain readable; this source inventory is not a general JavaScript dataflow check.
"""
import re
import tempfile
import unittest
from collections import Counter
from pathlib import Path
from unittest.mock import patch
from js_lift import _skip_literal
REPO_ROOT = Path(__file__).resolve().parent.parent
JS_DIR = REPO_ROOT / "js"
SINK_FILES = [
"common.js",
"custom-nodes-manager.js",
"model-manager.js",
"node-usage-analyzer.js",
]
SINK_METHODS = r"showStatus|showMessage|showError|showSelection"
# Capture receivers so a new alias is reviewed instead of silently omitted.
CALL_RE = re.compile(
r"(?<![\w$.])([\w$]+(?:\??\.[\w$]+)*)"
rf"\??\.({SINK_METHODS})\("
)
SINK_CALL_RE = re.compile(
rf"(?:\.(?:{SINK_METHODS})|\[\s*['\"](?:{SINK_METHODS})['\"]\s*\])"
r"\s*(?:\?\.\s*)?\("
)
PINNED_RECEIVERS = frozenset({"this", "self", "this.ui"})
# Destructuring would hide calls from the receiver-based inventory.
DESTRUCTURE_RE = re.compile(
r"(?:const|let|var)\s*\{[^}]*\b"
rf"(?:{SINK_METHODS})"
r"\b[^}]*\}"
)
SERVER_ESCAPED = "server-escaped" # already display-ready; caller must NOT escape
RAW = "raw" # not display-ready; caller MUST escape
NO_DATA = "no-data" # no channel/registry value interpolated
FORWARDED = "forwarded" # showError's own delegation; see HtmlByDesignSinkTest
def _argument(source: str, start: int) -> str:
"""Read a complete argument, including multiline templates."""
depth = 1
end = start
while depth:
skipped = _skip_literal(source, end)
if skipped != end:
end = skipped
continue
if source[end] == "(":
depth += 1
elif source[end] == ")":
depth -= 1
end += 1
if source[end:end + 1] == ";":
end += 1
return re.sub(r"\s+", " ", source[start:end].strip())
def _call_sites():
"""Return (file, line, receiver, method, argument) for direct calls."""
sites = []
for name in SINK_FILES:
source = (JS_DIR / name).read_text(encoding="utf-8")
matches = list(CALL_RE.finditer(source))
parsed = {match.end() for match in matches}
for candidate in SINK_CALL_RE.finditer(source):
if candidate.end() not in parsed:
lineno = source.count("\n", 0, candidate.start()) + 1
raise AssertionError(f"Unsupported message-sink call at {name}:{lineno}")
for match in matches:
lineno = source.count("\n", 0, match.start()) + 1
sites.append((name, lineno, match.group(1), match.group(2),
_argument(source, match.end())))
return sites
# ---------------------------------------------------------------------------
# THE REGISTRY. Keyed by (file, method, argument-source). Every showStatus and
# showMessage call site must appear here with a provenance verdict and a reason.
# ---------------------------------------------------------------------------
REGISTRY = {
# --- custom-nodes-manager.js -------------------------------------------
("custom-nodes-manager.js", "showStatus",
"`${prevViewRowsLength.toLocaleString()} custom nodes`);"):
(NO_DATA, "a formatted row count"),
("custom-nodes-manager.js", "showStatus",
"`Loading node mappings (${mode}) ...`);"):
(NO_DATA, "`mode` is a datasrc-combo value chosen in the UI, not channel data"),
("custom-nodes-manager.js", "showStatus",
"`${label} ${item.title} ...`);"):
(SERVER_ESCAPED,
"item.title comes from /customnode/getlist, where populate_markdown -> "
"sanitize_tag has already escaped it; escaping again shows entities"),
("custom-nodes-manager.js", "showStatus",
"`${label} ${Object.keys(result).length} custom node(s) successfully`);"):
(NO_DATA, "a count of queue results"),
("custom-nodes-manager.js", "showStatus",
"`Loading missing nodes (${mode}) ...`);"):
(NO_DATA, "UI-chosen mode"),
("custom-nodes-manager.js", "showStatus",
"`Loading alternatives (${mode}) ...`);"):
(NO_DATA, "UI-chosen mode"),
("custom-nodes-manager.js", "showStatus",
"`Loading workflow usage analysis ...`);"):
(NO_DATA, "hardcoded"),
("custom-nodes-manager.js", "showStatus",
"`Loading custom nodes (${mode}) ...`);"):
(NO_DATA, "UI-chosen mode"),
("custom-nodes-manager.js", "showMessage",
"`To apply the installed/updated/disabled/enabled custom node, please restart "
"ComfyUI. And refresh browser.`, \"red\");"):
(NO_DATA, "hardcoded"),
("custom-nodes-manager.js", "showMessage", "\"\");"):
(NO_DATA, "clears the banner"),
# --- model-manager.js ---------------------------------------------------
("model-manager.js", "showStatus",
"`${grid.viewRows.length.toLocaleString()} external models`);"):
(NO_DATA, "a formatted row count"),
("model-manager.js", "showStatus", "`Install ${item.name} ...`);"):
(SERVER_ESCAPED,
"item.name comes from /externalmodel/getlist, where populate_markdown -> "
"sanitize_tag has already escaped it. WI-112 added a caller escape here "
"and it rendered '&lt;Flux&gt;' to the user; removing it is the fix"),
("model-manager.js", "showStatus", "`Install ${result.length} models successfully`);"):
(NO_DATA, "a count of queue results"),
("model-manager.js", "showStatus", "`Loading external model list ...`);"):
(NO_DATA, "hardcoded"),
("model-manager.js", "showMessage",
"`To apply the installed model, please click the 'Refresh' button.`, \"red\")"):
(NO_DATA, "hardcoded"),
("model-manager.js", "showMessage", "\"\");"):
(NO_DATA, "clears the banner"),
# --- node-usage-analyzer.js --------------------------------------------
("node-usage-analyzer.js", "showStatus",
"`${grid.viewRows.length.toLocaleString()} installed packages`);"):
(NO_DATA, "a formatted row count"),
("node-usage-analyzer.js", "showStatus",
"`Install ${sanitizeHTML(String(item.name))} ...`);"):
(RAW,
"item.name is packKey (loadData sets `name: packKey`) — a raw dict key no "
"server transform touches, so this caller escapes it. The ONLY raw-provenance "
"showStatus caller in the four files"),
("node-usage-analyzer.js", "showStatus", "msg);"):
(SERVER_ESCAPED,
"uninstallNodes preserves the escaped title or escapes the raw name fallback; "
"test_node_usage_escaping executes that path through the panel sink"),
("node-usage-analyzer.js", "showStatus",
"`Uninstalled ${targets.length} custom node(s) successfully`);"):
(NO_DATA, "a count of targets"),
("node-usage-analyzer.js", "showStatus",
"`Install ${Object.keys(result).length} models successfully`);"):
(NO_DATA, "a count of queue results"),
("node-usage-analyzer.js", "showStatus",
"`Uninstall ${Object.keys(result).length} custom node(s) successfully`);"):
(NO_DATA, "a count of queue results"),
("node-usage-analyzer.js", "showStatus", "`Analyzing node usage ...`);"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage",
"`To apply the uninstalled custom nodes, please restart ComfyUI and refresh "
"browser.`, \"red\");"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage",
"`To apply the installed model, please click the 'Refresh' button.`, \"red\");"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage", "\"No workflows were found for analysis.\");"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage", "\"\");"):
(NO_DATA, "clears the banner"),
# --- showError's own delegation, in each of the three classes -----------
# Not an application caller: this IS `showError(err) { this.showMessage(err,
# "red"); }`. Whether `err` is display-ready is the showError CALLER's
# responsibility, which HtmlByDesignSinkTest below enforces separately. It is
# registered rather than pattern-excluded so that if showError ever stops
# forwarding — or starts forwarding something else — this entry goes stale
# and the guard says so.
("custom-nodes-manager.js", "showMessage", "err, \"red\");"):
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
("model-manager.js", "showMessage", "err, \"red\");"):
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
("node-usage-analyzer.js", "showMessage", "err, \"red\");"):
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
}
DISPLAY_READY_METHODS = ("showStatus", "showMessage")
# A caller escape is spelled this way throughout the UI files.
ESCAPE_MARKER = "sanitizeHTML"
class MessageSinkRegistryTest(unittest.TestCase):
"""The registry must account for every display-ready-sink call site."""
@classmethod
def setUpClass(cls):
cls.sites = _call_sites()
def test_the_four_sink_implementations_all_exist(self):
# If a file stops defining its own sink (e.g. it starts delegating to
# createUIStateManager) the registry's routing assumptions change and
# this guard must be re-derived rather than silently kept.
own = {
"custom-nodes-manager.js": ".cn-manager-status",
"model-manager.js": ".cmm-manager-status",
"node-usage-analyzer.js": ".nu-manager-status",
}
for name, selector in own.items():
source = (JS_DIR / name).read_text(encoding="utf-8")
self.assertIn("showStatus(msg, color)", source, "%s lost its own sink" % name)
self.assertIn(selector, source)
common = (JS_DIR / "common.js").read_text(encoding="utf-8")
self.assertIn("export function createUIStateManager(", common)
self.assertIn("showStatus: (msg, color)", common)
def test_every_caller_is_classified(self):
"""FAIL-CLOSED: an unregistered call site breaks the build."""
observed = Counter((name, method, arg) for name, _, _, method, arg in self.sites
if method in DISPLAY_READY_METHODS)
duplicates = {
('custom-nodes-manager.js', 'showStatus', '`Loading workflow usage analysis ...`);'),
('node-usage-analyzer.js', 'showMessage',
'`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");'),
}
expected = Counter({key: 1 + (key in duplicates) for key in REGISTRY})
self.assertEqual(observed, expected, 'Changed message calls require a provenance review')
def test_the_observed_receivers_are_the_pinned_set(self):
observed = {receiver for _name, _lineno, receiver, _m, _a in self.sites}
self.assertEqual(
observed, set(PINNED_RECEIVERS),
"the set of receivers calling the message sinks changed: %s. These "
"sinks assign innerHTML and DO NOT escape, so every route to them "
"must be accounted for. Classify the new receiver's call sites in "
"REGISTRY and add it to PINNED_RECEIVERS — do not narrow CALL_RE to "
"make this pass." % (sorted(observed ^ set(PINNED_RECEIVERS)),),
)
def test_no_sink_method_is_destructured_out_of_its_receiver(self):
for name in SINK_FILES:
source = (JS_DIR / name).read_text(encoding="utf-8")
with self.subTest(file=name):
self.assertEqual(
DESTRUCTURE_RE.findall(source), [],
"%s destructures a message-sink method out of its receiver. "
"The resulting bare calls are invisible to this guard; call "
"them through their receiver instead." % (name,),
)
def test_unsupported_sink_calls_require_review(self):
source = (JS_DIR / "common.js").read_text(encoding="utf-8")
for call in ('this["showMessage"](raw);',
'createUIStateManager(element).showError(raw);'):
with self.subTest(call=call), patch(__name__ + ".SINK_FILES", ["common.js"]):
with patch.object(Path, "read_text", return_value=source + "\n" + call):
with self.assertRaisesRegex(AssertionError, "Unsupported message-sink call"):
_call_sites()
def test_the_registry_has_no_stale_entries(self):
"""A registry entry whose call site is gone or whose argument changed."""
live = {
(name, method, arg)
for name, _lineno, _receiver, method, arg in self.sites
if method in DISPLAY_READY_METHODS
}
stale = sorted(key for key in REGISTRY if key not in live)
self.assertEqual(
stale, [],
"REGISTRY entries no longer match any call site — the caller was removed "
"or its argument changed, so its provenance verdict is unverified:\n %s"
% "\n ".join("%s %s(%s" % key for key in stale),
)
def test_server_escaped_callers_do_not_escape_again(self):
for (name, method, arg), (provenance, reason) in REGISTRY.items():
if provenance != SERVER_ESCAPED:
continue
with self.subTest(file=name, method=method, arg=arg):
self.assertNotIn(
ESCAPE_MARKER, arg,
"%s %s escapes an ALREADY-escaped value — the user reads "
"'&lt;Flux&gt;' instead of '<Flux>'. Reason on file: %s"
% (name, method, reason),
)
def test_raw_callers_escape(self):
for (name, method, arg), (provenance, reason) in REGISTRY.items():
if provenance != RAW:
continue
with self.subTest(file=name, method=method, arg=arg):
self.assertIn(
ESCAPE_MARKER, arg,
"%s %s passes a RAW value into a sink that does not escape. "
"Reason on file: %s" % (name, method, reason),
)
def test_no_data_callers_interpolate_no_channel_value(self):
# Interpolations are allowed, but only of things that cannot carry
# channel/registry text: counts, lengths, and the UI-chosen mode.
allowed = re.compile(
r"^\$\{(?:[A-Za-z_.]*\.length|Object\.keys\([A-Za-z_.]+\)\.length"
r"|[A-Za-z_.]+\.toLocaleString\(\)|mode|label"
r"|[A-Za-z_.]*\.length\.toLocaleString\(\))\}$"
)
for (name, method, arg), (provenance, reason) in REGISTRY.items():
if provenance != NO_DATA:
continue
for expr in re.findall(r"\$\{[^}]*\}", arg):
with self.subTest(file=name, method=method, expr=expr):
self.assertRegex(
expr, allowed,
"%s %s is classified %s but interpolates %s, which is not a "
"count or a UI-chosen value. Re-classify it. Reason on file: %s"
% (name, method, NO_DATA, expr, reason),
)
class HtmlByDesignSinkTest(unittest.TestCase):
"""showSelection / showError render real markup and are NOT display-ready sinks."""
@classmethod
def setUpClass(cls):
cls.sites = _call_sites()
def test_show_selection_receives_no_channel_data(self):
expected = {
"custom-nodes-manager.js": [
'"");',
'"");',
# Unknown labels in this builder have behavioral coverage.
'list.join(""));',
],
"model-manager.js": [
'"");',
'"");',
'`<span>Selected <b>${selectedList.length}</b> models <button '
'class="cmm-btn-install p-button p-component" mode="install">Install</button>`);',
],
"node-usage-analyzer.js": [
'"");',
'"");',
'`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);',
'` <div class="nu-selected-buttons"> <span>Selected <b>${installedSelected.length}</b> '
'installed packages</span> <button class="nu-btn-uninstall" mode="uninstall">'
'Uninstall Selected</button> </div> `);',
],
}
actual = Counter((name, arg) for name, _, _, method, arg in self.sites if method == "showSelection")
self.assertEqual(actual, Counter((name, arg) for name, args in expected.items() for arg in args))
def test_show_error_arguments_are_escaped_or_literal(self):
# showError forwards to showMessage, which does not escape, AND its
# errorMsg is separately handed to ComfyUI-core show_message(). So every
# non-literal argument must already be escaped at the call site.
escaped = {
"custom-nodes-manager.js": {
'`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);',
'`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);',
'`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);',
},
"node-usage-analyzer.js": {'sanitizeHTML(String(result.error)));'},
}
for name, lineno, _receiver, method, arg in self.sites:
if method != "showError":
continue
is_literal = re.fullmatch(
r'''(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\);''', arg,
) is not None
# These direct arguments are checked by the per-file guards below.
is_error_var = re.fullmatch(r"errorMsg\s*\);", arg) is not None
with self.subTest(file=name, line=lineno, arg=arg):
if is_literal or is_error_var:
continue
self.assertIn(
arg, escaped.get(name, set()),
"%s:%d passes an unescaped non-literal into showError, which does "
"not escape and whose text also reaches core show_message()"
% (name, lineno),
)
def test_every_error_message_builder_escapes_its_server_text(self):
common = {
'errorMsg = "";',
"errorMsg += `This action is not allowed with this security level configuration.\\n`;",
"errorMsg += sanitizeHTML(await res.text()) + '\\n';",
}
queue_error = "errorMsg += sanitizeHTML(String(v)) + '\\n';"
outdated = "errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\\n`;"
expected = {
"custom-nodes-manager.js": common | {
"errorMsg = `'${item.title}': `;",
"errorMsg = `Not found custom node: ${hash}`;",
queue_error, outdated,
},
"model-manager.js": common | {
"errorMsg = `'${item.name}': `;", queue_error, outdated,
},
"node-usage-analyzer.js": common | {
"errorMsg = `'${sanitizeHTML(String(item.name))}': `;", queue_error,
},
"common.js": common | {"errorMsg = `'${displayTitle}': `;"},
}
mutations = re.compile(r"(?<![\w$.])errorMsg\s*(?:[+*/%&|^?-]*=(?!=)|\+\+|--)[^;]*;")
for name, statements in expected.items():
source = (JS_DIR / name).read_text(encoding="utf-8")
actual = {re.sub(r"\s+", " ", match) for match in mutations.findall(source)}
with self.subTest(file=name):
self.assertEqual(actual, statements)
def test_error_guard_rejects_unclassified_arguments(self):
for argument in (
"err);", "errText);", "errorMsg$raw);", "err + extra);", "err.detail);",
'"prefix " + errText);', "'prefix ' + errText);",
"sanitizeHTML(err) + extra);",
):
with self.subTest(argument=argument):
guard = HtmlByDesignSinkTest("test_show_error_arguments_are_escaped_or_literal")
guard.sites = [("custom-nodes-manager.js", 1, "this", "showError", argument)]
result = unittest.TestResult()
guard.run(result)
self.assertEqual(len(result.failures), 1)
self.assertEqual(result.errors, [])
def test_html_guards_reject_unreviewed_source(self):
cases = [
("custom-nodes-manager.js", 'this.showSelection(list.join(""));',
'this.showSelection(selectedList[0].title);',
"test_show_selection_receives_no_channel_data"),
("node-usage-analyzer.js", "${installedSelected.length}</b>",
"${installedSelected[0].title}</b>",
"test_show_selection_receives_no_channel_data"),
("custom-nodes-manager.js", 'let errorMsg = "";',
'let errorMsg = "";\nerrorMsg += response.message;',
"test_every_error_message_builder_escapes_its_server_text"),
("model-manager.js", "errorMsg += sanitizeHTML(String(v)) + '\\n';",
"errorMsg += sanitizeHTML(String(v)) + response.message + '\\n';",
"test_every_error_message_builder_escapes_its_server_text"),
]
for name, before, after, test in cases:
with self.subTest(file=name, replacement=after), tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
for filename in SINK_FILES:
source = (JS_DIR / filename).read_text(encoding="utf-8")
if filename == name:
self.assertIn(before, source)
source = source.replace(before, after, 1)
(directory / filename).write_text(source, encoding="utf-8")
with patch(__name__ + ".JS_DIR", directory):
guard = HtmlByDesignSinkTest(test)
guard.sites = _call_sites()
result = unittest.TestResult()
guard.run(result)
self.assertTrue(result.failures)
self.assertEqual(result.errors, [])
def test_duplicate_calls_require_review(self):
for cls, test, argument in (
(MessageSinkRegistryTest, 'test_every_caller_is_classified', 'msg);'),
(HtmlByDesignSinkTest, 'test_show_selection_receives_no_channel_data', 'list.join(""));'),
):
with self.subTest(test=test):
guard = cls(test)
guard.sites = _call_sites()
guard.sites.append(next(site for site in guard.sites if site[-1] == argument))
result = unittest.TestResult()
guard.run(result)
self.assertTrue(result.failures)
self.assertEqual(result.errors, [])
if __name__ == "__main__":
unittest.main()
+412
View File
@@ -0,0 +1,412 @@
import html
import json
import os
import unittest
from pathlib import Path
from js_lift import NODE, JsSource, parse as _parse, run_node as _run_node, slice_braced
from manager_test_utils import load_manager_util, load_markdown_functions
REPO_ROOT = Path(__file__).resolve().parent.parent
JS_DIR = Path(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
JS = JsSource(JS_DIR)
MANAGER_UTIL = load_manager_util()
MODEL_MANAGER_SRC = (JS_DIR / "model-manager.js").read_text(encoding="utf-8")
COMMON_SRC = (JS_DIR / "common.js").read_text(encoding="utf-8")
def _lift_sanitize_html() -> str:
return JS.lift_declaration("common.js", "export function sanitizeHTML(")
def _lift_icons() -> str:
"""The real `icons` constant — the url formatter embeds icons.download."""
return JS.lift_declaration("common.js", "export const icons = ")
def _lift_url_helpers() -> str:
"""sanitizeUrl + safeHref, which live in common.js since the consolidation.
Returns '' on a pre-consolidation tree so the MANAGER_JS_DIR reproduce still
works against an older revision: there the helpers were local to
model-manager.js and _lift_model_manager_helpers picks them up instead.
"""
if "export function sanitizeUrl" not in COMMON_SRC:
return ""
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
return span.replace("export ", "")
def _lift_model_manager_helpers() -> str:
"""The escaping helpers that are still local to model-manager.js.
escapeCell and renderOptions stay in the client file — they are specific to
its grid and its filter dropdowns. On a pre-consolidation tree this block
also still contains sanitizeUrl/safeHref, which is why the slice starts at
whichever marker that revision actually has.
"""
if "const escapeCell" not in MODEL_MANAGER_SRC:
return ""
marker = ("const SAFE_URL_SCHEMES" if "const SAFE_URL_SCHEMES" in MODEL_MANAGER_SRC
else "const escapeCell")
start = MODEL_MANAGER_SRC.index(marker)
end = MODEL_MANAGER_SRC.index("const renderOptions")
end = MODEL_MANAGER_SRC.index('}).join("");', end) + len('}).join("");')
return MODEL_MANAGER_SRC[start:end]
def _slice_column(column_id: str) -> str:
"""Return one column-definition object out of the `}, {`-chained columns array.
These objects are literals inside an array, so the first `{` after the id
marker belongs to the NEXT column — brace matching cannot be used. The
object instead ends at the first `}` sitting at the array's own indent.
"""
start = MODEL_MANAGER_SRC.index("id: %s," % column_id)
end = MODEL_MANAGER_SRC.index("\n\t\t}", start)
return MODEL_MANAGER_SRC[start:end]
def _lift_formatter(anchor: str) -> str:
"""Lift the ``formatter: ...`` at ``anchor`` as a bare function expression."""
block = slice_braced(MODEL_MANAGER_SRC, anchor)
marker = "formatter:"
return block[block.index(marker) + len(marker):].strip()
NAME_FORMATTER_ANCHOR = "formatter: function(name, rowItem, columnItem, cellNode)"
URL_FORMATTER_ANCHOR = "formatter: (url, rowItem, columnItem)"
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
ATTR_BREAKOUT = '" onfocus=alert(1) x="'
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ModelManagerSinkEscapingTest(unittest.TestCase):
"""Every sink is exercised through the REAL lifted code, never a copy."""
@classmethod
def setUpClass(cls):
cls.preamble = "\n".join([
_lift_sanitize_html(),
_lift_icons(),
_lift_url_helpers(),
_lift_model_manager_helpers(),
])
def _eval(self, expressions: dict) -> dict:
body = ",".join("%s: %s" % (json.dumps(k), v) for k, v in expressions.items())
return _run_node(
"%s\n"
"const nameFormatter = %s;\n"
"const urlFormatter = %s;\n"
"console.log(JSON.stringify({%s}));\n"
% (self.preamble,
_lift_formatter(NAME_FORMATTER_ANCHOR),
_lift_formatter(URL_FORMATTER_ANCHOR),
body)
)
def _assert_inert(self, markup: str, allowed_tags):
"""No element outside the allow-list, and no event-handler attribute."""
parsed = _parse(markup)
self.assertEqual(
[t for t in parsed.tags if t not in allowed_tags], [],
"live element injected into: %r" % (markup,),
)
handlers = [name for name, _ in parsed.attrs if name.lower().startswith("on")]
self.assertEqual(handlers, [], "event-handler attribute injected into: %r" % (markup,))
# --- D-IV: name column, previously UNQUOTED href ------------------------
def test_d4_name_href_survives_breakout_attempts(self):
out = self._eval({
"space": "nameFormatter('Model', {reference: %s})" % json.dumps(BREAKOUT_HREF),
"quote": "nameFormatter('Model', {reference: %s})" % json.dumps(QUOTE_BREAKOUT),
})
for markup in out.values():
self._assert_inert(markup, allowed_tags={"a", "b"})
self.assertEqual(
sorted(name for name, _ in _parse(markup).attrs),
["href", "rel", "target"],
"unexpected attributes on the anchor: %r" % (markup,),
)
def test_d4_name_href_rejects_dangerous_schemes(self):
out = self._eval({
"js": "nameFormatter('Model', {reference: 'javascript:alert(1)'})",
"obfuscated": r"nameFormatter('Model', {reference: 'java\tscript:alert(1)'})",
"entity": "nameFormatter('Model', {reference: 'javascript&#58;alert(1)'})",
"data": "nameFormatter('Model', {reference: 'data:text/html,<script>alert(1)</script>'})",
"ok": "nameFormatter('Model', {reference: 'https://huggingface.co/repo'})",
})
for key in ("js", "obfuscated", "data"):
href = dict(_parse(out[key]).attrs)["href"]
self.assertEqual(href, "#", "%s: dangerous scheme survived as %r" % (key, href))
# The entity form is defeated by the attribute escape, not the scheme
# check: `&` becomes `&amp;`, so `&#58;` reaches the browser as text.
self.assertIn("&amp;#58;", out["entity"])
self.assertEqual(dict(_parse(out["ok"]).attrs)["href"], "https://huggingface.co/repo")
# --- D-V: url column, quoted but previously unescaped -------------------
def test_d5_url_href_escaped_inside_its_quotes(self):
out = self._eval({
"quote": "urlFormatter(%s)" % json.dumps(QUOTE_BREAKOUT),
"attr": "urlFormatter(%s)" % json.dumps(ATTR_BREAKOUT),
"js": "urlFormatter('javascript:alert(1)')",
})
self._assert_inert(out["quote"], allowed_tags={"a", "svg", "path"})
self._assert_inert(out["attr"], allowed_tags={"a", "svg", "path"})
self.assertEqual(dict(_parse(out["js"]).attrs)["href"], "#")
# --- D-III: the four previously formatter-less columns ------------------
def test_d3_columns_bind_the_escaping_formatter(self):
for column in ("'type'", "'base'", '"save_path"', "'filename'"):
block = _slice_column(column)
self.assertIn(
"formatter: escapeCell", block,
"column %s still renders RAW model-list.json into innerHTML" % column,
)
def test_d3_escape_cell_neutralises_payload_and_keeps_empty_cells(self):
out = _run_node(
"%s\nconsole.log(JSON.stringify({"
"payload: escapeCell(%s),"
"nul: escapeCell(null), undef: escapeCell(undefined),"
"num: escapeCell(42), plain: escapeCell('checkpoints')"
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD))
)
self._assert_inert(out["payload"], allowed_tags=set())
self.assertEqual(html.unescape(out["payload"]), TEXT_PAYLOAD,
"the payload must survive as visible text")
# null/undefined pass through so turbogrid keeps rendering an empty cell.
self.assertIsNone(out["nul"])
self.assertNotIn("undef", out)
self.assertEqual(out["num"], "42")
self.assertEqual(out["plain"], "checkpoints")
# --- D-VI: the showStatus message sink ----------------------------------
def test_d6_show_status_renders_item_name_inert(self):
"""item.name must reach .cmm-manager-status innerHTML inert.
This used to assert that the CALL SITE carried a sanitizeHTML. That was
wrong in a way the assertion could not see: ``item.name`` arrives from
``/externalmodel/getlist`` ALREADY escaped by ``populate_markdown`` ->
``sanitize_tag``, so escaping it again rendered ``&lt;Flux&gt;`` to the
user. The call-site escape was removed for that reason, and the sink
deliberately does not escape either -- see
tests/test_message_sink_provenance.py for why a blanket sink escape
cannot be correct across these callers.
The GUARANTEE is unchanged, and is now asserted end-to-end on the value
the caller ACTUALLY receives: take the server's own output for a hostile
name, push it through the REAL lifted sink, and read innerHTML. That is
strictly stronger than the old form, which passed whether or not the
rendered result was inert.
"""
anchor = "this.showStatus(`Install "
line = MODEL_MANAGER_SRC[MODEL_MANAGER_SRC.index(anchor):]
line = line[:line.index("\n")]
self.assertNotIn(
"sanitizeHTML", line,
"item.name is already server-escaped; escaping here is the mojibake "
"bug this call site used to have: %r" % (line,),
)
# What populate_markdown -> sanitize_tag puts on the wire for the payload.
server_value = TEXT_PAYLOAD.replace("<", "&lt;").replace(">", "&gt;")
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\tshowStatus(msg, color)")
rendered = _run_node(
"%s\nconst sink = %s;\n"
"const item = {name: %s};\n"
"const el = { innerHTML: '' };\n"
"const ctx = { element: { querySelector: () => el } };\n"
"sink.call(ctx, `Install ${item.name} ...`);\n"
"console.log(JSON.stringify({msg: el.innerHTML}));\n"
% (_lift_sanitize_html(), lifted, json.dumps(server_value))
)
self._assert_inert(rendered["msg"], allowed_tags=set())
# ...and the user reads the payload as text, not as entity soup.
self.assertEqual(
html.unescape(rendered["msg"]), "Install %s ..." % TEXT_PAYLOAD
)
# --- filter dropdowns: same raw type/base values, different route -------
def test_filter_dropdown_options_escape_label_and_value(self):
out = _run_node(
"%s\nconsole.log(JSON.stringify({"
"label: renderOptions([{label: %s, value: 'x'}], ''),"
"value: renderOptions([{label: 'x', value: %s}], ''),"
"plain: renderOptions([{label: 'checkpoints', value: 'checkpoints'}], 'checkpoints')"
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD), json.dumps(ATTR_BREAKOUT))
)
self._assert_inert(out["label"], allowed_tags={"option"})
self._assert_inert(out["value"], allowed_tags={"option"})
# Selection still matches on the RAW value, and benign data is untouched.
self.assertIn(" selected", out["plain"])
self.assertIn(">checkpoints</option>", out["plain"])
def test_update_filter_routes_all_three_dropdowns_through_render_options(self):
block = slice_braced(MODEL_MANAGER_SRC, "updateFilter()")
for dropdown in ("$filter", "$type", "$base"):
self.assertIn(
"%s.innerHTML = renderOptions(" % dropdown, block,
"%s still builds <option> markup inline" % dropdown,
)
# --- description: server-transformed HTML, must NOT be double-escaped ---
def test_description_column_is_not_re_escaped(self):
block = _slice_column("'description'")
self.assertNotIn(
"formatter", block,
"description is server-transformed HTML (populate_markdown); re-escaping "
"it renders visible mojibake such as 'Nodes for &lt;Flux&gt; workflows'",
)
def test_description_server_value_still_reads_cleanly(self):
# What populate_markdown -> sanitize_tag actually emits for "<Flux>".
server_value = "Nodes for &lt;Flux&gt; workflows"
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
# Run through the client escaper as well, the user would see the entity
# text instead of the angle brackets — that is what this guard forbids.
double = _run_node(
"%s\nconsole.log(JSON.stringify({v: sanitizeHTML(%s)}));\n"
% (_lift_sanitize_html(), json.dumps(server_value))
)["v"]
self.assertEqual(html.unescape(double), server_value)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ModelManagerMessageSinkTest(unittest.TestCase):
"""model-manager's OWN message sinks — it does not use createUIStateManager.
This class replaces an earlier ``test_turbogrid_bundle_untouched`` guard,
which asserted that ``js/turbogrid.esm.js`` was unchanged relative to a
fixed base commit. That was an INTEGRATION ARTIFACT, not a property of this
file: it encoded "only model-manager was touched by the work item that
added these tests". In the integrated tree the vendored grid IS changed on
purpose, so the assertion reported a cross-file fact that says nothing
about whether model-manager's own sinks are safe. What this file can
actually guarantee is asserted instead — and it is strictly more coverage,
because the sinks below were not covered by anything before.
"""
@classmethod
def setUpClass(cls):
cls.sanitize = _lift_sanitize_html()
def _call_sink(self, method: str, msg, color=None) -> str:
"""Run one lifted sink against a stub element; return the real innerHTML."""
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\t%s(msg, color)" % method)
args = json.dumps(msg) + ("" if color is None else ", " + json.dumps(color))
return _run_node(
"%s\n"
"const sink = %s;\n"
"const el = { innerHTML: '' };\n"
"const ctx = { element: { querySelector: () => el } };\n"
"sink.call(ctx, %s);\n"
"console.log(JSON.stringify({v: el.innerHTML}));\n"
% (self.sanitize, lifted, args)
)["v"]
# --- the sinks take DISPLAY-READY text and do NOT escape ---------------
def test_the_sinks_deliberately_do_not_escape(self):
# Structural, and load-bearing: a well-meaning sanitizeHTML added here
# would double-escape the server-escaped values these sinks actually
# receive. tests/test_message_sink_provenance.py is what keeps that safe
# by classifying every caller.
for method in ("showStatus(msg, color)", "showMessage(msg, color)"):
body = slice_braced(MODEL_MANAGER_SRC, "\t" + method)
self.assertNotIn(
"sanitizeHTML", body,
"%s escapes at the sink; its callers pass ALREADY-escaped server "
"values, so this renders '&lt;Flux&gt;' to the user" % method,
)
self.assertIn("innerHTML", body)
def test_a_server_escaped_value_renders_inert_through_the_sink(self):
server_value = "Install %s ..." % TEXT_PAYLOAD.replace(
"<", "&lt;").replace(">", "&gt;")
rendered = self._call_sink("showStatus", server_value)
self.assertNotIn("img", _parse(rendered).tags)
def test_a_benign_angle_bracket_name_is_not_double_escaped(self):
# THE regression this arrangement exists to prevent. The server sends
# 'Model for &lt;Flux&gt; workflows'; the user must read the angle
# brackets, not the entities.
server_value = "Install Model for &lt;Flux&gt; workflows ..."
rendered = self._call_sink("showStatus", server_value)
self.assertEqual(
html.unescape(rendered), "Install Model for <Flux> workflows ..."
)
self.assertNotIn("&amp;lt;", rendered)
def test_the_font_wrapper_still_renders(self):
rendered = self._call_sink("showMessage", "plain text", "red")
collector = _parse(rendered)
self.assertIn("font", collector.tags)
self.assertIn(("color", "red"), collector.attrs)
# --- showError: HTML BY CONTRACT, escaped by its callers ---------------
def test_show_error_forwards_to_the_non_escaping_show_message(self):
# showError is HTML by contract: its errorMsg is built escaped because
# the SAME string also goes to ComfyUI-core show_message(). Forwarding
# to a NON-escaping showMessage keeps that at exactly one escape.
body = slice_braced(MODEL_MANAGER_SRC, "\tshowError(err)")
self.assertIn("this.showMessage(err, \"red\")", body)
# --- the caller side ---------------------------------------------------
def test_the_install_status_caller_passes_the_server_value_untouched(self):
self.assertIn("this.showStatus(`Install ${item.name} ...`)", MODEL_MANAGER_SRC)
def test_the_install_error_message_escapes_only_the_raw_response(self):
# The name is already escaped by the server; the error response is raw.
self.assertIn(
"errorMsg = `'${item.name}': `", MODEL_MANAGER_SRC
)
self.assertIn("errorMsg += sanitizeHTML(await res.text())", MODEL_MANAGER_SRC)
def test_install_failure_preserves_server_escaped_names_in_both_sinks(self):
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
methods = ",\n".join(slice_braced(MODEL_MANAGER_SRC, marker) for marker in [
"async installModels(list, btn)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)"
])
response_text = '<img src=x onerror="window.__probe=1"> download failed'
for name in ["Model <Flux>", '<img src=x onerror="window.__probe=1">']:
for status in [403, 500]:
with self.subTest(name=name, status=status):
item = {"name": name, "originalData": {}, "hash": "model"}
populate(item)
rendered = _run_node("""
%s
const messages = {};
const elements = {};
const api = {fetchApi: async (url) => url.endsWith('/status')
? {json: async () => ({is_processing: false})}
: {status: %d, json: async () => ({}), text: async () => %s}};
const show_message = (message) => messages.dialog = message;
const ctx = {
%s,
element: {querySelector: (selector) => elements[selector] ||= {innerHTML: ''}},
grid: {scrollRowIntoView() {}, updateCell() {}},
focusInstall() {return true;}
};
await ctx.installModels([%s], {classList: {add() {}}});
messages.panel = elements['.cmm-manager-message'].innerHTML;
console.log(JSON.stringify(messages));
""" % (self.sanitize, status, json.dumps(response_text), methods, json.dumps(item)))
self.assertEqual(set(rendered), {"panel", "dialog"})
for message in rendered.values():
self.assertIn("'%s': " % name, html.unescape(message))
self.assertNotIn("img", _parse(message).tags)
if status == 500:
self.assertIn(response_text, html.unescape(message))
def test_the_queue_result_error_text_is_escaped_by_the_caller(self):
self.assertIn("errorMsg += sanitizeHTML(String(v))", MODEL_MANAGER_SRC)
if __name__ == "__main__":
unittest.main()
+286
View File
@@ -0,0 +1,286 @@
"""RED->GREEN guard for the js/node-usage-analyzer.js innerHTML sinks.
The analyzer writes pack data and workflow data into innerHTML through turbogrid
cells, a flyover, and the status/message elements. Before the fix: the title
column built an UNQUOTED href (a bare space breaks out of the attribute); the
install-progress showStatus interpolated a raw pack key; the usage-details
flyover interpolated a raw workflow filename; and the install error path
interpolated both a raw pack key and raw server text.
One value needed fixing at its SOURCE rather than at the sink. The title column
is built as `pack.title || packKey`: /customnode/getlist runs populate_markdown
over each pack, so `pack.title` is server-escaped, but `packKey` is a dict key
that no server transform touches. Escaping at the formatter would have
double-escaped the common path into visible entity text ("Nodes for &lt;Flux&gt;
workflows"); leaving it bare would have shipped the raw fallback. The fix
normalises the fallback so the column carries ONE provenance, which is what
makes the bare formatter correct by construction.
This grid uses cellResizeObserver only — no rowFilter, no
highlightKeywordsFilter — so the keyword-highlight re-render does not reach it.
The two searchable managers use js/manager-grid.js to secure that path without
modifying the vendored TurboGrid bundle.
The status/message sinks take DISPLAY-READY text and do NOT escape, because
their callers carry mixed provenance; every caller is classified in
tests/test_message_sink_provenance.py. This analyzer is the only file that
reaches BOTH sink implementations — the shared one from common.js via `this.ui`
and its own class methods.
REPRODUCE THE RED HALF against the pre-fix revision — one command:
mkdir -p /some/dir
git show <base>:js/node-usage-analyzer.js > /some/dir/node-usage-analyzer.js
git show <base>:js/common.js > /some/dir/common.js
MANAGER_JS_DIR=/some/dir pytest tests/test_node_usage_escaping.py
"""
import html
import json
import os
import unittest
from pathlib import Path
from js_lift import (
NODE,
JsSource,
event_handlers,
parse,
run_node,
slice_braced,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
ANALYZER = "node-usage-analyzer.js"
COMMON = "common.js"
TITLE_FORMATTER_ANCHOR = "formatter: function (name, rowItem, columnItem, cellNode)"
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
# An ordinary pack reference, for the legs that ask what a CORRECT link
# looks like rather than what a hostile one cannot do.
BENIGN_REFERENCE = "https://github.com/owner/repo"
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
def _url_helper_home() -> str:
"""Which file currently owns sanitizeUrl/safeHref, or '' on a pre-fix tree.
They were local to the analyzer when this guard was written and now live in
common.js, imported by every client file. Resolving the home rather than
hard-coding it keeps the MANAGER_JS_DIR reproduce working against both
revisions, and makes a THIRD home fail loudly instead of being lifted twice.
"""
for filename in (COMMON, ANALYZER):
if "SAFE_URL_SCHEMES" in JS.text(filename):
return filename
return ""
def _preamble() -> str:
parts = [JS.lift_declaration(COMMON, "export function sanitizeHTML(")]
home = _url_helper_home()
if home:
span = JS.lift_span(
home, "SAFE_URL_SCHEMES", "safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
parts.append("const " + span.replace("export ", ""))
return "\n".join(parts)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class NodeUsageSinkEscapingTest(unittest.TestCase):
"""Every sink is exercised through the REAL lifted code, never a copy."""
def _assert_inert(self, markup: str, allowed_tags):
"""No element outside the allow-list, and no event-handler attribute."""
parsed = parse(markup)
self.assertEqual(
[t for t in parsed.tags if t not in allowed_tags], [],
"live element injected into: %r" % (markup,),
)
self.assertEqual(
event_handlers(markup), [],
"event-handler attribute injected into: %r" % (markup,),
)
# --- C2: title column, previously UNQUOTED href -------------------------
def _render_title(self, reference: str, name: str = "My Pack") -> str:
return run_node(
"%s\nconst titleFormatter = %s;\n"
"console.log(JSON.stringify({markup: titleFormatter(%s, {reference: %s})}));\n"
% (_preamble(),
JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR),
json.dumps(name), json.dumps(reference))
)["markup"]
def test_c2_href_survives_breakout_attempts(self):
for reference in (BREAKOUT_HREF, QUOTE_BREAKOUT):
markup = self._render_title(reference)
self._assert_inert(markup, allowed_tags={"a", "b"})
self.assertEqual(
sorted(name for name, _ in parse(markup).attrs),
["href", "rel", "target"],
"unexpected attributes on the anchor: %r" % (markup,),
)
def test_c2_title_anchor_severs_the_opener(self):
"""The pack chooses this host, so the page it opens must not keep us.
`reference` is channel data, so the pack author picks the page that
opens; without rel that page holds a live window.opener back into the
Manager UI. Membership on the SPLIT attribute, not a literal match:
'noreferrer noopener' is the same thing, and pinning token order would
report a correct fix as a defect.
"""
markup = self._render_title(BENIGN_REFERENCE)
attrs = dict(parse(markup).attrs)
self.assertEqual(
attrs.get("target"), "_blank",
"this guard is about _blank anchors; the target changed, so "
"re-derive it rather than deleting the assertion: %r" % (markup,))
self.assertIn(
"rel", attrs,
"the title anchor opens a pack-chosen page in a new tab without "
"rel, so that page keeps a reference to this window: %r" % (markup,))
tokens = attrs["rel"].lower().split()
self.assertIn("noopener", tokens, markup)
self.assertIn("noreferrer", tokens, markup)
def test_c2_href_rejects_dangerous_schemes(self):
for reference in ("javascript:alert(1)", "java\tscript:alert(1)",
"data:text/html,<script>alert(1)</script>"):
href = dict(parse(self._render_title(reference)).attrs)["href"]
self.assertEqual(href, "#", "%r survived as %r" % (reference, href))
def test_c2_benign_reference_and_title_are_untouched(self):
markup = self._render_title(BENIGN_REFERENCE, name="My Pack")
self.assertEqual(dict(parse(markup).attrs)["href"], BENIGN_REFERENCE)
self.assertIn("<b>My Pack</b>", markup)
def test_c2_title_is_not_double_escaped_at_the_formatter(self):
# The column value is already escaped HTML text by the time it arrives,
# so re-escaping here would put entity text on screen.
block = JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR)
self.assertNotIn(
"sanitizeHTML(String(name))", block,
"title is normalised at its source; escaping it again renders '&lt;' to the user",
)
server_value = "Nodes for &lt;Flux&gt; workflows"
markup = self._render_title("https://example.com/x", name=server_value)
self.assertIn(server_value, markup)
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
def _run_panel(self, action, packs=None, status=500):
"""Run the production data, operation and rendering methods with stubbed I/O."""
methods = ",\n".join(slice_braced(JS.text(ANALYZER), "\n\t" + marker) for marker in [
"async loadData()", "async installModels(list, btn)", "async uninstallModels(list, btn)",
"showUsageDetails(rowItem)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
])
return run_node("""
%s
%s
%s
const elements = {};
const messages = {};
const posts = [];
const manager_instance = {datasrc_combo: {value: 'default'}};
const fetchData = async () => ({data: {channel: 'default', node_packs: %s}});
const analyzeWorkflowUsage = async () => ({success: true});
const api = {fetchApi: async (url, options) => {
if (options?.body) posts.push(JSON.parse(options.body));
return {status: %d, json: async () => ({is_processing: false}), text: async () => %s};
}};
const customConfirm = async () => true;
const md5 = () => 'hash';
const show_message = message => messages.dialog = message;
const btn = {classList: {add() {}, remove() {}}};
const ctx = {
%s,
element: {querySelector: selector => elements[selector] ||= {innerHTML: '', style: {}}},
grid: {scrollRowIntoView() {}, updateCell() {}, updateRow() {}},
flyover: {show: (title, body) => Object.assign(messages, {title, body})},
showLoading() {}, hideLoading() {}, renderGrid() {}, getModelList(rows) {return rows;}
};
ctx.ui = createUIStateManager(ctx.element, {
status: '.nu-manager-status', message: '.nu-manager-message'
});
%s
console.log(JSON.stringify({elements, messages, posts, rows: ctx.modelList}));
""" % (_preamble(),
JS.lift_declaration(COMMON, "export function createUIStateManager(element, selectors)"),
JS.lift_declaration(COMMON, "export async function uninstallNodes(nodeList, options = {})"),
json.dumps(packs or {}), status, json.dumps(TEXT_PAYLOAD), methods, action))
def test_title_fallback_is_escaped_at_its_source(self):
out = self._run_panel("await ctx.loadData();", {TEXT_PAYLOAD: {"state": "enabled"}})
row = out["rows"][0]
self._assert_inert(row["title"], allowed_tags=set())
self.assertEqual(html.unescape(row["title"]), TEXT_PAYLOAD)
self.assertEqual(row["name"], TEXT_PAYLOAD)
def test_install_progress_and_errors_escape_raw_values(self):
for status in (403, 500):
with self.subTest(status=status):
out = self._run_panel("await ctx.loadData(); await ctx.installModels(ctx.modelList, btn);",
{TEXT_PAYLOAD: {"state": "enabled"}}, status)
progress = out["elements"][".nu-manager-status"]["innerHTML"]
self._assert_inert(progress, allowed_tags=set())
self.assertEqual(html.unescape(progress), f"Install {TEXT_PAYLOAD} ...")
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
for error in errors:
self._assert_inert(error, allowed_tags={"font"})
self.assertIn(f"'{TEXT_PAYLOAD}': ", html.unescape(error))
if status == 500:
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
def test_flyover_escapes_workflow_filename(self):
for filename in (TEXT_PAYLOAD, "workflow <Flux> & friends.json"):
with self.subTest(filename=filename):
out = self._run_panel("ctx.showUsageDetails(%s);" % json.dumps({
"title": "My Pack", "workflowDetails": [{"filename": filename, "nodeCount": 2}],
}))
body = out["messages"]["body"]
self._assert_inert(body, allowed_tags={"div"})
self.assertIn(filename, html.unescape(body))
self.assertIn("2 nodes", body)
def test_uninstall_failure_keeps_normalized_titles_readable(self):
for name in ("Pack <Flux> & friends", TEXT_PAYLOAD):
for has_title in (False, True):
for status in (403, 500):
with self.subTest(name=name, has_title=has_title, status=status):
pack = {"state": "enabled"}
if has_title:
pack["title"] = html.escape(name)
out = self._run_panel("await ctx.loadData(); await ctx.uninstallModels(ctx.modelList, btn);",
{name: pack}, status)
progress = out["elements"][".nu-manager-status"]["innerHTML"]
self.assertEqual(html.unescape(progress), f"Uninstall {name} ...")
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
for error in errors:
self._assert_inert(error, allowed_tags={"font"})
self.assertIn(f"'{name}': ", html.unescape(error))
if status == 500:
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
def test_uninstall_helper_escapes_a_raw_name_fallback(self):
out = self._run_panel("""
await uninstallNodes([{name: %s}], {
onProgress: msg => messages.progress = msg,
onError: msg => messages.error = msg
});
""" % json.dumps(TEXT_PAYLOAD))
for message in out["messages"].values():
self._assert_inert(message, allowed_tags=set())
self.assertIn(TEXT_PAYLOAD, html.unescape(message))
self.assertEqual(out["posts"][0]["name"], TEXT_PAYLOAD)
def test_grid_has_no_keyword_highlight_path(self):
# The premise the row states: this grid cannot hit the highlight defect.
source = JS.text(ANALYZER)
self.assertNotIn("highlightKeywordsFilter", source)
self.assertNotIn("rowFilter:", source)
if __name__ == "__main__":
unittest.main()
+83
View File
@@ -0,0 +1,83 @@
"""Exercise notice dependency installation without starting ComfyUI or pip."""
import ast
import builtins
import importlib.metadata
import itertools
import os
import re
import subprocess
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import Mock, mock_open, patch
import toml
from packaging.requirements import Requirement
from manager_test_utils import load_manager_util
ROOT = Path(__file__).resolve().parent.parent
class NoticeDependencyTest(unittest.TestCase):
def test_util_can_load_before_nh3_is_installed(self):
with patch.dict(sys.modules, {'nh3': None}):
util = load_manager_util()
self.assertEqual(util.escape_html_attribute('<text>'), '&lt;text&gt;')
with self.assertRaises(ModuleNotFoundError):
util.sanitize_html_fragment('<p>notice</p>')
def test_bootstrap_installs_missing_or_outdated_nh3_before_importing_it(self):
manifest = (ROOT / 'requirements.txt').read_text()
commented_manifest = '\n# Dependencies\n--index-url https://pypi.org/simple\n' + manifest.replace(
'nh3>=0.3.7', ' nh3>=0.3.7 # notice sanitizer',
)
for installed_version, requirements in itertools.product(
(None, '0.3.0', '0.3.7rc1', '0.3.7', '0.4.0'), (manifest, commented_manifest),
):
with self.subTest(installed_version=installed_version, commented=requirements == commented_manifest):
source = (ROOT / 'prestartup_script.py').read_text()
node = next(n for n in ast.parse(source).body
if isinstance(n, ast.FunctionDef) and n.name == 'ensure_dependencies')
installer = Mock()
namespace = {
'__file__': str(ROOT / 'prestartup_script.py'),
'os': os,
're': re,
'subprocess': types.SimpleNamespace(
check_output=installer, CalledProcessError=subprocess.CalledProcessError,
),
'manager_util': types.SimpleNamespace(make_pip_cmd=lambda args: args),
}
exec(compile(ast.Module(body=[node], type_ignores=[]), '<bootstrap>', 'exec'), namespace)
needs_install = installed_version in (None, '0.3.0', '0.3.7rc1')
modules = {name: types.ModuleType(name) for name in ('git', 'toml', 'rich', 'chardet')}
# An old native module must not be imported before pip upgrades it.
modules['nh3'] = None if needs_install else types.ModuleType('nh3')
failure = importlib.metadata.PackageNotFoundError('nh3') if installed_version is None else None
with patch.dict(sys.modules, modules), patch(
'importlib.metadata.version', return_value=installed_version, side_effect=failure,
), patch('builtins.open', mock_open(read_data=requirements)), patch('builtins.print'), patch(
'builtins.__import__', wraps=builtins.__import__,
) as imports:
namespace['ensure_dependencies']()
nh3_imports = [call for call in imports.call_args_list if call.args[0] == 'nh3']
self.assertEqual(len(nh3_imports), 0 if needs_install else 1)
if needs_install:
installer.assert_called_once_with(['install', '-r', str(ROOT / 'requirements.txt')])
else:
installer.assert_not_called()
def test_dependency_manifests_agree_on_notice_requirements(self):
lines = (re.split(r'\s+#', line.strip(), maxsplit=1)[0]
for line in (ROOT / 'requirements.txt').read_text().splitlines())
pip_requirements = {
req.name: req for req in map(Requirement, (line for line in lines if line and not line.startswith(('#', '-'))))
}
project_requirements = {
req.name: req for req in map(Requirement, toml.load(ROOT / 'pyproject.toml')['project']['dependencies'])
}
for name in ('nh3', 'packaging'):
with self.subTest(name=name):
self.assertEqual(str(pip_requirements[name]), str(project_requirements[name]))
+372
View File
@@ -0,0 +1,372 @@
"""RED->GREEN guard for the pack-grid column sinks in js/custom-nodes-manager.js.
WHY THESE THREE VALUES
----------------------
The Custom Nodes grid renders every cell through turbogrid, which hands a column
formatter the RAW row value and assigns the formatter's return through
``innerHTML``. Whatever a formatter returns unescaped is therefore live markup on
FIRST RENDER, before any click, for every row the list contains.
Three values reached that sink unescaped, and all three carry the same provenance
as ``author`` (which the neighbouring formatter in the same column array already
escapes):
``stars`` and ``last_update`` are written by ``populate_github_stats`` in
glob/manager_core.py straight off github-stats.json, with no transform.
``populate_markdown`` -- the only server-side escape on this payload -- covers
``description``, ``name`` and ``title`` and NOTHING else, and
``populate_github_stats`` swallows a broken row with a bare ``except:``, so a
channel-supplied ``stars`` / ``last_update`` survives untouched when
``v['reference']`` raises. That double fact is pinned in
``PopulateProvenanceTest`` below, because it is what makes escaping HERE
correct rather than a double-escape.
``channel`` is the resolved channel alias from /customnode/getlist -- a key out
of the channel-list config file, passed through untransformed into the
"Channel: ..." banner's ``innerHTML``.
WHY THE OLD GUARDS DID NOT STOP THEM
------------------------------------
Neither ``stars`` guard was a guard. ``stars < 0`` is a NaN comparison for a
string ('<img ...>' < 0 is false), and ``typeof stars === 'number'`` only chooses
the toLocaleString branch -- so a string payload fell through to the bare
``return stars;``. ``last_update`` looked defended by ``.split(' ')[0]``, but a
space is not the only attribute separator a browser accepts: ``/`` works, and so
do tab and newline, so ``<img/src=x/onerror=alert(1)>`` passes through the split
whole. Escaping is what closes both; a smarter split would not.
REPRODUCE THE RED HALF against the pre-fix revision -- one command:
mkdir -p /some/dir
git show <base>:js/custom-nodes-manager.js > /some/dir/custom-nodes-manager.js
git show <base>:js/common.js > /some/dir/common.js
MANAGER_JS_DIR=/some/dir pytest tests/test_pack_column_escaping.py
"""
import html
import json
import os
import unittest
from pathlib import Path
from js_lift import (
NODE,
JsSource,
event_handlers,
line_containing,
parse,
slice_object_entry,
slice_braced,
)
from js_lift import run_node
from manager_test_utils import load_manager_util, load_markdown_functions
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (REPO_ROOT / "glob"))
MANAGER = "custom-nodes-manager.js"
COMMON = "common.js"
STARS_ANCHOR = "id: 'stars',"
LAST_UPDATE_ANCHOR = "id: 'last_update',"
CHANNEL_ANCHOR = '.cn-manager-channel").innerHTML'
# A space-separated payload for the columns that never split, and a
# SLASH-separated one for last_update, whose `.split(' ')[0]` used to read as a
# defence. Neither needs `<` to be dangerous in an attribute, but both are
# element injections here because the sink is element text.
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
NO_SPACE_PAYLOAD = "<img/src=x/onerror=alert(1)>"
TAB_PAYLOAD = "<img\tsrc=x\tonerror=alert(1)>"
def _lift_get_time_ago() -> str:
"""getTimeAgo, lifted by SPAN rather than by brace matching.
js_lift's brace scanner reads a lone ``/`` as a regex literal -- correct for
every range it was written for -- and getTimeAgo DIVIDES
(``Math.abs(diff) / divisor``), so ``slice_braced`` raises on it by design
rather than handing back a wrong slice. The span is bounded by the
function's own last statement instead, so a change to that statement fails
loudly here rather than lifting a truncated body.
"""
span = JS.lift_span(COMMON, "export function getTimeAgo(", "'in a year', diff < 0);")
return span.replace("export ", "", 1) + "\n}"
def _preamble() -> str:
"""sanitizeHTML and getTimeAgo, lifted from the shipped common.js."""
return "\n".join([
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
_lift_get_time_ago(),
])
def _render(formatter_source: str, value) -> str:
return run_node(
"%s\nconst formatter = %s;\n"
"console.log(JSON.stringify({markup: String(formatter(%s))}));\n"
% (_preamble(), formatter_source, json.dumps(value))
)["markup"]
class _InertAssertions(unittest.TestCase):
def assert_inert(self, markup: str, allowed_tags):
parsed = parse(markup)
self.assertEqual(
[t for t in parsed.tags if t not in allowed_tags], [],
"live element injected into: %r" % (markup,),
)
self.assertEqual(
event_handlers(markup), [],
"event-handler attribute injected into: %r" % (markup,),
)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class StarsColumnTest(_InertAssertions):
"""The ★ column formatter, lifted and executed as shipped."""
@classmethod
def setUpClass(cls):
cls.formatter = JS.lift_formatter(MANAGER, STARS_ANCHOR)
def test_a_string_payload_renders_inert(self):
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
with self.subTest(payload=payload):
markup = self._markup(payload)
self.assert_inert(markup, allowed_tags=set())
self.assertEqual(
html.unescape(markup), payload,
"the payload must survive as READABLE TEXT, not be dropped",
)
def _markup(self, value):
return _render(self.formatter, value)
def test_a_number_still_renders_through_toLocaleString(self):
"""The behaviour the fix must not disturb: a real star count is grouped.
Compared against node's OWN toLocaleString rather than a hard-coded
"1,234", so the assertion does not encode the runtime's default locale.
"""
expected = run_node(
"console.log(JSON.stringify({v: (1234567).toLocaleString()}));"
)["v"]
markup = self._markup(1234567)
self.assertEqual(markup, expected)
self.assertNotIn("&", markup, "a number must not come back entity-escaped")
def test_a_negative_count_is_still_na(self):
# -1 is populate_github_stats' "no stats for this pack" sentinel.
self.assertEqual(self._markup(-1), "N/A")
def test_zero_is_not_treated_as_missing(self):
self.assertEqual(self._markup(0), "0")
def test_a_missing_value_still_renders_an_empty_cell(self):
"""null / undefined pass through, as at every other escaped column.
turbogrid's renderNodeContent does `void 0===e&&(e="")` and then assigns
`innerHTML`, which is [LegacyNullToEmptyString] -- so a formatter that
passes null/undefined THROUGH yields an empty cell, while one that
stringifies them first puts the literal words "undefined" / "null" on
screen. That is the shape spelled out at the id formatter in this same
file and at model-manager's escapeCell, both of which say so in a comment.
It is reachable on exactly the malformed-row path this file is about:
populate_github_stats swallows a broken entry with a bare `except:`, so
`stars` is simply ABSENT for a pack whose `reference` lookup raises.
Asserted through renderNodeContent's own semantics rather than on the
formatter's return alone, because "returns undefined" and "renders an
empty cell" are different claims and it is the second one the user sees.
"""
out = run_node(
"%s\nconst formatter = %s;\n"
"const cell = (v) => { let e = formatter(v); if (void 0 === e) e = '';\n"
" return e === null ? '' : String(e); };\n"
"console.log(JSON.stringify({\n"
" undefined_passthrough: formatter(undefined) === undefined,\n"
" null_passthrough: formatter(null) === null,\n"
" undefined_cell: cell(undefined), null_cell: cell(null)}));\n"
% (_preamble(), self.formatter)
)
self.assertTrue(
out["undefined_passthrough"],
"a missing star count is stringified instead of passed through, so "
"the cell reads 'undefined' where it used to be empty",
)
self.assertTrue(out["null_passthrough"], "null is stringified, not passed through")
self.assertEqual(out["undefined_cell"], "")
self.assertEqual(out["null_cell"], "")
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class LastUpdateColumnTest(_InertAssertions):
"""The Last Update column formatter, lifted and executed as shipped."""
@classmethod
def setUpClass(cls):
cls.formatter = JS.lift_formatter(MANAGER, LAST_UPDATE_ANCHOR)
def _markup(self, value):
return _render(self.formatter, value)
def test_a_slash_separated_payload_renders_inert(self):
"""The case `.split(' ')[0]` cannot touch: no space anywhere in it."""
markup = self._markup(NO_SPACE_PAYLOAD)
self.assert_inert(markup, allowed_tags={"span"})
def test_separator_variants_render_inert(self):
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
with self.subTest(payload=payload):
self.assert_inert(self._markup(payload), allowed_tags={"span"})
def test_the_title_attribute_cannot_be_broken_out_of(self):
# `ago` is not escaped, deliberately: getTimeAgo returns "" or a phrase
# off a fixed table and never echoes its input. This pins that premise
# behaviourally rather than trusting it.
markup = self._markup('x" onmouseover=alert(1) y')
self.assertEqual(event_handlers(markup), [], markup)
self.assertEqual(
sorted(name for name, _ in parse(markup).attrs), ["title"], markup,
)
def test_a_normal_date_still_renders_its_short_form(self):
markup = self._markup("2024-01-02 03:04:05")
self.assertIn(">2024-01-02<", markup)
self.assertEqual([t for t in parse(markup).tags], ["span"])
def test_a_negative_timestamp_is_still_na(self):
self.assertEqual(self._markup(-1), "N/A")
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ChannelBannerTest(_InertAssertions):
"""The "Channel: ..." banner, rendered through the shipped source line."""
@classmethod
def setUpClass(cls):
cls.line = line_containing(JS.text(MANAGER), CHANNEL_ANCHOR)
def _markup(self, channel):
expression = self.line.split("innerHTML =", 1)[1].strip().rstrip(";")
return run_node(
"%s\nconst self = {channel: %s};\n"
"console.log(JSON.stringify({markup: %s}));\n"
% (_preamble(), json.dumps(channel),
expression.replace("this.channel", "self.channel"))
)["markup"]
def test_a_payload_in_the_channel_alias_renders_inert(self):
markup = self._markup(TEXT_PAYLOAD)
self.assert_inert(markup, allowed_tags=set())
self.assertIn("Channel: ", markup)
def test_a_benign_alias_is_still_readable(self):
# No server transform touches this value, so escaping it once is
# correct and cannot show entity text for an ordinary alias.
self.assertIn("Channel: dev (Incomplete list)", self._markup("dev"))
class ColumnShapeTest(unittest.TestCase):
"""The formatters must keep EXISTING, or the grid's default takes over.
turbogrid's default formatter is a pass-through, so deleting either
formatter silently restores the raw-value-into-innerHTML path these tests
exist to close -- with every behavioural test above still passing, because
they lift the formatter and would simply stop being able to find one.
"""
def test_both_columns_declare_a_formatter(self):
source = JS.text(MANAGER)
for anchor in (STARS_ANCHOR, LAST_UPDATE_ANCHOR):
with self.subTest(column=anchor):
entry = slice_object_entry(source, anchor)
self.assertIn(
"formatter:", entry,
"%s lost its formatter; the grid default renders the raw "
"value into innerHTML" % (anchor,),
)
self.assertIn("sanitizeHTML(", entry)
class PopulateProvenanceTest(unittest.TestCase):
"""WHY escaping here is single-escaping, not double-escaping.
If the server ever started escaping these two fields, the formatters would
render entity text to the user and this file would be the thing to change.
The premise is therefore asserted rather than assumed.
"""
def test_populate_markdown_does_not_cover_stars_or_last_update(self):
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
body = source[source.index("def populate_markdown("):]
body = body[:body.index("\ndef ", 1)] if "\ndef " in body[1:] else body
for field in ("'description'", "'name'", "'title'"):
self.assertIn("if %s in x:" % field, body)
for field in ("stars", "last_update"):
self.assertNotIn(
"'%s'" % field, body,
"populate_markdown now touches %s -- if it escapes it, the "
"formatter must STOP escaping or the user reads entity text"
% (field,),
)
def test_the_stats_fields_are_written_untransformed(self):
source = (GLOB_DIR / "manager_core.py").read_text(encoding="utf-8")
body = source[source.index("def populate_github_stats("):]
body = body[:body.index("\ndef ", 1)]
self.assertIn("v['stars'] = json_obj_github[url]['stars']", body)
self.assertIn("v['last_update'] = json_obj_github[url]['last_update']", body)
self.assertIn(
"except:", body,
"the bare except is what leaves a channel-supplied stars/last_update "
"in place when reference lookup raises; if it is gone, re-derive the "
"provenance argument in this file's docstring",
)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class PackOperationErrorTest(_InertAssertions):
def test_failed_operations_keep_server_escaped_titles_readable(self):
populate = load_markdown_functions(load_manager_util())["populate_markdown"]
methods = ",\n".join(slice_braced(JS.text(MANAGER), "\n\t" + marker) for marker in [
"async installNodes(list, btn, title, selected_version)",
"showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
])
for title in ("Pack <Flux> & friends", TEXT_PAYLOAD):
item = {"title": title, "originalData": {}, "hash": "pack"}
populate(item)
for mode in ("install", "uninstall"):
for status in (403, 500):
with self.subTest(title=title, mode=mode, status=status):
out = run_node("""
%s
const item = %s;
const elements = {};
const messages = {};
const api = {fetchApi: async () => ({status: %d,
json: async () => ({is_processing: false}), text: async () => %s})};
const show_message = message => messages.dialog = message;
const customConfirm = async () => true;
const ctx = {
%s,
element: {querySelector: selector => elements[selector] ||= {innerHTML: ''}},
grid: {getRowItemBy: () => item, scrollRowIntoView() {}, updateCell() {}},
focusInstall() {return true;}
};
await ctx.installNodes(['pack'], {target: {classList: {add() {}}},
label: 'Operation', mode: %s}, '', null);
messages.panel = elements['.cn-manager-message'].innerHTML;
console.log(JSON.stringify(messages));
""" % (_preamble(), json.dumps(item), status, json.dumps(TEXT_PAYLOAD), methods, json.dumps(mode)))
for message in out.values():
self.assert_inert(message, allowed_tags={"font"})
self.assertIn(f"'{title}': ", html.unescape(message))
if status == 500:
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(message))
if __name__ == "__main__":
unittest.main()
+590
View File
@@ -0,0 +1,590 @@
"""Check remote notice HTML, sharing URLs, snapshot names and node selection UI.
Execute shipped Python and JavaScript with isolated external boundaries. Rendering
checks cover hostile inputs and readable controls; link checks preserve opener isolation.
"""
import ast
import os
import re
import unittest
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
from js_lift import NODE, JsSource, run_node
from manager_test_utils import GLOB_DIR, load_manager_util, load_markdown_functions
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
COMMON = "common.js"
SHARE_COMMON = "comfyui-share-common.js"
SHARE_OPENART = "comfyui-share-openart.js"
SHARE_COPUS = "comfyui-share-copus.js"
SHARE_YOUML = "comfyui-share-youml.js"
SNAPSHOT = "snapshot.js"
MANAGER = "custom-nodes-manager.js"
# A scheme that executes on click, and a scheme that carries its own document.
# Both are strings a third party can put in a URL field; neither is a host.
SCHEME_PAYLOAD = "javascript:window.__probe=1"
DATA_PAYLOAD = "data:text/html,<b>x</b>"
BENIGN_URL = "https://example.com/w/123?a=1&b=2"
# An http URL that ALSO closes the attribute it lands in. A service-chosen URL
# is not "either a bad scheme or a quote" -- it is one string that can be both,
# so a scheme allow-list alone leaves the quote. One variant per quoting style,
# because the call sites differ.
BREAKOUT_SINGLE_QUOTED = "https://example.com/x' onmouseover='window.__probe=1"
BREAKOUT_DOUBLE_QUOTED = 'https://example.com/x" onmouseover="window.__probe=1'
# Element text, for the sinks that render a name rather than a link.
TEXT_PAYLOAD = '<img src=x onerror="window.__probe=1">'
def _function_source(name: str, filename: str = "manager_server.py") -> str:
"""The source text of one top-level function, WITHOUT importing its module.
Used for the wiring legs, where the question is "does this call site pass
its value through the normaliser" -- a question about the call site, which a
behavioural test on the normaliser alone cannot answer.
"""
source = (GLOB_DIR / filename).read_text(encoding="utf-8")
tree = ast.parse(source)
wanted = [
node for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
]
assert len(wanted) == 1, "expected exactly one %s, got %d" % (name, len(wanted))
return ast.get_source_segment(source, wanted[0]) or ""
class _Collector(HTMLParser):
"""What a browser would actually build from a fragment."""
def __init__(self):
super().__init__()
self.elements = []
def handle_starttag(self, tag, attrs):
self.elements.append((tag, dict(attrs)))
handle_startendtag = handle_starttag
def parse(markup: str) -> _Collector:
collector = _Collector()
collector.feed(markup)
collector.close()
return collector
def tags(markup: str):
return [tag for tag, _ in parse(markup).elements]
def handler_attrs(markup: str):
return [
"%s@%s" % (tag, name)
for tag, attrs in parse(markup).elements
for name in attrs
if name.lower().startswith("on")
]
def hrefs(markup: str):
return [
attrs["href"] for tag, attrs in parse(markup).elements
if tag == "a" and "href" in attrs
]
# ---------------------------------------------------------------------------
# [D1] the relayed wiki fragment
# ---------------------------------------------------------------------------
class NoticeFragmentSanitizerTest(unittest.TestCase):
"""Notice sanitization must remove executable content while preserving formatting."""
@classmethod
def setUpClass(cls):
cls.manager_util = load_manager_util()
def _sanitize(self, fragment: str) -> str:
fn = getattr(self.manager_util, "sanitize_html_fragment", None)
self.assertIsNotNone(
fn,
"glob/manager_util.py exposes no sanitize_html_fragment(fragment). "
"The notice fragment is remote HTML the server relays verbatim and "
"the client assigns to innerHTML by design, so the server is the "
"only boundary that can constrain it.",
)
return fn(fragment)
def test_a_script_element_does_not_survive(self):
out = self._sanitize('<p>before</p><script>window.__probe=1</script><p>after</p>')
self.assertNotIn("script", tags(out), out)
self.assertIn("p", tags(out), "ordinary markup was destroyed along with it: %r" % (out,))
def test_a_handler_attribute_does_not_survive(self):
out = self._sanitize('<img src="https://example.com/i.png" onerror="window.__probe=1">')
self.assertEqual(handler_attrs(out), [], out)
def test_a_handler_attribute_does_not_survive_on_any_element(self):
# The allow-list is over ATTRIBUTES, not over one element that happened
# to be tested: a deny-list keyed to <img> leaves <div onmouseover=...>.
for markup in (
'<div onmouseover="window.__probe=1">hover</div>',
'<a href="https://example.com/" onclick="window.__probe=1">click</a>',
'<b ONERROR="window.__probe=1">bold</b>',
):
with self.subTest(markup=markup):
self.assertEqual(handler_attrs(self._sanitize(markup)), [], markup)
def test_an_executing_scheme_does_not_survive_in_an_href(self):
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD):
with self.subTest(payload=payload):
out = self._sanitize('<a href="%s">click</a>' % (payload,))
self.assertEqual(
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [],
"the scheme survived into an href: %r" % (out,),
)
def test_ordinary_formatting_still_displays(self):
"""The half a strip-everything sanitizer would fail.
The notice is formatted prose. If the fix returns text, the feature is
gone and no other test in this file would say so.
"""
fragment = (
'<p><b>bold</b> and <i>italic</i></p>'
'<ul><li>one</li><li>two</li></ul>'
'<a href="https://example.com/x">link</a>'
'<img src="https://example.com/i.png">'
)
out = self._sanitize(fragment)
for tag in ("p", "b", "i", "ul", "li", "a", "img"):
with self.subTest(tag=tag):
self.assertIn(tag, tags(out), "%r was dropped: %r" % (tag, out))
self.assertIn("https://example.com/x", hrefs(out), out)
self.assertIn("one", out)
self.assertIn("two", out)
def test_an_unclosed_element_does_not_blank_the_rest_of_the_notice(self):
"""Malformed elements must not discard unrelated notice content."""
for wrapper in ("div", "blockquote", "section", "article", "figure",
"main", "header", "nav", "aside"):
with self.subTest(wrapper=wrapper):
out = self._sanitize(
"<%s><svg></%s><p>after</p>" % (wrapper, wrapper))
self.assertIn(
"after", out,
"an unclosed element inside <%s> swallowed the rest of the "
"notice: %r" % (wrapper, out))
self.assertNotIn("svg", tags(out), out)
def test_an_unterminated_script_still_fails_closed(self):
"""HTML script/style contents must not be emitted as notice markup."""
for fragment in ("<section><script></section><p>after</p>",
"<div><style></div><p>after</p>"):
with self.subTest(fragment=fragment):
out = self._sanitize(fragment)
# NOT `out == ""`: a wrapper's OPEN tag is emitted before the
# skip window starts, so `<div><style>...` legitimately keeps
# its `<div>`. The property is that nothing AFTER the
# unterminated element comes back -- as markup or as text.
self.assertNotIn(
"after", out,
"text after an unterminated script/style came back: %r"
% (out,))
self.assertEqual(
[t for t in tags(out) if t != "div"], [],
"markup after an unterminated script/style came back: %r"
% (out,))
def test_foreign_content_does_not_hide_safe_following_prose(self):
# HTML5 parsing closes the SVG context before this paragraph. The old
# HTMLParser-based sanitizer treated the rest as script text and lost it.
out = self._sanitize('<svg><script></svg><p>after</p>')
self.assertEqual(tags(out), ['p'])
self.assertIn('after', out)
def test_notice_formatting_attributes_survive(self):
out = self._sanitize(
'<p class="notice" title="Release" align="center">'
'<font color="red">Update</font></p>'
'<table width="400"><tr><td colspan="2" rowspan="3">Data</td></tr></table>'
'<img src="https://example.com/i.png" alt="Preview" width="100" height="80">'
)
elements = dict(parse(out).elements)
self.assertEqual(elements['p'], {'class': 'notice', 'title': 'Release', 'align': 'center'})
self.assertEqual(elements['font'], {'color': 'red'})
self.assertEqual(elements['table']['width'], '400')
self.assertEqual(elements['td'], {'colspan': '2', 'rowspan': '3'})
self.assertEqual(elements['img'], {
'src': 'https://example.com/i.png', 'alt': 'Preview', 'width': '100', 'height': '80',
})
def test_notice_links_open_safely_regardless_of_input_attribute_order(self):
for attrs in (
'href="https://example.com/?a=1&amp;b=2" target="named" rel="opener"',
'title="Docs" rel="opener" target="named" href="https://example.com/?a=1&amp;b=2"',
):
with self.subTest(attrs=attrs):
out = self._sanitize(f'<a {attrs}>Docs</a>')
anchor = dict(parse(out).elements)['a']
self.assertEqual(anchor['href'], 'https://example.com/?a=1&b=2')
self.assertEqual(anchor['target'], '_blank')
self.assertEqual(set(anchor['rel'].split()), {'noopener', 'noreferrer'})
def test_notice_url_schemes_remain_limited_to_http_https_and_relative(self):
for url in ('ftp://example.com', 'mailto:a@example.com', 'java&#9;script:alert`1`'):
with self.subTest(url=url):
out = self._sanitize(f'<a href="{url}">Link</a><img src="{url}">')
for _, attrs in parse(out).elements:
self.assertNotIn('href', attrs)
self.assertNotIn('src', attrs)
for url in ('http://example.com', 'https://example.com', '/relative', '#section'):
with self.subTest(url=url):
self.assertEqual(hrefs(self._sanitize(f'<a href="{url}">Link</a>')), [url])
def test_hidden_element_contents_are_not_exposed_as_notice_text(self):
for tag in ('script', 'style', 'iframe', 'object', 'svg', 'math',
'template', 'noscript', 'textarea', 'title'):
with self.subTest(tag=tag):
out = self._sanitize(f'<{tag}>hidden</{tag}><p>after</p>')
self.assertNotIn('hidden', out)
self.assertIn('after', out)
def test_a_mixed_fragment_keeps_the_good_and_drops_the_bad(self):
"""One fragment carrying both, since a real wiki page would."""
out = self._sanitize(
'<p>Release <b>1.2</b></p>'
'<script>window.__probe=1</script>'
'<a href="https://example.com/notes">notes</a>'
'<a href="%s">click</a>'
'<img src="https://example.com/i.png" onerror="window.__probe=1">'
% (SCHEME_PAYLOAD,)
)
self.assertNotIn("script", tags(out), out)
self.assertEqual(handler_attrs(out), [], out)
self.assertEqual(
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [], out)
self.assertIn("https://example.com/notes", hrefs(out), out)
self.assertIn("b", tags(out), out)
self.assertIn("img", tags(out), "the benign image was dropped: %r" % (out,))
class NoticeWiringTest(unittest.TestCase):
"""Check the notice route applies the sanitizer to its captured remote HTML."""
def test_get_notice_passes_the_captured_fragment_through_the_sanitizer(self):
"""Verify the captured HTML is sanitized, not merely that the function is mentioned."""
body = _function_source("get_notice")
self.assertIn(
"match.group(1)", body,
"get_notice no longer captures the fragment the way this guard "
"expects; re-derive it against the new shape rather than deleting "
"the assertion.",
)
self.assertRegex(
body, r"sanitize_html_fragment\s*\(\s*match\.group\(1\)\s*\)",
"get_notice relays the captured wiki fragment without passing it "
"through an allow-list normaliser. The client assigns the response "
"body to innerHTML, so whatever the wiki page contains is what the "
"browser builds. The call has to take the capture as its argument; "
"naming the function elsewhere in the body is not wiring.",
)
self.assertNotRegex(
body, re.compile(r"^\s*markdown_content\s*=\s*match\.group\(1\)\s*$",
re.MULTILINE),
"the raw capture is still assigned directly; the sanitizer must sit "
"on this assignment, not beside it.",
)
# ---------------------------------------------------------------------------
# [D2] share-service URLs
# ---------------------------------------------------------------------------
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ShareDialogUrlTest(unittest.TestCase):
"""Share-service URLs must use safe schemes and quoted attributes."""
def _preamble(self) -> str:
span = JS.lift_span(
COMMON, "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
return "\n".join([
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
span.replace("export ", ""),
])
def _render(self, expression: str, bindings: str) -> str:
return run_node(
"%s\n%s\nconsole.log(JSON.stringify({markup: %s}));\n"
% (self._preamble(), bindings, expression)
)["markup"]
def _assert_scheme_rejected(self, markup: str, label: str):
bad = [h for h in hrefs(markup) if h.startswith(("javascript:", "data:"))]
self.assertEqual(
bad, [],
"%s emits a service-chosen scheme as a clickable href: %r" % (label, markup),
)
self.assertEqual(handler_attrs(markup), [], markup)
# --- comfyui-share-common.js, the fully service-controlled URL ----------
def _common_expression(self) -> str:
from js_lift import line_containing
line = line_containing(JS.text(SHARE_COMMON),
'this.final_message.innerHTML = "Your art has been shared:')
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
def test_share_common_rejects_an_executing_scheme(self):
# The href here is SINGLE-quoted, so the breakout variant uses `'`.
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_SINGLE_QUOTED):
with self.subTest(payload=payload):
markup = self._render(
self._common_expression(),
"const response_json = {comfyworkflows: {url: %s}};" % (_json(payload),))
self._assert_scheme_rejected(markup, "comfyui-share-common.js")
def test_share_common_still_links_an_ordinary_url(self):
"""The leg a refuse-everything fix would fail."""
markup = self._render(
self._common_expression(),
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),))
self.assertIn(
BENIGN_URL, hrefs(markup),
"an ordinary https URL must still become a working link: %r" % (markup,))
def test_every_share_anchor_severs_the_opener(self):
"""Check opener isolation without imposing an order on rel tokens."""
cases = [
("comfyui-share-common.js", self._common_expression(),
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),)),
("comfyui-share-youml.js", self._youml_expression(),
"const messagePrefix = 'shared.';\nconst recipePageUrl = %s;" % (_json(BENIGN_URL),)),
("comfyui-share-openart.js", self._openart_expression(),
"const url = %s;" % (_json(BENIGN_URL),)),
("comfyui-share-copus.js", self._copus_expression(),
"const url = %s;" % (_json(BENIGN_URL),)),
]
for name, expression, bindings in cases:
with self.subTest(file=name):
markup = self._render(expression, bindings)
anchors = [attrs for tag, attrs in parse(markup).elements if tag == "a"]
self.assertTrue(anchors, "no anchor produced by %s: %r" % (name, markup))
for attrs in anchors:
self.assertEqual(
attrs.get("target"), "_blank",
"%s: this guard is about _blank anchors; the target "
"changed, so re-derive it: %r" % (name, markup))
self.assertIn(
"rel", attrs,
"%s opens a service-chosen page in a new tab without "
"rel, so that page keeps a reference to this window: %r"
% (name, markup))
rel = attrs["rel"].lower().split()
self.assertIn("noopener", rel, markup)
self.assertIn("noreferrer", rel, markup)
# --- comfyui-share-youml.js, also fully service-controlled --------------
def _youml_expression(self) -> str:
span = JS.lift_span(SHARE_YOUML, "this.message.innerHTML = `${messagePrefix}",
"visit it on YouML</a>`;")
return span.split("innerHTML =", 1)[1].strip().rstrip(";")
def test_share_youml_rejects_an_executing_scheme(self):
# The href here is DOUBLE-quoted, so the breakout variant uses `"`.
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_DOUBLE_QUOTED):
with self.subTest(payload=payload):
markup = self._render(
self._youml_expression(),
"const messagePrefix = 'Workflow has been shared.';\n"
"const recipePageUrl = %s;" % (_json(payload),))
self._assert_scheme_rejected(markup, "comfyui-share-youml.js")
# --- openart / copus: an id is interpolated into a URL PATH -------------
# The scheme is hardcoded, so these cannot carry javascript:. What they CAN
# carry is a quote: the id is a service-returned string dropped inside a
# double-quoted attribute, so `"` closes it and the rest is markup. That is a
# different failure from the two above and is asserted as such.
def _openart_expression(self) -> str:
from js_lift import line_containing
line = line_containing(JS.text(SHARE_OPENART),
"this.message.innerHTML = `Workflow has been shared successfully.")
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
def _copus_expression(self) -> str:
from js_lift import line_containing
line = line_containing(JS.text(SHARE_COPUS),
"this.message.innerHTML = `Workflow has been shared successfully.")
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
def test_openart_id_cannot_break_out_of_the_href_attribute(self):
markup = self._render(
self._openart_expression(),
'const url = "https://openart.ai/workflows/-/-/" + '
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
self.assertEqual(handler_attrs(markup), [], markup)
def test_copus_id_cannot_break_out_of_the_href_attribute(self):
markup = self._render(
self._copus_expression(),
'const url = "https://example.invalid/work/" + '
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
self.assertEqual(handler_attrs(markup), [], markup)
# ---------------------------------------------------------------------------
# [D3] the snapshot name
# ---------------------------------------------------------------------------
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class SnapshotNameTest(unittest.TestCase):
"""Snapshot filenames must remain inert text in status messages."""
def _render(self, name: str) -> str:
line = _unique_line(JS.text(SNAPSHOT), "self.updateMessage(`<BR><font color=")
expression = _call_argument(line, "self.updateMessage(")
return run_node(
"%s\nconst target = {name: %s};\n"
"console.log(JSON.stringify({markup: %s}));\n"
% (JS.lift_declaration(COMMON, "export function sanitizeHTML("),
_json(name), expression)
)["markup"]
def test_a_crafted_snapshot_name_renders_as_text(self):
markup = self._render(TEXT_PAYLOAD)
self.assertEqual(handler_attrs(markup), [], markup)
self.assertNotIn("img", tags(markup), markup)
def test_an_ordinary_snapshot_name_still_reads_normally(self):
markup = self._render("2026-09-07_backup")
self.assertIn("2026-09-07_backup", markup)
# ---------------------------------------------------------------------------
# [D4] rel=noopener, and the defensive escapes
# ---------------------------------------------------------------------------
class ServerAnchorRelTest(unittest.TestCase):
"""Converted [a/text](url) links must isolate the opener and remain usable."""
@classmethod
def setUpClass(cls):
cls.manager_util = load_manager_util()
# staticmethod, or attribute access binds it and passes `self`
# as the markdown argument.
cls.convert = staticmethod(load_markdown_functions(cls.manager_util)["convert_markdown_to_html"])
def test_a_markdown_link_carries_rel_noopener_noreferrer(self):
out = self.convert("see [a/the docs](https://example.com/docs)")
anchors = [attrs for tag, attrs in parse(out).elements if tag == "a"]
self.assertTrue(anchors, "no anchor was produced at all: %r" % (out,))
for attrs in anchors:
with self.subTest(attrs=attrs):
self.assertIn(
"rel", attrs,
"the anchor opens a new tab without rel: %r. The opened page "
"keeps a reference to this window." % (out,),
)
rel = attrs["rel"].lower().split()
self.assertIn("noopener", rel, out)
self.assertIn("noreferrer", rel, out)
def test_the_link_still_works(self):
out = self.convert("see [a/the docs](https://example.com/docs)")
self.assertIn("https://example.com/docs", hrefs(out), out)
class ClientAnchorAndStateTest(unittest.TestCase):
"""External-link isolation and rendering of unrecognised pack states."""
@classmethod
def setUpClass(cls):
cls.source = JS.text(MANAGER)
def test_the_title_link_severs_the_opener(self):
marker = "link.target = '_blank';"
index = _unique_index(self.source, marker)
# max(0, ...) because a negative start would wrap to the END of the
# string and silently hand back an unrelated window.
window = self.source[max(0, index - 400):index + 400]
self.assertRegex(
window, r"link\.rel\s*=",
"the title link opens a new tab without setting rel, so the pack's "
"own repository page keeps a reference to this window.",
)
rel = re.search(r"link\.rel\s*=\s*['\"]([^'\"]*)['\"]", window)
self.assertIsNotNone(rel, "link.rel is set from a non-literal: %r" % (window,))
tokens = rel.group(1).lower().split()
# Membership, not token order: 'noreferrer noopener' is the same thing,
# and demanding one spelling would false-RED a correct fix. Matches how
# ServerAnchorRelTest checks the server-side anchor.
self.assertIn("noopener", tokens, window)
self.assertIn("noreferrer", tokens, window)
@unittest.skipIf(NODE is None, "node is required")
def test_selection_survives_unrecognised_and_prototype_names(self):
sanitize = JS.lift_declaration(COMMON, "export function sanitizeHTML(")
buttons = JS.lift_declaration(MANAGER, "\n\tgetActionButtons(")
render = JS.lift_declaration(MANAGER, "\n\trenderSelected()")
for state in ['not-a-known-install-group', 'constructor', '__proto__', TEXT_PAYLOAD, 'enabled']:
with self.subTest(state=state):
result = run_node(sanitize + "\nconst manager = {" + buttons + "," + render + """,
grid: {hasMask: false, getSelectedRows: () => [{action: STATE, hash: 'fixture'}]},
getFilterItem: () => null,
showSelection(html) { this.html = html; }
};
manager.renderSelected();
console.log(JSON.stringify({html: manager.html,
buttons: manager.getActionButtons(STATE, {version: 'unknown'}, true)}));
""".replace('STATE', _json(state)))
self.assertIn('Selected <b>1</b>', result['html'])
self.assertIn(state, unescape(result['html']))
self.assertFalse(handler_attrs(result['html']))
self.assertNotIn('img', tags(result['html']))
if state == 'enabled':
self.assertIn('<button', result['buttons'])
else:
self.assertEqual(result['buttons'], '')
def _json(value) -> str:
import json
return json.dumps(value)
def _unique_index(source: str, marker: str) -> int:
"""Index of `marker`, refusing to guess when it is not unique.
js_lift.line_containing takes the FIRST occurrence silently. Every marker
here is unique today; this makes a future second occurrence fail loudly
instead of pinning whichever copy happens to come first.
"""
count = source.count(marker)
assert count == 1, "marker %r occurs %d times; disambiguate it" % (marker, count)
return source.index(marker)
def _unique_line(source: str, marker: str) -> str:
"""The single source line holding `marker`, with the same uniqueness rule."""
index = _unique_index(source, marker)
start = source.rfind("\n", 0, index) + 1
return source[start:source.index("\n", index)]
def _call_argument(line: str, call_marker: str) -> str:
"""The argument text of `call_marker(...)`, dropping exactly one `)` and `;`.
NOT `.rstrip(");")`: rstrip takes a CHARACTER SET, so it eats every trailing
`)`, `;` and `"`. A fix spelled `updateMessage(escape(`...`));` would lose a
closing paren it needs and become a node syntax error -- which pytest would
show as this guard failing, i.e. the fix would look like the defect.
"""
text = line.split(call_marker, 1)[1].rstrip()
if text.endswith(";"):
text = text[:-1].rstrip()
assert text.endswith(")"), "unexpected call shape: %r" % (line.strip(),)
return text[:-1]
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
"""Render actual update-completion handlers in an isolated browser."""
from pathlib import Path
import pytest
from js_lift import JsSource
JS = JsSource(Path(__file__).resolve().parent.parent / "js")
@pytest.mark.parametrize("status", ["success", "failed"])
def test_update_results_preserve_links_titles_and_missing_url_labels(status):
playwright = pytest.importorskip("playwright.sync_api")
helpers = JS.lift_declaration("common.js", "export function sanitizeHTML(")
helpers += JS.lift_span(
"common.js", "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));",
).replace("export ", "")
helpers += JS.lift_declaration("common.js", "export function show_message(")
handler = JS.lift_declaration("comfyui-manager.js", "async function onQueueStatus(")
url = "https://example.test/guide?q=O'Reilly&lang=en"
title = "Guide <draft> & notes"
fallback_id = "Pack <draft>"
with playwright.sync_playwright() as driver:
if not Path(driver.chromium.executable_path).exists():
pytest.skip("install Chromium with: playwright install chromium")
browser = driver.chromium.launch()
page = browser.new_page()
page.route("**/*", lambda route: route.abort())
page.set_content('<div id="dialog"></div>')
page.add_script_tag(content="""
let is_updating = true;
function reset_action_buttons() {}
function setNeedRestart() {}
function infoToast() {}
const app = {ui: {dialog: {
element: document.getElementById('dialog'),
show(message) { this.element.innerHTML = message; }
}}};
""" + helpers + handler)
page.evaluate("event => onQueueStatus(event)", {"detail": {
"status": "done", "nodepack_result": {
"comfyui": "skip",
"pack-link": {"url": url, "title": title, "msg": status},
fallback_id: {"msg": status},
},
}})
items = page.locator("#dialog li")
assert items.all_text_contents() == [title, fallback_id]
assert items.nth(0).locator("a").get_attribute("href") == url
assert set(items.nth(0).locator("a").evaluate("node => node.getAttributeNames()")) == {
"href", "target", "rel",
}
assert items.nth(0).locator("a").get_attribute("rel") == "noopener noreferrer"
assert items.nth(1).locator("a").count() == 0
assert page.locator("#dialog draft").count() == 0
browser.close()
+410
View File
@@ -0,0 +1,410 @@
"""Drift guard: the JS sanitizeUrl must agree with glob/manager_util.py::sanitize_url.
There are two implementations of one URL allow-list — ``sanitizeUrl`` in
js/common.js and ``sanitize_url`` in glob/manager_util.py. They agree today.
Nothing structural keeps them agreeing: a change to either side has no failing
test on the other, and the JS side is what stands between channel data and an
href. This module is that missing coupling. It runs BOTH real implementations
over a shared case list and fails on any divergence, naming the input and both
answers.
WHAT IT COMPARES: the real shipped code on both sides. The Python function is
loaded by file location (``glob/`` has no ``__init__.py``, and putting it on
sys.path would shadow the stdlib ``glob``); the JS function is lifted out of
js/common.js by tests/js_lift and executed in node. Neither is a copy, so a
perturbation of either implementation turns this guard RED.
CASES: the shared matrix at ``cases/url_sanitize_cases.json`` (57 inputs handed
over by gm3-review from the WI-112 and WI-122 reviews, since extended) PLUS a
generated grid that composes scheme x in-scheme-noise x delimiter. The generated
half matters because a fixed list can only catch divergence it happens to
contain, and the implementations are what is under test, not the list.
TWO AXES ARE COMPARED, not one. Behaviour over sampled inputs cannot see a
difference no sample happens to hold, so the two pieces of CONFIGURATION the
implementations branch on are compared directly as well: the scheme allow-list
(``test_the_scheme_allow_lists_are_identical``) and the whitespace set each side
trims off an accepted URL (``test_the_trim_sets_are_identical``). The trim axis
was added after the matrix was found to contain no BOM or unicode-whitespace
input at all — ``String.prototype.trim()`` and ``str.strip()`` disagree on six
codepoints (U+FEFF one way; U+001C-001F and U+0085 the other), and every case in
the matrix had ASCII edges, so the divergence sat unguarded. js/common.js now
spells Python's set out rather than delegating to ``trim()``; see the
``unicode_whitespace_divergence`` block in the case file.
⛔ STRING INPUTS ONLY, deliberately. Python ``str()`` and JS ``String()`` are
not equivalent for non-strings — 1.0 -> '1.0' vs '1', True -> 'True' vs 'true',
[1,2] -> '[1, 2]' vs '1,2', {} -> '{}' vs '[object Object]' — so feeding
non-strings would report divergence for something that is not a defect. In
practice ``url`` arrives as a string off the JSON payload. The two aligned
non-string cases that DO matter are pinned separately in
``test_null_and_undefined_agree`` rather than left as an undocumented gap; the
right response to a coercion mismatch is to narrow the domain, never to loosen
the comparison, because a loosened comparison stops catching real drift.
"""
import inspect
import json
import os
import re
import unittest
from pathlib import Path
from js_lift import NODE, JsSource, run_node
from manager_test_utils import load_manager_util
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
CASES_PATH = Path(__file__).resolve().parent / "cases" / "url_sanitize_cases.json"
# Every codepoint ``str.strip()`` removes, measured by RUNNING it rather than by
# restating a table. sanitize_url returns ``raw.strip()``, so this is the set the
# JS side has to match; test_the_trim_sets_are_identical asserts that linkage
# still holds before comparing.
PYTHON_TRIM_SET = frozenset(c for c in range(0x110000) if chr(c).strip() == "")
def _generated_cases(allow_listed):
"""Compose scheme x in-scheme noise x delimiter into extra inputs.
A fixed list only catches the divergence it already contains. This grid is
derived from the axes the two implementations actually branch on — the
scheme allow-list, the control-char strip, and the head-delimiter cut — so
it explores combinations nobody wrote down.
``allow_listed`` is the UNION of the two live allow-lists, not a literal, so
a scheme added to either side is exercised behaviourally without anyone
remembering to add a case for it. That is a second layer under
test_the_scheme_allow_lists_are_identical, which is what catches an
allow-list divergence definitively; this layer only makes the behavioural
sampling track the configuration instead of drifting away from it.
"""
schemes = sorted(set(allow_listed) | {
"HTTP", "javascript", "data", "vbscript", "file", "a-b", "a.b",
})
noises = ["", "\t", "\n", "\x00", "\x01", " ", "\x7f"]
delimiters = ["", "/", "?", "#", "//host/p", "?q=1", "#frag"]
for scheme in schemes:
for noise in noises:
head = scheme[:2] + noise + scheme[2:]
for delimiter in delimiters:
yield "%s:%s" % (head, delimiter)
yield " %s:%s" % (head, delimiter)
def _all_cases(allow_listed):
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
cases = list(matrix["shared_matrix"])
cases.extend(_generated_cases(allow_listed))
# De-duplicate while preserving order so a failure names a stable index.
seen = set()
ordered = []
for case in cases:
if case not in seen:
seen.add(case)
ordered.append(case)
return ordered
def _lift_sanitize_url() -> str:
"""The REAL sanitizeUrl and the two constants it closes over, from common.js.
Lifted as one contiguous span rather than three declarations: SAFE_URL_SCHEMES
is an array literal, so a brace-matching lift would run past it into the
function body and declare the constants twice.
"""
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
# safeHref needs sanitizeHTML, which this guard does not exercise; drop the
# trailing line rather than pulling an unrelated dependency into the script.
span = span[:span.index("export const safeHref")]
return span.replace("export ", "")
_JS_ALLOW_LIST_LITERAL = re.compile(r"(SAFE_URL_SCHEMES\s*=\s*)\[[^\]]*\]")
def _js_allow_list(lifted_js=None):
"""The JS SAFE_URL_SCHEMES as the running code actually sees it.
Evaluated in node rather than parsed out of the source, so a change to how
the list is spelled cannot make this silently read the old value.
"""
return run_node(
"%s\nconsole.log(JSON.stringify({v: SAFE_URL_SCHEMES}));\n"
% (lifted_js if lifted_js is not None else _lift_sanitize_url())
)["v"]
def _js_trim_set(lifted_js=None):
"""Every codepoint the JS side strips off the edges of an accepted URL.
Evaluated in node, for the same reason ``_js_allow_list`` is: a set parsed
out of the source text stops describing the running code the moment the
spelling changes.
Shimmed on a PRE-FIX tree, where sanitizeUrl called ``raw.trim()`` inline and
no ``trimUrl`` exists. Without the shim this guard would raise on the old
revision instead of reporting the divergence, and the RED half of the fix
could not be demonstrated with the guard that is supposed to catch it.
"""
lifted = _lift_sanitize_url() if lifted_js is None else lifted_js
shim = "" if "trimUrl" in lifted else "const trimUrl = (value) => value.trim();\n"
return set(run_node(
"%s\n%sconst out = [];\n"
"for (let c = 0; c < 0x110000; c += 1) {\n"
" if (trimUrl(String.fromCodePoint(c)) === '') out.push(c);\n"
"}\n"
"console.log(JSON.stringify({v: out}));\n" % (lifted, shim)
)["v"])
def _perturb_js_allow_list(lifted_js, extra_scheme):
"""Add a scheme to the lifted JS allow-list, whatever it currently holds.
Rewriting the array by PATTERN rather than by a hard-coded literal matters:
a literal-match perturbation stops applying the moment someone edits the
allow-list, and then the harness's own did-it-apply self-test fires instead
of the parity comparison — a red that looks like coverage and is not.
"""
perturbed, count = _JS_ALLOW_LIST_LITERAL.subn(
lambda m: "%s%s" % (m.group(1), json.dumps(sorted(
set(_js_allow_list(lifted_js)) | {extra_scheme}))),
lifted_js, count=1,
)
assert count == 1, "could not locate the JS SAFE_URL_SCHEMES array to perturb"
return perturbed
def _js_results(cases):
"""Run the REAL js/common.js sanitizeUrl over every case, in node."""
lifted = _lift_sanitize_url()
return run_node(
"%s\nconst cases = %s;\n"
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
% (lifted, json.dumps(cases))
)["results"]
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class UrlSanitizeParityTest(unittest.TestCase):
"""Both real implementations, one case list, zero tolerated divergence."""
@classmethod
def setUpClass(cls):
cls.manager_util = load_manager_util()
cls.allow_lists = {
"python": set(cls.manager_util.SAFE_URL_SCHEMES),
"js": set(_js_allow_list()),
}
cls.cases = _all_cases(cls.allow_lists["python"] | cls.allow_lists["js"])
cls.js = _js_results(cls.cases)
def test_case_matrix_is_actually_loaded(self):
# A guard that silently ran zero cases would pass forever.
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
self.assertGreaterEqual(len(matrix["shared_matrix"]), 57)
self.assertGreater(len(self.cases), 400)
self.assertEqual(len(self.js), len(self.cases))
def test_the_scheme_allow_lists_are_identical(self):
"""Compare the CONFIGURATION, not only the behaviour it drives.
Comparing behaviour over sampled inputs cannot see a scheme added to one
allow-list unless that scheme happens to be in the sample — adding 'ftp'
or 'mailto' or 'ws' to either side changes what is accepted and produces
no divergence at all. That is the most plausible future drift for this
pair (someone adds a scheme for a real reason and forgets the other
side), so it is closed definitively here rather than by sampling.
"""
self.assertEqual(
self.allow_lists["js"], self.allow_lists["python"],
"the URL scheme allow-lists have diverged — js/common.js accepts %r "
"and glob/manager_util.py accepts %r. A scheme added to one side and "
"not the other silently changes what reaches an href. Fix the lists, "
"do NOT relax this comparison."
% (sorted(self.allow_lists["js"]), sorted(self.allow_lists["python"])),
)
def test_the_trim_sets_are_identical(self):
"""Compare the trim CONFIGURATION, the way the allow-lists are compared.
Both implementations end an accepted URL with a trim, and the two trims
are NOT the same function: ``String.prototype.trim()`` strips U+FEFF,
which ``str.strip()`` keeps, and leaves U+001C-001F and U+0085, which
``str.strip()`` strips. Sampling cannot be relied on to find that — the
handed-over matrix had ASCII edges on all 62 inputs and reported no
divergence for years of cases. The cases added alongside this test do
catch it, but the set comparison is what closes the axis DEFINITIVELY,
exactly as test_the_scheme_allow_lists_are_identical does for schemes.
The fix direction is fixed, not free: glob/manager_util.py is the
declared source of truth for this pair, so the JS side matches Python.
"""
source = inspect.getsource(self.manager_util.sanitize_url)
self.assertIn(
"raw.strip()", source,
"sanitize_url no longer returns raw.strip(), so PYTHON_TRIM_SET is "
"measuring the wrong function — re-derive it from whatever the "
"Python side trims with now.",
)
js = _js_trim_set()
self.assertEqual(
js, set(PYTHON_TRIM_SET),
"the URL trim sets have diverged. js/common.js strips %s that "
"glob/manager_util.py keeps, and keeps %s that it strips. Both sides "
"must trim the SAME set or the two implementations answer differently "
"for the same accepted URL; fix js/common.js's URL_TRIM, do NOT relax "
"this comparison."
% (sorted("U+%04X" % c for c in js - PYTHON_TRIM_SET) or "nothing",
sorted("U+%04X" % c for c in PYTHON_TRIM_SET - js) or "nothing"),
)
def test_js_and_python_agree_on_every_case(self):
divergences = []
for case, js_answer in zip(self.cases, self.js):
py_answer = self.manager_util.sanitize_url(case)
if py_answer != js_answer:
divergences.append(
" input=%r\n python=%r\n js =%r" % (case, py_answer, js_answer)
)
self.assertEqual(
divergences, [],
"js/common.js sanitizeUrl has drifted from "
"glob/manager_util.py::sanitize_url over %d case(s):\n%s\n"
"Fix the implementations so they agree again — do NOT loosen this "
"comparison, which is the only thing coupling them."
% (len(divergences), "\n".join(divergences)),
)
def test_null_and_undefined_agree(self):
"""The two non-string inputs both sides handle explicitly.
Everything else non-string is out of domain — see the module docstring.
"""
out = run_node(
"%s\nconsole.log(JSON.stringify({"
"nul: sanitizeUrl(null), undef: sanitizeUrl(undefined)}));\n"
% _lift_sanitize_url()
)
self.assertEqual(out["nul"], self.manager_util.sanitize_url(None))
self.assertEqual(out["undef"], "#")
def _divergences_against(self, lifted_js):
"""Run the SAME comparison test_js_and_python_agree_on_every_case runs."""
js = run_node(
"%s\nconst cases = %s;\n"
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
% (lifted_js, json.dumps(self.cases))
)["results"]
return [
case for case, answer in zip(self.cases, js)
if self.manager_util.sanitize_url(case) != answer
]
def test_the_guard_goes_red_on_each_perturbation_of_the_js_side(self):
"""Prove the comparison has teeth rather than asserting that it does.
Each perturbation is a shape a real regression takes — a scheme added to
the allow-list, the control-char strip losing its /g flag so only the
FIRST noise byte is removed, the scheme-less branch changing its answer,
and the empty-input fallback changing. Every one must be caught by the
case list as it stands, so a guard whose lift silently returned a stub,
or whose matrix had rotted into irrelevance, cannot look healthy.
"""
base = _lift_sanitize_url()
perturbations = {
# By PATTERN, not by literal: a literal-match perturbation stops
# applying the moment someone edits the allow-list, and the
# did-it-apply assertion below then fires INSTEAD of the parity
# comparison — a red that reads like coverage and is not.
"allow-list gains a scheme": _perturb_js_allow_list(base, "javascript"),
"noise strip loses its /g flag":
base.replace("/[\\x00-\\x20\\x7f]/g", "/[\\x00-\\x20\\x7f]/", 1),
"scheme-less branch stops trimming":
base.replace("return trimUrl(raw);\n}", "return raw;\n}", 1),
# The regression this fix closed: delegating back to String.trim(),
# which strips U+FEFF that str.strip() keeps and keeps U+001C-001F
# and U+0085 that str.strip() strips. Caught only by the unicode
# cases added with the fix — before them the matrix had ASCII edges
# throughout and this perturbation produced zero divergence.
"trim delegates back to String.trim()":
base.replace("trimUrl(raw)", "raw.trim()"),
"empty-input fallback changes": base.replace("return '#';", "return '';", 1),
}
for label, lifted in perturbations.items():
with self.subTest(perturbation=label):
self.assertNotEqual(
lifted, base,
"perturbation %r did not apply — this is the HARNESS "
"self-test, NOT the parity comparison. Do not read it as "
"coverage: fix the perturbation so the comparison is "
"actually exercised." % (label,),
)
caught = self._divergences_against(lifted)
self.assertNotEqual(
caught, [],
"perturbing the JS side (%s) produced NO divergence — the case "
"list cannot detect this drift, so the guard is weaker than it "
"looks. Add a case that distinguishes it." % (label,),
)
def test_the_guard_goes_red_when_either_allow_list_gains_a_scheme(self):
"""The perturbation that was NOT caught before this test existed.
Both directions are exercised, because the drift can start on either
side and the guard is only worth having if it is symmetric. 'ftp' is used
deliberately: it is absent from the shared matrix, so nothing but the
allow-list comparison itself can catch it.
"""
with self.subTest(side="python (the declared SoT)"):
real = self.manager_util.SAFE_URL_SCHEMES
try:
setattr(self.manager_util, "SAFE_URL_SCHEMES", frozenset(set(real) | {"ftp"}))
self.assertNotEqual(
set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["js"],
"adding a scheme to the PYTHON allow-list produced no "
"divergence — the configuration axis is unguarded again",
)
finally:
setattr(self.manager_util, "SAFE_URL_SCHEMES", real)
self.assertEqual(set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["python"])
with self.subTest(side="js"):
perturbed = _js_allow_list(_perturb_js_allow_list(_lift_sanitize_url(), "ftp"))
self.assertIn("ftp", perturbed, "the JS perturbation did not apply")
self.assertNotEqual(
set(perturbed), self.allow_lists["python"],
"adding a scheme to the JS allow-list produced no divergence",
)
def test_the_guard_goes_red_on_a_perturbed_python_side(self):
"""The coupling has to run both ways, not just JS-drifts-from-Python."""
real = self.manager_util.sanitize_url
try:
setattr(self.manager_util, "sanitize_url", lambda url: "#")
caught = self._divergences_against(_lift_sanitize_url())
finally:
setattr(self.manager_util, "sanitize_url", real)
self.assertNotEqual(
caught, [],
"perturbing the PYTHON side produced no divergence — the comparison "
"is not reading the Python implementation it claims to",
)
def test_sanitize_url_has_exactly_one_client_side_home(self):
"""The consolidation this guard exists to protect."""
js_dir = JS.js_dir
homes = sorted(
path.name for path in js_dir.glob("*.js")
if "function sanitizeUrl" in path.read_text(encoding="utf-8")
)
self.assertEqual(
homes, ["common.js"],
"sanitizeUrl must live only in common.js; every other client file "
"imports it. A second copy is the drift this guard cannot see, "
"because it only compares the copy in common.js.",
)
if __name__ == "__main__":
unittest.main()
+470
View File
@@ -0,0 +1,470 @@
"""GOAL #25 RED — write_config() clobbers hand-maintained config.ini content.
Reproduces the three clobber classes the frozen MM
(`scratch/gm3-team/g25-mm.md`) records, as born-RED pytest nodes, plus two
born-GREEN regression guards. Node ids are the RED contract: the GREEN step
must drive these SAME ids to PASS.
| node | MM row | AC | born |
|----------------------------------------|--------|------|-------|
| test_t1_hand_edited_flag_survives | T1 | [D1] | RED |
| test_t3_unwritten_key_survives | T3 | [D3] | RED |
| test_t4_non_default_section_survives | T4 | [D3] | RED |
| test_t2_each_caller_key_persists | T2 | [D2] | GREEN |
| test_t5_bootstrap_writes_defaults | T5 | S4 | GREEN |
GOAL #53 adds one more node to the same harness:
| node | AC | born |
|----------------------------------------|------|-------|
| test_t7_silent_read_failure_is_loud | [D1] | RED |
T7 — `config.read()` SILENTLY suppresses OSError. A config.ini that exists
but cannot be read leaves the parser empty, so the merge falls into the
BOOTSTRAP branch and rewrites the file from defaults, destroying every
foreign key and non-`[default]` section. The failure must be loud, and
the file must be left untouched.
Why they fail today (MM State Model, measured at 30bd355e):
T1 — `write_config()` sources 17 of its 18 keys from `get_config()`, the
process-lifetime startup snapshot (`manager_core.py:1685-1704`), so a
hand edit made after startup is overwritten with the stale value.
T3 — `read_config()` returns 20 keys, `write_config()` persists 18;
`http_channel_enabled` and `default_cache_as_channel_url` are read but
never written, so every write deletes them.
T4 — `write_config()` builds a FRESH `ConfigParser`, assigns only
`config['default']`, and opens the file `'w'` (`:1715`), erasing every
other section.
Harness: the subprocess-isolated child from `tests/test_install_flags_config.py`
— stub `folder_paths`, `glob/` on the CHILD's sys.path only, `manager_core`
imported (NEVER `manager_server`: module level starts a network thread at
`:2093`), `manager_config_path` pointed at a tmp file, `cached_config` reset.
`force_security_level_if_needed` (MM HAZARD C-1) is NOT stubbed — it runs on
the real `read_config` path. The `folder_paths` stub supplies a system-user
directory, so `has_system_user_api()` is True and the function takes its
no-force branch and returns without mutating. The alias-mutation branch is
therefore present but not exercised here; the GREEN step must keep it in view
when it pins the dirty-tracking wrap point.
"""
import json
import subprocess
import sys
import textwrap
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
#: The real mutation shape every settings caller uses (MM Action Map): a
#: subscript assignment on the cached dict, then `write_config()`. Verified
#: exhaustive by AST at 30bd355e — 6 sites, no other shape.
_CALLER_KEYS = [
("db_mode", "local"), # manager_server.py:1779 -> :244
("component_policy", "higher"), # manager_server.py:1793 -> :238
("update_policy", "nightly-comfyui"), # manager_server.py:1806 -> :241
("channel_url", "https://example.invalid/ch"), # manager_server.py:1833 -> :1832
("share_option", "none"), # share_3rdparty.py:60 -> :59
]
_CHILD_PREAMBLE = textwrap.dedent(
"""
import sys, types, tempfile, os, json, configparser
tmp = tempfile.mkdtemp(prefix="cm_wcfg_")
stub = types.ModuleType("folder_paths")
stub.get_user_directory = lambda: tmp
stub.get_system_user_directory = lambda *a, **k: os.path.join(tmp, "sysuser")
sys.modules["folder_paths"] = stub
sys.path.insert(0, {glob_path!r})
import manager_core
CONFIG_PATH = os.path.join(tmp, "config.ini")
manager_core.manager_config_path = CONFIG_PATH
manager_core.cached_config = None
def write_ini(text):
with open(CONFIG_PATH, "w") as f:
f.write(text)
def read_raw():
with open(CONFIG_PATH) as f:
return f.read()
def parse_disk():
cp = configparser.ConfigParser(strict=False)
cp.read(CONFIG_PATH)
return cp
def boot():
\"\"\"Simulate process start: read config.ini once into the cache.\"\"\"
manager_core.cached_config = None
return manager_core.get_config()
def settings_change(key, value):
\"\"\"The real caller shape: mutate the cached dict, then write.\"\"\"
manager_core.get_config()[key] = value
manager_core.write_config()
"""
)
def _run_child(body):
"""Run a scenario body in the isolated child; return its JSON payload."""
script = _CHILD_PREAMBLE.format(glob_path=str(REPO_ROOT / "glob")) + textwrap.dedent(body)
proc = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
timeout=180,
cwd=str(REPO_ROOT),
)
if proc.returncode != 0:
raise AssertionError(
"write-config-harness child failed (rc=%d). stderr tail:\n%s"
% (proc.returncode, "\n".join(proc.stderr.strip().splitlines()[-8:]))
)
lines = proc.stdout.strip().splitlines()
if not lines:
raise AssertionError(
"write-config-harness child exited 0 but produced no stdout. stderr tail:\n%s"
% "\n".join(proc.stderr.strip().splitlines()[-8:])
)
try:
return json.loads(lines[-1])
except json.JSONDecodeError as e:
raise AssertionError(
"write-config-harness child emitted a non-JSON last line: %r\nfull stdout:\n%s"
% (lines[-1], proc.stdout)
) from e
class WriteConfigPersistenceTest(unittest.TestCase):
"""MM Test Scenario Map T1-T5 (`scratch/gm3-team/g25-mm.md`)."""
def test_t1_hand_edited_flag_survives(self):
"""T1 / [D1] — a hand edit made while running survives a settings change.
BORN-RED. The user follows GOAL #8's denial copy, edits config.ini,
then touches any Manager setting; `write_config()` re-writes the flag
from the startup snapshot and the edit is gone.
"""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = normal\\nallow_git_url_install = false\\n")
boot() # startup snapshot: flag False
# user hand-edits config.ini while the server is UP
write_ini("[default]\\nsecurity_level = normal\\nallow_git_url_install = true\\n")
settings_change("db_mode", "local") # ordinary Manager settings change
cp = parse_disk()
print(json.dumps({
"flag_on_disk": cp["default"].get("allow_git_url_install", "<ABSENT>"),
"raw": read_raw(),
}))
"""
)
self.assertEqual(
"true", str(payload["flag_on_disk"]).lower(),
"T1 [D1]: the hand-edited allow_git_url_install=true was reverted to "
"%r by an unrelated settings change. write_config() sources 17 of 18 "
"keys from the startup snapshot (manager_core.py:1685-1704), so the "
"user is denied again after restarting despite following the denial "
"message exactly. On-disk file:\n%s"
% (payload["flag_on_disk"], payload["raw"]),
)
def test_t3_unwritten_key_survives(self):
"""T3 / [D3] — a key read_config reads but write_config never writes.
BORN-RED. `http_channel_enabled` gates the security warning at
`manager_core.py:1793`; every write deletes it from the file.
"""
payload = _run_child(
"""
write_ini(
"[default]\\nsecurity_level = normal\\n"
"http_channel_enabled = true\\ndefault_cache_as_channel_url = true\\n"
)
boot()
settings_change("db_mode", "local")
cp = parse_disk()
print(json.dumps({
"http_channel_enabled": cp["default"].get("http_channel_enabled", "<ABSENT>"),
"default_cache_as_channel_url": cp["default"].get(
"default_cache_as_channel_url", "<ABSENT>"),
"raw": read_raw(),
}))
"""
)
# NOT subTest: pytest-subtests reports a SUBFAIL while leaving the
# NODE status PASSED, which would break the node-id RED contract this
# WI pins — the GREEN step keys on these ids. An aggregate assertion
# keeps the per-key detail in the message AND fails the node.
lost = [
key for key in ("http_channel_enabled", "default_cache_as_channel_url")
if payload[key] == "<ABSENT>"
]
self.assertEqual(
[], lost,
"T3 [D3]: %r were present in config.ini and are GONE after an "
"unrelated settings change. read_config() returns 20 keys, "
"write_config() persists 18 — these keys are read but never "
"written, so every write deletes them. On-disk file:\n%s"
% (lost, payload["raw"]),
)
def test_t4_non_default_section_survives(self):
"""T4 / [D3] — a non-`default` section survives a settings change.
BORN-RED. `write_config()` builds a fresh ConfigParser, assigns only
`config['default']`, and opens `'w'` (`manager_core.py:1715`).
"""
payload = _run_child(
"""
write_ini(
"[default]\\nsecurity_level = normal\\n\\n"
"[my_section]\\nmy_key = keepme\\n"
)
boot()
settings_change("db_mode", "local")
cp = parse_disk()
print(json.dumps({
"sections": cp.sections(),
"my_key": cp["my_section"].get("my_key", "<ABSENT>")
if cp.has_section("my_section") else "<SECTION GONE>",
"raw": read_raw(),
}))
"""
)
self.assertIn(
"my_section", payload["sections"],
"T4 [D3]: the user's [my_section] was erased by an unrelated "
"settings change — write_config() writes only config['default'] "
"into a fresh parser with mode 'w'. Sections left on disk: %r\n%s"
% (payload["sections"], payload["raw"]),
)
self.assertEqual("keepme", payload["my_key"])
def test_t2_each_caller_key_persists(self):
"""T2 / [D2] — regression guard, expected born-GREEN.
Each settings caller's OWN key must reach disk with its new value. This
is what a fix must not break; it is exercised at the manager_core layer
with the same mutation + `write_config()` the handlers make, because
importing `manager_server` starts a network thread (`:2093`).
"""
payload = _run_child(
"""
import json as _j
cases = %s
out = {}
for key, value in cases:
write_ini("[default]\\nsecurity_level = normal\\n")
boot()
settings_change(key, value)
out[key] = parse_disk()["default"].get(key, "<ABSENT>")
print(_j.dumps(out))
""" % json.dumps(_CALLER_KEYS)
)
# NOT subTest — same reason as T3: a regression guard whose node
# reports PASSED while a subtest fails cannot signal the regression
# it exists to catch.
wrong = {
key: payload[key] for key, value in _CALLER_KEYS
if payload[key] != value
}
self.assertEqual(
{}, wrong,
"T2 [D2]: these caller keys did not persist their new value: %r — "
"a fix must not regress the write path it is narrowing."
% (wrong,),
)
def test_t5_bootstrap_writes_defaults(self):
"""T5 / S4 — regression guard, expected born-GREEN.
The 7th `write_config()` caller (`manager_server.py:2098`) is guarded by
`if not os.path.exists(...)` (`:2096`) and runs only when config.ini is
absent, so it cannot clobber. Its bootstrap write must still produce a
usable defaults file.
"""
payload = _run_child(
"""
if os.path.exists(CONFIG_PATH):
os.remove(CONFIG_PATH)
manager_core.get_config() # the bootstrap pair at :2097-2098
manager_core.write_config()
cp = parse_disk()
print(json.dumps({
"exists": os.path.exists(CONFIG_PATH),
"security_level": cp["default"].get("security_level", "<ABSENT>"),
"allow_git_url_install": cp["default"].get(
"allow_git_url_install", "<ABSENT>"),
"allow_flagged_nodepack_install": cp["default"].get(
"allow_flagged_nodepack_install", "<ABSENT>"),
}))
"""
)
self.assertTrue(payload["exists"], "T5: bootstrap did not create config.ini")
self.assertEqual("normal", payload["security_level"])
self.assertEqual(
"False", payload["allow_git_url_install"],
"T5: the install flag must bootstrap secure-by-default",
)
self.assertEqual(
"False", payload["allow_flagged_nodepack_install"],
"T5: flagged installs must bootstrap with the override disabled",
)
def test_t6_persisted_key_is_not_reclobbered(self):
"""T6 / [D1] — a key stays writable by hand AFTER the UI has set it.
BORN-RED. `dirty_keys` is add-only: `write_config()` never removes the
keys it just persisted. So a key the user once changed through the UI
remains dirty for the life of the process, and the NEXT unrelated
settings write overlays its stale cached value back over whatever the
user has since edited on disk — the same clobber class the merge-on-
write fix removes, just deferred by one settings change.
This is the residual case: T1 covers a key the UI never touched, which
is why T1 passes while this fails.
"""
payload = _run_child(
"""
write_ini("[default]\\nsecurity_level = normal\\ndb_mode = cache\\n")
boot()
# 1. the user changes db_mode through the UI -> persisted, and the
# key is now marked dirty
settings_change("db_mode", "local")
after_ui = parse_disk()["default"].get("db_mode", "<ABSENT>")
# 2. the user hand-edits THAT SAME key on disk, touching nothing else
raw = read_raw()
assert "db_mode = local" in raw, raw
with open(CONFIG_PATH, "w") as f:
f.write(raw.replace("db_mode = local", "db_mode = remote"))
# 3. one UNRELATED settings change
settings_change("component_policy", "higher")
cp = parse_disk()
print(json.dumps({
"after_ui": after_ui,
"db_mode_final": cp["default"].get("db_mode", "<ABSENT>"),
"component_policy": cp["default"].get("component_policy", "<ABSENT>"),
"raw": read_raw(),
}))
"""
)
# Guard against a vacuous pass: step 1 must really have persisted, and
# step 3's own key must really have landed. Without these, a
# write_config() that wrote nothing at all would satisfy the assertion
# below for the wrong reason.
self.assertEqual("local", payload["after_ui"], "T6 precondition: the UI change did not persist")
self.assertEqual(
"higher", payload["component_policy"],
"T6 precondition: the unrelated settings change did not persist",
)
self.assertEqual(
"remote", payload["db_mode_final"],
"T6 [D1]: the hand-edited db_mode=remote was reverted to %r by an "
"unrelated component_policy change. `db_mode` stayed in dirty_keys "
"after write_config() persisted it, so the stale cached value was "
"overlaid onto the re-read file. Any key the user has ever changed "
"through the UI becomes permanently un-hand-editable for the life "
"of the process. On-disk file:\\n%s"
% (payload["db_mode_final"], payload["raw"]),
)
def test_t7_silent_read_failure_is_loud(self):
"""T7 / GOAL #53 [D1] — an UNREADABLE config.ini must not be wiped.
BORN-RED. `configparser.ConfigParser.read()` opens each filename inside
a `try/except OSError` and just SKIPS the ones it could not open, so a
file that EXISTS but denies reads leaves `read()` returning normally
with an EMPTY parser. `write_config()` then sees no `[default]`, takes
the BOOTSTRAP branch, and rewrites the file from the default set —
silently destroying the foreign key and the user's `[my_section]`,
while the settings endpoint answers 200.
The scenario is gm3-solver's measured boundary (GOAL #52 D2): reads
denied but writes allowed. When BOTH are denied the write itself
raises, so the failure is already loud and the file already survives;
this node pins the asymmetric case that is not.
"""
payload = _run_child(
"""
import builtins
write_ini(
"[default]\\nsecurity_level = normal\\ndb_mode = cache\\n"
"http_channel_enabled = true\\n\\n"
"[my_section]\\nmy_key = keepme\\n"
)
boot()
before = read_raw()
# Deny READS of config.ini while leaving WRITES allowed — the
# asymmetric permission shape gm3-solver reproduced live. Reads go
# through builtins.open, which is what configparser.read() calls
# and whose OSError it swallows.
_real_open = builtins.open
deny = {"on": False}
def guarded_open(file, mode="r", *args, **kwargs):
is_read = "r" in mode and "w" not in mode and "a" not in mode
if deny["on"] and is_read and os.path.abspath(str(file)) == os.path.abspath(CONFIG_PATH):
raise PermissionError(13, "Permission denied", str(file))
return _real_open(file, mode, *args, **kwargs)
builtins.open = guarded_open
raised = None
try:
deny["on"] = True
settings_change("db_mode", "local")
except Exception as e:
raised = "%s: %s" % (type(e).__name__, e)
finally:
deny["on"] = False
builtins.open = _real_open
after = read_raw()
cp = parse_disk()
print(json.dumps({
"raised": raised,
"unchanged": before == after,
"before": before,
"after": after,
"sections": cp.sections(),
"http_channel_enabled": cp["default"].get(
"http_channel_enabled", "<ABSENT>")
if cp.has_section("default") else "<NO DEFAULT>",
}))
"""
)
self.assertIsNotNone(
payload["raised"],
"T7 [D1]: write_config() returned SUCCESSFULLY on a config.ini it "
"could not read. config.read() suppressed the PermissionError, so "
"the empty parser routed the write into the bootstrap branch and "
"the settings change reports success while destroying the file. "
"Sections left on disk: %r\nOn-disk file after the write:\n%s"
% (payload["sections"], payload["after"]),
)
self.assertTrue(
payload["unchanged"],
"T7 [D1]: config.ini was MODIFIED by a settings change that could "
"not read it. A write that cannot see the current contents must "
"leave them alone.\n--- before ---\n%s\n--- after ---\n%s"
% (payload["before"], payload["after"]),
)
# Spell out the two losses the byte-comparison above rolls up, so a
# failure names the user-visible damage and not just "bytes differ".
self.assertIn("my_section", payload["sections"], "T7 [D1]: [my_section] was erased")
self.assertEqual(
"true", str(payload["http_channel_enabled"]).lower(),
"T7 [D1]: http_channel_enabled was erased",
)
if __name__ == "__main__": # pragma: no cover
unittest.main()