mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
feat(dashboard): interactive auth setup on no-provider non-loopback bind (#50551)
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.
This commit is contained in:
parent
9e96e70995
commit
e448b21414
3 changed files with 151 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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; "
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue