From aa1fac980d8d2957a0a74b5aed2efb416fcf667f Mon Sep 17 00:00:00 2001 From: Paulo Nascimento Date: Wed, 15 Jul 2026 13:14:46 -0400 Subject: [PATCH] fix(cli): read .env as utf-8-sig so a BOM doesn't drop the first key PowerShell 5.1 Set-Content -Encoding UTF8 and Windows Notepad write a UTF-8 BOM. load_dotenv(encoding="utf-8") kept U+FEFF on the first key name, so the canonical name was absent from os.environ and Hermes looked unconfigured with no error. utf-8-sig strips the BOM and is a no-op for BOM-less UTF-8; latin-1 fallback unchanged. --- hermes_cli/env_loader.py | 6 ++- tests/hermes_cli/test_env_loader.py | 79 +++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 5f309827cc9d7..f54c945931524 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -341,7 +341,11 @@ def _sanitize_loaded_credentials() -> None: def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None: try: - load_dotenv(dotenv_path=path, override=override, encoding="utf-8") + # utf-8-sig strips a leading UTF-8 BOM if present (PowerShell 5.1 + # Set-Content -Encoding UTF8 / Notepad) and is a no-op for BOM-less + # UTF-8. Plain "utf-8" would keep U+FEFF on the first key name and + # silently drop it from os.environ under its canonical name. + load_dotenv(dotenv_path=path, override=override, encoding="utf-8-sig") except UnicodeDecodeError: load_dotenv(dotenv_path=path, override=override, encoding="latin-1") # Strip non-ASCII characters from credential env vars that were just diff --git a/tests/hermes_cli/test_env_loader.py b/tests/hermes_cli/test_env_loader.py index 0546c9957eb54..2b3dbea1aca47 100644 --- a/tests/hermes_cli/test_env_loader.py +++ b/tests/hermes_cli/test_env_loader.py @@ -6,21 +6,92 @@ import sys from hermes_cli.env_loader import load_hermes_dotenv +def test_utf8_bom_does_not_mangle_first_key(tmp_path, monkeypatch): + """A leading UTF-8 BOM must not prefix the first key name in os.environ. + + PowerShell 5.1 ``Set-Content -Encoding UTF8`` and Windows Notepad write + a BOM (EF BB BF). With encoding=utf-8, python-dotenv keeps U+FEFF on the + first key so the canonical name is absent and callers see "not configured". + """ + home = tmp_path / "hermes" + home.mkdir() + env_file = home / ".env" + env_file.write_bytes( + b"\xef\xbb\xbfFIRST_KEY=first-value\nSECOND_KEY=second-value\n" + ) + + monkeypatch.delenv("FIRST_KEY", raising=False) + monkeypatch.delenv("SECOND_KEY", raising=False) + monkeypatch.delenv("\ufeffFIRST_KEY", raising=False) + + loaded = load_hermes_dotenv(hermes_home=home) + + assert loaded == [env_file] + assert os.getenv("FIRST_KEY") == "first-value" + assert os.getenv("SECOND_KEY") == "second-value" + assert os.environ.get("\ufeffFIRST_KEY") is None +def test_bomless_utf8_env_still_loads(tmp_path, monkeypatch): + """BOM-less UTF-8 .env files must keep loading after utf-8-sig.""" + home = tmp_path / "hermes" + home.mkdir() + env_file = home / ".env" + env_file.write_text("OPENAI_API_KEY=sk-plain\nSECOND_KEY=ok\n", encoding="utf-8") + + 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" +def test_latin1_env_falls_back(tmp_path, monkeypatch): + """Invalid UTF-8 bytes must still load via the latin-1 fallback.""" + home = tmp_path / "hermes" + home.mkdir() + env_file = home / ".env" + # 0xE9 is "é" in latin-1 and not a valid UTF-8 lead sequence alone. + env_file.write_bytes(b"LATIN1_VALUE=caf\xe9\n") + + monkeypatch.delenv("LATIN1_VALUE", raising=False) + + loaded = load_hermes_dotenv(hermes_home=home) + + assert loaded == [env_file] + assert os.getenv("LATIN1_VALUE") == "café" +def test_utf8_bom_preserves_first_api_key_name(tmp_path, monkeypatch): + """Real-world case: BOM + first line is a provider API key name.""" + home = tmp_path / "hermes" + home.mkdir() + env_file = home / ".env" + env_file.write_bytes( + b"\xef\xbb\xbfANTHROPIC_API_KEY=sk-test-123\nSECOND_KEY=ok\n" + ) + + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("SECOND_KEY", raising=False) + monkeypatch.delenv("\ufeffANTHROPIC_API_KEY", raising=False) + + loaded = load_hermes_dotenv(hermes_home=home) + + assert loaded == [env_file] + assert os.getenv("ANTHROPIC_API_KEY") == "sk-test-123" + assert os.getenv("SECOND_KEY") == "ok" + assert os.environ.get("\ufeffANTHROPIC_API_KEY") is None # --------------------------------------------------------------------------- # 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). +# UTF-8 BOM handling for _load_dotenv_with_fallback is covered above (#65124). +# This section covers the sanitizer rewrite path for UTF-16/32 (and UTF-8 / +# cp1252 regression guards for that path). # ---------------------------------------------------------------------------