fix: make profile subprocess HOME policy explicit

This commit is contained in:
Teknium
2026-06-14 03:20:21 -07:00
parent b00060ce54
commit 723c2331bd
16 changed files with 342 additions and 253 deletions
+7 -5
View File
@@ -174,12 +174,13 @@ def _fake_popen_capture(captured):
return _fake
def test_run_prompt_prefers_profile_home_when_available(monkeypatch, tmp_path):
def test_run_prompt_preserves_real_home_when_profile_home_available(monkeypatch, tmp_path):
hermes_home = tmp_path / "hermes"
profile_home = hermes_home / "home"
profile_home.mkdir(parents=True)
(hermes_home / "home").mkdir(parents=True)
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.delenv("HOME", raising=False)
monkeypatch.setenv("HOME", str(real_home))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
captured = {}
@@ -189,7 +190,8 @@ def test_run_prompt_prefers_profile_home_when_available(monkeypatch, tmp_path):
with pytest.raises(RuntimeError, match="Could not start Copilot ACP command"):
client._run_prompt("hello", timeout_seconds=1)
assert captured["kwargs"]["env"]["HOME"] == str(profile_home)
assert captured["kwargs"]["env"]["HOME"] == str(real_home)
assert captured["kwargs"]["env"]["HERMES_REAL_HOME"] == str(real_home)
def test_run_prompt_passes_home_when_parent_env_is_clean(monkeypatch, tmp_path):
+6
View File
@@ -32,6 +32,7 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None):
"backend": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
@@ -215,6 +216,11 @@ class TestNestedTerminalCwdPlaceholderSkip:
assert result["TERMINAL_TIMEOUT"] == "300"
assert result["TERMINAL_CWD"] == "/from/env"
def test_terminal_home_mode_bridges_to_env(self):
cfg = {"terminal": {"home_mode": "profile"}}
result = _simulate_config_bridge(cfg)
assert result["TERMINAL_HOME_MODE"] == "profile"
class TestTildeExpansion:
"""terminal.cwd values containing shell tilde must be expanded.
+122 -18
View File
@@ -1,16 +1,21 @@
"""Tests for per-profile subprocess HOME isolation (#4426).
"""Tests for subprocess HOME handling in profile mode.
Verifies that subprocesses (terminal, execute_code, background processes)
receive a per-profile HOME directory while the Python process's own HOME
and Path.home() remain unchanged.
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/4426
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
# ---------------------------------------------------------------------------
@@ -20,6 +25,16 @@ from pathlib import Path
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_returns_none_when_hermes_home_unset(self, monkeypatch):
monkeypatch.delenv("HERMES_HOME", raising=False)
from hermes_constants import get_subprocess_home
@@ -33,26 +48,70 @@ class TestGetSubprocessHome:
from hermes_constants import get_subprocess_home
assert get_subprocess_home() is None
def test_returns_path_when_home_dir_exists(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
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()
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):
"""Named profiles get their own isolated HOME."""
"""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_real_home_falls_back_to_os_account_when_home_is_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)
monkeypatch.setenv("HERMES_HOME", str(profile_dir))
monkeypatch.setenv("HOME", str(profile_home))
from hermes_constants import get_real_home
assert get_real_home() != str(profile_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
@@ -117,20 +176,42 @@ class TestGetSubprocessHome:
# ---------------------------------------------------------------------------
class TestMakeRunEnvHomeInjection:
"""Verify _make_run_env() injects HOME into subprocess envs."""
"""Verify _make_run_env() applies the subprocess HOME policy."""
def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):
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", "/root")
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"
@@ -156,6 +237,7 @@ class TestMakeRunEnvHomeInjection:
assert result["HOME"] == "/home/user"
def test_context_override_bridges_to_subprocess_env(self, tmp_path, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
root = tmp_path / "root"
profile = tmp_path / "profile"
root.mkdir()
@@ -183,19 +265,40 @@ class TestMakeRunEnvHomeInjection:
# ---------------------------------------------------------------------------
class TestSanitizeSubprocessEnvHomeInjection:
"""Verify _sanitize_subprocess_env() injects HOME for background procs."""
"""Verify _sanitize_subprocess_env() applies the subprocess HOME policy."""
def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch):
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": "/root", "PATH": "/usr/bin", "USER": "root"}
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"
@@ -209,6 +312,7 @@ class TestSanitizeSubprocessEnvHomeInjection:
assert result["HOME"] == "/root"
def test_context_override_bridges_to_background_env(self, tmp_path, monkeypatch):
monkeypatch.setattr(hermes_constants, "is_container", lambda: True)
root = tmp_path / "root"
profile = tmp_path / "profile"
root.mkdir()
@@ -274,7 +378,7 @@ class TestPythonProcessUnchanged:
from hermes_constants import get_subprocess_home
sub_home = get_subprocess_home()
# Subprocess home is set but Python HOME stays the same
assert sub_home is not None
# Resolving subprocess HOME must not mutate the Python process env.
assert sub_home in (None, str(hermes_home / "home"), original_home)
assert os.environ.get("HOME") == original_home
assert str(Path.home()) == original_path_home
-143
View File
@@ -1,143 +0,0 @@
"""Test HERMES_REAL_HOME is set in subprocess environments.
Covers: https://github.com/NousResearch/hermes-agent/issues/25114
When profile isolation activates (HERMES_HOME/home/ exists), child
processes receive HOME={HERMES_HOME}/home/ for tool config isolation.
This test verifies that HERMES_REAL_HOME is also set, pointing to the
actual user home so scripts can locate ~/.hermes/ correctly.
"""
from __future__ import annotations
import os
from pathlib import Path
from unittest import mock
import pytest
# ---------------------------------------------------------------------------
# get_real_home unit tests
# ---------------------------------------------------------------------------
class TestGetRealHome:
"""Verify get_real_home() returns the actual user home."""
def test_returns_home_env(self):
"""When HOME is set, get_real_home returns it."""
from hermes_constants import get_real_home
with mock.patch.dict(os.environ, {"HOME": "/home/testuser"}, clear=False):
assert get_real_home() == "/home/testuser"
def test_prefers_hermes_real_home(self):
"""HERMES_REAL_HOME takes priority over HOME."""
from hermes_constants import get_real_home
with mock.patch.dict(os.environ, {
"HERMES_REAL_HOME": "/home/real",
"HOME": "/home/fake",
}, clear=False):
assert get_real_home() == "/home/real"
def test_fallback_expanduser(self):
"""When HOME is empty, falls back to expanduser."""
from hermes_constants import get_real_home
with mock.patch.dict(os.environ, {"HOME": ""}, clear=False):
result = get_real_home()
assert result # not empty
assert result != ""
def test_fallback_tmp(self):
"""Last resort is /tmp."""
from hermes_constants import get_real_home
with mock.patch.dict(os.environ, {}, clear=True):
# Remove HOME and HERMES_REAL_HOME
env = {k: v for k, v in os.environ.items()
if k not in ("HOME", "HERMES_REAL_HOME")}
with mock.patch.dict(os.environ, env, clear=True):
with mock.patch("os.path.expanduser", return_value="~"):
result = get_real_home()
assert result == "/tmp"
# ---------------------------------------------------------------------------
# Subprocess env injection tests
# ---------------------------------------------------------------------------
class TestSubprocessEnvRealHome:
"""Verify HERMES_REAL_HOME is injected into subprocess environments."""
def test_code_execution_sets_real_home(self, tmp_path):
"""execute_code child_env includes HERMES_REAL_HOME."""
# Simulate profile isolation: HERMES_HOME/home/ exists
profile_home = tmp_path / "profiles" / "worker"
home_dir = profile_home / "home"
home_dir.mkdir(parents=True)
with mock.patch.dict(os.environ, {
"HOME": "/home/testuser",
"HERMES_HOME": str(profile_home),
}, clear=False):
from hermes_constants import get_subprocess_home, get_real_home
profile_home_val = get_subprocess_home()
assert profile_home_val == str(home_dir)
real_home = get_real_home()
assert real_home == "/home/testuser"
assert real_home != profile_home_val
def test_local_env_sets_real_home(self, tmp_path):
"""Local environment subprocesses get HERMES_REAL_HOME."""
profile_home = tmp_path / "profiles" / "worker"
home_dir = profile_home / "home"
home_dir.mkdir(parents=True)
with mock.patch.dict(os.environ, {
"HOME": "/home/testuser",
"HERMES_HOME": str(profile_home),
}, clear=False):
# Import and check the _make_run_env function
import importlib
import tools.environments.local as local_mod
importlib.reload(local_mod)
# The function should add HERMES_REAL_HOME when profile home is active
from hermes_constants import get_real_home
assert get_real_home() == "/home/testuser"
def test_no_real_home_when_not_isolated(self):
"""When profile isolation is off, HERMES_REAL_HOME is not needed."""
with mock.patch.dict(os.environ, {
"HOME": "/home/testuser",
"HERMES_HOME": "/home/testuser/.hermes",
}, clear=False):
from hermes_constants import get_subprocess_home
result = get_subprocess_home()
assert result is None # No profile home dir
# ---------------------------------------------------------------------------
# Integration: verify the pattern works end-to-end
# ---------------------------------------------------------------------------
class TestRealHomeIntegration:
"""End-to-end verification that subprocesses can find ~/.hermes/."""
def test_subprocess_can_find_hermes_dir(self, tmp_path):
"""A subprocess with overridden HOME can still find .hermes/ via HERMES_REAL_HOME."""
real_home = tmp_path / "real_home"
real_home.mkdir()
(real_home / ".hermes").mkdir()
profile_home = tmp_path / "profile_home"
profile_home.mkdir()
with mock.patch.dict(os.environ, {
"HOME": str(profile_home), # Simulated profile override
"HERMES_REAL_HOME": str(real_home),
}, clear=False):
# Script logic: find .hermes/ using HERMES_REAL_HOME fallback
hermes_base = Path(os.environ.get("HERMES_REAL_HOME", os.environ.get("HOME", ""))) / ".hermes"
assert hermes_base.exists()
assert str(hermes_base).startswith(str(real_home))