From b00060ce545c54d9ead5a7b1ca66f9bfa35064d2 Mon Sep 17 00:00:00 2001 From: zccyman Date: Thu, 14 May 2026 01:39:49 +0800 Subject: [PATCH] fix(agent): expose HERMES_REAL_HOME in subprocess envs for profile isolation When profile isolation activates ({HERMES_HOME}/home/ exists), child processes receive HOME={HERMES_HOME}/home/ for tool config isolation (git, ssh, gh). However, scripts using Path.home() to locate ~/.hermes/ would incorrectly resolve to the isolated profile home, breaking helpers that rely on the real user home directory. New get_real_home() helper in hermes_constants resolves the actual user home independently of profile isolation. All four subprocess spawners now inject HERMES_REAL_HOME alongside the profile HOME: - tools/code_execution_tool.py (execute_code) - tools/environments/local.py (terminal background, run_env) - agent/copilot_acp_client.py (Copilot ACP) Child scripts can now use: Path(os.environ.get("HERMES_REAL_HOME", os.environ.get("HOME", ""))) to reliably find the real user home regardless of profile isolation. Closes #25114 --- agent/copilot_acp_client.py | 9 +- hermes_constants.py | 34 +++++++ tests/test_subprocess_real_home.py | 143 +++++++++++++++++++++++++++++ tools/code_execution_tool.py | 2 + tools/environments/local.py | 4 + 5 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/test_subprocess_real_home.py diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index b24ddbef5da..25f47b412a8 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -105,7 +105,14 @@ def _resolve_home_dir() -> str: def _build_subprocess_env() -> dict[str, str]: env = os.environ.copy() - env["HOME"] = _resolve_home_dir() + home = _resolve_home_dir() + env["HOME"] = home + # Always expose the real user home so child scripts can find + # ~/.hermes/ even when HOME is overridden for profile isolation. + from hermes_constants import get_real_home + real = get_real_home() + if real and real != home: + env["HERMES_REAL_HOME"] = real return env diff --git a/hermes_constants.py b/hermes_constants.py index b9d633ba8ba..f26e5811ae2 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -298,6 +298,11 @@ def get_subprocess_home() -> str | None: **never** modified — only subprocess environments should inject this value. Activation is directory-based: if the ``home/`` subdirectory doesn't exist, returns ``None`` and behavior is unchanged. + + Callers that inject the profile home as ``HOME`` into a subprocess + environment should also set ``HERMES_REAL_HOME`` to the **real** user + home so that child scripts can distinguish the two (e.g. to locate + ``~/.hermes/`` vs the isolated profile home). """ hermes_home = get_hermes_home_override() or os.getenv("HERMES_HOME") if not hermes_home: @@ -308,6 +313,35 @@ def get_subprocess_home() -> str | None: return None +def get_real_home() -> str: + """Return the **real** user home directory, ignoring profile isolation. + + This is the value that ``HOME`` held before any profile-level + override. Subprocess helpers should inject this as + ``HERMES_REAL_HOME`` alongside any profile-specific ``HOME`` so that + child scripts can find ``~/.hermes/`` correctly. + + Resolution order: + 1. ``HERMES_REAL_HOME`` env var (if already set by a parent process). + 2. ``HOME`` env var (the real one, set before profile activation). + 3. ``os.path.expanduser("~")``. + 4. ``/tmp`` as a safe last resort. + """ + # If a parent process already set this, trust it. + explicit = os.getenv("HERMES_REAL_HOME", "").strip() + if explicit: + return explicit + # The current HOME — this is the *real* one because the Python + # process never overrides os.environ["HOME"] (only subprocess envs). + home = os.getenv("HOME", "").strip() + if home: + return home + expanded = os.path.expanduser("~") + if expanded and expanded != "~": + return expanded + return "/tmp" + + VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh") diff --git a/tests/test_subprocess_real_home.py b/tests/test_subprocess_real_home.py new file mode 100644 index 00000000000..131c73e240e --- /dev/null +++ b/tests/test_subprocess_real_home.py @@ -0,0 +1,143 @@ +"""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)) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 9c87488aa39..8a4c307bcb2 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -1276,6 +1276,8 @@ def execute_code( _profile_home = get_subprocess_home() if _profile_home: child_env["HOME"] = _profile_home + from hermes_constants import get_real_home + child_env["HERMES_REAL_HOME"] = get_real_home() # Resolve interpreter + CWD based on execute_code mode. # - strict : today's behavior (sys.executable + tmpdir CWD). diff --git a/tools/environments/local.py b/tools/environments/local.py index 32c6897fe5f..0632104f00e 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -232,6 +232,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non _profile_home = get_subprocess_home() if _profile_home: sanitized["HOME"] = _profile_home + from hermes_constants import get_real_home + sanitized["HERMES_REAL_HOME"] = get_real_home() return sanitized @@ -394,6 +396,8 @@ def _make_run_env(env: dict) -> dict: _profile_home = get_subprocess_home() if _profile_home: run_env["HOME"] = _profile_home + from hermes_constants import get_real_home + run_env["HERMES_REAL_HOME"] = get_real_home() # Inject ContextVar-based session vars into subprocess env. # ContextVars don't propagate to child processes, so we bridge them here.