2026-03-15 06:46:28 -07:00
|
|
|
|
"""Helpers for loading Hermes .env files consistently across entrypoints."""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_dotenv_with_fallback(path: Path, *, override: bool) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
load_dotenv(dotenv_path=path, override=override, encoding="utf-8")
|
|
|
|
|
|
except UnicodeDecodeError:
|
|
|
|
|
|
load_dotenv(dotenv_path=path, override=override, encoding="latin-1")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 18:41:12 +08:00
|
|
|
|
def _sanitize_env_file_if_needed(path: Path) -> None:
|
|
|
|
|
|
"""Pre-sanitize a .env file before python-dotenv reads it.
|
|
|
|
|
|
|
|
|
|
|
|
python-dotenv does not handle corrupted lines where multiple
|
|
|
|
|
|
KEY=VALUE pairs are concatenated on a single line (missing newline).
|
|
|
|
|
|
This produces mangled values — e.g. a bot token duplicated 8×
|
|
|
|
|
|
(see #8908).
|
|
|
|
|
|
|
|
|
|
|
|
We delegate to ``hermes_cli.config._sanitize_env_lines`` which
|
|
|
|
|
|
already knows all valid Hermes env-var names and can split
|
|
|
|
|
|
concatenated lines correctly.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
from hermes_cli.config import _sanitize_env_lines
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
return # early bootstrap — config module not available yet
|
|
|
|
|
|
|
|
|
|
|
|
read_kw = {"encoding": "utf-8", "errors": "replace"}
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(path, **read_kw) as f:
|
|
|
|
|
|
original = f.readlines()
|
|
|
|
|
|
sanitized = _sanitize_env_lines(original)
|
|
|
|
|
|
if sanitized != original:
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
fd, tmp = tempfile.mkstemp(
|
|
|
|
|
|
dir=str(path.parent), suffix=".tmp", prefix=".env_"
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
|
|
|
|
f.writelines(sanitized)
|
|
|
|
|
|
f.flush()
|
|
|
|
|
|
os.fsync(f.fileno())
|
|
|
|
|
|
os.replace(tmp, path)
|
|
|
|
|
|
except BaseException:
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.unlink(tmp)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass # best-effort — don't block gateway startup
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 06:46:28 -07:00
|
|
|
|
def load_hermes_dotenv(
|
|
|
|
|
|
*,
|
|
|
|
|
|
hermes_home: str | os.PathLike | None = None,
|
|
|
|
|
|
project_env: str | os.PathLike | None = None,
|
|
|
|
|
|
) -> list[Path]:
|
|
|
|
|
|
"""Load Hermes environment files with user config taking precedence.
|
|
|
|
|
|
|
|
|
|
|
|
Behavior:
|
|
|
|
|
|
- `~/.hermes/.env` overrides stale shell-exported values when present.
|
|
|
|
|
|
- project `.env` acts as a dev fallback and only fills missing values when
|
|
|
|
|
|
the user env exists.
|
|
|
|
|
|
- if no user env exists, the project `.env` also overrides stale shell vars.
|
|
|
|
|
|
"""
|
|
|
|
|
|
loaded: list[Path] = []
|
|
|
|
|
|
|
|
|
|
|
|
home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
|
|
|
|
|
user_env = home_path / ".env"
|
|
|
|
|
|
project_env_path = Path(project_env) if project_env else None
|
|
|
|
|
|
|
2026-04-13 18:41:12 +08:00
|
|
|
|
# Fix corrupted .env files before python-dotenv parses them (#8908).
|
|
|
|
|
|
if user_env.exists():
|
|
|
|
|
|
_sanitize_env_file_if_needed(user_env)
|
|
|
|
|
|
|
2026-03-15 06:46:28 -07:00
|
|
|
|
if user_env.exists():
|
|
|
|
|
|
_load_dotenv_with_fallback(user_env, override=True)
|
|
|
|
|
|
loaded.append(user_env)
|
|
|
|
|
|
|
|
|
|
|
|
if project_env_path and project_env_path.exists():
|
|
|
|
|
|
_load_dotenv_with_fallback(project_env_path, override=not loaded)
|
|
|
|
|
|
loaded.append(project_env_path)
|
|
|
|
|
|
|
|
|
|
|
|
return loaded
|