From 723c2331bd236fdb9bbc0d6e6f85a1b7704e4aa1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:48:52 -0700 Subject: [PATCH] fix: make profile subprocess HOME policy explicit --- agent/copilot_acp_client.py | 18 +-- cli-config.yaml.example | 5 + cli.py | 3 +- gateway/run.py | 1 + hermes_cli/config.py | 7 ++ hermes_cli/profiles.py | 8 +- hermes_cli/tips.py | 2 +- hermes_constants.py | 145 ++++++++++++++++------- tests/agent/test_copilot_acp_client.py | 12 +- tests/gateway/test_config_cwd_bridge.py | 6 + tests/test_subprocess_home_isolation.py | 140 +++++++++++++++++++--- tests/test_subprocess_real_home.py | 143 ---------------------- tools/code_execution_tool.py | 10 +- tools/environments/local.py | 20 +--- website/docs/user-guide/configuration.md | 49 ++++++++ website/docs/user-guide/profiles.md | 26 ++++ 16 files changed, 342 insertions(+), 253 deletions(-) delete mode 100644 tests/test_subprocess_real_home.py diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py index 25f47b412a8..e3c03938af4 100644 --- a/agent/copilot_acp_client.py +++ b/agent/copilot_acp_client.py @@ -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 diff --git a/cli-config.yaml.example b/cli-config.yaml.example index b1e46947953..fd45f429190 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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. diff --git a/cli.py b/cli.py index 2fecdd0fb23..47bca386241 100644 --- a/cli.py +++ b/cli.py @@ -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", diff --git a/gateway/run.py b/gateway/run.py index f95a535bede..c91e04ac64a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 390970daf10..971f7ed0274 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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 diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index ee810a1dd34..e4c0182cdd9 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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", ] diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 610128c6fbf..1c446c81782 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -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.", diff --git a/hermes_constants.py b/hermes_constants.py index f26e5811ae2..a848a0df80a 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -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") diff --git a/tests/agent/test_copilot_acp_client.py b/tests/agent/test_copilot_acp_client.py index dfc336b41ce..d8188d8604e 100644 --- a/tests/agent/test_copilot_acp_client.py +++ b/tests/agent/test_copilot_acp_client.py @@ -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): diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py index 05ffee9b8d2..299b7e6b3be 100644 --- a/tests/gateway/test_config_cwd_bridge.py +++ b/tests/gateway/test_config_cwd_bridge.py @@ -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. diff --git a/tests/test_subprocess_home_isolation.py b/tests/test_subprocess_home_isolation.py index 4c69c719b6e..67abaebeaa2 100644 --- a/tests/test_subprocess_home_isolation.py +++ b/tests/test_subprocess_home_isolation.py @@ -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 diff --git a/tests/test_subprocess_real_home.py b/tests/test_subprocess_real_home.py deleted file mode 100644 index 131c73e240e..00000000000 --- a/tests/test_subprocess_real_home.py +++ /dev/null @@ -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)) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 8a4c307bcb2..5514f63b9f7 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -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). diff --git a/tools/environments/local.py b/tools/environments/local.py index 0632104f00e..b808816ef16 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -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. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 871e041f3fc..25ac4fedd3b 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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. ::: diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index f2cfeac54df..904d3ec3d1e 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -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