From e448b21414b9dece9b74c3281f04ba4f5c79a771 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:21:48 -0700 Subject: [PATCH] feat(dashboard): interactive auth setup on no-provider non-loopback bind (#50551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `hermes dashboard --host 0.0.0.0` is run interactively with the auth gate engaged but no DashboardAuthProvider configured, prompt to set up the bundled username/password provider on the spot (or point at `hermes dashboard register` for OAuth) instead of only emitting the fail-closed error. - main.py: `_maybe_setup_dashboard_auth_interactively()` runs before start_server. No-ops on loopback binds, when a provider is already registered, or when stdin/stdout isn't a TTY (Docker/s6, CI, piped runs) so the fail-closed SystemExit stays the backstop for unattended deploys. On the password path it writes dashboard.basic_auth.{username,password_hash,secret} to config.yaml (scrypt hash, never plaintext), then force-rediscovers plugins so the basic provider registers before the gate check. - web_server.py: fix the fail-closed hint — it told operators to set `dashboard_auth.basic.username` but the provider reads `dashboard.basic_auth`. - docs: note the interactive setup under Fail-closed semantics. No new env vars; reuses the existing dashboard.basic_auth config surface. --- hermes_cli/main.py | 148 ++++++++++++++++++ hermes_cli/web_server.py | 2 +- .../docs/user-guide/features/web-dashboard.md | 2 + 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 62784c1b3dc..6050e80b2c1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10981,6 +10981,147 @@ def _dashboard_listening(host: str, port: int) -> bool: return False +def _maybe_setup_dashboard_auth_interactively(args) -> None: + """Offer to configure dashboard auth when a non-loopback bind has none. + + Called from ``cmd_dashboard`` just before ``start_server``. The auth + gate engages on every non-loopback bind (``--insecure`` is a no-op since + the June 2026 hardening), and ``start_server`` fails closed when no + ``DashboardAuthProvider`` is registered. Rather than greet an interactive + operator with that hard error, prompt them to set up the bundled + username/password provider on the spot — or point them at + ``hermes dashboard register`` for OAuth. + + No-ops (so the existing fail-closed ``SystemExit`` remains the backstop) + when: + * the bind is loopback (gate never engages), or + * a provider is already registered, or + * stdin/stdout isn't a TTY (Docker/s6, CI, piped ``--no-open`` runs). + """ + host = getattr(args, "host", "127.0.0.1") or "127.0.0.1" + + try: + from hermes_cli.web_server import should_require_auth + if not should_require_auth(host): + return # loopback bind — gate never engages + except Exception: + return # if we can't tell, defer to start_server's own gate + + try: + from hermes_cli.dashboard_auth import list_providers + if list_providers(): + return # a provider is already configured/registered + except Exception: + return + + # Only prompt an interactive operator. Non-TTY callers fall through to + # start_server's fail-closed SystemExit (with the corrected fix hint). + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return + + print() + print( + f"⚠ The dashboard is binding to a non-loopback address ({host}) and " + f"needs an auth provider." + ) + print( + " Non-loopback binds always require authentication " + "(--insecure no longer bypasses this)." + ) + print() + print(" How do you want to authenticate the dashboard?") + print(" [1] Username & password (quickest; for a trusted LAN / VPN)") + print(" [2] OAuth via Nous Portal (run `hermes dashboard register`)") + print(" [3] Cancel") + print() + + try: + choice = input(" Choice [1]: ").strip() or "1" + except (EOFError, KeyboardInterrupt): + print("\n Cancelled.") + sys.exit(1) + + if choice == "2": + print() + print( + " Run this on the host where the dashboard lives, then start " + "the dashboard again:\n" + " hermes dashboard register\n" + " It provisions a Nous Portal OAuth client and writes " + "HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env for you.\n" + " Docs: https://hermes-agent.nousresearch.com/docs/" + "user-guide/features/web-dashboard#authentication-gated-mode" + ) + sys.exit(0) + + if choice not in ("1",): + print(" Cancelled.") + sys.exit(1) + + # ── Username/password setup ────────────────────────────────────────── + import getpass + import secrets + + print() + try: + username = input(" Username [admin]: ").strip() or "admin" + password = getpass.getpass(" Password: ") + confirm = getpass.getpass(" Confirm password: ") + except (EOFError, KeyboardInterrupt): + print("\n Cancelled.") + sys.exit(1) + + if not password: + print(" ✗ Empty password — aborting.") + sys.exit(1) + if password != confirm: + print(" ✗ Passwords don't match — aborting.") + sys.exit(1) + + try: + from plugins.dashboard_auth.basic import hash_password + except Exception as exc: + print(f" ✗ Could not load the password provider: {exc}") + sys.exit(1) + + password_hash = hash_password(password) + # A stable token-signing secret so sessions survive a dashboard restart. + secret = secrets.token_urlsafe(32) + + try: + from hermes_cli.config import load_config, save_config + + cfg = load_config() + dash = cfg.setdefault("dashboard", {}) + basic = dash.setdefault("basic_auth", {}) + basic["username"] = username + basic["password_hash"] = password_hash + # Never persist plaintext: clear any stale plaintext password key. + basic["password"] = "" + if not str(basic.get("secret", "") or "").strip(): + basic["secret"] = secret + save_config(cfg) + except Exception as exc: + print(f" ✗ Failed to write config.yaml: {exc}") + sys.exit(1) + + # Re-run plugin discovery so the basic provider registers from the + # just-written config before start_server's gate check runs. + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins(force=True) + except Exception as exc: + print(f" ⚠ Plugin re-discovery failed ({exc}); the gate may still " + "fail closed. Set the password again or restart the dashboard.") + + print() + print(f" ✓ Username/password auth configured (user: {username}).") + print(" Saved to config.yaml under dashboard.basic_auth.") + print(" Sign in at the dashboard with these credentials.") + print() + + def cmd_dashboard(args): """Start the web UI server, or (with --stop/--status) manage running ones.""" # --status: report running dashboards and exit, no deps needed. @@ -11172,6 +11313,13 @@ def cmd_dashboard(args): from hermes_cli.web_server import start_server + # Interactive auth setup: if this bind will engage the auth gate but no + # provider is registered yet, offer to configure one here (TTY only) + # instead of hard-failing inside start_server. Non-interactive callers + # (Docker/s6, CI, --no-open pipelines) fall through to start_server's + # fail-closed SystemExit unchanged. + _maybe_setup_dashboard_auth_interactively(args) + # The in-browser Chat tab (the embedded TUI over PTY/WebSocket) is always # available — the desktop app and the dashboard's own Chat tab both rely on # the `/api/ws` + `/api/pty` sockets, so there is no reason to gate them. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b89eafecfa2..ade50c60051 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12867,7 +12867,7 @@ def start_server( _fix_hint = ( "Configure an auth provider before exposing the dashboard:\n" - " • Password: set dashboard_auth.basic.username + " + " • Password: set dashboard.basic_auth.username + " "password_hash in config.yaml\n" " (hash with: python -c \"from " "plugins.dashboard_auth.basic import hash_password; " diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index d562879c243..64db237cae4 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -585,6 +585,8 @@ The gate is on if and only if: If the gate would engage but **no** `DashboardAuthProvider` is registered (no Nous plugin, no custom plugin), `hermes dashboard` refuses to bind with an explicit error message. There is no "default-deny but accept everything" fallback — a misconfigured gated dashboard never starts. +When you run `hermes dashboard --host 0.0.0.0` **interactively** (a real terminal) and no provider is configured yet, Hermes doesn't just fail — it offers to set one up on the spot: pick **username & password** (writes `dashboard.basic_auth` to `config.yaml` and you're running in seconds) or **OAuth** (points you at `hermes dashboard register`). Non-interactive callers — Docker/s6, CI, piped runs — skip the prompt and hit the fail-closed error above, so an unattended deploy still never starts without auth. + ### Default provider: Nous Research The bundled `plugins/dashboard_auth/nous` plugin is **always installed** and auto-loaded. It auto-registers a `DashboardAuthProvider` named `nous` when a client ID is configured.