Files
hermes-agent/tests/tools/test_subprocess_home_isolation.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

262 lines
10 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
"""
import os
import threading
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_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 home_a.endswith("alpha/home")
assert home_b.endswith("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_profile_dirs_includes_home(self):
from hermes_cli.profiles import _PROFILE_DIRS
assert "home" in _PROFILE_DIRS
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
# ---------------------------------------------------------------------------