mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
* fix(cli): persist custom --portal-url to .env on dashboard register
`hermes dashboard register --portal-url <url>` resolved the custom portal
for the registration request but only persisted it to .env when the var was
absent AND non-default. So a user who re-registered against a different
portal (e.g. switching preview deploys) silently kept the stale
HERMES_DASHBOARD_PORTAL_URL, and an explicit request for the production
portal was never written at all.
Track whether a custom portal was *explicitly supplied* (--portal-url flag
or HERMES_DASHBOARD_PORTAL_URL env), separately from the resolved value:
- explicit custom URL -> always persist (update in place via
save_env_value, which overwrites the matching key rather than appending
a duplicate), even when it equals the production default; no-op when it
already matches.
- no custom URL supplied -> unchanged conservative behaviour: only write an
inferred portal when absent and non-default; never alter an existing
entry unexpectedly.
save_env_value already preserves other lines/comments and dedups in place;
this only changes the decision of *when* to call it.
Adds TestCustomPortalPersistence covering all four cases.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
* feat(cli): persist dashboard public URL from --redirect-uri on register
When the user registers a publicly-exposed dashboard with --redirect-uri
(the full OAuth callback, e.g. https://hermes.example.com/auth/callback),
derive its origin and persist it as HERMES_DASHBOARD_PUBLIC_URL — the env var
the dashboard auth layer actually consumes at serve time.
dashboard_auth/routes._redirect_uri reconstructs the callback as
HERMES_DASHBOARD_PUBLIC_URL + "/auth/callback" (verbatim), and
dashboard_auth/prefix.resolve_public_url reads that var (then config.yaml
dashboard.public_url) to decide the public origin. Previously --redirect-uri
was sent to the portal at registration but never persisted, so the operator
had to set HERMES_DASHBOARD_PUBLIC_URL by hand for the login gate to engage
and the callback to round-trip. We now wire it automatically.
Persist the ORIGIN (scheme://host[:port]), not the full callback path —
persisting the raw redirect would double the path when the runtime appends
/auth/callback. Mirrors the portal-url persistence semantics already in this
PR: always write an explicitly-derived value (updating in place, no
duplicate), no-op when it already matches, never written on a localhost-only
install (no --redirect-uri), and skipped for a non-http(s)/malformed redirect.
Verified end-to-end: cmd_dashboard_register writes the origin to .env, then
resolve_public_url() reads it back and public_url + /auth/callback
reconstructs exactly the originally-supplied --redirect-uri.
Adds TestPublicUrlPersistence (8 cases) incl. origin-derivation, port
preservation, update-in-place, no-op, no-flag, non-http skip, and
both-portal-and-public-url-persisted.
Co-authored-by: Hermes Agent <agent@nousresearch.com>
---------
Co-authored-by: Hermes Agent <agent@nousresearch.com>
427 lines
18 KiB
Python
427 lines
18 KiB
Python
"""``hermes dashboard register`` — register a self-hosted dashboard OAuth client.
|
|
|
|
Automates what a user otherwise does by hand: open the Nous Portal
|
|
``/local-dashboards`` page in a browser, click "register", copy the
|
|
resulting ``agent:{id}`` OAuth client ID, and paste it into ``~/.hermes/.env``
|
|
as ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``.
|
|
|
|
This command:
|
|
1. Resolves a fresh Nous Portal access token from the existing login
|
|
(``~/.hermes/auth.json``), refreshing it if needed. Fails fast with a
|
|
"run `hermes setup`" hint when the user isn't logged in.
|
|
2. POSTs to ``{portal}/api/oauth/self-hosted-client`` with that bearer
|
|
token, which creates a SELF_HOSTED agent client owned by the caller's
|
|
org and returns the fully-formed ``agent:{id}`` client_id.
|
|
3. Writes ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and (if absent)
|
|
``HERMES_DASHBOARD_PORTAL_URL`` into ``~/.hermes/.env`` idempotently.
|
|
4. Prints a post-register hint explaining that the OAuth gate only engages
|
|
on a non-loopback bind.
|
|
|
|
The portal endpoint is the NAS half of this feature (POST
|
|
/api/oauth/self-hosted-client). The ``agent:`` prefix is applied server-side,
|
|
so this client never needs to know the namespace convention.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import random
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Optional
|
|
|
|
|
|
# Docker-style name generator. Same vibe as Docker's adjective_surname, but
|
|
# adjective_noun with a space-free underscore join so it drops cleanly into a
|
|
# label field. There is NO uniqueness constraint on the portal side (the row
|
|
# id is the key), so collisions are harmless and we don't retry.
|
|
_NAME_ADJECTIVES = (
|
|
"amber", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp",
|
|
"dreamy", "eager", "electric", "fancy", "gentle", "golden", "happy",
|
|
"hidden", "jolly", "keen", "lively", "lucid", "lunar", "mellow", "merry",
|
|
"mighty", "nimble", "noble", "polished", "quiet", "quirky", "rapid",
|
|
"serene", "sharp", "shiny", "silent", "snappy", "solar", "spry", "stellar",
|
|
"sunny", "swift", "tidy", "vivid", "vibrant", "witty", "zesty",
|
|
)
|
|
|
|
_NAME_NOUNS = (
|
|
"albatross", "antelope", "badger", "beacon", "comet", "condor", "cypress",
|
|
"dolphin", "ember", "falcon", "ferret", "galaxy", "glacier", "harbor",
|
|
"heron", "ibex", "jaguar", "kestrel", "lantern", "lynx", "meadow", "nebula",
|
|
"ocelot", "orchid", "otter", "panther", "petrel", "quasar", "raven", "reef",
|
|
"sparrow", "summit", "tundra", "vortex", "walrus", "willow", "yarrow",
|
|
# A couple of scientist surnames in the Docker spirit.
|
|
"kepler", "tesla", "curie", "hopper", "turing", "lovelace",
|
|
)
|
|
|
|
|
|
def _generate_dashboard_name() -> str:
|
|
"""Return a human-readable ``adjective_noun`` name (Docker-style)."""
|
|
return f"{random.choice(_NAME_ADJECTIVES)}_{random.choice(_NAME_NOUNS)}"
|
|
|
|
|
|
def _resolve_portal_base_url(override: Optional[str] = None) -> str:
|
|
"""Resolve the portal base URL for the registration request.
|
|
|
|
Precedence:
|
|
1. ``override`` — explicit ``--portal-url`` flag or
|
|
``HERMES_DASHBOARD_PORTAL_URL`` env (used for testing against a
|
|
preview/staging portal). NOTE: the access token must be valid at
|
|
this portal — it's minted by whatever portal you logged into, so an
|
|
override only works if the token's issuer matches (e.g. you logged
|
|
into the same staging/preview portal).
|
|
2. The ``portal_base_url`` stored on the Nous login — this is the
|
|
portal that issued the token, so it's the correct default target.
|
|
3. The production default.
|
|
"""
|
|
if isinstance(override, str) and override.strip():
|
|
return override.rstrip("/")
|
|
try:
|
|
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL, get_provider_auth_state
|
|
|
|
state = get_provider_auth_state("nous") or {}
|
|
base = state.get("portal_base_url")
|
|
if isinstance(base, str) and base.strip():
|
|
return base.rstrip("/")
|
|
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
|
except Exception:
|
|
return "https://portal.nousresearch.com"
|
|
|
|
|
|
def _register_self_hosted_client(
|
|
*,
|
|
access_token: str,
|
|
portal_base_url: str,
|
|
name: Optional[str],
|
|
custom_redirect_uri: Optional[str],
|
|
existing_client_id: Optional[str] = None,
|
|
timeout: float = 15.0,
|
|
) -> dict:
|
|
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
|
|
|
|
When ``existing_client_id`` is provided (the client_id this install
|
|
persisted on a prior run), it is sent so the portal updates that existing
|
|
dashboard record in place instead of minting a duplicate — this is what
|
|
makes re-running ``hermes dashboard register`` idempotent. The portal
|
|
falls back to creating a fresh client if the id no longer resolves to a row
|
|
in the caller's org (stale/deleted), so passing it is always safe.
|
|
|
|
``name`` may be ``None`` on the idempotent update path (re-run without an
|
|
explicit ``--name``): omitting it tells the portal to keep the name it
|
|
already stored rather than overwriting it. It is required on the create
|
|
path; the caller guarantees a value there.
|
|
|
|
Raises RuntimeError with a user-facing message on any non-2xx response or
|
|
transport failure.
|
|
"""
|
|
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
|
|
body: dict[str, str] = {}
|
|
if name:
|
|
body["name"] = name
|
|
if custom_redirect_uri:
|
|
body["custom_redirect_uri"] = custom_redirect_uri
|
|
if existing_client_id:
|
|
body["client_id"] = existing_client_id
|
|
|
|
data = json.dumps(body).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=data,
|
|
method="POST",
|
|
headers={
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
},
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
payload = json.loads(resp.read().decode())
|
|
except urllib.error.HTTPError as exc:
|
|
# The endpoint returns structured JSON errors ({error, error_description}).
|
|
detail = ""
|
|
try:
|
|
err_body = json.loads(exc.read().decode())
|
|
detail = (
|
|
err_body.get("error_description")
|
|
or err_body.get("error")
|
|
or ""
|
|
)
|
|
except Exception:
|
|
pass
|
|
if exc.code == 401:
|
|
raise RuntimeError(
|
|
"Nous Portal rejected the access token (401). "
|
|
"Try `hermes auth login nous` to re-authenticate."
|
|
) from exc
|
|
if exc.code == 403:
|
|
raise RuntimeError(
|
|
detail
|
|
or "Your account is not permitted to register a self-hosted dashboard."
|
|
) from exc
|
|
raise RuntimeError(
|
|
f"Portal returned HTTP {exc.code}"
|
|
+ (f": {detail}" if detail else "")
|
|
) from exc
|
|
except urllib.error.URLError as exc:
|
|
raise RuntimeError(
|
|
f"Could not reach Nous Portal at {portal_base_url}: {exc.reason}"
|
|
) from exc
|
|
|
|
if not isinstance(payload, dict) or not payload.get("client_id"):
|
|
raise RuntimeError("Portal returned an unexpected response (no client_id).")
|
|
return payload
|
|
|
|
|
|
def _print_post_register_hint(
|
|
*,
|
|
client_id: str,
|
|
portal_base_url: str,
|
|
custom_redirect_uri: Optional[str],
|
|
wrote_portal_url: bool,
|
|
public_url: str = "",
|
|
) -> None:
|
|
"""Print the success summary + the gate-engagement caveat."""
|
|
from hermes_cli.config import get_env_path
|
|
|
|
env_path = get_env_path()
|
|
_cid = client_id
|
|
print()
|
|
print(f" Wrote to {env_path}:")
|
|
print(" HERMES_DASHBOARD_OAUTH_CLIENT_ID=" + str(_cid))
|
|
if wrote_portal_url:
|
|
print(" HERMES_DASHBOARD_PORTAL_URL=" + str(portal_base_url))
|
|
if public_url:
|
|
print(" HERMES_DASHBOARD_PUBLIC_URL=" + str(public_url))
|
|
print()
|
|
print(
|
|
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
|
|
" `hermes dashboard` (localhost) leaves the gate off and serves locally\n"
|
|
" without auth, which is fine for your own machine."
|
|
)
|
|
print()
|
|
if custom_redirect_uri:
|
|
# Derive the host the user registered so the example matches it.
|
|
try:
|
|
from urllib.parse import urlparse
|
|
|
|
host = urlparse(custom_redirect_uri).hostname or "your-host"
|
|
except Exception:
|
|
host = "your-host"
|
|
print(" To require Nous login on your registered host, run the dashboard")
|
|
print(f" bound publicly (it must be reachable at https://{host}) and log in")
|
|
print(" at its /login page.")
|
|
else:
|
|
print(" To require Nous login (e.g. exposing on your LAN or a public host):")
|
|
print(" hermes dashboard --host 0.0.0.0")
|
|
print(" …then log in at the dashboard's /login page.")
|
|
print()
|
|
print(
|
|
" If the dashboard is already running, restart it to pick up the new env."
|
|
)
|
|
print(
|
|
f" Manage or revoke this dashboard at {portal_base_url}/local-dashboards"
|
|
)
|
|
|
|
|
|
def cmd_dashboard_register(args) -> None:
|
|
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
|
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
|
from hermes_cli.config import get_env_value, is_managed, save_env_value
|
|
|
|
# Managed (Docker/hosted) installs get their dashboard OAuth client_id
|
|
# stamped in by the orchestrator (NAS sets HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
|
# via buildContainerEnvVars). Registering from inside such a container is a
|
|
# mistake — and save_env_value refuses to write anyway.
|
|
if is_managed():
|
|
print(
|
|
"✗ `hermes dashboard register` is not available in a managed/hosted "
|
|
"install.\n"
|
|
" The dashboard OAuth client is provisioned by the hosting platform."
|
|
)
|
|
sys.exit(1)
|
|
|
|
# 1. Resolve a fresh Nous access token (refreshes if near expiry). Fail fast
|
|
# with a setup hint when the user isn't logged in.
|
|
try:
|
|
access_token = resolve_nous_access_token()
|
|
except AuthError as exc:
|
|
if getattr(exc, "relogin_required", False):
|
|
print("✗ You're not logged into Nous Portal.")
|
|
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
|
|
else:
|
|
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
|
sys.exit(1)
|
|
except Exception as exc:
|
|
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
|
sys.exit(1)
|
|
|
|
# Portal override: explicit --portal-url flag wins, else the
|
|
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
|
|
#
|
|
# We track whether a custom URL was *explicitly supplied* (flag or env)
|
|
# separately from the resolved value. An explicit custom URL is an
|
|
# intentional choice the user wants to persist (and update in place if it
|
|
# already exists in .env); a portal merely inferred from the stored login
|
|
# keeps the older, more conservative write-only-if-absent behaviour so we
|
|
# don't clutter .env for the common production case.
|
|
portal_override = getattr(args, "portal_url", None) or os.environ.get(
|
|
"HERMES_DASHBOARD_PORTAL_URL"
|
|
)
|
|
custom_portal_supplied = bool(
|
|
isinstance(portal_override, str) and portal_override.strip()
|
|
)
|
|
portal_base_url = _resolve_portal_base_url(portal_override)
|
|
|
|
# Idempotency: if this install already registered a dashboard, we hold its
|
|
# client_id locally (HERMES_DASHBOARD_OAUTH_CLIENT_ID). Re-send it so the
|
|
# portal UPDATES that existing record instead of creating a duplicate. No
|
|
# stored client_id -> this is a first registration -> create a fresh one
|
|
# (the original behavior). This mirrors the portal's rule: no client id =
|
|
# new dashboard; client id present = the stable key of the row to modify.
|
|
existing_client_id = None
|
|
try:
|
|
existing_client_id = get_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID")
|
|
except Exception:
|
|
existing_client_id = None
|
|
if isinstance(existing_client_id, str):
|
|
existing_client_id = existing_client_id.strip() or None
|
|
else:
|
|
existing_client_id = None
|
|
|
|
explicit_name = getattr(args, "name", None)
|
|
# Auto-generate a random name ONLY for a first registration. On a re-run
|
|
# (we hold a client_id) without an explicit --name, keep the name the
|
|
# portal already stored rather than churning it to a new random value
|
|
# every time — so leave `name` unset and let the portal preserve it.
|
|
if explicit_name:
|
|
name = explicit_name
|
|
elif existing_client_id:
|
|
name = None
|
|
else:
|
|
name = _generate_dashboard_name()
|
|
custom_redirect_uri = getattr(args, "redirect_uri", None)
|
|
|
|
# 2. Register with the portal.
|
|
try:
|
|
result = _register_self_hosted_client(
|
|
access_token=access_token,
|
|
portal_base_url=portal_base_url,
|
|
name=name,
|
|
custom_redirect_uri=custom_redirect_uri,
|
|
existing_client_id=existing_client_id,
|
|
)
|
|
except RuntimeError as exc:
|
|
print(f"✗ Registration failed: {exc}")
|
|
sys.exit(1)
|
|
|
|
client_id = str(result["client_id"])
|
|
registered_name = str(result.get("name") or name or "")
|
|
|
|
# Distinguish create vs update for the user: the portal echoes back the
|
|
# same client_id we sent when it updated in place.
|
|
updated_existing = bool(
|
|
existing_client_id and client_id == existing_client_id
|
|
)
|
|
if updated_existing:
|
|
print(f'✓ Updated dashboard "{registered_name}"')
|
|
else:
|
|
print(f'✓ Registered dashboard "{registered_name}"')
|
|
|
|
# 3. Write env vars idempotently. Always set the client_id.
|
|
try:
|
|
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
|
|
except Exception as exc:
|
|
print(f"✗ Failed to write HERMES_DASHBOARD_OAUTH_CLIENT_ID to .env: {exc}")
|
|
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
|
sys.exit(1)
|
|
|
|
# Persist the portal URL. Two cases:
|
|
# a) The user explicitly supplied a custom portal (--portal-url flag or
|
|
# HERMES_DASHBOARD_PORTAL_URL env). That's an intentional choice we
|
|
# always persist so it survives across sessions — overwriting any
|
|
# existing entry in place (save_env_value updates a matching key
|
|
# rather than appending a duplicate). This is true even when it equals
|
|
# the production default: the user asked for it explicitly.
|
|
# b) No custom portal was supplied. Keep the older conservative behaviour:
|
|
# only write a portal inferred from the stored login when it isn't
|
|
# already configured AND differs from the production default, so we
|
|
# don't clutter .env for the common production case and don't alter an
|
|
# existing entry unexpectedly.
|
|
wrote_portal_url = False
|
|
default_portal = "https://portal.nousresearch.com"
|
|
existing_portal = None
|
|
try:
|
|
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
|
|
except Exception:
|
|
existing_portal = None
|
|
|
|
if custom_portal_supplied:
|
|
should_write_portal = existing_portal != portal_base_url
|
|
else:
|
|
should_write_portal = (
|
|
not existing_portal and portal_base_url.rstrip("/") != default_portal
|
|
)
|
|
|
|
if should_write_portal:
|
|
try:
|
|
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
|
|
wrote_portal_url = True
|
|
except Exception:
|
|
# Non-fatal: the client_id is the load-bearing value.
|
|
pass
|
|
|
|
# Persist the dashboard public URL derived from the OAuth redirect URI.
|
|
#
|
|
# --redirect-uri is the full public HTTPS callback the user registered with
|
|
# the portal, e.g. https://hermes.example.com/auth/callback. At serve time
|
|
# the dashboard auth layer (dashboard_auth/routes._redirect_uri) reconstructs
|
|
# that same callback by taking HERMES_DASHBOARD_PUBLIC_URL and appending
|
|
# "/auth/callback" verbatim. So the value the runtime actually consumes is
|
|
# the ORIGIN (scheme://host[:port]), not the full callback path — persisting
|
|
# the raw redirect URI would double up the path. We derive the origin from
|
|
# the supplied redirect URI and persist it as HERMES_DASHBOARD_PUBLIC_URL so
|
|
# the operator doesn't have to re-supply it and the public-URL override is
|
|
# actually wired (the gate engages and the callback round-trips correctly).
|
|
#
|
|
# Like the portal URL, an explicitly supplied value is always written
|
|
# (updating an existing entry in place rather than appending a duplicate),
|
|
# a no-op when it already matches, and never written on a localhost-only
|
|
# install (no --redirect-uri).
|
|
wrote_public_url = False
|
|
public_url = ""
|
|
if custom_redirect_uri:
|
|
try:
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(custom_redirect_uri)
|
|
if parsed.scheme in ("http", "https") and parsed.netloc:
|
|
public_url = f"{parsed.scheme}://{parsed.netloc}"
|
|
except Exception:
|
|
public_url = ""
|
|
|
|
if public_url:
|
|
existing_public_url = None
|
|
try:
|
|
existing_public_url = get_env_value("HERMES_DASHBOARD_PUBLIC_URL")
|
|
except Exception:
|
|
existing_public_url = None
|
|
if existing_public_url != public_url:
|
|
try:
|
|
save_env_value("HERMES_DASHBOARD_PUBLIC_URL", public_url)
|
|
wrote_public_url = True
|
|
except Exception:
|
|
# Non-fatal: the client_id is the load-bearing value.
|
|
pass
|
|
|
|
# 4. Hint.
|
|
_print_post_register_hint(
|
|
client_id=client_id,
|
|
portal_base_url=portal_base_url,
|
|
custom_redirect_uri=custom_redirect_uri,
|
|
wrote_portal_url=wrote_portal_url,
|
|
public_url=public_url if wrote_public_url else "",
|
|
)
|