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