From 89acc196067c3a4a8987a8f0d01ed4e08d7daa2d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Jul 2026 19:47:33 -0500 Subject: [PATCH] fix(dump): flag API keys visible only to the shell, not the managed backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes debug share reads os.getenv — the invoking terminal's environment — but launchd/systemd and the desktop-spawned `serve` backend load credentials from ~/.hermes/.env, not the login shell. A key exported in the shell but absent from .env is invisible to the backend, yet the dump printed a bare "set", sending support down a phantom "the key is configured" path. This was the actual trap behind a "Desktop has no web_search / no tools" report: FIRECRAWL_API_KEY was a shell export (so `debug share` in a terminal read "firecrawl set") but not in .env, so the launchd backend's check_web_api_key returned False and web_search was gated off — which a contributor then misdiagnosed as a missing `desktop` platform registration. The dump now annotates any key set in-process but missing from ~/.hermes/.env with "(shell only — not in .env; managed/desktop backend may not see it)" so the mismatch is obvious instead of hidden behind "set". --- hermes_cli/dump.py | 41 +++++++++++ tests/hermes_cli/test_dump_env_visibility.py | 77 ++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/hermes_cli/test_dump_env_visibility.py diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index 82a49b03f..8f992e928 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -19,6 +19,38 @@ from hermes_constants import display_hermes_home from agent.skill_utils import is_excluded_skill_path +def _dotenv_key_names() -> set[str]: + """Return the set of env-var names assigned a non-empty value in ~/.hermes/.env. + + The managed backends (launchd / systemd / the desktop-spawned ``serve`` + process) load credentials from this file — NOT from an interactive shell's + exports. ``hermes debug share`` runs in a terminal, so ``os.getenv`` reflects + the shell's environment, which can include exported keys the managed backend + never sees. Comparing against this set lets the dump flag that mismatch (the + exact trap behind #48504-style "no web_search" reports: key exported in the + shell, absent from .env, invisible to the launchd backend). + """ + try: + env_path = get_env_path() + text = env_path.read_text(encoding="utf-8", errors="ignore") + except (OSError, UnicodeError): + return set() + + names: set[str] = set() + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + if line.lower().startswith("export "): + line = line[len("export "):].lstrip() + name, _, value = line.partition("=") + name = name.strip() + # A bare `KEY=` (empty value) is effectively unset for the backend. + if name and value.strip().strip("'\""): + names.add(name) + return names + + def _get_git_commit(project_root: Path) -> str: """Return short git commit hash, or '(unknown)'. @@ -355,12 +387,21 @@ def run_dump(args): ("GITHUB_TOKEN", "github"), ] + dotenv_keys = _dotenv_key_names() + for env_var, label in api_keys: val = os.getenv(env_var, "") if show_keys and val: display = _redact(val) else: display = "set" if val else "not set" + # Set in this (shell) process but absent from ~/.hermes/.env: a managed + # backend (launchd/systemd/desktop `serve`) loads .env, not the login + # shell, so it likely can't see this key — even though the dump reads + # "set". Flag it so support doesn't chase a phantom "key is configured" + # (the actual cause of gated tools like web_search going missing). + if val and env_var not in dotenv_keys: + display += " (shell only — not in .env; managed/desktop backend may not see it)" # A credential added via `hermes auth add openrouter` lives in the # credential pool, not as an env var — surface it so the dump doesn't # misleadingly read "not set" while `hermes auth list` shows it (#42130). diff --git a/tests/hermes_cli/test_dump_env_visibility.py b/tests/hermes_cli/test_dump_env_visibility.py new file mode 100644 index 000000000..c8ffc61cb --- /dev/null +++ b/tests/hermes_cli/test_dump_env_visibility.py @@ -0,0 +1,77 @@ +"""`hermes debug` must not report a shell-only API key as plainly "set". + +The dump reads ``os.getenv`` — the invoking terminal's environment — but the +managed backends (launchd / systemd / the desktop-spawned ``serve`` process) +load credentials from ``~/.hermes/.env``, not the login shell. A key exported +in the shell but absent from ``.env`` is invisible to the backend, yet the dump +used to print a bare "set", sending support down a phantom "the key is +configured" path (the real cause behind gated tools like ``web_search`` going +missing on Desktop). The dump now flags that mismatch. +""" + +from pathlib import Path +from types import SimpleNamespace + + +def _api_key_line(out: str, label: str) -> str: + for line in out.splitlines(): + if line.strip().startswith(f"{label} "): + return line + raise AssertionError(f"no '{label}' api_keys line in dump output:\n{out}") + + +def test_dump_flags_shell_only_key_not_in_dotenv(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + home.mkdir(parents=True, exist_ok=True) + # .env has some OTHER key but NOT firecrawl. + (home / ".env").write_text("OPENROUTER_API_KEY=sk-or-xxxx\n") + # firecrawl is exported in the (test) shell only. + monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-shell-only") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _api_key_line(capsys.readouterr().out, "firecrawl") + assert "set" in line + assert "shell only" in line + assert ".env" in line + + +def test_dump_does_not_flag_key_present_in_dotenv(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + + home = get_hermes_home() + home.mkdir(parents=True, exist_ok=True) + (home / ".env").write_text("FIRECRAWL_API_KEY=fc-in-dotenv\n") + monkeypatch.setenv("FIRECRAWL_API_KEY", "fc-in-dotenv") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _api_key_line(capsys.readouterr().out, "firecrawl") + assert "set" in line + assert "shell only" not in line + + +def test_dump_leaves_unset_key_untouched(monkeypatch, capsys, tmp_path): + from hermes_cli import dump + from hermes_cli.config import get_hermes_home + + monkeypatch.setattr(dump, "get_project_root", lambda: tmp_path / "noproject") + monkeypatch.delenv("TAVILY_API_KEY", raising=False) + + home = get_hermes_home() + home.mkdir(parents=True, exist_ok=True) + (home / ".env").write_text("OPENROUTER_API_KEY=sk-or-xxxx\n") + + dump.run_dump(SimpleNamespace(show_keys=False)) + + line = _api_key_line(capsys.readouterr().out, "tavily") + assert "not set" in line + assert "shell only" not in line