{"repo":"NousResearch/hermes-agent","public_id":"38008","display_label":"System prompt hardcoded paths and profile-awareness fixes","summary":"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.","subsystem":"agent","counts":{"members":11,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":1,"merged_prs":1,"attempt_prs":7},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":67192},"resolution":{"state":"partially_resolved","by":"pr-67192"},"freshness":{"state":"verified","last_verified_at":"2026-07-18T23:10:06+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15854545454545452},"fix_edges":[{"issue_number":66450,"pr_number":67192,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":66474,"pr_number":67192,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":66482,"pr_number":67192,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":66641,"pr_number":67192,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":38008,"title":"Agents under a non-default profile aren't told they're profile-scoped (environment hints omit HERMES_HOME)","url":null,"state":"open"},{"kind":"issue","number":52669,"title":"[Bug]: System prompt hardcodes ~/.hermes paths instead of respecting HERMES_HOME","url":null,"state":"open"},{"kind":"issue","number":66450,"title":"[Bug]: System prompt hardcodes ~/.hermes, misdirecting the agent under a non-default HERMES_HOME","url":null,"state":"closed"},{"kind":"pr","number":24088,"title":"fix(aux): resolve key_env: prefix in auxiliary task config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#36080","number":36080}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38010,"title":"feat(prompt): surface profile isolation in environment hints","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":52670,"title":"fix(system_prompt): use dynamic HERMES_HOME paths instead of hardcoded ~/.hermes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52676","number":52676}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":52676,"title":"fix(agent): use Hermes root in profile prompt","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2bae4df8bb29243fe6b627cb3942aa272edcffc2","number":0},{"kind":"pr","value":"#66450","number":66450}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":57223,"title":"fix(windows): show native profile path in system prompt","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#52669","number":52669},{"kind":"pr","value":"#52670","number":52670},{"kind":"pr","value":"#52676","number":52676}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":66453,"title":"fix: replace hardcoded ~/.hermes with get_hermes_home() in system prompt (#66450)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52676","number":52676}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":66483,"title":"fix(cli): quote .env values with internal whitespace in save_env_value","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67192","number":67192}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":67192,"title":"fix(config): P2 batch — .env quoting/UTF-16, aux key_env, profile-aware system prompt","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/auxiliary_client.py","hunks":["@@ -6253,6 +6253,13 @@ def _resolve_task_provider_model(\n         cfg_model = str(task_config.get(\"model\", \"\")).strip() or None\n         cfg_base_url = str(task_config.get(\"base_url\", \"\")).strip() or None\n         cfg_api_key = [redacted:secret-assignment]\"api_key\", \"\")).strip() or None\n+        # Resolve key_env → env var when api_key is not set directly\n+        if not cfg_api_key:\n+            cfg_key_env = str(\n+                task_config.get(\"key_env\") or task_config.get(\"api_key_env\") or \"\"\n+            ).strip()\n+            if cfg_key_env:\n+                cfg_api_key = [redacted:secret-assignment] \"\").strip() or None\n         cfg_api_mode = str(task_config.get(\"api_mode\", \"\")).strip() or None\n \n     # 'auto' is a sentinel meaning \"inherit from main runtime / auto-detect\", not"]},{"file":"agent/system_prompt.py","hunks":["@@ -46,6 +46,7 @@\n     drain_truncation_warnings,\n )\n from agent.runtime_cwd import resolve_context_cwd\n+from hermes_constants import get_hermes_home\n from utils import is_truthy_value\n \n ","@@ -395,7 +396,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)\n     if active_profile == \"default\":\n         stable_parts.append(\n             \"Active Hermes profile: default. Other profiles (if any) live \"\n-            \"under ~/.hermes/profiles/<name>/. Each profile has its own \"\n+            \"under \" + str(get_hermes_home()) + \"/profiles/<name>/. Each profile has its own \"\n             \"skills/, plugins/, cron/, and memories/ that affect a different \"\n             \"session than this one. Do not modify another profile's \"\n             \"skills/plugins/cron/memories unless the user explicitly directs \"","@@ -404,9 +405,9 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)\n     else:\n         stable_parts.append(\n             f\"Active Hermes profile: {active_profile}. This session reads \"\n-            f\"and writes ~/.hermes/profiles/{active_profile}/. The default \"\n-            f\"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, \"\n-            f\"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a \"\n+            f\"and writes {get_hermes_home()}/profiles/{active_profile}/. The default \"\n+            f\"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, \"\n+            f\"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a \"\n             f\"different session run from a different shell. Do NOT modify \"\n             f\"another profile's skills/plugins/cron/memories unless the user \"\n             f\"explicitly directs you to. The cross-profile write guard will \""]},{"file":"hermes_cli/config.py","hunks":["@@ -7829,11 +7829,16 @@ def _quote_env_value(value: str) -> str:\n     \"\"\"Quote .env values containing characters with special dotenv meaning.\"\"\"\n     if value == \"\":\n         return value\n+    # Internal whitespace (space/tab/etc.) must be quoted so shell `set -a; . file`\n+    # word-splits don't break paths like macOS \"Application Support\". Leading/\n+    # trailing whitespace is already covered by the strip check; any() covers\n+    # internal runs that strip() would leave alone.\n     needs_quoting = (\n         \"#\" in value\n         or '\"' in value\n         or \"'\" in value\n         or value != value.strip()\n+        or any(c.isspace() for c in value)\n     )\n     if not needs_quoting:\n         return value"]},{"file":"hermes_cli/env_loader.py","hunks":["@@ -2,6 +2,8 @@\n \n from __future__ import annotations\n \n+import codecs\n+import io\n import os\n import sys\n from pathlib import Path","@@ -21,6 +23,12 @@\n # tests) don't spam the same warning multiple times.\n _WARNED_KEYS: set[str] = set()\n \n+# Paths we've already emitted a UTF-32 refuse-to-mangle warning for.\n+# load_hermes_dotenv can call _sanitize_env_file_if_needed multiple times\n+# for the same file (user env + project env + hot-reload); once per path\n+# is enough.\n+_WARNED_UTF32_PATHS: set[str] = set()\n+\n # Map of env-var name → source label (\"bitwarden\", etc.) for credentials\n # that were injected by an external secret source during load_hermes_dotenv().\n # Used by setup / `hermes model` flows to label detected credentials so","@@ -176,6 +184,12 @@ def _sanitize_env_file_if_needed(path: Path) -> None:\n     with ``ValueError: embedded null byte`` — typically introduced by\n     copy-pasting API keys from terminals or rich-text editors.\n \n+    Encoding: sniffs a leading BOM *before* any text decode. UTF-16\n+    (Notepad \"Unicode\") is decoded correctly and rewritten as clean\n+    UTF-8. UTF-32 is refused (left untouched) so we never fall through\n+    to the errors=replace corruption path. Order of BOM checks matters:\n+    UTF-32-LE's BOM starts with UTF-16-LE's FF FE.\n+\n     We delegate to ``hermes_cli.config._sanitize_env_lines`` which\n     already knows all valid Hermes env-var names and can split\n     concatenated lines correctly.","@@ -187,16 +201,70 @@ def _sanitize_env_file_if_needed(path: Path) -> None:\n     except ImportError:\n         return  # early bootstrap — config module not available yet\n \n-    read_kw = {\"encoding\": \"utf-8-sig\", \"errors\": \"replace\"}\n     try:\n-        with open(path, **read_kw) as f:\n-            original = f.readlines()\n+        raw = path.read_bytes()\n+    except Exception:\n+        return\n+\n+    # Sniff leading BOM bytes BEFORE decoding. ORDER MATTERS:\n+    # codecs.BOM_UTF32_LE is FF FE 00 00, which startswith\n+    # codecs.BOM_UTF16_LE (FF FE). Checking UTF-16 first would\n+    # misdetect UTF-32-LE as UTF-16-LE and mangle the file.\n+    force_utf8_rewrite = False\n+    if raw.startswith(codecs.BOM_UTF32_LE) or raw.startswith(codecs.BOM_UTF32_BE):\n+        # Lazy import keeps the module import block identical to #65124's\n+        # codecs/io additions so the two PRs auto-merge either order.\n+        path_key = str(path.resolve())\n+        if path_key not in _WARNED_UTF32_PATHS:\n+            _WARNED_UTF32_PATHS.add(path_key)\n+            import logging\n+\n+            logging.getLogger(__name__).warning(\n+                \"Skipping .env sanitize for %s: UTF-32 BOM detected; \"\n+                \"leaving file untouched to avoid corruption\",\n+                path,\n+            )\n+        return\n+    if raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE):\n+        # \"utf-16\" uses the BOM to select endianness and strips it.\n+        # TextIOWrapper + newline=None matches open()'s universal-newlines\n+        # line splitting (\\\\n/\\\\r\\\\n/\\\\r only — not splitlines()'s extra\n+        # Unicode boundaries like U+2028), so sanitize sees the same lines\n+        # as the UTF-8 path.\n+        try:\n+            with io.TextIOWrapper(\n+                io.BytesIO(raw), encoding=\"utf-16\", newline=None\n+            ) as f:\n+                original = f.readlines()\n+        except UnicodeDecodeError:\n+            return\n+        # Source is UTF-16 on disk; always rewrite as clean UTF-8 so\n+        # the subsequent utf-8 dotenv load sees a canonical file.\n+        force_utf8_rewrite = True\n+    else:\n+        # Default path: utf-8-sig (strips UTF-8 BOM if present) with\n+        # errors=replace so embedded NULs can be stripped below.\n+        try:\n+            with open(path, encoding=\"utf-8-sig\", errors=\"replace\") as f:\n+                original = f.readlines()\n+        except Exception:\n+            return\n+        # Defense-in-depth: errors=replace turns undecodable leading\n+        # bytes into U+FFFD. Persisting that glues replacement chars\n+        # onto the first key name and rewrites the file permanently\n+        # (the UTF-16-with-BOM corruption path before BOM sniffing).\n+        # Leave the file untouched rather than write the mangling.\n+        if original and original[0].startswith(\"\\ufffd\"):\n+            return\n+\n+    try:\n         # Strip null bytes before _sanitize_env_lines so they never\n         # reach python-dotenv (which passes them to os.environ and\n-        # crashes with ValueError).\n+        # crashes with ValueError). Also intentionally repairs\n+        # BOM-less UTF-16 (NUL-padded ASCII) into clean UTF-8.\n         stripped = [line.replace(\"\\x00\", \"\") for line in original]\n         sanitized = _sanitize_env_lines(stripped)\n-        if sanitized != original:\n+        if sanitized != original or force_utf8_rewrite:\n             import tempfile\n             fd, tmp = tempfile.mkstemp(\n                 dir=str(path.parent), suffix=\".tmp\", prefix=\".env_\""]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -607,6 +607,210 @@ def test_save_env_value_updates_hash_value_with_quotes(self, tmp_path):\n             assert parsed[\"ANTHROPIC_TOKEN\"] == token\n             assert load_env()[\"ANTHROPIC_TOKEN\"] == token\n \n+    def test_save_env_value_quotes_values_with_internal_spaces(self, tmp_path):\n+        \"\"\"Internal spaces must be quoted so shell-sourcing does not word-split.\n+\n+        Sibling of installer #57247: core writer left\n+        TERMINAL_SSH_KEY=/Users/.../Application Support/... unquoted.\n+        python-dotenv still parsed it; ``set -a; . file`` failed.\n+        \"\"\"\n+        import subprocess\n+        from dotenv import dotenv_values\n+\n+        path = \"/Users/paulo/Library/Application Support/hermes/keys/id_ed25519\"\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"TERMINAL_SSH_KEY\", None)\n+            save_env_value(\"TERMINAL_SSH_KEY\", path)\n+\n+            env_path = tmp_path / \".env\"\n+            content = env_path.read_text(encoding=\"utf-8\")\n+            assert f'TERMINAL_SSH_KEY=\"{path}\"' in content\n+\n+            parsed = dotenv_values(str(env_path))\n+            assert parsed[\"TERMINAL_SSH_KEY\"] == path\n+            assert load_env()[\"TERMINAL_SSH_KEY\"] == path\n+\n+            # Shell source must round-trip (this is what the bug broke).\n+            r = subprocess.run(\n+                [\n+                    \"env\",\n+                    \"-i\",\n+                    \"sh\",\n+                    \"-c\",\n+                    f\"set -a; . '{env_path}'; set +a; \"\n+                    f'printf \"%s\" \"$TERMINAL_SSH_KEY\"',\n+                ],\n+                capture_output=True,\n+                text=True,\n+            )\n+            assert r.returncode == 0, r.stderr\n+            assert r.stderr == \"\"\n+            assert r.stdout == path\n+\n+    def test_save_env_value_quotes_values_with_tabs(self, tmp_path):\n+        \"\"\"Tabs trigger quoting; round-trip via dotenv and shell source.\"\"\"\n+        import subprocess\n+        from dotenv import dotenv_values\n+\n+        value = \"left\\tright\"\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"TABBY_KEY\", None)\n+            save_env_value(\"TABBY_KEY\", value)\n+\n+            env_path = tmp_path / \".env\"\n+            content = env_path.read_text(encoding=\"utf-8\")\n+            assert f'TABBY_KEY=\"{value}\"' in content\n+\n+            parsed = dotenv_values(str(env_path))\n+            assert parsed[\"TABBY_KEY\"] == value\n+            assert load_env()[\"TABBY_KEY\"] == value\n+\n+            r = subprocess.run(\n+                [\n+                    \"env\",\n+                    \"-i\",\n+                    \"sh\",\n+                    \"-c\",\n+                    f\"set -a; . '{env_path}'; set +a; \"\n+                    f'printf \"%s\" \"$TABBY_KEY\"',\n+                ],\n+                capture_output=True,\n+                text=True,\n+            )\n+            assert r.returncode == 0, r.stderr\n+            assert r.stderr == \"\"\n+            assert r.stdout == value\n+\n+    def test_save_env_value_spaced_path_is_idempotent(self, tmp_path):\n+        \"\"\"Saving the same spaced value twice must not grow quotes.\"\"\"\n+        path = \"/Users/me/Application Support/key\"\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"TERMINAL_SSH_KEY\", None)\n+            save_env_value(\"TERMINAL_SSH_KEY\", path)\n+            first = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+            save_env_value(\"TERMINAL_SSH_KEY\", path)\n+            second = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+\n+            assert first == second\n+            assert first.count('TERMINAL_SSH_KEY=\"') == 1\n+            assert '\"\"' not in first\n+            assert f'TERMINAL_SSH_KEY=\"{path}\"' in first\n+\n+    def test_save_env_value_readback_resave_is_idempotent(self, tmp_path):\n+        \"\"\"hermes setup path: dotenv unquotes, then re-save must not grow quotes.\"\"\"\n+        from dotenv import dotenv_values\n+\n+        path = \"/Users/me/Application Support/key\"\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"TERMINAL_SSH_KEY\", None)\n+            save_env_value(\"TERMINAL_SSH_KEY\", path)\n+            first = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+\n+            # Real read-back boundary (what setup uses via get_env_value/dotenv).\n+            read_back = dotenv_values(str(tmp_path / \".env\"))[\"TERMINAL_SSH_KEY\"]\n+            assert read_back == path\n+            save_env_value(\"TERMINAL_SSH_KEY\", read_back)\n+            second = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+\n+            assert first == second\n+            assert f'TERMINAL_SSH_KEY=\"{path}\"' in second\n+\n+    def test_save_env_value_strips_newlines_before_quoting(self, tmp_path):\n+        \"\"\"save_env_value strips \\\\n/\\\\r before _quote_env_value; result is one line.\n+\n+        Pins the boundary so any(c.isspace()) never quotes multi-line dotenv\n+        values through this writer (newlines never reach the quoter).\n+        \"\"\"\n+        from dotenv import dotenv_values\n+\n+        raw = \"line1\\nline2\\rline3\"\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"MULTI_KEY\", None)\n+            save_env_value(\"MULTI_KEY\", raw)\n+\n+            content = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+            # Single KEY= line, no embedded raw newlines in the value payload.\n+            lines = [ln for ln in content.splitlines() if ln.startswith(\"MULTI_KEY=\")]\n+            assert len(lines) == 1\n+            assert \"\\n\" not in lines[0]\n+            assert \"\\r\" not in lines[0]\n+            # Newlines stripped -> \"line1line2line3\" has no whitespace -> unquoted.\n+            assert lines[0] == \"MULTI_KEY=line1line2line3\"\n+            parsed = dotenv_values(str(tmp_path / \".env\"))\n+            assert parsed[\"MULTI_KEY\"] == \"line1line2line3\"\n+\n+    def test_save_env_value_simple_values_stay_unquoted(self, tmp_path):\n+        \"\"\"No quoting churn: plain values remain bare; untouched lines unchanged.\"\"\"\n+        env_path = tmp_path / \".env\"\n+        # Pre-existing lines: one simple, one already correctly bare.\n+        env_path.write_text(\n+            \"KEEP_SIMPLE=plainvalue\\n\"\n+            \"OTHER_KEY=foo123\\n\",\n+            encoding=\"utf-8\",\n+        )\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"NEW_KEY\", None)\n+            os.environ.pop(\"KEEP_SIMPLE\", None)\n+            save_env_value(\"NEW_KEY\", \"bar-simple\")\n+\n+            content = env_path.read_text(encoding=\"utf-8\")\n+            # Newly written simple value is unquoted.\n+            assert \"NEW_KEY=bar-simple\\n\" in content\n+            assert 'NEW_KEY=\"' not in content\n+            # Untouched pre-existing simple lines are not re-quoted.\n+            assert \"KEEP_SIMPLE=plainvalue\\n\" in content\n+            assert \"OTHER_KEY=foo123\\n\" in content\n+            assert 'KEEP_SIMPLE=\"' not in content\n+            assert 'OTHER_KEY=\"' not in content\n+\n+    def test_save_env_value_does_not_requote_untouched_spaced_lines(self, tmp_path):\n+        \"\"\"Mass-requote guard: rewriting another key leaves legacy spaced\n+        lines as-is (fix only applies when that key is saved again).\n+        \"\"\"\n+        env_path = tmp_path / \".env\"\n+        legacy = (\n+            \"TERMINAL_SSH_KEY=/Users/me/Application Support/key\\n\"\n+            \"PLAIN=ok\\n\"\n+        )\n+        env_path.write_text(legacy, encoding=\"utf-8\")\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"PLAIN\", None)\n+            save_env_value(\"PLAIN\", \"ok2\")\n+\n+            content = env_path.read_text(encoding=\"utf-8\")\n+            # Legacy spaced line not re-serialized by this write.\n+            assert (\n+                \"TERMINAL_SSH_KEY=/Users/me/Application Support/key\\n\" in content\n+            )\n+            assert 'TERMINAL_SSH_KEY=\"' not in content\n+            assert \"PLAIN=ok2\\n\" in content\n+\n+    def test_save_env_value_already_quoted_input_is_not_double_wrapped_idempotently(\n+        self, tmp_path\n+    ):\n+        \"\"\"Callers pass raw values; if a value literally contains quote\n+        characters, escaping+wrap is the dialect (#57249). Re-saving the\n+        same raw value is stable (no quote growth). load_env round-trips.\n+        \"\"\"\n+        # User-typed value that already includes surrounding quotes as data.\n+        raw = '\"/Users/me/Application Support/key\"'\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}, clear=False):\n+            os.environ.pop(\"TERMINAL_SSH_KEY\", None)\n+            save_env_value(\"TERMINAL_SSH_KEY\", raw)\n+            first = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+            save_env_value(\"TERMINAL_SSH_KEY\", raw)\n+            second = (tmp_path / \".env\").read_text(encoding=\"utf-8\")\n+            assert first == second\n+            # One outer wrap layer only (escaped inner quotes, not nested wraps).\n+            line = [\n+                ln for ln in first.splitlines() if ln.startswith(\"TERMINAL_SSH_KEY=\")\n+            ][0]\n+            assert line.startswith('TERMINAL_SSH_KEY=\"')\n+            assert line.endswith('\"')\n+            assert line.count('TERMINAL_SSH_KEY=\"') == 1\n+            # Escaping dialect end-to-end: load sees the raw input, not stripped quotes.\n+            assert load_env()[\"TERMINAL_SSH_KEY\"] == raw\n+\n \n class TestRemoveEnvValue:\n     def test_removes_key_from_env_file(self, tmp_path):","@@ -726,7 +930,7 @@ def test_atomic_write_creates_valid_yaml(self, tmp_path):\n \n             # Read raw YAML to verify it's valid and correct\n             config_path = tmp_path / \"config.yaml\"\n-            with open(config_path) as f:\n+            with open(config_path, encoding=\"utf-8\") as f:\n                 raw = yaml.safe_load(f)\n             assert raw[\"model\"] == \"test/atomic-model\"\n             assert raw[\"agent\"][\"max_turns\"] == 77"]},{"file":"tests/hermes_cli/test_env_loader.py","hunks":["@@ -1,3 +1,4 @@\n+import codecs\n import importlib\n import os\n import sys","@@ -103,3 +104,268 @@ def test_main_import_applies_user_env_over_shell_values(tmp_path, monkeypatch):\n \n     assert os.getenv(\"OPENAI_BASE_URL\") == \"https://new.example/v1\"\n     assert os.getenv(\"HERMES_INFERENCE_PROVIDER\") == \"custom\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# UTF-16 / UTF-32 .env sanitizer coverage\n+#\n+# Scope note: intentionally NO UTF-8-BOM assertions here. UTF-8 BOM handling\n+# for _load_dotenv_with_fallback is #65124's un-merged fix; a test here would\n+# couple the PRs. This suite covers only the sanitizer rewrite path for\n+# UTF-16/32 (and UTF-8 / cp1252 regression guards for that path).\n+# ---------------------------------------------------------------------------\n+\n+\n+def _assert_clean_utf8_env_on_disk(env_file, *, first_key: str) -> None:\n+    \"\"\"On-disk file must be clean UTF-8: no BOM, no U+FFFD, canonical key.\"\"\"\n+    after = env_file.read_bytes()\n+    assert not after.startswith(codecs.BOM_UTF8)\n+    assert not after.startswith(codecs.BOM_UTF16_LE)\n+    assert not after.startswith(codecs.BOM_UTF16_BE)\n+    text = after.decode(\"utf-8\")  # strict — raises if not clean UTF-8\n+    assert \"\\ufffd\" not in text\n+    assert text.startswith(f\"{first_key}=\") or f\"\\n{first_key}=\" in text\n+    assert first_key.encode(\"ascii\") in after\n+\n+\n+def test_utf16_le_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):\n+    \"\"\"Notepad 'Unicode' (UTF-16-LE + BOM): first key loads; file rewritten UTF-8.\"\"\"\n+    home = tmp_path / \"hermes\"\n+    home.mkdir()\n+    env_file = home / \".env\"\n+    content = \"HERMES_TEST_KEY=hello_utf16\\nSECOND_KEY=world\\n\"\n+    env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode(\"utf-16-le\"))\n+\n+    monkeypatch.delenv(\"HERMES_TEST_KEY\", raising=False)\n+    monkeypatch.delenv(\"SECOND_KEY\", raising=False)\n+    monkeypatch.delenv(\"\\ufffd\\ufffdHERMES_TEST_KEY\", raising=False)\n+\n+    loaded = load_hermes_dotenv(hermes_home=home)\n+\n+    assert loaded == [env_file]\n+    assert os.getenv(\"HERMES_TEST_KEY\") == \"hello_utf16\"\n+    assert os.getenv(\"SECOND_KEY\") == \"world\"\n+    assert os.environ.get(\"\\ufffd\\ufffdHERMES_TEST_KEY\") is None\n+    _assert_clean_utf8_env_on_disk(env_file, first_key=\"HERMES_TEST_KEY\")\n+\n+\n+def test_utf16_be_bom_env_loads_and_rewrites_clean_utf8(tmp_path, monkeypatch):\n+    \"\"\"UTF-16-BE + BOM: first key loads; file rewritten as clean UTF-8.\"\"\"\n+    home = tmp_path / \"hermes\"\n+    home.mkdir()\n+    env_file = home / \".env\"\n+    content = \"HERMES_TEST_KEY=hello_utf16\\nSECOND_KEY=world\\n\"\n+    env_file.write_bytes(codecs.BOM_UTF16_BE + content.encode(\"utf-16-be\"))\n+\n+    monkeypatch.delenv(\"HERMES_TEST_KEY\", raising=False)\n+    monkeypatch.delenv(\"SECOND_KEY\", raising=False)\n+\n+    loaded = load_hermes_dotenv(hermes_home=home)\n+\n+    assert loaded == [env_file]\n+    assert os.getenv(\"HERMES_TEST_KEY\") == \"hello_utf16\"\n+    assert os.getenv(\"SECOND_KEY\") == \"world\"\n+    _assert_clean_utf8_env_on_disk(env_file, first_key=\"HERMES_TEST_KEY\")\n+\n+\n+def test_utf16_le_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):\n+    \"\"\"BOM-less UTF-16-LE: NUL-strip repair is now intentional; rewrites UTF-8.\"\"\"\n+    home = tmp_path / \"hermes\"\n+    home.mkdir()\n+    env_file = home / \".env\"\n+    content = \"HERMES_TEST_KEY=hello_utf16\\nSECOND_KEY=world\\n\"\n+    env_file.write_bytes(content.encode(\"utf-16-le\"))  # no BOM\n+\n+    monkeypatch.delenv(\"HERMES_TEST_KEY\", raising=False)\n+    monkeypatch.delenv(\"SECOND_KEY\", raising=False)\n+\n+    loaded = load_hermes_dotenv(hermes_home=home)\n+\n+    assert loaded == [env_file]\n+    assert os.getenv(\"HERMES_TEST_KEY\") == \"hello_utf16\"\n+    assert os.getenv(\"SECOND_KEY\") == \"world\"\n+    _assert_clean_utf8_env_on_disk(env_file, first_key=\"HERMES_TEST_KEY\")\n+\n+\n+def test_utf16_be_no_bom_still_repairs_to_utf8(tmp_path, monkeypatch):\n+    \"\"\"BOM-less UTF-16-BE: NULs are on the opposite side; still repairs.\"\"\"\n+    home = tmp_path / \"hermes\"\n+    home.mkdir()\n+    env_file = home / \".env\"\n+    content = \"HERMES_TEST_KEY=hello_utf16\\nSECOND_KEY=world\\n\"\n+    env_file.write_bytes(content.encode(\"utf-16-be\"))  # no BOM\n+\n+    monkeypatch.delenv(\"HERMES_TEST_KEY\", raising=False)\n+    monkeypatch.delenv(\"SECOND_KEY\", raising=False)\n+\n+    loaded = load_hermes_dotenv(hermes_home=home)\n+\n+    assert loaded == [env_file]\n+    assert os.getenv(\"HERMES_TEST_KEY\") == \"hello_utf16\"\n+    assert os.getenv(\"SECOND_KEY\") == \"world\"\n+    _assert_clean_utf8_env_on_disk(env_file, first_key=\"HERMES_TEST_KEY\")\n+\n+\n+def test_utf16_le_bom_preserves_non_ascii_values(tmp_path, monkeypatch):\n+    \"\"\"UTF-16-LE+BOM rewrite must preserve non-ASCII values (not just ASCII keys).\n+\n+    Uses non-credential var names so _sanitize_loaded_credentials does not\n+    strip non-ASCII from values (that path only targets *_KEY/*_TOKEN/etc.).\n+    \"\"\"\n+    home = tmp_path / \"hermes\"\n+    home.mkdir()\n+    env_file = home / \".env\"\n+    content = \"GREETING=café\\nCJK_LABEL=日本語\\n\"\n+    env_file.write_bytes(codecs.BOM_UTF16_LE + content.encode(\"utf-16-le\"))\n+\n+    monkeypatch.delenv(\"GREETING\", raising=False)\n+    monkeypatch.delenv(\"CJK_LABEL\", raising=False)\n+\n+    loaded = load_hermes_dotenv(hermes_home=home)\n+\n+    assert loaded == [env_file]\n+    assert os.getenv(\"GREETING\") == \"café\"\n+    assert os.getenv(\"CJK_LABEL\") == \"日本語\"\n+    after = env_file.read_bytes()\n+    assert after.decode(\"utf-8\")  # strict\n+    assert \"café\".encode(\"utf-8\") in after\n+    assert \"日本語\".encode(\"utf-8\") in after\n+    assert b\"\\xef\\xbf\\xbd\" not in after\n+\n+\n+def test_utf32_le_bom_leaves_file_untouched(tmp_path, caplog):\n+    \"\"\"UTF-32-LE BOM: refuse-to-mangle (leave bytes untouched + warning).\n+\n+    UTF-32-LE's BOM starts with UTF-16-LE's FF FE; sniff order must check\n+    UTF-32 first so we never misdetect and corrupt.\n+\n+    Exercises ``_sanitize_env_file_if_needed`` only: the dotenv load path\n+    is out of scope here (#65124's surface) and still cannot ingest UTF-32.\n+    \"\"\"\n+    import logging\n+\n+    from hermes_cli.env_loader import _sanitize_env_file_if_needed\n+\n+    env_file = tmp_path / \".env\"\n+    content = \"HERMES_TEST_KEY=hello_utf32\\nSECOND_KEY=world\\n\"\n+    raw = codecs.BOM_UTF32_LE + content.encode(\"utf-32-le\")\n+    env_file.write_bytes(raw)\n+\n+    with caplog.at_level(logging.WARNING, logger=\"hermes_cli.env_loader\"):\n+        _sanitize_env_file_if_needed(env_file)\n+\n+    assert env_file.read_bytes() == raw  # untouched\n+    assert any(\"UTF-32\" in r.message for r in caplog.records)\n+\n+\n+def test_utf32_be_bom_leaves_file_untouched(tmp_path, caplog):\n+    \"\"\"UTF-32-BE BOM: same refuse-to-mangle path as LE (ordering independence).\"\"\"\n+    import logging\n+\n+    from hermes_cli.env_loader import _sanitize_env_file_if_needed\n+\n+    env_file = tmp_path / \".env\"\n+    content = \"HERMES_TEST_KEY=hello_utf32\\nSECOND_KEY=world\\n\"\n+    raw = codecs.BOM_UTF32_BE + content.encode(\"utf-32-be\")\n+    env_file.write_bytes(raw)\n+\n+    with caplog.at_level(logging.WARNING, logger=\"hermes_cli.env_loader\"):\n+        _sanitize_env_file_if_needed(env_file)\n+\n+    assert env_file.read_bytes() == raw\n+    assert any(\"UTF-32\" in r.message for r in caplog.records)\n+\n+\n+def test_utf32_warning_fires_once_per_path(tmp_path, caplog, monkeypatch):\n+    \"\"\"Three sanitize calls on the same UTF-32 file → exactly one warning.\n+\n+    Matches house style for warn-once (module-level seen-set, same class as\n+    ``_WARNED_KEYS``): hot-reload / multi-entry load must not spam logs.\n+    \"\"\"\n+    import logging\n+\n+    import hermes_cli.env_loader as env_loader\n+    from hermes_cli.env_loader import _sanitize_env_file_if_needed\n+\n+    # Isolate process-level seen-set so other tests' paths don't leak in.\n+    monkeypatch.setattr(env_loader, \"_WARNED_UTF32_PATHS\", set())\n+\n+    env_file = tmp_path / \".env\"\n+    content = \"HERMES_TEST_KEY=hello_utf32\\nSECOND_KEY=world\\n\"\n+    raw = codecs.BOM_UTF32_LE + content.encode(\"utf-32-le\")\n+    env_file.write_bytes(raw)\n+\n+    with caplog.at_level(logging.WARNING, logger=\"hermes_cli.env_loader\"):\n+        _sanitize_env_file_if_needed(env_file)\n+        _sanitize_env_file_if_needed(env_file)\n+        _sanitize_env_file_if_needed(env_file)\n+\n+    utf32_warnings = [r for r in caplog.records if \"UTF-32\" in r.message]\n+    assert len(utf32_warnings) == 1\n+    assert env_file.read_bytes() == raw\n+\n+\n+def test_leading_replacement_char_does_not_rewrite(tmp_path):\n+    \"\"\"errors=replace FFFD-on-first-line guard: do not persist mangling.\n+\n+    Leading 0xFF is not a UTF-16/32 BOM (those need the second BOM byte) but\n+    is undecodable as UTF-8, so the replace path would glue U+FFFD onto the\n+    key. The guard must leave the on-disk bytes untouched.\n+    \"\"\"\n+    from hermes_cli.env_loader import _sanitize_env_file_if_needed\n+\n+    env_file = tmp_path / \".env\"\n+    raw = b\"\\xffHERMES_TEST_KEY=should-not-rewrite\\nSECOND_KEY=ok\\n\"\n+    env_file.write_bytes(raw)\n+\n+    _sanitize_env_file_if_needed(env_file)\n+\n+    assert env_file.read_bytes() == raw\n+\n+\n+def test_plain_utf8_env_regression(tmp_path, monkeypatch):\n+    \"\"\"Plain UTF-8 .env must keep loading after the UTF-16 sanitize changes.\"\"\"\n+    home = tmp_path / \"hermes\"\n+    home.mkdir()\n+    env_file = home / \".env\"\n+    before = b\"OPENAI_API_KEY=[redacted:secret-assignment]\"\n+    env_file.write_bytes(before)\n+\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    monkeypatch.delenv(\"SECOND_KEY\", raising=False)\n+\n+    loaded = load_hermes_dotenv(hermes_home=home)\n+\n+    assert loaded == [env_file]\n+    assert os.getenv(\"OPENAI_API_KEY\") == \"sk-plain\"\n+    assert os.getenv(\"SECOND_KEY\") == \"ok\"\n+    # No spurious rewrite of an already-clean file.\n+    assert env_file.read_bytes() == before\n+\n+\n+def test_cp1252_env_regression_does_not_crash(tmp_path, monkeypatch):\n+    \"\"\"cp1252/latin-1 body must not crash sanitize; ASCII keys still usable.\n+\n+    0xE9 is 'é' in cp1252 and incomplete as UTF-8. First line does not begin\n+    with U+FFFD, so the FFFD guard must not refuse the whole file.\n+\n+    Sanitize leaves the file bytes alone when the only \"change\" is\n+    errors=replace on values (original already replace-decoded equals\n+    sanitized), so _load_dotenv_with_fallback's latin-1 path recovers café.\n+    \"\"\"\n+    home = tmp_path / \"hermes\"\n+    home.mkdir()\n+    env_file = home / \".env\"\n+    before = b\"ASCII_KEY=ok\\nLATIN1_VALUE=caf\\xe9\\n\"\n+    env_file.write_bytes(before)\n+\n+    monkeypatch.delenv(\"ASCII_KEY\", raising=False)\n+    monkeypatch.delenv(\"LATIN1_VALUE\", raising=False)\n+\n+    loaded = load_hermes_dotenv(hermes_home=home)\n+\n+    assert loaded == [env_file]\n+    assert os.getenv(\"ASCII_KEY\") == \"ok\"\n+    assert os.getenv(\"LATIN1_VALUE\") == \"café\"\n+    # Sanitize must not have rewritten (would have persisted U+FFFD).\n+    assert env_file.read_bytes() == before\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}