fix: make profile subprocess HOME policy explicit

This commit is contained in:
Teknium 2026-06-14 02:48:52 -07:00
parent b00060ce54
commit 723c2331bd
16 changed files with 342 additions and 253 deletions

View file

@ -70,16 +70,6 @@ def _resolve_args() -> list[str]:
def _resolve_home_dir() -> str:
"""Return a stable HOME for child ACP processes."""
try:
from hermes_constants import get_subprocess_home
profile_home = get_subprocess_home()
if profile_home:
return profile_home
except Exception:
pass
home = os.environ.get("HOME", "").strip()
if home:
return home
@ -107,12 +97,8 @@ def _build_subprocess_env() -> dict[str, str]:
env = os.environ.copy()
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
from hermes_constants import apply_subprocess_home_env
apply_subprocess_home_env(env)
return env

View file

@ -182,6 +182,11 @@ terminal:
backend: "local"
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
timeout: 180
# HOME policy for tool subprocesses:
# auto - default: host uses your real HOME; containers use HERMES_HOME/home
# real - force your real OS-user HOME
# profile - force HERMES_HOME/home for strict per-profile CLI config isolation
home_mode: "auto"
docker_mount_cwd_to_workspace: false # SECURITY: off by default. Opt in to mount the launch cwd into Docker /workspace.
lifetime_seconds: 300
# sudo_password: "hunter2" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext.

3
cli.py
View file

