fix(update): scope install-method stamp to the code tree, not $HERMES_HOME (#48188)

The install method (docker/git/pip/...) describes the *running binary*, but
detect_install_method() read it from $HERMES_HOME/.install_method — a shared
DATA directory. The Docker docs deliberately bind-mount $HERMES_HOME
(~/.hermes:/opt/data) so config/sessions/memory persist and can be shared with
a host-side Desktop/CLI install.

When a containerized gateway and a host install share one $HERMES_HOME, the
home-scoped stamp is a single slot describing two installs: the published image
stamps 'docker' on every boot, the host install then reads 'docker' and the
in-app updater refuses to run 'hermes update' ("doesn't apply inside the Docker
container"). Reinstalling the Desktop app from the DMG doesn't help because the
contaminated stamp is re-read every time.

Fix (option 1 — code-scoped stamp):
- detect_install_method() reads <install tree>/.install_method first (next to
  the running code, immune to the shared data dir). It falls back to the legacy
  $HERMES_HOME stamp for back-compat, but IGNORES a 'docker' home stamp when
  not actually containerized — so already-poisoned shared homes self-heal.
- stamp_install_method() writes the code-scoped stamp.
- install.sh stamps $INSTALL_DIR instead of $HERMES_HOME.
- Dockerfile bakes 'docker' into /opt/hermes/.install_method at build time
  (inside the immutable block); stage2-hook.sh no longer writes the home stamp
  and proactively removes a stale 'docker' one to heal existing shared homes.

Genuine containers still resolve to 'docker' (baked stamp, or legacy home stamp
honored when containerized). Unstamped installs in generic containers still fall
through to git/pip (preserves the #34397 fix).
This commit is contained in:
Ben Barclay
2026-06-18 14:14:41 +10:00
committed by GitHub
parent 3769dff5dd
commit 4440d77bf3
8 changed files with 352 additions and 34 deletions
@@ -48,6 +48,97 @@ def test_stamp_file_takes_precedence(tmp_path):
assert detect_install_method(project_root=tmp_path) == "docker"
def test_code_scoped_stamp_wins_over_home_stamp(tmp_path):
"""The stamp next to the running code is authoritative over $HERMES_HOME.
Models a host git install whose $HERMES_HOME is shared with (and stamped
'docker' by) a co-located container. The code-scoped stamp must win so the
host install is correctly identified as 'git' and 'hermes update' works.
"""
code = tmp_path / "code"
home = tmp_path / "home"
code.mkdir()
home.mkdir()
(code / ".install_method").write_text("git\n")
(home / ".install_method").write_text("docker\n") # container contamination
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=home):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=code) == "git"
def test_home_docker_stamp_ignored_when_not_containerized(tmp_path):
"""A 'docker' home stamp is ignored on a host (non-container) install.
Self-heal path for homes already poisoned by an older image that wrote
'docker' into the shared $HERMES_HOME. With no code-scoped stamp, a host
git checkout must fall through to '.git' detection rather than honour the
contaminating 'docker' value and refuse to update.
"""
code = tmp_path / "code"
home = tmp_path / "home"
code.mkdir()
home.mkdir()
(code / ".git").mkdir()
(home / ".install_method").write_text("docker\n")
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=home), \
patch("hermes_cli.config._running_in_container", return_value=False):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=code) == "git"
def test_home_docker_stamp_honored_inside_container(tmp_path):
"""A 'docker' home stamp is still honoured when genuinely containerized.
Back-compat: an older published image that only ever wrote the home-scoped
stamp (no baked code stamp) must still resolve to 'docker' so the update
path keeps directing the user to ``docker pull``.
"""
code = tmp_path / "code"
home = tmp_path / "home"
code.mkdir()
home.mkdir()
(home / ".install_method").write_text("docker\n")
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=home), \
patch("hermes_cli.config._running_in_container", return_value=True):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=code) == "docker"
def test_home_non_docker_stamp_still_honored_for_backcompat(tmp_path):
"""Legacy non-'docker' home stamps (e.g. 'git') are still respected.
Only the 'docker' value carries the cross-contamination risk, so a host
install that historically stamped 'git'/'pip' into $HERMES_HOME keeps
resolving from there when no code-scoped stamp exists yet.
"""
code = tmp_path / "code"
home = tmp_path / "home"
code.mkdir()
home.mkdir()
(home / ".install_method").write_text("git\n")
with patch("hermes_cli.config.get_managed_system", return_value=None), \
patch("hermes_cli.config.get_hermes_home", return_value=home), \
patch("hermes_cli.config._running_in_container", return_value=False):
from hermes_cli.config import detect_install_method
assert detect_install_method(project_root=code) == "git"
def test_stamp_install_method_writes_code_scoped(tmp_path):
"""stamp_install_method writes next to the code, not into $HERMES_HOME."""
code = tmp_path / "code"
home = tmp_path / "home"
code.mkdir()
home.mkdir()
with patch("hermes_cli.config.get_hermes_home", return_value=home):
from hermes_cli.config import stamp_install_method
stamp_install_method("pip", project_root=code)
assert (code / ".install_method").read_text().strip() == "pip"
assert not (home / ".install_method").exists()
def test_container_without_stamp_is_not_docker(tmp_path):
"""An unstamped install in a generic container must NOT be flagged as docker.