# NousResearch/hermes-agent · System prompt hardcoded paths and profile-awareness fixes

## Open now

- 2 issues, 2 PRs open on GitHub.
- #38008: Agents under a non-default profile aren't told they're profile-scoped (environment hints omit HERMES_HOME)
- #52669: \[Bug\]: System prompt hardcodes ~/.hermes paths instead of respecting HERMES_HOME
- PR #38010: feat(prompt): surface profile isolation in environment hints
- PR #57223: fix(windows): show native profile path in system prompt

These issues and PRs address hardcoded ~/.hermes paths in system prompts that ignore HERMES_HOME, along with related fixes for .env value quoting, auxiliary task key_env resolution, and surfacing profile isolation in environment hints.

## TL;DR

- 11 member(s): 2 open issue(s), 1 closed issue(s), 2 open PR(s), 1 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #67192. Verify it addresses your case.
- **Recommended:** read PR #67192 (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-67192`. 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.16`: 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

- #38008: Agents under a non-default profile aren't told they're profile-scoped (environment hints omit HERMES_HOME)
- #52669: \[Bug\]: System prompt hardcodes ~/.hermes paths instead of respecting HERMES_HOME

## Closed / duplicate issues

- #66450: \[Bug\]: System prompt hardcodes ~/.hermes, misdirecting the agent under a non-default HERMES_HOME

## Prior & competing PRs

