fix(pm): derive profile roots from get_default_hermes_root — custom HERMES_HOME joins the union

Profile-discovery gap (found in deployment review): with a custom
HERMES_HOME root (Docker /opt/data, non-default local roots), profiles
live under <HERMES_HOME>/profiles/<name>, but the new pm code scanned
Path.home()/.hermes/profiles in two places (plugins_state._profiles_root,
workspace._plugin_dir_roots). Result: an enabled dep plugin in a
custom-root profile was silently omitted from the union, and bisect
disable decisions never wrote back to that profile's config.

Fix: both sites derive from get_default_hermes_root() — the ONE
authority (hermes_constants), which already handles every layout:
custom HERMES_HOME → that root directly, profile-mode
<root>/profiles/<name> → <root>, standard ~/.hermes unchanged. The
hardcoded restatement (with a comment even citing the HOME-anchor rule
as justification — the authority IS home-anchored, it just also
handles custom roots) is gone.

tests/pm/test_custom_root_union.py: 4 e2e tests through the REAL
authority (no path mocks on the derivation itself): Docker-shape root
(union discovery + disable write-back to the profile's config),
standard layout (unchanged), profile-mode HERMES_HOME (sibling
profiles visible). tests/pm: 194 passed, 0 failed.
This commit is contained in:
ethernet
2026-09-03 10:55:26 -04:00
parent ef5a98ed57
commit d5922fb698
3 changed files with 155 additions and 6 deletions

View File

@@ -14,10 +14,17 @@ from pathlib import Path
def _profiles_root() -> Path:
# Profile operations are HOME-anchored by design (AGENTS.md rule 6:
# _get_profiles_root returns Path.home()/.hermes/profiles so every
# profile is visible regardless of which is active).
return Path.home() / ".hermes" / "profiles"
# 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 <root> 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
return get_default_hermes_root() / "profiles"
def _enabled_list_for_home(home: Path) -> list[str]:

View File

@@ -77,10 +77,16 @@ def build_root(plugin_dirs: list[Path]) -> Path:
def _plugin_dir_roots() -> set[Path]:
"""All profile plugin dirs + the default home's, machine-wide."""
"""All profile plugin dirs + the default home's, machine-wide.
Profile roots derive from get_default_hermes_root() — the ONE
authority (custom HERMES_HOME → that root directly; profile-mode
→ its parent), so Docker/custom-root profiles join the union the
same as standard ones."""
roots: set[Path] = set()
try:
profiles_root = Path.home() / ".hermes" / "profiles"
from hermes_constants import get_default_hermes_root
profiles_root = get_default_hermes_root() / "profiles"
if profiles_root.is_dir():
for profile in profiles_root.iterdir():
if profile.is_dir():

View File

@@ -0,0 +1,136 @@
"""E2E: custom HERMES_HOME roots (Docker, non-default local) join the
plugin-deps union the same as standard ~/.hermes ones.
get_default_hermes_root() is the ONE authority: HERMES_HOME outside the
default (e.g. /opt/data in Docker) → that root directly; profile-mode
HERMES_HOME (<root>/profiles/<name>) → <root>. The union's profile scan
and the bisect disable write-back must both flow through it — the bug
this guards: hardcoded Path.home()/.hermes/profiles silently omitted
custom-root profiles (enabled dep plugins never joined the union; disable
decisions never wrote back).
"""
from __future__ import annotations
from pathlib import Path
import yaml
import pm.plugins_state as pstate
import pm.workspace as ws
def _write_enabled(home: Path, enabled: list) -> None:
home.mkdir(parents=True, exist_ok=True)
with (home / "config.yaml").open("w", encoding="utf-8") as f:
yaml.safe_dump({"plugins": {"enabled": enabled}}, f)
def _make_dep_plugin(plugins_dir: Path, name: str) -> Path:
plug = plugins_dir / name
plug.mkdir(parents=True)
(plug / "plugin.yaml").write_text(f"name: {name}\n", encoding="utf-8")
(plug / "pyproject.toml").write_text(
"[project]\n"
f'name = "{name}"\n'
'version = "0.1.0"\n'
'requires-python = ">=3.11"\n'
'dependencies = ["pyfiglet==1.0.2"]\n',
encoding="utf-8",
)
return plug
def test_custom_hermes_home_profile_joins_union(tmp_path, monkeypatch):
"""Docker shape: HERMES_HOME=/opt/data-like root, a profile under it
with an enabled dep plugin. Discovery must find the member through
the REAL get_default_hermes_root (no path mocks)."""
custom_root = tmp_path / "opt-data" # the /opt/data shape
profile_home = custom_root / "profiles" / "worker"
_write_enabled(profile_home, ["dep-plug"])
_make_dep_plugin(profile_home / "plugins", "dep-plug")
monkeypatch.setenv("HERMES_HOME", str(custom_root))
import hermes_constants
# the real authority must resolve the custom root (the contract this
# test exists to pin: env → get_default_hermes_root → union)
assert hermes_constants.get_default_hermes_root() == custom_root
# union discovery: the profile's enabled dep plugin IS a member
members = ws.enabled_member_dirs()
assert [p.name for p in members] == ["dep-plug"], (
"custom-root profile's enabled dep plugin missing from the union — "
"profile scan must derive from get_default_hermes_root"
)
# ordered enabled reads see it too
ordered = pstate.enabled_plugins_ordered()
assert ordered.get(profile_home / "plugins") == ["dep-plug"]
def test_custom_hermes_home_disable_writes_back(tmp_path, monkeypatch):
"""Bisect disable decisions must write back to the CUSTOM-root
profile's config — the resolver disabling a plugin there updates
that profile's enabled list."""
custom_root = tmp_path / "opt-data"
profile_home = custom_root / "profiles" / "worker"
_write_enabled(profile_home, ["bad-plug", "keep-plug"])
_make_dep_plugin(profile_home / "plugins", "bad-plug")
_make_dep_plugin(profile_home / "plugins", "keep-plug")
monkeypatch.setenv("HERMES_HOME", str(custom_root))
removed = pstate.disable_plugins(["bad-plug"])
assert removed[str(profile_home)] == ["bad-plug"]
# the profile's config reflects the removal
with (profile_home / "config.yaml").open(encoding="utf-8-sig") as f:
cfg = yaml.safe_load(f)
assert cfg["plugins"]["enabled"] == ["keep-plug"]
# and the union no longer sees the disabled member
members = ws.enabled_member_dirs()
assert [p.name for p in members] == ["keep-plug"]
def test_standard_layout_still_works(tmp_path, monkeypatch):
"""The default-home path: profile under the (monkeypatched) default
root, standard layout. Guards the derivation change didn't break the
common case."""
default_root = tmp_path / "home" # stands in for ~/.hermes
profile_home = default_root / "profiles" / "coder"
_write_enabled(profile_home, ["dep-plug"])
_make_dep_plugin(profile_home / "plugins", "dep-plug")
import hermes_constants
monkeypatch.setattr(
hermes_constants, "get_default_hermes_root", lambda: default_root
)
# NOTE: pm.plugins_state imports the function lazily inside
# _profiles_root, so the attribute patch reaches it.
members = ws.enabled_member_dirs()
assert [p.name for p in members] == ["dep-plug"]
def test_profile_mode_hermes_home_resolves_parent(tmp_path, monkeypatch):
"""Profile-mode shape: HERMES_HOME=<root>/profiles/<name> — the
authority returns <root> so sibling profiles join the union."""
root = tmp_path / "data-root"
active_home = root / "profiles" / "active"
sibling_home = root / "profiles" / "sibling"
_write_enabled(sibling_home, ["dep-plug"])
_make_dep_plugin(sibling_home / "plugins", "dep-plug")
active_home.mkdir(parents=True) # active profile has no dep plugins
monkeypatch.setenv("HERMES_HOME", str(active_home))
import hermes_constants
assert hermes_constants.get_default_hermes_root() == root
members = ws.enabled_member_dirs()
assert [p.name for p in members] == ["dep-plug"], (
"profile-mode HERMES_HOME must not hide sibling profiles' plugins "
"from the union"
)