diff --git a/hermes_cli/runtime_paths.py b/hermes_cli/runtime_paths.py index bbef8ac886..89ace6c633 100644 --- a/hermes_cli/runtime_paths.py +++ b/hermes_cli/runtime_paths.py @@ -18,8 +18,16 @@ def install_key(project_root: Path) -> str: return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] +def dependency_home_root() -> Path: + """Scope dependency state like a process launched in the active home.""" + from hermes_constants import get_default_hermes_root, get_hermes_home_override + + override = get_hermes_home_override() + return get_default_hermes_root(home=override) if override else get_default_hermes_root() + + def installs_root() -> Path: - return get_default_hermes_root() / "installs" + return dependency_home_root() / "installs" def install_state_dir(project_root: Path) -> Path: diff --git a/hermes_cli/runtime_state.py b/hermes_cli/runtime_state.py index 342b12e94e..c94db18ef1 100644 --- a/hermes_cli/runtime_state.py +++ b/hermes_cli/runtime_state.py @@ -14,8 +14,7 @@ import tempfile import time import uuid -from hermes_cli.runtime_paths import install_state_dir, runtime_facts_path -from hermes_constants import get_default_hermes_root +from hermes_cli.runtime_paths import dependency_home_root, install_state_dir, runtime_facts_path def _lock(fd: int, *, wait: bool) -> bool: @@ -93,7 +92,7 @@ def recover_publication(project: Path) -> None: try: row = json.loads(data) config = Path(row["config"]) - if config.name != "config.yaml" or not config.resolve().is_relative_to(get_default_hermes_root().resolve()): + if config.name != "config.yaml" or not config.resolve().is_relative_to(dependency_home_root().resolve()): raise ValueError("config path is outside Hermes state") previous = base64.b64decode(row["previous"], validate=True) if row["previous"] is not None else None if _digest(runtime_facts_path(project)) == row["facts_before"]: diff --git a/hermes_constants.py b/hermes_constants.py index 69ea317cf5..db5b839268 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -143,11 +143,11 @@ def get_process_hermes_home() -> Path: _default_hermes_root_memo: "tuple[str, str, Path] | None" = None -def get_default_hermes_root() -> Path: - """Root Hermes dir for profile-level ops: ```` when ``HERMES_HOME=/profiles/``.""" +def get_default_hermes_root(*, home: str | Path | None = None) -> Path: + """Root of an explicit home, or the process home when none is supplied.""" global _default_hermes_root_memo native_home = _get_platform_default_hermes_home() - env_home = os.environ.get("HERMES_HOME", "") + env_home = str(home) if home is not None else os.environ.get("HERMES_HOME", "") memo = _default_hermes_root_memo if memo is not None and memo[:2] == (str(native_home), env_home): return memo[2] diff --git a/pm/plugins_state.py b/pm/plugins_state.py index d57d8d59b4..651e4f4e76 100644 --- a/pm/plugins_state.py +++ b/pm/plugins_state.py @@ -15,17 +15,10 @@ from typing import Any, Optional def _profiles_root() -> Path: - # Profile roots are derived from get_default_hermes_root() — the ONE - # authority for "where do profiles live" (hermes_constants): it - # returns HERMES_HOME directly for custom roots (Docker /opt/data, - # non-default local roots) and for profile-mode HERMES_HOME, - # on both standard and custom layouts. Hardcoding Path.home()/ - # .hermes/profiles silently omits custom-root profiles — their - # enabled dep plugins never join the union and bisect disable - # decisions never write back to their config. - from hermes_constants import get_default_hermes_root + # Plugin discovery and dependency publication must use the same home root. + from hermes_cli.runtime_paths import dependency_home_root - return get_default_hermes_root() / "profiles" + return dependency_home_root() / "profiles" def _read_home_config(home: Path) -> Optional[dict[str, Any]]: @@ -69,9 +62,9 @@ def _all_homes() -> list[Path]: """The default home + every profile home (the union's scope).""" homes: list[Path] = [] try: - from hermes_constants import get_default_hermes_root + from hermes_cli.runtime_paths import dependency_home_root - homes.append(get_default_hermes_root()) + homes.append(dependency_home_root()) except Exception: pass try: diff --git a/tests/pm/test_runtime_context_home.py b/tests/pm/test_runtime_context_home.py new file mode 100644 index 0000000000..a79791ed10 --- /dev/null +++ b/tests/pm/test_runtime_context_home.py @@ -0,0 +1,55 @@ +"""Context-only homes use the same dependency state as their own process.""" + +from hermes_cli import runtime_paths +from hermes_cli.plugins_admission import _config_commit +from hermes_cli.runtime_state import recover_publication, runtime_lock +from hermes_constants import reset_hermes_home_override, set_hermes_home_override +from pm import paths, plugins_state + + +def test_context_home_publication_recovers_from_its_own_process(tmp_path, monkeypatch): + process_home = tmp_path / "process-home" + context_home = tmp_path / "context-home" + context_home.mkdir() + project = tmp_path / "repo" + project.mkdir() + config = context_home / "config.yaml" + original = b"plugins:\n enabled: [old]\n" + config.write_bytes(original) + monkeypatch.setenv("HERMES_HOME", str(process_home)) + monkeypatch.setattr(paths, "repo_root", lambda: project) + process_state = runtime_paths.install_state_dir(project) + token = set_hermes_home_override(context_home) + try: + context_state = runtime_paths.install_state_dir(project) + assert context_state != process_state + assert plugins_state.enabled_plugins_ordered() == { + context_home / "plugins": ["old"], + } + with runtime_lock(project): + _config_commit({"new"}, set()) + assert config.read_bytes() != original + finally: + reset_hermes_home_override(token) + + assert not process_state.exists() + monkeypatch.setenv("HERMES_HOME", str(context_home)) + assert runtime_paths.install_state_dir(project) == context_state + with runtime_lock(project): + recover_publication(project) + assert config.read_bytes() == original + assert not (context_state / "publication.json").exists() + + +def test_named_context_profile_shares_its_install_root(tmp_path, monkeypatch): + home = tmp_path / "home" + profile = home / "profiles" / "worker" + profile.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + project = tmp_path / "repo" + state = runtime_paths.install_state_dir(project) + token = set_hermes_home_override(profile) + try: + assert runtime_paths.install_state_dir(project) == state + finally: + reset_hermes_home_override(token)