Narrow the salvaged fix: keep store_root() returning $HERMES_HOME/tools as spelled, and resolve the interpreter's directories only where pm/runtime.py::_inputs hashes them. Resolving store_root() itself moves every path built from store_root().parent (features.json, the native-build state dir, writable_store_root's manifest probe), so after the update a symlinked tools dir would read no feature selections and every install under a symlinked parent would re-key. Only directories resolve: the pinned CPython's bin/python3 is itself a symlink to python3.X, so a full resolve() would re-key every install. A selected.json written before this change (spelled path) is still accepted, so installs that were current stay current; only per-task homes that were already re-staging every launch re-stage once more and then converge. Tests: the salvaged store_root alias tests are replaced by one invariant through prepare_runtime (home, per-task home and real path share one generation; a pre-fix record is not re-staged). Red on origin/main.
196 lines
8.9 KiB
Python
196 lines
8.9 KiB
Python
"""PM's resolver must not depend on the application it is repairing."""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
|
|
def test_pm_runtime_discovers_plugins_without_application_dependencies(tmp_path, monkeypatch):
|
|
from pm.runtime import prepare_runtime
|
|
|
|
uv = shutil.which("uv")
|
|
if uv is None:
|
|
pytest.skip("uv is required for the real dependency-runtime test")
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
|
|
(home / "config.yaml").write_text("plugins:\n enabled: []\n", encoding="utf-8")
|
|
repo = Path(__file__).resolve().parents[2]
|
|
python = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime")
|
|
env = {k: v for k, v in os.environ.items() if not k.startswith(("PYTHON", "UV_"))}
|
|
env.update(HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(home / "tools"))
|
|
# Import real PM, including its production plugin-discovery chain.
|
|
code = f"""
|
|
import importlib.util, json, sys
|
|
sys.path.insert(0, {str(repo)!r})
|
|
from pm.workspace import enabled_member_dirs
|
|
from pm.plugins_state import read_home_selection
|
|
from pathlib import Path
|
|
assert read_home_selection(Path({str(home)!r}))["plugins"]["enabled"] == []
|
|
assert enabled_member_dirs() == []
|
|
assert importlib.util.find_spec("openai") is None
|
|
assert importlib.util.find_spec("yaml") is None
|
|
print(json.dumps({{"prefix": sys.prefix, "yaml": importlib.util.find_spec("ruamel.yaml").origin}}))
|
|
"""
|
|
result = subprocess.run([str(python), "-I", "-B", "-c", code], env=env,
|
|
capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
report = json.loads(result.stdout)
|
|
assert Path(report["yaml"]).is_relative_to(Path(report["prefix"]))
|
|
assert prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime", offline=True) == python
|
|
# Repair PM itself from its own lock, without trusting an existing marker.
|
|
(Path(report["yaml"]).parent / "main.py").unlink()
|
|
repaired = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime", offline=True)
|
|
assert repaired != python
|
|
checked = subprocess.run([str(repaired), "-I", "-B", "-c", code], env=env,
|
|
capture_output=True, text=True, timeout=30)
|
|
assert checked.returncode == 0, checked.stdout + checked.stderr
|
|
|
|
|
|
def test_cold_worker_bootstrap_reuses_the_requests_cache(tmp_path, monkeypatch):
|
|
import pm
|
|
from hermes_constants import get_default_hermes_root
|
|
from pm import client, runtime
|
|
from pm.runtime_stage import stage_runtime
|
|
|
|
uv = shutil.which("uv")
|
|
assert uv, "the bootstrap cache contract requires real uv"
|
|
tools = Path(uv), Path(sys.executable)
|
|
cache = tmp_path / "shared-cache"
|
|
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
monkeypatch.setenv("USERPROFILE", str(tmp_path / "home"))
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home/.hermes"))
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools"))
|
|
monkeypatch.setattr("pm.paths.repo_root", lambda: tmp_path / "project")
|
|
monkeypatch.setattr("pm._uv._toolchain", lambda **kwargs: tools)
|
|
monkeypatch.setattr(client, "is_runtime", lambda: False)
|
|
stage_runtime(*tools, tmp_path / "warmup", cache=cache)
|
|
shutil.rmtree(tmp_path / "warmup")
|
|
|
|
def offline_stage(*args, **kwargs):
|
|
# A fresh manager must use the populated explicit cache, not download
|
|
# its dependencies again under the bundle's isolated HOME.
|
|
kwargs["offline"] = True
|
|
return stage_runtime(*args, **kwargs)
|
|
|
|
monkeypatch.setattr("pm.runtime_stage.stage_runtime", offline_stage)
|
|
worker = Path(client.__file__).with_name("worker.py")
|
|
script = (
|
|
"import runpy, sys; from pathlib import Path; "
|
|
f"sys.path.insert(0, {str(worker.parent.parent)!r}); import pm._uv; "
|
|
f"pm._uv._toolchain = lambda **kwargs: (Path({uv!r}), Path({sys.executable!r})); "
|
|
f"runpy.run_path({str(worker)!r}, run_name='__main__')"
|
|
)
|
|
|
|
def command(*args, **kwargs):
|
|
prepared = runtime.runtime_command(*args, **kwargs)
|
|
return [*prepared[:3], "-c", script]
|
|
|
|
monkeypatch.setattr(client, "runtime_command", command)
|
|
before = dict(os.environ)
|
|
pm.prune_cache(cache)
|
|
assert cache.is_dir()
|
|
assert not (get_default_hermes_root() / "cache/uv").exists(), "bootstrap created an unshared private cache"
|
|
assert dict(os.environ) == before
|
|
|
|
|
|
@pytest.mark.platforms("macos", "windows")
|
|
def test_sealed_worker_command_uses_only_its_recorded_site(tmp_path, monkeypatch):
|
|
from pm import paths
|
|
from pm.runtime import runtime_command
|
|
|
|
repo = tmp_path / "payload" / "hermes-agent"
|
|
repo.mkdir(parents=True)
|
|
(repo.parent / "manifest.json").write_text('{"repo":"hermes-agent"}')
|
|
runtime = repo.parent / "pm-runtime"
|
|
site = runtime / "site"
|
|
site.mkdir(parents=True)
|
|
base = repo.parent / "python"
|
|
if os.name == "nt":
|
|
shutil.copytree(Path(sys.base_prefix), base)
|
|
python = base / "python.exe"
|
|
else:
|
|
# The guard resolves symlinks (a python linked outside the payload IS an escape), so the
|
|
# interpreter is a real copy inside the payload. A relocatable/framework build locates its
|
|
# stdlib beside the executable: supply the host's library tree the way the bundle test does.
|
|
(base / "bin").mkdir(parents=True)
|
|
python = base / "bin" / "python"
|
|
shutil.copy2(Path(sys._base_executable).resolve(), python)
|
|
(base / "lib").symlink_to(Path(sys.base_prefix) / "lib", target_is_directory=True)
|
|
(runtime / "pm-runtime.json").write_text(json.dumps({
|
|
"python": os.path.relpath(python, runtime), "sitePackages": "site",
|
|
}))
|
|
script = repo / "probe.py"
|
|
script.write_text("import sys,json; print(json.dumps(sys.path))")
|
|
monkeypatch.setattr(paths, "repo_root", lambda: repo)
|
|
command = runtime_command(script)
|
|
result = subprocess.run(command, cwd=tmp_path, capture_output=True, text=True, timeout=30)
|
|
assert result.returncode == 0, result.stderr
|
|
entries = json.loads(result.stdout)
|
|
assert str(site) in entries
|
|
assert not any(Path(entry).name in {"site-packages", "dist-packages"} for entry in entries)
|
|
|
|
|
|
def test_one_store_through_symlinked_homes_keeps_one_pm_runtime(tmp_path, monkeypatch):
|
|
"""#123798: a store reached through symlinks (a per-task HERMES_HOME whose tools/ links back,
|
|
a home under /home -> /var/home) is ONE PM runtime: every spelling reuses the selected
|
|
generation, and a record written before canonicalization is not re-staged."""
|
|
from pm import runtime
|
|
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
(project / "pyproject.toml").write_text("[project]\nname = 'fixture'\n")
|
|
(project / "uv.lock").write_text("version = 1\n")
|
|
real = tmp_path / "volume" / "tools"
|
|
(real / "python" / "bin").mkdir(parents=True)
|
|
(real / "python" / "bin" / "python3.14").touch()
|
|
home, task = tmp_path / "home", tmp_path / "task"
|
|
try:
|
|
(real / "python" / "bin" / "python3").symlink_to("python3.14") # as the pinned CPython ships
|
|
for linked in (home, task):
|
|
linked.mkdir()
|
|
(linked / "tools").symlink_to(real, target_is_directory=True)
|
|
except OSError as exc:
|
|
pytest.skip(f"symlinks unavailable: {exc}")
|
|
|
|
def pre_fix_identity(python: Path) -> str: # the selected.json format earlier releases wrote
|
|
digest = hashlib.sha256()
|
|
for name in ("pyproject.toml", "uv.lock"):
|
|
digest.update((project / name).read_bytes() + b"\0")
|
|
digest.update(str(python).encode())
|
|
return digest.hexdigest()
|
|
|
|
staged = []
|
|
|
|
def stage(uv, python, environment, **_):
|
|
staged.append(python)
|
|
environment.mkdir(parents=True)
|
|
return runtime._python(environment)
|
|
|
|
monkeypatch.setattr("pm.runtime_stage.stage_runtime", stage)
|
|
monkeypatch.setattr(runtime, "_validate", lambda python, env: "")
|
|
monkeypatch.setattr(runtime, "_hold_for_children", lambda environment: None)
|
|
root = tmp_path / "pm-runtime"
|
|
old = root / "generations" / "old"
|
|
old.mkdir(parents=True)
|
|
(old / "pm-runtime.json").write_text("{}")
|
|
(root / "selected.json").write_text(json.dumps(
|
|
{"inputs": pre_fix_identity(home / "tools" / "python" / "bin" / "python3"), "generation": "generations/old"}))
|
|
|
|
def launch(tools: Path, bootstrap: bool = False) -> Path:
|
|
return runtime.prepare_runtime(tools / "uv", tools / "python" / "bin" / "python3", root,
|
|
project=project, bootstrap=bootstrap)
|
|
|
|
assert launch(home / "tools") == runtime._python(old)
|
|
current = launch(task / "tools", bootstrap=True) # a per-task home that was already churning re-stages once
|
|
for tools in (home / "tools", task / "tools", real, home / "tools"):
|
|
assert launch(tools) == current
|
|
assert len(staged) == 1
|