fix(cli): apply BOM-safe .env decoding to hermes send's private loader

send_cmd._load_hermes_env intentionally reimplements a minimal dotenv
load (no secret-source pulls, no sanitize rewrite, get_hermes_home path
resolution incl. Windows/profile override), so the shared-loader BOM fix
is mirrored in place: utf-8-sig primary read, BOM strip before the
latin-1 stream fallback.

Claude-Session: https://claude.ai/code/session_01JPmJz5u1Bvtw4cCRvRWnYr
This commit is contained in:
Paulo Nascimento 2026-07-16 10:52:58 -04:00 committed by Teknium
parent b76498ba07
commit ece678db97
2 changed files with 155 additions and 2 deletions

View File

@ -263,10 +263,22 @@ def _load_hermes_env() -> None:
env_path = home / ".env"
if load_dotenv and env_path.exists():
try:
load_dotenv(str(env_path), override=True, 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(str(env_path), override=True, encoding="utf-8-sig")
except UnicodeDecodeError:
try:
load_dotenv(str(env_path), override=True, encoding="latin-1")
# utf-8-sig can't strip a BOM once we fall back to latin-1.
import codecs
import io
raw = env_path.read_bytes()
if raw.startswith(codecs.BOM_UTF8):
raw = raw[len(codecs.BOM_UTF8) :]
load_dotenv(stream=io.StringIO(raw.decode("latin-1")), override=True)
except Exception:
pass
except Exception:

View File

@ -234,3 +234,144 @@ def test_load_hermes_env_bridges_config_yaml_scalars(tmp_path, monkeypatch):
assert os.environ.get("TELEGRAM_HOME_CHANNEL") == "5550001111"
def test_load_hermes_env_utf8_bom_preserves_first_key(tmp_path, monkeypatch):
"""A leading UTF-8 BOM must not mangle the first .env key name.
PowerShell 5.1 `Set-Content -Encoding UTF8` and Notepad prepend a BOM
(EF BB BF). With encoding=utf-8, python-dotenv kept U+FEFF on the first
key, so the credential never appeared under its canonical name and
`hermes send` failed to authenticate.
"""
import os
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / ".env").write_bytes(
b"\xef\xbb\xbfSEND_BOM_BOT_TOKEN=tok-first\nSEND_BOM_SECOND=two\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SEND_BOM_BOT_TOKEN", raising=False)
monkeypatch.delenv("SEND_BOM_SECOND", raising=False)
from importlib import reload
import hermes_cli.config as _hc_config
reload(_hc_config)
send_cmd._load_hermes_env()
assert os.environ.get("SEND_BOM_BOT_TOKEN") == "tok-first"
assert os.environ.get("SEND_BOM_SECOND") == "two"
assert "\ufeff" + "SEND_BOM_BOT_TOKEN" not in os.environ
def test_load_hermes_env_bomless_utf8_still_loads(tmp_path, monkeypatch):
"""BOM-less UTF-8 .env files must keep loading after the utf-8-sig switch."""
import os
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / ".env").write_bytes(b"SEND_PLAIN_TOKEN=plain-val\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SEND_PLAIN_TOKEN", raising=False)
from importlib import reload
import hermes_cli.config as _hc_config
reload(_hc_config)
send_cmd._load_hermes_env()
assert os.environ.get("SEND_PLAIN_TOKEN") == "plain-val"
def test_load_hermes_env_latin1_fallback_still_loads(tmp_path, monkeypatch):
"""Invalid UTF-8 bytes must still load via the latin-1 fallback path,
and a leading BOM must be stripped before the latin-1 decode so the
first key keeps its canonical name."""
import os
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# BOM + valid first key + latin-1 é (0xE9, invalid UTF-8 alone) in a
# later value — forces the UnicodeDecodeError → latin-1 stream path.
(hermes_home / ".env").write_bytes(
b"\xef\xbb\xbfSEND_L1_TOKEN=tok-l1\nSEND_L1_NOTE=caf\xe9\n"
)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.delenv("SEND_L1_TOKEN", raising=False)
monkeypatch.delenv("SEND_L1_NOTE", raising=False)
from importlib import reload
import hermes_cli.config as _hc_config
reload(_hc_config)
send_cmd._load_hermes_env()
assert os.environ.get("SEND_L1_TOKEN") == "tok-l1"
assert os.environ.get("SEND_L1_NOTE") == "caf\xe9"
assert "\ufeff" + "SEND_L1_TOKEN" not in os.environ
def test_load_hermes_env_latin1_fallback_overrides_shell(tmp_path, monkeypatch):
"""The stream-based latin-1 fallback must keep override=True semantics:
the .env value wins over a stale shell export, same as the primary path."""
import os
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# 0xE9 forces the UnicodeDecodeError \u2192 latin-1 stream fallback.
(hermes_home / ".env").write_bytes(b"SEND_OVR_TOKEN=caf\xe9-file\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
monkeypatch.setenv("SEND_OVR_TOKEN", "stale-shell-value")
from importlib import reload
import hermes_cli.config as _hc_config
reload(_hc_config)
send_cmd._load_hermes_env()
assert os.environ.get("SEND_OVR_TOKEN") == "caf\xe9-file"
def test_load_hermes_env_fallback_read_error_is_swallowed(tmp_path, monkeypatch):
"""An I/O error inside the latin-1 fallback must not escape \u2014 the send
path is best-effort by design and must never crash on a broken .env."""
from pathlib import Path
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
# Invalid UTF-8 so the fallback (and its read_bytes call) is reached.
(hermes_home / ".env").write_bytes(b"SEND_ERR_TOKEN=caf\xe9\n")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
def _boom(self):
raise OSError("disk went away")
monkeypatch.setattr(Path, "read_bytes", _boom)
from importlib import reload
import hermes_cli.config as _hc_config
reload(_hc_config)
# Should not raise.
send_cmd._load_hermes_env()
def test_load_hermes_env_bom_only_env_is_noop(tmp_path, monkeypatch):
"""A .env containing only a BOM must load zero vars without error."""
import os
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / ".env").write_bytes(b"\xef\xbb\xbf")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from importlib import reload
import hermes_cli.config as _hc_config
reload(_hc_config)
before = dict(os.environ)
send_cmd._load_hermes_env()
added = {k: v for k, v in os.environ.items() if k not in before}
assert "\ufeff" not in "".join(added)