From a2db110cccb41f1178606eb0383d2381e217c3df Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:45:31 +0530 Subject: [PATCH] feat(auth): Nous free tier: free inference and connectors out of the box, one command to sign in (#105258) * feat(auth): Nous free tier core: anonymous identity minted on first use, welcome inference, shared-store scoping A fresh install with no provider sets up a free Nous identity (anonymous auth method of the nous provider) instead of forcing the setup wizard. The identity is persisted through the same path a real login uses, so the resolver ladder is unchanged. Two seams differ: token acquisition re-exchanges the anon credential (no refresh token), and routing pins the welcome host's single model nous/welcome. One identity per shared store; nous.guest: false turns the free tier off. * test(auth): free tier core contracts: lifecycle, resolver precedence, exchange seam, model pin * docs(user-guide): free tier and signing in New page explaining what a fresh install gets before any key or sign-in (free inference on nous/welcome plus connectors), how the free tier coexists with a user's own API key, how to sign in with hermes auth upgrade and keep connectors, how to turn the free tier off with nous.guest, what hermes logout does in each state, a troubleshooting table, and a plain privacy note. Wired into the Using Hermes sidebar. * fix(auth): logout leaves the free tier alone and clears the shared store for a real Nous account Logging out of the free tier is a no-op: it is not a login, so nothing is cleared and the user is told they were never signed in. Logging out of a real Nous account now also clears the cross-profile store, so a profile logout is not silently re-adopted on the next boot. * fix(model): switching off the free tier points at signing in, never hops providers * Name the free tier in the gateway startup notice and tell explicit-provider installs about it once * Render the Nous free tier as free tier on auth status, auth list, hermes status and portal info, short-circuit billing copy for it, and skip the keepalive when there is no refresh token * fix(auth): free tier is set up where nothing is configured: resolver last rung and first-run check Both the provider resolver's terminal rung and the CLI first-run check now try to set up the free tier before declaring nothing configured. On a fresh install the first command lands in chat on nous/welcome; a failed setup still falls through to the existing guidance. * Add hermes auth upgrade: sign the free tier into a Nous account while keeping its connectors The device-code flow runs as usual, with a promotion intent registered on the portal between the code request and the token poll so the account that approves the code inherits the free tier's connectors. The promotion status decides the outcome: only a completed one is followed by the token grant, which is persisted over the free-tier singleton and the shared store. Declined, superseded, retired and busy outcomes each print their own plain copy, and a retired identity is cleared so the next use sets up a fresh one. User-facing text never names the free tier's internals. * Show the Nous free tier as one picker row with nous/welcome and hide it when nous.guest is off * fix(auth): upgrade opens the consent page for this sign-in; one mint attempt per process; forced free tier wins the first-run check The browser leg of hermes auth upgrade now prints and opens the promotion claim URL with the claim code, not the generic device page. A failed mint is attempted once per process so several bootstrap sites cannot hit a closed gate or a 429 twice; a retired credential resets that so re-minting still happens. HERMES_FORCE_GUEST is honoured ahead of the first-run provider check. * fix(auth): pin the welcome model on the selected route, not on profile state; background setup retries after a failure A credential-pool entry can select a paid Nous key while the profile singleton is still the free tier. The model pin now keys on the resolved endpoint (welcome host) in agent init and /model, and the pin in model normalization is removed since it had no route to look at. A failed background identity setup releases its latch so a later attempt in the same process can try again. * fix(auth): decide the Nous model together with the route on every credential-pool swap The credential pool can move a Nous agent between the welcome host and the portal host after init. One helper, pin_model_for_route, now runs at init and inside every pool swap, so the welcome host always carries nous/welcome and a paid endpoint always keeps the caller's model. * fix(auth): apply the route model policy on every wire mode during a pool swap; release the setup latch if the thread cannot start * fix(auth): free-tier lifecycle takes profile then shared lock, reconciles with the shared store, persists the mint before exchanging, and clears only the identity that died The shared store is the identity of record for a Hermes root: a profile holding a stale free-tier identity adopts a sibling's newer sign-in instead of keeping the guest, and never overwrites the shared account. Locks are taken in the documented order (profile, then shared). A minted credential is stored as soon as create succeeds, so a rate-limited or timed-out exchange does not lose it and trigger a second mint. Retiring a dead credential removes only that credential from both stores. Guest exchange uses the resolver's canonical portal URL. * fix(auth): a credential rotation never rewrites the conversation model; connectors honour the off switch and replace a retired free-tier credential The welcome host serves one model, so a rotation onto it is refused for any conversation on another model instead of silently switching that conversation to nous/welcome (the model pin applies only when a route is first chosen). The connector token path now treats the free tier as absent when nous.guest is false, including cached tokens, and shares the one dead-credential rule with inference: a retired identity is replaced once rather than returning its stale token. * fix(auth): plain login never imports the free tier as OAuth credentials; the gateway startup line reads persisted state only A free-tier identity in the shared store is not an OAuth credential to offer for import; a real sign-in replaces it. The gateway's startup notice now answers provider precedence from persisted state (no token refresh at boot), so an expired free-tier token cannot stall the online message. --- agent/agent_init.py | 5 + agent/agent_runtime_helpers.py | 6 +- agent/client_lifecycle.py | 19 +- agent/credential_pool.py | 3 + gateway/run_notifications.py | 21 + hermes_cli/anon_auth.py | 650 ++++++++++++++++++ hermes_cli/auth.py | 38 + hermes_cli/auth_commands.py | 34 +- hermes_cli/auth_nous.py | 72 +- hermes_cli/cli_agent_setup_mixin.py | 26 + hermes_cli/config_defaults.py | 9 + hermes_cli/main.py | 18 + hermes_cli/main_provider_setup.py | 11 +- hermes_cli/model_setup_flows.py | 15 + hermes_cli/model_switch.py | 8 + hermes_cli/model_switch_providers.py | 37 +- hermes_cli/nous_account.py | 23 + hermes_cli/nous_auth_keepalive.py | 9 + hermes_cli/portal_cli.py | 9 +- hermes_cli/status_auth.py | 11 + hermes_cli/subcommands/auth.py | 5 + .../gateway/test_free_tier_startup_notice.py | 102 +++ tests/hermes_cli/test_anon_auth_core.py | 418 +++++++++++ tests/hermes_cli/test_anon_first_notice.py | 92 +++ tests/hermes_cli/test_anon_picker.py | 91 +++ tests/hermes_cli/test_anon_surfaces.py | 144 ++++ tests/hermes_cli/test_anon_upgrade.py | 178 +++++ tools/managed_tool_gateway.py | 48 +- website/docs/user-guide/free-tier.md | 140 ++++ website/sidebars.ts | 1 + 30 files changed, 2218 insertions(+), 25 deletions(-) create mode 100644 hermes_cli/anon_auth.py create mode 100644 tests/gateway/test_free_tier_startup_notice.py create mode 100644 tests/hermes_cli/test_anon_auth_core.py create mode 100644 tests/hermes_cli/test_anon_first_notice.py create mode 100644 tests/hermes_cli/test_anon_picker.py create mode 100644 tests/hermes_cli/test_anon_surfaces.py create mode 100644 tests/hermes_cli/test_anon_upgrade.py create mode 100644 website/docs/user-guide/free-tier.md diff --git a/agent/agent_init.py b/agent/agent_init.py index aa6574e654..e9a1fd4503 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -457,6 +457,11 @@ def _finalize_routing(agent, api_mode, credential_pool): if agent.provider not in _AGGREGATOR_PROVIDERS: agent.model = normalize_model_for_provider(agent.model, agent.provider) + # Nous model policy follows the ROUTE (the welcome host serves one model); a credential-pool + # swap can change the route later, so ``_swap_credential`` applies the same helper again. + from hermes_cli.anon_auth import pin_model_for_route + agent.model = pin_model_for_route(agent.provider, agent.base_url, agent.model) + # Auto-upgrade to Responses for GPT-5.x-style models and direct OpenAI URLs, unless # api_mode was explicit, the runtime is ACP (`acp://` clients route themselves, no # Responses surface) or Azure OpenAI (gpt-5.x on /chat/completions only). Provider diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 3cb43761ba..df57cae665 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -749,7 +749,8 @@ def _recover_auth_failure(agent, pool, *, status_code, has_retried_429, error_co ) return False, has_retried_429 _ra().logger.info("Credential auth failure — refreshed pool entry %s", getattr(refreshed, 'id', '?')) - agent._swap_credential(refreshed) + if agent._swap_credential(refreshed) is False: + return False, has_retried_429 return True, has_retried_429 @@ -847,8 +848,7 @@ def recover_with_credential_pool( "Credential %s (%s) — rotated to pool entry %s", rotate_status, label, getattr(next_entry, "id", "?"), ) - agent._swap_credential(next_entry) - return True + return agent._swap_credential(next_entry) is not False if effective_reason == FailoverReason.upstream_rate_limit: # Upstream (e.g. DeepSeek behind OpenRouter) is throttling the aggregator; the credential is # healthy. Do not rotate/exhaust; let fallback switch models. diff --git a/agent/client_lifecycle.py b/agent/client_lifecycle.py index eb2dc19eb8..06e3f18163 100644 --- a/agent/client_lifecycle.py +++ b/agent/client_lifecycle.py @@ -917,20 +917,30 @@ class ClientLifecycleMixin: if merged: self._client_kwargs["default_headers"] = merged - def _swap_credential(self, entry) -> None: + def _swap_credential(self, entry) -> bool: + """Adopt *entry* as the live credential. Returns False, changing nothing, when the entry's + route cannot serve this conversation's model (a conversation's model is never rewritten by a + rotation; the caller treats a refused swap as "no entry").""" runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") runtime_base = getattr(entry, "runtime_base_url", None) or getattr(entry, "base_url", None) or self.base_url from hermes_cli.providers import is_actual_route - if is_actual_route(getattr(self, "provider", ""), runtime_base): + actual_route = is_actual_route(getattr(self, "provider", ""), runtime_base) + if actual_route: from hermes_cli.auth import normalize_actual_base_url runtime_base = normalize_actual_base_url(runtime_base) + stripped_base = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base + # Refuse BEFORE any state changes below: a refused swap must leave the agent exactly as it was. + from hermes_cli.anon_auth import route_can_serve_model + if not route_can_serve_model(getattr(self, "provider", None), stripped_base, getattr(self, "model", None)): + logger.info("Credential %s skipped: its route cannot serve model %s", getattr(entry, "id", "?"), self.model) + return False + if actual_route: self.api_mode = "chat_completions" if hasattr(self, "_transport_cache"): self._transport_cache.clear() self._credential_pool_entry_id = getattr(entry, "id", None) from hermes_cli.route_identity import normalize_route_base_url route_changed = normalize_route_base_url(self.base_url) != normalize_route_base_url(runtime_base) - stripped_base = runtime_base.rstrip("/") if isinstance(runtime_base, str) else runtime_base if self.api_mode == "anthropic_messages": with suppress(Exception): self._anthropic_client.close() @@ -938,13 +948,14 @@ class ClientLifecycleMixin: self._anthropic_client = self._build_direct_anthropic_client(runtime_key, self._anthropic_base_url) self._is_anthropic_oauth = self._anthropic_oauth_flag(runtime_key) self.api_key, self.base_url = runtime_key, stripped_base - return + return True self.api_key, self.base_url = runtime_key, stripped_base # Inlined (not _sync_client_kwargs_credentials): tests call this unbound on a SimpleNamespace agent. self._client_kwargs["api_key"] = self.api_key self._client_kwargs["base_url"] = self.base_url self._reapply_route_client_config(route_changed=route_changed) self._replace_primary_openai_client(reason="credential_rotation") + return True def _reapply_route_client_config(self, *, route_changed: bool) -> None: """Recompute route-derived client kwargs (TLS material, default headers) for ``self.base_url``. diff --git a/agent/credential_pool.py b/agent/credential_pool.py index a311cae1e3..b76e53d866 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -167,6 +167,8 @@ _EXTRA_KEYS = frozenset({ "token_type", "scope", "client_id", "portal_base_url", "obtained_at", "expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused", "agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint", + # Nous guest identity (``auth_method: anonymous``): the anon_ credential is the refresh material. + "auth_method", "account_tier", "anon_token", "user_id", "org_id", # Classified failure semantics for the last exhaustion (agent/error_classifier.py). # Providers return 403 for both an edge throttle and a spending limit, so the # raw status cannot size a cooldown; persisted so a restart doesn't downgrade @@ -178,6 +180,7 @@ _EXTRA_KEYS = frozenset({ _NOUS_EXTRA_STATE_KEYS = ( "obtained_at", "expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused", "agent_key_obtained_at", + "auth_method", "account_tier", "anon_token", "user_id", "org_id", ) # ``replace(entry, **_CLEAR_STATUS)`` returns an entry with no error state. diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 205e0166aa..1d9f821c4f 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -742,6 +742,24 @@ class GatewayNotificationsMixin: logger.warning(failure_fmt, platform.value, home.chat_id, exc) return False + def _free_tier_startup_line(self) -> Optional[str]: + """Extra startup line when the gateway's inference is carried by the Nous free tier; None otherwise. + + Best-effort: a resolution failure (no provider, auth error) must not block the online notice.""" + try: + # Persisted state only: provider precedence is answered by the resolver WITHOUT touching + # the network (no token refresh at boot), and the free-tier check reads auth.json. + from hermes_cli.auth import resolve_provider + from hermes_cli.anon_auth import guest_carries_inference + if resolve_provider("auto") != "nous": + return None + if not guest_carries_inference(): + return None + except Exception as exc: + logger.debug("Free tier startup line skipped: %s", exc) + return None + return "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade" + async def _send_home_channel_startup_notifications( self, *, skip_targets: Optional[set[tuple[str, str, Optional[str]]]] = None ) -> set[tuple[str, str, Optional[str]]]: @@ -753,6 +771,9 @@ class GatewayNotificationsMixin: delivered: set[tuple[str, str, Optional[str]]] = set() skipped = skip_targets or set() message = "♻️ Gateway online — Hermes is back and ready." + free_tier_line = self._free_tier_startup_line() + if free_tier_line: + message = f"{message}\n{free_tier_line}" for platform, platform_cfg, home, transport in self._home_channel_transports(): if not platform_cfg.gateway_restart_notification: logger.info( diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py new file mode 100644 index 0000000000..cb42c7cd33 --- /dev/null +++ b/hermes_cli/anon_auth.py @@ -0,0 +1,650 @@ +"""Nous guest identity: the ``anonymous`` auth method of the ``nous`` provider. + +A fresh install mints an anonymous Nous account (``POST /api/anonymous/create``) and exchanges its +``anon_`` credential for short-lived JWTs (``POST /api/anonymous/token``). The result is persisted +through the same ``persist_nous_credentials`` a real login uses, so it is the singleton +``providers.nous`` *and* ``active_provider`` -- the resolver ladder (``resolve_provider``) is +untouched; ``active_provider`` is already its last-resort rung, so any explicit provider (env key, +``model.provider``, OpenRouter pool) beats the guest for inference while the guest keeps carrying the +tool-gateway JWT for connectors. + +Only two mechanics differ from an OAuth login and both are isolated behind ``is_guest_state``: +token acquisition (re-exchange the ``anon_`` credential; there is no refresh token) and routing +(the welcome inference host, single model ``nous/welcome``). + +Users are never shown the words guest / anonymous / account for this state: surfaces say +"Nous · free tier". The one user-facing verb is ``hermes auth upgrade`` (sign in, keeping the +identity's connectors). + +Lifecycle lives in ONE primitive, :func:`ensure_portal_identity`: adopt what the shared store already +holds, else mint under the shared-store lock. It is the only minter; nothing else calls +:func:`mint_guest`. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +from hermes_cli.auth_constants import ( + AuthError, DEFAULT_NOUS_PORTAL_URL, _decode_jwt_claims, httpx) + +logger = logging.getLogger("hermes_cli.auth") + +ANON_AUTH_METHOD = "anonymous" +ANON_CLIENT_ID = "nas-anonymous" +ANON_ACCOUNT_TIER = "anonymous" +GUEST_MODEL = "nous/welcome" +ANON_SECRET_HEADER = "x-anonymous-api-secret" +# The shared secret gates the anonymous surface during its integration phase. It is a deployment +# secret (Sid's), read from the environment only. +ANON_SECRET_ENV = "HERMES_ANON_API_SECRET" +# Dev lever: "1" makes the guest carry inference even when explicit providers exist; "new" also +# bypasses the shared store and mints a fresh guest for this process. Overrides ``nous.guest: false``. +FORCE_GUEST_ENV = "HERMES_FORCE_GUEST" +GUEST_MINT_TIMEOUT_SECONDS = 5.0 +# Copy shared by every surface that names the free tier (R-USR-1): never guest / anonymous / account. +FREE_TIER_LABEL = "Nous · free tier" +UPGRADE_HINT = "Run `hermes auth upgrade` to sign in with a Nous account." +FREE_TIER_NOT_SIGNED_IN = ( + "You're not signed in. Free inference and connectors are always on. " + "Run `hermes auth` to sign in with a Nous account.") + + +class AnonCredentialDead(AuthError): + """NAS no longer knows this ``anon_`` credential (reaped, or claimed into a real account). + + The one client rule for reap AND claim: mark dead, re-mint on the next need. + """ + + +def _anon_err(message: str, code: str) -> AuthError: + return AuthError(message, code=code) + + +def force_guest_mode() -> str: + """``""`` (off), ``"1"`` or ``"new"``; anything else truthy counts as ``"1"``.""" + raw = (os.environ.get(FORCE_GUEST_ENV) or "").strip().lower() + if not raw or raw in {"0", "false", "no", "off"}: + return "" + return "new" if raw == "new" else "1" + + +def guest_enabled() -> bool: + """``nous.guest`` (default True), overridden by the dev lever.""" + if force_guest_mode(): + return True + try: + from hermes_cli.config import load_config_readonly + nous_cfg = load_config_readonly().get("nous") + except Exception as exc: # config unreadable: keep today's behaviour (no guest) rather than mint + logger.debug("guest: config unreadable, treating nous.guest as false: %s", exc) + return False + if not isinstance(nous_cfg, dict): + return True + return bool(nous_cfg.get("guest", True)) + + +def is_guest_state(state: Any) -> bool: + return isinstance(state, dict) and state.get("auth_method") == ANON_AUTH_METHOD + + +def current_nous_state() -> Optional[Dict[str, Any]]: + """The profile's ``providers.nous`` state without locking or network (status/picker reads).""" + from hermes_cli.auth import _load_auth_store, _load_provider_state + try: + return _load_provider_state(_load_auth_store(), "nous") + except Exception as exc: + logger.debug("guest: auth store unreadable: %s", exc) + return None + + +def has_guest() -> bool: + return is_guest_state(current_nous_state()) + + +def guest_carries_inference() -> bool: + """True when the profile's Nous identity is the free tier and the free tier is on. + + Profile-level: use for status, picker and notice surfaces. Routing decisions (which model a + request may carry) must use :func:`route_is_welcome_host` on the SELECTED runtime instead: a + credential-pool entry can pick a paid Nous key while the profile singleton is still a guest. + """ + return guest_enabled() and has_guest() + + +WELCOME_HOSTS = frozenset({"welcome-api.nousresearch.com"}) + + +def pin_model_for_route(provider: Any, base_url: Any, model: Any) -> Any: + """Model policy at agent START: on the Nous welcome host the model is ``nous/welcome``; anywhere + else the caller's model stands. Used once, when the route is first finalized. Mid-conversation + route changes go through :func:`route_can_serve_model` instead: a conversation's model is never + silently rewritten by a credential rotation. + """ + if provider == "nous" and route_is_welcome_host(base_url): + if model and model != GUEST_MODEL: + logger.info("Nous free tier: using %s instead of configured model %s", GUEST_MODEL, model) + return GUEST_MODEL + return model + + +def route_can_serve_model(provider: Any, base_url: Any, model: Any) -> bool: + """Eligibility for a credential ROTATION: the welcome host serves only ``nous/welcome``, so a + conversation on any other model must not be rotated onto it (and a ``nous/welcome`` conversation + may move to the portal host, which serves it too). Non-Nous routes are always eligible.""" + if provider != "nous" or not route_is_welcome_host(base_url): + return True + return not model or model == GUEST_MODEL + + +def route_is_welcome_host(base_url: Any) -> bool: + """The routing predicate for the free tier: the welcome host serves exactly ``nous/welcome``. + + Keyed on the resolved endpoint, never on profile state, so a paid pool credential routed to the + portal host keeps its model even when a guest singleton exists beside it. + """ + from urllib.parse import urlparse + try: + host = (urlparse(str(base_url or "")).hostname or "").lower() + except ValueError: + return False + return host in WELCOME_HOSTS + + +def anon_secret() -> str: + return (os.environ.get(ANON_SECRET_ENV) or "").strip() + + +def _anon_headers() -> Dict[str, str]: + headers = {"content-type": "application/json"} + if secret := anon_secret(): + headers[ANON_SECRET_HEADER] = secret + return headers + + +def _raise_for_anon_status(response: httpx.Response, *, action: str) -> Dict[str, Any]: + try: + payload = response.json() + except ValueError: + payload = {} + if not isinstance(payload, dict): + payload = {} + error = str(payload.get("error") or "") + if response.status_code in (200, 201): + return payload + if response.status_code == 404 and error == "unknown_token": + raise AnonCredentialDead("Nous free-tier credential is no longer valid.", code="anon_credential_dead") + if response.status_code == 401 and error == "invalid_shared_secret": + raise _anon_err("Nous free tier is not open on this portal.", "anon_gate_closed") + if response.status_code == 401: + raise AnonCredentialDead("Nous free-tier credential was revoked.", code="anon_credential_dead") + if response.status_code == 429: + raise _anon_err("Nous free tier is rate limited; try again shortly.", "anon_rate_limited") + if response.status_code == 403 and error in {"anonymous_accounts_disabled", "circuit_open"}: + raise _anon_err("Nous free tier is currently disabled.", "anon_gate_closed") + raise _anon_err( + f"Nous free tier {action} failed ({response.status_code}{': ' + error if error else ''}).", + "anon_server_error") + + +def mint_guest(client: httpx.Client, portal_base_url: str) -> Dict[str, Any]: + """``POST /api/anonymous/create`` -> ``{user_id, org_id, token, idle_ttl_days}``. Token shown once.""" + response = client.post(f"{portal_base_url.rstrip('/')}/api/anonymous/create", headers=_anon_headers(), json={}) + payload = _raise_for_anon_status(response, action="sign-up") + token = payload.get("token") + if not isinstance(token, str) or not token.startswith("anon_"): + raise _anon_err("Nous free tier sign-up returned no credential.", "anon_server_error") + return payload + + +def exchange_anon_jwt(client: httpx.Client, portal_base_url: str, anon_token: str) -> Dict[str, Any]: + """``POST /api/anonymous/token {token}`` -> ``{access_token, expires_in, inference_base_url, ...}``. + + Raises :class:`AnonCredentialDead` on 404 ``unknown_token`` / 401 (reaped or claimed). + """ + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/token", headers=_anon_headers(), json={"token": anon_token}) + payload = _raise_for_anon_status(response, action="token exchange") + if not isinstance(payload.get("access_token"), str) or not payload["access_token"]: + raise _anon_err("Nous free tier token exchange returned no token.", "anon_server_error") + return payload + + +def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> None: + """Write a fresh exchange result into a guest state in place (token, expiry, routing).""" + from hermes_cli.auth_nous import _validate_nous_inference_url_from_network + access_token = exchanged["access_token"] + claims = _decode_jwt_claims(access_token) + now = datetime.now(timezone.utc) + exp = claims.get("exp") + if isinstance(exp, (int, float)): + expires_at = datetime.fromtimestamp(float(exp), tz=timezone.utc) + else: + expires_at = now + timedelta(seconds=int(exchanged.get("expires_in") or 900)) + inference_url = _validate_nous_inference_url_from_network(exchanged.get("inference_base_url")) + scope = claims.get("scope") or claims.get("scp") or state.get("scope") + if isinstance(scope, (list, tuple)): + scope = " ".join(str(s) for s in scope) + state.update( + access_token=access_token, token_type="Bearer", scope=scope, + obtained_at=now.isoformat(), expires_at=expires_at.isoformat(), + expires_in=max(0, int((expires_at - now).total_seconds())), + account_tier=str(claims.get("account_tier") or ANON_ACCOUNT_TIER)) + if inference_url: + state["inference_base_url"] = inference_url + for key in ("user_id", "org_id"): + if exchanged.get(key): + state[key] = exchanged[key] + state.pop("refresh_token", None) + + +def _portal_base_url() -> str: + from hermes_cli.auth_nous import _nous_portal_env_override + return (_nous_portal_env_override() or DEFAULT_NOUS_PORTAL_URL).rstrip("/") + + +def _shared_identity_key(state: Any) -> Optional[str]: + """Stable identity of a Nous credential: the anon_ token for a guest, the refresh token for an + account. Used to decide whether two stores hold the SAME identity.""" + if not isinstance(state, dict): + return None + return state.get("anon_token") if is_guest_state(state) else state.get("refresh_token") + + +def _mint_locked(client: httpx.Client, portal: str, auth_store: Dict[str, Any]) -> Dict[str, Any]: + """Mint under the caller's locks. The identity is persisted as soon as ``create`` succeeds, BEFORE + the exchange: a 429 or timeout on the exchange must not lose a credential NAS still honours (the + next attempt exchanges the stored one instead of minting again).""" + from hermes_cli.auth import _save_provider_state, _save_auth_store + from hermes_cli.auth_nous import _write_shared_nous_state + minted = mint_guest(client, portal) + state: Dict[str, Any] = { + "auth_method": ANON_AUTH_METHOD, "account_tier": ANON_ACCOUNT_TIER, + "anon_token": minted["token"], "client_id": ANON_CLIENT_ID, + "portal_base_url": portal.rstrip("/"), + "user_id": minted.get("user_id"), "org_id": minted.get("org_id"), + "idle_ttl_days": minted.get("idle_ttl_days"), + } + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + _write_shared_nous_state(state) + logger.info("Nous free tier ready (identity minted)") + return state + + +_background_lock = threading.Lock() +_background_started = False +# Per-process memos for the blocking path. ``_mint_failed``: one failed mint is enough for a process +# (several bootstrap sites call in sequence; a 429 or a closed gate must not be hit twice); +# ``clear_dead_guest`` resets it because a retired credential is a reason to mint again. +# ``_forced_new_done``: ``HERMES_FORCE_GUEST=new`` re-mints once per process, not on every resolution. +_mint_failed = False +_forced_new_done = False + + +def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Dict[str, Any]: + """The lifecycle body, run under profile lock THEN shared lock (the documented order). + + 1. The shared store is the identity of record for this Hermes root. If it holds an identity + that differs from the profile's, the profile adopts it (a stale guest never outlives a + sibling profile's sign-in, and never overwrites it). + 2. Otherwise the profile's own identity stands. + 3. Nothing anywhere: mint, persisting the credential before exchanging it. + ``force == "new"`` skips 1 and 2. + """ + from hermes_cli.auth import ( + _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, + _save_provider_state, _resolve_verify) + from hermes_cli.auth_nous import ( + _nous_http_client, _nous_shared_store_lock, _read_shared_nous_state, _write_shared_nous_state) + portal = _portal_base_url() + with _auth_store_lock(): + auth_store = _load_auth_store() + profile_state = _load_provider_state(auth_store, "nous") + with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds, 5.0)): + if force != "new": + shared = _read_shared_nous_state() + if shared and _shared_identity_key(shared) != _shared_identity_key(profile_state): + state = dict(shared) + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + logger.debug("Nous identity adopted from the shared store") + return state + if profile_state: + if not shared: + _write_shared_nous_state(profile_state) + return profile_state + verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None) + with _nous_http_client(timeout_seconds, verify) as client: + return _mint_locked(client, portal, auth_store) + + +def ensure_portal_identity(*, blocking: bool = True, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: + """Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared + store has none. Returns the ``providers.nous`` state, or None (disabled / non-blocking / failed). + + Order: ``nous.guest`` gate -> reconcile with the shared store -> mint. Locks are taken profile + first, then shared, matching every other Nous path. Non-blocking mode runs on a daemon thread + and returns None immediately; a failure there is logged at DEBUG (the guest is a fallback; a + fallback failing is not an error). + """ + global _mint_failed, _forced_new_done + if not guest_enabled(): + return None + force = force_guest_mode() + if force == "new" and _forced_new_done: + force = "1" + if _mint_failed and force != "new" and not current_nous_state(): + return None # this process already tried and failed; do not hammer the portal + + if blocking: + try: + result = _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds) + except Exception: + _mint_failed = True + raise + if force == "new": + _forced_new_done = True + return result + + global _background_started + with _background_lock: + if _background_started: + return None + _background_started = True + + def _run() -> None: + global _background_started + try: + _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds) + except Exception as exc: + logger.debug("Nous free tier background setup skipped: %s", exc) + # A transient failure must not consume the process's only attempt: release the latch + # so a later non-blocking call can try again (still one setup in flight at a time). + with _background_lock: + _background_started = False + + try: + threading.Thread(target=_run, name="nous-guest-identity", daemon=True).start() + except Exception as exc: # thread limit / interpreter shutdown: release so a later call can retry + with _background_lock: + _background_started = False + logger.debug("Nous free tier background setup could not start: %s", exc) + return None + + +def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: + """Token-acquisition seam for a guest: re-exchange the ``anon_`` credential in place. + + The portal URL is the resolver's canonical one (env override, else the validated stored URL, + else the default), never a raw stored value on its own. + Raises :class:`AnonCredentialDead` when NAS no longer knows the credential; the caller owns + re-minting (:func:`ensure_portal_identity` after :func:`clear_dead_guest`). + """ + anon_token = state.get("anon_token") + if not isinstance(anon_token, str) or not anon_token: + raise AnonCredentialDead("Nous free-tier credential is missing.", code="anon_credential_dead") + from hermes_cli.auth import _nous_portal_base_url + apply_exchange_to_state(state, exchange_anon_jwt(client, _nous_portal_base_url(state), anon_token)) + + +def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None: + """Drop a dead guest so the next need re-mints. + + Only the identity that actually failed is removed: a stale profile whose credential NAS rejected + must not erase a sibling profile's newer sign-in or replacement guest from the shared store. When + *dead_token* is None the profile's current guest is treated as the failed one. + """ + from hermes_cli.auth import ( + _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, _store_section) + from hermes_cli.auth_nous import _clear_shared_nous_state, _nous_shared_store_lock, _read_shared_nous_state + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + if is_guest_state(state): + token = dead_token or state.get("anon_token") + if state.get("anon_token") == token: + _store_section(auth_store, "providers").pop("nous", None) + _store_section(auth_store, "credential_pool").pop("nous", None) + if auth_store.get("active_provider") == "nous": + auth_store["active_provider"] = None + _save_auth_store(auth_store) + else: + token = dead_token + with _nous_shared_store_lock(): + shared = _read_shared_nous_state() + if token and is_guest_state(shared) and shared.get("anon_token") == token: + _clear_shared_nous_state(reason) + global _mint_failed + _mint_failed = False + logger.info("Nous free-tier identity retired (%s); a new one is set up on next use", reason) + + +# One-time CLI notice: an install whose inference is carried by an explicit provider learns once that +# the free tier (inference + connectors) now exists. The flag lives on the guest state itself so it +# dies with the identity; a fresh guest (re-mint, new profile) may announce itself once more. +GUEST_NOTICE_FLAG = "guest_notice_shown" +FREE_TIER_AVAILABLE_NOTICE = ( + "Free Nous inference and connectors are now available. " + "`hermes model` to try them, `hermes auth upgrade` to sign in.") + + +def guest_notice_pending() -> bool: + """True when a guest identity exists and the one-time availability notice has not been shown.""" + state = current_nous_state() + return is_guest_state(state) and not bool(state.get(GUEST_NOTICE_FLAG)) + + +def mark_guest_notice_shown() -> bool: + """Persist ``guest_notice_shown`` on the guest's ``providers.nous`` state (whichever store holds it). + + Returns True when a flag was written; False when there is no guest to mark.""" + from hermes_cli.auth import ( + _auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, _save_auth_store, + _store_section) + with _provider_state_transaction("nous") as (auth_store, state, source_path): + if not is_guest_state(state) or source_path is None: + return False + if state.get(GUEST_NOTICE_FLAG): + return True + state = dict(state) + state[GUEST_NOTICE_FLAG] = True + if _same_path(source_path, _auth_file_path()): + _store_section(auth_store, "providers")["nous"] = state + _save_auth_store(auth_store) + else: + source_store = _load_auth_store(source_path) + _store_section(source_store, "providers")["nous"] = state + _save_auth_store(source_store, target_path=source_path) + return True + + +# --- ``hermes auth upgrade``: sign the guest into a real Nous account, keeping its connectors --------- +# +# Wire: the normal device-code flow, with a promotion intent registered on NAS BETWEEN the code +# request and the token poll (``POST /api/anonymous/promotion-intent {token, user_code, device_code}``). +# NAS then transfers the guest's connectors into whichever account approves that device code. We +# watch ``POST /api/anonymous/promotion-status {claim_code}`` until it leaves ``pending``; only a +# ``completed`` promotion is followed by the token grant, which ``persist_nous_credentials`` writes +# over the guest singleton and the shared store. The server never reports expiry: our own +# ``expires_in`` clock ends the wait. User-facing copy never says guest / anonymous / claim. + +UPGRADE_START = "Sign in to keep your connectors and unlock more." +UPGRADE_ALREADY_SIGNED_IN = "Already signed in." +UPGRADE_DO_NOT_SHARE = "Do not share this code." +UPGRADE_TIMED_OUT = "Sign-in timed out; run the command again." +UPGRADE_NOT_COMPLETED = "Sign-in did not complete; run the command again." +UPGRADE_UNAVAILABLE = "The free tier is not available right now; run `hermes auth add nous` to sign in." +UPGRADE_REASON_COPY = { + "user_declined": "Sign-in was rejected in the browser.", + "superseded": "A newer sign-in code replaced this one.", + "account_retired": "This free-tier identity was already used or expired; a new one is set up on next use.", + "account_not_anonymous": "This free-tier identity was already used or expired; a new one is set up on next use.", + "account_busy": "The transfer could not run; run the command again.", +} +_RETIRED_REASONS = frozenset({"account_retired", "account_not_anonymous"}) +UPGRADED_AUTH_METHOD = "oauth_device_code" + + +def register_promotion_intent( + client: httpx.Client, portal_base_url: str, anon_token: str, *, user_code: str, device_code: str, +) -> Dict[str, Any]: + """``POST /api/anonymous/promotion-intent`` -> ``{claim_code, claim_url, expires_in, interval}``.""" + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-intent", headers=_anon_headers(), + json={"token": anon_token, "user_code": user_code, "device_code": device_code}) + payload = _raise_for_anon_status(response, action="sign-in") + if not isinstance(payload.get("claim_code"), str) or not payload["claim_code"]: + raise _anon_err("Nous free tier sign-in returned no transfer code.", "anon_server_error") + return payload + + +def _retry_after_seconds(response: httpx.Response, default: float) -> float: + raw = (response.headers.get("retry-after") or "").strip() + try: + return max(0.0, float(raw)) if raw else default + except ValueError: + return default + + +def wait_for_promotion( + client: httpx.Client, portal_base_url: str, claim_code: str, *, expires_in: int, interval: int, +) -> Dict[str, Any]: + """Poll ``POST /api/anonymous/promotion-status`` until it leaves ``pending`` or our clock runs out. + + Returns the final status payload; ``{"status": "timeout"}`` when ``expires_in`` elapsed. 429 honours + ``Retry-After``; other non-2xx statuses raise through :func:`_raise_for_anon_status`. + """ + deadline = time.monotonic() + max(1, int(expires_in)) + wait = max(0, int(interval)) + while time.monotonic() < deadline: + response = client.post( + f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-status", headers=_anon_headers(), + json={"claim_code": claim_code}) + if response.status_code == 429: + time.sleep(min(_retry_after_seconds(response, default=max(1, wait)), max(0.0, deadline - time.monotonic()))) + continue + payload = _raise_for_anon_status(response, action="sign-in") + if str(payload.get("status") or "unknown") != "pending": + return payload + time.sleep(wait) + return {"status": "timeout"} + + +def _account_state_from_token( + token_data: Dict[str, Any], *, portal_base_url: str, client_id: str, scope: Optional[str], verify: Any, + timeout_seconds: float, +) -> Dict[str, Any]: + """The ``providers.nous`` shape for the signed-in account (same fields the device-code login writes).""" + from hermes_cli.auth import PROVIDER_REGISTRY, _coerce_ttl_seconds, _optional_base_url, _tls_state_from_verify + from hermes_cli.auth_nous import _NOUS_EMPTY_AGENT_KEY_FIELDS, _iso_after, refresh_nous_oauth_from_state + now = datetime.now(timezone.utc) + ttl = _coerce_ttl_seconds(token_data.get("expires_in", 0)) + inference_url = ( + _optional_base_url(token_data.get("inference_base_url")) + or PROVIDER_REGISTRY["nous"].inference_base_url.rstrip("/")) + state = { + "portal_base_url": portal_base_url, "inference_base_url": inference_url, + "client_id": client_id, "scope": token_data.get("scope") or scope, + "token_type": token_data.get("token_type", "Bearer"), + "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"), + "obtained_at": now.isoformat(), "expires_at": _iso_after(now, ttl), "expires_in": ttl, + "tls": _tls_state_from_verify(verify), **_NOUS_EMPTY_AGENT_KEY_FIELDS} + state = refresh_nous_oauth_from_state(state, timeout_seconds=timeout_seconds, force_refresh=False) + state["auth_method"] = UPGRADED_AUTH_METHOD + return state + + +def _print_promotion_outcome(outcome: Dict[str, Any]) -> None: + status = str(outcome.get("status") or "unknown") + reason = str(outcome.get("reason") or "") + if status == "timeout": + print(UPGRADE_TIMED_OUT) + return + print(UPGRADE_REASON_COPY.get(reason, UPGRADE_NOT_COMPLETED)) + if reason in _RETIRED_REASONS: + clear_dead_guest("retired") + + +def upgrade_guest(args) -> int: + """``hermes auth upgrade``: sign in with a Nous account, transferring the free tier's connectors. + + Returns 0 on success (or when already signed in), 1 otherwise. Never persists anything unless the + promotion completed AND the token grant succeeded. + """ + from hermes_cli.auth import PROVIDER_REGISTRY, _resolve_verify + from hermes_cli.auth_device_flow import ( + _is_remote_session, _poll_for_token, _print_device_code_instructions, _request_device_code) + from hermes_cli.auth_nous import _nous_http_client, persist_nous_credentials + timeout_seconds = float(getattr(args, "timeout", None) or 15.0) + open_browser = not getattr(args, "no_browser", False) and not _is_remote_session() + state = current_nous_state() + if state and not is_guest_state(state): + print(UPGRADE_ALREADY_SIGNED_IN) + return 0 + if not state: + try: + state = ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) + except AuthError as exc: + print(f"{UPGRADE_UNAVAILABLE} ({exc})") + return 1 + if not is_guest_state(state): + print(UPGRADE_UNAVAILABLE) + return 1 + anon_token = str(state.get("anon_token") or "") + portal = (state.get("portal_base_url") or _portal_base_url()).rstrip("/") + pconfig = PROVIDER_REGISTRY["nous"] + client_id, scope = pconfig.client_id, pconfig.scope + verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None) + print(UPGRADE_START) + try: + with _nous_http_client(timeout_seconds, verify) as client: + device = _request_device_code(client, portal, client_id, scope) + intent = register_promotion_intent( + client, portal, anon_token, user_code=str(device["user_code"]), + device_code=str(device["device_code"])) + # The browser leg is the consent page for THIS sign-in (claim_url), not the generic + # device page: it shows both identities and the Move button. Relative paths are + # portal-relative. + claim_url = str(intent.get("claim_url") or "") + if claim_url.startswith("/"): + claim_url = f"{portal}{claim_url}" + _print_device_code_instructions( + claim_url or str(device["verification_uri_complete"]), str(intent["claim_code"]), + open_browser=open_browser, swallow_open_errors=True) + print(f" {UPGRADE_DO_NOT_SHARE}") + expires_in = min(int(device["expires_in"]), int(intent.get("expires_in") or device["expires_in"])) + interval = int(intent.get("interval") or device.get("interval") or 5) + print("Waiting for sign-in...") + outcome = wait_for_promotion(client, portal, intent["claim_code"], expires_in=expires_in, interval=interval) + if str(outcome.get("status")) != "completed": + _print_promotion_outcome(outcome) + return 1 + token_data = _poll_for_token( + client=client, portal_base_url=portal, client_id=client_id, + device_code=str(device["device_code"]), expires_in=max(1, expires_in), poll_interval=interval) + account_state = _account_state_from_token( + token_data, portal_base_url=portal, client_id=client_id, scope=scope, verify=verify, + timeout_seconds=timeout_seconds) + except AnonCredentialDead: + print(UPGRADE_REASON_COPY["account_retired"]) + clear_dead_guest("retired") + return 1 + except TimeoutError: + print(UPGRADE_TIMED_OUT) + return 1 + except KeyboardInterrupt: + print("\nSign-in cancelled.") + return 130 + except Exception as exc: + print(f"Sign-in failed: {exc}") + return 1 + persist_nous_credentials(account_state) + email = str(outcome.get("account_email") or "").strip() + print(f"Signed in as {email}. Your connectors are kept." if email else "Signed in. Your connectors are kept.") + return 0 diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e0cd0b81ca..74fc77277b 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1371,6 +1371,10 @@ def _logged_in_oauth_active_provider() -> Optional[str]: """auth.json ``active_provider`` when it is a registry provider that reports logged in.""" try: _maybe = _load_auth_store().get("active_provider") + if _maybe == "nous": + from hermes_cli.anon_auth import guest_enabled, has_guest + if has_guest() and not guest_enabled(): + return None # nous.guest: false — the free tier is off, so a guest is not a login if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"): return _maybe except Exception as e: @@ -1490,6 +1494,15 @@ def resolve_provider( return "bedrock" except ImportError: pass # boto3 not installed + # Nothing configured at all: set up the Nous free tier (blocking, short timeout). Success writes + # ``active_provider: nous``, which the OAuth rung above then picks up on every later call; + # failure of this fallback is not an error and falls through to the guidance below. + try: + from hermes_cli.anon_auth import ensure_portal_identity + if ensure_portal_identity(blocking=True) is not None: + return "nous" + except Exception as exc: + logger.debug("free tier setup during provider resolution skipped: %s", exc) raise AuthError( "No inference provider configured. Run 'hermes model' to choose a " "provider and model, or set an API key (OPENROUTER_API_KEY, " @@ -1628,6 +1641,21 @@ def resolve_nous_access_token( lock_timeout = max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) with _nous_shared_store_lock(timeout_seconds=lock_timeout): + from hermes_cli.anon_auth import is_guest_state, refresh_guest_state + if is_guest_state(state): + # Guest seam: the anon_ credential is the identity; a first use has no access token + # yet and an expired one is re-exchanged. No refresh token, no quarantine. + access_token = state.get("access_token") + if isinstance(access_token, str) and access_token and not _is_expiring( + state.get("expires_at"), refresh_skew_seconds): + return _memo(access_token) + with httpx.Client(timeout=httpx.Timeout(timeout_seconds or 15.0), + headers={"Accept": "application/json"}, verify=verify) as client: + refresh_guest_state(state, client) + persist() + _write_shared_nous_state(state) + return _memo(state["access_token"]) + merged_shared = _merge_shared_nous_oauth_state(state) access_token = state.get("access_token") refresh_token = state.get("refresh_token") @@ -2217,11 +2245,21 @@ def logout_command(args) -> None: if not target: print("No provider is currently logged in.") return + if target == "nous": + from hermes_cli.anon_auth import FREE_TIER_NOT_SIGNED_IN, is_guest_state + if is_guest_state(get_provider_auth_state("nous")): + # Free tier is not a login; there is nothing to log out of and nothing is cleared. + print(FREE_TIER_NOT_SIGNED_IN) + return should_reset_config = _should_reset_config_provider_on_logout(target) provider_name = get_auth_provider_display_name(target) if not (clear_provider_auth(target) or should_reset_config): print(f"No auth state found for {provider_name}.") return + if target == "nous": + # A profile logout must not be re-adopted from the cross-profile store on the next boot. + from hermes_cli.auth_nous import _clear_shared_nous_state + _clear_shared_nous_state("logout") if should_reset_config: _reset_config_provider() print(f"Logged out of {provider_name}.") diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 0b51aea7a7..78c3303707 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -420,6 +420,17 @@ def auth_priority_command(args) -> None: _report_priority(provider, pool, moved, requested, "Set", "to") +def _free_tier_lines() -> tuple[str, str]: + """The two-line free-tier rendering shared by every auth display surface (R-USR-1).""" + from hermes_cli.anon_auth import FREE_TIER_LABEL, GUEST_MODEL, UPGRADE_HINT + return f"{FREE_TIER_LABEL} · {GUEST_MODEL}", UPGRADE_HINT + + +def _is_free_tier_entry(entry) -> bool: + from hermes_cli.anon_auth import is_guest_state + return is_guest_state(getattr(entry, "extra", None)) + + def auth_list_command(args) -> None: provider_filter = _normalize_provider(getattr(args, "provider", "") or "") if provider_filter: @@ -436,6 +447,13 @@ def auth_list_command(args) -> None: if not entries: continue current = pool.peek() + if provider == "nous" and all(_is_free_tier_entry(e) for e in entries): + # The free tier is not a credential the user added; never list it as one. + label, hint = _free_tier_lines() + print(f"{provider}: {label}") + print(f" {hint}") + print() + continue print(f"{provider} ({len(entries)} credentials):") for idx, entry in enumerate(entries, start=1): marker = "← " if current is not None and entry.id == current.id else " " @@ -562,6 +580,12 @@ def auth_status_command(args) -> None: load_pool(provider) # runs the forked-grant heal first so the report reflects the consolidated grant status = auth_mod.get_auth_status(provider) _print_oauth_heal_notices() + if status.get("free_tier"): + # Free tier: not an account login, so no account fields; point at the upgrade path. + label, hint = _free_tier_lines() + print(f"{provider}: {label}") + print(f" {hint}") + return if not status.get("logged_in"): reason = status.get("error") print(f"{provider}: logged out" + (f" ({reason})" if reason else "")) @@ -756,10 +780,18 @@ def _interactive_strategy() -> None: print(f"Set {provider} strategy to: {strategy}") +def auth_upgrade_command(args) -> None: + """``hermes auth upgrade``: sign the free tier into a Nous account, keeping its connectors.""" + from hermes_cli.anon_auth import upgrade_guest + code = upgrade_guest(args) + if code: + raise SystemExit(code) + + _AUTH_ACTIONS = { "add": auth_add_command, "list": auth_list_command, "remove": auth_remove_command, "reset": auth_reset_command, "priority": auth_priority_command, "refresh": auth_refresh_command, "status": auth_status_command, - "logout": auth_logout_command, + "logout": auth_logout_command, "upgrade": auth_upgrade_command, "spotify": auth_spotify_command} diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 7289291d13..faef278718 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -103,7 +103,10 @@ def _migrate_stale_nous_portal_url(providers: Dict[str, Any]) -> None: # else would leak. Consulted only for URLs from the NETWORK side (Portal refresh responses); # the NOUS_INFERENCE_BASE_URL env override bypasses it (documented dev/staging escape hatch, the # user set it themselves). -_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({"inference-api.nousresearch.com"}) +_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({ + "inference-api.nousresearch.com", + # Free-tier (anonymous) host: serves the single ``nous/welcome`` model. + "welcome-api.nousresearch.com"}) def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]: @@ -329,7 +332,9 @@ def _shared_lock_timeout(timeout_seconds: float) -> float: # OAuth fields mirrored between a profile's Nous state and the shared cross-profile store. _NOUS_SHARED_STATE_KEYS = ( "access_token", "refresh_token", "token_type", "scope", "client_id", "portal_base_url", - "inference_base_url", "obtained_at", "expires_at") + "inference_base_url", "obtained_at", "expires_at", + # Guest (``auth_method: anonymous``) identity: the ``anon_`` credential is the refresh material. + "auth_method", "account_tier", "anon_token", "user_id", "org_id") def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: @@ -338,7 +343,7 @@ def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool: shared = _read_shared_nous_state() or {} shared_refresh = shared.get("refresh_token") if not _nonempty_str(shared_refresh): - return False + return False # a free-tier identity has no refresh token; nothing to merge into an OAuth state shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0 local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0 refresh_changed = shared_refresh.strip() != str(state.get("refresh_token") or "").strip() @@ -360,7 +365,9 @@ def _nous_shared_shape(src: Dict[str, Any]) -> Dict[str, Any]: "client_id": src.get("client_id") or DEFAULT_NOUS_CLIENT_ID, "portal_base_url": src.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL, "inference_base_url": src.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL, - "obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at")} + "obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at"), + **{k: src[k] for k in ("auth_method", "account_tier", "anon_token", "user_id", "org_id") + if src.get(k) not in (None, "")}} def _write_shared_nous_state(state: Dict[str, Any]) -> None: @@ -370,8 +377,10 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None: """ from hermes_cli.auth import _nonempty_str, _write_private_file_atomic refresh_token = state.get("refresh_token") - # No refresh_token = nothing worth sharing across profiles - if not (_nonempty_str(refresh_token) and _nonempty_str(state.get("access_token"))): + # Nothing worth sharing without refresh material: an OAuth refresh_token (with its access token), + # or a guest's anon_ credential, which is the whole identity and may not have been exchanged yet. + is_guest = _nonempty_str(state.get("anon_token")) + if not is_guest and not (_nonempty_str(refresh_token) and _nonempty_str(state.get("access_token"))): return shared = { "_schema": 1, **_nous_shared_shape(state), @@ -407,8 +416,8 @@ def _read_shared_nous_state() -> Optional[Dict[str, Any]]: return None if not isinstance(payload, dict): return None - has_tokens = ( - _nonempty_str(payload.get("refresh_token")) and _nonempty_str(payload.get("access_token"))) + has_tokens = _nonempty_str(payload.get("anon_token")) or ( + _nonempty_str(payload.get("access_token")) and _nonempty_str(payload.get("refresh_token"))) return payload if has_tokens else None @@ -919,6 +928,17 @@ class _NousRuntimeResolve: def ensure_usable_access_token(self, client: httpx.Client) -> None: """Merge from the shared store / refresh until the access token is a usable invoke JWT.""" + from hermes_cli.anon_auth import is_guest_state, refresh_guest_state + if is_guest_state(self.state): + # Guest seam: the anon_ credential is the refresh material; re-exchange instead of + # redeeming a rotating refresh token. Quarantine never applies to a guest. + if self.force_refresh or self.invoke_jwt_status() is not None: + refresh_guest_state(self.state, client) + self.access_token = self.state["access_token"] + self.stored_inference_base_url = self.state.get("inference_base_url") or self.stored_inference_base_url + self.inference_base_url = _nous_inference_env_override() or self.stored_inference_base_url + self.persist("guest_exchange") + return if not self.has_access_token(): with self.shared_lock(): if self.merge_shared(): @@ -944,6 +964,30 @@ def resolve_nous_runtime_credentials( stale_access_token: Optional[str] = None) -> Dict[str, Any]: """Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock). + A guest whose ``anon_`` credential NAS no longer knows (reaped or claimed) is retired and a new + identity is set up once, transparently -- the one client rule covering both reap and claim. + """ + from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest, ensure_portal_identity + try: + return _resolve_nous_runtime_credentials( + timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle, + force_refresh=force_refresh, stale_access_token=stale_access_token) + except AnonCredentialDead: + from hermes_cli.auth import get_provider_auth_state + dead = get_provider_auth_state("nous") or {} + clear_dead_guest("anon_credential_dead", dead_token=dead.get("anon_token")) + if ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) is None: + raise + return _resolve_nous_runtime_credentials( + timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle) + + +def _resolve_nous_runtime_credentials( + *, timeout_seconds: float = 15.0, insecure: Optional[bool] = None, + ca_bundle: Optional[str] = None, force_refresh: bool = False, + stale_access_token: Optional[str] = None) -> Dict[str, Any]: + """Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock). + ``stale_access_token`` is the bearer that just failed upstream (401): with ``force_refresh``, the refresh POST is skipped if the store (re-read under the lock) already holds a *different* usable token — a peer won the rotation; adopt it rather than invalidate a sibling's token. @@ -1042,7 +1086,9 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]: def _nous_status_from_state( state: Dict[str, Any], *, logged_in: bool, source: str) -> Dict[str, Any]: """Auth-store-backed Nous status snapshot (shared by the live and refresh-free variants).""" + from hermes_cli.anon_auth import is_guest_state access_token = state.get("access_token") + account_tier = state.get("account_tier") return { "logged_in": logged_in, "portal_base_url": state.get("portal_base_url"), "inference_base_url": state.get("inference_base_url"), @@ -1050,7 +1096,10 @@ def _nous_status_from_state( "agent_key_expires_at": state.get("agent_key_expires_at"), "has_refresh_token": bool(state.get("refresh_token")), "access_token": access_token, "inference_credential_present": bool(access_token or state.get("agent_key")), - "credential_source": "auth_store", "source": source} + "credential_source": "auth_store", "source": source, + # Free tier: display surfaces render it with the free-tier copy, never as an account login. + "account_tier": account_tier if isinstance(account_tier, str) else None, + "free_tier": is_guest_state(state)} def _compute_nous_auth_status() -> Dict[str, Any]: @@ -1394,8 +1443,11 @@ def _offer_shared_nous_import(timeout_seconds: float) -> Optional[Dict[str, Any] auth state when the user accepted and the import succeeded, else None. """ from hermes_cli.auth import _prompt_yes_no, _read_shared_nous_state + from hermes_cli.anon_auth import is_guest_state shared = _read_shared_nous_state() - if not shared: + if not shared or is_guest_state(shared): + # A free-tier identity is not an OAuth credential to import; a real sign-in replaces it + # (persist_nous_credentials overwrites the singleton and the shared store). return None try: shared_path = _nous_shared_store_path() diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 0c24f4c214..1dc6432bdf 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -198,6 +198,18 @@ class CLIAgentSetupMixin: api_key = runtime.get("api_key") base_url = runtime.get("base_url") resolved_provider = runtime.get("provider", "openrouter") + if resolved_provider != "nous": + # Explicit provider carries inference; the free tier still sets itself up (background, + # nothing waits) so connectors have a bearer. No-op when an identity exists or the + # free tier is off. + try: + from hermes_cli.anon_auth import ensure_portal_identity + ensure_portal_identity(blocking=False) + except Exception as exc: + logger.debug("free tier background setup skipped: %s", exc) + # The mint above may land after this turn, so the one-time "free tier is here" notice is + # checked on every credential resolve and printed the first time an identity is seen. + self._maybe_print_free_tier_available_notice() resolved_routing = ( resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"), list(runtime.get("args") or [])) @@ -264,6 +276,20 @@ class CLIAgentSetupMixin: self._active_agent_route_signature = None return True + def _maybe_print_free_tier_available_notice(self) -> None: + """One-time notice for installs whose inference is carried by an explicit provider: the free + tier (inference + connectors) now exists. Printed the first time an identity is present, then + flagged on that identity so it never repeats. Never blocks or raises.""" + from cli import logger + try: + from hermes_cli import anon_auth + if not anon_auth.guest_notice_pending(): + return + self._console_print(f"[dim]{anon_auth.FREE_TIER_AVAILABLE_NOTICE}[/]") + anon_auth.mark_guest_notice_shown() + except Exception as exc: + logger.debug("free tier availability notice skipped: %s", exc) + def _resolve_fallback_runtime(self, primary_exc): """Primary provider resolution failed: on an AuthError try each fallback entry in order and switch the CLI's requested_provider/model to the first that resolves. diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 665a458135..d2902f298b 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2335,6 +2335,10 @@ DEFAULT_CONFIG = { # 14-20% of consecutive calls in concurrent tool loops (measured 2026-09-06; # NousResearch/api#227), so chat is the default until that is fixed. "anthropic_wire": "chat", + # Nous free tier: with no other provider configured, Hermes sets up a free Nous identity on + # first use (inference on nous/welcome + connectors) and offers `hermes auth upgrade` to + # sign in. false turns the free tier off entirely: nothing is set up and nothing is used. + "guest": True, }, # Google Vertex AI (Gemini). Auth is OAuth2 from a service-account JSON or ADC, NOT an API key; # the credential path lives in .env (VERTEX_CREDENTIALS_PATH / GOOGLE_APPLICATION_CREDENTIALS). @@ -2411,6 +2415,11 @@ def _base_url(name, prompt_name=None): OPTIONAL_ENV_VARS = { # ── Provider (handled in provider selection, not shown in checklists) ── "NOUS_BASE_URL": _base_url("Nous Portal"), + "HERMES_ANON_API_SECRET": _env( + "Shared secret for the Nous free-tier sign-up endpoints while they are in their gated " + "integration phase (not needed once the gate is removed)", + "Nous free-tier shared secret (leave empty unless given one)", password=True, + category="provider", advanced=True), "OPENROUTER_API_KEY": _env("OpenRouter API key (for vision, web scraping helpers, and MoA)", "OpenRouter API key", url="https://openrouter.ai/keys", password=True, tools=["vision_analyze"], category="provider", advanced=True), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 24988010e5..ca0cd6f906 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -968,6 +968,16 @@ def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool: from hermes_cli.config import DEFAULT_CONFIG, get_env_path, get_hermes_home, load_config from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status + # Dev lever: HERMES_FORCE_GUEST makes the free tier the answer regardless of what else is + # configured ("new" also re-mints once per process). Kept ahead of every other check on purpose. + try: + from hermes_cli.anon_auth import ensure_portal_identity, force_guest_mode + if force_guest_mode(): + return ensure_portal_identity(blocking=True) is not None + except Exception as exc: + logger.debug("forced free tier setup failed: %s", exc) + return False + cfg = load_config() model_cfg = cfg.get("model") _model_name = model_cfg if isinstance(model_cfg, str) else "" @@ -1043,6 +1053,14 @@ def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool: except Exception: pass + # Nothing explicit anywhere: the Nous free tier counts as configured once its identity exists. + # Setting it up here (blocking, short timeout) is the first-run path for a fresh install; any + # failure means "not configured" and the setup guard takes over as before. + try: + from hermes_cli.anon_auth import ensure_portal_identity + return ensure_portal_identity(blocking=True) is not None + except Exception as exc: + logger.debug("free tier setup on first run skipped: %s", exc) return False diff --git a/hermes_cli/main_provider_setup.py b/hermes_cli/main_provider_setup.py index 863fbf343a..6e18cb4035 100644 --- a/hermes_cli/main_provider_setup.py +++ b/hermes_cli/main_provider_setup.py @@ -816,7 +816,16 @@ def _build_provider_picker_rows(config: dict, active: str, provider_labels: dict _add(f"group:{gid}", label, row["members"], bool(active_group) and gid == active_group) else: slug = row["slug"] - _add(slug, canonical_descs.get(slug, provider_labels.get(slug, slug)), [], bool(active) and slug == active) + label = canonical_descs.get(slug, provider_labels.get(slug, slug)) + if slug == "nous": + # Same free-tier rule as the gateway/TUI pickers: relabel for a guest, hide + # when nous.guest is off, untouched for a real account. + from hermes_cli.model_switch_providers import _free_tier_nous_row + tier_row = _free_tier_nous_row({"name": label, "models": []}) + if tier_row is None: + continue + label = tier_row["name"] + _add(slug, label, [], bool(active) and slug == active) for key, provider_info in custom_provider_map.items(): saved_model = provider_info.get("model", "") diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index f6e7108c15..a3dbbe35fa 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -289,6 +289,21 @@ def _model_flow_nous(config, current_model="", args=None): # instead of the hundreds returned by the live /models endpoint. from hermes_cli.models import check_nous_free_tier, get_curated_nous_model_ids from hermes_cli.models_pricing import get_pricing_for_provider + from hermes_cli.model_switch_providers import _free_tier_nous_row + tier_row = _free_tier_nous_row({"name": "Nous Portal", "models": []}) + if tier_row is None: + print("Nous free tier is switched off (nous.guest: false); sign in with `hermes auth upgrade` to use Nous models.") + return + if tier_row["models"]: + # Free-tier identity: the welcome host serves the single pinned model; no Portal catalog, + # pricing, or account lookups apply. + creds = _nous_verified_credentials() + if creds is None: + return + selected = tier_row["models"][0] + _nous_persist_selection(selected, creds) + print(f"Default model set to: {selected} (via {tier_row['name']})") + return model_ids = get_curated_nous_model_ids() if not model_ids: print("No curated models available for Nous Portal.") diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index ac49fb7e5a..69b40092b1 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1220,6 +1220,14 @@ def _route_from_model_input(st: _Switch) -> Optional[ModelSwitchResult]: # Steps d.5 / e only apply while the request is still unrouted on the current provider. if st.resolved_alias or resolved_in_current_catalog or st.target_provider != current_provider: return None + if current_provider == "nous": + # The welcome host serves nous/welcome only; a model outside it needs an account or a key. + # Never hop to another provider on the user's behalf here (there is no key to hop to). + from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host + if route_is_welcome_host(st.current_base_url) and st.new_model != GUEST_MODEL: + return st.fail( + f"{st.new_model} needs a Nous account or an API key. " + "Run `hermes auth upgrade` to sign in, or `hermes model` to pick another provider.") config_routed = _route_configured_provider(st) # d.5 — deliberately NOT gated on ``not is_custom`` if isinstance(config_routed, ModelSwitchResult): return config_routed diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py index 00014ab4e2..8a307a738d 100644 --- a/hermes_cli/model_switch_providers.py +++ b/hermes_cli/model_switch_providers.py @@ -437,6 +437,27 @@ def _nous_picker_model_ids(curated: dict, force_fresh_nous_tier: bool) -> list: return model_ids +def _free_tier_nous_row(row: dict) -> dict | None: + """The one free-tier rule for a Nous picker row, shared by every row builder. + + ``row`` carries at least ``name`` and ``models``. A guest identity carrying inference turns + it into "Nous · free tier" with the single model ``nous/welcome`` (the welcome host serves + nothing else). A guest that ``nous.guest: false`` has switched off yields ``None``: no Nous + row at all, since there is nothing selectable. A real account (or no Nous state) passes the + row through untouched. Builders that compute the full catalog lazily should pass + ``models=[]`` and only compute when the returned row still has no models.""" + from hermes_cli import anon_auth + if not anon_auth.has_guest(): + return row + if not anon_auth.guest_enabled(): + return None + out = dict(row) + out["name"] = anon_auth.FREE_TIER_LABEL + out["models"] = [anon_auth.GUEST_MODEL] + out["total_models"] = 1 + return out + + def _cap_models(model_ids: list, max_models: int | None, slug: str = "") -> list: """Apply ``max_models``; aggregators in ``_UNCAPPED_PICKER_PROVIDERS`` show everything.""" if slug in _UNCAPPED_PICKER_PROVIDERS or max_models is None: @@ -653,10 +674,16 @@ class _PickerBuild: def add_builtin_row( self, slug: str, name: str, is_current: bool, model_ids: list, source: str, *, uncapped_ok: bool = True, ) -> None: - self.results.append({ + row = { "slug": slug, "name": name, "is_current": is_current, "is_user_defined": False, "models": _cap_models(model_ids, self.max_models, slug if uncapped_ok else ""), - "total_models": len(model_ids), "source": source}) + "total_models": len(model_ids), "source": source} + if slug == "nous": + # Free-tier identity: one row "Nous · free tier" / nous/welcome, or no row when + # nous.guest is off. Still marks the slug seen so a later lap cannot re-emit it. + row = _free_tier_nous_row(row) + if row is not None: + self.results.append(row) self.seen_slugs.add(slug.lower()) self.record_builtin_endpoint(slug) @@ -801,7 +828,11 @@ def _lap_overlay_rows(b: _PickerBuild, data: dict) -> None: elif overlay.auth_type == "aws_sdk": model_ids = _aws_live_or_curated_ids(hermes_slug, b.curated, hermes_slug, pid) elif hermes_slug == "nous": - model_ids = _nous_picker_model_ids(b.curated, b.force_fresh_nous_tier) + # A guest identity never needs the Portal catalog: add_builtin_row pins nous/welcome + # (or drops the row when nous.guest is off), so only a real account fetches. + tier_row = _free_tier_nous_row({"name": get_label(hermes_slug), "models": []}) + real_account = tier_row is not None and not tier_row["models"] + model_ids = _nous_picker_model_ids(b.curated, b.force_fresh_nous_tier) if real_account else [] else: model_ids = _live_or_curated_ids(hermes_slug, b.curated, hermes_slug, pid) b.add_builtin_row( diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index c3a0b92974..a7cb2f2cec 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -67,6 +67,15 @@ class NousToolAccessInfo: coverage: dict[str, bool] = field(default_factory=dict) +_ANON_ACCOUNT_TIER = "anonymous" +# Every billing / top-up / entitlement surface says exactly this for the free tier (R-USR-1). +FREE_TIER_NEEDS_ACCOUNT = "This needs a Nous account. Run `hermes auth upgrade`." + + +def _is_anonymous_tier(account_info: Optional["NousPortalAccountInfo"]) -> bool: + return account_info is not None and account_info.account_tier == _ANON_ACCOUNT_TIER + + @dataclass(frozen=True) class NousPortalAccountInfo: logged_in: bool @@ -93,11 +102,18 @@ class NousPortalAccountInfo: raw_claims: Optional[dict[str, Any]] = None raw_account: Optional[dict[str, Any]] = None error: Optional[str] = None + # NAS account tier claim; ``"anonymous"`` is the free tier (no Nous account behind it). + account_tier: Optional[str] = None @property def is_paid(self) -> bool: return self.paid_service_access is True + @property + def is_anonymous_tier(self) -> bool: + """The free tier: no Nous account, so no billing, credits, or entitlement to speak of.""" + return self.account_tier == _ANON_ACCOUNT_TIER + @property def is_free_tier(self) -> bool: return self.paid_service_access is False @@ -154,6 +170,8 @@ def format_nous_portal_entitlement_message( access doesn't fund it gets a neutral billing nudge, never an "exhausted" message. The pool-vs-paid distinction is never surfaced. """ + if _is_anonymous_tier(account_info): + return FREE_TIER_NEEDS_ACCOUNT billing_url = nous_portal_billing_url(account_info) if account_info is not None: @@ -209,6 +227,8 @@ def format_nous_portal_entitlement_message( def _no_paid_access_message(account_info: NousPortalAccountInfo, capability: str, billing_url: str) -> str: + if _is_anonymous_tier(account_info): + return FREE_TIER_NEEDS_ACCOUNT access = account_info.paid_service_access_info or NousPaidServiceAccessInfo() active, paid = access.has_active_subscription, access.active_subscription_is_paid labelled = ( @@ -478,6 +498,7 @@ def _info_from_valid_jwt( paid_service_access=paid_access, paid_service_access_info=access_info, tool_access=_tool_access_from_value(claims.get("tool_access")), raw_claims=dict(claims), + account_tier=_coerce_str(claims.get("account_tier")) or _coerce_str(state.get("account_tier")), ) @@ -506,6 +527,8 @@ def _info_from_account_payload( paid_service_access=paid_access, paid_service_access_info=access, tool_access=_tool_access_from_value(payload.get("tool_access")), raw_account=dict(payload), + account_tier=_coerce_str(payload.get("account_tier")) or _coerce_str(user.get("account_tier")) + or _coerce_str(state.get("account_tier")), ) diff --git a/hermes_cli/nous_auth_keepalive.py b/hermes_cli/nous_auth_keepalive.py index 580408e8b5..67d55e9a66 100644 --- a/hermes_cli/nous_auth_keepalive.py +++ b/hermes_cli/nous_auth_keepalive.py @@ -196,6 +196,15 @@ def start_nous_auth_keepalive( interval_seconds = _interval_seconds(interval_seconds) if interval_seconds <= 0: return None + # The free tier has no refresh token to keep alive: its access token is re-minted from the + # anon credential on demand by the request path, so a background refresher has nothing to do. + from hermes_cli.anon_auth import is_guest_state + try: + if is_guest_state(get_provider_auth_state("nous")): + logger.debug("Nous auth keepalive skipped: free tier has no refresh token") + return None + except Exception: + pass global _keepalive_thread with _keepalive_lock: if _keepalive_thread is not None and _keepalive_thread.is_alive(): diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index 5b516c0640..389a22b347 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -46,8 +46,15 @@ def _cmd_status(args) -> int: except Exception: auth = {} logged_in = bool(auth.get("logged_in")) + free_tier = bool(auth.get("free_tier")) _heading("Nous Portal") - if logged_in: + if free_tier: + from hermes_cli.anon_auth import FREE_TIER_LABEL, GUEST_MODEL, UPGRADE_HINT + print(f" Auth: {color(f'{FREE_TIER_LABEL} · {GUEST_MODEL}', Colors.GREEN)}") + print(f" {UPGRADE_HINT}") + if auth.get("inference_base_url"): + print(f" API: {auth['inference_base_url']}") + elif logged_in: print(f" Auth: {color('✓ logged in', Colors.GREEN)}") print(f" Portal: {auth.get('portal_base_url') or DEFAULT_PORTAL_URL}") if auth.get("inference_base_url"): diff --git a/hermes_cli/status_auth.py b/hermes_cli/status_auth.py index afea2536f2..640f305a43 100644 --- a/hermes_cli/status_auth.py +++ b/hermes_cli/status_auth.py @@ -124,6 +124,17 @@ def _render_auth_providers(ctx): ctx.nous_inference_present = inference = bool( nous_status.get("inference_credential_present") or (info and info.inference_credential_present) ) + if nous_status.get("free_tier"): + # Free tier: never rendered as an account login (no account ids, no refresh row). + from hermes_cli.anon_auth import FREE_TIER_LABEL, GUEST_MODEL, UPGRADE_HINT + _status._row("Nous Portal", True, f"{FREE_TIER_LABEL} · {GUEST_MODEL}") + _status._detail("", UPGRADE_HINT) + inference_url = nous_status.get("inference_base_url") + if inference_url: + _status._detail("Inference:", inference_url) + for name, getter, hint, rows in _OAUTH_BLOCKS: + _oauth_block(name, statuses.get(getter, {}), hint, rows) + return nous_error = nous_status.get("error") _status._row("Nous Portal", logged_in, "logged in" if logged_in else "not logged in (Nous inference key configured)" if inference diff --git a/hermes_cli/subcommands/auth.py b/hermes_cli/subcommands/auth.py index 70df5fec23..686f9ffb1e 100644 --- a/hermes_cli/subcommands/auth.py +++ b/hermes_cli/subcommands/auth.py @@ -59,6 +59,11 @@ def build_auth_parser(subparsers, *, cmd_auth: Callable) -> None: auth_logout = auth_subparsers.add_parser( "logout", help="Log out a provider and clear stored auth state") auth_logout.add_argument("provider", help="Provider id") + auth_upgrade = auth_subparsers.add_parser( + "upgrade", help="Sign in with a Nous account, keeping your connectors") + auth_upgrade.add_argument( + "--no-browser", action="store_true", help="Do not auto-open a browser for sign-in") + auth_upgrade.add_argument("--timeout", type=float, help="Network timeout in seconds") auth_spotify = auth_subparsers.add_parser( "spotify", help="Authenticate Hermes with Spotify via PKCE") auth_spotify.add_argument( diff --git a/tests/gateway/test_free_tier_startup_notice.py b/tests/gateway/test_free_tier_startup_notice.py new file mode 100644 index 0000000000..036e26b617 --- /dev/null +++ b/tests/gateway/test_free_tier_startup_notice.py @@ -0,0 +1,102 @@ +"""Home-channel startup notice names the free tier only when a guest carries the gateway's inference.""" + +import base64 +import json +import time + +import pytest +from unittest.mock import AsyncMock + +import gateway.run as gateway_run +from gateway.config import HomeChannel, Platform +from gateway.platforms.base import SendResult +from hermes_cli import anon_auth +from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store +from tests.gateway.restart_test_helpers import make_restart_runner + +FREE_TIER_LINE = "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade" + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +def _seed_nous(state: dict) -> None: + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})["nous"] = state + store["active_provider"] = "nous" + _save_auth_store(store) + + +def _guest_state() -> dict: + return {"auth_method": anon_auth.ANON_AUTH_METHOD, "account_tier": "anonymous", "anon_token": "anon_0001", + "client_id": "nas-anonymous", "access_token": _jwt(), "expires_at": "2999-01-01T00:00:00+00:00", + "inference_base_url": "https://welcome-api.nousresearch.com/v1"} + + +def _account_state() -> dict: + return {"auth_method": "oauth", "access_token": _jwt(client_id="hermes-cli", account_tier="pro"), + "refresh_token": "rt", "expires_at": "2999-01-01T00:00:00+00:00"} + + +@pytest.fixture +def nous_runner(tmp_path, monkeypatch): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + # Provider precedence gates the line and is answered from persisted state only (no network at boot). + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + runner, adapter = make_restart_runner() + runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="home-1", name="Home") + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home")) + return runner, adapter + + +async def _startup_message(runner, adapter) -> str: + delivered = await runner._send_home_channel_startup_notifications() + assert delivered == {("telegram", "home-1", None)} + adapter.send.assert_called_once() + return adapter.send.call_args.args[1] + + +@pytest.mark.asyncio +async def test_guest_inference_adds_exactly_one_free_tier_line(nous_runner): + runner, adapter = nous_runner + _seed_nous(_guest_state()) + assert anon_auth.guest_carries_inference() + + message = await _startup_message(runner, adapter) + + lines = message.splitlines() + assert lines[0] == "♻️ Gateway online — Hermes is back and ready." + assert lines[1:] == [FREE_TIER_LINE] + assert "guest" not in message.lower() and "anonymous" not in message.lower() + + +@pytest.mark.asyncio +async def test_signed_in_account_keeps_the_plain_online_notice(nous_runner): + runner, adapter = nous_runner + _seed_nous(_account_state()) + assert not anon_auth.guest_carries_inference() + + message = await _startup_message(runner, adapter) + + assert message == "♻️ Gateway online — Hermes is back and ready." + + +@pytest.mark.asyncio +async def test_non_nous_provider_never_mentions_the_free_tier(nous_runner, monkeypatch): + runner, adapter = nous_runner + _seed_nous(_guest_state()) # identity exists for connectors, but inference is elsewhere + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") # an explicit key wins provider precedence + + message = await _startup_message(runner, adapter) + + assert message == "♻️ Gateway online — Hermes is back and ready." diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py new file mode 100644 index 0000000000..e1f4941b02 --- /dev/null +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -0,0 +1,418 @@ +"""Nous free tier core: identity lifecycle, token-acquisition seam, routing pin, opt-out. + +Behaviour contracts on the public seams (``ensure_portal_identity``, ``resolve_provider``, +``resolve_runtime_provider``, ``normalize_model_for_provider``), driven through a fake portal so +the wire contract is exercised, never mocked away. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from pathlib import Path + +import httpx +import pytest + +from hermes_cli import anon_auth +from hermes_cli.auth import _load_auth_store, resolve_provider + +WELCOME = "https://welcome-api.nousresearch.com/v1" +PORTAL = "https://portal.example.test" + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +class FakePortal: + """Minimal NAS anonymous surface. Records every call; scenarios flip its behaviour.""" + + def __init__(self): + self.calls: list[tuple[str, str]] = [] + self.dead_tokens: set[str] = set() + self.gate_closed = False + self.minted = 0 + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + self.calls.append((request.method, path)) + if path.startswith("/api/anonymous/") and not request.headers.get("x-anonymous-api-secret"): + return httpx.Response(401, json={"error": "invalid_shared_secret"}) + if self.gate_closed: + return httpx.Response(401, json={"error": "invalid_shared_secret"}) + if path == "/api/anonymous/create": + self.minted += 1 + return httpx.Response(201, json={"user_id": f"nas_user:{self.minted}", "org_id": "nas_org:1", + "token": f"anon_{self.minted:04d}", "idle_ttl_days": 14}) + if path == "/api/anonymous/token": + token = json.loads(request.content)["token"] + if token in self.dead_tokens: + return httpx.Response(404, json={"error": "unknown_token"}) + return httpx.Response(200, json={"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900, + "user_id": "nas_user:1", "org_id": "nas_org:1", + "inference_base_url": WELCOME}) + return httpx.Response(500, json={"error": f"unexpected {path}"}) + + +@pytest.fixture +def portal(monkeypatch, tmp_path): + fake = FakePortal() + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", PORTAL) + monkeypatch.setenv("HERMES_ANON_API_SECRET", "test-secret") + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + from hermes_cli import auth_nous + + def _client(timeout_seconds, verify): + return httpx.Client(transport=httpx.MockTransport(fake.handler), base_url=PORTAL) + monkeypatch.setattr(auth_nous, "_nous_http_client", _client) + # resolve_nous_access_token builds its own client; route it through the fake too. + real_client = httpx.Client + + class _RoutedClient(real_client): + def __init__(self, *a, **kw): + kw.pop("verify", None) + kw["transport"] = httpx.MockTransport(fake.handler) + super().__init__(*a, **kw) + monkeypatch.setattr(httpx, "Client", _RoutedClient) + anon_auth._background_started = False + anon_auth._mint_failed = False + anon_auth._forced_new_done = False + # resolve_nous_access_token memoises the last token for 5 s across the process; a token minted + # by an earlier test must not be served to this one. + from hermes_cli import auth as auth_mod + monkeypatch.setattr(auth_mod, "_RESOLVE_TOKEN_CACHE", None) + return fake + + +def _write_config(monkeypatch, **nous): + home = Path(os.environ["HERMES_HOME"]) + (home / "config.yaml").write_text("nous:\n" + "".join(f" {k}: {str(v).lower()}\n" for k, v in nous.items())) + from hermes_cli import config as cfg_mod + for attr in ("_config_cache", "_cached_config"): + if hasattr(cfg_mod, attr): + monkeypatch.setattr(cfg_mod, attr, None, raising=False) + + +def _shared_store(tmp_path) -> dict: + p = tmp_path / "shared-store" / "nous_auth.json" + return json.loads(p.read_text()) if p.exists() else {} + + +class TestIdentityLifecycle: + def test_fresh_install_mints_once_and_is_the_active_provider(self, portal, tmp_path): + state = anon_auth.ensure_portal_identity(blocking=True) + assert anon_auth.is_guest_state(state) + assert "refresh_token" not in state + store = _load_auth_store() + assert store["active_provider"] == "nous" + assert anon_auth.is_guest_state(store["providers"]["nous"]) + assert _shared_store(tmp_path).get("anon_token") == state["anon_token"] + assert portal.minted == 1 + # Second call: identity exists, zero network. + before = len(portal.calls) + assert anon_auth.ensure_portal_identity(blocking=True)["anon_token"] == state["anon_token"] + assert len(portal.calls) == before + + def test_second_profile_under_same_root_adopts_from_shared_store(self, portal, tmp_path, monkeypatch): + first = anon_auth.ensure_portal_identity(blocking=True) + other_home = tmp_path / "profiles" / "two" + other_home.mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(other_home)) + before = len(portal.calls) + second = anon_auth.ensure_portal_identity(blocking=True) + assert second["anon_token"] == first["anon_token"] + assert len(portal.calls) == before, "adoption must not touch the network" + assert portal.minted == 1 + + def test_gate_closed_persists_nothing_and_raises_gate_code(self, portal): + portal.gate_closed = True + with pytest.raises(anon_auth.AuthError) as exc: + anon_auth.ensure_portal_identity(blocking=True) + assert exc.value.code == "anon_gate_closed" + assert "nous" not in _load_auth_store().get("providers", {}) + # A process tries once: later bootstrap sites must not hit the portal again. + assert anon_auth.ensure_portal_identity(blocking=True) is None + assert [p for _, p in portal.calls].count("/api/anonymous/create") == 1 + + def test_opt_out_bool_disables_everything(self, portal, monkeypatch): + _write_config(monkeypatch, guest=False) + assert anon_auth.ensure_portal_identity(blocking=True) is None + assert portal.calls == [] + with pytest.raises(anon_auth.AuthError): + resolve_provider("auto") + + def test_force_guest_overrides_opt_out_and_new_bypasses_shared_store(self, portal, monkeypatch): + _write_config(monkeypatch, guest=False) + monkeypatch.setenv("HERMES_FORCE_GUEST", "1") + first = anon_auth.ensure_portal_identity(blocking=True) + assert anon_auth.is_guest_state(first) + monkeypatch.setenv("HERMES_FORCE_GUEST", "new") + second = anon_auth.ensure_portal_identity(blocking=True) + assert second["anon_token"] != first["anon_token"] + assert portal.minted == 2 + + +class TestResolverIsUnchanged: + def test_guest_is_last_resort_and_explicit_key_wins(self, portal, monkeypatch): + anon_auth.ensure_portal_identity(blocking=True) + assert resolve_provider("auto") == "nous" + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test") + assert resolve_provider("auto") == "openrouter" + + def test_runtime_routes_to_welcome_host(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider() + assert runtime["provider"] == "nous" + assert runtime["base_url"].rstrip("/") == WELCOME + assert runtime["api_key"] + + +class TestTokenAcquisitionSeam: + def test_expired_guest_jwt_reexchanges_and_never_hits_oauth_token(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth import _auth_store_lock, _save_auth_store + with _auth_store_lock(): + store = _load_auth_store() + store["providers"]["nous"]["access_token"] = _jwt(exp=int(time.time()) - 10) + store["providers"]["nous"]["expires_at"] = "2000-01-01T00:00:00+00:00" + _save_auth_store(store) + portal.calls.clear() + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + creds = resolve_nous_runtime_credentials() + paths = [p for _, p in portal.calls] + assert paths == ["/api/anonymous/token"] + assert "/api/oauth/token" not in paths + assert creds["base_url"].rstrip("/") == WELCOME + assert "quarantine" not in json.dumps(_load_auth_store()) + + def test_dead_credential_is_replaced_by_a_fresh_identity(self, portal): + first = anon_auth.ensure_portal_identity(blocking=True) + portal.dead_tokens.add(first["anon_token"]) + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + creds = resolve_nous_runtime_credentials(force_refresh=True) + assert creds["api_key"] + state = _load_auth_store()["providers"]["nous"] + assert state["anon_token"] != first["anon_token"] + assert portal.minted == 2 + + def test_tool_gateway_token_path_reexchanges(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth import _auth_store_lock, _save_auth_store, resolve_nous_access_token + with _auth_store_lock(): + store = _load_auth_store() + store["providers"]["nous"]["expires_at"] = "2000-01-01T00:00:00+00:00" + _save_auth_store(store) + portal.calls.clear() + token = resolve_nous_access_token() + assert token + assert [p for _, p in portal.calls] == ["/api/anonymous/token"] + + +class TestModelPin: + """The pin is a property of the selected ROUTE (welcome host), never of profile state: a paid + pool credential routed to the portal host keeps its model even beside a guest singleton.""" + + def test_pin_keys_on_the_welcome_host_not_on_guest_state(self, portal): + anon_auth.ensure_portal_identity(blocking=True) # guest singleton exists + assert anon_auth.route_is_welcome_host(WELCOME) + assert not anon_auth.route_is_welcome_host("https://inference-api.nousresearch.com/v1") + assert not anon_auth.route_is_welcome_host("") + + def test_agent_init_pins_only_on_welcome_route(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + from run_agent import AIAgent + welcome = AIAgent(provider="nous", base_url=WELCOME, api_key="k", model="openai/gpt-5", + quiet_mode=True, skip_context_files=True, skip_memory=True) + paid = AIAgent(provider="nous", base_url="https://inference-api.nousresearch.com/v1", api_key="k", + model="nous/paid-model", quiet_mode=True, skip_context_files=True, skip_memory=True) + assert welcome.model == anon_auth.GUEST_MODEL + assert paid.model == "nous/paid-model" + + +class TestLogout: + def test_logout_with_only_free_tier_is_a_true_noop(self, portal, capsys): + from types import SimpleNamespace + from hermes_cli.auth import _auth_file_path, logout_command + anon_auth.ensure_portal_identity(blocking=True) + before = _auth_file_path().read_bytes() + logout_command(SimpleNamespace(provider=None)) + out = capsys.readouterr().out.lower() + assert "not signed in" in out + assert "guest" not in out and "anonymous" not in out + assert _auth_file_path().read_bytes() == before + + def test_logout_of_real_account_clears_shared_store(self, portal, tmp_path): + from types import SimpleNamespace + from hermes_cli.auth import logout_command + from hermes_cli.auth_nous import persist_nous_credentials + persist_nous_credentials({"access_token": _jwt(client_id="hermes-cli", account_tier="free"), + "refresh_token": "rt-1", "expires_at": "2030-01-01T00:00:00+00:00", + "auth_method": "oauth_device_code"}) + assert _shared_store(tmp_path).get("refresh_token") == "rt-1" + logout_command(SimpleNamespace(provider="nous")) + assert _shared_store(tmp_path) == {} + assert "nous" not in _load_auth_store().get("providers", {}) + + +class TestModelSwitchCopy: + def test_switching_away_from_welcome_names_the_account_path_not_another_provider(self, portal, monkeypatch): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli import model_switch + monkeypatch.setattr(model_switch, "list_provider_models", lambda *a, **k: [], raising=False) + result = model_switch.switch_model("gpt-5", "nous", anon_auth.GUEST_MODEL, WELCOME) + assert not result.success + msg = (result.error_message or "").lower() + assert "hermes auth upgrade" in msg + assert "openrouter" not in msg and "switching" not in msg + + +class TestRotationNeverRewritesTheConversationModel: + """A credential rotation adopts an entry only if its route can serve the conversation's model. + The model is never changed by a swap; an ineligible entry is refused (swap returns False).""" + + def _agent(self, api_mode="chat_completions", model="nous/paid-model"): + from types import SimpleNamespace + return SimpleNamespace(provider="nous", api_mode=api_mode, base_url="https://inference-api.nousresearch.com/v1", + api_key="k", model=model, _client_kwargs={}, _credential_pool_entry_id="p", + _reapply_route_client_config=lambda **kw: None, _replace_primary_openai_client=lambda **kw: None, + _anthropic_client=SimpleNamespace(close=lambda: None), + _build_direct_anthropic_client=lambda key, url: object(), _anthropic_oauth_flag=lambda key: False) + + def test_paid_conversation_refuses_a_welcome_route_on_every_wire_mode(self, portal): + from types import SimpleNamespace + from agent.client_lifecycle import ClientLifecycleMixin + for mode, model in (("chat_completions", "nous/paid-model"), ("anthropic_messages", "anthropic/claude-sonnet")): + agent = self._agent(mode, model) + ok = ClientLifecycleMixin._swap_credential(agent, SimpleNamespace(id="g", runtime_api_key="jwt", runtime_base_url=WELCOME)) + assert ok is False + assert agent.model == model and agent.api_key == "k" and agent._credential_pool_entry_id == "p" + + def test_welcome_conversation_may_move_to_the_portal_host(self, portal): + from types import SimpleNamespace + from agent.client_lifecycle import ClientLifecycleMixin + agent = self._agent(model=anon_auth.GUEST_MODEL); agent.base_url = WELCOME + ok = ClientLifecycleMixin._swap_credential(agent, SimpleNamespace(id="p2", runtime_api_key="key", runtime_base_url="https://inference-api.nousresearch.com/v1")) + assert ok is True and agent.model == anon_auth.GUEST_MODEL + + +class TestBackgroundRetry: + def test_background_failure_releases_the_latch(self, portal): + import time as _t + portal.gate_closed = True + assert anon_auth.ensure_portal_identity(blocking=False) is None + for _ in range(50): + if not anon_auth._background_started: + break + _t.sleep(0.05) + assert anon_auth._background_started is False, "a failed background attempt must not consume the latch" + portal.gate_closed = False + anon_auth.ensure_portal_identity(blocking=False) + for _ in range(50): + if anon_auth.has_guest(): + break + _t.sleep(0.05) + assert anon_auth.has_guest() + + +class TestBackgroundLatchOnThreadFailure: + def test_thread_start_failure_releases_latch(self, portal, monkeypatch): + import threading + class Boom(threading.Thread): + def start(self): raise RuntimeError("can't start new thread") + monkeypatch.setattr(anon_auth.threading, "Thread", Boom) + assert anon_auth.ensure_portal_identity(blocking=False) is None + assert anon_auth._background_started is False + + +class TestIdentityOfRecordIsTheSharedStore: + def test_stale_profile_guest_adopts_a_newer_shared_account(self, portal, tmp_path): + anon_auth.ensure_portal_identity(blocking=True) + # A sibling profile signed in: the shared store now holds a real account. + from hermes_cli.auth_nous import _write_shared_nous_state + _write_shared_nous_state({"access_token": _jwt(client_id="hermes-cli", account_tier="free"), + "refresh_token": "rt-sibling", "expires_at": "2030-01-01T00:00:00+00:00", + "auth_method": "oauth_device_code"}) + state = anon_auth.ensure_portal_identity(blocking=True) + assert not anon_auth.is_guest_state(state) + assert state["refresh_token"] == "rt-sibling" + assert _load_auth_store()["providers"]["nous"]["refresh_token"] == "rt-sibling" + assert _shared_store(tmp_path)["refresh_token"] == "rt-sibling", "the profile must never overwrite the shared account" + + def test_mint_persists_before_any_exchange_and_first_use_exchanges_once(self, portal): + first = anon_auth.ensure_portal_identity(blocking=True) + assert anon_auth.is_guest_state(first) and "access_token" not in first + assert [p for _, p in portal.calls] == ["/api/anonymous/create"], "mint alone; exchange is lazy" + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + creds = resolve_nous_runtime_credentials() + assert creds["api_key"] + assert portal.minted == 1, "a stored credential is exchanged, never re-minted" + assert [p for _, p in portal.calls].count("/api/anonymous/token") == 1 + + def test_clearing_a_dead_guest_leaves_a_sibling_identity_alone(self, portal, tmp_path): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth_nous import _write_shared_nous_state + _write_shared_nous_state({"access_token": _jwt(client_id="hermes-cli"), "refresh_token": "rt-sibling", + "expires_at": "2030-01-01T00:00:00+00:00", "auth_method": "oauth_device_code"}) + anon_auth.clear_dead_guest("test") + assert "nous" not in _load_auth_store().get("providers", {}) + assert _shared_store(tmp_path)["refresh_token"] == "rt-sibling" + + def test_lock_order_is_profile_then_shared(self, portal, monkeypatch): + order = [] + from hermes_cli import auth as auth_mod, auth_nous + real_profile, real_shared = auth_mod._auth_store_lock, auth_nous._nous_shared_store_lock + from contextlib import contextmanager + + @contextmanager + def profile(*a, **k): + order.append("profile") + with real_profile(*a, **k): + yield + + @contextmanager + def shared(*a, **k): + order.append("shared") + with real_shared(*a, **k): + yield + monkeypatch.setattr(auth_mod, "_auth_store_lock", profile) + monkeypatch.setattr(auth_nous, "_nous_shared_store_lock", shared) + anon_auth.ensure_portal_identity(blocking=True) + assert order[:2] == ["profile", "shared"] + + +class TestConnectorTokenPath: + def test_opt_out_hides_the_free_tier_from_connectors_including_cached_tokens(self, portal, monkeypatch): + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + resolve_nous_runtime_credentials() # now a cached, valid JWT exists + from tools import managed_tool_gateway as mtg + assert mtg.read_nous_access_token() + _write_config(monkeypatch, guest=False) + assert mtg.peek_nous_access_token() is None + assert mtg.read_nous_access_token() is None + + def test_connector_path_replaces_a_dead_credential_once(self, portal): + first = anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.auth import _auth_store_lock, _save_auth_store + with _auth_store_lock(): + store = _load_auth_store() + store["providers"]["nous"]["expires_at"] = "2000-01-01T00:00:00+00:00" + store["providers"]["nous"]["access_token"] = _jwt(exp=1) + _save_auth_store(store) + portal.dead_tokens.add(first["anon_token"]) + from tools import managed_tool_gateway as mtg + token = mtg.read_nous_access_token() + assert token and token != _jwt(exp=1) + assert _load_auth_store()["providers"]["nous"]["anon_token"] != first["anon_token"] + assert portal.minted == 2 diff --git a/tests/hermes_cli/test_anon_first_notice.py b/tests/hermes_cli/test_anon_first_notice.py new file mode 100644 index 0000000000..b147952fa7 --- /dev/null +++ b/tests/hermes_cli/test_anon_first_notice.py @@ -0,0 +1,92 @@ +"""CLI one-time notice: an explicit-provider install learns once that the Nous free tier exists.""" + +import base64 +import json +import time + +import pytest + +from hermes_cli import anon_auth +from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store +from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +def _seed_guest() -> None: + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})["nous"] = { + "auth_method": anon_auth.ANON_AUTH_METHOD, "account_tier": "anonymous", "anon_token": "anon_0001", + "client_id": "nas-anonymous", "access_token": _jwt(), "expires_at": "2999-01-01T00:00:00+00:00", + "inference_base_url": "https://welcome-api.nousresearch.com/v1"} + _save_auth_store(store) + + +class _RecordingConsole: + def __init__(self): + self.lines: list[str] = [] + + def print(self, *args, **kwargs): + self.lines.append(" ".join(str(a) for a in args)) + + +class _NoticeCLI(CLIAgentSetupMixin): + """Only what the notice path touches: the console seam.""" + + def __init__(self): + self.console = _RecordingConsole() + self._app = None + + def _console_print(self, *args, **kwargs): + self.console.print(*args, **kwargs) + + +@pytest.fixture(autouse=True) +def _isolated_store(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + + +def test_notice_prints_once_after_identity_appears_and_persists_the_flag(): + cli = _NoticeCLI() + + # Background mint has not landed yet: nothing to say. + cli._maybe_print_free_tier_available_notice() + assert cli.console.lines == [] + + _seed_guest() + cli._maybe_print_free_tier_available_notice() + assert len(cli.console.lines) == 1 + text = cli.console.lines[0] + assert anon_auth.FREE_TIER_AVAILABLE_NOTICE in text + assert "guest" not in text.lower() and "anonymous" not in text.lower() + + # Same process, and a fresh process reading the store: never again. + cli._maybe_print_free_tier_available_notice() + _NoticeCLI()._maybe_print_free_tier_available_notice() + assert len(cli.console.lines) == 1 + assert _load_auth_store()["providers"]["nous"][anon_auth.GUEST_NOTICE_FLAG] is True + assert not anon_auth.guest_notice_pending() + + +def test_signed_in_account_is_never_told_about_the_free_tier(): + with _auth_store_lock(): + store = _load_auth_store() + store.setdefault("providers", {})["nous"] = { + "auth_method": "oauth", "access_token": _jwt(client_id="hermes-cli", account_tier="pro"), + "refresh_token": "rt", "expires_at": "2999-01-01T00:00:00+00:00"} + _save_auth_store(store) + cli = _NoticeCLI() + + cli._maybe_print_free_tier_available_notice() + + assert cli.console.lines == [] + assert anon_auth.mark_guest_notice_shown() is False + assert anon_auth.GUEST_NOTICE_FLAG not in _load_auth_store()["providers"]["nous"] diff --git a/tests/hermes_cli/test_anon_picker.py b/tests/hermes_cli/test_anon_picker.py new file mode 100644 index 0000000000..c902be5510 --- /dev/null +++ b/tests/hermes_cli/test_anon_picker.py @@ -0,0 +1,91 @@ +"""Model pickers under a Nous free-tier identity. + +A guest identity carrying inference shows one row, "Nous · free tier", with the single model +``nous/welcome``; the same guest with ``nous.guest: false`` shows no Nous row at all. The rule lives +in one helper (``_free_tier_nous_row``) and this file exercises it through the real row builders +against a temp ``HERMES_HOME`` with the network catalog fetch stubbed. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from hermes_cli.auth import _save_auth_store + +GUEST_STATE = { + "auth_method": "anonymous", + "account_tier": "anonymous", + "anon_token": "anon_t", + "access_token": "aaa.bbb.ccc", + "expires_at": "2030-01-01T00:00:00+00:00", + "inference_base_url": "https://welcome-api.nousresearch.com/v1", +} + + +@pytest.fixture +def guest_home(monkeypatch, tmp_path): + """Seed a guest identity as the only Nous state and keep every row builder offline.""" + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY", "LM_API_KEY", "LM_BASE_URL"): + monkeypatch.delenv(var, raising=False) + _save_auth_store({"active_provider": "nous", "providers": {"nous": dict(GUEST_STATE)}}) + + # No network from any lap: models.dev, the Portal catalog, and Ollama Cloud all stubbed. + from agent import models_dev + from hermes_cli import models as models_mod + from hermes_cli import model_switch_providers as msp + monkeypatch.setattr(models_dev, "fetch_models_dev", lambda *a, **k: {}) + monkeypatch.setattr(models_mod, "get_curated_nous_model_ids", lambda *a, **k: ["anthropic/claude-x", "openai/gpt-y"]) + monkeypatch.setattr(models_mod, "fetch_ollama_cloud_models", lambda *a, **k: []) + monkeypatch.setattr(msp, "_nous_picker_model_ids", lambda *a, **k: pytest.fail("guest must not fetch the Portal catalog")) + return Path(os.environ["HERMES_HOME"]) + + +def _write_config(monkeypatch, home: Path, text: str) -> None: + (home / "config.yaml").write_text(text) + from hermes_cli import config as cfg_mod + for attr in ("_config_cache", "_cached_config"): + if hasattr(cfg_mod, attr): + monkeypatch.setattr(cfg_mod, attr, None, raising=False) + + +def _nous_rows(rows): + return [r for r in rows if str(r.get("slug", "")).lower() == "nous"] + + +def _cli_nous_rows(config): + from hermes_cli.main_provider_setup import _build_provider_picker_rows + ordered, _ = _build_provider_picker_rows(config, "nous", {}, {}) + return [(key, label) for key, label, _members in ordered if key == "nous"] + + +def test_guest_identity_shows_free_tier_row_with_only_welcome_model(guest_home, monkeypatch): + from hermes_cli.model_switch_providers import list_picker_providers + rows = _nous_rows(list_picker_providers("nous", "", None, None, 50, "nous/welcome")) + assert len(rows) == 1 + row = rows[0] + assert "free tier" in row["name"] + assert row["models"] == ["nous/welcome"] + assert row["total_models"] == 1 + rendered = repr(row).lower() + assert "guest" not in rendered and "anonymous" not in rendered + + # The `hermes model` provider picker applies the same rule from the same helper. + cli_rows = _cli_nous_rows({}) + assert len(cli_rows) == 1 + assert "free tier" in cli_rows[0][1] + assert "guest" not in cli_rows[0][1].lower() and "anonymous" not in cli_rows[0][1].lower() + + +def test_guest_identity_with_guest_off_hides_the_nous_row(guest_home, monkeypatch): + _write_config(monkeypatch, guest_home, "nous:\n guest: false\n") + from hermes_cli import anon_auth + assert anon_auth.has_guest() and not anon_auth.guest_enabled() + + from hermes_cli.model_switch_providers import list_picker_providers + assert _nous_rows(list_picker_providers("nous", "", None, None, 50, "nous/welcome")) == [] + assert _cli_nous_rows({"nous": {"guest": False}}) == [] diff --git a/tests/hermes_cli/test_anon_surfaces.py b/tests/hermes_cli/test_anon_surfaces.py new file mode 100644 index 0000000000..9bd5ea10d1 --- /dev/null +++ b/tests/hermes_cli/test_anon_surfaces.py @@ -0,0 +1,144 @@ +"""Nous free tier on the read-only display surfaces and the keepalive. + +Contract (R-USR-1): wherever a free-tier identity renders (``hermes auth status nous``, +``hermes auth list``, ``hermes status``, ``hermes portal info``) the user sees the free-tier label +plus the upgrade hint, and never the internal identity vocabulary. A real account keeps its normal +rendering. The keepalive has nothing to keep alive for the free tier and must not start a thread. +""" + +from __future__ import annotations + +import base64 +import json +import re +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from hermes_cli import anon_auth, auth_commands, nous_account, nous_auth_keepalive, portal_cli, status_auth +from hermes_cli.auth import _load_auth_store # noqa: F401 (store import name kept for parity with core tests) +from hermes_constants import get_hermes_home + +WELCOME = "https://welcome-api.nousresearch.com/v1" +# Words that must never appear on a user-facing free-tier surface. +_FORBIDDEN = re.compile(r"guest|anonymous|user id|org id|nas_user|nas_organisation", re.IGNORECASE) + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:abc", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 10 ** 8, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +def _write_auth(nous_state: dict) -> None: + home = Path(get_hermes_home()) + home.mkdir(parents=True, exist_ok=True) + (home / "auth.json").write_text(json.dumps({"active_provider": "nous", "providers": {"nous": nous_state}})) + + +def _guest_state() -> dict: + return { + "auth_method": "anonymous", "account_tier": "anonymous", "anon_token": "anon_t", + "access_token": _jwt(), "expires_at": "2030-01-01T00:00:00+00:00", + "inference_base_url": WELCOME, "user_id": "nas_user:abc", "org_id": "nas_organisation:def", + } + + +def _account_state() -> dict: + return { + "auth_method": "oauth_device_code", "client_id": "hermes-cli", + "access_token": _jwt(sub="nas_user:real", client_id="hermes-cli", account_tier="standard", paid_access=True), + "refresh_token": "rt_live", "expires_at": "2030-01-01T00:00:00+00:00", + "portal_base_url": "https://portal.nousresearch.com", "inference_base_url": "https://inference-api.nousresearch.com/v1", + } + + +@pytest.fixture +def isolated_store(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + # No network: the account lookup is derived from the JWT the store already holds. + monkeypatch.setattr(nous_account, "_fetch_nous_account_info", + lambda *a, **k: pytest.fail("portal fetch must not happen on a read-only surface")) + nous_account.reset_nous_portal_account_info_cache() + import hermes_cli.auth as auth_mod + auth_mod.invalidate_nous_auth_status_cache() + yield + nous_account.reset_nous_portal_account_info_cache() + auth_mod.invalidate_nous_auth_status_cache() + + +def _render_all(capsys) -> dict[str, str]: + out: dict[str, str] = {} + auth_commands.auth_status_command(SimpleNamespace(provider="nous")) + out["auth status"] = capsys.readouterr().out + auth_commands.auth_list_command(SimpleNamespace(provider="nous")) + out["auth list"] = capsys.readouterr().out + ctx = SimpleNamespace(config={}, nous_logged_in=False, nous_inference_present=False, nous_account_info=None) + status_auth._render_auth_providers(ctx) + out["hermes status"] = capsys.readouterr().out + portal_cli._cmd_status(SimpleNamespace()) + out["portal info"] = capsys.readouterr().out + return out + + +def test_free_tier_renders_free_tier_copy_on_every_surface(isolated_store, capsys): + _write_auth(_guest_state()) + rendered = _render_all(capsys) + for surface, text in rendered.items(): + assert "free tier" in text.lower(), f"{surface} did not name the free tier:\n{text}" + assert anon_auth.FREE_TIER_LABEL in text and anon_auth.GUEST_MODEL in text, surface + assert anon_auth.UPGRADE_HINT in text, f"{surface} lacks the upgrade hint:\n{text}" + leaked = _FORBIDDEN.search(text) + assert leaked is None, f"{surface} leaked {leaked.group(0)!r}:\n{text}" + # Billing / entitlement copy for the free tier points at the upgrade path, never at billing. + info = nous_account.get_nous_portal_account_info() + assert info.is_anonymous_tier + message = nous_account.format_nous_portal_entitlement_message(info, capability="managed web tools") + assert message == nous_account.FREE_TIER_NEEDS_ACCOUNT + assert "billing" not in message.lower() and _FORBIDDEN.search(message) is None + + +def test_real_account_keeps_account_rendering(isolated_store, capsys): + _write_auth(_account_state()) + rendered = _render_all(capsys) + for surface, text in rendered.items(): + assert "free tier" not in text.lower(), f"{surface} mislabelled a real account:\n{text}" + assert anon_auth.UPGRADE_HINT not in text, surface + assert "logged in" in rendered["auth status"] + assert "credentials" in rendered["auth list"] + info = nous_account.get_nous_portal_account_info() + assert not info.is_anonymous_tier + assert nous_account.format_nous_portal_entitlement_message(info) is None # paid_access claim entitles + + +def test_keepalive_does_not_start_for_free_tier(isolated_store, monkeypatch): + started: list = [] + + class _Thread: + def __init__(self, *args, **kwargs): + self._name = kwargs.get("name") + def start(self): + started.append(self._name) + def is_alive(self): + return True + def join(self, timeout=None): + pass + monkeypatch.setattr(nous_auth_keepalive.threading, "Thread", _Thread) + monkeypatch.setattr(nous_auth_keepalive, "_keepalive_thread", None) + + _write_auth(_guest_state()) + assert nous_auth_keepalive.start_nous_auth_keepalive(interval_seconds=900) is None + assert started == [] + + _write_auth(_account_state()) + thread = nous_auth_keepalive.start_nous_auth_keepalive(interval_seconds=900) + assert thread is not None and started == ["nous-auth-keepalive"] + monkeypatch.setattr(nous_auth_keepalive, "_keepalive_thread", None) diff --git a/tests/hermes_cli/test_anon_upgrade.py b/tests/hermes_cli/test_anon_upgrade.py new file mode 100644 index 0000000000..cf317ace0d --- /dev/null +++ b/tests/hermes_cli/test_anon_upgrade.py @@ -0,0 +1,178 @@ +"""``hermes auth upgrade``: the free tier signs into a Nous account, keeping its connectors. + +Driven through a fake portal covering the device-code endpoints plus the promotion intent/status +surface, so the wire contract (both codes in the intent, status-driven outcome, token grant +persisted over the guest singleton) is exercised rather than mocked away. +""" + +from __future__ import annotations + +import base64 +import json +import time +from types import SimpleNamespace +from urllib.parse import parse_qs + +import httpx +import pytest + +from hermes_cli import anon_auth +from hermes_cli.auth import _auth_file_path, _load_auth_store + +WELCOME = "https://welcome-api.nousresearch.com/v1" +INFERENCE = "https://inference-api.nousresearch.com/v1" +PORTAL = "https://portal.example.test" +REFRESH_TOKEN = "rt-upgraded-1" +EMAIL = "sid@example.test" + + +def _jwt(**claims) -> str: + def seg(obj): + return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode() + payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous", + "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims} + return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig" + + +class FakePortal: + """NAS anonymous surface + device-code endpoints. Records every call; scenarios flip behaviour.""" + + def __init__(self): + self.calls: list[tuple[str, str]] = [] + self.minted = 0 + self.intent_bodies: list[dict] = [] + self.status_bodies: list[dict] = [] + self.device_code = "dev-code-123" + self.user_code = "ABCD-EFGH" + # Sequence of promotion-status payloads; the last one repeats. + self.status_sequence: list[dict] = [{"status": "completed", "user_id": "nas_user:9", "account_email": EMAIL}] + self.token_grants = 0 + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + self.calls.append((request.method, path)) + if path.startswith("/api/anonymous/") and not request.headers.get("x-anonymous-api-secret"): + return httpx.Response(401, json={"error": "invalid_shared_secret"}) + if path == "/api/anonymous/create": + self.minted += 1 + return httpx.Response(201, json={"user_id": f"nas_user:{self.minted}", "org_id": "nas_org:1", + "token": f"anon_{self.minted:04d}", "idle_ttl_days": 14}) + if path == "/api/anonymous/token": + return httpx.Response(200, json={"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900, + "user_id": "nas_user:1", "org_id": "nas_org:1", + "inference_base_url": WELCOME}) + if path == "/api/oauth/device/code": + return httpx.Response(200, json={ + "device_code": self.device_code, "user_code": self.user_code, + "verification_uri": f"{PORTAL}/device", + "verification_uri_complete": f"{PORTAL}/device?user_code={self.user_code}", + "expires_in": 600, "interval": 5}) + if path == "/api/anonymous/promotion-intent": + body = json.loads(request.content) + self.intent_bodies.append(body) + if not (body.get("user_code") and body.get("device_code")): + return httpx.Response(400, json={"error": "invalid_request"}) + return httpx.Response(200, json={"claim_code": "clm_1", "claim_url": f"{PORTAL}/device", + "expires_in": 600, "interval": 0}) + if path == "/api/anonymous/promotion-status": + body = json.loads(request.content) + self.status_bodies.append(body) + idx = min(len(self.status_bodies) - 1, len(self.status_sequence) - 1) + return httpx.Response(200, json=self.status_sequence[idx]) + if path == "/api/oauth/token": + form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()} + if form.get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code": + return httpx.Response(400, json={"error": "unsupported_grant_type"}) + if form.get("device_code") != self.device_code: + return httpx.Response(400, json={"error": "invalid_grant"}) + self.token_grants += 1 + return httpx.Response(200, json={ + "access_token": _jwt(sub="nas_user:9", client_id="hermes-cli", account_tier="free"), + "refresh_token": REFRESH_TOKEN, "token_type": "Bearer", "expires_in": 900, + "scope": "inference:invoke tool:invoke", "inference_base_url": INFERENCE}) + return httpx.Response(500, json={"error": f"unexpected {path}"}) + + +@pytest.fixture +def portal(monkeypatch, tmp_path): + fake = FakePortal() + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", PORTAL) + monkeypatch.setenv("HERMES_ANON_API_SECRET", "test-secret") + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"): + monkeypatch.delenv(var, raising=False) + from hermes_cli import auth_nous + + def _client(timeout_seconds, verify): + return httpx.Client(transport=httpx.MockTransport(fake.handler), base_url=PORTAL) + monkeypatch.setattr(auth_nous, "_nous_http_client", _client) + real_client = httpx.Client + + class _RoutedClient(real_client): + def __init__(self, *a, **kw): + kw.pop("verify", None) + kw["transport"] = httpx.MockTransport(fake.handler) + super().__init__(*a, **kw) + monkeypatch.setattr(httpx, "Client", _RoutedClient) + anon_auth._background_started = False + anon_auth._mint_failed = False + anon_auth._forced_new_done = False + return fake + + +def _shared_store(tmp_path) -> dict: + p = tmp_path / "shared-store" / "nous_auth.json" + return json.loads(p.read_text()) if p.exists() else {} + + +def _args(): + return SimpleNamespace(no_browser=True, timeout=None) + + +class TestUpgrade: + def test_intent_carries_both_device_codes_from_the_code_request(self, portal): + anon_auth.ensure_portal_identity(blocking=True) + anon_auth.upgrade_guest(_args()) + assert len(portal.intent_bodies) == 1 + body = portal.intent_bodies[0] + assert body["user_code"] == portal.user_code + assert body["device_code"] == portal.device_code + assert body["token"].startswith("anon_") + paths = [p for _, p in portal.calls] + assert paths.index("/api/oauth/device/code") < paths.index("/api/anonymous/promotion-intent") + assert paths.index("/api/anonymous/promotion-intent") < paths.index("/api/oauth/token") + + def test_declined_in_browser_prints_copy_and_leaves_auth_store_untouched(self, portal, capsys, tmp_path): + anon_auth.ensure_portal_identity(blocking=True) + before = _auth_file_path().read_bytes() + shared_before = _shared_store(tmp_path) + portal.status_sequence = [{"status": "pending"}, {"status": "voided", "reason": "user_declined"}] + code = anon_auth.upgrade_guest(_args()) + out = capsys.readouterr().out + assert code == 1 + assert "Sign-in was rejected in the browser." in out + assert portal.token_grants == 0 + assert _auth_file_path().read_bytes() == before + assert _shared_store(tmp_path) == shared_before + + def test_completed_promotion_signs_in_and_keeps_no_free_tier_fields(self, portal, capsys, tmp_path): + guest = anon_auth.ensure_portal_identity(blocking=True) + assert _shared_store(tmp_path).get("anon_token") == guest["anon_token"] + code = anon_auth.upgrade_guest(_args()) + out = capsys.readouterr().out + assert code == 0 + assert f"Signed in as {EMAIL}. Your connectors are kept." in out + lowered = out.lower() + for banned in ("guest", "anonymous", "claim"): + assert banned not in lowered, f"{banned!r} leaked into user-facing output:\n{out}" + store = _load_auth_store() + state = store["providers"]["nous"] + assert store["active_provider"] == "nous" + assert "anon_token" not in state + assert state.get("auth_method") != anon_auth.ANON_AUTH_METHOD + assert not anon_auth.is_guest_state(state) + assert state["refresh_token"] == REFRESH_TOKEN + shared = _shared_store(tmp_path) + assert shared.get("refresh_token") == REFRESH_TOKEN + assert "anon_token" not in shared diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index a7504d1d4c..458c4daf31 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -38,13 +38,22 @@ def auth_json_path(): def _read_nous_provider_state() -> Optional[dict]: + """The profile's Nous state, or None. A free-tier identity counts only while the free tier is on: + with ``nous.guest: false`` it is invisible here, so no cached or refreshed token of it is ever + attached to a request.""" try: path = auth_json_path() if not path.is_file(): return None providers = json.loads(path.read_text(encoding="utf-8-sig")).get("providers", {}) nous_provider = providers.get("nous", {}) if isinstance(providers, dict) else None - return nous_provider if isinstance(nous_provider, dict) else None + if not isinstance(nous_provider, dict): + return None + from hermes_cli.anon_auth import guest_enabled, is_guest_state + + if is_guest_state(nous_provider) and not guest_enabled(): + return None + return nous_provider except Exception: return None @@ -87,10 +96,25 @@ def peek_nous_access_token() -> Optional[str]: def read_nous_access_token() -> Optional[str]: - """Read a Nous Subscriber OAuth access token from auth store or env override.""" + """Read a Nous Subscriber OAuth access token from auth store or env override. + + With no Nous identity at all, the free tier is set up here (blocking, short timeout): this is + the guarantee that managed-tool and connector calls always have a bearer once the free tier is + on, whichever surface booted the process. + """ if explicit := _read_user_token_override(): return explicit nous_provider = _read_nous_provider_state() or {} + if not nous_provider: + try: + from hermes_cli.anon_auth import ensure_portal_identity + + nous_provider = ensure_portal_identity(blocking=True) or {} + except Exception as exc: + logger.debug("Nous free tier setup from tool gateway skipped: %s", exc) + nous_provider = {} + if not nous_provider: + return None cached_token = peek_nous_access_token() if cached_token and not _access_token_is_expiring(nous_provider.get("expires_at"), _NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS): return cached_token @@ -100,10 +124,30 @@ def read_nous_access_token() -> Optional[str]: if refreshed_token := _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)): return refreshed_token except Exception as exc: + # Same dead-credential rule as inference (one place decides it: anon_auth): a retired free-tier + # identity is replaced once, here, instead of handing back its stale token forever. + from hermes_cli.anon_auth import AnonCredentialDead + + if isinstance(exc, AnonCredentialDead): + return _replace_dead_guest_token(nous_provider) logger.debug("Nous access token refresh failed: %s", exc) return cached_token +def _replace_dead_guest_token(dead_state: dict) -> Optional[str]: + from hermes_cli.anon_auth import clear_dead_guest, ensure_portal_identity + from hermes_cli.auth import resolve_nous_access_token + + clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token")) + try: + if ensure_portal_identity(blocking=True) is None: + return None + return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)) + except Exception as exc: + logger.debug("Nous free tier replacement after a retired credential failed: %s", exc) + return None + + def get_tool_gateway_scheme() -> str: """Return configured shared gateway URL scheme.""" scheme = os.getenv("TOOL_GATEWAY_SCHEME", "").strip().lower() or _DEFAULT_TOOL_GATEWAY_SCHEME diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md new file mode 100644 index 0000000000..12c01d5ab8 --- /dev/null +++ b/website/docs/user-guide/free-tier.md @@ -0,0 +1,140 @@ +--- +sidebar_position: 3 +title: "Free tier and signing in" +description: "What Hermes gives you before you add a key or sign in, how the free tier coexists with your own API key, how to sign in, and how to turn it off." +--- + +# Free tier and signing in + +A fresh Hermes install works before you paste an API key or sign in anywhere. The first time you +run a command, Hermes sets up the **Nous free tier** (a few seconds, shown as "Setting up free +inference…") and answers on the `nous/welcome` model. Nothing to configure, no wizard to click +through. `hermes setup` is still there when you want it; it is never forced. + +## What you get out of the box + +| | Free tier | After signing in | +|---|---|---| +| Inference | `nous/welcome` (one model) | Full Nous Portal catalog | +| Connectors (Gmail, Linear, Notion, ...) | Yes | Yes | +| Paid tools through the [Tool Gateway](/user-guide/features/tool-gateway) (web search, image generation, TTS, cloud browser) | No | Yes, billed to your subscription | +| Credits or a balance | None | Yes | + +"Connectors" are the third-party accounts you link on the Nous portal so the agent can act in +them. They work on the free tier without any sign-in. + +While the free tier carries inference, the banner and `hermes auth status` read +`Nous · free tier · nous/welcome`, and `hermes model` lists a **Nous · free tier** row with that +single model. Asking for another model on the free tier prints a pointer instead of switching +silently: + +```text +gpt-5 needs a Nous account or an API key. Run `hermes auth upgrade` or `hermes model`. +``` + +Calling a paid tool prints `This tool requires a Nous account. Run hermes auth upgrade.` and the +turn continues without it. + +If `model.default` in `config.yaml` names something other than `nous/welcome` while the free tier +is doing inference, Hermes uses `nous/welcome` anyway and says so in one line. The free tier +serves exactly one model. + +## Using your own API key alongside it + +The free tier is the last resort, never a preference. Any provider you configure wins: + +| You have | Inference runs on | Connectors | +|---|---|---| +| Nothing | Nous free tier (`nous/welcome`) | Free tier | +| An API key in `.env` (OpenRouter, OpenAI, Anthropic, ...) | Your key | Free tier | +| `model.provider` set in `config.yaml` | That provider | Free tier | +| A Nous Portal sign-in | Nous Portal | Your account | + +On an install that already has a provider, the free tier sets itself up quietly in the +background on the next start so connectors have something to authenticate with, and prints a +one-time notice: + +```text +Free Nous inference and connectors are now available. `hermes model` to try them, `hermes auth upgrade` to sign in. +``` + +You can pick the free tier explicitly from `hermes model` (or `/model`) like any other provider. + +## Signing in + +```bash +hermes auth upgrade +``` + +The command name is provisional and may change in a later release; the behaviour will not. + +1. Hermes prints a URL and a short code, and opens the browser unless you pass `--no-browser` + or you are in an SSH session. Never share the code. +2. Sign in to Nous Portal in the browser and confirm. +3. Back in the terminal: `Signed in as you@example.com. Your connectors are kept.` + +Connectors you linked on the free tier carry over. Inference moves to the Nous Portal catalog, +paid tools unlock, and `hermes auth status` shows your account instead of the free-tier line. + +`hermes auth upgrade` is offered wherever the free tier is present, including installs that +run inference on their own API key. Signing in still unlocks paid tools for those installs. + +:::note Plain login starts fresh +`hermes auth add nous --type oauth` also signs you in, but it replaces the free tier outright and +does not carry your connectors over. Use `hermes auth upgrade` when you have connectors you want +to keep. +::: + +## Turning the free tier off + +```bash +hermes config set nous.guest false +``` + +`nous.guest` is a normal `config.yaml` setting (default `true`), not an environment variable. +With it off: + +| | `nous.guest: true` (default) | `nous.guest: false` | +|---|---|---| +| Free inference on `nous/welcome` | Available | Off | +| Connectors without sign-in | Available | Off | +| Free-tier row in `hermes model` | Shown | Hidden | +| Fresh install with nothing configured | Chats immediately | Offered `hermes setup` | +| Signing in with a Nous account | Works | Works | + +Nothing else changes. A signed-in Nous account, your own API keys, and every other provider work +exactly as before. Set it back to `true` and the free tier returns on the next command that +needs it. + +## What `hermes logout` does + +| Situation | Result | +|---|---| +| Only the free tier is present | Nothing is cleared. Hermes prints: `You're not signed in. Free inference and connectors are always on. Run hermes auth to sign in with a Nous account.` | +| Signed in with a Nous account | The sign-in is removed from this profile and from the shared store, so no other profile on this machine picks it back up. With `nous.guest: true` the install returns to the free tier the next time it needs inference or a connector. | +| Another provider is active | Unchanged behaviour: that provider's stored credential is cleared. | + +There is no command to reset or recreate the free tier. It is created once and looks after +itself. + +## Troubleshooting + +| Symptom | What it means | What to do | +|---|---|---| +| First command prints `It looks like Hermes isn't configured yet` and offers `hermes setup` | The free tier could not be set up within a few seconds: you are offline, or the free tier is not open on the portal Hermes is pointed at, or it is rate limited. | Come back online and run the command again, or run `hermes setup` and add a provider of your own. Nothing is left half-configured. | +| `Nous free tier is not open on this portal.` | The portal Hermes is pointed at is not offering the free tier right now. If you set `HERMES_PORTAL_BASE_URL`, that portal may not have it at all. | Sign in with an account, unset a portal override you no longer need, or add your own key with `hermes setup`. | +| `Nous free tier is rate limited; try again shortly.` | The portal is throttling new free-tier setups at the moment. | Wait a few minutes and retry, or add your own key with `hermes setup`. | +| `This tool requires a Nous account.` | You called a paid Tool Gateway tool on the free tier. | `hermes auth upgrade`, or configure that tool with your own key in `hermes tools`. | +| Model picker shows only `nous/welcome` under Nous | Expected on the free tier. | Sign in for the full catalog, or add an API key for another provider. | +| The free tier stopped working after two weeks away | The free-tier identity expired (see below) and is replaced on next use. | Nothing; run any command. Connectors linked before the gap need to be linked again unless you had signed in. | + +## Privacy + +To make the free tier work, Hermes creates an identity on the Nous portal the first time it +needs one and stores the credential in your Hermes directory, shared across the profiles under +that directory. That identity holds no email address, no name, and no other personal data; it +exists so inference and connector calls can be authenticated and rate limited. It expires after +14 days without use, at which point Hermes transparently creates a new one the next time you run +a command. Signing in with `hermes auth upgrade` moves what that identity holds (your linked +connectors) into your account. Turning the free tier off with `nous.guest: false` means no +identity is created or used at all. diff --git a/website/sidebars.ts b/website/sidebars.ts index 34f3693fe6..35b8659473 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -29,6 +29,7 @@ const sidebars: SidebarsConfig = { 'user-guide/windows-native', 'user-guide/windows-wsl-quickstart', 'user-guide/configuration', + 'user-guide/free-tier', 'user-guide/managed-scope', 'user-guide/configuring-models', {