fix(memory): cover the remaining setup-time .env reads with utf-8-sig

Follow-up to review feedback:

- mem0 _prompt_api_key read .env with the locale default, so a Notepad
  BOM hid the first key from the masked current-value lookup; read it
  with utf-8-sig + errors=replace like the canonical readers in
  hermes_cli/config.py.
- hindsight _load_simple_env used plain utf-8; it also parses the Hermes
  .env during post_setup, where a BOM stuck to the first key. Switch to
  utf-8-sig + errors=replace.
- Add hindsight regressions: BOM key matching in _load_simple_env and in
  the cloud post_setup writer, plus non-ASCII round-trip preservation,
  and a mem0 regression for the BOM'd masked-key lookup. The BOM tests
  fail without the fix on any platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
solyanviktor-star 2026-07-12 15:34:51 +03:00 committed by Teknium
parent 75afc47baa
commit f1ea4a56c2
4 changed files with 92 additions and 2 deletions

View file

@ -496,7 +496,9 @@ def _load_simple_env(path) -> dict[str, str]:
return {}
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
# utf-8-sig, not plain utf-8: this is also used on the Hermes .env during
# post_setup, and a Notepad BOM would otherwise stick to the first key.
for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)

View file

@ -500,7 +500,12 @@ def _prompt_api_key(label: str, env_var: str, hermes_home: str) -> str:
if not existing:
env_path = Path(hermes_home) / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
# BOM-tolerant read matching the canonical .env readers in
# hermes_cli/config.py; a Notepad BOM on the first line would
# otherwise defeat the startswith() key match below.
for line in env_path.read_text(
encoding="utf-8-sig", errors="replace"
).splitlines():
if line.startswith(f"{env_var}="):
existing = line.split("=", 1)[1].strip()
break

View file

@ -22,6 +22,7 @@ from plugins.memory.hindsight import (
REFLECT_SCHEMA,
RETAIN_SCHEMA,
_load_config,
_load_simple_env,
_build_embedded_profile_env,
_normalize_observation_scopes,
_normalize_retain_tags,
@ -1819,3 +1820,62 @@ def test_save_config_sets_owner_only_permissions(tmp_path):
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"
class TestLoadSimpleEnv:
def test_bom_first_key_is_recognized(self, tmp_path):
"""A Notepad-edited .env carries a BOM; the first key must still parse
instead of becoming '\ufeffHINDSIGHT_LLM_API_KEY'."""
env_path = tmp_path / ".env"
env_path.write_bytes("HINDSIGHT_LLM_API_KEY=sk-test\n".encode("utf-8"))
values = _load_simple_env(env_path)
assert values.get("HINDSIGHT_LLM_API_KEY") == "sk-test"
def test_non_ascii_values_read_intact(self, tmp_path):
env_path = tmp_path / ".env"
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
values = _load_simple_env(env_path)
assert values["PROXY_NOTE"] == "café-zürich-完了"
class TestPostSetupEnvEncoding:
def _run_cloud_post_setup(self, tmp_path, monkeypatch):
"""Drive post_setup through the cloud path with piped stdin."""
import io
import shutil as shutil_mod
monkeypatch.setattr("hermes_cli.memory_setup._curses_select",
lambda *a, **kw: 0) # cloud mode
monkeypatch.setattr("hermes_cli.config.save_config", lambda c: None)
monkeypatch.setattr(shutil_mod, "which", lambda *_: None) # skip uv install
# First line: API key prompt (readline). Second line: API URL (input).
monkeypatch.setattr(sys, "stdin", io.StringIO("sk-new\n\n"))
provider = HindsightMemoryProvider()
provider.post_setup(str(tmp_path), {"memory": {}})
def test_bom_first_key_updated_in_place(self, tmp_path, monkeypatch):
"""The setup writer reads the existing .env BOM-tolerantly, so a
BOM'd first key is matched and rewritten, not duplicated."""
env_path = tmp_path / ".env"
env_path.write_bytes("HINDSIGHT_API_KEY=old\n".encode("utf-8"))
self._run_cloud_post_setup(tmp_path, monkeypatch)
content = env_path.read_text(encoding="utf-8")
assert content.count("HINDSIGHT_API_KEY=") == 1
assert "HINDSIGHT_API_KEY=sk-new" in content
assert "old" not in content
assert "" not in content
def test_non_ascii_lines_survive_round_trip(self, tmp_path, monkeypatch):
"""Unrelated non-ASCII .env content must be copied through as UTF-8
(the locale codec would crash or mangle it on Windows)."""
env_path = tmp_path / ".env"
env_path.write_bytes("PROXY_NOTE=café-zürich-完了\n".encode("utf-8"))
self._run_cloud_post_setup(tmp_path, monkeypatch)
content = env_path.read_text(encoding="utf-8")
assert "PROXY_NOTE=café-zürich-完了" in content
assert "HINDSIGHT_API_KEY=sk-new" in content

View file

@ -11,6 +11,7 @@ from plugins.memory.mem0._setup import (
parse_flags,
build_oss_config,
_write_env,
_prompt_api_key,
post_setup,
_check_qdrant_path,
_check_ollama,
@ -202,6 +203,28 @@ class TestWriteEnv:
assert "OPENAI_API_KEY=new" in content
class TestPromptApiKey:
def test_existing_key_found_behind_bom(self, tmp_path, monkeypatch):
"""The masked-current-value lookup must see a key on the BOM'd first
line of a Notepad-edited .env instead of prompting from scratch."""
env_path = tmp_path / ".env"
env_path.write_bytes("OPENAI_API_KEY=sk-existing\n".encode("utf-8"))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
prompts: list[str] = []
def _fake_getpass(prompt):
prompts.append(prompt)
return ""
monkeypatch.setattr("plugins.memory.mem0._setup.getpass.getpass", _fake_getpass)
_prompt_api_key("OpenAI", "OPENAI_API_KEY", str(tmp_path))
assert len(prompts) == 1
assert "current: ...ting" in prompts[0]
class TestPostSetup:
def test_platform_flag_mode(self, tmp_path, monkeypatch):