/ tests / test_subprocess_home_isolation.py
test_subprocess_home_isolation.py
  1  """Tests for per-profile subprocess HOME isolation (#4426).
  2  
  3  Verifies that subprocesses (terminal, execute_code, background processes)
  4  receive a per-profile HOME directory while the Python process's own HOME
  5  and Path.home() remain unchanged.
  6  
  7  See: https://github.com/NousResearch/hermes-agent/issues/4426
  8  """
  9  
 10  import os
 11  from pathlib import Path
 12  from unittest.mock import patch
 13  
 14  import pytest
 15  
 16  
 17  # ---------------------------------------------------------------------------
 18  # get_subprocess_home()
 19  # ---------------------------------------------------------------------------
 20  
 21  class TestGetSubprocessHome:
 22      """Unit tests for hermes_constants.get_subprocess_home()."""
 23  
 24      def test_returns_none_when_hermes_home_unset(self, monkeypatch):
 25          monkeypatch.delenv("HERMES_HOME", raising=False)
 26          from hermes_constants import get_subprocess_home
 27          assert get_subprocess_home() is None
 28  
 29      def test_returns_none_when_home_dir_missing(self, tmp_path, monkeypatch):
 30          hermes_home = tmp_path / ".hermes"
 31          hermes_home.mkdir()
 32          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
 33          # No home/ subdirectory created
 34          from hermes_constants import get_subprocess_home
 35          assert get_subprocess_home() is None
 36  
 37      def test_returns_path_when_home_dir_exists(self, tmp_path, monkeypatch):
 38          hermes_home = tmp_path / ".hermes"
 39          hermes_home.mkdir()
 40          profile_home = hermes_home / "home"
 41          profile_home.mkdir()
 42          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
 43          from hermes_constants import get_subprocess_home
 44          assert get_subprocess_home() == str(profile_home)
 45  
 46      def test_returns_profile_specific_path(self, tmp_path, monkeypatch):
 47          """Named profiles get their own isolated HOME."""
 48          profile_dir = tmp_path / ".hermes" / "profiles" / "coder"
 49          profile_dir.mkdir(parents=True)
 50          profile_home = profile_dir / "home"
 51          profile_home.mkdir()
 52          monkeypatch.setenv("HERMES_HOME", str(profile_dir))
 53          from hermes_constants import get_subprocess_home
 54          assert get_subprocess_home() == str(profile_home)
 55  
 56      def test_two_profiles_get_different_homes(self, tmp_path, monkeypatch):
 57          base = tmp_path / ".hermes" / "profiles"
 58          for name in ("alpha", "beta"):
 59              p = base / name
 60              p.mkdir(parents=True)
 61              (p / "home").mkdir()
 62  
 63          from hermes_constants import get_subprocess_home
 64  
 65          monkeypatch.setenv("HERMES_HOME", str(base / "alpha"))
 66          home_a = get_subprocess_home()
 67  
 68          monkeypatch.setenv("HERMES_HOME", str(base / "beta"))
 69          home_b = get_subprocess_home()
 70  
 71          assert home_a != home_b
 72          assert home_a.endswith("alpha/home")
 73          assert home_b.endswith("beta/home")
 74  
 75  
 76  # ---------------------------------------------------------------------------
 77  # _make_run_env() injection
 78  # ---------------------------------------------------------------------------
 79  
 80  class TestMakeRunEnvHomeInjection:
 81      """Verify _make_run_env() injects HOME into subprocess envs."""
 82  
 83      def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):
 84          hermes_home = tmp_path / "hermes"
 85          hermes_home.mkdir()
 86          (hermes_home / "home").mkdir()
 87          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
 88          monkeypatch.setenv("HOME", "/root")
 89          monkeypatch.setenv("PATH", "/usr/bin:/bin")
 90  
 91          from tools.environments.local import _make_run_env
 92          result = _make_run_env({})
 93  
 94          assert result["HOME"] == str(hermes_home / "home")
 95  
 96      def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):
 97          hermes_home = tmp_path / "hermes"
 98          hermes_home.mkdir()
 99          # No home/ subdirectory
