fix(send): the 'not configured' error lists the home and sources it consulted

The error now names the resolved home's `.env` (and whether the platform's
token key is defined there), `config.yaml` (block absent / `enabled: false` /
no token) and the environment variable(s) checked, so a Windows or profile
home user can fix the file this process actually read. When a gateway started
from the same home already has the platform connected, the message says its
token lives only in that process's environment and which key to add to `.env`.

Docstrings in `gateway/channel_directory.py` and `send_cmd._load_hermes_env`
stop naming `~/.hermes`; the pipe-script-output guide documents the message.
This commit is contained in:
teknium1
2026-09-18 00:47:44 -07:00
committed by Teknium
parent 59e40220e2
commit 7a99c4d13c
6 changed files with 109 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
"""Channel directory -- cached map of reachable channels/contacts per platform.
Built on gateway startup, refreshed every 5 min, saved to ~/.hermes/channel_directory.json.
Built on gateway startup, refreshed every 5 min, saved to ``<HERMES_HOME>/channel_directory.json``.
send_message reads it for action="list" and to resolve friendly channel names to IDs.
"""

View File

@@ -133,7 +133,7 @@ def _list_targets(platform_filter: Optional[str], *, json_mode: bool) -> int:
def _load_hermes_env() -> None:
"""Populate the credential environment from ``~/.hermes/.env`` AND bridge top-level ``config.yaml``
"""Populate the credential environment from ``<HERMES_HOME>/.env`` AND bridge top-level ``config.yaml``
keys into it so the gateway config loader sees platform credentials and home channels.
The target is ``os.environ`` for the standalone CLI. Inside a multi-profile host (dashboard console

View File

@@ -376,3 +376,34 @@ def test_load_hermes_env_bom_only_env_is_noop(tmp_path, monkeypatch):
added = {k: v for k, v in os.environ.items() if k not in before}
assert "\ufeff" not in "".join(added)
def test_help_and_empty_list_hint_name_the_resolved_home(tmp_path, monkeypatch, capsys):
"""``--help`` and the ``--list`` empty-state hint derive their paths from the resolved home instead of a
hardcoded ``~/.hermes`` (absent on a Windows install or under a profile home)."""
import argparse
import sys
import types
home = tmp_path / "AppData" / "Local" / "hermes"
home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
parser = argparse.ArgumentParser(prog="hermes")
send_parser = send_cmd.register_send_subparser(parser.add_subparsers(dest="command"))
help_text = send_parser.format_help()
assert str(home / ".env") in help_text and str(home / "config.yaml") in help_text
assert "~/.hermes" not in help_text
fake_gw_config = types.ModuleType("gateway.config")
fake_gw_config.load_gateway_config = lambda: types.SimpleNamespace(get_connected_platforms=lambda: [])
monkeypatch.setitem(sys.modules, "gateway.config", fake_gw_config)
fake_dir = types.ModuleType("gateway.channel_directory")
fake_dir.load_directory = lambda: {"updated_at": None, "platforms": {}}
fake_dir.format_directory_for_display = lambda platforms=None: ""
monkeypatch.setitem(sys.modules, "gateway.channel_directory", fake_dir)
assert send_cmd._list_targets(None, json_mode=False) == 0
out = capsys.readouterr().out
assert str(home / "channel_directory.json") in out
assert "~/.hermes" not in out

View File

@@ -1821,3 +1821,24 @@ class TestSendTelegramThreadNotFoundRetry:
finally:
if media_path and os.path.exists(media_path):
os.unlink(media_path)
def test_not_configured_error_names_resolved_home_and_consulted_sources(tmp_path, monkeypatch):
"""The 'not configured' error names the files this process actually read (resolved home, not a
hardcoded ``~/.hermes``) and what each source held, so a Windows/profile home user can fix the right file."""
from gateway.config import GatewayConfig
from tools.send_message_tool import _resolve_platform_config
home = tmp_path / "AppData" / "Local" / "hermes"
home.mkdir(parents=True)
(home / ".env").write_text("FIRECRAWL_API_KEY=x\n", encoding="utf-8")
(home / "config.yaml").write_text("platforms:\n discord:\n enabled: false\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.delenv("DISCORD_BOT_TOKEN", raising=False)
_, _, _, err = _resolve_platform_config("discord", GatewayConfig())
assert "~/.hermes" not in err
assert f"{home / '.env'} (no DISCORD_BOT_TOKEN)" in err
assert f"{home / 'config.yaml'} (platforms.discord.enabled: false)" in err
assert "environment (DISCORD_BOT_TOKEN unset)" in err

View File

@@ -304,13 +304,53 @@ def _resolve_platform_config(platform_name, config):
if not pconfig or not pconfig.enabled:
pconfig = _weixin_env_pconfig() if platform_name == "weixin" else None
if pconfig is None:
from hermes_constants import get_hermes_home
config_path = get_hermes_home() / "config.yaml"
return None, None, None, (f"Platform '{platform_name}' is not configured. Set up credentials in "
f"{config_path} or environment variables.")
return None, None, None, _not_configured_error(platform_name, platform, entry)
return platform, pconfig, entry, None
def _not_configured_error(platform_name, platform, entry):
"""Name the resolved home and what each credential source held, so the user edits the file this
process actually read (a hardcoded ``~/.hermes`` does not exist on a Windows or profile home)."""
from agent.secret_scope import load_env_file
from gateway.config import _getenv
from gateway.config_env import _ENV_ENABLE_CREDENTIALS
from hermes_constants import get_hermes_home
home = get_hermes_home()
env_names = list(_ENV_ENABLE_CREDENTIALS.get(platform) or (entry.required_env if entry else ()))
names = "/".join(env_names) or "credentials"
env_path, config_path = home / ".env", home / "config.yaml"
dotenv_keys = load_env_file(env_path)
dotenv_state = (f"{names} present" if any(n in dotenv_keys for n in env_names) else f"no {names}") \
if env_path.exists() else "missing"
try:
from hermes_cli.config_effective import load_user_config_effective
block = (load_user_config_effective(config_path) or {}).get("platforms", {}).get(platform_name)
except Exception:
block = None
if not config_path.exists():
config_state = "missing"
elif not isinstance(block, dict):
config_state = f"no platforms.{platform_name} block"
elif block.get("enabled") is False:
config_state = f"platforms.{platform_name}.enabled: false"
else:
config_state = f"platforms.{platform_name} has no token"
env_state = f"{names} set" if any(_getenv(n) for n in env_names) else f"{names} unset"
msg = (f"Platform '{platform_name}' is not configured. Looked in: {env_path} ({dotenv_state}), "
f"{config_path} ({config_state}), environment ({env_state}).")
# The gateway can hold a token only in its own process environment; a fresh CLI cannot see it.
try:
from gateway.status import read_runtime_status, runtime_status_pid_is_live
record = read_runtime_status()
state = ((record or {}).get("platforms") or {}).get(platform_name, {}).get("state")
if state == "connected" and "present" not in dotenv_state and runtime_status_pid_is_live(record):
msg += (f" A gateway (pid {record.get('pid')}) running from {home} has {platform_name} connected, "
f"so its credentials live only in that process's environment; add {names} to {env_path}.")
except Exception:
pass
return msg
def _home_chat_id(config, platform, platform_name):
"""``(home chat_id, None)`` or ``(None, actionable error)``; Weixin also honours WEIXIN_HOME_CHANNEL."""
home = config.get_home_channel(platform)

View File

@@ -186,9 +186,19 @@ msg_id=$(hermes send --to discord:#ops --json "build started" \
**Usually no.** For any bot-token platform — Telegram, Discord, Slack,
Signal, SMS, WhatsApp Cloud API, and most others — `hermes send` calls
the platform's REST endpoint directly using credentials from
`~/.hermes/.env` and `~/.hermes/config.yaml`. It's a standalone subprocess
`~/.hermes/.env` and `~/.hermes/config.yaml` (or the equivalent files under
your resolved Hermes home — `%LOCALAPPDATA%\hermes` on Windows, or the profile
directory when `HERMES_HOME` / `-p` is set). It's a standalone subprocess
that exits as soon as the message is delivered.
If a platform reports `not configured`, the error lists the exact files it
read and what each one held, e.g.
`Looked in: C:\Users\me\AppData\Local\hermes\.env (no DISCORD_BOT_TOKEN),
C:\Users\me\AppData\Local\hermes\config.yaml (no platforms.discord block),
environment (DISCORD_BOT_TOKEN unset)`. When a gateway started from the same
home has that platform connected, the token only exists in the gateway's
process environment — add it to that home's `.env` so `hermes send` can use it.
A live gateway is only required for **plugin platforms** that rely on a
persistent adapter connection (for example, a custom plugin that keeps
a long-lived WebSocket open). In that case you'll get a clear error