@ -394,7 +394,7 @@ def load_cli_config() -> Dict[str, Any]:
"terminal": {
"env_type": "local",
"cwd": ".", # "." is resolved to os.getcwd() at runtime
"timeout": 60,
"home_mode": "auto",
"lifetime_seconds": 300,
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [],
@ -589,6 +589,7 @@ def load_cli_config() -> Dict[str, Any]:
"env_type": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",

View file

@ -1021,6 +1021,7 @@ if _config_path.exists():
"backend": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",

View file

@ -941,6 +941,13 @@ DEFAULT_CONFIG = {
# (terminal and execute_code). Skill-declared required_environment_variables
# are passed through automatically; this list is for non-skill use cases.
"env_passthrough": [],
# HOME handling for host tool subprocesses:
# auto — host keeps the real OS-user HOME; containers use
# HERMES_HOME/home for persistent state (default)
# real — force the real OS-user HOME
# profile — force HERMES_HOME/home when it exists (old strict
# per-profile CLI config isolation)
"home_mode": "auto",
# Extra files to source in the login shell when building the
# per-session environment snapshot. Use this when tools like nvm,
# pyenv, asdf, or custom PATH entries are registered by files that

View file

@ -45,10 +45,10 @@ _PROFILE_DIRS = [
"plans",
"workspace",
"cron",
# Per-profile HOME for subprocesses: isolates system tool configs (git,
# ssh, gh, npm …) so credentials don't bleed between profiles. In Docker
# this also ensures tool configs land inside the persistent volume.
# See hermes_constants.get_subprocess_home() and issue #4426.
# Back-compat/Docker HOME for tool subprocesses. Host subprocesses keep
# the user's real HOME by default so normal CLI credentials remain visible;
# containers still use this directory for persistent HOME state.
# See hermes_constants.get_subprocess_home().
"home",
]

View file

@ -298,7 +298,7 @@ TIPS = [
"Any website can expose skills via /.well-known/skills/index.json — the skills hub discovers them automatically.",
"The skills audit log at ~/.hermes/skills/.hub/audit.log tracks every install and removal operation.",
"Stale git worktrees are auto-cleaned: 24-72h old with no unpushed commits get pruned on startup.",
"Each profile gets its own subprocess HOME at HERMES_HOME/home/ — isolated git, ssh, npm, gh configs.",
"Profiles scope Hermes state via HERMES_HOME; host tool subprocesses keep your real HOME unless terminal.home_mode is profile.",
"HERMES_HOME_MODE env var (octal, e.g. 0701) sets custom directory permissions for web server traversal.",
"Container mode: place .container-mode in HERMES_HOME and the host CLI auto-execs into the container.",
"Ctrl+C has 5 priority tiers: cancel recording → cancel prompts → cancel picker → interrupt agent → exit.",

View file

@ -282,29 +282,20 @@ def secure_parent_dir(path: Path) -> None:
pass
def get_subprocess_home() -> str | None:
"""Return a per-profile HOME directory for subprocesses, or None.
def _norm_home_path(path: str | None) -> str:
"""Return a comparable absolute path string, or ``""`` for empty input."""
raw = (path or "").strip()
if not raw:
return ""
try:
return os.path.normcase(os.path.abspath(os.path.expanduser(raw)))
except Exception:
return os.path.normcase(raw)
When ``{HERMES_HOME}/home/`` exists on disk, subprocesses should use it
as ``HOME`` so system tools (git, ssh, gh, npm ) write their configs
inside the Hermes data directory instead of the OS-level ``/root`` or
``~/``. This provides:
* **Docker persistence** tool configs land inside the persistent volume.
* **Profile isolation** each profile gets its own git identity, SSH
keys, gh tokens, etc.
The Python process's own ``os.environ["HOME"]`` and ``Path.home()`` are
**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")
def _profile_home_path(env: dict[str, str] | None = None) -> str | None:
"""Return ``{HERMES_HOME}/home`` when the profile-home directory exists."""
hermes_home = get_hermes_home_override() or (env or {}).get("HERMES_HOME") or os.getenv("HERMES_HOME")
if not hermes_home:
return None
profile_home = os.path.join(hermes_home, "home")
@ -313,35 +304,107 @@ def get_subprocess_home() -> str | None:
return None
def get_real_home() -> str:
"""Return the **real** user home directory, ignoring profile isolation.
def _is_profile_home(candidate: str | None, profile_home: str | None) -> bool:
return bool(candidate and profile_home and _norm_home_path(candidate) == _norm_home_path(profile_home))
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()
def _iter_real_home_candidates(env: dict[str, str] | None = None) -> list[str]:
"""Return likely OS-user home candidates in trust order."""
env = env or {}
candidates: list[str] = []
explicit = str(env.get("HERMES_REAL_HOME") or 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()
candidates.append(explicit)
home = str(env.get("HOME") or os.getenv("HOME", "")).strip()
if home:
return home
candidates.append(home)
try:
import pwd
pw_home = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX-only module inside try/except
if pw_home:
candidates.append(pw_home)
except Exception:
pass
userprofile = str(env.get("USERPROFILE") or os.getenv("USERPROFILE", "")).strip()
if userprofile:
candidates.append(userprofile)
drive = str(env.get("HOMEDRIVE") or os.getenv("HOMEDRIVE", "")).strip()
path = str(env.get("HOMEPATH") or os.getenv("HOMEPATH", "")).strip()
if drive and path:
candidates.append(f"{drive}{path}" if path.startswith(("\\", "/")) else os.path.join(drive, path))
expanded = os.path.expanduser("~")
if expanded and expanded != "~":
return expanded
candidates.append(expanded)
return candidates
def get_real_home(env: dict[str, str] | None = None) -> str:
"""Return the OS user's real home directory, avoiding Hermes profile HOME.
``HERMES_HOME`` scopes Hermes state. ``HOME`` is reserved for the OS/user
account and the many external CLIs that store credentials under ``~``.
If a parent process is already running with ``HOME={HERMES_HOME}/home``,
this helper repairs back to the account home when possible.
"""
profile_home = _profile_home_path(env)
seen: set[str] = set()
for candidate in _iter_real_home_candidates(env):
key = _norm_home_path(candidate)
if not key or key in seen:
continue
seen.add(key)
if not _is_profile_home(candidate, profile_home):
return candidate
return "/tmp"
def get_subprocess_home(env: dict[str, str] | None = None) -> str | None:
"""Return a subprocess ``HOME`` override, if one should be applied.
Policy is controlled by ``terminal.home_mode`` (bridged to
``TERMINAL_HOME_MODE``):
* ``auto`` (default): host installs keep the real user HOME; containers use
``{HERMES_HOME}/home`` for persistent state. If a host parent already has
HOME pointed at the profile home, repair subprocesses back to real HOME.
* ``real``: always prefer the real OS-user HOME.
* ``profile``: use ``{HERMES_HOME}/home`` when it exists, preserving the
older strict per-profile tool-config isolation.
"""
env = env or {}
profile_home = _profile_home_path(env)
mode = str(env.get("TERMINAL_HOME_MODE") or os.getenv("TERMINAL_HOME_MODE", "auto")).strip().lower() or "auto"
if mode in {"isolated", "profile_home", "profile-home"}:
mode = "profile"
if mode in {"host", "user", "real_home", "real-home"}:
mode = "real"
if mode == "profile":
return profile_home
real_home = get_real_home(env)
current_home = str(env.get("HOME") or os.getenv("HOME", "")).strip()
if mode == "real":
return real_home if _norm_home_path(real_home) != _norm_home_path(current_home) else None
if profile_home and is_container():
return profile_home
if _is_profile_home(current_home, profile_home):
return real_home if _norm_home_path(real_home) != _norm_home_path(current_home) else None
return None
def apply_subprocess_home_env(env: dict[str, str]) -> None:
"""Apply Hermes' subprocess HOME contract to *env* in-place."""
real_home = get_real_home(env)
if real_home:
env["HERMES_REAL_HOME"] = real_home
home = get_subprocess_home(env)
if home:
env["HOME"] = home
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh")

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):

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.

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

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))

View file

@ -1270,14 +1270,8 @@ def execute_code(
child_env["TZ"] = _tz_name
child_env.pop("HERMES_TIMEZONE", None)
# Per-profile HOME isolation: redirect system tool configs into
# {HERMES_HOME}/home/ when that directory exists.
from hermes_constants import get_subprocess_home
_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()
from hermes_constants import apply_subprocess_home_env
apply_subprocess_home_env(child_env)
# Resolve interpreter + CWD based on execute_code mode.
# - strict : today's behavior (sys.executable + tmpdir CWD).

View file

@ -227,13 +227,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non
_inject_context_hermes_home(sanitized)
# Per-profile HOME isolation for background processes (same as _make_run_env).
from hermes_constants import get_subprocess_home
_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()
from hermes_constants import apply_subprocess_home_env
apply_subprocess_home_env(sanitized)
return sanitized
@ -389,15 +384,8 @@ def _make_run_env(env: dict) -> dict:
_inject_context_hermes_home(run_env)
# Per-profile HOME isolation: redirect system tool configs (git, ssh, gh,
# npm …) into {HERMES_HOME}/home/ when that directory exists. Only the
# subprocess sees the override — the Python process keeps the real HOME.
from hermes_constants import get_subprocess_home
_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()
from hermes_constants import apply_subprocess_home_env
apply_subprocess_home_env(run_env)
# Inject ContextVar-based session vars into subprocess env.
# ContextVars don't propagate to child processes, so we bridge them here.

View file

@ -109,6 +109,7 @@ terminal:
backend: local # local | docker | ssh | modal | daytona | singularity
cwd: "." # Gateway/cron working directory (CLI always uses launch dir)
timeout: 180 # Per-command timeout in seconds
home_mode: auto # auto | real | profile — subprocess HOME policy
env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code)
singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend
modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Modal backend
@ -137,6 +138,54 @@ terminal:
backend: local
```
By default, local tool subprocesses keep your real OS-user `HOME`. This lets
external CLIs such as `git`, `ssh`, `gh`, `az`, `npm`, Claude Code, and Codex
find the credentials and config they already use in your normal shell. Hermes
state is still profile-scoped through `HERMES_HOME`; `HOME` is not how profiles
select config, memory, sessions, or skills.
Hermes does **not** change your system-wide `HOME`, your shell startup files, or
the operating system account home. This setting only controls the environment
passed to subprocesses that Hermes launches through tools such as `terminal`,
background terminal processes, `execute_code`, and ACP helper processes.
#### `terminal.home_mode`
| Mode | Host installs | Containers | Tradeoff |
|---|---|---|---|
| `auto` | Keep the real OS-user `HOME` | Use `{HERMES_HOME}/home` | Recommended default. Host CLIs keep working; container state persists. |
| `real` | Force the real OS-user `HOME` | Force the real OS-user `HOME` if visible | Useful if a parent process accidentally started with `HOME` pointed at a profile home. |
| `profile` | Use `{HERMES_HOME}/home` when it exists | Use `{HERMES_HOME}/home` when it exists | Strict per-profile CLI config isolation, but normal `~/.ssh`, `~/.gitconfig`, `~/.azure`, `~/.config/gh`, Claude/Codex auth, npm state, etc. will not be visible unless you initialize or link them inside the profile home. |
The downside of the default is that host profiles share the same normal
user-level CLI credentials/config under `~`. If you need a profile with a
separate git identity, SSH keys, GitHub CLI login, npm config, or cloud CLI
login, use `home_mode: profile` and initialize those tools inside that profile
home deliberately.
If you intentionally want strict per-profile tool-config isolation, set:
```yaml
terminal:
home_mode: profile
```
In that mode tool subprocesses use `{HERMES_HOME}/home` as `HOME`. Hermes also
sets `HERMES_REAL_HOME` so scripts can still locate the actual user home when
they need it. Container backends keep using `{HERMES_HOME}/home` in `auto` mode
because that directory lives on the persistent Hermes data volume.
Scripts that need to distinguish profile state from the real user home should
prefer `HERMES_HOME` for Hermes data and `HERMES_REAL_HOME` for the account home:
```python
from pathlib import Path
import os
hermes_home = Path(os.environ["HERMES_HOME"])
real_home = Path(os.environ.get("HERMES_REAL_HOME", os.environ["HOME"]))
```
:::warning
The agent has the same filesystem access as your user account. Use `hermes tools` to disable tools you don't want, or switch to Docker for sandboxing.
:::

View file

@ -273,6 +273,32 @@ Profiles use the `HERMES_HOME` environment variable. When you run `coder chat`,
This is separate from terminal working directory. Tool execution starts from `terminal.cwd` (or the launch directory when `cwd: "."` on the local backend), not automatically from `HERMES_HOME`.
On host installs, tool subprocesses keep your real OS-user `HOME` by default so
existing CLI credentials under `~` keep working across profiles. Profile data is
isolated by `HERMES_HOME`, not by changing `HOME`. Container backends still use
`{HERMES_HOME}/home` for persistent tool state, and host users who need strict
per-profile tool config can opt in with `terminal.home_mode: profile`.
This means two things that are easy to mix up:
- `HERMES_HOME` is the profile boundary. It controls Hermes config, `.env`,
memory, sessions, skills, logs, cron jobs, gateway state, and other Hermes
data.
- `HOME` is the operating-system/user home that external CLIs expect. On host
installs, Hermes keeps it as the real user home by default so tools like
`git`, `ssh`, `gh`, `az`, `npm`, Claude Code, and Codex find the same
credentials they use in your normal shell.
The tradeoff is that host profiles share normal user-level CLI state by default.
If you need separate CLI identities per profile, set `terminal.home_mode:
profile` in that profile's `config.yaml`. In that mode Hermes launches tool
subprocesses with `HOME={HERMES_HOME}/home`; you then need to initialize or link
the profile-specific `~/.ssh`, `~/.gitconfig`, `~/.config/gh`, cloud CLI auth,
Claude/Codex auth, npm state, and similar files inside that profile home.
Hermes also exposes `HERMES_REAL_HOME` to subprocesses so scripts can still find
the actual account home when `home_mode: profile` is active.
The default profile is simply `~/.hermes` itself. No migration needed — existing installs work identically.
## Sharing profiles as distributions