| """Helpers for loading Hermes .env files consistently across entrypoints.""" |
|
|
| from __future__ import annotations |
|
|
| import os |
| import sys |
| from pathlib import Path |
|
|
| from dotenv import load_dotenv |
|
|
|
|
| |
| |
| |
| |
| _CREDENTIAL_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY") |
|
|
| |
| |
| |
| _WARNED_KEYS: set[str] = set() |
|
|
|
|
| def _format_offending_chars(value: str, limit: int = 3) -> str: |
| """Return a compact 'U+XXXX ('c'), ...' summary of non-ASCII codepoints.""" |
| seen: list[str] = [] |
| for ch in value: |
| if ord(ch) > 127: |
| label = f"U+{ord(ch):04X}" |
| if ch.isprintable(): |
| label += f" ({ch!r})" |
| if label not in seen: |
| seen.append(label) |
| if len(seen) >= limit: |
| break |
| return ", ".join(seen) |
|
|
|
|
| def _sanitize_loaded_credentials() -> None: |
| """Strip non-ASCII characters from credential env vars in os.environ. |
| |
| Called after dotenv loads so the rest of the codebase never sees |
| non-ASCII API keys. Only touches env vars whose names end with |
| known credential suffixes (``_API_KEY``, ``_TOKEN``, etc.). |
| |
| Emits a one-line warning to stderr when characters are stripped. |
| Silent stripping would mask copy-paste corruption (Unicode lookalike |
| glyphs from PDFs / rich-text editors, ZWSP from web pages) as opaque |
| provider-side "invalid API key" errors (see #6843). |
| """ |
| for key, value in list(os.environ.items()): |
| if not any(key.endswith(suffix) for suffix in _CREDENTIAL_SUFFIXES): |
| continue |
| try: |
| value.encode("ascii") |
| continue |
| except UnicodeEncodeError: |
| pass |
| cleaned = value.encode("ascii", errors="ignore").decode("ascii") |
| os.environ[key] = cleaned |
| if key in _WARNED_KEYS: |
| continue |
| _WARNED_KEYS.add(key) |
| stripped = len(value) - len(cleaned) |
| detail = _format_offending_chars(value) or "non-printable" |
| print( |
| f" Warning: {key} contained {stripped} non-ASCII character" |
| f"{'s' if stripped != 1 else ''} ({detail}) — stripped so the " |
| f"key can be sent as an HTTP header.", |
| file=sys.stderr, |
| ) |
| print( |
| " This usually means the key was copy-pasted from a PDF, " |
| "rich-text editor, or web page that substituted lookalike\n" |
| " Unicode glyphs for ASCII letters. If authentication fails " |
| "(e.g. \"API key not valid\"), re-copy the key from the\n" |
| " provider's dashboard and run `hermes setup` (or edit the " |
| ".env file in a plain-text editor).", |
| file=sys.stderr, |
| ) |
|
|
|
|
| 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") |
| |
| |
| |
| |
| |
| _sanitize_loaded_credentials() |
|
|
|
|
| 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 |
|
|
| 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 |
|
|
|
|
| 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 |
|
|
| |
| if user_env.exists(): |
| _sanitize_env_file_if_needed(user_env) |
| if project_env_path and project_env_path.exists(): |
| _sanitize_env_file_if_needed(project_env_path) |
|
|
| 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 |
|
|