- **#24088: fix(aux): resolve key_env: prefix in auxiliary task config**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#36080`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#52670: fix(system_prompt): use dynamic HERMES_HOME paths instead of hardcoded ~/.hermes**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#52676`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#66483: fix(cli): quote .env values with internal whitespace in save_env_value**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#67192`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#67192: fix(config): P2 batch — .env quoting/UTF-16, aux key_env, profile-aware system prompt**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/auxiliary_client.py`, `agent/system_prompt.py`, `hermes_cli/config.py`, `hermes_cli/env_loader.py`, `tests/hermes_cli/test_config.py`, `tests/hermes_cli/test_env_loader.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -6253,6 +6253,13 @@ def _resolve_task_provider_model(
             cfg_model = str(task_config.get("model", "")).strip() or None
             cfg_base_url = str(task_config.get("base_url", "")).strip() or None
             cfg_api_key = [redacted:secret-assignment]"api_key", "")).strip() or None
    +        # Resolve key_env → env var when api_key is not set directly
    +        if not cfg_api_key:
    +            cfg_key_env = str(
    +                task_config.get("key_env") or task_config.get("api_key_env") or ""
    +            ).strip()
    +            if cfg_key_env:
    +                cfg_api_key = [redacted:secret-assignment] "").strip() or None
             cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
     
         # 'auto' is a sentinel meaning "inherit from main runtime / auto-detect", not
    ```
  - `agent/system_prompt.py`

    ```diff
    @@ -46,6 +46,7 @@
         drain_truncation_warnings,
     )
     from agent.runtime_cwd import resolve_context_cwd
    +from hermes_constants import get_hermes_home
     from utils import is_truthy_value
     
     
    ```

    ```diff
    @@ -395,7 +396,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
         if active_profile == "default":
             stable_parts.append(
                 "Active Hermes profile: default. Other profiles (if any) live "
    -            "under ~/.hermes/profiles/<name>/. Each profile has its own "
    +            "under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
                 "skills/, plugins/, cron/, and memories/ that affect a different "
                 "session than this one. Do not modify another profile's "
                 "skills/plugins/cron/memories unless the user explicitly directs "
    ```

    ```diff
    @@ -404,9 +405,9 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
         else:
             stable_parts.append(
                 f"Active Hermes profile: {active_profile}. This session reads "
    -            f"and writes ~/.hermes/profiles/{active_profile}/. The default "
    -            f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
    -            f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
    +            f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default "
    +            f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, "
    +            f"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a "
                 f"different session run from a different shell. Do NOT modify "
                 f"another profile's skills/plugins/cron/memories unless the user "
                 f"explicitly directs you to. The cross-profile write guard will "
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -7829,11 +7829,16 @@ def _quote_env_value(value: str) -> str:
         """Quote .env values containing characters with special dotenv meaning."""
         if value == "":
             return value
    +    # Internal whitespace (space/tab/etc.) must be quoted so shell `set -a; . file`
    +    # word-splits don't break paths like macOS "Application Support". Leading/
    +    # trailing whitespace is already covered by the strip check; any() covers
    +    # internal runs that strip() would leave alone.
         needs_quoting = (
             "#" in value
             or '"' in value
             or "'" in value
             or value != value.strip()
    +        or any(c.isspace() for c in value)
         )
         if not needs_quoting:
             return value
    ```
  - `hermes_cli/env_loader.py`

    ```diff
    @@ -2,6 +2,8 @@
     
     from __future__ import annotations
     
    +import codecs
    +import io
     import os
     import sys
     from pathlib import Path
    ```

    ```diff
    @@ -21,6 +23,12 @@
     # tests) don't spam the same warning multiple times.
     _WARNED_KEYS: set[str] = set()
     
    +# Paths we've already emitted a UTF-32 refuse-to-mangle warning for.
    +# load_hermes_dotenv can call _sanitize_env_file_if_needed multiple times
    +# for the same file (user env + project env + hot-reload); once per path
    +# is enough.
    +_WARNED_UTF32_PATHS: set[str] = set()
    +
     # Map of env-var name → source label ("bitwarden", etc.) for credentials
     # that were injected by an external secret source during load_hermes_dotenv().
     # Used by setup / `hermes model` flows to label detected credentials so
    ```

    ```diff
    @@ -176,6 +184,12 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
         with ``ValueError: embedded null byte`` — typically introduced by
         copy-pasting API keys from terminals or rich-text editors.
     
    +    Encoding: sniffs a leading BOM *before* any text decode. UTF-16
    +    (Notepad "Unicode") is decoded correctly and rewritten as clean
    +    UTF-8. UTF-32 is refused (left untouched) so we never fall through
    +    to the errors=replace corruption path. Order of BOM checks matters:
    +    UTF-32-LE's BOM starts with UTF-16-LE's FF FE.
    +
         We delegate to ``hermes_cli.config._sanitize_env_lines`` which
         already knows all valid Hermes env-var names and can split
         concatenated lines correctly.
    ```

    ```diff
    @@ -187,16 +201,70 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
         except ImportError:
             return  # early bootstrap — config module not available yet
     
    -    read_kw = {"encoding": "utf-8-sig", "errors": "replace"}
         try:
    -        with open(path, **read_kw) as f:
    -            original = f.readlines()
    +        raw = path.read_bytes()
    +    except Exception:
    +        return
    +
    +    # Sniff leading BOM bytes BEFORE decoding. ORDER MATTERS:
    +    # codecs.BOM_UTF32_LE is FF FE 00 00, which startswith
    +    # codecs.BOM_UTF16_LE (FF FE). Checking UTF-16 first would
    +    # misdetect UTF-32-LE as UTF-16-LE and mangle the file.
    +    force_utf8_rewrite = False
    +    if raw.startswith(codecs.BOM_UTF32_LE) or raw.startswith(codecs.BOM_UTF32_BE):
    +        # Lazy import keeps the module import block identical to #65124's
    +        # codecs/io additions so the two PRs auto-merge either order.
    +        path_key = str(path.resolve())
    +        if path_key not in _WARNED_UTF32_PATHS:
    +            _WARNED_UTF32_PATHS.add(path_key)
    +            import logging
    +
    +            logging.getLogger(__name__).warning(
    +                "Skipping .env sanitize for %s: UTF-32 BOM detected; "
    +                "leaving file untouched to avoid corruption",
    +                path,
    +            )
    +        return
    +    if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE):
    +        # "utf-16" uses the BOM to select endianness and strips it.
    +        # TextIOWrapper + newline=None matches open()'s universal-newlines
    +        # line splitting (\\n/\\r\\n/\\r only — not splitlines()'s extra
    +        # Unicode boundaries like U+2028), so sanitize sees the same lines
    +        # as the UTF-8 path.
    +        try:
    +            with io.TextIOWrapper(
    +                io.BytesIO(raw), encoding="utf-16", newline=None
    +            ) as f:
    +                original = f.readlines()
    +        except UnicodeDecodeError:
    +            return
    +        # Source is UTF-16 on disk; always rewrite as clean UTF-8 so
    +        # the subsequent utf-8 dotenv load sees a canonical file.
    +        force_utf8_rewrite = True
    +    else:
    +        # Default path: utf-8-sig (strips UTF-8 BOM if present) with
    +        # errors=replace so embedded NULs can be stripped below.
    +        try:
    +            with open(path, encoding="utf-8-sig", errors="replace") as f:
    +                original = f.readlines()
    +        except Exception:
    +            return
    +        # Defense-in-depth: errors=replace turns undecodable leading
    +        # bytes into U+FFFD. Persisting that glues replacement chars
    +        # onto the first key name and rewrites the file permanently
    +        # (the UTF-16-with-BOM corruption path before BOM sniffing).
    +        # Leave the file untouched rather than write the mangling.
    +        if original and original[0].startswith("\ufffd"):
    +            return
    +
    +    try:
             # Strip null bytes before _sanitize_env_lines so they never
             # reach python-dotenv (which passes them to os.environ and
    -        # crashes with ValueError).
    +        # crashes with ValueError). Also intentionally repairs
    +        # BOM-less UTF-16 (NUL-padded ASCII) into clean UTF-8.
             stripped = [line.replace("\x00", "") for line in original]
             sanitized = _sanitize_env_lines(stripped)
    -        if sanitized != original:
    +        if sanitized != original or force_utf8_rewrite:
                 import tempfile
                 fd, tmp = tempfile.mkstemp(
                     dir=str(path.parent), suffix=".tmp", prefix=".env_"
    ```
  - `tests/hermes_cli/test_config.py`

    ```diff
    @@ -607,6 +607,210 @@ def test_save_env_value_updates_hash_value_with_quotes(self, tmp_path):
                 assert parsed["ANTHROPIC_TOKEN"] == token
                 assert load_env()["ANTHROPIC_TOKEN"] == token
     
    +    def test_save_env_value_quotes_values_with_internal_spaces(self, tmp_path):
    +        """Internal spaces must be quoted so shell-sourcing does not word-split.
    +
    +        Sibling of installer #57247: core writer left
    +        TERMINAL_SSH_KEY=/Users/.../Application Support/... unquoted.
    +        python-dotenv still parsed it; ``set -a; . file`` failed.
    +        """
    +        import subprocess
    +        from dotenv import dotenv_values
    +
    +        path = "/Users/paulo/Library/Application Support/hermes/keys/id_ed25519"
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("TERMINAL_SSH_KEY", None)
    +            save_env_value("TERMINAL_SSH_KEY", path)
    +
    +            env_path = tmp_path / ".env"
    +            content = env_path.read_text(encoding="utf-8")
    +            assert f'TERMINAL_SSH_KEY="{path}"' in content
    +
    +            parsed = dotenv_values(str(env_path))
    +            assert parsed["TERMINAL_SSH_KEY"] == path
    +            assert load_env()["TERMINAL_SSH_KEY"] == path
    +
    +            # Shell source must round-trip (this is what the bug broke).
    +            r = subprocess.run(
    +                [
    +                    "env",
    +                    "-i",
    +                    "sh",
    +                    "-c",
    +                    f"set -a; . '{env_path}'; set +a; "
    +                    f'printf "%s" "$TERMINAL_SSH_KEY"',
    +                ],
    +                capture_output=True,
    +                text=True,
    +            )
    +            assert r.returncode == 0, r.stderr
    +            assert r.stderr == ""
    +            assert r.stdout == path
    +
    +    def test_save_env_value_quotes_values_with_tabs(self, tmp_path):
    +        """Tabs trigger quoting; round-trip via dotenv and shell source."""
    +        import subprocess
    +        from dotenv import dotenv_values
    +
    +        value = "left\tright"
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("TABBY_KEY", None)
    +            save_env_value("TABBY_KEY", value)
    +
    +            env_path = tmp_path / ".env"
    +            content = env_path.read_text(encoding="utf-8")
    +            assert f'TABBY_KEY="{value}"' in content
    +
    +            parsed = dotenv_values(str(env_path))
    +            assert parsed["TABBY_KEY"] == value
    +            assert load_env()["TABBY_KEY"] == value
    +
    +            r = subprocess.run(
    +                [
    +                    "env",
    +                    "-i",
    +                    "sh",
    +                    "-c",
    +                    f"set -a; . '{env_path}'; set +a; "
    +                    f'printf "%s" "$TABBY_KEY"',
    +                ],
    +                capture_output=True,
    +                text=True,
    +            )
    +            assert r.returncode == 0, r.stderr
    +            assert r.stderr == ""
    +            assert r.stdout == value
    +
    +    def test_save_env_value_spaced_path_is_idempotent(self, tmp_path):
    +        """Saving the same spaced value twice must not grow quotes."""
    +        path = "/Users/me/Application Support/key"
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("TERMINAL_SSH_KEY", None)
    +            save_env_value("TERMINAL_SSH_KEY", path)
    +            first = (tmp_path / ".env").read_text(encoding="utf-8")
    +            save_env_value("TERMINAL_SSH_KEY", path)
    +            second = (tmp_path / ".env").read_text(encoding="utf-8")
    +
    +            assert first == second
    +            assert first.count('TERMINAL_SSH_KEY="') == 1
    +            assert '""' not in first
    +            assert f'TERMINAL_SSH_KEY="{path}"' in first
    +
    +    def test_save_env_value_readback_resave_is_idempotent(self, tmp_path):
    +        """hermes setup path: dotenv unquotes, then re-save must not grow quotes."""
    +        from dotenv import dotenv_values
    +
    +        path = "/Users/me/Application Support/key"
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("TERMINAL_SSH_KEY", None)
    +            save_env_value("TERMINAL_SSH_KEY", path)
    +            first = (tmp_path / ".env").read_text(encoding="utf-8")
    +
    +            # Real read-back boundary (what setup uses via get_env_value/dotenv).
    +            read_back = dotenv_values(str(tmp_path / ".env"))["TERMINAL_SSH_KEY"]
    +            assert read_back == path
    +            save_env_value("TERMINAL_SSH_KEY", read_back)
    +            second = (tmp_path / ".env").read_text(encoding="utf-8")
    +
    +            assert first == second
    +            assert f'TERMINAL_SSH_KEY="{path}"' in second
    +
    +    def test_save_env_value_strips_newlines_before_quoting(self, tmp_path):
    +        """save_env_value strips \\n/\\r before _quote_env_value; result is one line.
    +
    +        Pins the boundary so any(c.isspace()) never quotes multi-line dotenv
    +        values through this writer (newlines never reach the quoter).
    +        """
    +        from dotenv import dotenv_values
    +
    +        raw = "line1\nline2\rline3"
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("MULTI_KEY", None)
    +            save_env_value("MULTI_KEY", raw)
    +
    +            content = (tmp_path / ".env").read_text(encoding="utf-8")
    +            # Single KEY= line, no embedded raw newlines in the value payload.
    +            lines = [ln for ln in content.splitlines() if ln.startswith("MULTI_KEY=")]
    +            assert len(lines) == 1
    +            assert "\n" not in lines[0]
    +            assert "\r" not in lines[0]
    +            # Newlines stripped -> "line1line2line3" has no whitespace -> unquoted.
    +            assert lines[0] == "MULTI_KEY=line1line2line3"
    +            parsed = dotenv_values(str(tmp_path / ".env"))
    +            assert parsed["MULTI_KEY"] == "line1line2line3"
    +
    +    def test_save_env_value_simple_values_stay_unquoted(self, tmp_path):
    +        """No quoting churn: plain values remain bare; untouched lines unchanged."""
    +        env_path = tmp_path / ".env"
    +        # Pre-existing lines: one simple, one already correctly bare.
    +        env_path.write_text(
    +            "KEEP_SIMPLE=plainvalue\n"
    +            "OTHER_KEY=foo123\n",
    +            encoding="utf-8",
    +        )
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("NEW_KEY", None)
    +            os.environ.pop("KEEP_SIMPLE", None)
    +            save_env_value("NEW_KEY", "bar-simple")
    +
    +            content = env_path.read_text(encoding="utf-8")
    +            # Newly written simple value is unquoted.
    +            assert "NEW_KEY=bar-simple\n" in content
    +            assert 'NEW_KEY="' not in content
    +            # Untouched pre-existing simple lines are not re-quoted.
    +            assert "KEEP_SIMPLE=plainvalue\n" in content
    +            assert "OTHER_KEY=foo123\n" in content
    +            assert 'KEEP_SIMPLE="' not in content
    +            assert 'OTHER_KEY="' not in content
    +
    +    def test_save_env_value_does_not_requote_untouched_spaced_lines(self, tmp_path):
    +        """Mass-requote guard: rewriting another key leaves legacy spaced
    +        lines as-is (fix only applies when that key is saved again).
    +        """
    +        env_path = tmp_path / ".env"
    +        legacy = (
    +            "TERMINAL_SSH_KEY=/Users/me/Application Support/key\n"
    +            "PLAIN=ok\n"
    +        )
    +        env_path.write_text(legacy, encoding="utf-8")
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("PLAIN", None)
    +            save_env_value("PLAIN", "ok2")
    +
    +            content = env_path.read_text(encoding="utf-8")
    +            # Legacy spaced line not re-serialized by this write.
    +            assert (
    +                "TERMINAL_SSH_KEY=/Users/me/Application Support/key\n" in content
    +            )
    +            assert 'TERMINAL_SSH_KEY="' not in content
    +            assert "PLAIN=ok2\n" in content
    +
    +    def test_save_env_value_already_quoted_input_is_not_double_wrapped_idempotently(
    +        self, tmp_path
    +    ):
    +        """Callers pass raw values; if a value literally contains quote
    +        characters, escaping+wrap is the dialect (#57249). Re-saving the
    +        same raw value is stable (no quote growth). load_env round-trips.
    +        """
    +        # User-typed value that already includes surrounding quotes as data.
    +        raw = '"/Users/me/Application Support/key"'
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}, clear=False):
    +            os.environ.pop("TERMINAL_SSH_KEY", None)
    +            save_env_value("TERMINAL_SSH_KEY", raw)
    +            first = (tmp_path / ".env").read_text(encoding="utf-8")
    +            save_env_value("TERMINAL_SSH_KEY", raw)
    +            second = (tmp_path / ".env").read_text(encoding="utf-8")
    +            assert first == second
    +            # One outer wrap layer only (escaped inner quotes, not nested wraps).
    +            line = [
    +                ln for ln in first.splitlines() if ln.startswith("TERMINAL_SSH_KEY=")
    +            ][0]
    +            assert line.startswith('TERMINAL_SSH_KEY="')
    +            assert line.endswith('"')
    +            assert line.count('TERMINAL_SSH_KEY="') == 1
    +            # Escaping dialect end-to-end: load sees the raw input, not stripped quotes.
    +            assert load_env()["TERMINAL_SSH_KEY"] == raw
    +
     
     class TestRemoveEnvValue:
         def test_removes_key_from_env_file(self, tmp_path):
    ```

    ```diff
    @@ -726,7 +930,7 @@ def test_atomic_write_creates_valid_yaml(self, tmp_path):
     
                 # Read raw YAML to verify it's valid and correct
                 config_path = tmp_path / "config.yaml"
    -            with open(config_path) as f:
    +            with open(config_path, encoding="utf-8") as f:
                     raw = yaml.safe_load(f)
                 assert raw["model"] == "test/atomic-model"
                 assert raw["agent"]["max_turns"] == 77
    ```
  - `tests/hermes_cli/test_env_loader.py`

    ```diff
    @@ -1,3 +1,4 @@
    +import codecs
     import importlib
     import os
     import sys
    ```

    ```diff
    @@ -103,3 +104,268 @@ def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):
     
         assert os.getenv("OPENAI_BASE_URL") == "https://new.example/v1"
         assert os.getenv("HERMES_INFERENCE_PROVIDER") == "custom"
    +
    +
    +# ---------------------------------------------------------------------------
    +# UTF-16 / UTF-32 .env sanitizer coverage
    +#
    +# Scope note: intentionally NO UTF-8-BOM assertions here. UTF-8 BOM handling
    +# for _load_dotenv_with_fallback is #65124's un-merged fix; a test here would
    +# couple the PRs. This suite covers only the sanitizer rewrite path for
    +# UTF-16/32 (and UTF-8 / cp1252 regression guards for that path).
    +# ---------------------------------------------------------------------------
    +
    +
    +def _assert_clean_utf8_env_on_disk(env_file, *, first_key: str) -> None:
    +    """On-disk file must be clean UTF-8: no BOM, no U+FFFD, canonical key."""
    +    after = env_file.read_bytes()
    +    assert not after.startswith(codecs.BOM_UTF8)
    +    assert not after.startswith(codecs.BOM_UTF16_LE)
    +    assert not after.startswith(codecs.BOM_UTF16_BE)
    +    text = after.decode("utf-8")  # strict — raises if not clean UTF-8
    +    assert "\ufffd" not in text
    +    assert text.startswith(f"{first_key}=") or f"\n{first_key}=" in text
    +    assert first_key.encode("ascii") in after
    +
    +
    +def test_utf16_le_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):
    +    """Notepad 'Unicode' (UTF-16-LE + BOM): first key loads; file rewritten UTF-8."""
    +    home = tmp_path / "hermes"
    +    home.mkdir()
    +    env_file = home / ".env"
    +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
    +    env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode("utf-16-le"))
    +
    +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
    +    monkeypatch.delenv("SECOND_KEY", raising=False)
    +    monkeypatch.delenv("\ufffd\ufffdHERMES_TEST_KEY", raising=False)
    +
    +    loaded = load_hermes_dotenv(hermes_home=home)
    +
    +    assert loaded == [env_file]
    +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
    +    assert os.getenv("SECOND_KEY") == "world"
    +    assert os.environ.get("\ufffd\ufffdHERMES_TEST_KEY") is None
    +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
    +
    +
    +def test_utf16_be_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):
    +    """UTF-16-BE + BOM: first key loads; file rewritten as clean UTF-8."""
    +    home = tmp_path / "hermes"
    +    home.mkdir()
    +    env_file = home / ".env"
    +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
    +    env_file.write_bytes(codecs.BOM_UTF16_BE + content.encode("utf-16-be"))
    +
    +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
    +    monkeypatch.delenv("SECOND_KEY", raising=False)
    +
    +    loaded = load_hermes_dotenv(hermes_home=home)
    +
    +    assert loaded == [env_file]
    +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
    +    assert os.getenv("SECOND_KEY") == "world"
    +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
    +
    +
    +def test_utf16_le_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):
    +    """BOM-less UTF-16-LE: NUL-strip repair is now intentional; rewrites UTF-8."""
    +    home = tmp_path / "hermes"
    +    home.mkdir()
    +    env_file = home / ".env"
    +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
    +    env_file.write_bytes(content.encode("utf-16-le"))  # no BOM
    +
    +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
    +    monkeypatch.delenv("SECOND_KEY", raising=False)
    +
    +    loaded = load_hermes_dotenv(hermes_home=home)
    +
    +    assert loaded == [env_file]
    +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
    +    assert os.getenv("SECOND_KEY") == "world"
    +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
    +
    +
    +def test_utf16_be_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):
    +    """BOM-less UTF-16-BE: NULs are on the opposite side; still repairs."""
    +    home = tmp_path / "hermes"
    +    home.mkdir()
    +    env_file = home / ".env"
    +    content = "HERMES_TEST_KEY=hello_utf16\nSECOND_KEY=world\n"
    +    env_file.write_bytes(content.encode("utf-16-be"))  # no BOM
    +
    +    monkeypatch.delenv("HERMES_TEST_KEY", raising=False)
    +    monkeypatch.delenv("SECOND_KEY", raising=False)
    +
    +    loaded = load_hermes_dotenv(hermes_home=home)
    +
    +    assert loaded == [env_file]
    +    assert os.getenv("HERMES_TEST_KEY") == "hello_utf16"
    +    assert os.getenv("SECOND_KEY") == "world"
    +    _assert_clean_utf8_env_on_disk(env_file, first_key="HERMES_TEST_KEY")
    +
    +
    +def test_utf16_le_bom_preserves_non_ascii_values(tmp_path, monkeypatch):
    +    """UTF-16-LE+BOM rewrite must preserve non-ASCII values (not just ASCII keys).
    +
    +    Uses non-credential var names so _sanitize_loaded_credentials does not
    +    strip non-ASCII from values (that path only targets *_KEY/*_TOKEN/etc.).
    +    """
    +    home = tmp_path / "hermes"
    +    home.mkdir()
    +    env_file = home / ".env"
    +    content = "GREETING=café\nCJK_LABEL=日本語\n"
    +    env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode("utf-16-le"))
    +
    +    monkeypatch.delenv("GREETING", raising=False)
    +    monkeypatch.delenv("CJK_LABEL", raising=False)
    +
    +    loaded = load_hermes_dotenv(hermes_home=home)
    +
    +    assert loaded == [env_file]
    +    assert os.getenv("GREETING") == "café"
    +    assert os.getenv("CJK_LABEL") == "日本語"
    +    after = env_file.read_bytes()
    +    assert after.decode("utf-8")  # strict
    +    assert "café".encode("utf-8") in after
    +    assert "日本語".encode("utf-8") in after
    +    assert b"\xef\xbf\xbd" not in after
    +
    +
    +def test_utf32_le_bom_leaves_file_untouched(tmp_path, caplog):
    +    """UTF-32-LE BOM: refuse-to-mangle (leave bytes untouched + warning).
    +
    +    UTF-32-LE's BOM starts with UTF-16-LE's FF FE; sniff order must check
    +    UTF-32 first so we never misdetect and corrupt.
    +
    +    Exercises ``_sanitize_env_file_if_needed`` only: the dotenv load path
    +    is out of scope here (#65124's surface) and still cannot ingest UTF-32.
    +    """
    +    import logging
    +
    +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
    +
    +    env_file = tmp_path / ".env"
    +    content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
    +    raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
    +    env_file.write_bytes(raw)
    +
    +    with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
    +        _sanitize_env_file_if_needed(env_file)
    +
    +    assert env_file.read_bytes() == raw  # untouched
    +    assert any("UTF-32" in r.message for r in caplog.records)
    +
    +
    +def test_utf32_be_bom_leaves_file_untouched(tmp_path, caplog):
    +    """UTF-32-BE BOM: same refuse-to-mangle path as LE (ordering independence)."""
    +    import logging
    +
    +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
    +
    +    env_file = tmp_path / ".env"
    +    content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
    +    raw = codecs.BOM_UTF32_BE + content.encode("utf-32-be")
    +    env_file.write_bytes(raw)
    +
    +    with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
    +        _sanitize_env_file_if_needed(env_file)
    +
    +    assert env_file.read_bytes() == raw
    +    assert any("UTF-32" in r.message for r in caplog.records)
    +
    +
    +def test_utf32_warning_fires_once_per_path(tmp_path, caplog, monkeypatch):
    +    """Three sanitize calls on the same UTF-32 file → exactly one warning.
    +
    +    Matches house style for warn-once (module-level seen-set, same class as
    +    ``_WARNED_KEYS``): hot-reload / multi-entry load must not spam logs.
    +    """
    +    import logging
    +
    +    import hermes_cli.env_loader as env_loader
    +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
    +
    +    # Isolate process-level seen-set so other tests' paths don't leak in.
    +    monkeypatch.setattr(env_loader, "_WARNED_UTF32_PATHS", set())
    +
    +    env_file = tmp_path / ".env"
    +    content = "HERMES_TEST_KEY=hello_utf32\nSECOND_KEY=world\n"
    +    raw = codecs.BOM_UTF32_LE + content.encode("utf-32-le")
    +    env_file.write_bytes(raw)
    +
    +    with caplog.at_level(logging.WARNING, logger="hermes_cli.env_loader"):
    +        _sanitize_env_file_if_needed(env_file)
    +        _sanitize_env_file_if_needed(env_file)
    +        _sanitize_env_file_if_needed(env_file)
    +
    +    utf32_warnings = [r for r in caplog.records if "UTF-32" in r.message]
    +    assert len(utf32_warnings) == 1
    +    assert env_file.read_bytes() == raw
    +
    +
    +def test_leading_replacement_char_does_not_rewrite(tmp_path):
    +    """errors=replace FFFD-on-first-line guard: do not persist mangling.
    +
    +    Leading 0xFF is not a UTF-16/32 BOM (those need the second BOM byte) but
    +    is undecodable as UTF-8, so the replace path would glue U+FFFD onto the
    +    key. The guard must leave the on-disk bytes untouched.
    +    """
    +    from hermes_cli.env_loader import _sanitize_env_file_if_needed
    +
    +    env_file = tmp_path / ".env"
    +    raw = b"\xffHERMES_TEST_KEY=should-not-rewrite\nSECOND_KEY=ok\n"
    +    env_file.write_bytes(raw)
    +
    +    _sanitize_env_file_if_needed(env_file)
    +
    +    assert env_file.read_bytes() == raw
    +
    +
    +def test_plain_utf8_env_regression(tmp_path, monkeypatch):
    +    """Plain UTF-8 .env must keep loading after the UTF-16 sanitize changes."""
    +    home = tmp_path / "hermes"
    +    home.mkdir()
    +    env_file = home / ".env"
    +    before = b"OPENAI_API_KEY=[redacted:secret-assignment]"
    +    env_file.write_bytes(before)
    +
    +    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    +    monkeypatch.delenv("SECOND_KEY", raising=False)
    +
    +    loaded = load_hermes_dotenv(hermes_home=home)
    +
    +    assert loaded == [env_file]
    +    assert os.getenv("OPENAI_API_KEY") == "sk-plain"
    +    assert os.getenv("SECOND_KEY") == "ok"
    +    # No spurious rewrite of an already-clean file.
    +    assert env_file.read_bytes() == before
    +
    +
    +def test_cp1252_env_regression_does_not_crash(tmp_path, monkeypatch):
    +    """cp1252/latin-1 body must not crash sanitize; ASCII keys still usable.
    +
    +    0xE9 is 'é' in cp1252 and incomplete as UTF-8. First line does not begin
    +    with U+FFFD, so the FFFD guard must not refuse the whole file.
    +
    +    Sanitize leaves the file bytes alone when the only "change" is
    +    errors=replace on values (original already replace-decoded equals
    +    sanitized), so _load_dotenv_with_fallback's latin-1 path recovers café.
    +    """
    +    home = tmp_path / "hermes"
    +    home.mkdir()
    +    env_file = home / ".env"
    +    before = b"ASCII_KEY=ok\nLATIN1_VALUE=caf\xe9\n"
    +    env_file.write_bytes(before)
    +
    +    monkeypatch.delenv("ASCII_KEY", raising=False)
    +    monkeypatch.delenv("LATIN1_VALUE", raising=False)
    +
    +    loaded = load_hermes_dotenv(hermes_home=home)
    +
    +    assert loaded == [env_file]
    +    assert os.getenv("ASCII_KEY") == "ok"
    +    assert os.getenv("LATIN1_VALUE") == "café"
    +    # Sanitize must not have rewritten (would have persisted U+FFFD).
    +    assert env_file.read_bytes() == before
    ```
- **#38010: feat(prompt): surface profile isolation in environment hints**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `agent/prompt_builder.py`, `tests/agent/test_prompt_builder.py`
- **#52676: fix(agent): use Hermes root in profile prompt**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `2bae4df8bb29243fe6b627cb3942aa272edcffc2`, `#66450`
- **#57223: fix(windows): show native profile path in system prompt**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#52669`, `#52670`, `#52676`
  - files: `agent/_platform_paths.py`, `agent/system_prompt.py`, `tests/agent/test_platform_paths.py`, `tests/agent/test_system_prompt.py`
- **#66453: fix: replace hardcoded ~/.hermes with get_hermes_home() in system prompt (#66450)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#52676`

## Fix status

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