100          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
101          monkeypatch.setenv("HOME", "/root")
102          monkeypatch.setenv("PATH", "/usr/bin:/bin")
103  
104          from tools.environments.local import _make_run_env
105          result = _make_run_env({})
106  
107          assert result["HOME"] == "/root"
108  
109      def test_no_injection_when_hermes_home_unset(self, monkeypatch):
110          monkeypatch.delenv("HERMES_HOME", raising=False)
111          monkeypatch.setenv("HOME", "/home/user")
112          monkeypatch.setenv("PATH", "/usr/bin:/bin")
113  
114          from tools.environments.local import _make_run_env
115          result = _make_run_env({})
116  
117          assert result["HOME"] == "/home/user"
118  
119  
120  # ---------------------------------------------------------------------------
121  # _sanitize_subprocess_env() injection
122  # ---------------------------------------------------------------------------
123  
124  class TestSanitizeSubprocessEnvHomeInjection:
125      """Verify _sanitize_subprocess_env() injects HOME for background procs."""
126  
127      def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):
128          hermes_home = tmp_path / "hermes"
129          hermes_home.mkdir()
130          (hermes_home / "home").mkdir()
131          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
132  
133          base_env = {"HOME": "/root", "PATH": "/usr/bin", "USER": "root"}
134          from tools.environments.local import _sanitize_subprocess_env
135          result = _sanitize_subprocess_env(base_env)
136  
137          assert result["HOME"] == str(hermes_home / "home")
138  
139      def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch):
140          hermes_home = tmp_path / "hermes"
141          hermes_home.mkdir()
142          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
143  
144          base_env = {"HOME": "/root", "PATH": "/usr/bin"}
145          from tools.environments.local import _sanitize_subprocess_env
146          result = _sanitize_subprocess_env(base_env)
147  
148          assert result["HOME"] == "/root"
149  
150  
151  # ---------------------------------------------------------------------------
152  # Profile bootstrap
153  # ---------------------------------------------------------------------------
154  
155  class TestProfileBootstrap:
156      """Verify new profiles get a home/ subdirectory."""
157  
158      def test_profile_dirs_includes_home(self):
159          from hermes_cli.profiles import _PROFILE_DIRS
160          assert "home" in _PROFILE_DIRS
161  
162      def test_create_profile_bootstraps_home_dir(self, tmp_path, monkeypatch):
163          """create_profile() should create home/ inside the profile dir."""
164          home = tmp_path / ".hermes"
165          home.mkdir()
166          monkeypatch.setattr(Path, "home", lambda: tmp_path)
167          monkeypatch.setenv("HERMES_HOME", str(home))
168  
169          from hermes_cli.profiles import create_profile
170          profile_dir = create_profile("testbot", no_alias=True)
171          assert (profile_dir / "home").is_dir()
172  
173  
174  # ---------------------------------------------------------------------------
175  # Python process HOME unchanged
176  # ---------------------------------------------------------------------------
177  
178  class TestPythonProcessUnchanged:
179      """Confirm the Python process's own HOME is never modified."""
180  
181      def test_path_home_unchanged_after_subprocess_home_resolved(
182          self, tmp_path, monkeypatch
183      ):
184          hermes_home = tmp_path / "hermes"
185          hermes_home.mkdir()
186          (hermes_home / "home").mkdir()
187          monkeypatch.setenv("HERMES_HOME", str(hermes_home))
188  
189          original_home = os.environ.get("HOME")
190          original_path_home = str(Path.home())
191  
192          from hermes_constants import get_subprocess_home
193          sub_home = get_subprocess_home()
194  
195          # Subprocess home is set but Python HOME stays the same
196          assert sub_home is not None
197          assert os.environ.get("HOME") == original_home
198          assert str(Path.home()) == original_path_home