fix: refuse venv replacement when a secondary profile is unreadable

This commit is contained in:
ethernet
2026-09-24 03:11:19 -04:00
parent f7b7a5a0b0
commit fec22d3ecd
3 changed files with 72 additions and 3 deletions

View File

@@ -398,6 +398,11 @@ class Venv(StatePackage):
candidate = generation / "venv"
environment = managed_environment(candidate, env=source_build_environment(project),
explicit=explicit or repair, output=sys.stderr)
if not repair:
# Inspection may skip a broken secondary profile, but publishing a replacement
# graph must not silently evict its recorded members (including passed candidates).
from pm.plugins_state import enabled_plugins_ordered
enabled_plugins_ordered()
members = [] if repair else (enabled_member_dirs() if plugin_dirs is None else plugin_dirs)
try:
generation.mkdir(parents=True)

View File

@@ -268,6 +268,69 @@ def test_conflicting_candidate_refused_unenabled_and_unimported(admission_env):
subprocess.run([str(sidecar_python), "-c", "import sys; assert sys.prefix != sys.base_prefix"], check=True, timeout=30)
@pytest.mark.skipif(not _uv_available(), reason="uv not on PATH")
def test_malformed_secondary_cannot_evict_recorded_member(admission_env, monkeypatch, caplog):
"""A→B→A: passive inspection survives bad config; A's recorded graph does not shrink."""
from pm.environments import runtime_facts_path, selected_venv
from pm.install import sync_venv, venv_is_current
from pm.lock import Facts
tmp_path, home_a = admission_env
core = tmp_path / "core"
profile = home_a / "profiles" / "work"
profile.mkdir(parents=True)
_write_enabled(profile, ["profile-dep"])
member = profile / "plugins" / "profile-dep"
member.mkdir(parents=True)
(member / "pyproject.toml").write_text(
'[project]\nname="profile-dep"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
sync_venv(explicit=True)
recorded = Facts(runtime_facts_path(core), strict=True).get("venv")
selected = selected_venv(core)
assert recorded["stamp"] and selected.is_dir()
assert "profile-dep" in (Path(recorded["resolved_lock"]).parent / "pyproject.toml").read_text()
bad = profile / "config.yaml"
bad.write_text("plugins: [broken]\n", encoding="utf-8")
candidate = home_a / "plugins" / "new-dep"
candidate.mkdir(parents=True)
(candidate / "pyproject.toml").write_text(
'[project]\nname="new-dep"\nversion="1"\nrequires-python=">=3.11"\n'
'dependencies=[]\n[tool.uv]\npackage=false\n', encoding="utf-8",
)
_write_enabled(home_a, ["new-dep"])
assert venv_is_current(project_root=core) is False
assert str(bad) in caplog.text
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True)
assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded
assert selected_venv(core) == selected
# Even a precomputed member list cannot bypass a newly broken profile.
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True, plugin_dirs=[])
assert Facts(runtime_facts_path(core), strict=True).get("venv") == recorded
home_b = tmp_path / "home-b"
_write_enabled(home_b, [])
monkeypatch.setenv("HERMES_HOME", str(home_b))
sync_venv(explicit=True)
assert selected_venv(core).is_dir()
assert Facts(runtime_facts_path(core), strict=True).get("venv")["stamp"] != recorded["stamp"]
monkeypatch.setenv("HERMES_HOME", str(home_a))
with pytest.raises(ValueError, match="config.yaml"):
sync_venv(explicit=True)
assert selected_venv(core) == selected
_write_enabled(profile, ["profile-dep"])
sync_venv(explicit=True)
restored = Facts(runtime_facts_path(core), strict=True).get("venv")
assert selected_venv(core).is_dir()
assert restored["stamp"] != recorded["stamp"]
assert "profile-dep" in (Path(restored["resolved_lock"]).parent / "pyproject.toml").read_text()
def test_active_context_home_exported_to_wrapper_subprocess(monkeypatch, tmp_path):
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tools.environments.local import build_subprocess_env

View File

@@ -213,11 +213,12 @@ def test_uncertain_profile_selection_skips_sync_but_not_admission_or_recorded_re
)
assert result.stdout.strip() == "1.0"
assert Path(Facts(paths.runtime_facts_path()).get("venv")["resolved_lock"]).read_bytes() == old_lock
engine.sync_venv(explicit=True)
with pytest.raises(ValueError, match="config.yaml"):
engine.sync_venv(explicit=True)
assert str(sibling_config) in caplog.text
current = selected_venv(core)
assert selected_venv(core) == repaired
result = subprocess.run(
[str(current / ("Scripts/python.exe" if os.name == "nt" else "bin/python")),
[str(repaired / ("Scripts/python.exe" if os.name == "nt" else "bin/python")),
"-I", "-c", "import core_dep; print(core_dep.__version__)"],
cwd=tmp_path, capture_output=True, text=True, check=True, timeout=30,
)