Files
hermes-agent/tests/tools/test_subprocess_home_isolation.py
ethernet 427d4936c2 test: retarget merge-fallout tests at pm-clean's seams; drop tests of retired code
The merges from main kept or added about 45 test files written against the
legacy updater and installer seams that pm-clean replaced. Each failing test
was checked against the current code:

- Deleted, because every test targets code that pm-clean removed or that is
  only reachable from dead or frozen old-updater code:
  update_orphan/handoff_backend_reap, handoff_desktop_rebuild,
  interrupted_recovery (it also launched a real completion against the live
  checkout), update_stale_virtualenv, update_venv_health,
  verify_core_dependencies, lazy_refresh_venv_repair,
  certifi_repair (already dropped in 2f20ceb6f7; the merge resurrected it),
  banner_git_state (covered by test_source_check).
- Retargeted where production reads now:
  - version identity via version_info.get_code_identity
  - update receipts via a ContextVar scope
  - pm.extras / pm.installed_package / pm.ensure_import for matrix,
    computer_use, fal and tirith
  - install hints via pm.install_hint
  - the lock pins in pm/lock.json
  - probe_root for the critical-module probe
- Fixture repairs:
  - git-committed trees for source stamps
  - activate's real `--runtime-only` argument
  - a semver install stamp for requires_hermes gates
  - the Windows gateway restart after a verified update
  - snowflake-length ids that cannot collide with the runner uid
- Collection fix: one platforms() marker per test in gateway_proc_fallback.
2026-09-23 16:55:09 -04:00

286 lines
12 KiB
Python

