# NousResearch/hermes-agent · HOME/tilde resolution broken under profile isolation

## Open now

- 3 issues, 2 PRs open on GitHub.
- #27250: Docker/profile mode should avoid different HOME values between Hermes process and tool subprocesses
- #36144: Agent session HOME points to wrong path — tools look in hermes profile instead of user's actual home
- #39523: \[Feature\]: Sandbox HOME bridge initialization — subprocess tools can't find Keychain/SSH/Git/Python resources
- PR #27259: fix(docker): align process HOME with active profile home
- PR #28469: fix: resolve ~ to real user home in sandboxed profiles

A 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.

## TL;DR

- 33 member(s): 3 open issue(s), 8 closed issue(s), 2 open PR(s), 3 merged PR(s), 17 PR(s) closed without merging.
- A fix was merged to main in PR #46075. Verify it addresses your case.
- **Recommended:** read PR #46075 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-46075,pr-51060,pr-109194`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #27250: Docker/profile mode should avoid different HOME values between Hermes process and tool subprocesses
- #36144: Agent session HOME points to wrong path — tools look in hermes profile instead of user's actual home
- #39523: \[Feature\]: Sandbox HOME bridge initialization — subprocess tools can't find Keychain/SSH/Git/Python resources

## Closed / duplicate issues

- #8669: fix: sandbox HOME override breaks Path.home() and expanduser() resolution
- #12260: \[Bug\]: resolve_skill_config_values() uses wrong HOME semantics for ~ expansion — expands against Python process HOME instead of Hermes subprocess HOME
- #25114: Bug: HOME env corrupted inside Hermes profile runtime context — Path.home() resolves to wrong directory
- #28456: Bug: read_file tilde expansion broken in sandboxed profiles (HOME points to profile sandbox)
- #29015: macOS: per-profile HOME isolation in _make_run_env hides ~/Library/Keychains from worker subprocesses, breaks claude CLI auth
- #35289: hermes-profile-env-bug-issue
- #48037: Profile HOME isolation causes agents to silently operate on incorrect paths for host-level commands
- #48552: \[Bug\]: in-process file tools resolve ~ (tilde) using gateway HOME, not profile HOME, breaking cron jobs with tilde paths

## Prior & competing PRs

- **#8910: fix: use real user home instead of sandbox-overridden HOME**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12284: fix(skill-config): expand ~ against subprocess HOME, not Python process HOME (#12260)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12736`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#12331: fix: use subprocess HOME for ~ expansion in skill config values**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#12736`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#29229: fix(tools): expand ~ to real home, not sandbox HOME (#28456)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#28469`
- **#29255: fix(env): preserve macOS Keychain access from sandboxed worker subprocesses (#29015)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#29050`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#46075: fix: make profile subprocess HOME policy explicit**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.md`
  - fix hunks:
  - `agent/copilot_acp_client.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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": [],
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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 = {}
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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",
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    +
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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"
    ```

    ```diff
    @@ -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()
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```
- **#48566: fix(tools): use profile-aware HOME for ~ expansion in file tools**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `15880da8bbd5c9a48c3bc5f6955bea86fba54965`, `#48552`
- **#48573: fix(file-tools): expand ~ via profile HOME, not gateway process HOME**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#48566`, `#48566`
- **#51060: fix(file_tools): resolve tilde using profile home for file operations (#48552)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/skill_utils.py`, `tests/agent/test_skill_utils.py`
- **#8671: fix(paths): preserve real home under sandbox HOME overrides**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#8910`
- **#8672: fix: use real user home instead of sandbox-overridden HOME**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#8910`
- **#12273: fix(agent): resolve skill config ~/... against subprocess HOME**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#12736: fix(agent): use correct HOME for ~ expansion in skill config resolution**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`; refs `#12736`
- **#25151: fix(agent): expose HERMES_REAL_HOME in subprocess envs for profile isolation**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `723c2331bd236fdb9bbc0d6e6f85a1b7704e4aa1`, `b00060ce545c54d9ead5a7b1ca66f9bfa35064d2`, `#46075`
- **#27259: fix(docker): align process HOME with active profile home**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `docker/entrypoint.sh`, `hermes_cli/main.py`, `tests/hermes_cli/test_apply_profile_override.py`
- **#27260: fix(profiles): align main-process HOME with subprocess HOME within a profile (#27250)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
- **#28469: fix: resolve ~ to real user home in sandboxed profiles**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `tests/tools/test_file_tools.py`, `tools/file_tools.py`
- **#29050: fix(local): expose macOS keychains in profile home**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#38107: fix(agent): preserve real HOME for session tools**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `723c2331bd236fdb9bbc0d6e6f85a1b7704e4aa1`
- **#48759: fix(file-tools): resolve tilde using effective home for in-process tools (#48552)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#48566`, `#48552`, `#48566`, `#48573`
- **#49251: fix(file-tools): resolve tilde paths via profile home (#48552)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `15880da8bbd5c9a48c3bc5f6955bea86fba54965`, `#51060`

## Fix status

- Merged to main in PR #46075 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-12`.
- Subsystem: `agent`
