HOME/tilde resolution broken under profile isolation
NousResearch/hermes-agent · cluster 8669 · agent
Data as of
5 open items. Fix merged in #46075. Check it covers your case.
The short answer
- Known?
- Yes. 33 related items grouped here, 8 issues already closed.
- Fix event
- A fix was merged to main in PR #46075. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #46075, #51060, #109194. That is separate from the fix event. - Still open
- 3 issues and 2 PRs.
Next step: read #46075, then check whether your report is one of the 3 still open.
Read #46075Pick up a Lab taskA cluster of bugs and fixes centered on Hermes profile isolation overriding the HOME environment variable in ways that cause tilde expansion, Path.home(), and skill config resolution to point to the wrong directory. Related changes address subprocess HOME exposure, sandboxed profile paths, and a few adjacent profile-scoping concerns.
Open issues3
- Issue#27250Docker/profile mode should avoid different HOME values between Hermes process and tool subprocesses
- Issue#36144Agent session HOME points to wrong path — tools look in hermes profile instead of user's actual home
- Issue#39523[Feature]: Sandbox HOME bridge initialization — subprocess tools can't find Keychain/SSH/Git/Python resources
Open PRs2
Fixes on record
-
#46075 fix: make profile subprocess HOME policy explicit
files:
agent/copilot_acp_client.py,cli-config.yaml.example,cli.py,gateway/run.py,hermes_cli/config.py,hermes_cli/profiles.py,hermes_cli/tips.py,hermes_constants.py,tests/agent/test_copilot_acp_client.py,tests/gateway/test_config_cwd_bridge.py,tests/test_subprocess_home_isolation.py,tools/code_execution_tool.py,tools/environments/local.py,website/docs/user-guide/configuration.md,website/docs/user-guide/profiles.mdShow the diff
-
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 @@ -105,7 +95,10 @@ 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 + from hermes_constants import apply_subprocess_home_env + apply_subprocess_home_env(env) return env -
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: "[redacted:secret-assignment]" # Optional: pipe a sudo password via sudo -S. SECURITY WARNING: plaintext. -
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", -
gateway/run.py@@ -1021,6 +1021,7 @@ def _reload_runtime_env_preserving_config_authority() -> None: "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", -
hermes_cli/config.py@@ -941,6 +941,13 @@ def _ensure_hermes_home_managed(home: Path): # (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 -
hermes_cli/profiles.py@@ -45,10 +45,10 @@ "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", ] -
hermes_cli/tips.py@@ -298,7 +298,7 @@ "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.", -
hermes_constants.py@@ -282,24 +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. - - 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. - """ - hermes_home = get_hermes_home_override() or os.getenv("HERMES_HOME") +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) + + +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") @@ -308,6 +304,107 @@ def get_subprocess_home() -> str | None: return None +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)) + + +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: + candidates.append(explicit) + home = str(env.get("HOME") or os.getenv("HOME", "")).strip() + if 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 != "~": + 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") -
tests/agent/test_copilot_acp_client.py@@ -174,12 +174,13 @@ def _fake(cmd, **kwargs): 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): -
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 @@ def test_non_cwd_terminal_keys_still_bridge(self): 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. -
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 @@ 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 @@ def test_returns_none_when_home_dir_missing(self, tmp_path, monkeypatch): 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): + def test_host_auto_keeps_real_home_when_profile_home_exists(self, tmp_path, monkeypatch): + """Host installs should not hide real ~/.ssh, ~/.gitconfig, ~/.azure, etc.""" + self._host_mode(monkeypatch) + real_home = tmp_path / "real-home" + hermes_home = real_home / ".hermes" / "profiles" / "coder" + profile_home = hermes_home / "home" + profile_home.mkdir(parents=True) + monkeypatch.setenv("HOME", str(real_home)) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + from hermes_constants import get_subprocess_home + assert get_subprocess_home() is None + + def test_container_auto_uses_profile_home_when_home_dir_exists(self, tmp_path, monkeypatch): + self._container_mode(monkeypatch) hermes_home = tmp_path / ".hermes" - hermes_home.mkdir() profile_home = hermes_home / "home" - profile_home.mkdir() + 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 @@ def read_from_other_thread(): # --------------------------------------------------------------------------- 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 @@ def test_no_injection_when_hermes_home_unset(self, monkeypatch): 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 @@ def test_context_override_bridges_to_subprocess_env(self, tmp_path, monkeypatch) # --------------------------------------------------------------------------- class TestSanitizeSubprocessEnvHomeInjection: - """Verify _sanitize_subprocess_env() injects HOME for background procs.""" + """Verify _sanitize_subprocess_env() applies the subprocess HOME policy.""" + + def test_host_auto_preserves_real_home_when_profile_home_exists(self, tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "home").mkdir() + real_home = tmp_path / "real-home" + real_home.mkdir() + monkeypatch.setattr(hermes_constants, "is_container", lambda: False) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + base_env = {"HOME": str(real_home), "PATH": "/usr/bin", "USER": "root"} + from tools.environments.local import _sanitize_subprocess_env + result = _sanitize_subprocess_env(base_env) + + assert result["HOME"] == str(real_home) + assert result["HERMES_REAL_HOME"] == str(real_home) - def test_injects_home_when_profile_home_exists(self, tmp_path, monkeypatch): + 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": "/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(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 @@ def test_no_injection_when_home_dir_missing(self, tmp_path, monkeypatch): 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 @@ def test_path_home_unchanged_after_subprocess_home_resolved( 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 -
tools/code_execution_tool.py@@ -1270,12 +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 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). -
tools/environments/local.py@@ -227,11 +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 apply_subprocess_home_env + apply_subprocess_home_env(sanitized) return sanitized @@ -387,13 +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 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. -
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. ::: -
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
-
-
#51060 fix(file_tools): resolve tilde using profile home for file operations (#48552)
files:
tests/tools/test_file_tools_tilde_profile.py,tools/file_tools.py -
#109194 fix(skills): `~` and `$HOME` in skill config defaults resolve against the tool HOME (#12260, salvage #58227)
files:
agent/skill_utils.py,tests/agent/test_skill_utils.py
17 PRs closed without merging
From the close comments: superseded by another PR 6 · done elsewhere 4 · duplicate 3 · stale or abandoned 2 · closed without comment 1 · other 1
-
#8671 fix(paths): preserve real home under sandbox HOME overrides · duplicate →
#8910 -
#8672 fix: use real user home instead of sandbox-overridden HOME · duplicate →
#8910 - #8910 fix: use real user home instead of sandbox-overridden HOME · other
- #12273 fix(agent): resolve skill config ~/... against subprocess HOME · closed without comment
-
#12284 fix(skill-config): expand ~ against subprocess HOME, not Python process HOME (#12260) · superseded by another PR →
#12736
Show 12 more
-
#12331 fix: use subprocess HOME for ~ expansion in skill config values · superseded by another PR →
#12736 -
#12736 fix(agent): use correct HOME for ~ expansion in skill config resolution · stale or abandoned →
#12736 -
#25151 fix(agent): expose HERMES_REAL_HOME in subprocess envs for profile isolation · done elsewhere →
723c233,b00060c,#46075 - #27260 fix(profiles): align main-process HOME with subprocess HOME within a profile (#27250) · superseded by another PR
- #29050 fix(local): expose macOS keychains in profile home · stale or abandoned
-
#29229 fix(tools): expand ~ to real home, not sandbox HOME (#28456) · superseded by another PR →
#28469 -
#29255 fix(env): preserve macOS Keychain access from sandboxed worker subprocesses (#29015) · superseded by another PR →
#29050 -
#38107 fix(agent): preserve real HOME for session tools · done elsewhere →
723c233 -
#48566 fix(tools): use profile-aware HOME for ~ expansion in file tools · done elsewhere →
15880da,#48552 -
#48573 fix(file-tools): expand ~ via profile HOME, not gateway process HOME · superseded by another PR →
#48566 -
#48759 fix(file-tools): resolve tilde using effective home for in-process tools (#48552) · duplicate →
#48566,#48552,#48573 -
#49251 fix(file-tools): resolve tilde paths via profile home (#48552) · done elsewhere →
15880da,#51060
Closed issues · 8
- Closed#8669fix: sandbox HOME override breaks Path.home() and expanduser() resolution
- Closed#12260[Bug]: resolve_skill_config_values() uses wrong HOME semantics for ~ expansion — expands against Python process HOME instead of Hermes subprocess HOME
- Closed#25114Bug: HOME env corrupted inside Hermes profile runtime context — Path.home() resolves to wrong directory
- Closed#28456Bug: read_file tilde expansion broken in sandboxed profiles (HOME points to profile sandbox)
- Closed#29015macOS: per-profile HOME isolation in _make_run_env hides ~/Library/Keychains from worker subprocesses, breaks claude CLI auth
- Closed#35289hermes-profile-env-bug-issue
- Closed#48037Profile HOME isolation causes agents to silently operate on incorrect paths for host-level commands
- Closed#48552[Bug]: in-process file tools resolve ~ (tilde) using gateway HOME, not profile HOME, breaking cron jobs with tilde paths
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.05: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab