feat(egress): smoother UX — restart command, auto-restart on setup, .env key discovery

- Add `hermes egress restart` (stop-then-start) so applying a config /
  token / Bitwarden-rotation change is one command instead of the
  stop+start dance.
- `hermes egress setup` now offers to restart a running daemon after
  rewriting config/tokens (asks on a tty; `--restart` / `--no-restart`
  for non-interactive control), so changes take effect without the
  operator remembering a manual restart.
- `setup` discovers provider keys kept only in ~/.hermes/.env, not just
  exported shell vars — no more confusing 'no provider keys found' when
  the keys plainly exist.
- Tests + docs updated.
This commit is contained in:
Teknium 2026-06-26 01:11:08 -07:00 committed by teknium1
parent eb7aae5816
commit 6ff7e78649
No known key found for this signature in database
4 changed files with 214 additions and 7 deletions

View file

@ -83,6 +83,17 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None:
"preserve tokens for providers that already had one — avoids "
"401-ing already-running sandboxes on re-setup).",
)
setup.add_argument(
"--restart", dest="restart", action="store_true", default=None,
help="If a daemon is already running, restart it automatically after "
"writing the new config/tokens (non-interactive default on a tty "
"is to ask).",
)
setup.add_argument(
"--no-restart", dest="restart", action="store_false",
help="Do not restart a running daemon after setup; you'll need to run "
"`hermes egress restart` yourself for changes to take effect.",
)
setup.set_defaults(func=cmd_setup)
start = sub.add_parser("start", help="Start the managed iron-proxy")
@ -91,6 +102,12 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None:
stop = sub.add_parser("stop", help="Stop the managed iron-proxy")
stop.set_defaults(func=cmd_stop)
restart = sub.add_parser(
"restart",
help="Restart the managed iron-proxy (stop if running, then start)",
)
restart.set_defaults(func=cmd_restart)
status = sub.add_parser("status", help="Show proxy state and mappings")
status.add_argument(
"--show-tokens", action="store_true",
@ -217,6 +234,19 @@ def cmd_setup(args: argparse.Namespace) -> int:
"the host process env at start time)."
)
return 1
else:
# Env-based discovery reads os.environ. Operators commonly keep their
# provider keys only in ~/.hermes/.env (loaded automatically when the
# agent runs, but NOT exported into an interactive shell). Fall back
# to loading that file so `hermes egress setup` finds the same keys the
# agent would — otherwise a user with keys solely in .env sees a
# confusing "no provider keys found" when the keys clearly "exist".
loaded = _load_env_file_into_environ()
if loaded:
console.print(
f" [dim]Loaded {loaded} provider key name(s) from "
f"~/.hermes/.env for discovery.[/dim]"
)
discovered = ip.discover_provider_mappings(
available_env_names=available_env_names or None,
@ -423,12 +453,65 @@ def cmd_setup(args: argparse.Namespace) -> int:
save_config(cfg)
live_status = ip.get_status()
if live_status.pid is not None:
was_running = live_status.pid is not None
if was_running:
ip.stop_proxy()
# Decide whether to (re)start the daemon so the new config/tokens take
# effect, rather than leaving the operator to remember a manual restart
# (the #1 UX papercut for this feature).
# --restart → always (re)start, even if nothing was running
# --no-restart → never; leave it as-is and print the manual hint
# neither + tty → ask (only when a daemon was running)
# neither + !tty → restart when a daemon was running; otherwise no-op
# (first-time setup never auto-starts — matches the
# "configured, now run start" flow)
import sys as _sys
restart_pref = getattr(args, "restart", None)
if restart_pref is True:
do_restart = True
elif restart_pref is False:
do_restart = False
elif was_running:
if _sys.stdin.isatty():
try:
ans = input(
" Restart the running proxy now with the new config? [Y/n] "
).strip().lower()
except EOFError:
ans = ""
do_restart = ans in ("", "y", "yes")
else:
do_restart = True
else:
do_restart = False
if do_restart:
try:
new_status = ip.start_proxy(
install_if_missing=bool(proxy_cfg.get("auto_install", True)),
)
except Exception as exc: # noqa: BLE001 — user-facing funnel
console.print(
f" [yellow]⚠ could not start iron-proxy with the new "
f"config: {exc}[/yellow]"
)
console.print(
" Run [cyan]hermes egress start[/cyan] manually before "
"launching new Docker sandboxes."
)
else:
listening = "listening" if new_status.listening else "not yet listening"
verb = "restarted" if was_running else "started"
console.print(
f" [green]✓[/green] {verb} iron-proxy with the new config "
f"(pid={new_status.pid}, port={new_status.tunnel_port}, {listening})"
)
elif was_running:
console.print(
" [yellow]⚠ stopped the running iron-proxy; config or tokens changed, "
"so restart it with `hermes egress start` before launching new "
"Docker sandboxes.[/yellow]"
" [yellow]⚠ stopped the running iron-proxy; config or tokens "
"changed. Run [cyan]hermes egress restart[/cyan] (or "
"[cyan]start[/cyan]) before launching new Docker sandboxes.[/yellow]"
)
console.print()
@ -438,6 +521,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
)
console.print(
" Start: [cyan]hermes egress start[/cyan]\n"
" Restart: [cyan]hermes egress restart[/cyan] (after any re-setup)\n"
" Status: [cyan]hermes egress status[/cyan]\n"
" Stop: [cyan]hermes egress stop[/cyan]\n"
" Disable: [cyan]hermes egress disable[/cyan]"
@ -593,6 +677,21 @@ def cmd_stop(args: argparse.Namespace) -> int:
return 0
def cmd_restart(args: argparse.Namespace) -> int:
"""Stop the running daemon (if any) and start it with the current config.
The one-command way to apply config changes (new allowlist hosts, rotated
tokens, a Bitwarden key rotation) without making the operator remember the
stop/start dance. Delegates to ``cmd_start`` so all the credential-source
and fail-on-uncovered-provider guards run exactly as they do for ``start``.
"""
console = Console()
was_running = ip.stop_proxy()
if was_running:
console.print("[dim]stopped the running iron-proxy[/dim]")
return cmd_start(args)
def format_status_text(*, show_tokens: bool = False) -> str:
"""Plain-text egress status for slash commands, Dashboard, and Desktop."""
cfg = load_config()
@ -737,6 +836,39 @@ def cmd_config(args: argparse.Namespace) -> int:
# ---------------------------------------------------------------------------
def _load_env_file_into_environ() -> int:
"""Backfill provider keys from ``~/.hermes/.env`` into ``os.environ``.
``hermes egress setup`` discovers providers by reading ``os.environ``, but
many operators keep their keys ONLY in ``~/.hermes/.env`` (which the agent
loads at runtime but which is NOT exported into an interactive shell).
Without this, ``setup`` reports "no provider keys found" even though the
keys plainly exist a confusing first-run papercut.
Only fills names that aren't already set in the process env (an exported
value always wins), and only for known bearer-provider names so we don't
slurp unrelated secrets into the process. Returns the count of names added.
"""
try:
from hermes_cli.config import load_env
except ImportError:
return 0
try:
file_env = load_env()
except Exception: # noqa: BLE001 — best-effort convenience, never fatal
return 0
added = 0
known = set(ip._BEARER_PROVIDERS) | set(ip._NON_BEARER_PROVIDERS)
for name in known:
if name in os.environ and os.environ[name].strip():
continue
val = (file_env.get(name) or "").strip()
if val:
os.environ[name] = val
added += 1
return added
def _yn(value: bool) -> str:
return "[green]yes[/green]" if value else "[dim]no[/dim]"

View file

@ -43,7 +43,9 @@ def _args(**overrides):
force=False,
tunnel_port=None,
from_bitwarden=False,
no_bitwarden=False,
rotate_tokens=False,
restart=None,
show_tokens=False,
)
for k, v in overrides.items():
@ -361,6 +363,73 @@ def test_cmd_stop_returns_0_when_already_stopped(hermes_home, monkeypatch):
assert rc == 0
# ---------------------------------------------------------------------------
# cmd_restart
# ---------------------------------------------------------------------------
def test_cmd_restart_stops_then_starts(hermes_home, monkeypatch):
calls = []
monkeypatch.setattr(ip, "stop_proxy", lambda: (calls.append("stop"), True)[1])
monkeypatch.setattr(
proxy_cli, "cmd_start",
lambda args: (calls.append("start"), 0)[1],
)
rc = proxy_cli.cmd_restart(_args())
assert rc == 0
# stop must precede start, and both must run
assert calls == ["stop", "start"]
def test_cmd_restart_starts_even_when_not_previously_running(hermes_home, monkeypatch):
calls = []
monkeypatch.setattr(ip, "stop_proxy", lambda: (calls.append("stop"), False)[1])
monkeypatch.setattr(
proxy_cli, "cmd_start",
lambda args: (calls.append("start"), 0)[1],
)
rc = proxy_cli.cmd_restart(_args())
assert rc == 0
assert calls == ["stop", "start"]
def test_cmd_restart_propagates_start_failure(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "stop_proxy", lambda: True)
monkeypatch.setattr(proxy_cli, "cmd_start", lambda args: 1)
rc = proxy_cli.cmd_restart(_args())
assert rc == 1
# ---------------------------------------------------------------------------
# _load_env_file_into_environ — setup discovers keys kept only in ~/.hermes/.env
# ---------------------------------------------------------------------------
def test_load_env_file_backfills_provider_keys(hermes_home, monkeypatch):
# Key present in .env but NOT exported in the process env.
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setattr(
"hermes_cli.config.load_env",
lambda: {"OPENROUTER_API_KEY": "sk-or-from-dotenv", "UNRELATED": "x"},
)
added = proxy_cli._load_env_file_into_environ()
assert added >= 1
assert os.environ.get("OPENROUTER_API_KEY") == "sk-or-from-dotenv"
# Only known provider names are backfilled, not arbitrary secrets.
assert "UNRELATED" not in os.environ
def test_load_env_file_does_not_override_exported_value(hermes_home, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-exported-wins")
monkeypatch.setattr(
"hermes_cli.config.load_env",
lambda: {"OPENROUTER_API_KEY": "sk-or-from-dotenv"},
)
proxy_cli._load_env_file_into_environ()
# An exported value always wins over the .env file.
assert os.environ["OPENROUTER_API_KEY"] == "sk-or-exported-wins"
def test_cmd_status_returns_0(hermes_home, monkeypatch):
monkeypatch.setattr(ip, "get_status", lambda: ip.ProxyStatus())
monkeypatch.setattr(ip, "load_mappings", lambda: [])

View file

@ -630,6 +630,7 @@ hermes egress setup --rotate-tokens # mint fresh proxy tokens (default preser
hermes egress start # spawn the managed proxy daemon
hermes egress stop # SIGTERM (then SIGKILL after 5s grace)
hermes egress restart # stop (if running) then start — apply config/token changes
hermes egress status # binary + config + pid + listening + mappings
hermes egress status --show-tokens # print proxy tokens in full (default: redacted)
@ -652,14 +653,13 @@ hermes egress setup --no-bitwarden # bitwarden → env
# (just `setup` without either flag preserves the existing mode)
# Rotating all tokens (e.g. after a suspected token leak)
hermes egress setup --rotate-tokens
hermes egress start # setup stops a stale daemon; start it again
hermes egress setup --rotate-tokens # setup offers to restart the running daemon for you
# (running sandboxes still hold old tokens; restart them too)
# Adding a new upstream
# Edit ~/.hermes/config.yaml proxy.extra_allowed_hosts: [api.example.com]
hermes egress setup
hermes egress start
hermes egress restart # one-command apply (stop + start)
```
### Diagnostic shortcuts

View file

@ -38,6 +38,10 @@ hermes egress start
hermes egress status
```
`hermes egress setup` discovers provider keys from your environment. If your keys live only in `~/.hermes/.env` (not exported into your shell), setup reads that file automatically — you don't have to `export` them first.
When you re-run `setup` later (new allowlist host, rotated tokens, switched credential source), it stops the running daemon because its config is held in memory, then **offers to restart it for you** so the change takes effect immediately. On a tty it asks; pass `--restart` to always restart or `--no-restart` to leave it down. To apply changes any other time, `hermes egress restart` is the one-command stop-then-start.
Once running, the Docker terminal backend automatically:
- Mounts `~/.hermes/proxy/ca.crt` into the sandbox at `/etc/ssl/certs/hermes-egress-ca.crt`
@ -254,6 +258,8 @@ hermes egress setup --rotate-tokens # mint fresh tokens for every provider
hermes egress start # spawn the managed proxy daemon
hermes egress stop # SIGTERM (then SIGKILL after 5s grace)
hermes egress restart # stop (if running) then start — the one-command
# way to apply config / token changes
hermes egress status # binary + config + pid + listening state + mappings
hermes egress status --show-tokens # print proxy tokens in full