"""Tests for subprocess HOME handling in profile mode.
Hermes state stays profile-scoped through HERMES_HOME. Host subprocesses should
keep the user's real HOME by default so external CLIs find existing credentials.
Containers still use the profile home for persistence, and users can explicitly
opt into profile HOME isolation on the host.
See: https://github.com/NousResearch/hermes-agent/issues/25114
See: https://github.com/NousResearch/hermes-agent/issues/36144
See: https://github.com/NousResearch/hermes-agent/issues/29015
"""
from pathlib import Path
import hermes_constants
# ---------------------------------------------------------------------------
# get_subprocess_home()
# ---------------------------------------------------------------------------
class TestGetSubprocessHome:
"""Unit tests for hermes_constants.get_subprocess_home()."""
def _host_mode(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_container", lambda: False)
monkeypatch.delenv("TERMINAL_HOME_MODE", raising=False)
monkeypatch.delenv("HERMES_REAL_HOME", raising=False)
def _container_mode(self, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
monkeypatch.delenv("TERMINAL_HOME_MODE", raising=False)
monkeypatch.delenv("HERMES_REAL_HOME", raising=False)
def test_host_auto_keeps_real_home_when_profile_home_exists(self, tmp_path, monkeypatch):
"""Host installs should not hide real ~/.ssh, ~/.gitconfig, ~/.azure, etc."""
self._host_mode(monkeypatch)
real_home = tmp_path / "real-home"
hermes_home = real_home / ".hermes" / "profiles" / "coder"
profile_home = hermes_home / "home"
profile_home.mkdir(parents=True)
monkeypatch.setenv("HOME", str(real_home))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_constants import get_subprocess_home
assert get_subprocess_home() is None
def test_host_auto_repairs_missing_home(self, tmp_path, monkeypatch):
"""A systemd system unit with no HOME at all should still get real HOME repaired."""
self._host_mode(monkeypatch)
real_home = tmp_path / "real-home"
hermes_home = real_home / ".hermes" / "profiles" / "coder"
profile_home = hermes_home / "home"
profile_home.mkdir(parents=True)
monkeypatch.delenv("HOME", raising=False)
monkeypatch.setenv("HERMES_REAL_HOME", str(real_home))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_constants import get_subprocess_home
assert get_subprocess_home() == str(real_home)
def test_terminal_child_env_carries_home_when_host_has_none(self, tmp_path, monkeypatch):
"""A host with no HOME at all (systemd system unit without User=) must still hand
terminal children a HOME; without it a `set -u` script dies on its first `$HOME` (#116081).
Drives the production spawn seam, tools.environments.local.build_subprocess_env, not the
resolver directly.
"""
self._host_mode(monkeypatch)
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.delenv("HOME", raising=False)
monkeypatch.setenv("HERMES_REAL_HOME", str(real_home))
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
from tools.environments.local import build_subprocess_env
assert build_subprocess_env()["HOME"] == str(real_home)
def test_container_auto_uses_profile_home_when_home_dir_exists(self, tmp_path, monkeypatch):
self._container_mode(monkeypatch)
hermes_home = tmp_path / ".hermes"
profile_home = hermes_home / "home"
profile_home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from hermes_constants import get_subprocess_home
assert get_subprocess_home() == str(profile_home)
def test_returns_profile_specific_path(self, tmp_path, monkeypatch):
"""Explicit profile mode keeps the old per-profile HOME behavior."""
self._host_mode(monkeypatch)
profile_dir = tmp_path / ".hermes" / "profiles" / "coder"
profile_dir.mkdir(parents=True)
profile_home = profile_dir / "home"
profile_home.mkdir()
monkeypatch.setenv("TERMINAL_HOME_MODE", "profile")
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
from hermes_constants import get_subprocess_home
assert get_subprocess_home() == str(profile_home)
def test_real_mode_repairs_parent_home_already_pointing_at_profile(self, tmp_path, monkeypatch):
self._host_mode(monkeypatch)
profile_dir = tmp_path / ".hermes" / "profiles" / "coder"
profile_home = profile_dir / "home"
profile_home.mkdir(parents=True)
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.setenv("TERMINAL_HOME_MODE", "real")
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
monkeypatch.setenv("HOME", str(profile_home))
monkeypatch.setenv("HERMES_REAL_HOME", str(real_home))
from hermes_constants import get_subprocess_home, get_real_home
assert get_real_home() == str(real_home)
assert get_subprocess_home() == str(real_home)
def test_two_profiles_get_different_homes(self, tmp_path, monkeypatch):
self._container_mode(monkeypatch)
base = tmp_path / ".hermes" / "profiles"
for name in ("alpha", "beta"):
p = base / name
p.mkdir(parents=True)
(p / "home").mkdir()
from hermes_constants import get_subprocess_home
monkeypatch.setenv("HERMES_HOME", str(base / "alpha"))
home_a = get_subprocess_home()
monkeypatch.setenv("HERMES_HOME", str(base / "beta"))
home_b = get_subprocess_home()
assert home_a is not None
assert home_b is not None
assert home_a != home_b
assert Path(home_a).parts[-2:] == ("alpha", "home")
assert Path(home_b).parts[-2:] == ("beta", "home")
# ---------------------------------------------------------------------------
# _make_run_env() injection
# ---------------------------------------------------------------------------
class TestMakeRunEnvHomeInjection:
"""Verify _make_run_env() applies the subprocess HOME policy."""
def test_host_auto_preserves_real_home_when_profile_home_exists(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
(hermes_home / "home").mkdir()
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.setattr(hermes_constants, "is_container", lambda: False)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("HOME", str(real_home))
monkeypatch.setenv("PATH", "/usr/bin:/bin")
from tools.environments.local import _make_run_env
result = _make_run_env({})
assert result["HOME"] == str(real_home)
assert result["HERMES_REAL_HOME"] == str(real_home)
def test_profile_mode_injects_profile_home_when_profile_home_exists(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
(hermes_home / "home").mkdir()
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.setattr(hermes_constants, "is_container", lambda: False)
monkeypatch.setenv("TERMINAL_HOME_MODE", "profile")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("HOME", str(real_home))
monkeypatch.setenv("PATH", "/usr/bin:/bin")
from tools.environments.local import _make_run_env
result = _make_run_env({})
assert result["HOME"] == str(hermes_home / "home")
assert result["HERMES_REAL_HOME"] == str(real_home)
def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
# No home/ subdirectory
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("HOME", "/root")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
from tools.environments.local import _make_run_env
result = _make_run_env({})
assert result["HOME"] == "/root"
def test_no_injection_when_hermes_home_unset(self, monkeypatch):
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.setenv("HOME", "/home/user")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
from tools.environments.local import _make_run_env
result = _make_run_env({})
assert result["HOME"] == "/home/user"
# ---------------------------------------------------------------------------
# _sanitize_subprocess_env() injection
# ---------------------------------------------------------------------------
class TestSanitizeSubprocessEnvHomeInjection:
"""Verify _sanitize_subprocess_env() applies the subprocess HOME policy."""
def test_host_auto_preserves_real_home_when_profile_home_exists(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
(hermes_home / "home").mkdir()
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.setattr(hermes_constants, "is_container", lambda: False)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
base_env = {"HOME": str(real_home), "PATH": "/usr/bin", "USER": "root"}
from tools.environments.local import _sanitize_subprocess_env
result = _sanitize_subprocess_env(base_env)
assert result["HOME"] == str(real_home)
assert result["HERMES_REAL_HOME"] == str(real_home)
def test_profile_mode_injects_profile_home_when_profile_home_exists(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
(hermes_home / "home").mkdir()
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.setattr(hermes_constants, "is_container", lambda: False)
monkeypatch.setenv("TERMINAL_HOME_MODE", "profile")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
base_env = {"HOME": str(real_home), "PATH": "/usr/bin", "USER": "root"}
from tools.environments.local import _sanitize_subprocess_env
result = _sanitize_subprocess_env(base_env)
assert result["HOME"] == str(hermes_home / "home")
assert result["HERMES_REAL_HOME"] == str(real_home)
def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
base_env = {"HOME": "/root", "PATH": "/usr/bin"}
from tools.environments.local import _sanitize_subprocess_env
result = _sanitize_subprocess_env(base_env)
assert result["HOME"] == "/root"
# ---------------------------------------------------------------------------
# Profile bootstrap
# ---------------------------------------------------------------------------
class TestProfileBootstrap:
"""Verify new profiles get a home/ subdirectory."""
def test_create_profile_bootstraps_home_dir(self, tmp_path, monkeypatch):
"""create_profile() should create home/ inside the profile dir."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli.profiles import create_profile
profile_dir = create_profile("testbot", no_alias=True)
assert (profile_dir / "home").is_dir()
# ---------------------------------------------------------------------------
# Python process HOME unchanged
# ---------------------------------------------------------------------------