Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
946ef8fe65 | ||
|
|
572f363696 | ||
|
|
ea7534fae8 | ||
|
|
21ab2b78c2 | ||
|
|
97f123ef44 | ||
|
|
fe43918863 | ||
|
|
b4eebf2b8e | ||
|
|
41600906a5 | ||
|
|
87258e4465 | ||
|
|
eed2d54b8f | ||
|
|
c8fb99ff5f | ||
|
|
53daaef541 | ||
|
|
9d4cceea13 | ||
|
|
1c8a34d1a7 | ||
|
|
d0fd6da37d | ||
|
|
9ef6d2ec83 | ||
|
|
b62e30f2d0 | ||
|
|
1048c958b9 | ||
|
|
6c120969f1 | ||
|
|
32fed18c89 | ||
|
|
6833ab2b6e | ||
|
|
4064519adc | ||
|
|
d972e92c60 | ||
|
|
85124357c7 | ||
|
|
f6d38f4ce8 | ||
|
|
53cd0381b7 | ||
|
|
d000c1173a | ||
|
|
9c2ceda51f | ||
|
|
05b84d270c | ||
|
|
8f91f5791f | ||
|
|
e704f26391 | ||
|
|
8c3a210c91 | ||
|
|
f82970b7cb | ||
|
|
db65fda766 | ||
|
|
6e7fdb7802 | ||
|
|
e252bb97b8 | ||
|
|
896151d8dd | ||
|
|
ee63fdc136 | ||
|
|
a62c4e561b | ||
|
|
4caa942c5f | ||
|
|
f9b9d846ff | ||
|
|
b75fc664ec | ||
|
|
3b165d265f | ||
|
|
a73048da1e | ||
|
|
f6dd36ed9e | ||
|
|
d81cc6bf0d | ||
|
|
36b878b192 | ||
|
|
fc052d4a5f | ||
|
|
5feefbaa21 | ||
|
|
40d96759f1 | ||
|
|
8d1d221aa7 | ||
|
|
f39cbd56fe | ||
|
|
d243fda858 | ||
|
|
abaad8e28b | ||
|
|
d5992a117e | ||
|
|
4f56cf3dfa | ||
|
|
8970a942a8 | ||
|
|
1b765ac340 | ||
|
|
fe4d5d434c | ||
|
|
67ec5f5c36 | ||
|
|
d7c376bb88 | ||
|
|
7e08e10ec1 | ||
|
|
98be899011 | ||
|
|
4fe65b088e | ||
|
|
8950d89648 | ||
|
|
00c273dbdc | ||
|
|
14de630433 | ||
|
|
aaed26a5a4 | ||
|
|
2f3fc41a13 | ||
|
|
ffb2f03e92 | ||
|
|
8c46ea2921 | ||
|
|
7d2e499d85 | ||
|
|
55746905d0 | ||
|
|
ae31058747 | ||
|
|
97421c4965 | ||
|
|
67acc56924 | ||
|
|
d5d48e7c18 | ||
|
|
6ab1d9dd93 | ||
|
|
8c5e640f83 | ||
|
|
62441d65dd | ||
|
|
78bc5df653 | ||
|
|
150f23c210 | ||
|
|
4814b5d67a | ||
|
|
97399264a2 | ||
|
|
db1aa8d73a | ||
|
|
c975c0ae74 | ||
|
|
d47c934619 | ||
|
|
fe1193c0c8 | ||
|
|
1c3031d62f | ||
|
|
d404e6234a | ||
|
|
f124e5cfc6 | ||
|
|
25f519ed0c | ||
|
|
2b40deba7d | ||
|
|
89bf85f0a7 | ||
|
|
522a437bd8 | ||
|
|
1a445fa44c | ||
|
|
314814b336 | ||
|
|
3af9708ee1 | ||
|
|
8daa17d9dd | ||
|
|
4eea3a377b | ||
|
|
64e2f1d04a | ||
|
|
35b1ad3410 | ||
|
|
ebe429346a | ||
|
|
e4bc691b94 | ||
|
|
21dfd92181 | ||
|
|
9463115e78 | ||
|
|
4d29f36979 | ||
|
|
aa6dc09d67 | ||
|
|
fc131473bc | ||
|
|
616de87e96 | ||
|
|
2ce7556b3f | ||
|
|
7a66661e78 | ||
|
|
370e11487e | ||
|
|
7b60d5f988 | ||
|
|
6bd3d83db9 | ||
|
|
c4ed394ec8 | ||
|
|
7cb06a6890 | ||
|
|
3dbb7bddb8 | ||
|
|
b709d6c05b | ||
|
|
8ca022718f | ||
|
|
25c1b3d9a3 | ||
|
|
7955e638db | ||
|
|
9a7fe5a9e8 | ||
|
|
13f068ae6c | ||
|
|
35204b1521 | ||
|
|
9db9d43f28 | ||
|
|
579bf48edf | ||
|
|
04d3d0f25a | ||
|
|
c638fc20f4 | ||
|
|
3f159c5f65 | ||
|
|
d0d8611866 | ||
|
|
e634489a97 | ||
|
|
8a9ce02959 | ||
|
|
2a81de6e89 | ||
|
|
2112b6a569 | ||
|
|
faa719aaeb | ||
|
|
32284a7100 | ||
|
|
be017c40b8 | ||
|
|
79dd5e2358 | ||
|
|
c5c1e1bbc6 | ||
|
|
065070a32a | ||
|
|
a4a9dd54c5 | ||
|
|
9c9b882e51 | ||
|
|
7465cebf47 | ||
|
|
11f983fa36 | ||
|
|
9e73b6b41d | ||
|
|
bfae406a10 | ||
|
|
7b0ff1afb4 | ||
|
|
d5989d8a66 | ||
|
|
a217097707 | ||
|
|
1f288b2f3c | ||
|
|
93f694a9ee | ||
|
|
bd88cffa71 | ||
|
|
e3ea3e154a | ||
|
|
4ee1ab7991 | ||
|
|
8a55772ece | ||
|
|
071688ebb1 | ||
|
|
05a75a10b5 | ||
|
|
c58bf0d49c | ||
|
|
66a408ebb4 | ||
|
|
997b9e45f4 | ||
|
|
b669845f5e | ||
|
|
d192cb62c1 | ||
|
|
54014b770d | ||
|
|
351d9c62c4 | ||
|
|
297b0b96e9 | ||
|
|
df761bcf27 | ||
|
|
ba2516dc48 | ||
|
|
377f01ee2f | ||
|
|
347e37a376 | ||
|
|
7c2b54ff04 | ||
|
|
5154e3c070 | ||
|
|
ef8fa07a7d | ||
|
|
3c5a948239 | ||
|
|
15204133a3 | ||
|
|
c7b880c46c | ||
|
|
842bbd5104 | ||
|
|
ec3f3030f9 | ||
|
|
619eae0cd6 | ||
|
|
7ccc7742c7 | ||
|
|
f2af615314 | ||
|
|
7aef461132 | ||
|
|
90ba854b1e | ||
|
|
a2a90adc72 | ||
|
|
e11e9034ce | ||
|
|
fb75c048bd | ||
|
|
f7fd701e19 | ||
|
|
cf883eb762 | ||
|
|
45891097bb | ||
|
|
54bd1c3861 | ||
|
|
037987b917 | ||
|
|
8076c200fc | ||
|
|
69bad851e0 | ||
|
|
47df9c702c | ||
|
|
d6df033a21 | ||
|
|
d72007d1a8 | ||
|
|
79a5258dd2 | ||
|
|
80db5bd605 | ||
|
|
14fe358337 | ||
|
|
16a212aa70 | ||
|
|
6ad8a24845 | ||
|
|
ab8ce7814a | ||
|
|
99d3e6ddac | ||
|
|
84c889b026 | ||
|
|
966813eebf | ||
|
|
c305db27fe | ||
|
|
62cb417cc1 | ||
|
|
bdab3680c4 | ||
|
|
293ea3a8c5 | ||
|
|
5e6a02590b | ||
|
|
0f4df5347c | ||
|
|
9d7a3adc8a | ||
|
|
977ba03dbd | ||
|
|
5cb9c828ad | ||
|
|
28855800cd | ||
|
|
87882134e4 | ||
|
|
c903f8be4a | ||
|
|
267449c55d | ||
|
|
6178d27b01 | ||
|
|
4a21c02967 | ||
|
|
71fd0affad | ||
|
|
8e06ee6552 | ||
|
|
19e0bd6592 | ||
|
|
c4d775079b | ||
|
|
7e80e39fdb | ||
|
|
30e055f4f6 | ||
|
|
8183e23858 | ||
|
|
e99a2b7f4c | ||
|
|
ceac3168e4 | ||
|
|
c55592b332 | ||
|
|
afbc217f03 | ||
|
|
94080278a5 | ||
|
|
94a3ae8abd | ||
|
|
66b3266488 | ||
|
|
9fa5b6918d | ||
|
|
dbe14a93b9 | ||
|
|
675440b387 | ||
|
|
dba1b7742b | ||
|
|
c352b16bb1 | ||
|
|
a2274577a1 | ||
|
|
8da299d978 | ||
|
|
36aa47f8bf | ||
|
|
d35a732216 | ||
|
|
fedd2112e0 | ||
|
|
6dc448c813 | ||
|
|
673f4ac4f5 | ||
|
|
918919c6dc | ||
|
|
77baaecb76 | ||
|
|
049a53f3d5 | ||
|
|
8ff2ba8ffe | ||
|
|
b6db1ac854 | ||
|
|
f697116029 | ||
|
|
03861f48c9 | ||
|
|
579c4f9493 | ||
|
|
9e3a5695a7 | ||
|
|
f92ce59d4b | ||
|
|
d6c6ac2559 | ||
|
|
2e93040db5 | ||
|
|
6165515a08 | ||
|
|
0fde8b155b | ||
|
|
062a164e47 | ||
|
|
e7ab2f5858 | ||
|
|
92e3fa5062 | ||
|
|
90fdb64a67 | ||
|
|
b4cdeb2297 | ||
|
|
3a48b149bb | ||
|
|
e886d03d31 | ||
|
|
73e2f53d2c | ||
|
|
d6b1e4980a | ||
|
|
e4c5401dd5 | ||
|
|
88a7c52410 | ||
|
|
40aa097706 | ||
|
|
de560aed84 | ||
|
|
dc15dcf83c | ||
|
|
c780c600b5 | ||
|
|
eb479ff891 | ||
|
|
3772432847 | ||
|
|
86a7d88270 | ||
|
|
89809b29d3 | ||
|
|
194bcedcfc | ||
|
|
4ab3d1631e | ||
|
|
d60e603467 | ||
|
|
6428a1009d | ||
|
|
efa2a46689 | ||
|
|
c4daf1dffd | ||
|
|
396308854d | ||
|
|
4a1c06b90a | ||
|
|
395bb24427 | ||
|
|
25c1cc0921 | ||
|
|
629ccba819 | ||
|
|
3d01524ee9 | ||
|
|
81c3c3d62c | ||
|
|
1aa6112c57 | ||
|
|
c571cf3ca9 | ||
|
|
4085957308 | ||
|
|
7ddad11d28 | ||
|
|
142f4ce365 | ||
|
|
3ec831a99b | ||
|
|
2d373448be | ||
|
|
56a54117ab | ||
|
|
dfa45a67e0 | ||
|
|
0ccaf67ed9 | ||
|
|
73686b372b | ||
|
|
cb46064d25 | ||
|
|
a0f4d401ec | ||
|
|
d6f480c911 | ||
|
|
5976e39d80 | ||
|
|
7d611c051e | ||
|
|
65cf3dbf5c | ||
|
|
66617e061c | ||
|
|
c72bc4e171 | ||
|
|
ef470d0119 | ||
|
|
e633858794 | ||
|
|
765e64ff1d | ||
|
|
1bed21771f | ||
|
|
36144e5608 | ||
|
|
10f23c1d19 | ||
|
|
5b17ff01bd | ||
|
|
8814191992 | ||
|
|
b717647e9c | ||
|
|
0151751ab5 | ||
|
|
4bab2faa5e | ||
|
|
a0c4c78e9e | ||
|
|
bfec5e4ca8 | ||
|
|
bf5c346428 | ||
|
|
eb57f6f6b5 | ||
|
|
1d4d74f745 | ||
|
|
928870f598 | ||
|
|
7110ec1eaa | ||
|
|
2483471541 | ||
|
|
aa747eec52 | ||
|
|
da873b5f3b | ||
|
|
ade66eb61d | ||
|
|
703438251b | ||
|
|
7cbd62c8bd | ||
|
|
d0cc0e4fe9 | ||
|
|
9ad16c0188 | ||
|
|
a2c41a2a21 | ||
|
|
9690eed4d1 | ||
|
|
5d1d287735 | ||
|
|
65789a6c9d | ||
|
|
871a646fd7 | ||
|
|
27f81a058e | ||
|
|
af11571dd7 | ||
|
|
f59dd4bdc6 | ||
|
|
90b87db05c | ||
|
|
d6a9d6544d | ||
|
|
42b028362e | ||
|
|
dcf04578b8 | ||
|
|
3eceec5bfa | ||
|
|
2c0d665929 | ||
|
|
7fa009e9da | ||
|
|
7cf155b2fe | ||
|
|
350ff2d044 | ||
|
|
f64b73fc57 | ||
|
|
1958957d36 | ||
|
|
f3236c3031 | ||
|
|
fcbad506bb | ||
|
|
934fd2e091 | ||
|
|
5de92e4f92 | ||
|
|
3ae1fe6ea6 | ||
|
|
19f33695ff | ||
|
|
8ea1cf7aa7 | ||
|
|
936dc09f22 | ||
|
|
5a19558c01 | ||
|
|
2513b3bff1 | ||
|
|
919c07f1e1 | ||
|
|
8f646e92a0 | ||
|
|
8d9eeb93f4 | ||
|
|
4f7f26da3b | ||
|
|
299976330f | ||
|
|
3b2f6fd149 | ||
|
|
ea3dcde0ca | ||
|
|
c2a33d2efc | ||
|
|
d9ac595785 | ||
|
|
b15c12ca4e | ||
|
|
dc0f66f042 | ||
|
|
3d1210aa52 | ||
|
|
d16ba306be | ||
|
|
1e409cdd11 | ||
|
|
40e27f8eb4 | ||
|
|
6cc7f3106b | ||
|
|
e7e36be084 | ||
|
|
6c9dd4be42 | ||
|
|
8f4e7b089b | ||
|
|
02dcb1dd60 | ||
|
|
c0d5b4e720 | ||
|
|
72db8aed73 | ||
|
|
0802a88354 | ||
|
|
2b7aa428b0 | ||
|
|
d61ef2eaba | ||
|
|
fcc429e686 | ||
|
|
d255e36ab6 | ||
|
|
3e5c680f38 | ||
|
|
cba745327f | ||
|
|
8079db221d | ||
|
|
5727dc7c55 | ||
|
|
c06ff81821 | ||
|
|
10d48c2670 | ||
|
|
b824811bc7 | ||
|
|
f9603624a9 | ||
|
|
cb17d1c848 | ||
|
|
244421458d | ||
|
|
49218fdb57 | ||
|
|
df1505efda | ||
|
|
96ae4e0c9f | ||
|
|
f94902966a | ||
|
|
b4d5d972de | ||
|
|
3d7f7d2439 | ||
|
|
2ae10236b9 | ||
|
|
6ffead7ba9 | ||
|
|
9a17c96da6 | ||
|
|
39dfa902d5 | ||
|
|
e4fadee042 | ||
|
|
d9f1ff34a7 | ||
|
|
9aa2ca09b8 | ||
|
|
81605d31db | ||
|
|
bdd6111d9d | ||
|
|
a4b6a589d7 | ||
|
|
611532491f | ||
|
|
ed8baf3e79 | ||
|
|
111e4d38f4 | ||
|
|
e6693e0b9f | ||
|
|
0c8cdf9aa8 | ||
|
|
8d2d37f1f7 | ||
|
|
bc334c7aa5 | ||
|
|
33d4b29422 | ||
|
|
20d352c2b5 | ||
|
|
117227e2fc | ||
|
|
7a8fc44f90 | ||
|
|
49b038bf69 | ||
|
|
1eda1d65ad | ||
|
|
10ff7d091c | ||
|
|
282025ed0e | ||
|
|
2bcc052700 | ||
|
|
30454742b3 | ||
|
|
5162e1d601 | ||
|
|
8d5c12037f | ||
|
|
d370403e2d | ||
|
|
e67e35795e | ||
|
|
ccf6d98457 | ||
|
|
113324728e | ||
|
|
f2a3d39e26 | ||
|
|
ec72d983ad | ||
|
|
a3aabfd72c | ||
|
|
03272b1f70 | ||
|
|
9686bae08a | ||
|
|
9b8f651061 | ||
|
|
66108ccdbc | ||
|
|
d8095b527f | ||
|
|
80efc5dbc0 | ||
|
|
abf9c654b3 | ||
|
|
5656002524 | ||
|
|
484bb2fe4c | ||
|
|
a129cc6321 | ||
|
|
491f847bbc | ||
|
|
57972a5689 | ||
|
|
b71e239f70 | ||
|
|
d57c142019 | ||
|
|
5e475f6883 | ||
|
|
6a26409bcb | ||
|
|
bbc223a76c | ||
|
|
aba857a350 | ||
|
|
74c2518894 | ||
|
|
98e0098ddd | ||
|
|
23da19ea90 | ||
|
|
ed56212fa0 | ||
|
|
22fc850853 | ||
|
|
15fd6dbe37 | ||
|
|
f1863994f4 | ||
|
|
7c0fa1c581 | ||
|
|
dcb15d4dc9 | ||
|
|
6be5491d9e | ||
|
|
1e8956f42e | ||
|
|
d7adab215b | ||
|
|
6a895c7225 | ||
|
|
e6d2e947c3 | ||
|
|
1e8ebfe3db | ||
|
|
c30dd182d3 | ||
|
|
15ec9a901b | ||
|
|
e9fd8a3078 | ||
|
|
980f8f58af | ||
|
|
9b37dcfe42 | ||
|
|
2f1d02b688 | ||
|
|
685dca9749 | ||
|
|
03b54019ff | ||
|
|
92a47f59cf | ||
|
|
bae0ac11c5 | ||
|
|
f016eadd64 | ||
|
|
0f13355b08 | ||
|
|
bf5efd72ff | ||
|
|
0fc4649d23 | ||
|
|
3842e70fd9 | ||
|
|
0952dd1686 | ||
|
|
a7fb42be01 | ||
|
|
ee0f446ad0 | ||
|
|
b220733094 | ||
|
|
5079365034 | ||
|
|
454f24145e | ||
|
|
9d838d302f | ||
|
|
928cc80088 | ||
|
|
a54ab14c37 | ||
|
|
ba5aca7122 | ||
|
|
715c6c2426 | ||
|
|
e51a83fc8c | ||
|
|
1fc7b34f4a | ||
|
|
ba33ebe2dc | ||
|
|
d98ca19ef5 | ||
|
|
a9f7a6167e | ||
|
|
bbafbb1290 | ||
|
|
f08075c04d | ||
|
|
246d715838 | ||
|
|
2a55999552 | ||
|
|
c7da60732e | ||
|
|
d0e3336804 | ||
|
|
49f294bac5 | ||
|
|
347b13b01a | ||
|
|
07c0155929 | ||
|
|
7a0beec47e | ||
|
|
32c5f26838 | ||
|
|
0b7669a283 | ||
|
|
a1ade259ef | ||
|
|
3615af1560 | ||
|
|
8aca0751d1 | ||
|
|
c8a50de72c | ||
|
|
cbf8068515 | ||
|
|
0e0b371417 | ||
|
|
4669d4bca9 | ||
|
|
59ed61722b | ||
|
|
8c14349f97 | ||
|
|
14352e2c00 | ||
|
|
5a4e346f06 | ||
|
|
152bd7b4f7 | ||
|
|
d966c4ea67 | ||
|
|
846fddccb9 | ||
|
|
d69dc99d84 | ||
|
|
12e6f66d92 | ||
|
|
1d19a74ca0 | ||
|
|
37cac20c4e | ||
|
|
ad9e73837f | ||
|
|
00e81bb353 | ||
|
|
3f5eb4dabb | ||
|
|
5462fe7acc | ||
|
|
7427b1e3d7 | ||
|
|
8d750aac72 | ||
|
|
d85f035662 | ||
|
|
a2aace6a23 | ||
|
|
0e9c0baef9 | ||
|
|
b770f27099 | ||
|
|
96ffcbd0be | ||
|
|
04e12f4b3b | ||
|
|
23056a0eb8 | ||
|
|
794161d81d | ||
|
|
b2d6a4eefe | ||
|
|
216e8586bc | ||
|
|
0cbbe25d89 | ||
|
|
1bdcd1bdbf | ||
|
|
e04ed0eda7 | ||
|
|
02aa67b541 | ||
|
|
67d03530a3 | ||
|
|
a471263602 | ||
|
|
a062f6f459 | ||
|
|
6d87c3e981 | ||
|
|
acc11e1bda |
+3
-1
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+23634
-1998
File diff suppressed because it is too large
Load Diff
+15704
-7799
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -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
@@ -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
@@ -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. &) 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('\\[', '[').replace('\\]', ']').replace('<', '<').replace('>', '>')
|
||||
# NUL dropped first so the input cannot forge the href placeholders.
|
||||
input_text = input_text.replace('\x00', '')
|
||||
input_text = input_text.replace('\\[', '[').replace('\\]', ']')
|
||||
|
||||
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> ()"
|
||||
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
@@ -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('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
SAFE_URL_SCHEMES = frozenset({'http', 'https'})
|
||||
_URL_SCHEME_NOISE = re.compile(r'[\x00-\x20\x7f]')
|
||||
_URL_HEAD_DELIMITERS = ('/', '?', '#')
|
||||
|
||||
|
||||
def escape_html_attribute(value):
|
||||
"""Escape a value for a quoted HTML attribute."""
|
||||
return escape(str(value), quote=True)
|
||||
|
||||
|
||||
_HTML_ENTITY = re.compile(r'&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);')
|
||||
_HTML_TEXT_TOKEN = re.compile(_HTML_ENTITY.pattern + "|[&<>\"']")
|
||||
|
||||
|
||||
def unescape_html_entities(text):
|
||||
"""Decode complete entities once, leaving query keys such as ¬ebook intact."""
|
||||
def replace(match):
|
||||
entity = match.group(1)
|
||||
if entity.startswith('#'):
|
||||
return unescape(match.group(0))
|
||||
return html5.get(entity + ';', match.group(0))
|
||||
|
||||
return _HTML_ENTITY.sub(replace, text)
|
||||
|
||||
|
||||
def escape_html_text(value):
|
||||
"""Escape HTML text without double-escaping existing entities."""
|
||||
return _HTML_TEXT_TOKEN.sub(
|
||||
lambda m: m.group(0) if m.group(1) else escape_html_attribute(m.group(0)),
|
||||
str(value).replace('\x00', ''),
|
||||
)
|
||||
|
||||
|
||||
def sanitize_url(url):
|
||||
"""Allow HTTP(S), relative URLs and anchors.
|
||||
|
||||
HTML callers must also escape the result as an attribute."""
|
||||
raw = '' if url is None else str(url)
|
||||
probe = _URL_SCHEME_NOISE.sub('', raw)
|
||||
if not probe:
|
||||
return '#'
|
||||
|
||||
cut = len(probe)
|
||||
for delimiter in _URL_HEAD_DELIMITERS:
|
||||
found = probe.find(delimiter)
|
||||
if found != -1:
|
||||
cut = min(cut, found)
|
||||
head = probe[:cut]
|
||||
|
||||
if ':' in head:
|
||||
return raw.strip() if head.split(':', 1)[0].lower() in SAFE_URL_SCHEMES else '#'
|
||||
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def sanitize_html_fragment(fragment):
|
||||
"""Sanitize notice HTML, preserving its formatting and opening links safely."""
|
||||
# prestartup_script imports this module before installing dependencies.
|
||||
import nh3
|
||||
|
||||
return nh3.clean(
|
||||
'' if fragment is None else str(fragment),
|
||||
tags=nh3.ALLOWED_TAGS | {'font'},
|
||||
attributes={
|
||||
**nh3.ALLOWED_ATTRIBUTES,
|
||||
'*': {'class', 'title', 'align', 'width', 'height'},
|
||||
'font': {'color'},
|
||||
},
|
||||
# These elements previously hid their contents from the notice.
|
||||
clean_content_tags={
|
||||
'script', 'style', 'iframe', 'object', 'embed', 'svg', 'math',
|
||||
'template', 'noscript', 'textarea', 'title',
|
||||
},
|
||||
url_schemes=SAFE_URL_SCHEMES,
|
||||
set_tag_attribute_values={'a': {'target': '_blank'}},
|
||||
)
|
||||
|
||||
|
||||
def extract_package_as_zip(file_path, extract_path):
|
||||
import zipfile
|
||||
try:
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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!";
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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, "'");
|
||||
}
|
||||
|
||||
// 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";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 = ` ${data}`;
|
||||
data2.innerHTML = ` ${sanitizeHTML(data)}`;
|
||||
var data_button = document.createElement('td');
|
||||
data_button.style.textAlign = "center";
|
||||
data_button.className = "data-btns";
|
||||
|
||||
+385
@@ -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",
|
||||
|
||||
+1467
-547
File diff suppressed because it is too large
Load Diff
+2606
-955
File diff suppressed because it is too large
Load Diff
+2135
-1645
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+8833
-449
File diff suppressed because it is too large
Load Diff
+23634
-1998
File diff suppressed because it is too large
Load Diff
+212
-29
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -9,3 +9,5 @@ typing-extensions
|
||||
toml
|
||||
uv
|
||||
chardet
|
||||
packaging
|
||||
nh3>=0.3.7
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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:a",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"javascript:a",
|
||||
"http://e.com",
|
||||
"https://e.com/a?b=1&c=2",
|
||||
"HTTPS://E.COM",
|
||||
"/rel/path",
|
||||
"rel",
|
||||
"#anchor",
|
||||
"//proto/x",
|
||||
"a&b/c",
|
||||
"x&y?z=1",
|
||||
"e.com:8080/p",
|
||||
"foo:bar",
|
||||
":alert(1)",
|
||||
"",
|
||||
" ",
|
||||
"https://e.com/x\" onmouseover=\"y",
|
||||
"https://evil.example/a onmouseover=alert(1) x",
|
||||
"\\\\evil.com/x",
|
||||
"/\\evil.com",
|
||||
"\u0000javascript:a",
|
||||
"j\u0000avascript:a",
|
||||
"coap+tcp://h/x",
|
||||
"x-scheme://h",
|
||||
"a.b://h",
|
||||
"a-b://h",
|
||||
"HTTP+S://h",
|
||||
"//user:pass@evil.example/x",
|
||||
"#a:b",
|
||||
"?a=b:c",
|
||||
"/p:q",
|
||||
"http://h/#f:g",
|
||||
"http ://h",
|
||||
"javascript :a",
|
||||
"javascript:a",
|
||||
"http://h",
|
||||
"http://h/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"http://h/evil",
|
||||
"http:",
|
||||
"https:",
|
||||
"http:/h",
|
||||
":",
|
||||
"::",
|
||||
"a::b",
|
||||
" /rel/path ",
|
||||
" rel ",
|
||||
"\t#anchor\n",
|
||||
" http://h/x ",
|
||||
"\thttps://h/x\t",
|
||||
"http://h/x",
|
||||
"/rel/path",
|
||||
"https://h/x",
|
||||
"",
|
||||
" /rel ",
|
||||
"http://h/x
",
|
||||
"
http://h/x",
|
||||
"http://h/x\u001c",
|
||||
"http://h/x\u001d",
|
||||
"http://h/x\u001e",
|
||||
"http://h/x\u001f",
|
||||
"/rel/path
",
|
||||
"/rel/path\u001c",
|
||||
"https://h/x
",
|
||||
"http://h/x ",
|
||||
" http://h/x",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x
",
|
||||
"http://h/x
",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
"http://h/x ",
|
||||
" /rel",
|
||||
" /rel "
|
||||
],
|
||||
"gap_cases_found_by_enforcement": {
|
||||
"why": "Found by tests/test_url_sanitize_parity.py's own perturbation test, not by review: perturbing the JS scheme-less branch from `return raw.trim()` to `return raw` produced ZERO divergences against the handed-over 57, because every scheme-less case in that set is already trim-clean. The two trim call sites in sanitize_url were therefore unpinned on both sides.",
|
||||
"cases": [
|
||||
" /rel/path ",
|
||||
" rel ",
|
||||
"\t#anchor\n",
|
||||
" http://h/x ",
|
||||
"\thttps://h/x\t"
|
||||
]
|
||||
},
|
||||
"unicode_whitespace_divergence": {
|
||||
"why": "The 62-case matrix had no BOM or unicode-whitespace input, so the two trim call sites in sanitize_url were compared only over inputs whose edges are ASCII. They are NOT equivalent: String.prototype.trim() and str.strip() disagree on six codepoints.",
|
||||
"measured": {
|
||||
"js_strips_python_keeps": [
|
||||
"U+FEFF"
|
||||
],
|
||||
"python_strips_js_keeps": [
|
||||
"U+001C",
|
||||
"U+001D",
|
||||
"U+001E",
|
||||
"U+001F",
|
||||
"U+0085"
|
||||
],
|
||||
"method": "enumerated every codepoint below U+110000 on both sides: chr(c).isspace() in Python vs /\\s/-equivalent String.trim() in node; 24 shared, 6 divergent"
|
||||
},
|
||||
"example": "'http://h/x<U+FEFF>' -> python 'http://h/x<U+FEFF>', js 'http://h/x'",
|
||||
"resolution": "ALIGNED, not accepted. glob/manager_util.py is the declared source of truth for this pair (see test_the_guard_goes_red_when_either_allow_list_gains_a_scheme, which names it), so js/common.js stopped delegating to trim() and spells Python's str.strip() set out as URL_TRIM. The divergence is therefore a REGRESSION SHAPE this matrix now catches, not a gap_case.",
|
||||
"scope": "The trim only shapes the RETURNED string for an already-accepted URL. Scheme acceptance is decided on `probe`, which both sides build identically, so no case here changes what is allowed."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Executable
+169
@@ -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"
|
||||
Executable
+82
@@ -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)"
|
||||
Executable
+142
@@ -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"
|
||||
Executable
+101
@@ -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."
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Real ComfyUI HTTP/queue/install tests with a local CNR service and harmless ZIPs.
|
||||
|
||||
Run with E2E_ROOT pointing to the existing ComfyUI + venv fixture. Each server
|
||||
uses an isolated base directory; no installed nodes or user config are changed.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
E2E_ROOT = Path(os.environ.get('E2E_ROOT', '/nonexistent'))
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (E2E_ROOT / 'venv/bin/python').is_file(), reason='E2E_ROOT is required',
|
||||
)
|
||||
PACKS = ['fixture-active', 'fixture-flagged', 'fixture-pending', 'fixture-banned', 'fixture-latest', 'fixture-switch', 'fixture-reinstall', 'fixture-update', 'fixture-snapshot']
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def registry():
|
||||
calls = []
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
calls.append(parsed.path + ('?' + parsed.query if parsed.query else ''))
|
||||
parts = parsed.path.strip('/').split('/')
|
||||
version = parse_qs(parsed.query).get('version', ['2.0.0'])[0]
|
||||
node = parts[1] if len(parts) > 1 else ''
|
||||
base = f'http://127.0.0.1:{self.server.server_port}'
|
||||
|
||||
def metadata(node, version):
|
||||
status = {'1.0.0': 'Active', '1.1.0': 'Active', '2.0.0': 'Flagged', '3.0.0': 'Pending'}[version]
|
||||
return {'id': f'{node}-{version}', 'node_id': node, 'version': version,
|
||||
'status': 'NodeVersionStatus' + status, 'dependencies': [],
|
||||
'downloadUrl': f'{base}/zip/{node}/{version}'}
|
||||
|
||||
status = 200
|
||||
if parts[0] == 'zip':
|
||||
node, version = parts[1:]
|
||||
archive = io.BytesIO()
|
||||
with zipfile.ZipFile(archive, 'w') as z:
|
||||
z.writestr('__init__.py', 'NODE_CLASS_MAPPINGS = {}\n')
|
||||
z.writestr('pyproject.toml', f'[project]\nname = "{node}"\nversion = "{version}"\n')
|
||||
z.writestr('install.py', 'from pathlib import Path\n'
|
||||
f'Path("installed.txt").write_text("{version}")\n')
|
||||
body = archive.getvalue()
|
||||
elif parsed.path == '/nodes':
|
||||
body = json.dumps({'nodes': [
|
||||
{'id': name, 'name': name, 'description': '', 'status': 'NodeStatusActive',
|
||||
'repository': f'https://example.invalid/{name}',
|
||||
'publisher': {'id': 'fixture', 'name': 'Fixture'},
|
||||
'latest_version': metadata(name, '2.0.0')}
|
||||
for name in PACKS], 'totalPages': 1}).encode()
|
||||
elif len(parts) == 3 and parts[0] == 'nodes' and parts[2] == 'install':
|
||||
if node == 'fixture-banned':
|
||||
status, body = 404, b'{"message":"Not found"}'
|
||||
else:
|
||||
body = json.dumps(metadata(node, version)).encode()
|
||||
else:
|
||||
status, body = 404, b'{}'
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Content-Type', 'application/zip' if parts[0] == 'zip' else 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield SimpleNamespace(url=f'http://127.0.0.1:{server.server_port}', calls=calls)
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join()
|
||||
|
||||
|
||||
# Redirect only the external Registry in both ComfyUI and its restore subprocess.
|
||||
REGISTRY_BOOTSTRAP = '''
|
||||
import asyncio, json, os, sys, urllib.request
|
||||
sys.path.insert(0, os.environ['CM_E2E_MANAGER_GLOB'])
|
||||
import manager_core, cnr_utils, manager_util
|
||||
cnr_utils.base_url = os.environ['CM_E2E_REGISTRY']
|
||||
with urllib.request.urlopen(cnr_utils.base_url + '/nodes') as response:
|
||||
manager_util.save_to_cache(cnr_utils.base_url + '/nodes', json.load(response))
|
||||
asyncio.run(manager_core.unified_manager.reload('cache', dont_wait=False))
|
||||
manager_core.unified_manager.custom_node_map_cache[(manager_core.normalize_channel('default'), 'cache')] = manager_core.NormalizedKeyDict()
|
||||
'''
|
||||
BOOTSTRAP = '''
|
||||
import os, runpy, sys
|
||||
sys.argv = [os.environ['CM_E2E_MAIN'], *sys.argv[1:]]
|
||||
import comfy.options
|
||||
comfy.options.enable_args_parsing()
|
||||
''' + REGISTRY_BOOTSTRAP + "\nrunpy.run_path(sys.argv[0], run_name='__main__')\n"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def running_server(root, listen, override, registry, security='normal'):
|
||||
custom_nodes = root / 'custom_nodes'
|
||||
custom_nodes.mkdir(exist_ok=True)
|
||||
mount = custom_nodes / 'comfyui-manager'
|
||||
if not mount.exists():
|
||||
mount.symlink_to(REPO_ROOT, target_is_directory=True)
|
||||
config_dir = root / 'user/__manager'
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / 'config.ini').write_text(
|
||||
'[default]\nnetwork_mode = offline\nuse_uv = false\n'
|
||||
f'security_level = {security}\nallow_flagged_nodepack_install = {override}\n'
|
||||
)
|
||||
# cm-cli.py inherits this test-only IO redirection from its real parent.
|
||||
hook = root / 'sitecustomize.py'
|
||||
hook.write_text("import os, sys\nif os.path.basename(sys.argv[0]) == 'cm-cli.py':\n"
|
||||
" from comfy.cli_args import args\n"
|
||||
" args.base_directory = os.environ['CM_E2E_BASE']\n"
|
||||
+ '\n'.join(' ' + line for line in REGISTRY_BOOTSTRAP.splitlines()))
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
port = sock.getsockname()[1]
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
env = dict(os.environ, PYTHONUNBUFFERED='1',
|
||||
PYTHONPATH=os.pathsep.join([str(root), str(E2E_ROOT / 'comfyui')]),
|
||||
COMFYUI_PATH=str(E2E_ROOT / 'comfyui'), COMFYUI_FOLDERS_BASE_PATH=str(root),
|
||||
CM_E2E_BASE=str(root), CM_E2E_MAIN=str(E2E_ROOT / 'comfyui/main.py'),
|
||||
CM_E2E_MANAGER_GLOB=str(REPO_ROOT / 'glob'), CM_E2E_REGISTRY=registry.url)
|
||||
command = [str(E2E_ROOT / 'venv/bin/python'), '-c', BOOTSTRAP, '--cpu',
|
||||
'--base-directory', str(root), '--listen', listen, '--port', str(port),
|
||||
'--database-url', f'sqlite:///{root / "comfyui.db"}']
|
||||
log_path = root / ('server-' + uuid.uuid4().hex + '.log')
|
||||
with log_path.open('w') as log:
|
||||
process = subprocess.Popen(command, env=env, cwd=E2E_ROOT / 'comfyui', stdout=log, stderr=subprocess.STDOUT)
|
||||
try:
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
assert process.poll() is None, log_path.read_text()[-6000:]
|
||||
try:
|
||||
if requests.get(base + '/system_stats', timeout=1).status_code == 200:
|
||||
break
|
||||
except requests.RequestException:
|
||||
pass
|
||||
time.sleep(.2)
|
||||
else:
|
||||
pytest.fail(log_path.read_text()[-6000:])
|
||||
assert requests.get(base + '/manager/version', timeout=5).status_code == 200, log_path.read_text()[-6000:]
|
||||
assert 'PRESTARTUP FAILED' not in log_path.read_text(), log_path.read_text()
|
||||
yield SimpleNamespace(root=root, base=base, listen=listen, override=override,
|
||||
security=security, registry=registry, log=log_path, env=env)
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['install', 'restore-snapshot'])
|
||||
def test_direct_cli_installs_flagged_without_server_options(tmp_path, registry, operation):
|
||||
with running_server(tmp_path, '0.0.0.0', False, registry) as server:
|
||||
env = server.env
|
||||
command = [str(E2E_ROOT / 'venv/bin/python'), str(REPO_ROOT / 'cm-cli.py'), operation]
|
||||
if operation == 'install':
|
||||
command += ['fixture-flagged@2.0.0', '--mode', 'cache']
|
||||
else:
|
||||
snapshot = tmp_path / 'direct-snapshot.json'
|
||||
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-flagged': '2.0.0'},
|
||||
'git_custom_nodes': {}, 'file_custom_nodes': []}))
|
||||
command.append(str(snapshot))
|
||||
command += ['--user-directory', str(tmp_path / 'user')]
|
||||
before = len(registry.calls)
|
||||
|
||||
result = subprocess.run(command, env=env, cwd=E2E_ROOT / 'comfyui',
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
assert result.returncode == 0, output
|
||||
installed = tmp_path / 'custom_nodes/fixture-flagged'
|
||||
assert 'version = "2.0.0"' in (installed / 'pyproject.toml').read_text(), output
|
||||
if operation == 'install':
|
||||
assert (installed / 'installed.txt').read_text() == '2.0.0', output
|
||||
assert sum('/install' in call for call in registry.calls[before:]) == 1
|
||||
assert 'Installation of this flagged CNR version is blocked' not in output
|
||||
|
||||
|
||||
@pytest.fixture(scope='module', params=[
|
||||
('127.0.0.1', False, 'normal'), ('0.0.0.0', False, 'normal'),
|
||||
('0.0.0.0', True, 'normal'), ('0.0.0.0', True, 'strong'),
|
||||
('127.0.0.1', False, 'strong'),
|
||||
], ids=lambda row: '-'.join(map(str, row)))
|
||||
def comfy_server(request, tmp_path_factory, registry):
|
||||
listen, override, security = request.param
|
||||
root = tmp_path_factory.mktemp('flagged-v3')
|
||||
with running_server(root, listen, override, registry, security) as server:
|
||||
yield server
|
||||
|
||||
|
||||
def install(server, node, version, operation='install'):
|
||||
async def request_and_wait():
|
||||
ui_id = uuid.uuid4().hex
|
||||
params = {'id': node, 'version': '1.0.0', 'selected_version': version,
|
||||
'mode': 'cache', 'channel': 'default', 'ui_id': ui_id,
|
||||
'repository': 'https://example.invalid/fixture'}
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=40)) as session:
|
||||
# Subscribe first: a fast worker can emit completion before POST returns.
|
||||
async with session.ws_connect(server.base + '/ws?clientId=' + ui_id) as ws:
|
||||
async with session.post(server.base + '/manager/queue/' + operation, json=params) as response:
|
||||
if response.status != 200:
|
||||
return {'http': response.status, 'message': await response.text()}
|
||||
async with session.post(server.base + '/manager/queue/start') as response:
|
||||
response.raise_for_status()
|
||||
while True:
|
||||
event = await ws.receive_json(timeout=30)
|
||||
data = event.get('data', {})
|
||||
if event.get('type') == 'cm-queue-status' and data.get('status') == 'done':
|
||||
if ui_id in data.get('nodepack_result', {}):
|
||||
return {'http': 200, 'message': data['nodepack_result'][ui_id]}
|
||||
return asyncio.run(request_and_wait())
|
||||
|
||||
|
||||
def assert_terminal_guidance(log):
|
||||
for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]',
|
||||
'allow_flagged_nodepack_install = true', 'config.ini',
|
||||
'trusted private network', 'Restart ComfyUI'):
|
||||
assert detail in log
|
||||
|
||||
|
||||
def assert_flagged_denial(result, log):
|
||||
assert result['message'] == ('This action is not allowed by the current security configuration. '
|
||||
'See the terminal for details.'), result
|
||||
assert_terminal_guidance(log)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('node,version,flagged', [
|
||||
('fixture-active', '1.0.0', False), ('fixture-flagged', '2.0.0', True),
|
||||
('fixture-pending', '3.0.0', False), ('fixture-banned', '1.0.0', False),
|
||||
('fixture-latest', 'latest', True),
|
||||
])
|
||||
def test_install_policy(comfy_server, node, version, flagged):
|
||||
server = comfy_server
|
||||
log_before = len(server.log.read_text())
|
||||
before = len(server.registry.calls)
|
||||
result = install(server, node, version)
|
||||
calls = server.registry.calls[before:]
|
||||
strong = server.security == 'strong'
|
||||
allowed = not strong and node != 'fixture-banned' and (
|
||||
not flagged or server.override or server.listen == '127.0.0.1')
|
||||
marker = server.root / 'custom_nodes' / node / 'installed.txt'
|
||||
assert marker.exists() is allowed, (result, server.log.read_text()[-6000:])
|
||||
assert sum('/install' in call for call in calls) == (0 if strong else 1), calls
|
||||
assert any(call.startswith('/zip/') for call in calls) is allowed
|
||||
if allowed:
|
||||
assert result['message'] == 'success', result
|
||||
assert marker.read_text() == ('2.0.0' if version == 'latest' else version)
|
||||
elif strong:
|
||||
assert result['http'] == 403, result
|
||||
elif flagged:
|
||||
assert_flagged_denial(result, server.log.read_text()[log_before:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation', ['install', 'reinstall', 'update'])
|
||||
def test_existing_pack_policy(comfy_server, operation):
|
||||
server = comfy_server
|
||||
if server.security == 'strong':
|
||||
pytest.skip('Existing-pack transitions require an installed pack')
|
||||
node = {'install': 'fixture-switch', 'reinstall': 'fixture-reinstall', 'update': 'fixture-update'}[operation]
|
||||
assert install(server, node, '1.0.0')['message'] == 'success'
|
||||
marker = server.root / 'custom_nodes' / node / 'installed.txt'
|
||||
log_before = len(server.log.read_text())
|
||||
before = len(server.registry.calls)
|
||||
result = install(server, node, '2.0.0', operation)
|
||||
calls = server.registry.calls[before:]
|
||||
allowed = server.override or server.listen == '127.0.0.1'
|
||||
assert sum('/install' in call for call in calls) == 1, calls
|
||||
if allowed:
|
||||
assert result['message'] == 'success', result
|
||||
if operation == 'reinstall':
|
||||
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
|
||||
else:
|
||||
assert marker.read_text() == '1.0.0'
|
||||
assert 'NodeVersionStatusFlagged' in (server.root / 'user/__manager/startup-scripts/install-scripts.txt').read_text()
|
||||
else:
|
||||
assert_flagged_denial(result, server.log.read_text()[log_before:])
|
||||
assert marker.read_text() == '1.0.0'
|
||||
assert any(call.startswith('/zip/') for call in calls) is (allowed and operation == 'reinstall')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('restart_listen,restart_override,allowed', [
|
||||
('0.0.0.0', False, False), ('0.0.0.0', True, True), ('127.0.0.1', False, True),
|
||||
])
|
||||
@pytest.mark.parametrize('stored_status', [True, False])
|
||||
def test_restart_uses_current_policy(tmp_path, registry, restart_listen, restart_override, allowed, stored_status):
|
||||
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
|
||||
assert install(server, 'fixture-switch', '1.0.0')['message'] == 'success'
|
||||
assert install(server, 'fixture-switch', '2.0.0')['message'] == 'success'
|
||||
if not stored_status:
|
||||
reservation = tmp_path / 'user/__manager/startup-scripts/install-scripts.txt'
|
||||
record = ast.literal_eval(reservation.read_text().strip())
|
||||
assert record[1] == '#LAZY-CNR-SWITCH-SCRIPT'
|
||||
assert len(record) == 9
|
||||
reservation.write_text(repr(record[:8]) + '\n')
|
||||
marker = tmp_path / 'custom_nodes/fixture-switch/installed.txt'
|
||||
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
|
||||
before = len(registry.calls)
|
||||
with running_server(tmp_path, restart_listen, restart_override, registry) as server:
|
||||
calls = registry.calls[before:]
|
||||
assert not any('/install' in call for call in calls), calls
|
||||
assert any(call.startswith('/zip/') for call in calls) is allowed
|
||||
if allowed:
|
||||
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
|
||||
else:
|
||||
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
|
||||
if stored_status:
|
||||
assert_terminal_guidance(server.log.read_text())
|
||||
else:
|
||||
assert 'stored Registry status is missing' in server.log.read_text()
|
||||
assert 'Request the installation again' in server.log.read_text()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('surface,payload,flag', [
|
||||
('git_url', {'url': 'https://example.invalid/fixture'}, 'allow_git_url_install'),
|
||||
('pip', {'packages': 'fixture-do-not-install'}, 'allow_pip_install'),
|
||||
])
|
||||
def test_other_install_flags_remain_required(comfy_server, surface, payload, flag):
|
||||
server = comfy_server
|
||||
before = len(server.registry.calls)
|
||||
response = requests.post(server.base + '/customnode/install/' + surface, json=payload, timeout=10)
|
||||
assert response.status_code == 403, response.text
|
||||
assert response.json() == {'error': flag}
|
||||
assert server.registry.calls[before:] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('existing', [False, True])
|
||||
@pytest.mark.parametrize('version,listen,override,allowed', [
|
||||
('1.1.0', '0.0.0.0', False, True),
|
||||
('2.0.0', '0.0.0.0', False, False),
|
||||
('2.0.0', '0.0.0.0', True, True),
|
||||
('2.0.0', '127.0.0.1', False, True),
|
||||
])
|
||||
def test_scheduled_snapshot_inherits_parent_policy(tmp_path, registry, existing, version, listen, override, allowed):
|
||||
installed = tmp_path / 'custom_nodes/fixture-snapshot'
|
||||
marker = installed / 'installed.txt'
|
||||
reservation = tmp_path / 'user/__manager/startup-scripts/restore-snapshot.json'
|
||||
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
|
||||
if existing:
|
||||
assert install(server, 'fixture-snapshot', '1.0.0')['message'] == 'success'
|
||||
assert marker.read_text() == '1.0.0'
|
||||
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
|
||||
snapshot = tmp_path / 'user/__manager/snapshots/flagged-policy.json'
|
||||
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-snapshot': version},
|
||||
'git_custom_nodes': {}, 'file_custom_nodes': []}))
|
||||
before = len(registry.calls)
|
||||
response = requests.post(server.base + '/snapshot/restore',
|
||||
json={'target': snapshot.stem}, timeout=10)
|
||||
assert response.status_code == 200, response.text
|
||||
assert reservation.read_bytes() == snapshot.read_bytes()
|
||||
assert registry.calls[before:] == []
|
||||
if existing:
|
||||
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
|
||||
else:
|
||||
assert not installed.exists()
|
||||
|
||||
before = len(registry.calls)
|
||||
with running_server(tmp_path, listen, override, registry) as server:
|
||||
log = server.log.read_text()
|
||||
calls = registry.calls[before:]
|
||||
assert 'Restore snapshot done.' in log, log[-6000:]
|
||||
assert 'Restore snapshot failed.' not in log, log[-6000:]
|
||||
assert 'Error in sitecustomize' not in log, log[-6000:]
|
||||
assert not reservation.exists()
|
||||
assert sum('/install' in call for call in calls) == 1, (calls, log[-6000:])
|
||||
assert any(call.startswith('/zip/') for call in calls) is allowed
|
||||
if allowed:
|
||||
assert f'version = "{version}"' in (installed / 'pyproject.toml').read_text(), log[-6000:]
|
||||
assert 'allow_flagged_nodepack_install' not in log
|
||||
else:
|
||||
assert_terminal_guidance(log)
|
||||
if existing:
|
||||
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
|
||||
assert '1.0.0' in (installed / 'pyproject.toml').read_text()
|
||||
else:
|
||||
assert not installed.exists()
|
||||
@@ -0,0 +1,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"]))
|
||||
@@ -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")]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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> & Beta'};
|
||||
const matched = grid.highlightKeywordsFilter(row, ['description'], 'alpha & beta');
|
||||
return {matched, text: row.tg_text_description, highlight: row.tg_highlight_description};
|
||||
}""")
|
||||
assert result == {"matched": True, "text": "Alpha & Beta", "highlight": True}
|
||||
page.wait_for_timeout(50)
|
||||
assert page.evaluate("window.__probe === undefined")
|
||||
|
||||
|
||||
def test_search_preserves_order_columns_and_empty_query(page):
|
||||
result = page.evaluate("""() => {
|
||||
const row = {name: 'Alpha Beta', description: 'Gamma', missing: null, count: 0};
|
||||
const columns = ['name', 'description', 'missing', 'count'];
|
||||
const ordered = grid.highlightKeywordsFilter(row, columns, ' ALPHA beta ');
|
||||
const reversed = grid.highlightKeywordsFilter(row, columns, 'beta alpha');
|
||||
const acrossColumns = grid.highlightKeywordsFilter(row, columns, 'alpha gamma');
|
||||
const zero = grid.highlightKeywordsFilter(row, columns, '0');
|
||||
const empty = grid.highlightKeywordsFilter(row, columns, '');
|
||||
return {ordered, reversed, acrossColumns, zero, empty,
|
||||
flags: columns.map(column => row['tg_highlight_' + column])};
|
||||
}""")
|
||||
assert result == {"ordered": True, "reversed": False, "acrossColumns": False,
|
||||
"zero": True, "empty": True, "flags": [None] * 4}
|
||||
|
||||
|
||||
def test_custom_text_generator_and_cache_keys(page):
|
||||
result = page.evaluate("""() => {
|
||||
Object.assign(grid.options.highlightKeywords, {
|
||||
textKey: 'text_', highlightKey: 'mark_',
|
||||
textGenerator: (row, column) => row[column].label
|
||||
});
|
||||
const row = {name: {label: '<b>Model</b> <Flux>'}};
|
||||
const matched = grid.highlightKeywordsFilter(row, ['name'], 'model <flux>');
|
||||
return {matched, text: row.text_name, highlight: row.mark_name};
|
||||
}""")
|
||||
assert result == {"matched": True, "text": "Model <Flux>", "highlight": True}
|
||||
|
||||
|
||||
def test_grid_render_search_and_clear_keep_names_readable(page):
|
||||
page.evaluate("""() => {
|
||||
window.query = 'flux';
|
||||
grid.setOption({rowFilter: row => grid.highlightKeywordsFilter(row, ['name'], window.query)});
|
||||
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: [
|
||||
{name: 'Model <Flux> <img src=x onerror=window.__probe=1>'},
|
||||
{name: 'Other Model'}
|
||||
]});
|
||||
grid.render();
|
||||
}""")
|
||||
page.wait_for_function("document.querySelectorAll('#grid mark').length === 1")
|
||||
assert page.locator("#grid mark").all_text_contents() == ["Flux"]
|
||||
assert page.evaluate("grid.viewRows.length") == 1
|
||||
assert page.locator("#grid img").count() == 0
|
||||
assert 'Model <Flux> <img src=x onerror=window.__probe=1>' in page.locator("#grid").inner_text()
|
||||
|
||||
page.evaluate("window.query = 'other'; grid.update()")
|
||||
page.wait_for_function("document.querySelector('#grid mark')?.textContent === 'Other'")
|
||||
assert page.evaluate("grid.viewRows.length") == 1
|
||||
|
||||
page.evaluate("window.query = ''; grid.update()")
|
||||
page.wait_for_function("grid.viewRows.length === 2 && !document.querySelector('#grid mark')")
|
||||
assert page.locator("#grid img").count() == 0
|
||||
assert page.evaluate("window.__probe === undefined")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,column,value,query,nonmatching", [
|
||||
("custom-nodes-manager.js", "title", "Pack <Flux>", "<flux>", "<flux>"),
|
||||
("custom-nodes-manager.js", "description", "Notes <Flux>", "<flux>", "<flux>"),
|
||||
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "<alias>"),
|
||||
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "<alias>"),
|
||||
("model-manager.js", "name", "Model <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "description", "<b>Notes</b> <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "filename", "model <Flux>", "<flux>", "<flux>"),
|
||||
("model-manager.js", "type", "Type <Flux>", "<flux>", "<flux>"),
|
||||
])
|
||||
def test_manager_search_matches_visible_text(page, filename, column, value, query, nonmatching):
|
||||
source = (JS / filename).read_text()
|
||||
init_body = source.split("\tinitGrid() {", 1)[1].split("\n\t}\n", 1)[0]
|
||||
common = (JS / "common.js").read_text()
|
||||
sanitizer = common.split("export function sanitizeHTML(str) {", 1)[1].split("\n}", 1)[0]
|
||||
escape_cell = "undefined"
|
||||
if filename == "model-manager.js":
|
||||
escape_cell = source.split("const escapeCell = ", 1)[1].split(";", 1)[0]
|
||||
page.evaluate("""({init_body, sanitizer, escape_cell, column, value, query}) => {
|
||||
const sanitizeHTML = new Function('str', sanitizer);
|
||||
const escapeCell = new Function('sanitizeHTML', `return (${escape_cell})`)(sanitizeHTML);
|
||||
const initGrid = new Function('ManagerGrid', 'createFlyover', 'gridId', 'sanitizeHTML', 'escapeCell',
|
||||
`return function initGrid() {${init_body}}`)(grid.constructor, () => ({}), 'review', sanitizeHTML, escapeCell);
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = '<div id="tested-grid" class="cn-manager-grid cmm-manager-grid" style="width:800px;height:300px"></div>';
|
||||
document.body.append(element);
|
||||
window.manager = {element, keywords: query, showStatus() {}, handleFlyoverHover() {}, createFlyover: () => ({}), hasAlternatives: () => false};
|
||||
initGrid.call(manager);
|
||||
manager.grid.setData({columns: [{id: column, name: column}], rows: [{[column]: value}]});
|
||||
manager.grid.render();
|
||||
}""", {"init_body": init_body, "sanitizer": sanitizer, "escape_cell": escape_cell,
|
||||
"column": column, "value": value, "query": query})
|
||||
page.wait_for_function("manager.grid.viewRows !== undefined")
|
||||
assert page.evaluate("manager.grid.viewRows.length") == 1
|
||||
page.evaluate("query => {manager.keywords=query;}", nonmatching)
|
||||
assert not page.evaluate("manager.grid.options.rowFilter(manager.grid.data.rows[0])")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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&b=2'", html)
|
||||
self.assertIn(">the docs</a>", html)
|
||||
self.assertIn("target='_blank'", html)
|
||||
|
||||
def test_link_text_is_escaped_once(self):
|
||||
for label in ["a <b> tag", "a <b> tag"]:
|
||||
with self.subTest(label=label):
|
||||
html = CONVERT(f"[a/{label}](https://example.com)")
|
||||
self.assertIn("<b>", html)
|
||||
self.assertNotIn("&lt;", html)
|
||||
|
||||
def test_prose_and_link_labels_are_safe_inside_formatted_notes(self):
|
||||
item = {"description": '<img src=x> [w/Read [a/**<Flux>**](https://example.com/?q=<Flux>)]\n<img src=y>'}
|
||||
load_markdown_functions(MANAGER_UTIL)["populate_markdown"](item)
|
||||
html = item["description"]
|
||||
self.assertEqual(_parse_element_names(html), ["p", "a", "b", "br"])
|
||||
self.assertIn("<B><Flux></B>", html)
|
||||
self.assertIn("<img src=x>", html)
|
||||
self.assertIn("<img src=y>", html)
|
||||
self.assertEqual(_parse_anchors(html)[0]["href"], "https://example.com/?q=<Flux>")
|
||||
|
||||
|
||||
class TestDescriptionUrlRoundTrip(unittest.TestCase):
|
||||
def test_description_urls_are_escaped_once(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
cases = [
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?a=1&b=2", "https://example.com/?a=1&b=2"),
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
|
||||
("https://example.com/?a=1¬ebook=2©=3", "https://example.com/?a=1¬ebook=2©=3"),
|
||||
("https://example.com/?q=¬it;", "https://example.com/?q=¬it;"),
|
||||
("https://example.com/?q=&lt;", "https://example.com/?q=<"),
|
||||
("https://example.com/a**b**c?x=1&y=2", "https://example.com/a**b**c?x=1&y=2"),
|
||||
]
|
||||
for url, expected in cases:
|
||||
with self.subTest(url=url):
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
anchors = _parse_anchors(item["description"])
|
||||
self.assertEqual(anchors[0]["href"], expected)
|
||||
self.assertEqual(_parse_element_names(item["description"]), ["a"])
|
||||
|
||||
def test_decoded_entities_cannot_bypass_url_or_attribute_checks(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
for url in ["javascript:alert`1`", "javascript:alert`1`", "java	script:alert`1`"]:
|
||||
with self.subTest(url=url):
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
self.assertEqual(_parse_anchors(item["description"])[0]["href"], "#")
|
||||
|
||||
url = "https://example.com/x' onmouseover='alert`1`"
|
||||
item = {"description": f"[a/link]({url})"}
|
||||
populate(item)
|
||||
anchor = _parse_anchors(item["description"])[0]
|
||||
self.assertEqual(anchor["href"], "https://example.com/x' onmouseover='alert`1`")
|
||||
self.assertEqual(set(anchor), {"href", "target", "rel"})
|
||||
|
||||
|
||||
class TestEscapedHrefSurvivesTheLaterPasses(unittest.TestCase):
|
||||
"""The escaper's output must still be intact by the time it is returned.
|
||||
|
||||
replace_a runs before the %%..%% / **..** / [w/..] / [i/..] passes, and those
|
||||
passes used to rewrite the INSIDE of the href it had just secured — putting
|
||||
<font>/<B>/<p> markup and fresh single quotes back into the attribute. That
|
||||
both corrupted legitimate URLs and undid the escaping.
|
||||
"""
|
||||
|
||||
def test_percent_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/%%A%%)")
|
||||
self.assertIn("href='https://e.com/%%A%%'", html)
|
||||
self.assertNotIn("<font", html)
|
||||
|
||||
def test_bold_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/a**b**c)")
|
||||
self.assertIn("href='https://e.com/a**b**c'", html)
|
||||
self.assertNotIn("<B>", html)
|
||||
|
||||
def test_note_markers_in_a_url_leave_the_href_intact(self):
|
||||
html = CONVERT("[a/t](https://e.com/[w/A])")
|
||||
self.assertIn("href='https://e.com/[w/A]'", html)
|
||||
self.assertNotIn("cm-warn-note", html)
|
||||
|
||||
def test_no_element_leaks_out_of_a_url(self):
|
||||
# Parsed rather than string-matched: the anchor must be the ONLY element.
|
||||
html = CONVERT("[a/t](https://e.com/%%A%%and**B**)")
|
||||
self.assertEqual(_parse_element_names(html), ["a"])
|
||||
|
||||
def test_nested_markdown_in_the_LINK_TEXT_still_renders(self):
|
||||
# Protecting the href must not cost the label. Moving the anchor
|
||||
# substitution last would have escaped these into visible mojibake.
|
||||
self.assertIn("<B>bold</B>", CONVERT("[a/**bold** text](https://e.com)"))
|
||||
self.assertIn("<font color='white'>hi</font>", CONVERT("[a/%%hi%% there](https://e.com)"))
|
||||
|
||||
def test_markup_outside_a_link_is_unaffected(self):
|
||||
html = CONVERT("%%white%% and **bold** and [w/warn]")
|
||||
self.assertIn("<font color='white'>white</font>", html)
|
||||
self.assertIn("<B>bold</B>", html)
|
||||
self.assertIn("cm-warn-note", html)
|
||||
|
||||
def test_the_newline_pass_cannot_reach_inside_the_href(self):
|
||||
# The newline substitution is the LAST thing convert_markdown_to_html
|
||||
# does, so restoring the href before it let '<BR>' be injected into the
|
||||
# attribute the escaper had secured. Not exploitable — '<BR>' carries no
|
||||
# quote — but it is the same class of breach this function exists to fix,
|
||||
# so the invariant is pinned all the way to the returned string.
|
||||
html = CONVERT("[a/t](https://e.com/a\nb)")
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1)
|
||||
self.assertNotIn("<BR>", anchors[0]["href"])
|
||||
self.assertEqual(_parse_element_names(html), ["a"])
|
||||
|
||||
def test_newlines_outside_a_link_still_become_line_breaks(self):
|
||||
html = CONVERT("first\nsecond")
|
||||
self.assertIn("<BR>", html)
|
||||
|
||||
def test_a_forged_placeholder_in_the_input_cannot_hijack_restoration(self):
|
||||
html = CONVERT("\x00H0\x00 [a/t](https://e.com/real)")
|
||||
self.assertIn("href='https://e.com/real'", html)
|
||||
self.assertEqual(html.count("https://e.com/real"), 1)
|
||||
|
||||
|
||||
class TestUrlSchemeAllowlist(unittest.TestCase):
|
||||
def test_helpers_are_public_on_manager_util(self):
|
||||
self.assertTrue(callable(getattr(MANAGER_UTIL, "escape_html_attribute", None)))
|
||||
self.assertTrue(callable(getattr(MANAGER_UTIL, "sanitize_url", None)))
|
||||
|
||||
def test_dangerous_schemes_are_rejected(self):
|
||||
for bad in [
|
||||
"javascript:alert`1`",
|
||||
"JaVaScRiPt:alert`1`",
|
||||
" javascript:alert`1`",
|
||||
"java\tscript:alert`1`",
|
||||
"java\nscript:alert`1`",
|
||||
"\x01javascript:alert`1`",
|
||||
"data:text/html;base64,PHNjcmlwdD4=",
|
||||
"vbscript:msgbox",
|
||||
]:
|
||||
with self.subTest(bad=bad):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_url(bad), "#")
|
||||
|
||||
def test_safe_urls_pass_through(self):
|
||||
for good in [
|
||||
"https://example.com/a?b=1",
|
||||
"http://example.com",
|
||||
"HTTPS://Example.COM/Path",
|
||||
"/relative/path",
|
||||
"relative/path",
|
||||
"#in-page-anchor",
|
||||
"//protocol-relative/path",
|
||||
# A relative link may legitimately contain '&'. An earlier version
|
||||
# rejected this shape while guarding against an entity-hidden colon;
|
||||
# the guarantee comes from the escape layer instead (below), so the
|
||||
# rejection was redundant and only broke real links.
|
||||
"a&b/c",
|
||||
"docs/page?x=1&y=2",
|
||||
]:
|
||||
with self.subTest(good=good):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_url(good), good)
|
||||
|
||||
def test_entity_hidden_colon_is_inert_after_escaping(self):
|
||||
# Asserted on the POST-ESCAPE href, because that is where the safety
|
||||
# actually comes from: sanitize_url returns these unchanged (they have no
|
||||
# real colon, so they read as relative), and escape_html_attribute then
|
||||
# escapes the '&'. Entity decoding in an attribute is single-pass, so
|
||||
# what the browser ends up with is a literal string, never a scheme.
|
||||
for hidden in ["javascript:alert`1`", "javascript:alert`1`",
|
||||
"javascript:alert`1`"]:
|
||||
with self.subTest(hidden=hidden):
|
||||
href = MANAGER_UTIL.escape_html_attribute(MANAGER_UTIL.sanitize_url(hidden))
|
||||
self.assertIn("&", href)
|
||||
self.assertNotIn(":", href)
|
||||
self.assertNotIn("j", href)
|
||||
self.assertNotIn("j", href)
|
||||
|
||||
def test_entity_hidden_colon_in_markdown_produces_no_live_scheme(self):
|
||||
html = CONVERT("[a/click](javascript:alert`1`)")
|
||||
anchors = _parse_anchors(html)
|
||||
self.assertEqual(len(anchors), 1)
|
||||
# The parser decodes entities exactly as a browser would; after that the
|
||||
# href must NOT be a javascript: URL.
|
||||
self.assertFalse(anchors[0]["href"].lower().startswith("javascript:"))
|
||||
|
||||
def test_dangerous_scheme_in_markdown_does_not_reach_the_href(self):
|
||||
html = CONVERT("[a/click](javascript:alert`1`)")
|
||||
self.assertNotIn("javascript:", html.lower())
|
||||
self.assertIn("href='#'", html)
|
||||
|
||||
|
||||
class TestAttributeEscaper(unittest.TestCase):
|
||||
def test_escapes_all_five_characters(self):
|
||||
self.assertEqual(
|
||||
MANAGER_UTIL.escape_html_attribute("""&<>"'"""),
|
||||
"&<>"'",
|
||||
)
|
||||
|
||||
def test_ampersand_is_escaped_first_and_only_once(self):
|
||||
self.assertEqual(MANAGER_UTIL.escape_html_attribute("a&b"), "a&b")
|
||||
|
||||
def test_non_string_input_is_coerced(self):
|
||||
self.assertEqual(MANAGER_UTIL.escape_html_attribute(None), "None")
|
||||
|
||||
|
||||
class TestServerTitleTransformIsTheOnlyUpstreamControl(unittest.TestCase):
|
||||
"""Pin the server-side facts that the bare flyover title sinks rest on.
|
||||
|
||||
js/custom-nodes-manager.js says, at both flyover pack-title sinks, that the
|
||||
title is already escaped by populate_markdown and so must NOT be escaped
|
||||
again at the client (a second escape would double-escape a legitimate
|
||||
<>-title into mojibake). That comment is a load-bearing claim about THIS
|
||||
file's subject: it is the whole reason those two sinks are bare. These
|
||||
cases make the claim machine-checked, so if the server side ever stops
|
||||
covering `title` the suite says so, instead of leaving a comment asserting
|
||||
a protection that is gone.
|
||||
"""
|
||||
|
||||
def test_populate_markdown_escapes_the_title_before_it_reaches_the_client(self):
|
||||
# The specific link the sink comments name. This is the case that fails
|
||||
# if someone drops `title` from populate_markdown, which is the drift
|
||||
# the bare sinks are actually exposed to.
|
||||
populate_markdown = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
pack = {"title": "Nodes for <Flux> workflows"}
|
||||
populate_markdown(pack)
|
||||
self.assertEqual(pack["title"], "Nodes for <Flux> workflows")
|
||||
|
||||
def test_sanitize_tag_escapes_angle_brackets(self):
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_tag("<img>"), "<img>")
|
||||
|
||||
def test_sanitize_tag_does_not_escape_quotes_or_ampersand(self):
|
||||
# Documented limit, not a defect: both title interpolations are in
|
||||
# element-text position, where quotes are inert. It IS the reason a
|
||||
# title moving into attribute position would need escaping.
|
||||
self.assertEqual(MANAGER_UTIL.sanitize_tag("\"a\" & 'b'"), "\"a\" & 'b'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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 '<Flux>' to the user; removing it is the fix"),
|
||||
("model-manager.js", "showStatus", "`Install ${result.length} models successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("model-manager.js", "showStatus", "`Loading external model list ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("model-manager.js", "showMessage",
|
||||
"`To apply the installed model, please click the 'Refresh' button.`, \"red\")"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("model-manager.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- node-usage-analyzer.js --------------------------------------------
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`${grid.viewRows.length.toLocaleString()} installed packages`);"):
|
||||
(NO_DATA, "a formatted row count"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Install ${sanitizeHTML(String(item.name))} ...`);"):
|
||||
(RAW,
|
||||
"item.name is packKey (loadData sets `name: packKey`) — a raw dict key no "
|
||||
"server transform touches, so this caller escapes it. The ONLY raw-provenance "
|
||||
"showStatus caller in the four files"),
|
||||
("node-usage-analyzer.js", "showStatus", "msg);"):
|
||||
(SERVER_ESCAPED,
|
||||
"uninstallNodes preserves the escaped title or escapes the raw name fallback; "
|
||||
"test_node_usage_escaping executes that path through the panel sink"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Uninstalled ${targets.length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of targets"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Install ${Object.keys(result).length} models successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("node-usage-analyzer.js", "showStatus",
|
||||
"`Uninstall ${Object.keys(result).length} custom node(s) successfully`);"):
|
||||
(NO_DATA, "a count of queue results"),
|
||||
("node-usage-analyzer.js", "showStatus", "`Analyzing node usage ...`);"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage",
|
||||
"`To apply the uninstalled custom nodes, please restart ComfyUI and refresh "
|
||||
"browser.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage",
|
||||
"`To apply the installed model, please click the 'Refresh' button.`, \"red\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage", "\"No workflows were found for analysis.\");"):
|
||||
(NO_DATA, "hardcoded"),
|
||||
("node-usage-analyzer.js", "showMessage", "\"\");"):
|
||||
(NO_DATA, "clears the banner"),
|
||||
|
||||
# --- showError's own delegation, in each of the three classes -----------
|
||||
# Not an application caller: this IS `showError(err) { this.showMessage(err,
|
||||
# "red"); }`. Whether `err` is display-ready is the showError CALLER's
|
||||
# responsibility, which HtmlByDesignSinkTest below enforces separately. It is
|
||||
# registered rather than pattern-excluded so that if showError ever stops
|
||||
# forwarding — or starts forwarding something else — this entry goes stale
|
||||
# and the guard says so.
|
||||
("custom-nodes-manager.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
("model-manager.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
("node-usage-analyzer.js", "showMessage", "err, \"red\");"):
|
||||
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
|
||||
}
|
||||
|
||||
DISPLAY_READY_METHODS = ("showStatus", "showMessage")
|
||||
|
||||
# A caller escape is spelled this way throughout the UI files.
|
||||
ESCAPE_MARKER = "sanitizeHTML"
|
||||
|
||||
|
||||
class MessageSinkRegistryTest(unittest.TestCase):
|
||||
"""The registry must account for every display-ready-sink call site."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sites = _call_sites()
|
||||
|
||||
def test_the_four_sink_implementations_all_exist(self):
|
||||
# If a file stops defining its own sink (e.g. it starts delegating to
|
||||
# createUIStateManager) the registry's routing assumptions change and
|
||||
# this guard must be re-derived rather than silently kept.
|
||||
own = {
|
||||
"custom-nodes-manager.js": ".cn-manager-status",
|
||||
"model-manager.js": ".cmm-manager-status",
|
||||
"node-usage-analyzer.js": ".nu-manager-status",
|
||||
}
|
||||
for name, selector in own.items():
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
self.assertIn("showStatus(msg, color)", source, "%s lost its own sink" % name)
|
||||
self.assertIn(selector, source)
|
||||
common = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
self.assertIn("export function createUIStateManager(", common)
|
||||
self.assertIn("showStatus: (msg, color)", common)
|
||||
|
||||
def test_every_caller_is_classified(self):
|
||||
"""FAIL-CLOSED: an unregistered call site breaks the build."""
|
||||
observed = Counter((name, method, arg) for name, _, _, method, arg in self.sites
|
||||
if method in DISPLAY_READY_METHODS)
|
||||
duplicates = {
|
||||
('custom-nodes-manager.js', 'showStatus', '`Loading workflow usage analysis ...`);'),
|
||||
('node-usage-analyzer.js', 'showMessage',
|
||||
'`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");'),
|
||||
}
|
||||
expected = Counter({key: 1 + (key in duplicates) for key in REGISTRY})
|
||||
self.assertEqual(observed, expected, 'Changed message calls require a provenance review')
|
||||
|
||||
def test_the_observed_receivers_are_the_pinned_set(self):
|
||||
observed = {receiver for _name, _lineno, receiver, _m, _a in self.sites}
|
||||
self.assertEqual(
|
||||
observed, set(PINNED_RECEIVERS),
|
||||
"the set of receivers calling the message sinks changed: %s. These "
|
||||
"sinks assign innerHTML and DO NOT escape, so every route to them "
|
||||
"must be accounted for. Classify the new receiver's call sites in "
|
||||
"REGISTRY and add it to PINNED_RECEIVERS — do not narrow CALL_RE to "
|
||||
"make this pass." % (sorted(observed ^ set(PINNED_RECEIVERS)),),
|
||||
)
|
||||
|
||||
def test_no_sink_method_is_destructured_out_of_its_receiver(self):
|
||||
for name in SINK_FILES:
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
with self.subTest(file=name):
|
||||
self.assertEqual(
|
||||
DESTRUCTURE_RE.findall(source), [],
|
||||
"%s destructures a message-sink method out of its receiver. "
|
||||
"The resulting bare calls are invisible to this guard; call "
|
||||
"them through their receiver instead." % (name,),
|
||||
)
|
||||
|
||||
def test_unsupported_sink_calls_require_review(self):
|
||||
source = (JS_DIR / "common.js").read_text(encoding="utf-8")
|
||||
for call in ('this["showMessage"](raw);',
|
||||
'createUIStateManager(element).showError(raw);'):
|
||||
with self.subTest(call=call), patch(__name__ + ".SINK_FILES", ["common.js"]):
|
||||
with patch.object(Path, "read_text", return_value=source + "\n" + call):
|
||||
with self.assertRaisesRegex(AssertionError, "Unsupported message-sink call"):
|
||||
_call_sites()
|
||||
|
||||
def test_the_registry_has_no_stale_entries(self):
|
||||
"""A registry entry whose call site is gone or whose argument changed."""
|
||||
live = {
|
||||
(name, method, arg)
|
||||
for name, _lineno, _receiver, method, arg in self.sites
|
||||
if method in DISPLAY_READY_METHODS
|
||||
}
|
||||
stale = sorted(key for key in REGISTRY if key not in live)
|
||||
self.assertEqual(
|
||||
stale, [],
|
||||
"REGISTRY entries no longer match any call site — the caller was removed "
|
||||
"or its argument changed, so its provenance verdict is unverified:\n %s"
|
||||
% "\n ".join("%s %s(%s" % key for key in stale),
|
||||
)
|
||||
|
||||
def test_server_escaped_callers_do_not_escape_again(self):
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != SERVER_ESCAPED:
|
||||
continue
|
||||
with self.subTest(file=name, method=method, arg=arg):
|
||||
self.assertNotIn(
|
||||
ESCAPE_MARKER, arg,
|
||||
"%s %s escapes an ALREADY-escaped value — the user reads "
|
||||
"'<Flux>' instead of '<Flux>'. Reason on file: %s"
|
||||
% (name, method, reason),
|
||||
)
|
||||
|
||||
def test_raw_callers_escape(self):
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != RAW:
|
||||
continue
|
||||
with self.subTest(file=name, method=method, arg=arg):
|
||||
self.assertIn(
|
||||
ESCAPE_MARKER, arg,
|
||||
"%s %s passes a RAW value into a sink that does not escape. "
|
||||
"Reason on file: %s" % (name, method, reason),
|
||||
)
|
||||
|
||||
def test_no_data_callers_interpolate_no_channel_value(self):
|
||||
# Interpolations are allowed, but only of things that cannot carry
|
||||
# channel/registry text: counts, lengths, and the UI-chosen mode.
|
||||
allowed = re.compile(
|
||||
r"^\$\{(?:[A-Za-z_.]*\.length|Object\.keys\([A-Za-z_.]+\)\.length"
|
||||
r"|[A-Za-z_.]+\.toLocaleString\(\)|mode|label"
|
||||
r"|[A-Za-z_.]*\.length\.toLocaleString\(\))\}$"
|
||||
)
|
||||
for (name, method, arg), (provenance, reason) in REGISTRY.items():
|
||||
if provenance != NO_DATA:
|
||||
continue
|
||||
for expr in re.findall(r"\$\{[^}]*\}", arg):
|
||||
with self.subTest(file=name, method=method, expr=expr):
|
||||
self.assertRegex(
|
||||
expr, allowed,
|
||||
"%s %s is classified %s but interpolates %s, which is not a "
|
||||
"count or a UI-chosen value. Re-classify it. Reason on file: %s"
|
||||
% (name, method, NO_DATA, expr, reason),
|
||||
)
|
||||
|
||||
|
||||
class HtmlByDesignSinkTest(unittest.TestCase):
|
||||
"""showSelection / showError render real markup and are NOT display-ready sinks."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sites = _call_sites()
|
||||
|
||||
def test_show_selection_receives_no_channel_data(self):
|
||||
expected = {
|
||||
"custom-nodes-manager.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
# Unknown labels in this builder have behavioral coverage.
|
||||
'list.join(""));',
|
||||
],
|
||||
"model-manager.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
'`<span>Selected <b>${selectedList.length}</b> models <button '
|
||||
'class="cmm-btn-install p-button p-component" mode="install">Install</button>`);',
|
||||
],
|
||||
"node-usage-analyzer.js": [
|
||||
'"");',
|
||||
'"");',
|
||||
'`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);',
|
||||
'` <div class="nu-selected-buttons"> <span>Selected <b>${installedSelected.length}</b> '
|
||||
'installed packages</span> <button class="nu-btn-uninstall" mode="uninstall">'
|
||||
'Uninstall Selected</button> </div> `);',
|
||||
],
|
||||
}
|
||||
actual = Counter((name, arg) for name, _, _, method, arg in self.sites if method == "showSelection")
|
||||
self.assertEqual(actual, Counter((name, arg) for name, args in expected.items() for arg in args))
|
||||
|
||||
def test_show_error_arguments_are_escaped_or_literal(self):
|
||||
# showError forwards to showMessage, which does not escape, AND its
|
||||
# errorMsg is separately handed to ComfyUI-core show_message(). So every
|
||||
# non-literal argument must already be escaped at the call site.
|
||||
escaped = {
|
||||
"custom-nodes-manager.js": {
|
||||
'`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);',
|
||||
'`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);',
|
||||
'`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);',
|
||||
},
|
||||
"node-usage-analyzer.js": {'sanitizeHTML(String(result.error)));'},
|
||||
}
|
||||
for name, lineno, _receiver, method, arg in self.sites:
|
||||
if method != "showError":
|
||||
continue
|
||||
is_literal = re.fullmatch(
|
||||
r'''(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\);''', arg,
|
||||
) is not None
|
||||
# These direct arguments are checked by the per-file guards below.
|
||||
is_error_var = re.fullmatch(r"errorMsg\s*\);", arg) is not None
|
||||
with self.subTest(file=name, line=lineno, arg=arg):
|
||||
if is_literal or is_error_var:
|
||||
continue
|
||||
self.assertIn(
|
||||
arg, escaped.get(name, set()),
|
||||
"%s:%d passes an unescaped non-literal into showError, which does "
|
||||
"not escape and whose text also reaches core show_message()"
|
||||
% (name, lineno),
|
||||
)
|
||||
|
||||
def test_every_error_message_builder_escapes_its_server_text(self):
|
||||
common = {
|
||||
'errorMsg = "";',
|
||||
"errorMsg += `This action is not allowed with this security level configuration.\\n`;",
|
||||
"errorMsg += sanitizeHTML(await res.text()) + '\\n';",
|
||||
}
|
||||
queue_error = "errorMsg += sanitizeHTML(String(v)) + '\\n';"
|
||||
outdated = "errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\\n`;"
|
||||
expected = {
|
||||
"custom-nodes-manager.js": common | {
|
||||
"errorMsg = `'${item.title}': `;",
|
||||
"errorMsg = `Not found custom node: ${hash}`;",
|
||||
queue_error, outdated,
|
||||
},
|
||||
"model-manager.js": common | {
|
||||
"errorMsg = `'${item.name}': `;", queue_error, outdated,
|
||||
},
|
||||
"node-usage-analyzer.js": common | {
|
||||
"errorMsg = `'${sanitizeHTML(String(item.name))}': `;", queue_error,
|
||||
},
|
||||
"common.js": common | {"errorMsg = `'${displayTitle}': `;"},
|
||||
}
|
||||
mutations = re.compile(r"(?<![\w$.])errorMsg\s*(?:[+*/%&|^?-]*=(?!=)|\+\+|--)[^;]*;")
|
||||
for name, statements in expected.items():
|
||||
source = (JS_DIR / name).read_text(encoding="utf-8")
|
||||
actual = {re.sub(r"\s+", " ", match) for match in mutations.findall(source)}
|
||||
with self.subTest(file=name):
|
||||
self.assertEqual(actual, statements)
|
||||
|
||||
def test_error_guard_rejects_unclassified_arguments(self):
|
||||
for argument in (
|
||||
"err);", "errText);", "errorMsg$raw);", "err + extra);", "err.detail);",
|
||||
'"prefix " + errText);', "'prefix ' + errText);",
|
||||
"sanitizeHTML(err) + extra);",
|
||||
):
|
||||
with self.subTest(argument=argument):
|
||||
guard = HtmlByDesignSinkTest("test_show_error_arguments_are_escaped_or_literal")
|
||||
guard.sites = [("custom-nodes-manager.js", 1, "this", "showError", argument)]
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertEqual(len(result.failures), 1)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
def test_html_guards_reject_unreviewed_source(self):
|
||||
cases = [
|
||||
("custom-nodes-manager.js", 'this.showSelection(list.join(""));',
|
||||
'this.showSelection(selectedList[0].title);',
|
||||
"test_show_selection_receives_no_channel_data"),
|
||||
("node-usage-analyzer.js", "${installedSelected.length}</b>",
|
||||
"${installedSelected[0].title}</b>",
|
||||
"test_show_selection_receives_no_channel_data"),
|
||||
("custom-nodes-manager.js", 'let errorMsg = "";',
|
||||
'let errorMsg = "";\nerrorMsg += response.message;',
|
||||
"test_every_error_message_builder_escapes_its_server_text"),
|
||||
("model-manager.js", "errorMsg += sanitizeHTML(String(v)) + '\\n';",
|
||||
"errorMsg += sanitizeHTML(String(v)) + response.message + '\\n';",
|
||||
"test_every_error_message_builder_escapes_its_server_text"),
|
||||
]
|
||||
for name, before, after, test in cases:
|
||||
with self.subTest(file=name, replacement=after), tempfile.TemporaryDirectory() as tmp:
|
||||
directory = Path(tmp)
|
||||
for filename in SINK_FILES:
|
||||
source = (JS_DIR / filename).read_text(encoding="utf-8")
|
||||
if filename == name:
|
||||
self.assertIn(before, source)
|
||||
source = source.replace(before, after, 1)
|
||||
(directory / filename).write_text(source, encoding="utf-8")
|
||||
with patch(__name__ + ".JS_DIR", directory):
|
||||
guard = HtmlByDesignSinkTest(test)
|
||||
guard.sites = _call_sites()
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertTrue(result.failures)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
def test_duplicate_calls_require_review(self):
|
||||
for cls, test, argument in (
|
||||
(MessageSinkRegistryTest, 'test_every_caller_is_classified', 'msg);'),
|
||||
(HtmlByDesignSinkTest, 'test_show_selection_receives_no_channel_data', 'list.join(""));'),
|
||||
):
|
||||
with self.subTest(test=test):
|
||||
guard = cls(test)
|
||||
guard.sites = _call_sites()
|
||||
guard.sites.append(next(site for site in guard.sites if site[-1] == argument))
|
||||
result = unittest.TestResult()
|
||||
guard.run(result)
|
||||
self.assertTrue(result.failures)
|
||||
self.assertEqual(result.errors, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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:alert(1)'})",
|
||||
"data": "nameFormatter('Model', {reference: 'data:text/html,<script>alert(1)</script>'})",
|
||||
"ok": "nameFormatter('Model', {reference: 'https://huggingface.co/repo'})",
|
||||
})
|
||||
for key in ("js", "obfuscated", "data"):
|
||||
href = dict(_parse(out[key]).attrs)["href"]
|
||||
self.assertEqual(href, "#", "%s: dangerous scheme survived as %r" % (key, href))
|
||||
# The entity form is defeated by the attribute escape, not the scheme
|
||||
# check: `&` becomes `&`, so `:` reaches the browser as text.
|
||||
self.assertIn("&#58;", out["entity"])
|
||||
self.assertEqual(dict(_parse(out["ok"]).attrs)["href"], "https://huggingface.co/repo")
|
||||
|
||||
# --- D-V: url column, quoted but previously unescaped -------------------
|
||||
def test_d5_url_href_escaped_inside_its_quotes(self):
|
||||
out = self._eval({
|
||||
"quote": "urlFormatter(%s)" % json.dumps(QUOTE_BREAKOUT),
|
||||
"attr": "urlFormatter(%s)" % json.dumps(ATTR_BREAKOUT),
|
||||
"js": "urlFormatter('javascript:alert(1)')",
|
||||
})
|
||||
self._assert_inert(out["quote"], allowed_tags={"a", "svg", "path"})
|
||||
self._assert_inert(out["attr"], allowed_tags={"a", "svg", "path"})
|
||||
self.assertEqual(dict(_parse(out["js"]).attrs)["href"], "#")
|
||||
|
||||
# --- D-III: the four previously formatter-less columns ------------------
|
||||
def test_d3_columns_bind_the_escaping_formatter(self):
|
||||
for column in ("'type'", "'base'", '"save_path"', "'filename'"):
|
||||
block = _slice_column(column)
|
||||
self.assertIn(
|
||||
"formatter: escapeCell", block,
|
||||
"column %s still renders RAW model-list.json into innerHTML" % column,
|
||||
)
|
||||
|
||||
def test_d3_escape_cell_neutralises_payload_and_keeps_empty_cells(self):
|
||||
out = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"payload: escapeCell(%s),"
|
||||
"nul: escapeCell(null), undef: escapeCell(undefined),"
|
||||
"num: escapeCell(42), plain: escapeCell('checkpoints')"
|
||||
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD))
|
||||
)
|
||||
self._assert_inert(out["payload"], allowed_tags=set())
|
||||
self.assertEqual(html.unescape(out["payload"]), TEXT_PAYLOAD,
|
||||
"the payload must survive as visible text")
|
||||
# null/undefined pass through so turbogrid keeps rendering an empty cell.
|
||||
self.assertIsNone(out["nul"])
|
||||
self.assertNotIn("undef", out)
|
||||
self.assertEqual(out["num"], "42")
|
||||
self.assertEqual(out["plain"], "checkpoints")
|
||||
|
||||
# --- D-VI: the showStatus message sink ----------------------------------
|
||||
def test_d6_show_status_renders_item_name_inert(self):
|
||||
"""item.name must reach .cmm-manager-status innerHTML inert.
|
||||
|
||||
This used to assert that the CALL SITE carried a sanitizeHTML. That was
|
||||
wrong in a way the assertion could not see: ``item.name`` arrives from
|
||||
``/externalmodel/getlist`` ALREADY escaped by ``populate_markdown`` ->
|
||||
``sanitize_tag``, so escaping it again rendered ``<Flux>`` to the
|
||||
user. The call-site escape was removed for that reason, and the sink
|
||||
deliberately does not escape either -- see
|
||||
tests/test_message_sink_provenance.py for why a blanket sink escape
|
||||
cannot be correct across these callers.
|
||||
|
||||
The GUARANTEE is unchanged, and is now asserted end-to-end on the value
|
||||
the caller ACTUALLY receives: take the server's own output for a hostile
|
||||
name, push it through the REAL lifted sink, and read innerHTML. That is
|
||||
strictly stronger than the old form, which passed whether or not the
|
||||
rendered result was inert.
|
||||
"""
|
||||
anchor = "this.showStatus(`Install "
|
||||
line = MODEL_MANAGER_SRC[MODEL_MANAGER_SRC.index(anchor):]
|
||||
line = line[:line.index("\n")]
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML", line,
|
||||
"item.name is already server-escaped; escaping here is the mojibake "
|
||||
"bug this call site used to have: %r" % (line,),
|
||||
)
|
||||
# What populate_markdown -> sanitize_tag puts on the wire for the payload.
|
||||
server_value = TEXT_PAYLOAD.replace("<", "<").replace(">", ">")
|
||||
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\tshowStatus(msg, color)")
|
||||
rendered = _run_node(
|
||||
"%s\nconst sink = %s;\n"
|
||||
"const item = {name: %s};\n"
|
||||
"const el = { innerHTML: '' };\n"
|
||||
"const ctx = { element: { querySelector: () => el } };\n"
|
||||
"sink.call(ctx, `Install ${item.name} ...`);\n"
|
||||
"console.log(JSON.stringify({msg: el.innerHTML}));\n"
|
||||
% (_lift_sanitize_html(), lifted, json.dumps(server_value))
|
||||
)
|
||||
self._assert_inert(rendered["msg"], allowed_tags=set())
|
||||
# ...and the user reads the payload as text, not as entity soup.
|
||||
self.assertEqual(
|
||||
html.unescape(rendered["msg"]), "Install %s ..." % TEXT_PAYLOAD
|
||||
)
|
||||
|
||||
# --- filter dropdowns: same raw type/base values, different route -------
|
||||
def test_filter_dropdown_options_escape_label_and_value(self):
|
||||
out = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({"
|
||||
"label: renderOptions([{label: %s, value: 'x'}], ''),"
|
||||
"value: renderOptions([{label: 'x', value: %s}], ''),"
|
||||
"plain: renderOptions([{label: 'checkpoints', value: 'checkpoints'}], 'checkpoints')"
|
||||
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD), json.dumps(ATTR_BREAKOUT))
|
||||
)
|
||||
self._assert_inert(out["label"], allowed_tags={"option"})
|
||||
self._assert_inert(out["value"], allowed_tags={"option"})
|
||||
# Selection still matches on the RAW value, and benign data is untouched.
|
||||
self.assertIn(" selected", out["plain"])
|
||||
self.assertIn(">checkpoints</option>", out["plain"])
|
||||
|
||||
def test_update_filter_routes_all_three_dropdowns_through_render_options(self):
|
||||
block = slice_braced(MODEL_MANAGER_SRC, "updateFilter()")
|
||||
for dropdown in ("$filter", "$type", "$base"):
|
||||
self.assertIn(
|
||||
"%s.innerHTML = renderOptions(" % dropdown, block,
|
||||
"%s still builds <option> markup inline" % dropdown,
|
||||
)
|
||||
|
||||
# --- description: server-transformed HTML, must NOT be double-escaped ---
|
||||
def test_description_column_is_not_re_escaped(self):
|
||||
block = _slice_column("'description'")
|
||||
self.assertNotIn(
|
||||
"formatter", block,
|
||||
"description is server-transformed HTML (populate_markdown); re-escaping "
|
||||
"it renders visible mojibake such as 'Nodes for <Flux> workflows'",
|
||||
)
|
||||
|
||||
def test_description_server_value_still_reads_cleanly(self):
|
||||
# What populate_markdown -> sanitize_tag actually emits for "<Flux>".
|
||||
server_value = "Nodes for <Flux> workflows"
|
||||
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
|
||||
# Run through the client escaper as well, the user would see the entity
|
||||
# text instead of the angle brackets — that is what this guard forbids.
|
||||
double = _run_node(
|
||||
"%s\nconsole.log(JSON.stringify({v: sanitizeHTML(%s)}));\n"
|
||||
% (_lift_sanitize_html(), json.dumps(server_value))
|
||||
)["v"]
|
||||
self.assertEqual(html.unescape(double), server_value)
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ModelManagerMessageSinkTest(unittest.TestCase):
|
||||
"""model-manager's OWN message sinks — it does not use createUIStateManager.
|
||||
|
||||
This class replaces an earlier ``test_turbogrid_bundle_untouched`` guard,
|
||||
which asserted that ``js/turbogrid.esm.js`` was unchanged relative to a
|
||||
fixed base commit. That was an INTEGRATION ARTIFACT, not a property of this
|
||||
file: it encoded "only model-manager was touched by the work item that
|
||||
added these tests". In the integrated tree the vendored grid IS changed on
|
||||
purpose, so the assertion reported a cross-file fact that says nothing
|
||||
about whether model-manager's own sinks are safe. What this file can
|
||||
actually guarantee is asserted instead — and it is strictly more coverage,
|
||||
because the sinks below were not covered by anything before.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.sanitize = _lift_sanitize_html()
|
||||
|
||||
def _call_sink(self, method: str, msg, color=None) -> str:
|
||||
"""Run one lifted sink against a stub element; return the real innerHTML."""
|
||||
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\t%s(msg, color)" % method)
|
||||
args = json.dumps(msg) + ("" if color is None else ", " + json.dumps(color))
|
||||
return _run_node(
|
||||
"%s\n"
|
||||
"const sink = %s;\n"
|
||||
"const el = { innerHTML: '' };\n"
|
||||
"const ctx = { element: { querySelector: () => el } };\n"
|
||||
"sink.call(ctx, %s);\n"
|
||||
"console.log(JSON.stringify({v: el.innerHTML}));\n"
|
||||
% (self.sanitize, lifted, args)
|
||||
)["v"]
|
||||
|
||||
# --- the sinks take DISPLAY-READY text and do NOT escape ---------------
|
||||
def test_the_sinks_deliberately_do_not_escape(self):
|
||||
# Structural, and load-bearing: a well-meaning sanitizeHTML added here
|
||||
# would double-escape the server-escaped values these sinks actually
|
||||
# receive. tests/test_message_sink_provenance.py is what keeps that safe
|
||||
# by classifying every caller.
|
||||
for method in ("showStatus(msg, color)", "showMessage(msg, color)"):
|
||||
body = slice_braced(MODEL_MANAGER_SRC, "\t" + method)
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML", body,
|
||||
"%s escapes at the sink; its callers pass ALREADY-escaped server "
|
||||
"values, so this renders '<Flux>' to the user" % method,
|
||||
)
|
||||
self.assertIn("innerHTML", body)
|
||||
|
||||
def test_a_server_escaped_value_renders_inert_through_the_sink(self):
|
||||
server_value = "Install %s ..." % TEXT_PAYLOAD.replace(
|
||||
"<", "<").replace(">", ">")
|
||||
rendered = self._call_sink("showStatus", server_value)
|
||||
self.assertNotIn("img", _parse(rendered).tags)
|
||||
|
||||
def test_a_benign_angle_bracket_name_is_not_double_escaped(self):
|
||||
# THE regression this arrangement exists to prevent. The server sends
|
||||
# 'Model for <Flux> workflows'; the user must read the angle
|
||||
# brackets, not the entities.
|
||||
server_value = "Install Model for <Flux> workflows ..."
|
||||
rendered = self._call_sink("showStatus", server_value)
|
||||
self.assertEqual(
|
||||
html.unescape(rendered), "Install Model for <Flux> workflows ..."
|
||||
)
|
||||
self.assertNotIn("&lt;", rendered)
|
||||
|
||||
def test_the_font_wrapper_still_renders(self):
|
||||
rendered = self._call_sink("showMessage", "plain text", "red")
|
||||
collector = _parse(rendered)
|
||||
self.assertIn("font", collector.tags)
|
||||
self.assertIn(("color", "red"), collector.attrs)
|
||||
|
||||
# --- showError: HTML BY CONTRACT, escaped by its callers ---------------
|
||||
def test_show_error_forwards_to_the_non_escaping_show_message(self):
|
||||
# showError is HTML by contract: its errorMsg is built escaped because
|
||||
# the SAME string also goes to ComfyUI-core show_message(). Forwarding
|
||||
# to a NON-escaping showMessage keeps that at exactly one escape.
|
||||
body = slice_braced(MODEL_MANAGER_SRC, "\tshowError(err)")
|
||||
self.assertIn("this.showMessage(err, \"red\")", body)
|
||||
|
||||
# --- the caller side ---------------------------------------------------
|
||||
def test_the_install_status_caller_passes_the_server_value_untouched(self):
|
||||
self.assertIn("this.showStatus(`Install ${item.name} ...`)", MODEL_MANAGER_SRC)
|
||||
|
||||
def test_the_install_error_message_escapes_only_the_raw_response(self):
|
||||
# The name is already escaped by the server; the error response is raw.
|
||||
self.assertIn(
|
||||
"errorMsg = `'${item.name}': `", MODEL_MANAGER_SRC
|
||||
)
|
||||
self.assertIn("errorMsg += sanitizeHTML(await res.text())", MODEL_MANAGER_SRC)
|
||||
|
||||
def test_install_failure_preserves_server_escaped_names_in_both_sinks(self):
|
||||
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
|
||||
methods = ",\n".join(slice_braced(MODEL_MANAGER_SRC, marker) for marker in [
|
||||
"async installModels(list, btn)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)"
|
||||
])
|
||||
response_text = '<img src=x onerror="window.__probe=1"> download failed'
|
||||
for name in ["Model <Flux>", '<img src=x onerror="window.__probe=1">']:
|
||||
for status in [403, 500]:
|
||||
with self.subTest(name=name, status=status):
|
||||
item = {"name": name, "originalData": {}, "hash": "model"}
|
||||
populate(item)
|
||||
rendered = _run_node("""
|
||||
%s
|
||||
const messages = {};
|
||||
const elements = {};
|
||||
const api = {fetchApi: async (url) => url.endsWith('/status')
|
||||
? {json: async () => ({is_processing: false})}
|
||||
: {status: %d, json: async () => ({}), text: async () => %s}};
|
||||
const show_message = (message) => messages.dialog = message;
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: (selector) => elements[selector] ||= {innerHTML: ''}},
|
||||
grid: {scrollRowIntoView() {}, updateCell() {}},
|
||||
focusInstall() {return true;}
|
||||
};
|
||||
await ctx.installModels([%s], {classList: {add() {}}});
|
||||
messages.panel = elements['.cmm-manager-message'].innerHTML;
|
||||
console.log(JSON.stringify(messages));
|
||||
""" % (self.sanitize, status, json.dumps(response_text), methods, json.dumps(item)))
|
||||
self.assertEqual(set(rendered), {"panel", "dialog"})
|
||||
for message in rendered.values():
|
||||
self.assertIn("'%s': " % name, html.unescape(message))
|
||||
self.assertNotIn("img", _parse(message).tags)
|
||||
if status == 500:
|
||||
self.assertIn(response_text, html.unescape(message))
|
||||
|
||||
def test_the_queue_result_error_text_is_escaped_by_the_caller(self):
|
||||
self.assertIn("errorMsg += sanitizeHTML(String(v))", MODEL_MANAGER_SRC)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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 <Flux>
|
||||
workflows"); leaving it bare would have shipped the raw fallback. The fix
|
||||
normalises the fallback so the column carries ONE provenance, which is what
|
||||
makes the bare formatter correct by construction.
|
||||
|
||||
This grid uses cellResizeObserver only — no rowFilter, no
|
||||
highlightKeywordsFilter — so the keyword-highlight re-render does not reach it.
|
||||
The two searchable managers use js/manager-grid.js to secure that path without
|
||||
modifying the vendored TurboGrid bundle.
|
||||
|
||||
The status/message sinks take DISPLAY-READY text and do NOT escape, because
|
||||
their callers carry mixed provenance; every caller is classified in
|
||||
tests/test_message_sink_provenance.py. This analyzer is the only file that
|
||||
reaches BOTH sink implementations — the shared one from common.js via `this.ui`
|
||||
and its own class methods.
|
||||
|
||||
REPRODUCE THE RED HALF against the pre-fix revision — one command:
|
||||
mkdir -p /some/dir
|
||||
git show <base>:js/node-usage-analyzer.js > /some/dir/node-usage-analyzer.js
|
||||
git show <base>:js/common.js > /some/dir/common.js
|
||||
MANAGER_JS_DIR=/some/dir pytest tests/test_node_usage_escaping.py
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from js_lift import (
|
||||
NODE,
|
||||
JsSource,
|
||||
event_handlers,
|
||||
parse,
|
||||
run_node,
|
||||
slice_braced,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
|
||||
|
||||
ANALYZER = "node-usage-analyzer.js"
|
||||
COMMON = "common.js"
|
||||
TITLE_FORMATTER_ANCHOR = "formatter: function (name, rowItem, columnItem, cellNode)"
|
||||
|
||||
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
|
||||
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
|
||||
# An ordinary pack reference, for the legs that ask what a CORRECT link
|
||||
# looks like rather than what a hostile one cannot do.
|
||||
BENIGN_REFERENCE = "https://github.com/owner/repo"
|
||||
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
|
||||
|
||||
|
||||
def _url_helper_home() -> str:
|
||||
"""Which file currently owns sanitizeUrl/safeHref, or '' on a pre-fix tree.
|
||||
|
||||
They were local to the analyzer when this guard was written and now live in
|
||||
common.js, imported by every client file. Resolving the home rather than
|
||||
hard-coding it keeps the MANAGER_JS_DIR reproduce working against both
|
||||
revisions, and makes a THIRD home fail loudly instead of being lifted twice.
|
||||
"""
|
||||
for filename in (COMMON, ANALYZER):
|
||||
if "SAFE_URL_SCHEMES" in JS.text(filename):
|
||||
return filename
|
||||
return ""
|
||||
|
||||
|
||||
def _preamble() -> str:
|
||||
parts = [JS.lift_declaration(COMMON, "export function sanitizeHTML(")]
|
||||
home = _url_helper_home()
|
||||
if home:
|
||||
span = JS.lift_span(
|
||||
home, "SAFE_URL_SCHEMES", "safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
parts.append("const " + span.replace("export ", ""))
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class NodeUsageSinkEscapingTest(unittest.TestCase):
|
||||
"""Every sink is exercised through the REAL lifted code, never a copy."""
|
||||
|
||||
def _assert_inert(self, markup: str, allowed_tags):
|
||||
"""No element outside the allow-list, and no event-handler attribute."""
|
||||
parsed = parse(markup)
|
||||
self.assertEqual(
|
||||
[t for t in parsed.tags if t not in allowed_tags], [],
|
||||
"live element injected into: %r" % (markup,),
|
||||
)
|
||||
self.assertEqual(
|
||||
event_handlers(markup), [],
|
||||
"event-handler attribute injected into: %r" % (markup,),
|
||||
)
|
||||
|
||||
# --- C2: title column, previously UNQUOTED href -------------------------
|
||||
def _render_title(self, reference: str, name: str = "My Pack") -> str:
|
||||
return run_node(
|
||||
"%s\nconst titleFormatter = %s;\n"
|
||||
"console.log(JSON.stringify({markup: titleFormatter(%s, {reference: %s})}));\n"
|
||||
% (_preamble(),
|
||||
JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR),
|
||||
json.dumps(name), json.dumps(reference))
|
||||
)["markup"]
|
||||
|
||||
def test_c2_href_survives_breakout_attempts(self):
|
||||
for reference in (BREAKOUT_HREF, QUOTE_BREAKOUT):
|
||||
markup = self._render_title(reference)
|
||||
self._assert_inert(markup, allowed_tags={"a", "b"})
|
||||
self.assertEqual(
|
||||
sorted(name for name, _ in parse(markup).attrs),
|
||||
["href", "rel", "target"],
|
||||
"unexpected attributes on the anchor: %r" % (markup,),
|
||||
)
|
||||
|
||||
def test_c2_title_anchor_severs_the_opener(self):
|
||||
"""The pack chooses this host, so the page it opens must not keep us.
|
||||
|
||||
`reference` is channel data, so the pack author picks the page that
|
||||
opens; without rel that page holds a live window.opener back into the
|
||||
Manager UI. Membership on the SPLIT attribute, not a literal match:
|
||||
'noreferrer noopener' is the same thing, and pinning token order would
|
||||
report a correct fix as a defect.
|
||||
"""
|
||||
markup = self._render_title(BENIGN_REFERENCE)
|
||||
attrs = dict(parse(markup).attrs)
|
||||
self.assertEqual(
|
||||
attrs.get("target"), "_blank",
|
||||
"this guard is about _blank anchors; the target changed, so "
|
||||
"re-derive it rather than deleting the assertion: %r" % (markup,))
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"the title anchor opens a pack-chosen page in a new tab without "
|
||||
"rel, so that page keeps a reference to this window: %r" % (markup,))
|
||||
tokens = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", tokens, markup)
|
||||
self.assertIn("noreferrer", tokens, markup)
|
||||
|
||||
def test_c2_href_rejects_dangerous_schemes(self):
|
||||
for reference in ("javascript:alert(1)", "java\tscript:alert(1)",
|
||||
"data:text/html,<script>alert(1)</script>"):
|
||||
href = dict(parse(self._render_title(reference)).attrs)["href"]
|
||||
self.assertEqual(href, "#", "%r survived as %r" % (reference, href))
|
||||
|
||||
def test_c2_benign_reference_and_title_are_untouched(self):
|
||||
markup = self._render_title(BENIGN_REFERENCE, name="My Pack")
|
||||
self.assertEqual(dict(parse(markup).attrs)["href"], BENIGN_REFERENCE)
|
||||
self.assertIn("<b>My Pack</b>", markup)
|
||||
|
||||
def test_c2_title_is_not_double_escaped_at_the_formatter(self):
|
||||
# The column value is already escaped HTML text by the time it arrives,
|
||||
# so re-escaping here would put entity text on screen.
|
||||
block = JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR)
|
||||
self.assertNotIn(
|
||||
"sanitizeHTML(String(name))", block,
|
||||
"title is normalised at its source; escaping it again renders '<' to the user",
|
||||
)
|
||||
server_value = "Nodes for <Flux> workflows"
|
||||
markup = self._render_title("https://example.com/x", name=server_value)
|
||||
self.assertIn(server_value, markup)
|
||||
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
|
||||
|
||||
def _run_panel(self, action, packs=None, status=500):
|
||||
"""Run the production data, operation and rendering methods with stubbed I/O."""
|
||||
methods = ",\n".join(slice_braced(JS.text(ANALYZER), "\n\t" + marker) for marker in [
|
||||
"async loadData()", "async installModels(list, btn)", "async uninstallModels(list, btn)",
|
||||
"showUsageDetails(rowItem)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
|
||||
])
|
||||
return run_node("""
|
||||
%s
|
||||
%s
|
||||
%s
|
||||
const elements = {};
|
||||
const messages = {};
|
||||
const posts = [];
|
||||
const manager_instance = {datasrc_combo: {value: 'default'}};
|
||||
const fetchData = async () => ({data: {channel: 'default', node_packs: %s}});
|
||||
const analyzeWorkflowUsage = async () => ({success: true});
|
||||
const api = {fetchApi: async (url, options) => {
|
||||
if (options?.body) posts.push(JSON.parse(options.body));
|
||||
return {status: %d, json: async () => ({is_processing: false}), text: async () => %s};
|
||||
}};
|
||||
const customConfirm = async () => true;
|
||||
const md5 = () => 'hash';
|
||||
const show_message = message => messages.dialog = message;
|
||||
const btn = {classList: {add() {}, remove() {}}};
|
||||
const ctx = {
|
||||
%s,
|
||||
element: {querySelector: selector => elements[selector] ||= {innerHTML: '', style: {}}},
|
||||
grid: {scrollRowIntoView() {}, updateCell() {}, updateRow() {}},
|
||||
flyover: {show: (title, body) => Object.assign(messages, {title, body})},
|
||||
showLoading() {}, hideLoading() {}, renderGrid() {}, getModelList(rows) {return rows;}
|
||||
};
|
||||
ctx.ui = createUIStateManager(ctx.element, {
|
||||
status: '.nu-manager-status', message: '.nu-manager-message'
|
||||
});
|
||||
%s
|
||||
console.log(JSON.stringify({elements, messages, posts, rows: ctx.modelList}));
|
||||
""" % (_preamble(),
|
||||
JS.lift_declaration(COMMON, "export function createUIStateManager(element, selectors)"),
|
||||
JS.lift_declaration(COMMON, "export async function uninstallNodes(nodeList, options = {})"),
|
||||
json.dumps(packs or {}), status, json.dumps(TEXT_PAYLOAD), methods, action))
|
||||
|
||||
def test_title_fallback_is_escaped_at_its_source(self):
|
||||
out = self._run_panel("await ctx.loadData();", {TEXT_PAYLOAD: {"state": "enabled"}})
|
||||
row = out["rows"][0]
|
||||
self._assert_inert(row["title"], allowed_tags=set())
|
||||
self.assertEqual(html.unescape(row["title"]), TEXT_PAYLOAD)
|
||||
self.assertEqual(row["name"], TEXT_PAYLOAD)
|
||||
|
||||
def test_install_progress_and_errors_escape_raw_values(self):
|
||||
for status in (403, 500):
|
||||
with self.subTest(status=status):
|
||||
out = self._run_panel("await ctx.loadData(); await ctx.installModels(ctx.modelList, btn);",
|
||||
{TEXT_PAYLOAD: {"state": "enabled"}}, status)
|
||||
progress = out["elements"][".nu-manager-status"]["innerHTML"]
|
||||
self._assert_inert(progress, allowed_tags=set())
|
||||
self.assertEqual(html.unescape(progress), f"Install {TEXT_PAYLOAD} ...")
|
||||
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
|
||||
for error in errors:
|
||||
self._assert_inert(error, allowed_tags={"font"})
|
||||
self.assertIn(f"'{TEXT_PAYLOAD}': ", html.unescape(error))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
|
||||
|
||||
def test_flyover_escapes_workflow_filename(self):
|
||||
for filename in (TEXT_PAYLOAD, "workflow <Flux> & friends.json"):
|
||||
with self.subTest(filename=filename):
|
||||
out = self._run_panel("ctx.showUsageDetails(%s);" % json.dumps({
|
||||
"title": "My Pack", "workflowDetails": [{"filename": filename, "nodeCount": 2}],
|
||||
}))
|
||||
body = out["messages"]["body"]
|
||||
self._assert_inert(body, allowed_tags={"div"})
|
||||
self.assertIn(filename, html.unescape(body))
|
||||
self.assertIn("2 nodes", body)
|
||||
|
||||
def test_uninstall_failure_keeps_normalized_titles_readable(self):
|
||||
for name in ("Pack <Flux> & friends", TEXT_PAYLOAD):
|
||||
for has_title in (False, True):
|
||||
for status in (403, 500):
|
||||
with self.subTest(name=name, has_title=has_title, status=status):
|
||||
pack = {"state": "enabled"}
|
||||
if has_title:
|
||||
pack["title"] = html.escape(name)
|
||||
out = self._run_panel("await ctx.loadData(); await ctx.uninstallModels(ctx.modelList, btn);",
|
||||
{name: pack}, status)
|
||||
progress = out["elements"][".nu-manager-status"]["innerHTML"]
|
||||
self.assertEqual(html.unescape(progress), f"Uninstall {name} ...")
|
||||
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
|
||||
for error in errors:
|
||||
self._assert_inert(error, allowed_tags={"font"})
|
||||
self.assertIn(f"'{name}': ", html.unescape(error))
|
||||
if status == 500:
|
||||
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
|
||||
|
||||
def test_uninstall_helper_escapes_a_raw_name_fallback(self):
|
||||
out = self._run_panel("""
|
||||
await uninstallNodes([{name: %s}], {
|
||||
onProgress: msg => messages.progress = msg,
|
||||
onError: msg => messages.error = msg
|
||||
});
|
||||
""" % json.dumps(TEXT_PAYLOAD))
|
||||
for message in out["messages"].values():
|
||||
self._assert_inert(message, allowed_tags=set())
|
||||
self.assertIn(TEXT_PAYLOAD, html.unescape(message))
|
||||
self.assertEqual(out["posts"][0]["name"], TEXT_PAYLOAD)
|
||||
|
||||
def test_grid_has_no_keyword_highlight_path(self):
|
||||
# The premise the row states: this grid cannot hit the highlight defect.
|
||||
source = JS.text(ANALYZER)
|
||||
self.assertNotIn("highlightKeywordsFilter", source)
|
||||
self.assertNotIn("rowFilter:", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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>'), '<text>')
|
||||
with self.assertRaises(ModuleNotFoundError):
|
||||
util.sanitize_html_fragment('<p>notice</p>')
|
||||
|
||||
def test_bootstrap_installs_missing_or_outdated_nh3_before_importing_it(self):
|
||||
manifest = (ROOT / 'requirements.txt').read_text()
|
||||
commented_manifest = '\n# Dependencies\n--index-url https://pypi.org/simple\n' + manifest.replace(
|
||||
'nh3>=0.3.7', ' nh3>=0.3.7 # notice sanitizer',
|
||||
)
|
||||
for installed_version, requirements in itertools.product(
|
||||
(None, '0.3.0', '0.3.7rc1', '0.3.7', '0.4.0'), (manifest, commented_manifest),
|
||||
):
|
||||
with self.subTest(installed_version=installed_version, commented=requirements == commented_manifest):
|
||||
source = (ROOT / 'prestartup_script.py').read_text()
|
||||
node = next(n for n in ast.parse(source).body
|
||||
if isinstance(n, ast.FunctionDef) and n.name == 'ensure_dependencies')
|
||||
installer = Mock()
|
||||
namespace = {
|
||||
'__file__': str(ROOT / 'prestartup_script.py'),
|
||||
'os': os,
|
||||
're': re,
|
||||
'subprocess': types.SimpleNamespace(
|
||||
check_output=installer, CalledProcessError=subprocess.CalledProcessError,
|
||||
),
|
||||
'manager_util': types.SimpleNamespace(make_pip_cmd=lambda args: args),
|
||||
}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), '<bootstrap>', 'exec'), namespace)
|
||||
needs_install = installed_version in (None, '0.3.0', '0.3.7rc1')
|
||||
modules = {name: types.ModuleType(name) for name in ('git', 'toml', 'rich', 'chardet')}
|
||||
# An old native module must not be imported before pip upgrades it.
|
||||
modules['nh3'] = None if needs_install else types.ModuleType('nh3')
|
||||
failure = importlib.metadata.PackageNotFoundError('nh3') if installed_version is None else None
|
||||
with patch.dict(sys.modules, modules), patch(
|
||||
'importlib.metadata.version', return_value=installed_version, side_effect=failure,
|
||||
), patch('builtins.open', mock_open(read_data=requirements)), patch('builtins.print'), patch(
|
||||
'builtins.__import__', wraps=builtins.__import__,
|
||||
) as imports:
|
||||
namespace['ensure_dependencies']()
|
||||
nh3_imports = [call for call in imports.call_args_list if call.args[0] == 'nh3']
|
||||
self.assertEqual(len(nh3_imports), 0 if needs_install else 1)
|
||||
if needs_install:
|
||||
installer.assert_called_once_with(['install', '-r', str(ROOT / 'requirements.txt')])
|
||||
else:
|
||||
installer.assert_not_called()
|
||||
|
||||
def test_dependency_manifests_agree_on_notice_requirements(self):
|
||||
lines = (re.split(r'\s+#', line.strip(), maxsplit=1)[0]
|
||||
for line in (ROOT / 'requirements.txt').read_text().splitlines())
|
||||
pip_requirements = {
|
||||
req.name: req for req in map(Requirement, (line for line in lines if line and not line.startswith(('#', '-'))))
|
||||
}
|
||||
project_requirements = {
|
||||
req.name: req for req in map(Requirement, toml.load(ROOT / 'pyproject.toml')['project']['dependencies'])
|
||||
}
|
||||
for name in ('nh3', 'packaging'):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(str(pip_requirements[name]), str(project_requirements[name]))
|
||||
@@ -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()
|
||||
@@ -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&b=2" target="named" rel="opener"',
|
||||
'title="Docs" rel="opener" target="named" href="https://example.com/?a=1&b=2"',
|
||||
):
|
||||
with self.subTest(attrs=attrs):
|
||||
out = self._sanitize(f'<a {attrs}>Docs</a>')
|
||||
anchor = dict(parse(out).elements)['a']
|
||||
self.assertEqual(anchor['href'], 'https://example.com/?a=1&b=2')
|
||||
self.assertEqual(anchor['target'], '_blank')
|
||||
self.assertEqual(set(anchor['rel'].split()), {'noopener', 'noreferrer'})
|
||||
|
||||
def test_notice_url_schemes_remain_limited_to_http_https_and_relative(self):
|
||||
for url in ('ftp://example.com', 'mailto:a@example.com', 'java	script:alert`1`'):
|
||||
with self.subTest(url=url):
|
||||
out = self._sanitize(f'<a href="{url}">Link</a><img src="{url}">')
|
||||
for _, attrs in parse(out).elements:
|
||||
self.assertNotIn('href', attrs)
|
||||
self.assertNotIn('src', attrs)
|
||||
for url in ('http://example.com', 'https://example.com', '/relative', '#section'):
|
||||
with self.subTest(url=url):
|
||||
self.assertEqual(hrefs(self._sanitize(f'<a href="{url}">Link</a>')), [url])
|
||||
|
||||
def test_hidden_element_contents_are_not_exposed_as_notice_text(self):
|
||||
for tag in ('script', 'style', 'iframe', 'object', 'svg', 'math',
|
||||
'template', 'noscript', 'textarea', 'title'):
|
||||
with self.subTest(tag=tag):
|
||||
out = self._sanitize(f'<{tag}>hidden</{tag}><p>after</p>')
|
||||
self.assertNotIn('hidden', out)
|
||||
self.assertIn('after', out)
|
||||
|
||||
def test_a_mixed_fragment_keeps_the_good_and_drops_the_bad(self):
|
||||
"""One fragment carrying both, since a real wiki page would."""
|
||||
out = self._sanitize(
|
||||
'<p>Release <b>1.2</b></p>'
|
||||
'<script>window.__probe=1</script>'
|
||||
'<a href="https://example.com/notes">notes</a>'
|
||||
'<a href="%s">click</a>'
|
||||
'<img src="https://example.com/i.png" onerror="window.__probe=1">'
|
||||
% (SCHEME_PAYLOAD,)
|
||||
)
|
||||
self.assertNotIn("script", tags(out), out)
|
||||
self.assertEqual(handler_attrs(out), [], out)
|
||||
self.assertEqual(
|
||||
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [], out)
|
||||
self.assertIn("https://example.com/notes", hrefs(out), out)
|
||||
self.assertIn("b", tags(out), out)
|
||||
self.assertIn("img", tags(out), "the benign image was dropped: %r" % (out,))
|
||||
|
||||
|
||||
class NoticeWiringTest(unittest.TestCase):
|
||||
"""Check the notice route applies the sanitizer to its captured remote HTML."""
|
||||
|
||||
def test_get_notice_passes_the_captured_fragment_through_the_sanitizer(self):
|
||||
"""Verify the captured HTML is sanitized, not merely that the function is mentioned."""
|
||||
body = _function_source("get_notice")
|
||||
self.assertIn(
|
||||
"match.group(1)", body,
|
||||
"get_notice no longer captures the fragment the way this guard "
|
||||
"expects; re-derive it against the new shape rather than deleting "
|
||||
"the assertion.",
|
||||
)
|
||||
self.assertRegex(
|
||||
body, r"sanitize_html_fragment\s*\(\s*match\.group\(1\)\s*\)",
|
||||
"get_notice relays the captured wiki fragment without passing it "
|
||||
"through an allow-list normaliser. The client assigns the response "
|
||||
"body to innerHTML, so whatever the wiki page contains is what the "
|
||||
"browser builds. The call has to take the capture as its argument; "
|
||||
"naming the function elsewhere in the body is not wiring.",
|
||||
)
|
||||
self.assertNotRegex(
|
||||
body, re.compile(r"^\s*markdown_content\s*=\s*match\.group\(1\)\s*$",
|
||||
re.MULTILINE),
|
||||
"the raw capture is still assigned directly; the sanitizer must sit "
|
||||
"on this assignment, not beside it.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D2] share-service URLs
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class ShareDialogUrlTest(unittest.TestCase):
|
||||
"""Share-service URLs must use safe schemes and quoted attributes."""
|
||||
|
||||
def _preamble(self) -> str:
|
||||
span = JS.lift_span(
|
||||
COMMON, "export const SAFE_URL_SCHEMES",
|
||||
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
|
||||
return "\n".join([
|
||||
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
span.replace("export ", ""),
|
||||
])
|
||||
|
||||
def _render(self, expression: str, bindings: str) -> str:
|
||||
return run_node(
|
||||
"%s\n%s\nconsole.log(JSON.stringify({markup: %s}));\n"
|
||||
% (self._preamble(), bindings, expression)
|
||||
)["markup"]
|
||||
|
||||
def _assert_scheme_rejected(self, markup: str, label: str):
|
||||
bad = [h for h in hrefs(markup) if h.startswith(("javascript:", "data:"))]
|
||||
self.assertEqual(
|
||||
bad, [],
|
||||
"%s emits a service-chosen scheme as a clickable href: %r" % (label, markup),
|
||||
)
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
# --- comfyui-share-common.js, the fully service-controlled URL ----------
|
||||
def _common_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_COMMON),
|
||||
'this.final_message.innerHTML = "Your art has been shared:')
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_share_common_rejects_an_executing_scheme(self):
|
||||
# The href here is SINGLE-quoted, so the breakout variant uses `'`.
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_SINGLE_QUOTED):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._render(
|
||||
self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(payload),))
|
||||
self._assert_scheme_rejected(markup, "comfyui-share-common.js")
|
||||
|
||||
def test_share_common_still_links_an_ordinary_url(self):
|
||||
"""The leg a refuse-everything fix would fail."""
|
||||
markup = self._render(
|
||||
self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),))
|
||||
self.assertIn(
|
||||
BENIGN_URL, hrefs(markup),
|
||||
"an ordinary https URL must still become a working link: %r" % (markup,))
|
||||
|
||||
def test_every_share_anchor_severs_the_opener(self):
|
||||
"""Check opener isolation without imposing an order on rel tokens."""
|
||||
cases = [
|
||||
("comfyui-share-common.js", self._common_expression(),
|
||||
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-youml.js", self._youml_expression(),
|
||||
"const messagePrefix = 'shared.';\nconst recipePageUrl = %s;" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-openart.js", self._openart_expression(),
|
||||
"const url = %s;" % (_json(BENIGN_URL),)),
|
||||
("comfyui-share-copus.js", self._copus_expression(),
|
||||
"const url = %s;" % (_json(BENIGN_URL),)),
|
||||
]
|
||||
for name, expression, bindings in cases:
|
||||
with self.subTest(file=name):
|
||||
markup = self._render(expression, bindings)
|
||||
anchors = [attrs for tag, attrs in parse(markup).elements if tag == "a"]
|
||||
self.assertTrue(anchors, "no anchor produced by %s: %r" % (name, markup))
|
||||
for attrs in anchors:
|
||||
self.assertEqual(
|
||||
attrs.get("target"), "_blank",
|
||||
"%s: this guard is about _blank anchors; the target "
|
||||
"changed, so re-derive it: %r" % (name, markup))
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"%s opens a service-chosen page in a new tab without "
|
||||
"rel, so that page keeps a reference to this window: %r"
|
||||
% (name, markup))
|
||||
rel = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", rel, markup)
|
||||
self.assertIn("noreferrer", rel, markup)
|
||||
|
||||
# --- comfyui-share-youml.js, also fully service-controlled --------------
|
||||
def _youml_expression(self) -> str:
|
||||
span = JS.lift_span(SHARE_YOUML, "this.message.innerHTML = `${messagePrefix}",
|
||||
"visit it on YouML</a>`;")
|
||||
return span.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_share_youml_rejects_an_executing_scheme(self):
|
||||
# The href here is DOUBLE-quoted, so the breakout variant uses `"`.
|
||||
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_DOUBLE_QUOTED):
|
||||
with self.subTest(payload=payload):
|
||||
markup = self._render(
|
||||
self._youml_expression(),
|
||||
"const messagePrefix = 'Workflow has been shared.';\n"
|
||||
"const recipePageUrl = %s;" % (_json(payload),))
|
||||
self._assert_scheme_rejected(markup, "comfyui-share-youml.js")
|
||||
|
||||
# --- openart / copus: an id is interpolated into a URL PATH -------------
|
||||
# The scheme is hardcoded, so these cannot carry javascript:. What they CAN
|
||||
# carry is a quote: the id is a service-returned string dropped inside a
|
||||
# double-quoted attribute, so `"` closes it and the rest is markup. That is a
|
||||
# different failure from the two above and is asserted as such.
|
||||
def _openart_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_OPENART),
|
||||
"this.message.innerHTML = `Workflow has been shared successfully.")
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def _copus_expression(self) -> str:
|
||||
from js_lift import line_containing
|
||||
line = line_containing(JS.text(SHARE_COPUS),
|
||||
"this.message.innerHTML = `Workflow has been shared successfully.")
|
||||
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
|
||||
|
||||
def test_openart_id_cannot_break_out_of_the_href_attribute(self):
|
||||
markup = self._render(
|
||||
self._openart_expression(),
|
||||
'const url = "https://openart.ai/workflows/-/-/" + '
|
||||
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
def test_copus_id_cannot_break_out_of_the_href_attribute(self):
|
||||
markup = self._render(
|
||||
self._copus_expression(),
|
||||
'const url = "https://example.invalid/work/" + '
|
||||
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D3] the snapshot name
|
||||
# ---------------------------------------------------------------------------
|
||||
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
|
||||
class SnapshotNameTest(unittest.TestCase):
|
||||
"""Snapshot filenames must remain inert text in status messages."""
|
||||
|
||||
def _render(self, name: str) -> str:
|
||||
line = _unique_line(JS.text(SNAPSHOT), "self.updateMessage(`<BR><font color=")
|
||||
expression = _call_argument(line, "self.updateMessage(")
|
||||
return run_node(
|
||||
"%s\nconst target = {name: %s};\n"
|
||||
"console.log(JSON.stringify({markup: %s}));\n"
|
||||
% (JS.lift_declaration(COMMON, "export function sanitizeHTML("),
|
||||
_json(name), expression)
|
||||
)["markup"]
|
||||
|
||||
def test_a_crafted_snapshot_name_renders_as_text(self):
|
||||
markup = self._render(TEXT_PAYLOAD)
|
||||
self.assertEqual(handler_attrs(markup), [], markup)
|
||||
self.assertNotIn("img", tags(markup), markup)
|
||||
|
||||
def test_an_ordinary_snapshot_name_still_reads_normally(self):
|
||||
markup = self._render("2026-09-07_backup")
|
||||
self.assertIn("2026-09-07_backup", markup)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [D4] rel=noopener, and the defensive escapes
|
||||
# ---------------------------------------------------------------------------
|
||||
class ServerAnchorRelTest(unittest.TestCase):
|
||||
"""Converted [a/text](url) links must isolate the opener and remain usable."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.manager_util = load_manager_util()
|
||||
# staticmethod, or attribute access binds it and passes `self`
|
||||
# as the markdown argument.
|
||||
cls.convert = staticmethod(load_markdown_functions(cls.manager_util)["convert_markdown_to_html"])
|
||||
|
||||
def test_a_markdown_link_carries_rel_noopener_noreferrer(self):
|
||||
out = self.convert("see [a/the docs](https://example.com/docs)")
|
||||
anchors = [attrs for tag, attrs in parse(out).elements if tag == "a"]
|
||||
self.assertTrue(anchors, "no anchor was produced at all: %r" % (out,))
|
||||
for attrs in anchors:
|
||||
with self.subTest(attrs=attrs):
|
||||
self.assertIn(
|
||||
"rel", attrs,
|
||||
"the anchor opens a new tab without rel: %r. The opened page "
|
||||
"keeps a reference to this window." % (out,),
|
||||
)
|
||||
rel = attrs["rel"].lower().split()
|
||||
self.assertIn("noopener", rel, out)
|
||||
self.assertIn("noreferrer", rel, out)
|
||||
|
||||
def test_the_link_still_works(self):
|
||||
out = self.convert("see [a/the docs](https://example.com/docs)")
|
||||
self.assertIn("https://example.com/docs", hrefs(out), out)
|
||||
|
||||
|
||||
class ClientAnchorAndStateTest(unittest.TestCase):
|
||||
"""External-link isolation and rendering of unrecognised pack states."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.source = JS.text(MANAGER)
|
||||
|
||||
def test_the_title_link_severs_the_opener(self):
|
||||
marker = "link.target = '_blank';"
|
||||
index = _unique_index(self.source, marker)
|
||||
# max(0, ...) because a negative start would wrap to the END of the
|
||||
# string and silently hand back an unrelated window.
|
||||
window = self.source[max(0, index - 400):index + 400]
|
||||
self.assertRegex(
|
||||
window, r"link\.rel\s*=",
|
||||
"the title link opens a new tab without setting rel, so the pack's "
|
||||
"own repository page keeps a reference to this window.",
|
||||
)
|
||||
rel = re.search(r"link\.rel\s*=\s*['\"]([^'\"]*)['\"]", window)
|
||||
self.assertIsNotNone(rel, "link.rel is set from a non-literal: %r" % (window,))
|
||||
tokens = rel.group(1).lower().split()
|
||||
# Membership, not token order: 'noreferrer noopener' is the same thing,
|
||||
# and demanding one spelling would false-RED a correct fix. Matches how
|
||||
# ServerAnchorRelTest checks the server-side anchor.
|
||||
self.assertIn("noopener", tokens, window)
|
||||
self.assertIn("noreferrer", tokens, window)
|
||||
|
||||
@unittest.skipIf(NODE is None, "node is required")
|
||||
def test_selection_survives_unrecognised_and_prototype_names(self):
|
||||
sanitize = JS.lift_declaration(COMMON, "export function sanitizeHTML(")
|
||||
buttons = JS.lift_declaration(MANAGER, "\n\tgetActionButtons(")
|
||||
render = JS.lift_declaration(MANAGER, "\n\trenderSelected()")
|
||||
for state in ['not-a-known-install-group', 'constructor', '__proto__', TEXT_PAYLOAD, 'enabled']:
|
||||
with self.subTest(state=state):
|
||||
result = run_node(sanitize + "\nconst manager = {" + buttons + "," + render + """,
|
||||
grid: {hasMask: false, getSelectedRows: () => [{action: STATE, hash: 'fixture'}]},
|
||||
getFilterItem: () => null,
|
||||
showSelection(html) { this.html = html; }
|
||||
};
|
||||
manager.renderSelected();
|
||||
console.log(JSON.stringify({html: manager.html,
|
||||
buttons: manager.getActionButtons(STATE, {version: 'unknown'}, true)}));
|
||||
""".replace('STATE', _json(state)))
|
||||
self.assertIn('Selected <b>1</b>', result['html'])
|
||||
self.assertIn(state, unescape(result['html']))
|
||||
self.assertFalse(handler_attrs(result['html']))
|
||||
self.assertNotIn('img', tags(result['html']))
|
||||
if state == 'enabled':
|
||||
self.assertIn('<button', result['buttons'])
|
||||
else:
|
||||
self.assertEqual(result['buttons'], '')
|
||||
|
||||
|
||||
def _json(value) -> str:
|
||||
import json
|
||||
return json.dumps(value)
|
||||
|
||||
|
||||
def _unique_index(source: str, marker: str) -> int:
|
||||
"""Index of `marker`, refusing to guess when it is not unique.
|
||||
|
||||
js_lift.line_containing takes the FIRST occurrence silently. Every marker
|
||||
here is unique today; this makes a future second occurrence fail loudly
|
||||
instead of pinning whichever copy happens to come first.
|
||||
"""
|
||||
count = source.count(marker)
|
||||
assert count == 1, "marker %r occurs %d times; disambiguate it" % (marker, count)
|
||||
return source.index(marker)
|
||||
|
||||
|
||||
def _unique_line(source: str, marker: str) -> str:
|
||||
"""The single source line holding `marker`, with the same uniqueness rule."""
|
||||
index = _unique_index(source, marker)
|
||||
start = source.rfind("\n", 0, index) + 1
|
||||
return source[start:source.index("\n", index)]
|
||||
|
||||
|
||||
def _call_argument(line: str, call_marker: str) -> str:
|
||||
"""The argument text of `call_marker(...)`, dropping exactly one `)` and `;`.
|
||||
|
||||
NOT `.rstrip(");")`: rstrip takes a CHARACTER SET, so it eats every trailing
|
||||
`)`, `;` and `"`. A fix spelled `updateMessage(escape(`...`));` would lose a
|
||||
closing paren it needs and become a node syntax error -- which pytest would
|
||||
show as this guard failing, i.e. the fix would look like the defect.
|
||||
"""
|
||||
text = line.split(call_marker, 1)[1].rstrip()
|
||||
if text.endswith(";"):
|
||||
text = text[:-1].rstrip()
|
||||
assert text.endswith(")"), "unexpected call shape: %r" % (line.strip(),)
|
||||
return text[:-1]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user