diff --git a/hermes_cli/dashboard_register.py b/hermes_cli/dashboard_register.py new file mode 100644 index 000000000..fbec62705 --- /dev/null +++ b/hermes_cli/dashboard_register.py @@ -0,0 +1,280 @@ +"""``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 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() -> str: + """Best-effort portal base URL from the stored Nous login, with default.""" + 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: str, + custom_redirect_uri: Optional[str], + timeout: float = 15.0, +) -> dict: + """POST to the portal's self-hosted-client endpoint and return the JSON body. + + 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] = {"name": name} + if custom_redirect_uri: + body["custom_redirect_uri"] = custom_redirect_uri + + 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, +) -> None: + """Print the success summary + the gate-engagement caveat.""" + from hermes_cli.config import get_env_path + + env_path = get_env_path() + print() + print(f" Wrote to {env_path}:") + print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}") + if wrote_portal_url: + print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_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_base_url = _resolve_portal_base_url() + + name = getattr(args, "name", None) or _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, + ) + 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) + + print(f'✓ Registered dashboard "{registered_name}"') + + # 3. Write env vars idempotently. Always set the client_id. Only set the + # portal URL when it isn't already configured (env or config) AND differs + # from the production default, so we don't clutter .env for the common case + # but DO persist a non-default portal (e.g. a preview deploy used in dev). + 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) + + 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 not existing_portal and portal_base_url.rstrip("/") != default_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 + + # 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, + ) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a13e21ceb..7624b2ffa 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11978,6 +11978,13 @@ def cmd_dashboard(args): ) +def cmd_dashboard_register(args): + """Register a self-hosted dashboard OAuth client with Nous Portal.""" + from hermes_cli.dashboard_register import cmd_dashboard_register as _impl + + _impl(args) + + def cmd_completion(args, parser=None): """Print shell completion script.""" from hermes_cli.completion import generate_bash, generate_zsh, generate_fish @@ -15288,6 +15295,39 @@ Examples: ) dashboard_parser.set_defaults(func=cmd_dashboard) + # `hermes dashboard register` — register a self-hosted dashboard OAuth + # client with Nous Portal and write the client_id into ~/.hermes/.env. + # Nested subparser so bare `hermes dashboard` keeps launching the server + # (set_defaults(func=cmd_dashboard) above remains the default). + dashboard_subparsers = dashboard_parser.add_subparsers( + dest="dashboard_subcommand" + ) + dashboard_register_parser = dashboard_subparsers.add_parser( + "register", + help="Register a self-hosted dashboard with Nous Portal (writes the OAuth client ID to .env)", + description=( + "Register this install as a self-hosted dashboard with your Nous " + "Portal account. Creates an OAuth client, writes " + "HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env, and prints " + "how to engage the login gate. Requires being logged in (hermes setup)." + ), + ) + dashboard_register_parser.add_argument( + "--name", + default=None, + help="Human-readable label for the dashboard (default: an auto-generated name)", + ) + dashboard_register_parser.add_argument( + "--redirect-uri", + dest="redirect_uri", + default=None, + help=( + "Optional public HTTPS OAuth redirect URI for the dashboard, e.g. " + "https://hermes.example.com/auth/callback. Omit for localhost-only use." + ), + ) + dashboard_register_parser.set_defaults(func=cmd_dashboard_register) + # ========================================================================= # desktop (a.k.a. gui) command # diff --git a/tests/hermes_cli/test_dashboard_register.py b/tests/hermes_cli/test_dashboard_register.py new file mode 100644 index 000000000..d24b61f60 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_register.py @@ -0,0 +1,189 @@ +"""Tests for ``hermes dashboard register``. + +Covers the CLI half of self-hosted dashboard registration: + - Docker-style auto-name generation + - not-logged-in fast-fail (AuthError with relogin_required) + - managed-install refusal + - the happy path: POST shape, env-var writes, custom redirect URI + - portal-URL write logic (only when non-default and not already set) + - portal HTTP error mapping (401/403) + +The portal HTTP call and the Nous token resolution are both mocked — this +file proves the CLI wiring + env-write behaviour. The live end-to-end token +round-trip against the Vercel preview build is a separate manual step. +""" + +from __future__ import annotations + +import argparse +import json +import urllib.error +from io import BytesIO +from unittest.mock import MagicMock, patch + +import pytest + +import hermes_cli.dashboard_register as dr + + +def _ns(**kw): + defaults = dict(name=None, redirect_uri=None) + defaults.update(kw) + return argparse.Namespace(**defaults) + + +class TestNameGenerator: + def test_shape_is_adjective_underscore_noun(self): + for _ in range(50): + name = dr._generate_dashboard_name() + assert "_" in name + adj, _, noun = name.partition("_") + assert adj in dr._NAME_ADJECTIVES + assert noun in dr._NAME_NOUNS + + +class TestFastFails: + def test_not_logged_in_exits_1_with_setup_hint(self, capsys): + from hermes_cli.auth import AuthError + + err = AuthError("not logged in", provider="nous", relogin_required=True) + with patch.object(dr, "cmd_dashboard_register", dr.cmd_dashboard_register): + with patch( + "hermes_cli.auth.resolve_nous_access_token", side_effect=err + ), patch("hermes_cli.config.is_managed", return_value=False): + with pytest.raises(SystemExit) as exc: + dr.cmd_dashboard_register(_ns()) + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "not logged into Nous Portal" in out + assert "hermes setup" in out + + def test_managed_install_refuses(self, capsys): + with patch("hermes_cli.config.is_managed", return_value=True): + with pytest.raises(SystemExit) as exc: + dr.cmd_dashboard_register(_ns()) + assert exc.value.code == 1 + out = capsys.readouterr().out + assert "not available in a managed" in out + + +def _fake_http_ok(payload: dict): + """Return a context-manager urlopen stub yielding `payload` as JSON.""" + cm = MagicMock() + cm.__enter__.return_value.read.return_value = json.dumps(payload).encode() + return cm + + +class TestHappyPath: + def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com", + response=None, captured=None): + response = response or { + "client_id": "agent:selfhost-1", + "id": "selfhost-1", + "name": "dreamy_tesla", + "kind": "SELF_HOSTED", + "custom_redirect_uri": None, + "created_at": "2026-06-04T12:00:00.000Z", + } + + def fake_urlopen(req, timeout=None): + if captured is not None: + captured["url"] = req.full_url + captured["headers"] = dict(req.header_items()) + captured["body"] = json.loads(req.data.decode()) + return _fake_http_ok(response) + + saved = {} + + def fake_save(key, value): + saved[key] = value + + with patch( + "hermes_cli.auth.resolve_nous_access_token", return_value=account_token + ), patch("hermes_cli.config.is_managed", return_value=False), patch.object( + dr, "_resolve_portal_base_url", return_value=portal + ), patch( + "hermes_cli.config.get_env_value", return_value=None + ), patch( + "hermes_cli.config.save_env_value", side_effect=fake_save + ), patch.object( + dr.urllib.request, "urlopen", side_effect=fake_urlopen + ): + dr.cmd_dashboard_register(args) + return saved + + def test_writes_client_id_and_posts_generated_name(self, capsys): + captured: dict = {} + saved = self._run(args=_ns(), captured=captured) + + # POST shape + assert captured["url"].endswith("/api/oauth/self-hosted-client") + assert captured["headers"]["Authorization"] == "Bearer tok_abc" + assert "name" in captured["body"] and captured["body"]["name"] + assert "custom_redirect_uri" not in captured["body"] + + # env write: client_id present, portal URL NOT written (default portal) + assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1" + assert "HERMES_DASHBOARD_PORTAL_URL" not in saved + + out = capsys.readouterr().out + assert "Registered dashboard" in out + assert "non-loopback bind" in out # the gate-engagement hint + + def test_explicit_name_is_sent(self, capsys): + captured: dict = {} + self._run(args=_ns(name="my_box"), captured=captured) + assert captured["body"]["name"] == "my_box" + + def test_custom_redirect_uri_is_forwarded(self, capsys): + captured: dict = {} + self._run( + args=_ns(redirect_uri="https://hermes.example.com/auth/callback"), + captured=captured, + ) + assert ( + captured["body"]["custom_redirect_uri"] + == "https://hermes.example.com/auth/callback" + ) + + def test_non_default_portal_is_persisted(self, capsys): + saved = self._run( + args=_ns(), + portal="https://nous-account-service-git-feat-x.vercel.app", + ) + assert ( + saved["HERMES_DASHBOARD_PORTAL_URL"] + == "https://nous-account-service-git-feat-x.vercel.app" + ) + + +class TestPortalErrors: + def _run_http_error(self, code, body): + err = urllib.error.HTTPError( + url="https://portal.nousresearch.com/api/oauth/self-hosted-client", + code=code, + msg="err", + hdrs=None, + fp=BytesIO(json.dumps(body).encode()), + ) + + with patch( + "hermes_cli.auth.resolve_nous_access_token", return_value="tok" + ), patch("hermes_cli.config.is_managed", return_value=False), patch.object( + dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com" + ), patch.object(dr.urllib.request, "urlopen", side_effect=err): + with pytest.raises(SystemExit) as exc: + dr.cmd_dashboard_register(_ns()) + return exc.value.code + + def test_401_maps_to_reauth_message(self, capsys): + code = self._run_http_error(401, {"error": "invalid_token"}) + assert code == 1 + assert "re-authenticate" in capsys.readouterr().out + + def test_403_surfaces_server_detail(self, capsys): + code = self._run_http_error( + 403, {"error": "access_denied", "error_description": "Not permitted here."} + ) + assert code == 1 + assert "Not permitted here." in capsys.readouterr().out