ComfyUI-Manager/tests/e2e/test_e2e_pip_url_form.py
Dr.Lt.Data fca7ef149d
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
feat(security): dedicated install flags decouple git_url/pip install from security_level (#2962)
* feat(security): dedicated install flags decouple git_url/pip from security_level

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

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

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

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

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

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

Verified from a fresh state (seed pack absent): both modules pass
(13 tests, incl. previously-failing TestInstalledPacks 2 and
TestNightlyInstallCycle 3).
2026-06-11 01:44:12 +09:00

409 lines
18 KiB
Python

"""[goal299 step3] E2E: URL-form pip install through the S-B endpoint.
CONTRACT UNDER TEST (SHIPPED — goal265; this module adds tests only):
the ``allow_pip_install`` gate on POST /v2/customnode/install/pip is
**argument-content-agnostic** (goal299-mm-addendum.md §1): a URL-form
argument (``git+https://github.com/...``) exercises the SAME
dedicated-flag gate as a bare package name — no code path inspects
the argument before the gate decision (legacy/manager_server.py:1574).
FIXTURE NOTE (goal329 — WHY the owned fixture): the install arm
originally exercised ``git+https://github.com/facebookresearch/sam2``;
that external, unpinned, heavy dependency (torch-ecosystem build at
restart time) made the test hostage to upstream maintainer drift
(maintainer-fragility decision, goal329). It is replaced by the OWNED,
purpose-built fixture ``git+https://github.com/ltdrdata/pip-test1-do-not-install``
(PUBLIC; package ``pip-test1-do-not-install``, module
``pip_test1_do_not_install``, ``MARKER = "pip-test1-do-not-install:ok"``,
zero deps, pure Python, no import side effects). The gate contract is
argument-content-agnostic, so the URL swap changes NOTHING about what
is being proven — only the install payload shrinks to a harmless,
owned package.
Two arms (goal299-spec.md §1.1, LOCKED):
DENY arm flag=false at security_level=weak -> 403, NO reservation
entry appended, denial log names allow_pip_install
(weak proves the FLAG, not the security_level, decides).
INSTALL arm flag=true at security_level=strong, loopback -> 200 =
gate-pass + RESERVATION (deferred-execution semantics,
addendum §1: '#FORCE' reserves into install-scripts.txt;
the real `uv pip install` runs at the NEXT server start)
-> restart -> fixture module REALLY importable (MARKER
verified) in the isolated E2E venv -> self-clean ->
absent again.
Placement: NEW SIBLING of tests/e2e/test_e2e_secgate_legacy_flags.py
(spec §1.2) — that module carries an engineered zero-install guarantee
(its header :96-100), so the real-install arm must NOT live there.
Helper reuse contract (spec §1.2, LOCKED): import-reuse is limited to
``_stage_flags_config`` / ``_stop_legacy`` / ``_make_flags_server_fixture``
/ ``_post_pip`` / ``_log_offset``; everything else needed here is a
sibling-local definition (provenance-commented copies where applicable).
Self-cleaning + idempotent (spec §1.3 install-arm steps 1/6): pre-guard
uninstall, teardown uninstall (runs even on failure), reservation-file
hygiene on both ends. The venv is uv-managed and has NO pip module
(addendum R4) — all (un)installs go through ``<venv>/bin/uv``.
Requires a pre-built E2E environment (setup_e2e_env.sh); skip-marked
otherwise. The install arm additionally requires github.com reachability
(addendum R5) and is skip-marked offline; the deny arm always runs.
"""
from __future__ import annotations
import ast
import os
import socket
import subprocess
import pytest
# Import-reuse: the LOCKED helper subset only (goal299-spec.md §1.2).
from test_e2e_secgate_legacy_flags import (
_log_offset,
_make_flags_server_fixture,
_post_pip,
_stop_legacy,
)
E2E_ROOT = os.environ.get("E2E_ROOT", "")
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
SERVER_LOG = os.path.join(E2E_ROOT, "logs", "comfyui.log") if E2E_ROOT else ""
# Same port as the flags module (its module constant is not in the locked
# import set; the value is part of the shared legacy-fixture contract).
PORT = 8199
# Owned fixture (goal329 — see FIXTURE NOTE in the module docstring).
# Dist name uses hyphens, import name uses underscores; MARKER gives a
# stronger installed-for-real check than a bare import.
FIXTURE_URL = "git+https://github.com/ltdrdata/pip-test1-do-not-install"
FIXTURE_DIST = "pip-test1-do-not-install"
FIXTURE_MODULE = "pip_test1_do_not_install"
FIXTURE_MARKER = "pip-test1-do-not-install:ok"
VENV_PY = os.path.join(E2E_ROOT, "venv", "bin", "python") if E2E_ROOT else ""
VENV_UV = os.path.join(E2E_ROOT, "venv", "bin", "uv") if E2E_ROOT else ""
# Reservation file (path shape per tests/e2e/test_e2e_legacy_real_ops.py:538;
# producer: reserve_script, legacy/manager_core.py:1836-1843; consumer:
# comfyui_manager/prestartup_script.py:485 at next server start).
SCRIPTS_PATH = (
os.path.join(
COMFYUI_PATH, "user", "__manager", "startup-scripts", "install-scripts.txt"
)
if E2E_ROOT
else ""
)
# Post-reservation restart budget (goal329): the owned fixture is pure
# Python with zero deps — its `uv pip install` completes in seconds, so
# the restart wall time is dominated by the plain server boot
# (+prestartup resolver), observed well under 60s in this harness. 180s
# equals the proven readiness budget the flags module's _start_legacy
# uses for a plain legacy start (>=3x headroom over observed boot); the
# python-side timeout is shell+60 per the helper invariant.
RESTART_BUDGET_S = 180
pytestmark = pytest.mark.skipif(
not E2E_ROOT
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
reason="E2E_ROOT not set or E2E environment not ready",
)
# ---------------------------------------------------------------------------
# Sibling-local helpers (spec §1.2 — outside the locked import set)
# ---------------------------------------------------------------------------
def _start_legacy_install(extra_env: dict, shell_timeout: int = RESTART_BUDGET_S) -> int:
"""Parameterized variant of test_e2e_secgate_legacy_flags._start_legacy
(:147-158) — provenance copy per goal299-spec.md §1.2. The flags
module's helper hardcodes its env dict and python-side timeout, which
can neither inject per-restart env nor take a per-call readiness
budget. TIMEOUT is forwarded to start_comfyui_legacy.sh as the shell
readiness budget; the python-side subprocess timeout MUST exceed it
(+60s) so the start script, not python, owns the timeout."""
env = {
**os.environ,
"E2E_ROOT": E2E_ROOT,
"PORT": str(PORT),
"TIMEOUT": str(shell_timeout),
**extra_env,
}
r = subprocess.run(
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui_legacy.sh")],
capture_output=True, text=True, timeout=shell_timeout + 60, env=env,
)
if r.returncode != 0:
raise RuntimeError(
f"Failed to start ComfyUI (legacy, install restart):\n{r.stderr}"
)
for part in r.stdout.strip().split():
if part.startswith("COMFYUI_PID="):
return int(part.split("=")[1])
raise RuntimeError(f"Could not parse PID:\n{r.stdout}")
def _log_slice(offset: int) -> str:
"""Provenance copy of test_e2e_secgate_legacy_flags._log_slice
(:241-246) — not in the locked import set (spec §1.2 fallback clause:
fixture semantics are the lock, import path is not)."""
if not os.path.isfile(SERVER_LOG):
return ""
with open(SERVER_LOG, "r", encoding="utf-8", errors="replace") as f:
f.seek(offset)
return f.read()
def _scripts_state() -> str:
"""Current install-scripts.txt content, or the sentinel 'ABSENT'
the file may legitimately not exist (spec §1.3 deny step 2: it is
created lazily by reserve_script on first reservation)."""
if not os.path.isfile(SCRIPTS_PATH):
return "ABSENT"
with open(SCRIPTS_PATH, "r", encoding="utf-8", errors="replace") as f:
return f.read()
def _venv_fixture_probe() -> subprocess.CompletedProcess:
"""Import the fixture module in the isolated E2E venv and print its
MARKER (subprocess — the observable for really-installed /
really-absent). rc != 0 means absent; rc == 0 AND the MARKER value in
stdout means installed for real (stronger than a bare import)."""
return subprocess.run(
[VENV_PY, "-c", f"import {FIXTURE_MODULE} as m; print(m.MARKER)"],
capture_output=True, text=True, timeout=60,
)
def _uninstall_fixture() -> None:
"""Uninstall the fixture distribution from the E2E venv via uv.
The venv has NO pip module (`python -m pip` fails — addendum R4);
`<venv>/bin/uv pip uninstall --python <venv-python>` is the working
path. A no-op when the dist is absent (uv warns, exits 0)."""
subprocess.run(
[VENV_UV, "pip", "uninstall", FIXTURE_DIST, "--python", VENV_PY],
capture_output=True, text=True, timeout=180,
)
def _strip_fixture_reservation() -> None:
"""Remove any fixture residual line from install-scripts.txt
(addendum R8: a leftover reservation would silently mutate the venv
on the NEXT restart, possibly a different test's). Matches both the
hyphenated dist/URL form and the underscored module form."""
if not os.path.isfile(SCRIPTS_PATH):
return
with open(SCRIPTS_PATH, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
kept = [
ln for ln in lines
if FIXTURE_DIST not in ln.lower() and FIXTURE_MODULE not in ln.lower()
]
if kept != lines:
with open(SCRIPTS_PATH, "w", encoding="utf-8") as f:
f.writelines(kept)
def _github_reachable() -> bool:
"""Runtime network probe for the install arm (addendum R5)."""
try:
socket.create_connection(("github.com", 443), timeout=10).close()
return True
except OSError:
return False
# ===========================================================================
# DENY arm — flag=false, security_level=weak (weak proves flag-not-level
# decides; goal299-spec.md §1.3 TestPipUrlFormDeny)
# ===========================================================================
comfyui_pip_url_deny = _make_flags_server_fixture(False, False, "weak")
class TestPipUrlFormDeny:
"""URL-form POST is denied by the allow_pip_install flag gate exactly
like a bare package name (argument-content-agnostic, addendum §1):
403, no install-side-effect (no reservation entry), denial log names
the flag. Shared-POST shape (spec §1.3, LOCKED): exactly ONE POST via
a class-scoped record fixture; the three tests assert exclusively
against the record — independent of execution order, no re-reads of
live state."""
@pytest.fixture(scope="class")
def deny_post_record(self, comfyui_pip_url_deny):
offset = _log_offset()
snapshot_before = _scripts_state()
resp = _post_pip(FIXTURE_URL)
return {
"log_offset_before": offset,
"scripts_snapshot_before": snapshot_before,
"status_code": resp.status_code,
"log_slice_after": _log_slice(offset),
"scripts_state_after": _scripts_state(),
}
def test_url_form_denied_403(self, deny_post_record):
"""403 from the dedicated-flag gate despite security_level=weak
(deny-direction decoupling, mm §2 row 'deny arm, after POST')."""
assert deny_post_record["status_code"] == 403, (
f"URL-form pip POST: expected 403 (allow_pip_install=false "
f"overrides sl=weak), got {deny_post_record['status_code']}"
f"the gate must be argument-content-agnostic."
)
def test_url_form_deny_no_reservation(self, deny_post_record):
"""No reservation entry appended on deny (mm §2: install-scripts.txt
'no entry written'; HTTP 200 would have meant reservation, so the
durable on-disk state is the decisive side-effect observable)."""
before = deny_post_record["scripts_snapshot_before"]
after = deny_post_record["scripts_state_after"]
assert after == before, (
f"deny arm: install-scripts.txt changed across the denied POST "
f"— a reservation leaked past the gate.\nbefore:\n{before}\n"
f"after:\n{after}"
)
if after != "ABSENT":
assert FIXTURE_DIST not in after.lower(), (
f"deny arm: a fixture line is present in install-scripts.txt "
f"after a denied POST:\n{after}"
)
def test_url_form_deny_log_names_flag(self, deny_post_record):
"""Denial log names allow_pip_install (SECURITY_MESSAGE_FLAG_PIP,
legacy/manager_server.py:47) and carries no security-level framing
for this denial (goal265 spec §1.1 invariant 6 — same assertion
shape as the flags module's SC-23)."""
log = deny_post_record["log_slice_after"]
assert "allow_pip_install" in log, (
"deny arm: denial log does not name allow_pip_install "
f"(SECURITY_MESSAGE_FLAG_PIP). Slice:\n{log[-1500:]}"
)
assert "security level to 'normal-'" not in log, (
"deny arm: denial log still carries the misleading "
"security-level copy (SECURITY_MESSAGE_NORMAL_MINUS)."
)
# ===========================================================================
# INSTALL arm — flag=true, security_level=strong (strong proves allow-side
# decoupling; goal299-spec.md §1.3 TestPipUrlFormInstall)
# ===========================================================================
comfyui_pip_url_install = _make_flags_server_fixture(False, True, "strong")
@pytest.mark.network
class TestPipUrlFormInstall:
"""Full deferred-install round trip (mm §2 observable-outcome table):
POST 200 = gate-pass + reservation (NOT installation) -> restart
executes the reserved `uv pip install -U git+...` at prestartup
-> fixture module imports with the expected MARKER in the isolated
venv -> self-clean -> absent.
Asserting importability right after the 200 would be a guaranteed
false-FAIL (mm §2 note) — the restart between POST and the import
assertion is the production execution path (addendum §4.1)."""
@pytest.fixture(scope="class", autouse=True)
def _network_guard(self):
if not _github_reachable():
pytest.skip("offline: github.com unreachable — install arm "
"requires network (deny arm unaffected)")
@pytest.fixture(autouse=True)
def _self_clean(self):
"""Teardown runs even on failure (spec §1.3 step 6): uninstall the
dist, strip any residual reservation line (addendum R8), and prove
the venv is back to baseline (import fails again)."""
yield
_uninstall_fixture()
_strip_fixture_reservation()
assert _venv_fixture_probe().returncode != 0, (
f"self-clean: {FIXTURE_MODULE} is still importable in the E2E "
f"venv after `uv pip uninstall {FIXTURE_DIST}` — venv NOT "
f"restored to baseline."
)
def test_url_form_install_end_to_end(self, comfyui_pip_url_install):
# Step 1 — pre-guard (idempotency, spec §1.3 step 1): a previous
# aborted run must not turn this into a false-PASS.
_uninstall_fixture()
_strip_fixture_reservation()
assert _venv_fixture_probe().returncode != 0, (
f"pre-guard: {FIXTURE_MODULE} already importable before the "
f"test — uninstall guard failed; venv not at baseline."
)
# Step 2 — POST the URL-form argument; 200 = gate-pass +
# reservation under the deferred-execution semantics (mm §1).
resp = _post_pip(FIXTURE_URL)
assert resp.status_code == 200, (
f"install arm: expected 200 (allow_pip_install=true overrides "
f"sl=strong), got {resp.status_code} — allow-side decoupling "
f"broken for the URL-form argument."
)
# Step 3 — reservation entry: a Python-list-repr line containing
# the fixture URL substring (producer reserve_script,
# legacy/manager_core.py:1836-1843; precedent assertion shape
# test_e2e_legacy_real_ops.py:516-538). NOT a shell-string match.
state = _scripts_state()
assert state != "ABSENT", (
"install arm: install-scripts.txt missing after 200 — "
"no reservation was written."
)
fixture_lines = [ln for ln in state.splitlines() if FIXTURE_URL in ln]
assert fixture_lines, (
f"install arm: no reservation line contains {FIXTURE_URL!r}.\n"
f"file content:\n{state}"
)
reserved = ast.literal_eval(fixture_lines[0])
assert isinstance(reserved, list) and "#FORCE" in reserved, (
f"install arm: reservation line is not the expected "
f"['..', '#FORCE', <pip cmd>...] list-repr shape: {reserved!r}"
)
# Step 4 — restart: the production execution path for the
# reservation (prestartup_script.py:485 consumes the file at
# server start). Budget rationale at RESTART_BUDGET_S — the
# owned zero-dep fixture installs in seconds; the budget covers
# the plain server boot, no build env vars needed (goal329
# retired the CUDA-build determinism knob with the heavy
# upstream package).
_stop_legacy()
_start_legacy_install({}, shell_timeout=RESTART_BUDGET_S)
# Step 5 — post-restart: reservation CONSUMED (prestartup removes
# the processed file) and the fixture REALLY importable in the
# venv, proven by its MARKER value (stronger than bare import:
# the marker ties the import to the owned fixture's content).
post_state = _scripts_state()
assert post_state == "ABSENT" or FIXTURE_URL not in post_state, (
f"install arm: reservation NOT consumed by the restart — "
f"install-scripts.txt still references the fixture:\n{post_state}"
)
probe = _venv_fixture_probe()
assert probe.returncode == 0, (
f"install arm: `import {FIXTURE_MODULE}` still fails in the "
f"E2E venv after the reservation-executing restart — install "
f"did not happen or did not target the venv.\n"
f"stderr:\n{probe.stderr[-500:]}"
)
assert FIXTURE_MARKER in probe.stdout, (
f"install arm: fixture imported but MARKER mismatch — "
f"expected {FIXTURE_MARKER!r} in stdout, got: {probe.stdout!r}"
)
# Version breadcrumb for triage (owned repo, so drift risk is
# retired — the breadcrumb now just documents what was installed).
show = subprocess.run(
[VENV_UV, "pip", "show", FIXTURE_DIST, "--python", VENV_PY],
capture_output=True, text=True, timeout=60,
)
print(f"\n[goal329 install-evidence] uv pip show {FIXTURE_DIST}:\n"
f"{show.stdout}")