mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-07-26 10:07:38 +08:00
Compare commits
567
Commits
3.39.2
...
571233fcc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571233fcc7 | ||
|
|
6ad8a24845 | ||
|
|
ab8ce7814a | ||
|
|
99d3e6ddac | ||
|
|
84c889b026 | ||
|
|
966813eebf | ||
|
|
c305db27fe | ||
|
|
62cb417cc1 | ||
|
|
bdab3680c4 | ||
|
|
293ea3a8c5 | ||
|
|
5e6a02590b | ||
|
|
0f4df5347c | ||
|
|
9d7a3adc8a | ||
|
|
5cb9c828ad | ||
|
|
28855800cd | ||
|
|
87882134e4 | ||
|
|
c903f8be4a | ||
|
|
267449c55d | ||
|
|
6178d27b01 | ||
|
|
4a21c02967 | ||
|
|
8e06ee6552 | ||
|
|
19e0bd6592 | ||
|
|
c4d775079b | ||
|
|
7e80e39fdb | ||
|
|
30e055f4f6 | ||
|
|
8183e23858 | ||
|
|
e99a2b7f4c | ||
|
|
ceac3168e4 | ||
|
|
c55592b332 | ||
|
|
afbc217f03 | ||
|
|
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 | ||
|
|
de560aed84 | ||
|
|
dc15dcf83c | ||
|
|
c780c600b5 | ||
|
|
eb479ff891 | ||
|
|
3772432847 | ||
|
|
86a7d88270 | ||
|
|
89809b29d3 | ||
|
|
194bcedcfc | ||
|
|
d60e603467 | ||
|
|
6428a1009d | ||
|
|
c4daf1dffd | ||
|
|
4a1c06b90a | ||
|
|
395bb24427 | ||
|
|
25c1cc0921 | ||
|
|
629ccba819 | ||
|
|
3d01524ee9 | ||
|
|
81c3c3d62c | ||
|
|
1aa6112c57 | ||
|
|
c571cf3ca9 | ||
|
|
4085957308 | ||
|
|
7ddad11d28 | ||
|
|
142f4ce365 | ||
|
|
3ec831a99b | ||
|
|
2d373448be | ||
|
|
56a54117ab | ||
|
|
dfa45a67e0 | ||
|
|
0ccaf67ed9 | ||
|
|
73686b372b | ||
|
|
cb46064d25 | ||
|
|
a0f4d401ec | ||
|
|
d6f480c911 | ||
|
|
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 | ||
|
|
2007eab26d | ||
|
|
d870273d4b | ||
|
|
448c6b2aab | ||
|
|
b30b7eaa25 | ||
|
|
9074ab900d | ||
|
|
6e453a697f | ||
|
|
90e05cd4ef | ||
|
|
962e2edb44 | ||
|
|
0d15786d5b | ||
|
|
fe5663a60f | ||
|
|
dd58ac8b99 | ||
|
|
a2aace6a23 | ||
|
|
0e9c0baef9 | ||
|
|
b770f27099 | ||
|
|
96ffcbd0be | ||
|
|
be5e019ec5 | ||
|
|
04e12f4b3b | ||
|
|
c94236a614 | ||
|
|
e637943e72 | ||
|
|
6d13fd025e | ||
|
|
4787204733 | ||
|
|
7dd05e822f | ||
|
|
b1a268501d | ||
|
|
0187da4672 | ||
|
|
ef27449962 | ||
|
|
03a93ac9ff | ||
|
|
6ecb39b409 | ||
|
|
31e9d281d5 | ||
|
|
7198541a34 | ||
|
|
e21017ad0b | ||
|
|
83e07578fd | ||
|
|
ad71567e8a | ||
|
|
23539a02a0 | ||
|
|
ac4cf1ce40 | ||
|
|
b8d7721e00 | ||
|
|
23056a0eb8 | ||
|
|
a801227663 | ||
|
|
8ffbde9fa8 | ||
|
|
a87171916d | ||
|
|
008e6ede56 | ||
|
|
f7d1bba7f0 | ||
|
|
dc0861b603 | ||
|
|
7bc06a07fa | ||
|
|
4dd60197c8 | ||
|
|
03d496d80d | ||
|
|
ffc5f03ed0 | ||
|
|
9d427effcb | ||
|
|
f4ba539d62 | ||
|
|
65ab25f0d4 | ||
|
|
83edc3d787 | ||
|
|
001adf19c9 | ||
|
|
d2459128db | ||
|
|
3ad3f0df39 | ||
|
|
535d288bf1 | ||
|
|
05076e56db | ||
|
|
6f887b012c | ||
|
|
eabd5ed9cd | ||
|
|
6079647938 | ||
|
|
effde1667b | ||
|
|
832344ca20 | ||
|
|
25f684c06f | ||
|
|
5ce422bcfa | ||
|
|
2f66249f51 | ||
|
|
fb90ad9e34 | ||
|
|
8be1f1494c | ||
|
|
613d2bd648 | ||
|
|
d7aad369a4 | ||
|
|
2030bb2428 | ||
|
|
e4657b2b0f | ||
|
|
ea3313c429 | ||
|
|
0219872906 | ||
|
|
60114405c1 | ||
|
|
00d58a213f | ||
|
|
7fba0abf02 | ||
|
|
c15f4a6117 | ||
|
|
1b935e439c | ||
|
|
5df7c9fbae | ||
|
|
285bec4f61 | ||
|
|
971d5a2ad1 | ||
|
|
96fc7a9bdf | ||
|
|
ddeca90708 | ||
|
|
299f7d9c74 | ||
|
|
93e6ff5168 | ||
|
|
8ec120e964 | ||
|
|
03befe1ac7 | ||
|
|
c88a9985c1 | ||
|
|
530f628523 | ||
|
|
5919b79752 | ||
|
|
eb12e015d5 | ||
|
|
3409ad6c3e | ||
|
|
3c24614aeb | ||
|
|
f41365abe9 | ||
|
|
b5defe2a38 | ||
|
|
ebb354a201 | ||
|
|
b181307098 | ||
|
|
e76457021c | ||
|
|
3cda7f602e | ||
|
|
d11db7e48c | ||
|
|
4d22ed9779 | ||
|
|
51edfa6194 | ||
|
|
5acd638ead | ||
|
|
fd8f1242e3 | ||
|
|
48acce370a | ||
|
|
db97557ba9 | ||
|
|
b66777aa0c | ||
|
|
77377eeddb | ||
|
|
bac31f1dc3 | ||
|
|
ae38dc9239 | ||
|
|
4f4172bf5e | ||
|
|
8742cc247b | ||
|
|
3c787f1fd2 | ||
|
|
438f26a3a2 | ||
|
|
9ad417cb69 | ||
|
|
c1869f4d1d | ||
|
|
1780916806 | ||
|
|
6d8d00e7f3 | ||
|
|
6097f88462 | ||
|
|
40787eface | ||
|
|
2757728935 | ||
|
|
7b3f032e77 | ||
|
|
e9020377ff | ||
|
|
7610b72825 | ||
|
|
76b876f4c8 | ||
|
|
4235ca33fe | ||
|
|
e5a10245f4 | ||
|
|
90ad0bc926 | ||
|
|
755a6f3c24 | ||
|
|
37027055f7 | ||
|
|
08cd0c4e27 | ||
|
|
774482cc00 | ||
|
|
bb46126fbd | ||
|
|
4faf4e05e3 | ||
|
|
404dde76c9 | ||
|
|
a58abc29d5 | ||
|
|
4ea05462e4 | ||
|
|
492f7eda6a | ||
|
|
e04224a2bd | ||
|
|
971af87529 | ||
|
|
920d70a26a | ||
|
|
45770ac2cb | ||
|
|
544ab8328c | ||
|
|
b06a85e1c2 | ||
|
|
3cad4eb209 | ||
|
|
c5c0749f85 | ||
|
|
c7711eb574 | ||
|
|
794161d81d | ||
|
|
bafe5004ca | ||
|
|
2944e5ef50 | ||
|
|
df1eaff802 | ||
|
|
cf5dfbf99d | ||
|
|
cd0b48de5a | ||
|
|
698ac53b0c | ||
|
|
2a126e0873 | ||
|
|
e3c9fea279 | ||
|
|
07a570d1f2 | ||
|
|
eec9d9c04f | ||
|
|
52564c3ad5 | ||
|
|
45ce612ac1 | ||
|
|
620d9fd387 | ||
|
|
e1c84570ea | ||
|
|
1bfaaf5d91 | ||
|
|
413c3b3855 | ||
|
|
2eae6faf46 | ||
|
|
b320c47e45 | ||
|
|
d57b0d7c4a | ||
|
|
4df0628a12 | ||
|
|
d8e3f531c7 | ||
|
|
40829b059a | ||
|
|
6c48c98900 | ||
|
|
934132e922 | ||
|
|
d2bb0c3b36 | ||
|
|
f57cc56285 | ||
|
|
63522345cb | ||
|
|
787457bf23 | ||
|
|
d13837ac5c | ||
|
|
c57ee0c6e8 | ||
|
|
53d924014d | ||
|
|
fcf7581431 | ||
|
|
53073a6015 | ||
|
|
c4fefd4721 | ||
|
|
d86f6196d0 | ||
|
|
aa4e7b553f | ||
|
|
389ca64bd9 | ||
|
|
f02e069d93 | ||
|
|
8627ac2f4c | ||
|
|
656ec027db | ||
|
|
83f63001e0 | ||
|
|
fd07a47db6 | ||
|
|
701d848fc4 | ||
|
|
0aa95ef88f | ||
|
|
e4eb87cc38 | ||
|
|
ca8c925fb9 | ||
|
|
0edd6607ae | ||
|
|
44aa47126e | ||
|
|
2f40e125be | ||
|
|
e6a2ae829c | ||
|
|
9390270879 | ||
|
|
74d1e9d296 | ||
|
|
8138954fc6 | ||
|
|
af1c698117 | ||
|
|
e8e0e884f2 | ||
|
|
379fad3809 | ||
|
|
bb0ef5bdc3 | ||
|
|
9617b0e56e | ||
|
|
f20f8549e6 | ||
|
|
82135ab168 | ||
|
|
2cb7f021e9 | ||
|
|
29a59595b9 | ||
|
|
58a1051c46 | ||
|
|
53bec8be40 | ||
|
|
162f25f570 | ||
|
|
5d0112f768 | ||
|
|
695d87cc0a | ||
|
|
f2ba36ec3a | ||
|
|
6b83a5a16e | ||
|
|
10aff14af5 | ||
|
|
7634a08073 | ||
|
|
92cf872f1b | ||
|
|
c916174499 | ||
|
|
4ad8735c89 | ||
|
|
2416aa2fc9 | ||
|
|
b2d6a4eefe | ||
|
|
216e8586bc | ||
|
|
0cbbe25d89 | ||
|
|
a471263602 | ||
|
|
a062f6f459 | ||
|
|
6d87c3e981 | ||
|
|
acc11e1bda |
@@ -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,79 @@ 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.
|
||||
|
||||
|
||||
# Disclaimer
|
||||
|
||||
|
||||
+14112
-1282
File diff suppressed because it is too large
Load Diff
+18487
-1690
File diff suppressed because it is too large
Load Diff
+13485
-7300
File diff suppressed because it is too large
Load Diff
+35
-7
@@ -44,7 +44,7 @@ import manager_migration
|
||||
from node_package import InstalledNodePackage
|
||||
|
||||
|
||||
version_code = [3, 39, 2]
|
||||
version_code = [3, 41]
|
||||
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
|
||||
|
||||
|
||||
@@ -921,7 +921,7 @@ class UnifiedManager:
|
||||
except:
|
||||
return version.parse("0.0.0")
|
||||
|
||||
def execute_install_script(self, url, repo_path, instant_execution=False, lazy_mode=False, no_deps=False):
|
||||
def execute_install_script(self, url, repo_path, instant_execution=False, lazy_mode=False, no_deps=False, selected_dependencies=None):
|
||||
install_script_path = os.path.join(repo_path, "install.py")
|
||||
requirements_path = os.path.join(repo_path, "requirements.txt")
|
||||
|
||||
@@ -933,8 +933,19 @@ class UnifiedManager:
|
||||
if os.path.exists(requirements_path) and not no_deps:
|
||||
print("Install: pip packages")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
|
||||
# Create a set of selected dependency lines for quick lookup
|
||||
selected_lines = set()
|
||||
if selected_dependencies:
|
||||
for dep in selected_dependencies:
|
||||
selected_lines.add(dep.get('line', '').strip())
|
||||
|
||||
lines = manager_util.robust_readlines(requirements_path)
|
||||
for line in lines:
|
||||
# Skip if selected_dependencies is provided and this line is not in the selected list
|
||||
if selected_dependencies is not None and line.strip() not in selected_lines:
|
||||
continue
|
||||
|
||||
package_name = remap_pip_package(line.strip())
|
||||
if package_name and not package_name.startswith('#') and package_name not in self.processed_install:
|
||||
self.processed_install.add(package_name)
|
||||
@@ -1342,7 +1353,7 @@ class UnifiedManager:
|
||||
|
||||
return result
|
||||
|
||||
def repo_install(self, url: str, repo_path: str, instant_execution=False, no_deps=False, return_postinstall=False):
|
||||
def repo_install(self, url: str, repo_path: str, instant_execution=False, no_deps=False, return_postinstall=False, selected_dependencies=None):
|
||||
result = ManagedResult('install-git')
|
||||
result.append(url)
|
||||
|
||||
@@ -1369,7 +1380,7 @@ class UnifiedManager:
|
||||
repo.close()
|
||||
|
||||
def postinstall():
|
||||
return self.execute_install_script(url, repo_path, instant_execution=instant_execution, no_deps=no_deps)
|
||||
return self.execute_install_script(url, repo_path, instant_execution=instant_execution, no_deps=no_deps, selected_dependencies=selected_dependencies)
|
||||
|
||||
if return_postinstall:
|
||||
return result.with_postinstall(postinstall)
|
||||
@@ -1468,7 +1479,7 @@ class UnifiedManager:
|
||||
else:
|
||||
return self.cnr_switch_version(node_id, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall).with_ver('cnr')
|
||||
|
||||
async def install_by_id(self, node_id: str, version_spec=None, channel=None, mode=None, instant_execution=False, no_deps=False, return_postinstall=False):
|
||||
async def install_by_id(self, node_id: str, version_spec=None, channel=None, mode=None, instant_execution=False, no_deps=False, return_postinstall=False, selected_dependencies=None):
|
||||
"""
|
||||
priority if version_spec == None
|
||||
1. CNR latest
|
||||
@@ -1519,7 +1530,7 @@ class UnifiedManager:
|
||||
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)
|
||||
res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall, selected_dependencies=selected_dependencies)
|
||||
if res.result:
|
||||
if version_spec == 'unknown':
|
||||
self.unknown_active_nodes[node_id] = repo_url, to_path
|
||||
@@ -1699,6 +1710,8 @@ def write_config():
|
||||
'always_lazy_install': get_config()['always_lazy_install'],
|
||||
'network_mode': get_config()['network_mode'],
|
||||
'db_mode': get_config()['db_mode'],
|
||||
'allow_git_url_install': get_config()['allow_git_url_install'],
|
||||
'allow_pip_install': get_config()['allow_pip_install'],
|
||||
}
|
||||
|
||||
# Sanitize all string values to prevent CRLF injection attacks
|
||||
@@ -1745,6 +1758,8 @@ 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),
|
||||
}
|
||||
manager_migration.force_security_level_if_needed(result)
|
||||
return result
|
||||
@@ -1774,6 +1789,8 @@ 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,
|
||||
}
|
||||
manager_migration.force_security_level_if_needed(result)
|
||||
return result
|
||||
@@ -1968,7 +1985,7 @@ def __win_check_git_pull(path):
|
||||
process.wait()
|
||||
|
||||
|
||||
def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=False, no_deps=False):
|
||||
def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=False, no_deps=False, selected_dependencies=None):
|
||||
# import ipdb; ipdb.set_trace()
|
||||
install_script_path = os.path.join(repo_path, "install.py")
|
||||
requirements_path = os.path.join(repo_path, "requirements.txt")
|
||||
@@ -1980,6 +1997,13 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
|
||||
if os.path.exists(requirements_path) and not no_deps:
|
||||
print("Install: pip packages")
|
||||
pip_fixer = manager_util.PIPFixer(manager_util.get_installed_packages(), comfy_path, manager_files_path)
|
||||
|
||||
# Create a set of selected dependency lines for quick lookup
|
||||
selected_lines = set()
|
||||
if selected_dependencies:
|
||||
for dep in selected_dependencies:
|
||||
selected_lines.add(dep.get('line', '').strip())
|
||||
|
||||
with open(requirements_path, "r") as requirements_file:
|
||||
for line in requirements_file:
|
||||
#handle comments
|
||||
@@ -1990,6 +2014,10 @@ def execute_install_script(url, repo_path, lazy_mode=False, instant_execution=Fa
|
||||
else:
|
||||
line = line.split('#')[0].strip()
|
||||
|
||||
# Skip if selected_dependencies is provided and this line is not in the selected list
|
||||
if selected_dependencies is not None and line.strip() not in selected_lines:
|
||||
continue
|
||||
|
||||
package_name = remap_pip_package(line.strip())
|
||||
|
||||
if package_name and not package_name.startswith('#'):
|
||||
|
||||
+508
-86
@@ -35,6 +35,8 @@ SECURITY_MESSAGE_MIDDLE_OR_BELOW = "ERROR: To use this action, a security_level
|
||||
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_FLAG_GIT_URL = "ERROR: This action requires 'allow_git_url_install = true' in config.ini ([default] section). This setting is independent of security_level. Reference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_FLAG_PIP = "ERROR: This action requires 'allow_pip_install = true' in config.ini ([default] section). This setting is independent of security_level. Reference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
@@ -82,6 +84,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)
|
||||
|
||||
|
||||
@@ -305,13 +320,72 @@ 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]
|
||||
@@ -443,7 +517,12 @@ async def task_worker():
|
||||
global tasks_in_progress
|
||||
|
||||
async def do_install(item) -> str:
|
||||
ui_id, node_spec_str, channel, mode, skip_post_install = item
|
||||
if len(item) == 6:
|
||||
ui_id, node_spec_str, channel, mode, skip_post_install, selected_dependencies = item
|
||||
else:
|
||||
# Backward compatibility
|
||||
ui_id, node_spec_str, channel, mode, skip_post_install = item
|
||||
selected_dependencies = []
|
||||
|
||||
try:
|
||||
node_spec = core.unified_manager.resolve_node_spec(node_spec_str)
|
||||
@@ -452,7 +531,7 @@ 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)
|
||||
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install, selected_dependencies=selected_dependencies)
|
||||
# discard post install if skip_post_install mode
|
||||
|
||||
if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']:
|
||||
@@ -737,16 +816,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)
|
||||
|
||||
@@ -764,7 +846,7 @@ async def fetch_updates(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@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 +856,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':
|
||||
@@ -1006,14 +1091,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 +1114,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 +1153,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)
|
||||
@@ -1228,8 +1318,11 @@ async def reinstall_custom_node(request):
|
||||
await install_custom_node(request)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -1270,6 +1363,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,11 +1412,23 @@ 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):
|
||||
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)
|
||||
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_GENERAL)
|
||||
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
|
||||
|
||||
install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install
|
||||
# Get selected dependencies if provided
|
||||
selected_dependencies = json_data.get('selectedDependencies', [])
|
||||
install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install, selected_dependencies
|
||||
task_queue.put(("install", install_item))
|
||||
|
||||
return web.Response(status=200)
|
||||
@@ -1311,8 +1436,11 @@ async def install_custom_node(request):
|
||||
|
||||
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 +1481,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)
|
||||
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,16 +1511,289 @@ 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)
|
||||
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)
|
||||
|
||||
|
||||
@routes.post("/customnode/analyze_dependencies")
|
||||
async def analyze_dependencies(request):
|
||||
"""
|
||||
Analyze dependencies for a custom node from git URL.
|
||||
Fetches requirements.txt, checks installed packages, and returns dependency list with status.
|
||||
"""
|
||||
try:
|
||||
json_data = await request.json()
|
||||
url = json_data.get('url')
|
||||
commit_id = json_data.get('commitId')
|
||||
branch = json_data.get('branch')
|
||||
|
||||
if not url:
|
||||
return web.json_response({'error': 'URL is required'}, status=400)
|
||||
|
||||
# Fetch requirements.txt from git repository
|
||||
requirements_content = await fetch_requirements_from_git(url, commit_id, branch)
|
||||
|
||||
if requirements_content is None:
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'requirements': None,
|
||||
'dependencies': [],
|
||||
'noRequirementsFile': True
|
||||
})
|
||||
|
||||
# Parse requirements
|
||||
dependencies = parse_requirements(requirements_content)
|
||||
|
||||
# Get installed packages
|
||||
installed_packages = manager_util.get_installed_packages()
|
||||
|
||||
# Analyze each dependency with subdependencies
|
||||
analyzed_dependencies = []
|
||||
for dep_line in dependencies:
|
||||
if not dep_line.strip() or dep_line.strip().startswith('#'):
|
||||
continue
|
||||
|
||||
# Parse dependency line
|
||||
parsed = manager_util.parse_requirement_line(dep_line)
|
||||
if not parsed:
|
||||
continue
|
||||
|
||||
package_name = parsed.get('package')
|
||||
if not package_name:
|
||||
# Fallback: extract from line if package is missing
|
||||
import re
|
||||
match = re.match(r'^([a-zA-Z0-9_.-]+)', dep_line.strip())
|
||||
package_name = match.group(1) if match else "Unknown"
|
||||
|
||||
version_spec = parsed.get('version')
|
||||
# Convert version_spec to string if it's a StrictVersion object
|
||||
if version_spec is not None:
|
||||
version_spec = str(version_spec)
|
||||
|
||||
normalized_name = package_name.lower().replace('-', '_')
|
||||
|
||||
# Check if already installed
|
||||
installed_version = installed_packages.get(normalized_name)
|
||||
|
||||
status = 'new'
|
||||
if installed_version:
|
||||
status = 'installed'
|
||||
|
||||
# Convert version to string if it's not already (handle StrictVersion objects)
|
||||
current_version_str = str(installed_version) if installed_version else None
|
||||
|
||||
# Get subdependencies using pip install --dry-run
|
||||
# This is optional and failures should not block the main flow
|
||||
subdependencies = []
|
||||
# Skip subdependency analysis for already installed packages (not needed)
|
||||
if status != 'installed':
|
||||
try:
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Run pip install --dry-run to get subdependencies
|
||||
# Some packages like pymeshlab can take longer due to complex dependency resolution
|
||||
# Use a reasonable timeout - if it times out, we'll continue without subdependencies
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-m', 'pip', 'install', '--dry-run', dep_line.strip()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45 # Increased timeout to 45 seconds
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
if output:
|
||||
subdependencies = parse_dry_run_output(output, package_name, installed_packages)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout is not critical - continue without subdependencies
|
||||
logging.debug(f"Subdependency analysis timed out for {package_name} (skipping subdependencies)")
|
||||
subdependencies = []
|
||||
except Exception as e:
|
||||
# Any other error is not critical - continue without subdependencies
|
||||
logging.debug(f"Failed to analyze subdependencies for {package_name}: {e}")
|
||||
subdependencies = []
|
||||
|
||||
# Add main dependency (always add, even if subdependency analysis failed)
|
||||
# Ensure all fields are properly set and clean
|
||||
clean_package_name = str(package_name).strip() if package_name else "Unknown"
|
||||
# Remove any None/null strings that might have been concatenated
|
||||
clean_package_name = clean_package_name.replace('None', '').replace('null', '').strip()
|
||||
if not clean_package_name:
|
||||
clean_package_name = "Unknown"
|
||||
|
||||
analyzed_dependencies.append({
|
||||
'name': clean_package_name,
|
||||
'version': str(version_spec) if version_spec else None,
|
||||
'line': dep_line.strip(),
|
||||
'status': status,
|
||||
'currentVersion': current_version_str,
|
||||
'selected': status != 'installed', # Deselect if already installed
|
||||
'subdependencies': subdependencies
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'requirements': requirements_content,
|
||||
'dependencies': analyzed_dependencies,
|
||||
'noRequirementsFile': False
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error analyzing dependencies: {e}")
|
||||
traceback.print_exc()
|
||||
return web.json_response({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
def parse_requirements(content):
|
||||
"""Parse requirements.txt content into list of dependency lines."""
|
||||
lines = []
|
||||
for line in content.split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def parse_dry_run_output(output, parent_name, installed_packages):
|
||||
"""Parse pip install --dry-run output to extract subdependencies."""
|
||||
import re
|
||||
subdependencies = []
|
||||
subdeps_map = {}
|
||||
|
||||
lines = output.split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
# Look for "Collecting package==version" lines
|
||||
if 'Collecting ' in line and 'Using cached' not in line:
|
||||
# Match: "Collecting package==version" or "Collecting package"
|
||||
match = re.search(r'Collecting\s+([a-zA-Z0-9_.-]+(?:\[[^\]]+\])?)(?:\s*==\s*([^\s\(]+))?', line)
|
||||
if match:
|
||||
dep_name = match.group(1).split('[')[0].strip()
|
||||
# Clean the name - remove any None/null strings
|
||||
if dep_name:
|
||||
dep_name = dep_name.replace('None', '').replace('null', '').strip()
|
||||
dep_version = match.group(2).strip() if match.group(2) else None
|
||||
# Clean version too
|
||||
if dep_version:
|
||||
dep_version = dep_version.replace('None', '').replace('null', '').strip() or None
|
||||
|
||||
# Skip the parent package itself
|
||||
if dep_name.lower() == parent_name.lower():
|
||||
continue
|
||||
|
||||
# Normalize name
|
||||
normalized_name = dep_name.lower().replace('-', '_')
|
||||
|
||||
# Check if already in map (avoid duplicates)
|
||||
if normalized_name not in subdeps_map:
|
||||
# Check if already installed
|
||||
installed_version = installed_packages.get(normalized_name)
|
||||
status = 'installed' if installed_version else 'new'
|
||||
current_version_str = str(installed_version) if installed_version else None
|
||||
|
||||
# Ensure name is always a string, not None
|
||||
if not dep_name:
|
||||
dep_name = "Unknown"
|
||||
|
||||
# Clean the name - remove any None/null strings
|
||||
clean_dep_name = str(dep_name).strip().replace('None', '').replace('null', '').strip()
|
||||
if not clean_dep_name:
|
||||
clean_dep_name = "Unknown"
|
||||
|
||||
subdeps_map[normalized_name] = {
|
||||
'name': clean_dep_name,
|
||||
'version': str(dep_version) if dep_version else None,
|
||||
'status': status,
|
||||
'currentVersion': current_version_str,
|
||||
'selected': status != 'installed'
|
||||
}
|
||||
|
||||
# Also look for "Would install" lines which have more accurate version info
|
||||
if 'Would install' in line:
|
||||
# Match: "Would install package-version"
|
||||
match = re.search(r'Would install\s+([a-zA-Z0-9_.-]+)-([\d.]+)', line)
|
||||
if match:
|
||||
dep_name = match.group(1)
|
||||
dep_version = match.group(2)
|
||||
normalized_name = dep_name.lower().replace('-', '_')
|
||||
|
||||
if normalized_name in subdeps_map:
|
||||
# Update with more accurate version
|
||||
subdeps_map[normalized_name]['version'] = dep_version
|
||||
|
||||
# Convert map to list
|
||||
for normalized_name, dep_info in subdeps_map.items():
|
||||
subdependencies.append(dep_info)
|
||||
|
||||
return subdependencies
|
||||
|
||||
|
||||
async def fetch_requirements_from_git(url, commit_id=None, branch=None):
|
||||
"""
|
||||
Fetch requirements.txt from a git repository URL.
|
||||
Supports GitHub URLs by converting to raw.githubusercontent.com.
|
||||
"""
|
||||
try:
|
||||
# Extract GitHub repo info
|
||||
if 'github.com' in url:
|
||||
# Convert to raw GitHub URL
|
||||
url = url.rstrip('/')
|
||||
if url.endswith('.git'):
|
||||
url = url[:-4]
|
||||
|
||||
# Extract owner/repo
|
||||
match = re.search(r'github\.com[:/]([^/]+)/([^/]+)', url)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
owner = match.group(1)
|
||||
repo = match.group(2)
|
||||
|
||||
# Build raw URL
|
||||
if commit_id:
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{commit_id}/requirements.txt"
|
||||
elif branch:
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/requirements.txt"
|
||||
else:
|
||||
raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/main/requirements.txt"
|
||||
|
||||
# Try to fetch using aiohttp
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(raw_url) as response:
|
||||
if response.status == 200:
|
||||
return await response.text()
|
||||
# Try with master branch if main fails
|
||||
if 'main' in raw_url:
|
||||
raw_url = raw_url.replace('/main/', '/master/')
|
||||
async with session.get(raw_url) as response2:
|
||||
if response2.status == 200:
|
||||
return await response2.text()
|
||||
else:
|
||||
# For non-GitHub URLs, we'd need to clone temporarily
|
||||
# For now, return None (can be enhanced later)
|
||||
logging.warning(f"Non-GitHub URL not fully supported for dependency analysis: {url}")
|
||||
return None
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"Error fetching requirements from git: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@routes.post("/manager/queue/uninstall")
|
||||
async def uninstall_custom_node(request):
|
||||
if not is_allowed_security_level('middle'):
|
||||
@@ -1427,8 +1838,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 +1859,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,83 +1941,87 @@ 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)
|
||||
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)
|
||||
|
||||
# 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)
|
||||
@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)
|
||||
|
||||
# Return current value if not available
|
||||
return web.Response(text=core.manager_funcs.get_current_preview_method(), status=200)
|
||||
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 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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -1700,8 +2119,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()
|
||||
|
||||
+53
-7
@@ -53,6 +53,40 @@ And kill and remove /tmp/ultralytics_runner
|
||||
|
||||
The version 8.3.41 to 8.3.42 of the Ultralytics package you installed is compromised. Please uninstall that version and reinstall the latest version.
|
||||
https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situation/
|
||||
""",
|
||||
"litellm==1.82.7": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall litellm
|
||||
|
||||
The litellm PyPI package versions 1.82.7 and 1.82.8 were compromised via a supply chain attack.
|
||||
Malicious code harvests SSH keys, environment variables, API keys, cloud credentials, and exfiltrates them to an attacker-controlled server.
|
||||
Version 1.82.8 also installs a .pth file that executes malware on ANY Python startup, even without importing litellm.
|
||||
|
||||
1. Uninstall litellm immediately.
|
||||
2. Assume all credentials accessible to the litellm environment are compromised.
|
||||
3. Rotate all API keys, cloud credentials, SSH keys, and database passwords.
|
||||
4. Check site-packages for unexpected .pth files (e.g. litellm_init.pth) and remove them.
|
||||
5. Run a full malware scan.
|
||||
|
||||
Details: https://github.com/BerriAI/litellm/issues/24518
|
||||
Advisory: PYSEC-2026-2
|
||||
""",
|
||||
"litellm==1.82.8": f"""
|
||||
Execute following commands:
|
||||
{sys.executable} -m pip uninstall litellm
|
||||
|
||||
The litellm PyPI package versions 1.82.7 and 1.82.8 were compromised via a supply chain attack.
|
||||
Malicious code harvests SSH keys, environment variables, API keys, cloud credentials, and exfiltrates them to an attacker-controlled server.
|
||||
Version 1.82.8 also installs a .pth file that executes malware on ANY Python startup, even without importing litellm.
|
||||
|
||||
1. Uninstall litellm immediately.
|
||||
2. Assume all credentials accessible to the litellm environment are compromised.
|
||||
3. Rotate all API keys, cloud credentials, SSH keys, and database passwords.
|
||||
4. Check site-packages for unexpected .pth files (e.g. litellm_init.pth) and remove them.
|
||||
5. Run a full malware scan.
|
||||
|
||||
Details: https://github.com/BerriAI/litellm/issues/24518
|
||||
Advisory: PYSEC-2026-2
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -60,7 +94,10 @@ https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situati
|
||||
|
||||
pip_blacklist = {
|
||||
"AppleBotzz": "ComfyUI_LLMVISION",
|
||||
"ultralytics==8.3.41": "ultralytics==8.3.41"
|
||||
"ultralytics==8.3.41": "ultralytics==8.3.41",
|
||||
"ultralytics==8.3.42": "ultralytics==8.3.42",
|
||||
"litellm==1.82.7": "litellm==1.82.7",
|
||||
"litellm==1.82.8": "litellm==1.82.8",
|
||||
}
|
||||
|
||||
file_blacklist = {
|
||||
@@ -93,10 +130,15 @@ https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situati
|
||||
print(f"[SECURITY ALERT] custom node '{k}' is dangerous.")
|
||||
detected.add(v)
|
||||
|
||||
installed_pip_set = set(installed_pips.strip().split('\n'))
|
||||
|
||||
for k, v in pip_blacklist.items():
|
||||
if k in installed_pips:
|
||||
detected.add(v)
|
||||
break
|
||||
if '==' in k:
|
||||
if k in installed_pip_set:
|
||||
detected.add(v)
|
||||
else:
|
||||
if any(line.split('==')[0] == k for line in installed_pip_set):
|
||||
detected.add(v)
|
||||
|
||||
for k, v in file_blacklist.items():
|
||||
for x in v:
|
||||
@@ -105,10 +147,14 @@ https://blog.comfy.org/comfyui-statement-on-the-ultralytics-crypto-miner-situati
|
||||
break
|
||||
|
||||
if len(detected) > 0:
|
||||
for line in installed_pips.split('\n'):
|
||||
for line in installed_pip_set:
|
||||
for k, v in pip_blacklist.items():
|
||||
if k in line:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
if '==' in k:
|
||||
if line == k:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
else:
|
||||
if line.split('==')[0] == k:
|
||||
print(f"[SECURITY ALERT] '{line}' is dangerous.")
|
||||
|
||||
print("\n########################################################################")
|
||||
print(" Malware has been detected, forcibly terminating ComfyUI execution.")
|
||||
|
||||
+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;
|
||||
|
||||
+14
-14
@@ -470,12 +470,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 +625,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}`);
|
||||
}
|
||||
@@ -769,10 +769,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 +784,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 +813,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.');
|
||||
}
|
||||
}
|
||||
@@ -967,7 +967,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 +1043,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 +1087,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 +1152,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 +1171,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);
|
||||
|
||||
+10
-6
@@ -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, set 'allow_pip_install = true' in config.ini ([default] section), then restart ComfyUI (the config is read once at startup). This setting is independent of security_level.");
|
||||
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, set 'allow_git_url_install = true' in config.ini ([default] section), then restart ComfyUI (the config is read once at startup). This setting is independent of security_level.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -678,7 +678,7 @@ export class ComponentBuilderDialog extends ComfyDialog {
|
||||
|
||||
let orig_handleFile = app.handleFile;
|
||||
|
||||
async function handleFile(file) {
|
||||
async function handleFile(file, ...args) {
|
||||
if (file.name?.endsWith(".json") || file.name?.endsWith(".pack")) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
@@ -694,7 +694,7 @@ async function handleFile(file) {
|
||||
await handle_import_components(jsonContent);
|
||||
}
|
||||
else {
|
||||
orig_handleFile.call(app, file);
|
||||
orig_handleFile.call(app, file, ...args);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
@@ -702,7 +702,7 @@ async function handleFile(file) {
|
||||
return;
|
||||
}
|
||||
|
||||
orig_handleFile.call(app, file);
|
||||
orig_handleFile.call(app, file, ...args);
|
||||
}
|
||||
|
||||
app.handleFile = handleFile;
|
||||
@@ -780,7 +780,7 @@ export function set_component_policy(v) {
|
||||
|
||||
let graphToPrompt = app.graphToPrompt;
|
||||
app.graphToPrompt = async function () {
|
||||
let p = await graphToPrompt.call(app);
|
||||
let p = await graphToPrompt.apply(app, arguments);
|
||||
try {
|
||||
let groupNodes = p.workflow.extra?.groupNodes;
|
||||
if(groupNodes) {
|
||||
|
||||
+645
-19
@@ -67,6 +67,7 @@ export class CustomNodesManager {
|
||||
this.filter = '';
|
||||
this.keywords = '';
|
||||
this.restartMap = {};
|
||||
this.analyzeDependenciesBeforeInstall = false; // Default: false
|
||||
|
||||
this.init();
|
||||
|
||||
@@ -77,6 +78,36 @@ export class CustomNodesManager {
|
||||
}
|
||||
|
||||
init() {
|
||||
// Create checkbox for dependency analysis
|
||||
const analyzeDepsCheckbox = $el("input", {
|
||||
type: "checkbox",
|
||||
id: "cn-analyze-deps-checkbox",
|
||||
checked: this.analyzeDependenciesBeforeInstall,
|
||||
onchange: (e) => {
|
||||
this.analyzeDependenciesBeforeInstall = e.target.checked;
|
||||
},
|
||||
style: {
|
||||
marginRight: "6px",
|
||||
cursor: "pointer"
|
||||
}
|
||||
});
|
||||
|
||||
const analyzeDepsLabel = $el("label", {
|
||||
for: "cn-analyze-deps-checkbox",
|
||||
style: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
fontSize: "12px",
|
||||
marginRight: "10px",
|
||||
whiteSpace: "nowrap"
|
||||
}
|
||||
}, [
|
||||
analyzeDepsCheckbox,
|
||||
$el("span", { textContent: "Analyse dependencies before node installation" })
|
||||
]);
|
||||
|
||||
const header = $el("div.cn-manager-header.px-2", {}, [
|
||||
// $el("label", {}, [
|
||||
// $el("span", { textContent: "Filter" }),
|
||||
@@ -84,6 +115,7 @@ export class CustomNodesManager {
|
||||
// ]),
|
||||
createSettingsCombo("Filter", $el("select.cn-manager-filter")),
|
||||
$el("input.cn-manager-keywords.p-inputtext.p-component", { type: "search", placeholder: "Search" }),
|
||||
analyzeDepsLabel,
|
||||
$el("div.cn-manager-status"),
|
||||
$el("div.cn-flex-auto"),
|
||||
$el("div.cn-manager-channel")
|
||||
@@ -105,6 +137,421 @@ export class CustomNodesManager {
|
||||
this.initGrid();
|
||||
}
|
||||
|
||||
showDependencySelectorDialog(dependencies, onSelect) {
|
||||
const dialog = new ComfyDialog();
|
||||
dialog.element.style.zIndex = 1100;
|
||||
dialog.element.style.width = "900px";
|
||||
dialog.element.style.maxHeight = "80vh";
|
||||
dialog.element.style.padding = "0";
|
||||
dialog.element.style.backgroundColor = "#2a2a2a";
|
||||
dialog.element.style.border = "1px solid #3a3a3a";
|
||||
dialog.element.style.borderRadius = "8px";
|
||||
dialog.element.style.boxSizing = "border-box";
|
||||
dialog.element.style.overflow = "hidden";
|
||||
|
||||
const contentStyle = {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "20px",
|
||||
boxSizing: "border-box",
|
||||
gap: "15px"
|
||||
};
|
||||
|
||||
// Create scrollable table container with sticky header
|
||||
const tableContainer = $el("div", {
|
||||
style: {
|
||||
maxHeight: "500px",
|
||||
overflowY: "auto",
|
||||
border: "1px solid #4a4a4a",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "#1a1a1a",
|
||||
position: "relative"
|
||||
}
|
||||
});
|
||||
|
||||
// Create table
|
||||
const table = $el("table", {
|
||||
style: {
|
||||
width: "100%",
|
||||
borderCollapse: "separate",
|
||||
borderSpacing: "0",
|
||||
fontSize: "14px"
|
||||
}
|
||||
});
|
||||
|
||||
// Create table header with sticky positioning
|
||||
const thead = $el("thead", {
|
||||
style: {
|
||||
position: "sticky",
|
||||
top: "0",
|
||||
zIndex: "10",
|
||||
backgroundColor: "#2a2a2a",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.3)"
|
||||
}
|
||||
}, [
|
||||
$el("tr", {
|
||||
style: {
|
||||
backgroundColor: "#2a2a2a",
|
||||
borderBottom: "2px solid #4a4a4a"
|
||||
}
|
||||
}, [
|
||||
$el("th", {
|
||||
textContent: "",
|
||||
style: {
|
||||
padding: "10px",
|
||||
textAlign: "left",
|
||||
width: "40px",
|
||||
color: "#fff"
|
||||
}
|
||||
}),
|
||||
$el("th", {
|
||||
textContent: "Dependency Name",
|
||||
style: {
|
||||
padding: "10px",
|
||||
textAlign: "left",
|
||||
color: "#fff",
|
||||
fontWeight: "bold"
|
||||
}
|
||||
}),
|
||||
$el("th", {
|
||||
textContent: "Current Version",
|
||||
style: {
|
||||
padding: "10px",
|
||||
textAlign: "left",
|
||||
color: "#fff",
|
||||
fontWeight: "bold"
|
||||
}
|
||||
}),
|
||||
$el("th", {
|
||||
textContent: "Incoming Version",
|
||||
style: {
|
||||
padding: "10px",
|
||||
textAlign: "left",
|
||||
color: "#fff",
|
||||
fontWeight: "bold"
|
||||
}
|
||||
})
|
||||
])
|
||||
]);
|
||||
|
||||
// Create table body
|
||||
const tbody = $el("tbody", {});
|
||||
|
||||
// Create table rows for each dependency and its subdependencies
|
||||
let rowIndex = 0;
|
||||
dependencies.forEach((dep) => {
|
||||
// Ensure name is not null/undefined and clean it
|
||||
let depName = dep.name;
|
||||
if (!depName || depName === 'null' || depName === 'None') {
|
||||
// Fallback: extract from line
|
||||
if (dep.line) {
|
||||
depName = dep.line.split(/[=<>!~]/)[0].trim();
|
||||
} else {
|
||||
depName = "Unknown";
|
||||
}
|
||||
}
|
||||
// Remove any "null" suffix that might have been appended
|
||||
depName = String(depName).replace(/null$/i, '').trim();
|
||||
|
||||
const isInstalled = dep.status === 'installed';
|
||||
const incomingVersion = dep.version || "NA";
|
||||
const currentVersion = dep.currentVersion || "NA";
|
||||
|
||||
// Main dependency row
|
||||
const row = $el("tr", {
|
||||
style: {
|
||||
backgroundColor: rowIndex % 2 === 0 ? "#1a1a1a" : "#222222",
|
||||
borderBottom: "1px solid #3a3a3a"
|
||||
}
|
||||
}, [
|
||||
$el("td", {
|
||||
style: {
|
||||
padding: "10px",
|
||||
textAlign: "center"
|
||||
}
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: dep.selected,
|
||||
onchange: (e) => {
|
||||
dep.selected = e.target.checked;
|
||||
},
|
||||
style: {
|
||||
cursor: "pointer",
|
||||
width: "18px",
|
||||
height: "18px"
|
||||
}
|
||||
})
|
||||
]),
|
||||
$el("td", {
|
||||
style: {
|
||||
padding: "10px",
|
||||
color: isInstalled ? "#888" : "#fff"
|
||||
}
|
||||
}, [
|
||||
$el("span", {
|
||||
textContent: depName,
|
||||
style: {
|
||||
fontWeight: "500",
|
||||
marginRight: isInstalled ? "8px" : "0"
|
||||
}
|
||||
}),
|
||||
isInstalled ? $el("span", {
|
||||
textContent: "Installed",
|
||||
style: {
|
||||
display: "inline-block",
|
||||
backgroundColor: "#2a4a2a",
|
||||
color: "#4a9",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "3px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "bold",
|
||||
border: "1px solid #4a9"
|
||||
}
|
||||
}) : ''
|
||||
]),
|
||||
$el("td", {
|
||||
textContent: currentVersion,
|
||||
style: {
|
||||
padding: "10px",
|
||||
color: isInstalled ? "#4a9" : "#aaa",
|
||||
fontFamily: "monospace"
|
||||
}
|
||||
}),
|
||||
$el("td", {
|
||||
textContent: incomingVersion,
|
||||
style: {
|
||||
padding: "10px",
|
||||
color: "#fff",
|
||||
fontFamily: "monospace"
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
tbody.appendChild(row);
|
||||
rowIndex++;
|
||||
|
||||
// Add subdependencies as indented rows
|
||||
if(dep.subdependencies && dep.subdependencies.length > 0) {
|
||||
dep.subdependencies.forEach((subdep) => {
|
||||
// Ensure subdependency name is not null/undefined and clean it
|
||||
let subdepName = subdep.name;
|
||||
if (!subdepName || subdepName === 'null' || subdepName === 'None') {
|
||||
subdepName = "Unknown";
|
||||
}
|
||||
// Remove any "null" suffix that might have been appended
|
||||
subdepName = String(subdepName).replace(/null$/i, '').trim();
|
||||
|
||||
const subIsInstalled = subdep.status === 'installed';
|
||||
const subIncomingVersion = subdep.version || "NA";
|
||||
const subCurrentVersion = subdep.currentVersion || "NA";
|
||||
|
||||
const subRow = $el("tr", {
|
||||
style: {
|
||||
backgroundColor: rowIndex % 2 === 0 ? "#1a1a1a" : "#222222",
|
||||
borderBottom: "1px solid #3a3a3a"
|
||||
}
|
||||
}, [
|
||||
$el("td", {
|
||||
style: {
|
||||
padding: "10px",
|
||||
textAlign: "center"
|
||||
}
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: subdep.selected,
|
||||
onchange: (e) => {
|
||||
subdep.selected = e.target.checked;
|
||||
},
|
||||
style: {
|
||||
cursor: "pointer",
|
||||
width: "18px",
|
||||
height: "18px"
|
||||
}
|
||||
})
|
||||
]),
|
||||
$el("td", {
|
||||
style: {
|
||||
padding: "10px 10px 10px 30px",
|
||||
color: subIsInstalled ? "#888" : "#aaa",
|
||||
fontSize: "13px"
|
||||
}
|
||||
}, [
|
||||
$el("span", {
|
||||
textContent: "└─ " + subdepName,
|
||||
style: {
|
||||
fontWeight: "400",
|
||||
marginRight: subIsInstalled ? "8px" : "0"
|
||||
}
|
||||
}),
|
||||
subIsInstalled ? $el("span", {
|
||||
textContent: "Installed",
|
||||
style: {
|
||||
display: "inline-block",
|
||||
backgroundColor: "#2a4a2a",
|
||||
color: "#4a9",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "3px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "bold",
|
||||
border: "1px solid #4a9"
|
||||
}
|
||||
}) : ''
|
||||
]),
|
||||
$el("td", {
|
||||
textContent: subCurrentVersion,
|
||||
style: {
|
||||
padding: "10px",
|
||||
color: subIsInstalled ? "#4a9" : "#666",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "13px"
|
||||
}
|
||||
}),
|
||||
$el("td", {
|
||||
textContent: subIncomingVersion,
|
||||
style: {
|
||||
padding: "10px",
|
||||
color: "#aaa",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "13px"
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
tbody.appendChild(subRow);
|
||||
rowIndex++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
table.appendChild(thead);
|
||||
table.appendChild(tbody);
|
||||
tableContainer.appendChild(table);
|
||||
|
||||
const content = $el("div", {
|
||||
style: contentStyle
|
||||
}, [
|
||||
$el("h3", {
|
||||
textContent: "Select Dependencies to Install",
|
||||
style: {
|
||||
color: "#ffffff",
|
||||
backgroundColor: "#1a1a1a",
|
||||
padding: "10px 15px",
|
||||
margin: "0 0 10px 0",
|
||||
width: "100%",
|
||||
textAlign: "center",
|
||||
borderRadius: "4px",
|
||||
boxSizing: "border-box"
|
||||
}
|
||||
}),
|
||||
$el("div", {
|
||||
textContent: `${dependencies.filter(d => d.status === 'installed').length} already installed, ${dependencies.filter(d => d.status !== 'installed').length} to install`,
|
||||
style: {
|
||||
color: "#aaa",
|
||||
fontSize: "12px",
|
||||
marginBottom: "5px"
|
||||
}
|
||||
}),
|
||||
tableContainer,
|
||||
$el("div", {
|
||||
style: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
gap: "10px",
|
||||
marginTop: "10px"
|
||||
}
|
||||
}, [
|
||||
$el("button", {
|
||||
textContent: "Cancel",
|
||||
onclick: () => {
|
||||
onSelect(null); // Pass null to indicate cancellation
|
||||
dialog.close();
|
||||
},
|
||||
style: {
|
||||
flex: "1",
|
||||
padding: "8px",
|
||||
backgroundColor: "#4a4a4a",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer"
|
||||
}
|
||||
}),
|
||||
$el("button", {
|
||||
textContent: "Select All",
|
||||
onclick: () => {
|
||||
dependencies.forEach(dep => {
|
||||
if (dep.status !== 'installed') {
|
||||
dep.selected = true;
|
||||
}
|
||||
// Also select subdependencies
|
||||
if(dep.subdependencies) {
|
||||
dep.subdependencies.forEach(subdep => {
|
||||
if(subdep.status !== 'installed') {
|
||||
subdep.selected = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Update checkboxes in the table
|
||||
const checkboxes = tableContainer.querySelectorAll('input[type="checkbox"]');
|
||||
checkboxes.forEach((checkbox) => {
|
||||
if(!checkbox.disabled) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
style: {
|
||||
padding: "8px 15px",
|
||||
backgroundColor: "#4a6a4a",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer"
|
||||
}
|
||||
}),
|
||||
$el("button", {
|
||||
textContent: "Install Selected",
|
||||
onclick: () => {
|
||||
// Collect all selected dependencies (main + subdependencies)
|
||||
const selected = [];
|
||||
dependencies.forEach(d => {
|
||||
if(d.selected) {
|
||||
selected.push(d);
|
||||
}
|
||||
// Also include selected subdependencies
|
||||
if(d.subdependencies) {
|
||||
d.subdependencies.forEach(subdep => {
|
||||
if(subdep.selected) {
|
||||
selected.push(subdep);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
onSelect(selected);
|
||||
dialog.close();
|
||||
},
|
||||
style: {
|
||||
flex: "1",
|
||||
padding: "8px",
|
||||
backgroundColor: "#4CAF50",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer"
|
||||
}
|
||||
}),
|
||||
])
|
||||
]);
|
||||
|
||||
console.log('[Dependency Dialog] Showing dialog with', dependencies.length, 'dependencies');
|
||||
dialog.show(content);
|
||||
console.log('[Dependency Dialog] Dialog shown');
|
||||
}
|
||||
|
||||
showVersionSelectorDialog(versions, onSelect) {
|
||||
const dialog = new ComfyDialog();
|
||||
dialog.element.style.zIndex = 1100;
|
||||
@@ -462,7 +909,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.');
|
||||
}
|
||||
},
|
||||
@@ -1470,15 +1917,116 @@ export class CustomNodesManager {
|
||||
}
|
||||
}
|
||||
|
||||
// For install mode, analyze dependencies BEFORE starting installation
|
||||
let selectedDependencies = [];
|
||||
let dependencyDialogShown = false; // Track if dialog was shown
|
||||
if(mode === "install" && this.analyzeDependenciesBeforeInstall) {
|
||||
// Analyze dependencies for all items first (only if checkbox is enabled)
|
||||
for (const hash of list) {
|
||||
const item = this.grid.getRowItemBy("hash", hash);
|
||||
if (!item) {
|
||||
console.log('[Dependency Analysis] Item not found for hash:', hash);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = item.originalData;
|
||||
console.log('[Dependency Analysis] Item data:', {
|
||||
title: item.title,
|
||||
files: data.files,
|
||||
repository: data.repository,
|
||||
hasFiles: !!data.files,
|
||||
filesLength: data.files ? data.files.length : 0
|
||||
});
|
||||
|
||||
// Try multiple ways to get the git URL
|
||||
let gitUrl = null;
|
||||
if(data.files && data.files.length > 0) {
|
||||
gitUrl = data.files[0];
|
||||
} else if(data.repository) {
|
||||
gitUrl = data.repository;
|
||||
}
|
||||
|
||||
if(gitUrl && (gitUrl.includes('github.com') || gitUrl.includes('.git'))) {
|
||||
try {
|
||||
this.showStatus(`Analyzing dependencies for ${item.title}...`);
|
||||
console.log('[Dependency Analysis] Fetching dependencies for:', gitUrl);
|
||||
|
||||
const analyzeRes = await api.fetchApi('/customnode/analyze_dependencies', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: gitUrl,
|
||||
commitId: data.commit_id,
|
||||
branch: data.branch
|
||||
})
|
||||
});
|
||||
|
||||
console.log('[Dependency Analysis] Response status:', analyzeRes.status);
|
||||
|
||||
if(analyzeRes.status === 200) {
|
||||
const analyzeData = await analyzeRes.json();
|
||||
console.log('[Dependency Analysis] Response data:', {
|
||||
success: analyzeData.success,
|
||||
hasDependencies: !!analyzeData.dependencies,
|
||||
dependenciesCount: analyzeData.dependencies ? analyzeData.dependencies.length : 0,
|
||||
noRequirementsFile: analyzeData.noRequirementsFile
|
||||
});
|
||||
|
||||
if(analyzeData.success && analyzeData.dependencies && analyzeData.dependencies.length > 0) {
|
||||
console.log('[Dependency Analysis] Showing dialog with', analyzeData.dependencies.length, 'dependencies');
|
||||
dependencyDialogShown = true;
|
||||
|
||||
// Show dependency selection dialog and wait for user
|
||||
const userSelection = await new Promise((resolve) => {
|
||||
this.showDependencySelectorDialog(analyzeData.dependencies, (selected) => {
|
||||
console.log('[Dependency Analysis] User selected:', selected);
|
||||
resolve(selected);
|
||||
});
|
||||
});
|
||||
|
||||
// If user cancelled (null), stop installation
|
||||
if(userSelection === null) {
|
||||
console.log('[Dependency Analysis] User cancelled installation');
|
||||
this.showStatus("Installation cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDependencies = userSelection || [];
|
||||
console.log('[Dependency Analysis] Selected dependencies:', selectedDependencies.length);
|
||||
} else if(analyzeData.noRequirementsFile) {
|
||||
console.log('[Dependency Analysis] No requirements.txt file found');
|
||||
} else {
|
||||
console.log('[Dependency Analysis] No dependencies to show');
|
||||
}
|
||||
} else {
|
||||
const errorText = await analyzeRes.text();
|
||||
console.error('[Dependency Analysis] API error:', analyzeRes.status, errorText);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('[Dependency Analysis] Exception:', e);
|
||||
// Continue with installation even if dependency analysis fails
|
||||
}
|
||||
} else {
|
||||
console.log('[Dependency Analysis] Not a GitHub URL or no URL found:', gitUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target.classList.add("cn-btn-loading");
|
||||
this.showError("");
|
||||
|
||||
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);
|
||||
@@ -1505,6 +2053,46 @@ export class CustomNodesManager {
|
||||
data.mode = this.mode;
|
||||
data.ui_id = hash;
|
||||
|
||||
// Add selected dependencies to data (including subdependencies)
|
||||
// Only install selected dependencies - respect user's selection
|
||||
const allSelected = [];
|
||||
if(selectedDependencies.length > 0) {
|
||||
selectedDependencies.forEach(d => {
|
||||
// Add main dependency if selected
|
||||
if(d.selected) {
|
||||
allSelected.push({
|
||||
name: d.name,
|
||||
version: d.version,
|
||||
line: d.line
|
||||
});
|
||||
}
|
||||
// Add selected subdependencies
|
||||
if(d.subdependencies) {
|
||||
d.subdependencies.forEach(subdep => {
|
||||
if(subdep.selected) {
|
||||
allSelected.push({
|
||||
name: subdep.name,
|
||||
version: subdep.version,
|
||||
line: `${subdep.name}${subdep.version ? '==' + subdep.version : ''}`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// Set selectedDependencies:
|
||||
// - If dialog was shown: always set (even if empty) to respect user's selection
|
||||
// - If dialog was not shown: don't set (install all dependencies - original behavior)
|
||||
if(dependencyDialogShown) {
|
||||
// User saw the dialog, respect their selection (even if empty)
|
||||
data.selectedDependencies = allSelected;
|
||||
} else if(allSelected.length > 0) {
|
||||
// Dialog wasn't shown but we have selections (shouldn't happen, but just in case)
|
||||
data.selectedDependencies = allSelected;
|
||||
}
|
||||
// If dialog wasn't shown and no selections, don't set selectedDependencies
|
||||
// This means backend will install all dependencies (original behavior)
|
||||
|
||||
let install_mode = mode;
|
||||
if(mode == 'switch') {
|
||||
install_mode = 'install';
|
||||
@@ -1550,8 +2138,6 @@ export class CustomNodesManager {
|
||||
}
|
||||
}
|
||||
|
||||
this.install_context = {btn: btn, targets: target_items};
|
||||
|
||||
if(errorMsg) {
|
||||
this.showError(errorMsg);
|
||||
show_message("[Installation Errors]\n"+errorMsg);
|
||||
@@ -1563,7 +2149,7 @@ export class CustomNodesManager {
|
||||
}
|
||||
}
|
||||
else {
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
await api.fetchApi('/manager/queue/start', { method: 'POST' });
|
||||
this.showStop();
|
||||
showTerminal();
|
||||
}
|
||||
@@ -1576,6 +2162,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 +2173,81 @@ 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') {
|
||||
errorMsg += 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();
|
||||
|
||||
+3
-3
@@ -170,7 +170,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.');
|
||||
}
|
||||
},
|
||||
@@ -444,7 +444,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 = [];
|
||||
|
||||
@@ -503,7 +503,7 @@ export class ModelManager {
|
||||
}
|
||||
}
|
||||
else {
|
||||
await api.fetchApi('/manager/queue/start');
|
||||
await api.fetchApi('/manager/queue/start', { method: 'POST' });
|
||||
this.showStop();
|
||||
showTerminal();
|
||||
}
|
||||
|
||||
+3
-3
@@ -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;
|
||||
}
|
||||
|
||||
+319
@@ -5180,6 +5180,325 @@
|
||||
"size": "25.75GB"
|
||||
},
|
||||
|
||||
{
|
||||
"name": "LTX-2 19B Dev FP8",
|
||||
"type": "checkpoint",
|
||||
"base": "LTX-2",
|
||||
"save_path": "checkpoints/LTX-2",
|
||||
"description": "LTX-2 19B Dev FP8 model.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2",
|
||||
"filename": "ltx-2-19b-dev-fp8.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-dev-fp8.safetensors",
|
||||
"size": "27.1GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B Distilled FP8",
|
||||
"type": "checkpoint",
|
||||
"base": "LTX-2",
|
||||
"save_path": "checkpoints/LTX-2",
|
||||
"description": "LTX-2 19B Distilled FP8 model.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2",
|
||||
"filename": "ltx-2-19b-distilled-fp8.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled-fp8.safetensors",
|
||||
"size": "27.1GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B Dev",
|
||||
"type": "checkpoint",
|
||||
"base": "LTX-2",
|
||||
"save_path": "checkpoints/LTX-2",
|
||||
"description": "LTX-2 19B Dev model.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2",
|
||||
"filename": "ltx-2-19b-dev.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-dev.safetensors",
|
||||
"size": "43.3GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B Distilled",
|
||||
"type": "checkpoint",
|
||||
"base": "LTX-2",
|
||||
"save_path": "checkpoints/LTX-2",
|
||||
"description": "LTX-2 19B Distilled model.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2",
|
||||
"filename": "ltx-2-19b-distilled.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled.safetensors",
|
||||
"size": "43.3GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 Spatial Upscaler",
|
||||
"type": "upscale",
|
||||
"base": "upscale",
|
||||
"save_path": "default",
|
||||
"description": "Spatial upscaler model for LTX-2. This model enhances the spatial resolution of generated videos.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2",
|
||||
"filename": "ltx-2-spatial-upscaler-x2-1.0.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-spatial-upscaler-x2-1.0.safetensors",
|
||||
"size": "996MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 Temporal Upscaler",
|
||||
"type": "upscale",
|
||||
"base": "upscale",
|
||||
"save_path": "default",
|
||||
"description": "Temporal upscaler model for LTX-2. This model enhances the temporal resolution of generated videos.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2",
|
||||
"filename": "ltx-2-temporal-upscaler-x2-1.0.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-temporal-upscaler-x2-1.0.safetensors",
|
||||
"size": "262MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B Distilled LoRA",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras",
|
||||
"description": "A LoRA adapter that transforms the standard LTX-2 19B model into a distilled version when loaded.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2",
|
||||
"filename": "ltx-2-19b-distilled-lora-384.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled-lora-384.safetensors",
|
||||
"size": "7.67GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B IC LoRA - Canny Control",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for canny control on LTX-2 19B IC model. Intended for advanced edge control and guided generation.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Canny-Control",
|
||||
"filename": "ltx-2-19b-ic-lora-canny-control.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Canny-Control/resolve/main/ltx-2-19b-ic-lora-canny-control.safetensors",
|
||||
"size": "654MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B IC LoRA - Depth Control",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for depth control on LTX-2 19B IC model. Adds depth-aware generation guidance.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Depth-Control",
|
||||
"filename": "ltx-2-19b-ic-lora-depth-control.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Depth-Control/resolve/main/ltx-2-19b-ic-lora-depth-control.safetensors",
|
||||
"size": "654MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B IC LoRA - Detailer",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA detailer for LTX-2 19B IC. Improves fine details and sharpness in generated outputs.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Detailer",
|
||||
"filename": "ltx-2-19b-ic-lora-detailer.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Detailer/resolve/main/ltx-2-19b-ic-lora-detailer.safetensors",
|
||||
"size": "2.62GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B IC LoRA - Pose Control",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for pose control on LTX-2 19B IC model. Enables pose-guided image/video generation.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control",
|
||||
"filename": "ltx-2-19b-ic-lora-pose-control.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control/resolve/main/ltx-2-19b-ic-lora-pose-control.safetensors",
|
||||
"size": "654MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B LoRA - Camera Control Dolly In",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for dolly-in camera control with LTX-2 19B. Simulates camera moving closer to subject.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In",
|
||||
"filename": "ltx-2-19b-lora-camera-control-dolly-in.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-In/resolve/main/ltx-2-19b-lora-camera-control-dolly-in.safetensors",
|
||||
"size": "327MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B LoRA - Camera Control Dolly Left",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for dolly-left camera control with LTX-2 19B. Simulates camera moving left.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left",
|
||||
"filename": "ltx-2-19b-lora-camera-control-dolly-left.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left/resolve/main/ltx-2-19b-lora-camera-control-dolly-left.safetensors",
|
||||
"size": "327MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B LoRA - Camera Control Dolly Out",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for dolly-out camera control with LTX-2 19B. Simulates camera moving away from subject.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out",
|
||||
"filename": "ltx-2-19b-lora-camera-control-dolly-out.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Out/resolve/main/ltx-2-19b-lora-camera-control-dolly-out.safetensors",
|
||||
"size": "327MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B LoRA - Camera Control Dolly Right",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for dolly-right camera control with LTX-2 19B. Simulates camera moving right.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right",
|
||||
"filename": "ltx-2-19b-lora-camera-control-dolly-right.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Right/resolve/main/ltx-2-19b-lora-camera-control-dolly-right.safetensors",
|
||||
"size": "327MB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B LoRA - Camera Control Jib Down",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for jib-down camera control with LTX-2 19B. Simulates vertical camera movement downwards.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down",
|
||||
"filename": "ltx-2-19b-lora-camera-control-jib-down.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Down/resolve/main/ltx-2-19b-lora-camera-control-jib-down.safetensors",
|
||||
"size": "2.21GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B LoRA - Camera Control Jib Up",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for jib-up camera control with LTX-2 19B. Simulates vertical camera movement upwards.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up",
|
||||
"filename": "ltx-2-19b-lora-camera-control-jib-up.safetensors",
|
||||
"url": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Jib-Up/resolve/main/ltx-2-19b-lora-camera-control-jib-up.safetensors",
|
||||
"size": "2.21GB"
|
||||
},
|
||||
{
|
||||
"name": "LTX-2 19B LoRA - Camera Control Static",
|
||||
"type": "lora",
|
||||
"base": "LTX-2",
|
||||
"save_path": "loras/LTX-2",
|
||||
"description": "LoRA for static camera control with LTX-2 19B. Simulates stationary/static camera view.",
|
||||
"reference": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Static",
|
||||
"filename": "ltx-2-19b-lora-camera-control-static.safetensors",
|
||||
"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 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-Video Spatial Upscaler v0.9.7",
|
||||
"type": "upscale",
|
||||
|
||||
+2340
-637
File diff suppressed because it is too large
Load Diff
+3160
-1009
File diff suppressed because it is too large
Load Diff
+2519
-1619
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+623
-381
File diff suppressed because it is too large
Load Diff
+18487
-1690
File diff suppressed because it is too large
Load Diff
@@ -371,6 +371,16 @@
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Complete ComfyUI development toolkit with 8 professional nodes including VAE tools, universal type testing, and comprehensive debugging infrastructure."
|
||||
}
|
||||
},
|
||||
{
|
||||
"author": "ganlvtech",
|
||||
"title": "ComfyUI-CustomModelPatcher",
|
||||
"reference": "https://github.com/ganlvtech/ComfyUI-CustomModelPatcher",
|
||||
"files": [
|
||||
"https://github.com/ganlvtech/ComfyUI-CustomModelPatcher"
|
||||
],
|
||||
"install_type": "git-clone",
|
||||
"description": "Demonstrates GPU memory management techniques for external models like onnxruntime and InsightFace in ComfyUI by pre-allocating VRAM. (Description by CC)"
|
||||
}
|
||||
]
|
||||
}
|
||||
+188
-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
|
||||
@@ -423,13 +432,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 +457,7 @@ paths:
|
||||
description: Security policy violation
|
||||
|
||||
/manager/queue/reset:
|
||||
get:
|
||||
post:
|
||||
summary: Reset queue
|
||||
description: Resets the operation queue
|
||||
responses:
|
||||
@@ -479,7 +497,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 +593,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 +639,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 +664,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 +703,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 +735,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 +755,7 @@ paths:
|
||||
description: Error switching version
|
||||
|
||||
/manager/reboot:
|
||||
get:
|
||||
post:
|
||||
summary: Reboot ComfyUI
|
||||
description: Restarts the ComfyUI server
|
||||
security:
|
||||
@@ -746,7 +786,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 +831,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 +875,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 +917,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 +973,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:
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
[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.2"
|
||||
version = "3.41"
|
||||
license = { file = "LICENSE.txt" }
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
# Used by Comfy Registry https://registry.comfy.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "drltdata"
|
||||
|
||||
+101
-3
@@ -2,6 +2,8 @@ import ast
|
||||
import re
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from git import Repo
|
||||
import concurrent
|
||||
import datetime
|
||||
@@ -15,12 +17,13 @@ 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
|
||||
|
||||
# Scanner version for cache invalidation
|
||||
SCANNER_VERSION = "2.0.12" # Add dict comprehension + export list detection
|
||||
SCANNER_VERSION = "2.0.13" # Add fallback for dynamic v3 node_id
|
||||
|
||||
# Cache for extract_nodes and extract_nodes_enhanced results
|
||||
_extract_nodes_cache: Dict[str, Set[str]] = {}
|
||||
@@ -195,6 +198,82 @@ g = None
|
||||
|
||||
parse_cnt = 0
|
||||
|
||||
# Thread-safe git error state
|
||||
_git_error_lock = threading.Lock()
|
||||
_git_errors: defaultdict = defaultdict(list) # category -> list[{'repo': str, 'op': str, 'msg': str}]
|
||||
|
||||
# Ordered categories: (key, display label, compiled regex). First match wins.
|
||||
# Single source of truth — add new categories here only.
|
||||
_GIT_ERROR_CATEGORIES = [
|
||||
('repository_not_found', 'Repository Not Found', re.compile(
|
||||
r'repository\s+not\s+found|does\s+not\s+exist|\b404\b|remote:\s*repository\s+not\s+found',
|
||||
re.IGNORECASE
|
||||
)),
|
||||
('divergent_branch', 'Divergent Branch', re.compile(
|
||||
r'divergent\s+branches|need\s+to\s+specify\s+how\s+to\s+reconcile\s+divergent\s+branches',
|
||||
re.IGNORECASE
|
||||
)),
|
||||
('auth_failed', 'Authentication Failed', re.compile(
|
||||
r'authentication\s+failed|could\s+not\s+read\s+username|invalid\s+username|invalid\s+password|auth\s+failed',
|
||||
re.IGNORECASE
|
||||
)),
|
||||
('network_error', 'Network Error', re.compile(
|
||||
r'could\s+not\s+resolve\s+host|connection\s+refused|timed?\s*out|failed\s+to\s+connect|'
|
||||
r'network\s+is\s+unreachable|temporary\s+failure\s+in\s+name\s+resolution',
|
||||
re.IGNORECASE
|
||||
)),
|
||||
('merge_conflict', 'Merge Conflict', re.compile(
|
||||
r'merge\s+conflict|\bCONFLICT\b|automatic\s+merge\s+failed',
|
||||
re.IGNORECASE
|
||||
)),
|
||||
('permission_denied', 'Permission Denied', re.compile(
|
||||
r'permission\s+denied|access\s+denied|operation\s+not\s+permitted|publickey',
|
||||
re.IGNORECASE
|
||||
)),
|
||||
]
|
||||
|
||||
|
||||
def _categorize_git_error(error_str: str) -> str:
|
||||
"""Classify a git error string into a category. First match wins."""
|
||||
for category, _label, pattern in _GIT_ERROR_CATEGORIES:
|
||||
if pattern.search(error_str):
|
||||
return category
|
||||
return 'other'
|
||||
|
||||
|
||||
def _record_git_error(repo_name: str, op: str, error: Exception) -> None:
|
||||
"""Record a git error in the thread-safe collector."""
|
||||
category = _categorize_git_error(str(error))
|
||||
with _git_error_lock:
|
||||
_git_errors[category].append({'repo': repo_name, 'op': op, 'msg': str(error)})
|
||||
|
||||
|
||||
def _report_git_errors() -> None:
|
||||
"""Print a grouped summary of git errors by category."""
|
||||
if not _git_errors:
|
||||
return
|
||||
|
||||
total = sum(len(v) for v in _git_errors.values())
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Git Operation Errors Summary: {total} failure(s)")
|
||||
print(f"{'='*60}")
|
||||
|
||||
for category, label, _pattern in _GIT_ERROR_CATEGORIES:
|
||||
entries = _git_errors.get(category, [])
|
||||
if not entries:
|
||||
continue
|
||||
print(f"\n[{label}] ({len(entries)} repo(s))")
|
||||
for entry in entries:
|
||||
print(f" • {entry['repo']} ({entry['op']}): {entry['msg']}")
|
||||
|
||||
other_entries = _git_errors.get('other', [])
|
||||
if other_entries:
|
||||
print(f"\n[Other] ({len(other_entries)} repo(s))")
|
||||
for entry in other_entries:
|
||||
print(f" • {entry['repo']} ({entry['op']}): {entry['msg']}")
|
||||
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
|
||||
def extract_nodes(code_text):
|
||||
global parse_cnt
|
||||
@@ -936,6 +1015,9 @@ def extract_v3_nodes(code_text):
|
||||
node_id = extract_node_id_from_schema(node)
|
||||
if node_id:
|
||||
nodes.add(node_id)
|
||||
else:
|
||||
# Fallback: use class name when node_id is dynamic/empty
|
||||
nodes.add(node.name)
|
||||
|
||||
return nodes
|
||||
|
||||
@@ -1157,7 +1239,7 @@ def clone_or_pull_git_repository(git_url):
|
||||
repo_name = git_url.split("/")[-1]
|
||||
if repo_name.endswith(".git"):
|
||||
repo_name = repo_name[:-4]
|
||||
|
||||
|
||||
repo_dir = os.path.join(temp_dir, repo_name)
|
||||
|
||||
if os.path.exists(repo_dir):
|
||||
@@ -1169,12 +1251,14 @@ def clone_or_pull_git_repository(git_url):
|
||||
print(f"Pulling {repo_name}...")
|
||||
except Exception as e:
|
||||
print(f"Failed to pull '{repo_name}': {e}")
|
||||
_record_git_error(repo_name, 'pull', e)
|
||||
else:
|
||||
try:
|
||||
Repo.clone_from(git_url, repo_dir, recursive=True)
|
||||
print(f"Cloning {repo_name}...")
|
||||
except Exception as e:
|
||||
print(f"Failed to clone '{repo_name}': {e}")
|
||||
_record_git_error(repo_name, 'clone', e)
|
||||
|
||||
|
||||
def update_custom_nodes(scan_only_mode=False, url_list_file=None):
|
||||
@@ -1325,11 +1409,18 @@ def update_custom_nodes(scan_only_mode=False, url_list_file=None):
|
||||
if not skip_stat_update:
|
||||
process_git_stats(git_url_titles_preemptions)
|
||||
|
||||
# Reset error collector before this run
|
||||
with _git_error_lock:
|
||||
_git_errors.clear()
|
||||
|
||||
# Git clone/pull for all repositories
|
||||
with concurrent.futures.ThreadPoolExecutor(11) as executor:
|
||||
for url, title, preemptions, node_pattern in git_url_titles_preemptions:
|
||||
executor.submit(process_git_url_title, url, title, preemptions, node_pattern)
|
||||
|
||||
# Report any git errors grouped by category (after all workers complete)
|
||||
_report_git_errors()
|
||||
|
||||
# .py file download (skip in scan-only mode - only process git repos)
|
||||
if not scan_only_mode:
|
||||
py_url_titles_and_pattern = get_py_urls_from_json('custom-node-list.json')
|
||||
@@ -1529,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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ git clone https://github.com/ltdrdata/ComfyUI-Manager comfyui-manager
|
||||
cd ..
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu121
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r custom_nodes/comfyui-manager/requirements.txt
|
||||
cd ..
|
||||
|
||||
@@ -4,7 +4,7 @@ git clone https://github.com/ltdrdata/ComfyUI-Manager comfyui-manager
|
||||
cd ..
|
||||
python -m venv venv
|
||||
call venv/Scripts/activate
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu121
|
||||
python -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu130
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r custom_nodes/comfyui-manager/requirements.txt
|
||||
cd ..
|
||||
|
||||
@@ -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,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,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,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
|
||||
"install_custom_node", # 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"),
|
||||
("install_custom_node", "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["install_custom_node"])
|
||||
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,136 @@
|
||||
"""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()
|
||||
|
||||
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."""
|
||||
self.assertEqual(self.common_src.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()
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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)
|
||||
Reference in New Issue
Block a user