mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-06-20 23:09:20 +08:00
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
* feat(security): dedicated install flags decouple git_url/pip from security_level
Install via git URL and pip install are no longer gated by
security_level. Each surface gets a dedicated config.ini flag —
allow_git_url_install / allow_pip_install (both default false, secure
by default) — that fully REPLACES the security-level term for these two
features. The network-position invariant is retained: a non-local
listener stays denied regardless of the flags unless
network_mode = personal_cloud.
- New pure predicate is_dedicated_install_allowed() in
common/manager_security (no config access; callers resolve config)
- Legacy endpoints /v2/customnode/install/git_url and .../pip switch
from is_allowed_security_level('high+') to the flag gate; batch
installs of unknown git URLs likewise (middle+ entry gate unchanged,
unknown-pip 'block' stays unconditional; response shapes preserved)
- Config readers/writers (glob + legacy) parse and persist the flags;
denial logs and frontend 403 messages name the responsible flag and
note the non-local-listener requirement (network_mode=personal_cloud)
- No auto-seed from security_level — users previously on weak/normal-
must opt in explicitly (see CHANGELOG migration notes; README
documents the new contract)
- Update the pre-existing permissive E2E harness
(start_comfyui_permissive.sh + test_e2e_legacy_real_ops.py) to the
new contract: it now also sets allow_git_url_install /
allow_pip_install = true, since security_level = normal- alone no
longer opens the git_url/pip endpoints
Tests: predicate truth table proving security_level independence in
both directions, dual-reader config contract, security-level-matrix
freeze guards, legacy gate regression guards (121 unit), plus 22
real-server E2E tests incl. URL-form pip install with self-clean.
* test(e2e): fix fresh-env failures in customnode_info and git_clone harnesses
Two pre-existing harness defects that fail deterministically on a fresh
E2E environment (unrelated to the dedicated-install-flags change):
- test_e2e_customnode_info: TestInstalledPacks asserted the seed pack
ComfyUI_SigmoidOffsetScheduler is installed, but nothing seeded it —
the installing module (test_e2e_endpoint) runs alphabetically later
and uninstalls it at the end. Add a module-scoped autouse fixture
that installs the pack via cm-cli BEFORE the server starts (the
imported-mode test asserts against the startup-frozen snapshot, so
API-based seeding after boot cannot work) and removes it on teardown
only if the fixture installed it.
- test_e2e_git_clone: _ensure_cache ran cm-cli update-cache with a
120s timeout; the full DB download routinely exceeds that on slow
links, erroring the whole module at setup. Raise to 600s.
Verified from a fresh state (seed pack absent): both modules pass
(13 tests, incl. previously-failing TestInstalledPacks 2 and
TestNightlyInstallCycle 3).
269 lines
10 KiB
Python
269 lines
10 KiB
Python
"""[goal265 step4 — RED] Dual-reader config contract for the dedicated
|
|
install flags ``allow_git_url_install`` / ``allow_pip_install``.
|
|
|
|
TARGET CONTRACT (NOT YET IMPLEMENTED — goal265-spec.md §3, LOCKED):
|
|
|
|
- Keys: ``allow_git_url_install``, ``allow_pip_install`` in
|
|
config.ini ``[default]``.
|
|
- BOTH readers carry the keys (read dict + write_config list +
|
|
exception-fallback dict — 3 anchors each):
|
|
* ``comfyui_manager/glob/manager_core.py``
|
|
* ``comfyui_manager/legacy/manager_core.py``
|
|
(dual-reader rule — the gated endpoints resolve through the LEGACY
|
|
reader; a glob-only registration would silently never reach the gates.)
|
|
- Only case-insensitive string "true" is truthy on read; anything else
|
|
(including "1", "yes") reads False.
|
|
- Missing key reads False — the shared ``get_bool(key, default)`` quirk:
|
|
the ``default`` param is IGNORED, missing key -> always False.
|
|
- Missing file / missing [default] section -> exception-fallback dict
|
|
returns False for both keys.
|
|
- Activation is restart-only (module-level ``cached_config``).
|
|
|
|
These tests are EXPECTED TO FAIL against current code (the keys are not
|
|
registered in either reader yet) — RED confirmation is goal265 Step 5.
|
|
|
|
SC rows covered (goal265-scenarios.md):
|
|
SC-19 missing keys -> False (both readers)
|
|
SC-20 malformed values -> only "true"/"TRUE"/"True" truthy (both readers)
|
|
SC-21 write_config round-trip persistence (both readers)
|
|
SC-22 cached_config staleness — restart-only activation (reader-level arm)
|
|
SC-29 no config.ini / no [default] section -> fallback False (both readers)
|
|
|
|
Fixtures (spec §4 binding): tmp config.ini + monkeypatch
|
|
``context.manager_config_path`` + per-reader ``cached_config`` reset.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _install_flags_testutil import import_context, import_reader # noqa: E402
|
|
|
|
FLAG_KEYS = ("allow_git_url_install", "allow_pip_install")
|
|
READERS = ("glob", "legacy")
|
|
|
|
|
|
def _reset_cache(core) -> None:
|
|
"""Clear the reader's module-level cached_config (simulates restart)."""
|
|
setattr(core, "cached_config", None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(params=READERS)
|
|
def reader_core(request):
|
|
"""Yield (reader_name, manager_core module) for BOTH readers, with the
|
|
module-level cached_config saved/cleared around each test so one test's
|
|
cache never leaks into another (or into other test modules)."""
|
|
core = import_reader(request.param)
|
|
saved = getattr(core, "cached_config", None)
|
|
_reset_cache(core)
|
|
yield request.param, core
|
|
setattr(core, "cached_config", saved)
|
|
|
|
|
|
@pytest.fixture
|
|
def point_config(monkeypatch):
|
|
"""Return a function that points context.manager_config_path at a path.
|
|
|
|
Both readers resolve ``context.manager_config_path`` at CALL time
|
|
(``config.read(context.manager_config_path)``), so monkeypatching the
|
|
context attribute redirects glob AND legacy alike."""
|
|
ctx = import_context()
|
|
|
|
def _point(path):
|
|
monkeypatch.setattr(ctx, "manager_config_path", str(path))
|
|
|
|
return _point
|
|
|
|
|
|
def _write_ini(tmp_path, body: str):
|
|
ini = tmp_path / "config.ini"
|
|
ini.write_text(body, encoding="utf-8")
|
|
return ini
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC-19 — missing keys read False (get_bool quirk: missing key -> False)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_sc19_missing_keys_read_false(reader_core, point_config, tmp_path):
|
|
"""SC-19: config.ini WITHOUT either new key -> read_config returns
|
|
allow_git_url_install == False AND allow_pip_install == False.
|
|
|
|
This is the observable manifestation of the shared ``get_bool(key,
|
|
default)`` quirk (spec §3): the ``default`` parameter is IGNORED —
|
|
a missing key always reads False, never the passed default. The quirk
|
|
is documented, NOT fixed, by this GOAL (spec §5 freeze item 9)."""
|
|
reader, core = reader_core
|
|
point_config(_write_ini(tmp_path, "[default]\nsecurity_level = normal\n"))
|
|
|
|
cfg = core.read_config()
|
|
|
|
for key in FLAG_KEYS:
|
|
assert key in cfg, (
|
|
f"RED: {reader} read_config() does not register {key!r} "
|
|
"(goal265-spec.md §3 reader registration)"
|
|
)
|
|
assert cfg[key] is False, f"{reader}: missing {key} must read False"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC-20 — malformed values: only case-insensitive "true" is truthy
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize(
|
|
("raw_value", "expected"),
|
|
[
|
|
("true", True),
|
|
("TRUE", True),
|
|
("True", True),
|
|
("1", False),
|
|
("yes", False),
|
|
("false", False),
|
|
("garbage", False),
|
|
],
|
|
ids=["true", "TRUE", "True", "1", "yes", "false", "garbage"],
|
|
)
|
|
@pytest.mark.parametrize("flag_key", FLAG_KEYS)
|
|
def test_sc20_only_case_insensitive_true_is_truthy(
|
|
reader_core, point_config, tmp_path, flag_key, raw_value, expected):
|
|
"""SC-20: key present with malformed values -> only case-insensitive
|
|
"true" reads True; "1"/"yes"/"false"/"garbage" read False (both flags,
|
|
both readers)."""
|
|
reader, core = reader_core
|
|
point_config(_write_ini(
|
|
tmp_path,
|
|
f"[default]\nsecurity_level = normal\n{flag_key} = {raw_value}\n",
|
|
))
|
|
|
|
cfg = core.read_config()
|
|
|
|
assert flag_key in cfg, (
|
|
f"RED: {reader} read_config() does not register {flag_key!r}"
|
|
)
|
|
assert cfg[flag_key] is expected, (
|
|
f"{reader}: {flag_key} = {raw_value!r} must read {expected}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC-21 — write_config round-trip (write list registration, both writers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_sc21_write_config_round_trips_both_flags(
|
|
reader_core, point_config, tmp_path):
|
|
"""SC-21: both flags true in cached config -> write_config -> reset the
|
|
reader's cached_config -> read_config -> both keys persist as True.
|
|
|
|
Failure mode this guards: keys registered in the read dict but NOT in
|
|
the write_config persistence list — the value would silently drop on
|
|
the next config save (spec §2 F3/F4 dual-anchor rule, residual risk R2)."""
|
|
reader, core = reader_core
|
|
ini = _write_ini(
|
|
tmp_path,
|
|
"[default]\nsecurity_level = normal\n"
|
|
"allow_git_url_install = true\nallow_pip_install = true\n",
|
|
)
|
|
point_config(ini)
|
|
|
|
loaded = core.get_config()
|
|
for key in FLAG_KEYS:
|
|
assert loaded.get(key) is True, (
|
|
f"RED: {reader} get_config() did not load {key!r}=True "
|
|
"from staged config.ini"
|
|
)
|
|
|
|
core.write_config()
|
|
|
|
raw = ini.read_text(encoding="utf-8")
|
|
for key in FLAG_KEYS:
|
|
assert key in raw, (
|
|
f"{reader}: write_config() dropped {key!r} — key missing from "
|
|
"the write_config persistence list (spec §3 reader registration)"
|
|
)
|
|
|
|
_reset_cache(core) # per-reader cache reset REQUIRED before re-read
|
|
cfg = core.read_config()
|
|
for key in FLAG_KEYS:
|
|
assert cfg.get(key) is True, (
|
|
f"{reader}: {key} did not survive the write/read round-trip"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC-22 — cached_config staleness: restart-only activation (reader arm)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("flag_key", FLAG_KEYS)
|
|
def test_sc22_flag_edit_without_restart_stays_stale(
|
|
reader_core, point_config, tmp_path, flag_key):
|
|
"""SC-22: process running with cached flag=false -> config.ini edited to
|
|
flag=true WITHOUT restart -> the reader still serves False (module-level
|
|
cached_config; identical semantics to security_level today, MM §1.5).
|
|
Clearing the cache (= restart) then serves True."""
|
|
reader, core = reader_core
|
|
ini = _write_ini(
|
|
tmp_path,
|
|
f"[default]\nsecurity_level = normal\n{flag_key} = false\n",
|
|
)
|
|
point_config(ini)
|
|
|
|
first = core.get_config()
|
|
assert first.get(flag_key) is False, (
|
|
f"RED: {reader} get_config() does not register {flag_key!r}"
|
|
)
|
|
|
|
# Edit config.ini on disk — NO cache reset (no restart).
|
|
_write_ini(
|
|
tmp_path,
|
|
f"[default]\nsecurity_level = normal\n{flag_key} = true\n",
|
|
)
|
|
|
|
stale = core.get_config()
|
|
assert stale.get(flag_key) is False, (
|
|
f"{reader}: {flag_key} hot-reloaded — activation MUST be "
|
|
"restart-only (cached_config, spec §3)"
|
|
)
|
|
|
|
_reset_cache(core) # simulate restart
|
|
fresh = core.get_config()
|
|
assert fresh.get(flag_key) is True, (
|
|
f"{reader}: {flag_key}=true not visible after cache reset (restart)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SC-29 — exception-fallback path carries the new keys as False
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("breakage", ["missing_file", "no_default_section"])
|
|
def test_sc29_fallback_path_returns_false_for_both_flags(
|
|
reader_core, point_config, tmp_path, breakage):
|
|
"""SC-29: NO config.ini present / config.ini WITHOUT [default] section ->
|
|
read_config's exception-fallback dict returns False for BOTH new keys
|
|
(fallback dicts are the third registration anchor per reader,
|
|
spec §2 F3/F4)."""
|
|
reader, core = reader_core
|
|
if breakage == "missing_file":
|
|
point_config(tmp_path / "does-not-exist" / "config.ini")
|
|
else:
|
|
point_config(_write_ini(tmp_path, "[other]\nsomething = 1\n"))
|
|
|
|
cfg = core.read_config()
|
|
|
|
for key in FLAG_KEYS:
|
|
assert key in cfg, (
|
|
f"RED: {reader} exception-fallback dict does not carry {key!r} "
|
|
"(goal265-spec.md §3 'Reader registration' — fallback anchor)"
|
|
)
|
|
assert cfg[key] is False, (
|
|
f"{reader}: fallback {key} must be False (secure by default)"
|
|
)
|