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
@@ -59,3 +59,28 @@ def test_dockerfile_does_not_chown_install_trees_to_hermes() -> None:
"runtime install trees under /opt/hermes must stay immutable; "
f"found forbidden pattern {pattern!r}"
)
def test_dockerfile_bakes_code_scoped_install_method_stamp() -> None:
"""The 'docker' install-method stamp is baked next to the code.
detect_install_method() reads the code-scoped stamp
(/opt/hermes/.install_method) first; baking it at build time keeps the
published image self-identifying as 'docker' WITHOUT writing into the
shared $HERMES_HOME data volume (which a host install may also use).
It must live inside the immutable block so the runtime user can't alter it.
"""
text = _dockerfile_text()
assert "printf 'docker\\n' > /opt/hermes/.install_method" in text
immutable_block = re.search(
r"RUN mkdir -p /opt/hermes/bin && \\\n"
r"(?:.*\\\n)+?"
r"\s+chmod -R a-w /opt/hermes",
text,
)
assert immutable_block, "immutable block must exist"
assert ".install_method" in immutable_block.group(0), (
"the code-scoped install-method stamp must be baked inside the "
"immutable /opt/hermes block"
)
@@ -0,0 +1,61 @@
"""Contract test: the s6-overlay stage2 hook must NOT stamp the install method
into the shared $HERMES_HOME, and must heal a stale 'docker' stamp left there
by older images.
Background (shared-$HERMES_HOME bug)
------------------------------------
$HERMES_HOME (/opt/data) is a DATA volume that users commonly bind-mount from
the host (``~/.hermes:/opt/data``) and sometimes share with a host-side
Desktop/CLI install. Older images wrote ``printf 'docker' > $HERMES_HOME/.install_method``
at boot, which clobbered the host install's own marker — so the host's in-app
updater read 'docker' and refused to run ``hermes update`` ("doesn't apply
inside the Docker container").
The fix scopes the stamp to the install tree (baked at
``/opt/hermes/.install_method`` in the Dockerfile, read first by
``detect_install_method``). stage2 must therefore:
* NOT write the 'docker' stamp into $HERMES_HOME any more, and
* proactively remove a stale 'docker' stamp from $HERMES_HOME so homes
already poisoned by an older image self-heal on the next boot.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
@pytest.fixture(scope="module")
def stage2_text() -> str:
if not STAGE2_HOOK.exists():
pytest.skip("docker/stage2-hook.sh not present in this checkout")
return STAGE2_HOOK.read_text()
def test_stage2_does_not_write_install_method_into_home(stage2_text: str) -> None:
# No write/tee of the home-scoped install-method stamp anywhere.
assert not re.search(
r"(tee|>)\s*\"?\$HERMES_HOME/\.install_method", stage2_text
), (
"stage2 must not stamp $HERMES_HOME/.install_method — that data dir "
"may be shared with a host install whose marker would be clobbered"
)
def test_stage2_heals_stale_docker_home_stamp(stage2_text: str) -> None:
# It must remove a stale 'docker' stamp from $HERMES_HOME so already
# poisoned shared homes recover.
assert 'rm -f "$HERMES_HOME/.install_method"' in stage2_text, (
"stage2 must remove a stale 'docker' stamp from $HERMES_HOME to heal "
"homes poisoned by older images"
)
# The removal must be guarded on the value being 'docker' so we never
# delete a legitimately-different stamp a user/host install put there.
assert re.search(r'\[\s*"\$stamped"\s*=\s*"docker"\s*\]', stage2_text), (
"the stale-stamp removal must be guarded on the value == 'docker'"
)