From 7fef76a6cfb1da34e0dc00a0f928726fb8d870bc Mon Sep 17 00:00:00 2001 From: solyanviktor-star <233359899+solyanviktor-star@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:29:05 +0300 Subject: [PATCH] fix(auth): read .env as utf-8-sig in the dotenv-vs-shell detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _remove_env_source() decides whether a credential var lives in ~/.hermes/.env or the shell by scanning the .env with env_path.read_text(errors="replace") — no encoding. read_text() with no encoding falls back to the system locale (cp1252/GBK on Windows) and never strips a BOM. The canonical .env readers in hermes_cli/config.py all use encoding="utf-8-sig" precisely because 'users may edit .env in Notepad which adds one' (a BOM), and doctor.py documents that .env is written as UTF-8 everywhere. This sibling reader diverged: on a Notepad-edited .env the BOM prefixes the first line, so line.strip().startswith(f"{env_var}=") is False for the first variable — the detector reports a .env-backed key as a phantom shell export and prints a misleading 'still set in your shell environment' hint on . Match the canonical reader (utf-8-sig + errors=replace). Adds a regression test with a BOM'd .env. Co-Authored-By: Claude Fable 5 --- agent/credential_sources.py | 10 ++++++- tests/hermes_cli/test_auth_commands.py | 41 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/agent/credential_sources.py b/agent/credential_sources.py index 32cd5e01a8..4c0e35d588 100644 --- a/agent/credential_sources.py +++ b/agent/credential_sources.py @@ -162,9 +162,17 @@ def _remove_env_source(provider: str, removed) -> RemovalResult: try: env_path = get_env_path() if env_path.exists(): + # Read the .env as UTF-8 with BOM tolerance, matching the + # canonical reader in hermes_cli/config.py. read_text() with no + # encoding falls back to the system locale (cp1252/GBK on Windows) + # and never strips a BOM, so a Notepad-edited .env (BOM + non-ASCII + # values) would make the first line fail the startswith() check — + # misreporting a .env-backed var as a shell export. env_in_dotenv = any( line.strip().startswith(f"{env_var}=") - for line in env_path.read_text(errors="replace", encoding="utf-8").splitlines() + for line in env_path.read_text( + encoding="utf-8-sig", errors="replace" + ).splitlines() ) except OSError: pass diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 3da85849c0..939be3674e 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -769,3 +769,44 @@ def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch): assert is_source_suppressed("copilot", "env:GITHUB_TOKEN") +def test_auth_remove_env_seeded_dotenv_with_bom_no_shell_hint(tmp_path, monkeypatch, capsys): + """A Notepad-edited .env carries a UTF-8 BOM. The dotenv-vs-shell + detector must still see the first variable as living in .env (and not + warn about a phantom shell export). Regression for the reader that + dropped encoding='utf-8-sig' and misread the BOM'd first line. + """ + hermes_home = tmp_path / "hermes" + hermes_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + # BOM prefix (utf-8-sig) + the target var as the FIRST line. + (hermes_home / ".env").write_bytes( + b"\xef\xbb\xbfDEEPSEEK_API_KEY=sk-ds-only\n" + ) + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-ds-only") + + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "deepseek": [{ + "id": "env-1", + "label": "DEEPSEEK_API_KEY", + "auth_type": "api_key", + "priority": 0, + "source": "env:DEEPSEEK_API_KEY", + "access_token": "sk-ds-only", + }] + }, + }, + ) + + from types import SimpleNamespace + from hermes_cli.auth_commands import auth_remove_command + auth_remove_command(SimpleNamespace(provider="deepseek", target="1")) + + out = capsys.readouterr().out + assert "Cleared DEEPSEEK_API_KEY from .env" in out + assert "still set in your shell environment" not in out