From c827454b0ecec82a5589969b50cee0083ef519ec Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Tue, 8 Sep 2026 14:14:11 +1000 Subject: [PATCH 01/16] fix(auth): close the free tier's gaps against the gateway's welcome-tier contract The inference gateway's welcome tier (NousResearch/api DOCS/anon-tier/plan.md) serves an anonymous account exactly one model on its own host, refuses everything else with a structured 429, cross-refuses a request on the wrong host with a 400 (403 while the tier is dark), and tells a signed-in account that still asks for `nous/welcome` what to switch to in an `x-nous-model-switch` header. Four client-side gaps against that contract: - Auxiliary calls were refused on every session. The auxiliary client asked the welcome host for the Portal's recommended compaction/vision model, a guaranteed 429 `model_not_free` before each fallback. On the welcome host it now uses `nous/welcome` (its backing model covers auxiliary work) and skips Nous for vision, which the welcome model does not take. - The structured 429 body was never read. The classifier now parses `reason` / `retry_after` / `alternates` / `upgrade_url`: `model_not_free` and `feature_not_free` are non-retryable gates that fall back; `at_capacity`, `admission_closed` and `rate_limited` are rate limits that honour `retry_after` and never rotate the free tier's only credential. The wrong-host 400 and the dark-tier 403 are deterministic, so they abort this route and fall back instead of retrying or re-exchanging. The terminal paths say what happened and name the sign-in (`/login` in a chat, `hermes auth upgrade` in a terminal). - The `x-nous-model-switch` header was ignored. The chat-completions transport records it beside the rate-limit and credits headers; the next call moves the session, and the config default when it still names `nous/welcome`, to the backing model the gateway named. - A guest fell back to the paid host. With `inference_base_url` absent from the exchange or outside the host allowlist, routing defaulted to inference-api, where every request is a 400. A guest now defaults to the welcome literal at the exchange, in the shared store's shape, and in effective routing. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit fc758aad7efceff6223fc144a9b5c69f13e41bd8) --- agent/auxiliary_client.py | 45 +++-- agent/chat_completion_helpers.py | 1 + agent/error_classifier.py | 36 ++++ agent/rate_limit_credits.py | 13 ++ agent/turn_api_call.py | 7 + agent/turn_recovery.py | 30 ++- hermes_cli/anon_auth.py | 177 +++++++++++++++++- hermes_cli/auth_constants.py | 4 + hermes_cli/auth_nous.py | 11 +- tests/agent/test_error_classifier.py | 80 ++++++++ .../test_nous_welcome_client_contract.py | 161 ++++++++++++++++ tests/hermes_cli/test_anon_auth_core.py | 43 ++++- website/docs/user-guide/free-tier.md | 4 + 13 files changed, 583 insertions(+), 29 deletions(-) create mode 100644 tests/agent/test_nous_welcome_client_contract.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a6af92bda361c..3401b58c4f7c4 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2234,13 +2234,38 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: return None, None if runtime is None and nous: logger.debug("Auxiliary Nous: runtime JWT refresh failed; checking stored auth.json token.") + if runtime is not None: + api_key, base_url = runtime + else: + api_key = _nous_api_key(nous or {}) + if not api_key: + logger.warning( + "Auxiliary Nous client unavailable: no usable inference JWT found " + "(run: hermes auth add nous)." + ) + _mark_provider_unhealthy("nous", ttl=60) + return None, None + base_url = str( + (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) + ).rstrip("/") + lane = "vision" if vision else "text" + # The free tier's host serves exactly one model (text only): asking it for the Portal's + # recommended aux model is a guaranteed 429 ``model_not_free``. Pin the route's model instead, + # and leave vision to the next provider in the ladder (the welcome model takes no images). + from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host global auxiliary_is_nous + if route_is_welcome_host(base_url): + if vision: + logger.debug("Auxiliary/vision: Nous free tier serves no vision model; skipping Nous") + return None, None + auxiliary_is_nous = True + logger.debug("Auxiliary/%s: Nous free tier; using %s", lane, GUEST_MODEL) + return _create_openai_client(api_key=api_key, base_url=base_url), GUEST_MODEL auxiliary_is_nous = True logger.debug("Auxiliary client: Nous Portal") # Portal recommended-models is authoritative (tier-aware); _NOUS_MODEL when unreachable/null. # Probes skip the lookup: exact model is irrelevant and it hits the network. model = _NOUS_MODEL - lane = "vision" if vision else "text" if not _aux_probe_active(): try: from hermes_cli.models import get_nous_recommended_aux_model @@ -2256,20 +2281,6 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: "falling back to %s", lane, exc, model, ) - if runtime is not None: - api_key, base_url = runtime - else: - api_key = _nous_api_key(nous or {}) - if not api_key: - logger.warning( - "Auxiliary Nous client unavailable: no usable inference JWT found " - "(run: hermes auth add nous)." - ) - _mark_provider_unhealthy("nous", ttl=60) - return None, None - base_url = str( - (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) - ).rstrip("/") return _create_openai_client(api_key=api_key, base_url=base_url), model @@ -2998,7 +3009,7 @@ def _contains_any(text: str, needles: Tuple[str, ...]) -> bool: _PAYMENT_KEYWORDS = ( "credits", "insufficient funds", "can only afford", "billing", "payment required", "out of funds", "run out of funds", "balance_depleted", "no usable credits", - "model_not_supported_on_free_tier", "not available on the free tier", + "model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier", "requires a subscription", "upgrade for access", "upgrade for higher limits", "reached your session usage limit", "quota exceeded", "quota_exceeded", "too many tokens per day", "daily limit", "tokens per day", "daily quota", "resource exhausted", @@ -3031,7 +3042,7 @@ def _nous_portal_account_has_fresh_paid_access() -> bool: _RATE_LIMIT_BILLING_KEYWORDS = ( "credits", "insufficient funds", "billing", "payment required", "can only afford", "out of funds", "run out of funds", "balance_depleted", "no usable credits", - "model_not_supported_on_free_tier", "not available on the free tier", + "model_not_supported_on_free_tier", "not available on the free tier", "isn't available on the free tier", ) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 2eeb6c89e9f36..acddfbf722b8d 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2707,6 +2707,7 @@ def _chat_stream_created(self, raw_stream: Any) -> None: response = self._attempt_stream_response = getattr(raw_stream, "response", None) self.agent._capture_rate_limits(response) self.agent._capture_credits(response) + self.agent._capture_nous_model_switch(response) self.agent._stream_diag_capture_response(self.clients.diag, response) self.agent._check_openrouter_cache_status(response) self._writer_token = claim_stream_writer(self.agent) diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 8c1d0030ea5ba..09488986ddc21 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -10,6 +10,7 @@ import enum import json import logging +import time from dataclasses import dataclass, field from typing import Any, Callable, Dict, Iterator, Optional, Sequence @@ -514,9 +515,44 @@ def _plugin_verdict(c: _Ctx) -> Optional[Verdict]: return verdict +def _nous_welcome_tier(c: _Ctx) -> Optional[Verdict]: + """The Nous inference gateway's welcome-tier (free tier) refusals, read from the structured body. + + A 429 carrying a fairshare ``reason`` is either a tier gate (``model_not_free`` / + ``feature_not_free``: the model or feature is never served on the free tier, so retrying is + pointless — abort this route and fall back) or capacity (``at_capacity`` / ``admission_closed`` + / ``rate_limited``: honour ``retry_after``, never rotate the free tier's only credential). A + 400/403 whose message names the wrong host or a dark tier is deterministic for the request. + The parsed refusal rides ``error_context`` so the terminal copy can say what happened. + """ + from hermes_cli.anon_auth import ( + WELCOME_TIER_GATE_REASONS, parse_welcome_refusal, welcome_route_refusal) + status = c.status_code + if status == 429: + refusal = parse_welcome_refusal(c.body) + if refusal is None: + return None + ctx = {"welcome_refusal": refusal} + if refusal["reason"] in WELCOME_TIER_GATE_REASONS: + return _v(_R.model_not_found, retryable=False, should_fallback=True, error_context=ctx) + if refusal["retry_after"] > 0: + ctx["reset_at"] = time.time() + refusal["retry_after"] + return _v(_R.rate_limit, should_fallback=True, error_context=ctx) + kind = welcome_route_refusal(status, c.msg) + if kind is None: + return None + ctx = {"welcome_route": kind} + if status == 403: + return _v(_R.auth_permanent, retryable=False, should_fallback=True, error_context=ctx) + return _v(_R.format_error, retryable=False, should_fallback=True, error_context=ctx) + + def _provider_special_cases(c: _Ctx) -> Optional[Verdict]: """Highest-priority provider-specific shapes that a status code would misroute.""" msg, status = c.msg, c.status_code + welcome = _nous_welcome_tier(c) + if welcome is not None: + return welcome # Safety refusal before status classification so a 400 block isn't downgraded # to format_error and a status-less block isn't left retryable (#18028). if any(p in msg for p in _CONTENT_POLICY_BLOCKED_PATTERNS): diff --git a/agent/rate_limit_credits.py b/agent/rate_limit_credits.py index 9dc0f2d45e236..44c6f7a2003d0 100644 --- a/agent/rate_limit_credits.py +++ b/agent/rate_limit_credits.py @@ -49,6 +49,19 @@ def get_rate_limit_state(self): """Return the last captured RateLimitState, or None.""" return self._rate_limit_state + def _capture_nous_model_switch(self, http_response: Any) -> None: + """Record the Nous gateway's ``x-nous-model-switch`` header (a named account asked for the + free tier's model; the gateway served its backing model and named it). Applied between + calls by ``hermes_cli.anon_auth.apply_model_switch``. Fail-open.""" + headers = _response_headers(http_response) + if not headers: + return + try: + from hermes_cli.anon_auth import note_model_switch + note_model_switch(self, headers) + except Exception: + pass # Never let header parsing break the agent loop + def _capture_anthropic_response_headers(self, http_response: Any) -> None: """Capture rate-limit + credits state from Anthropic Messages response headers (the SDK's aggregated ``Message`` drops them). Fail-open.""" diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index 194ee22f954e4..379a5e450936f 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -219,6 +219,13 @@ def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> NousRateGu ) if agent.provider == "nous": + # A gateway ``x-nous-model-switch`` recorded on the previous response moves this session + # (and the config default, when it still names the free tier's model) before the next call. + try: + from hermes_cli.anon_auth import apply_model_switch + apply_model_switch(agent) + except Exception: + pass try: from agent.nous_rate_guard import ( nous_rate_limit_remaining, format_remaining as _fmt_nous_remaining diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index 38019505fe03a..b71d7862c8f98 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -648,6 +648,19 @@ def _print_nonretryable_auth_guidance( _vlines(agent, " • Check credits: https://openrouter.ai/settings/credits") +def _welcome_tier_guidance(classified: Any, *, model: Any, in_chat: bool) -> str: + """Copy for a Nous free-tier refusal the classifier parsed (``welcome_refusal`` / + ``welcome_route`` in ``error_context``); empty for every other error.""" + ctx = getattr(classified, "error_context", None) or {} + refusal, route = ctx.get("welcome_refusal"), ctx.get("welcome_route") + if not refusal and not route: + return "" + from hermes_cli.anon_auth import welcome_refusal_copy, welcome_route_refusal_copy + if refusal: + return welcome_refusal_copy(refusal, model=str(model or ""), in_chat=in_chat) + return welcome_route_refusal_copy(str(route), in_chat=in_chat) + + # Terminal status label per non-retryable reason (default names the HTTP status). _NONRETRYABLE_LABELS = { FailoverReason.content_policy_blocked: "Provider safety filter blocked this request", @@ -683,7 +696,12 @@ def nonretryable_client_error_result( f" 🔌 Provider: {provider} Model: {model}", f" 🌐 Endpoint: {base_url}", ) - if classified.is_auth or classified.reason == FailoverReason.billing: + _welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False) + if _welcome_hint: + # A free-tier gate or a wrong-host refusal: the way forward is a sign-in or another + # provider, never the key/credits advice below. + _vlines(agent, f" 💡 {_welcome_hint}") + elif classified.is_auth or classified.reason == FailoverReason.billing: _print_nonretryable_auth_guidance( agent, classified, status_code=status_code, provider=provider, base_url=base_url, model=model ) @@ -738,7 +756,10 @@ def nonretryable_client_error_result( classified=classified, summary=_nonretryable_summary, messages=messages, api_call_count=api_call_count, provider=provider, base_url=base_url, model=model, ) - result = _failed_turn_result(_nonretryable_summary, messages, api_call_count, _nonretryable_summary) + _final_response = _nonretryable_summary + if _welcome_hint: + _final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}" + result = _failed_turn_result(_final_response, messages, api_call_count, _nonretryable_summary) # Same verdict fields as the max-retries path: without them the UI descriptor # (agent/error_surface.py) reads a rejected OAuth token as a retryable # "Provider error" and offers Retry instead of a re-login. @@ -795,6 +816,9 @@ def max_retries_exhausted_result( else: agent._emit_status(f"❌ API failed after {max_retries} retries — {_final_summary}") _vlines(agent, f" 💀 Final error: {_final_summary}") + _welcome_hint = _welcome_tier_guidance(classified, model=model, in_chat=False) + if _welcome_hint: + _vlines(agent, f" 💡 {_welcome_hint}") # SSE stream-drop (e.g. "Network connection lost"): usually a proxy/CDN cutting a very # large tool call mid-response. @@ -849,6 +873,8 @@ def max_retries_exhausted_result( ) else: _final_response = f"API call failed after {max_retries} retries: {_final_summary}" + if _welcome_hint: + _final_response += f"\n\n{_welcome_tier_guidance(classified, model=model, in_chat=True)}" if _is_thinking_timeout: # Thinking-timeout guidance overrides stream-drop guidance, which would wrongly # suggest splitting large file writes. diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 0953877f92bed..7a276d1fdab20 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -31,7 +31,7 @@ from typing import Any, Callable, Dict, Optional from hermes_cli.auth_constants import ( - AuthError, DEFAULT_NOUS_PORTAL_URL, _decode_jwt_claims, httpx) + AuthError, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_WELCOME_URL, _decode_jwt_claims, httpx) logger = logging.getLogger("hermes_cli.auth") @@ -226,7 +226,11 @@ def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> 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")) + # NAS names the welcome host on every exchange; absent (older NAS) or outside the allowlist + # (a staging host without NOUS_INFERENCE_BASE_URL set), the literal stands in. Never the paid + # host: the gateway cross-refuses an anonymous JWT there. + inference_url = (_validate_nous_inference_url_from_network(exchanged.get("inference_base_url")) + or DEFAULT_NOUS_WELCOME_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) @@ -235,8 +239,7 @@ def apply_exchange_to_state(state: Dict[str, Any], exchanged: Dict[str, Any]) -> 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 + state["inference_base_url"] = inference_url for key in ("user_id", "org_id"): if exchanged.get(key): state[key] = exchanged[key] @@ -425,6 +428,172 @@ def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None: logger.info("Nous free-tier identity retired (%s); a new one is set up on next use", reason) +# --- Gateway welcome-tier contract: structured refusals and the model-switch header ------------------ +# +# The inference gateway answers a welcome-tier request it will not serve with a structured 429 +# (``{status, message, reason, retry_after, alternates?, upgrade_url?}``), and a request on the wrong +# host with a 400 (or a 403 while the tier is dark) whose message names the right host. A NAMED +# account that still asks for ``nous/welcome`` is served the id's backing model and told what to +# switch to in the ``x-nous-model-switch`` response header. Every rule for reading those lives here; +# the error classifier and the turn loop only call in. + +MODEL_SWITCH_HEADER = "x-nous-model-switch" +# Fairshare refusal reasons the welcome tier can answer with (api ``FairshareRefusalReason``). +WELCOME_REFUSAL_REASONS = frozenset( + {"model_not_free", "feature_not_free", "at_capacity", "admission_closed", "rate_limited"}) +# Reasons that mean "not on this tier, ever": no retry helps, only a sign-in or another provider. +WELCOME_TIER_GATE_REASONS = frozenset({"model_not_free", "feature_not_free"}) +# Gateway messages (lowercased substrings) for a request on the wrong host or a dark tier. +_WELCOME_ROUTE_REFUSALS = ( + ("anonymous accounts must use", "anon_on_paid_host"), + ("serves anonymous hermes agent accounts only", "named_on_welcome_host"), + ("anonymous accounts are not accepted", "tier_disabled"), +) +_WELCOME_ROUTE_COPY = { + "anon_on_paid_host": "The Nous free tier must use its own inference host ({host}); " + "Hermes is pointed at the paid one. Restart Hermes to re-read the route, " + "or unset NOUS_INFERENCE_BASE_URL if you set it.", + "named_on_welcome_host": "This Nous account must use the Nous Portal inference host, " + "not the free tier's. Run /model and pick the Nous row again.", + "tier_disabled": "The Nous free tier is switched off right now. {signin}", +} +_SIGNIN_CHAT = "Sign in with a Nous account for the full catalog: /login." +_SIGNIN_TERMINAL = "Sign in with a Nous account for the full catalog: `hermes auth upgrade`." + + +def parse_welcome_refusal(body: Any) -> Optional[Dict[str, Any]]: + """The structured welcome-tier refusal in a gateway 429 body, or None for any other shape. + + Returns ``{"reason", "retry_after", "alternates", "upgrade_url"}`` with ``retry_after`` an int + of whole seconds (0 when the gateway sent none) and ``alternates`` a list of model ids. + """ + if not isinstance(body, dict): + return None + reason = body.get("reason") + if not isinstance(reason, str) or reason not in WELCOME_REFUSAL_REASONS: + return None + raw_retry = body.get("retry_after") + try: + retry_after = max(0, int(float(raw_retry))) if raw_retry not in (None, "") else 0 + except (TypeError, ValueError): + retry_after = 0 + raw_alternates = body.get("alternates") + alternates = [str(a) for a in raw_alternates if isinstance(a, str) and a] if isinstance(raw_alternates, list) else [] + upgrade_url = body.get("upgrade_url") + return {"reason": reason, "retry_after": retry_after, "alternates": alternates, + "upgrade_url": upgrade_url if isinstance(upgrade_url, str) else ""} + + +def welcome_refusal_copy(refusal: Dict[str, Any], *, model: str = "", in_chat: bool = True) -> str: + """User copy for a structured welcome-tier refusal: what happened and the one way forward. + + Never guest / anonymous / claim; ``in_chat`` picks ``/login`` over the terminal verb.""" + signin = _SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL + reason = str(refusal.get("reason") or "") + alternates = refusal.get("alternates") or [] + serves = alternates[0] if alternates else GUEST_MODEL + retry = int(refusal.get("retry_after") or 0) + wait = f"Retrying in {retry}s." if retry > 0 else "Try again shortly." + if reason == "model_not_free": + what = f"{model} isn't on the Nous free tier" if model else "That model isn't on the Nous free tier" + return f"{what}; it serves {serves} only. {signin}" + if reason == "feature_not_free": + return f"This feature isn't on the Nous free tier. {signin}" + if reason == "at_capacity": + return f"The Nous free tier is at capacity and briefly paused. {wait} {signin}" + if reason == "admission_closed": + return f"The Nous free tier isn't admitting new sessions right now. {wait} {signin}" + if reason == "rate_limited": + return f"Nous free tier rate limit active \u2014 resets in {retry}s. {signin}" + return f"The Nous free tier refused this request ({reason}). {signin}" + + +def welcome_route_refusal(status: Any, message: Any) -> Optional[str]: + """Which host cross-refusal a gateway 400/403 is, by its message; None for any other error. + + ``"anon_on_paid_host"``: a free-tier JWT reached the paid host. ``"named_on_welcome_host"``: an + account or API key reached the free tier's host. ``"tier_disabled"``: the tier is dark + (``WELCOME_MODE=off``). Each is deterministic for the request: retrying cannot help.""" + if status not in (400, 403): + return None + text = str(message or "").lower() + return next((kind for needle, kind in _WELCOME_ROUTE_REFUSALS if needle in text), None) + + +def welcome_route_refusal_copy(kind: str, *, in_chat: bool = True) -> str: + template = _WELCOME_ROUTE_COPY.get(kind) or "The Nous inference gateway refused this route." + return template.format( + host=DEFAULT_NOUS_WELCOME_URL, signin=_SIGNIN_CHAT if in_chat else _SIGNIN_TERMINAL) + + +def note_model_switch(agent: Any, headers: Any) -> Optional[str]: + """Record the gateway's ``x-nous-model-switch`` header on *agent* for the next call, if present. + + The header arrives on a NAMED account's response that asked for ``nous/welcome`` (the gateway + served the backing model and billed it normally): the free tier's model no longer belongs in + this install's configuration. Recorded here, applied by :func:`apply_model_switch` between + calls so a response still streaming is never re-labelled under itself. Returns the backing id. + """ + if headers is None: + return None + value = None + try: + value = headers.get(MODEL_SWITCH_HEADER) + if value is None and hasattr(headers, "items"): + value = next((v for k, v in headers.items() if str(k).lower() == MODEL_SWITCH_HEADER), None) + except Exception: + return None + backing = str(value or "").strip() + if not backing: + return None + requested = str(getattr(agent, "model", "") or "") + if backing == requested: + return None + try: + agent._nous_pending_model_switch = (requested, backing) + except Exception: + return None + return backing + + +def apply_model_switch(agent: Any) -> Optional[str]: + """Move *agent* (and the config default, when it still names the switched id) to the backing + model the gateway named. Returns the new model, or None when nothing was pending. + + Runs once per recorded header, between calls. The conversation keeps its history; only the id + the next request carries changes, so a promoted account stops relying on the gateway's reverse + map. The config write is the same one a sign-in completion uses, so ``hermes model`` and the + gateway's config re-read agree with the live session. + """ + pending = getattr(agent, "_nous_pending_model_switch", None) + if not pending: + return None + agent._nous_pending_model_switch = None + requested, backing = pending + if str(getattr(agent, "model", "") or "") != requested: + return None # the session already moved (a /model, a sign-in sweep) + agent.model = backing + logger.info("Nous gateway asked to switch %s -> %s; applied for this session", requested, backing) + try: + from hermes_cli.config import load_config_readonly + raw = load_config_readonly().get("model") + model_cfg = raw if isinstance(raw, dict) else ({"default": raw} if isinstance(raw, str) else {}) + if str(model_cfg.get("default") or "").strip() == requested: + from hermes_cli.auth import _update_config_for_provider + _update_config_for_provider( + "nous", str(getattr(agent, "base_url", "") or ""), default_model=backing) + logger.info("Config default model moved %s -> %s", requested, backing) + except Exception as exc: + logger.debug("model switch: config default left as is: %s", exc) + status = getattr(agent, "_buffer_status", None) + if callable(status): + try: + status(f"Model is now {backing} (your account's model; {requested} is the free tier's).") + except Exception: + pass + return backing + + # 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. diff --git a/hermes_cli/auth_constants.py b/hermes_cli/auth_constants.py index 9678da216cc1e..73ce99b961734 100644 --- a/hermes_cli/auth_constants.py +++ b/hermes_cli/auth_constants.py @@ -52,6 +52,10 @@ def __delattr__(self, name): # Nous Portal defaults DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com" DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1" +# The free tier's (anonymous account) inference host. NAS hands it to the client on every token +# exchange (``inference_base_url``); this literal is the fallback when that field is absent or fails +# the host allowlist, because the paid host cross-refuses an anonymous JWT with a 400. +DEFAULT_NOUS_WELCOME_URL = "https://welcome-api.nousresearch.com/v1" DEFAULT_NOUS_CLIENT_ID = "hermes-cli" NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke" NOUS_BILLING_MANAGE_SCOPE = "billing:manage" diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index faef27871823a..9e7cdd24e70b6 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -21,7 +21,7 @@ from hermes_cli.auth_codex import _pool_entries from hermes_cli.auth_constants import ( _decode_jwt_claims, AUTH_LOCK_TIMEOUT_SECONDS, AuthError, DEFAULT_NOUS_CLIENT_ID, - DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_SCOPE, + DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_SCOPE, DEFAULT_NOUS_WELCOME_URL, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS, NOUS_AUTH_PATH_INVOKE_JWT, NOUS_BILLING_MANAGE_SCOPE, NOUS_DEVICE_CODE_SOURCE, NOUS_INFERENCE_INVOKE_SCOPE, NOUS_INVOKE_JWT_MIN_TTL_SECONDS, _nous_err, httpx) @@ -364,7 +364,9 @@ def _nous_shared_shape(src: Dict[str, Any]) -> Dict[str, Any]: "scope": src.get("scope") or DEFAULT_NOUS_SCOPE, "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, + # A guest's route defaults to the welcome host: the paid host cross-refuses its JWT. + "inference_base_url": src.get("inference_base_url") or ( + DEFAULT_NOUS_WELCOME_URL if src.get("auth_method") == "anonymous" else DEFAULT_NOUS_INFERENCE_URL), "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, "")}} @@ -805,10 +807,13 @@ def _nous_effective_routing(state: Dict[str, Any]) -> tuple[str, str, str, str]: "(host %r or scheme not allowed), using default", portal_url, portal_host) portal_url = DEFAULT_NOUS_PORTAL_URL + # A guest never falls back to the paid host: the gateway cross-refuses an anonymous JWT there + # (400 naming the welcome host), so an absent or disallowed URL heals to the welcome literal. + from hermes_cli.anon_auth import is_guest_state stored_inference_url = ( _validate_nous_inference_url_from_network( _optional_base_url(state.get("inference_base_url"))) - or DEFAULT_NOUS_INFERENCE_URL) + or (DEFAULT_NOUS_WELCOME_URL if is_guest_state(state) else DEFAULT_NOUS_INFERENCE_URL)) return ( portal_url, stored_inference_url, _nous_inference_env_override() or stored_inference_url, str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)) diff --git a/tests/agent/test_error_classifier.py b/tests/agent/test_error_classifier.py index 828e83e108a8a..13cb0ab873b05 100644 --- a/tests/agent/test_error_classifier.py +++ b/tests/agent/test_error_classifier.py @@ -1712,3 +1712,83 @@ def test_other_bad_parameter_via_5xx_stays_non_retryable(self, status_code): assert result.retryable is False + + +# ── Test: Nous welcome tier (free tier) refusals ─────────────────────── + +class TestNousWelcomeTier: + """The Nous gateway's welcome-tier contract: a structured 429 body carries ``reason`` / + ``retry_after`` / ``alternates`` / ``upgrade_url``; a 400/403 names the wrong host or a + dark tier in its message. The parsed refusal rides ``error_context``.""" + + @staticmethod + def _refusal(reason, retry_after=0, **extra): + body = {"status": 429, "message": "refused", "reason": reason, "retry_after": retry_after, **extra} + return MockAPIError(f"Error code: 429 - {body}", status_code=429, body=body, + headers={"retry-after": str(retry_after)}) + + def test_model_not_free_is_a_non_retryable_gate_with_fallback(self): + err = self._refusal("model_not_free", alternates=["nous/welcome"], upgrade_url="https://portal.example/upgrade") + result = classify_api_error(err, provider="nous", model="gpt-5") + assert result.reason == FailoverReason.model_not_found + assert result.retryable is False + assert result.should_fallback is True + assert result.should_rotate_credential is False + refusal = result.error_context["welcome_refusal"] + assert refusal["reason"] == "model_not_free" + assert refusal["alternates"] == ["nous/welcome"] + assert refusal["upgrade_url"] == "https://portal.example/upgrade" + + def test_feature_not_free_is_the_same_gate(self): + result = classify_api_error(self._refusal("feature_not_free"), provider="nous") + assert result.reason == FailoverReason.model_not_found + assert result.retryable is False + + @pytest.mark.parametrize("reason", ["at_capacity", "admission_closed", "rate_limited"]) + def test_capacity_refusals_are_rate_limits_that_honour_retry_after(self, reason): + result = classify_api_error(self._refusal(reason, retry_after=30), provider="nous", model="nous/welcome") + assert result.reason == FailoverReason.rate_limit + assert result.retryable is True + assert result.should_fallback is True + ctx = result.error_context + assert ctx["welcome_refusal"]["retry_after"] == 30 + assert ctx["reset_at"] > 0 + + def test_retry_after_zero_carries_no_reset(self): + result = classify_api_error(self._refusal("at_capacity", retry_after=0), provider="nous") + assert "reset_at" not in result.error_context + + def test_unknown_reason_is_not_the_welcome_shape(self): + err = MockAPIError("Error code: 429", status_code=429, + body={"status": 429, "message": "x", "reason": "something_else", "retry_after": 5}) + result = classify_api_error(err, provider="nous") + assert "welcome_refusal" not in result.error_context + + def test_anonymous_jwt_on_the_paid_host_is_deterministic(self): + body = {"status": 400, "message": "Anonymous accounts must use https://welcome-api.nousresearch.com for inference."} + err = MockAPIError(f"Error code: 400 - {body}", status_code=400, body=body) + result = classify_api_error(err, provider="nous", model="nous/welcome") + assert result.reason == FailoverReason.format_error + assert result.retryable is False and result.should_fallback is True + assert result.error_context["welcome_route"] == "anon_on_paid_host" + + def test_named_caller_on_the_welcome_host_is_deterministic(self): + body = {"status": 400, "message": "This endpoint serves anonymous Hermes Agent accounts only. Use https://inference-api.nousresearch.com with your API key or signed-in account."} + err = MockAPIError(f"Error code: 400 - {body}", status_code=400, body=body) + result = classify_api_error(err, provider="nous") + assert result.error_context["welcome_route"] == "named_on_welcome_host" + assert result.retryable is False + + def test_dark_tier_403_never_triggers_a_credential_refresh(self): + body = {"status": 403, "message": "Anonymous accounts are not accepted by this API right now."} + err = MockAPIError(f"Error code: 403 - {body}", status_code=403, body=body) + result = classify_api_error(err, provider="nous", model="nous/welcome") + assert result.reason == FailoverReason.auth_permanent + assert result.retryable is False and result.should_fallback is True + assert result.should_rotate_credential is False + assert result.error_context["welcome_route"] == "tier_disabled" + + def test_ordinary_403_is_untouched(self): + result = classify_api_error(MockAPIError("forbidden", status_code=403, body={"message": "forbidden"}), provider="nous") + assert result.reason == FailoverReason.auth + assert "welcome_route" not in result.error_context diff --git a/tests/agent/test_nous_welcome_client_contract.py b/tests/agent/test_nous_welcome_client_contract.py new file mode 100644 index 0000000000000..ebf404e68d603 --- /dev/null +++ b/tests/agent/test_nous_welcome_client_contract.py @@ -0,0 +1,161 @@ +"""The client side of the Nous welcome tier's gateway contract: auxiliary calls on the welcome +host use its one model, the ``x-nous-model-switch`` header moves a session off ``nous/welcome``, +and refusal copy names the way forward (never guest / anonymous / claim).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from hermes_cli import anon_auth + +WELCOME = "https://welcome-api.nousresearch.com/v1" +PAID = "https://inference-api.nousresearch.com/v1" + + +# ── Auxiliary client: the welcome host serves exactly one model ────────────────────────────────── + +class TestAuxiliaryOnWelcomeHost: + @pytest.fixture(autouse=True) + def _isolate(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_read_nous_auth", return_value={"auth_method": "anonymous"}), \ + patch.object(ac, "nous_rate_limit_remaining", return_value=None, create=True): + yield + + def test_text_aux_on_welcome_host_pins_the_welcome_model(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_resolve_nous_runtime_api", return_value=("jwt", WELCOME)), \ + patch.object(ac, "_create_openai_client", return_value="client") as create, \ + patch("hermes_cli.models.get_nous_recommended_aux_model") as recommended: + client, model = ac._try_nous() + assert (client, model) == ("client", anon_auth.GUEST_MODEL) + assert create.call_args.kwargs["base_url"] == WELCOME + recommended.assert_not_called() # the Portal's pick would be a guaranteed 429 model_not_free + assert ac.auxiliary_is_nous is True + + def test_vision_aux_on_welcome_host_skips_nous(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_resolve_nous_runtime_api", return_value=("jwt", WELCOME)), \ + patch.object(ac, "_create_openai_client") as create: + assert ac._try_nous(vision=True) == (None, None) + create.assert_not_called() + + def test_paid_host_keeps_the_portal_recommendation(self): + import agent.auxiliary_client as ac + with patch.object(ac, "_resolve_nous_runtime_api", return_value=("jwt", PAID)), \ + patch.object(ac, "_create_openai_client", return_value="client"), \ + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value="some/free-model"): + client, model = ac._try_nous() + assert (client, model) == ("client", "some/free-model") + + +# ── Model-switch header ─────────────────────────────────────────────────────────────────────────── + +def _agent(model="nous/welcome", base_url=PAID): + return SimpleNamespace(model=model, base_url=base_url, provider="nous", statuses=[]) + + +class TestModelSwitchHeader: + def test_header_is_recorded_not_applied_on_the_streaming_response(self): + agent = _agent() + assert anon_auth.note_model_switch(agent, {"X-Nous-Model-Switch": "z-ai/glm-5.3-flash"}) == "z-ai/glm-5.3-flash" + assert agent.model == "nous/welcome" + assert agent._nous_pending_model_switch == ("nous/welcome", "z-ai/glm-5.3-flash") + + def test_no_header_records_nothing(self): + agent = _agent() + assert anon_auth.note_model_switch(agent, {"content-type": "application/json"}) is None + assert getattr(agent, "_nous_pending_model_switch", None) is None + + def test_apply_moves_the_session_and_the_config_default(self, monkeypatch): + agent = _agent() + agent._buffer_status = agent.statuses.append + anon_auth.note_model_switch(agent, {"x-nous-model-switch": "z-ai/glm-5.3-flash"}) + writes = [] + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"model": {"default": "nous/welcome"}}) + monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", + lambda provider, url, default_model=None, **kw: writes.append((provider, url, default_model))) + assert anon_auth.apply_model_switch(agent) == "z-ai/glm-5.3-flash" + assert agent.model == "z-ai/glm-5.3-flash" + assert writes == [("nous", PAID, "z-ai/glm-5.3-flash")] + assert agent._nous_pending_model_switch is None + assert agent.statuses and "z-ai/glm-5.3-flash" in agent.statuses[0] + assert anon_auth.apply_model_switch(agent) is None # one application per header + + def test_apply_leaves_a_user_chosen_default_alone(self, monkeypatch): + agent = _agent() + anon_auth.note_model_switch(agent, {"x-nous-model-switch": "z-ai/glm-5.3-flash"}) + writes = [] + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"model": {"default": "openai/gpt-5"}}) + monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", + lambda *a, **kw: writes.append(a)) + assert anon_auth.apply_model_switch(agent) == "z-ai/glm-5.3-flash" + assert writes == [] + + def test_apply_is_a_noop_when_the_session_already_moved(self): + agent = _agent() + anon_auth.note_model_switch(agent, {"x-nous-model-switch": "z-ai/glm-5.3-flash"}) + agent.model = "openai/gpt-5" # a /model or the sign-in sweep won the race + assert anon_auth.apply_model_switch(agent) is None + assert agent.model == "openai/gpt-5" + + def test_mixin_capture_records_the_header(self): + from agent.rate_limit_credits import RateLimitCreditsMixin + + class _Agent(RateLimitCreditsMixin): + model = "nous/welcome" + provider = "nous" + a = _Agent() + a._capture_nous_model_switch(SimpleNamespace(headers={"x-nous-model-switch": "z-ai/glm-5.3-flash"})) + assert a._nous_pending_model_switch == ("nous/welcome", "z-ai/glm-5.3-flash") + a._capture_nous_model_switch(None) # fail-open + + +# ── Refusal copy ────────────────────────────────────────────────────────────────────────────────── + +class TestRefusalCopy: + def test_parse_reads_the_gateway_shape(self): + body = {"status": 429, "message": "m", "reason": "model_not_free", "retry_after": 0, + "alternates": ["nous/welcome"], "upgrade_url": "https://portal.example/upgrade"} + assert anon_auth.parse_welcome_refusal(body) == { + "reason": "model_not_free", "retry_after": 0, "alternates": ["nous/welcome"], + "upgrade_url": "https://portal.example/upgrade"} + assert anon_auth.parse_welcome_refusal({"reason": "nope"}) is None + assert anon_auth.parse_welcome_refusal("not a dict") is None + + def test_copy_names_the_served_model_and_the_sign_in(self): + refusal = anon_auth.parse_welcome_refusal({"reason": "model_not_free", "alternates": ["nous/welcome"]}) + chat = anon_auth.welcome_refusal_copy(refusal, model="gpt-5", in_chat=True) + assert chat == "gpt-5 isn't on the Nous free tier; it serves nous/welcome only. Sign in with a Nous account for the full catalog: /login." + terminal = anon_auth.welcome_refusal_copy(refusal, model="gpt-5", in_chat=False) + assert "`hermes auth upgrade`" in terminal and "/login" not in terminal + + def test_capacity_copy_carries_the_retry(self): + refusal = anon_auth.parse_welcome_refusal({"reason": "at_capacity", "retry_after": 30}) + assert "Retrying in 30s." in anon_auth.welcome_refusal_copy(refusal) + + @pytest.mark.parametrize("copy_fn, args", [ + (anon_auth.welcome_refusal_copy, ({"reason": r},)) for r in sorted(anon_auth.WELCOME_REFUSAL_REASONS) + ] + [(anon_auth.welcome_route_refusal_copy, (k,)) for k in ("anon_on_paid_host", "named_on_welcome_host", "tier_disabled")]) + def test_copy_never_says_guest_anonymous_or_claim(self, copy_fn, args): + text = copy_fn(*args).lower() + assert not any(word in text for word in ("guest", "anonymous", "claim")) + + def test_route_refusal_detection(self): + assert anon_auth.welcome_route_refusal(400, "Anonymous accounts must use https://x for inference.") == "anon_on_paid_host" + assert anon_auth.welcome_route_refusal(403, "Anonymous accounts are not accepted by this API right now.") == "tier_disabled" + assert anon_auth.welcome_route_refusal(429, "Anonymous accounts must use") is None + assert anon_auth.welcome_route_refusal(400, "bad request") is None + + +class TestTurnRecoveryGuidance: + def test_guidance_from_classified_context(self): + from agent.turn_recovery import _welcome_tier_guidance + classified = SimpleNamespace(error_context={"welcome_refusal": {"reason": "model_not_free", "retry_after": 0, "alternates": []}}) + assert "nous/welcome" in _welcome_tier_guidance(classified, model="gpt-5", in_chat=True) + classified = SimpleNamespace(error_context={"welcome_route": "tier_disabled"}) + assert "switched off" in _welcome_tier_guidance(classified, model="", in_chat=True) + assert _welcome_tier_guidance(SimpleNamespace(error_context={}), model="", in_chat=True) == "" diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index 144a44869c86a..cb33fa8a64823 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -39,6 +39,8 @@ def __init__(self): self.dead_tokens: set[str] = set() self.gate_closed = False self.minted = 0 + # What the token exchange names as the inference host; None = an older NAS that omits it. + self.inference_base_url: str | None = WELCOME def handler(self, request: httpx.Request) -> httpx.Response: path = request.url.path @@ -55,9 +57,11 @@ def handler(self, request: httpx.Request) -> httpx.Response: 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}) + body = {"access_token": _jwt(), "token_type": "Bearer", "expires_in": 900, + "user_id": "nas_user:1", "org_id": "nas_org:1"} + if self.inference_base_url: + body["inference_base_url"] = self.inference_base_url + return httpx.Response(200, json=body) return httpx.Response(500, json={"error": f"unexpected {path}"}) @@ -178,6 +182,39 @@ def test_runtime_routes_to_welcome_host(self, portal): assert runtime["api_key"] +class TestRouteFallback: + """A guest never falls back to the paid host: the gateway cross-refuses an anonymous JWT there.""" + + def test_exchange_without_inference_url_routes_to_welcome_literal(self, portal): + portal.inference_base_url = None + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider() + assert runtime["base_url"].rstrip("/") == WELCOME + state = _load_auth_store()["providers"]["nous"] + assert state["inference_base_url"].rstrip("/") == WELCOME + + def test_disallowed_inference_host_heals_to_welcome_literal(self, portal): + portal.inference_base_url = "https://welcome-api.staging-nousresearch.com/v1" + anon_auth.ensure_portal_identity(blocking=True) + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider() + assert runtime["base_url"].rstrip("/") == WELCOME + + def test_guest_state_without_url_never_resolves_to_the_paid_host(self, portal): + from hermes_cli.auth_nous import _nous_effective_routing + guest = {"auth_method": "anonymous", "anon_token": "anon_x"} + _portal, stored, effective, _client = _nous_effective_routing(guest) + assert stored.rstrip("/") == WELCOME and effective.rstrip("/") == WELCOME + _portal, stored, _effective, _client = _nous_effective_routing({"refresh_token": "r"}) + assert stored.rstrip("/") == "https://inference-api.nousresearch.com/v1" + + def test_shared_store_shape_keeps_a_guest_on_the_welcome_host(self, portal): + from hermes_cli.auth_nous import _nous_shared_shape + shape = _nous_shared_shape({"auth_method": "anonymous", "anon_token": "anon_x"}) + assert shape["inference_base_url"].rstrip("/") == WELCOME + + class TestTokenAcquisitionSeam: def test_expired_guest_jwt_reexchanges_and_never_hits_oauth_token(self, portal): anon_auth.ensure_portal_identity(blocking=True) diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index e784809cbb831..297804f2c408a 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -23,6 +23,10 @@ through. `hermes setup` is still there when you want it; it is never forced. "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. +Background work (conversation compaction, chat titles, and similar) runs on `nous/welcome` too. +Image understanding is not part of that model: on the free tier, a step that needs to look at an +image falls through to any other provider you have configured, or is skipped. + 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 From c75554d4576c1d8e1ddeb9ac89c0b54811d51495 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Tue, 8 Sep 2026 16:55:41 +1000 Subject: [PATCH 02/16] feat(auth): the free tier is set up on request; nous.guest_setup decides whether also on first use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A caller that names nous/welcome on a Nous route with no Nous identity in reach — the guided setup's session (provider=nous, which skips the resolver's nothing-configured rung), the free-tier picker row, a bare --provider nous pointed at it — is asking for the free tier. The OAuth runtime rung now sets it up there instead of failing "not logged in", so the guided chat no longer races the root profile's first-run mint. nous.guest_setup is the policy seam: "auto" (default) keeps today's first-use setup wherever nothing else is configured; "on-request" mints only when the free tier is asked for by name (nous/welcome, /login, hermes auth upgrade, replacing a retired identity). Implicit callers — the resolver's last rung, the first-run check, free_tier.status, the CLI's background setup, the connector token path — still adopt what the shared store holds, so every profile follows the one identity the guided setup created, but never create one on their own. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit ae915ddc65ecdb81b81e29b604671d15cd49233c) (cherry picked from commit 62ad1ff3ab200ea064975a32c502041b25910165) --- hermes_cli/anon_auth.py | 63 ++++++++++++++++++++++--- hermes_cli/anon_sign_in.py | 3 +- hermes_cli/auth_nous.py | 4 +- hermes_cli/config_defaults.py | 5 ++ hermes_cli/runtime_provider.py | 11 +++++ tests/hermes_cli/test_anon_auth_core.py | 55 +++++++++++++++++++++ tools/managed_tool_gateway.py | 3 +- website/docs/user-guide/free-tier.md | 15 ++++++ 8 files changed, 149 insertions(+), 10 deletions(-) diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 7a276d1fdab20..ea79e2befbda3 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -89,6 +89,33 @@ def guest_enabled() -> bool: return bool(nous_cfg.get("guest", True)) +GUEST_SETUP_AUTO = "auto" +GUEST_SETUP_ON_REQUEST = "on-request" + + +def guest_setup_policy() -> str: + """``nous.guest_setup``: when Hermes creates a free-tier identity on its own. + + ``"auto"`` (default): on first use, wherever nothing else is configured — the resolver's last + rung, the first-run check, the desktop's status read, the connector token path. + ``"on-request"``: only when something asks for the free tier by name — ``nous/welcome`` selected + as the model (the guided setup's session, the free-tier row in ``hermes model``), ``/login`` and + ``hermes auth upgrade``. Implicit callers still ADOPT an identity the shared store already holds + (so every profile follows the one the guided setup minted); they never mint one. + ``HERMES_FORCE_GUEST`` reads as ``auto``. Unknown values read as ``auto``. + """ + if force_guest_mode(): + return GUEST_SETUP_AUTO + try: + from hermes_cli.config import load_config_readonly + nous_cfg = load_config_readonly().get("nous") + except Exception: + return GUEST_SETUP_AUTO + raw = nous_cfg.get("guest_setup") if isinstance(nous_cfg, dict) else None + value = str(raw or "").strip().lower().replace("_", "-") + return GUEST_SETUP_ON_REQUEST if value in {"on-request", "onrequest", "explicit", "request"} else GUEST_SETUP_AUTO + + def is_guest_state(state: Any) -> bool: return isinstance(state, dict) and state.get("auth_method") == ANON_AUTH_METHOD @@ -290,14 +317,15 @@ def _mint_locked(client: httpx.Client, portal: str, auth_store: Dict[str, Any]) _forced_new_done = False -def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Dict[str, Any]: +def _reconcile_and_provision(*, force: str, timeout_seconds: float, may_mint: bool = True) -> Optional[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. + 3. Nothing anywhere: mint, persisting the credential before exchanging it — unless *may_mint* + is False (an implicit caller under ``nous.guest_setup: on-request``), which returns None. ``force == "new"`` skips 1 and 2. """ from hermes_cli.auth import ( @@ -322,19 +350,28 @@ def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Dict[str, if not shared: _write_shared_nous_state(profile_state) return profile_state + if not may_mint: + return None 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]]: +def ensure_portal_identity( + *, blocking: bool = True, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, on_request: bool = False, +) -> 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). + store has none. Returns the ``providers.nous`` state, or None (disabled / non-blocking / failed / + nothing to adopt and this caller may not mint). 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). + + *on_request* says the caller asked for the free tier by name (``nous/welcome`` requested, a + sign-in from it, a retired identity being replaced). Under ``nous.guest_setup: on-request`` only + those callers mint; the implicit ones (default) still adopt what the shared store holds. """ global _mint_failed, _forced_new_done if not guest_enabled(): @@ -342,12 +379,13 @@ def ensure_portal_identity(*, blocking: bool = True, timeout_seconds: float = GU force = force_guest_mode() if force == "new" and _forced_new_done: force = "1" - if _mint_failed and force != "new" and not current_nous_state(): + may_mint = on_request or guest_setup_policy() == GUEST_SETUP_AUTO + if may_mint and _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) + result = _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds, may_mint=may_mint) except Exception: _mint_failed = True raise @@ -364,7 +402,7 @@ def ensure_portal_identity(*, blocking: bool = True, timeout_seconds: float = GU def _run() -> None: global _background_started try: - _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds) + _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds, may_mint=may_mint) 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 @@ -381,6 +419,17 @@ def _run() -> None: return None +def ensure_identity_for_model(model: Any, *, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: + """The free tier, set up because *model* asked for it: ``nous/welcome`` is served to a free-tier + identity and nothing else, so requesting it on a profile with no Nous identity IS the request + for one (the guided setup's session, the free-tier row in ``hermes model``, a bare + ``--provider nous`` pointed at it). Any other model is not a request: None, nothing minted. + Explicit under every ``nous.guest_setup`` policy; ``nous.guest: false`` still wins.""" + if str(model or "").strip() != GUEST_MODEL: + return None + return ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, on_request=True) + + 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. diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index dce6fa8ab404f..6b4c0acde3901 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -301,7 +301,8 @@ def run_sign_in( if not _core.guest_enabled(): precondition_state = Unavailable() else: - state = _core.ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) + state = _core.ensure_portal_identity( + blocking=True, timeout_seconds=timeout_seconds, on_request=True) if not _core.is_guest_state(state): # The free tier is off, or an account appeared mid-flight. precondition_state = Unavailable() diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 9e7cdd24e70b6..1c0340cd937de 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -981,7 +981,9 @@ def resolve_nous_runtime_credentials( 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: + # Replacing an identity the user already had is a continuation of that request, not a + # new implicit setup: allowed under every nous.guest_setup policy. + if ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, on_request=True) is None: raise return _resolve_nous_runtime_credentials( timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 84172ebf0aa65..41ee0d338e3dc 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2340,6 +2340,11 @@ def _aux(timeout, *, reasoning_effort=True, **extra): # `hermes auth upgrade`) to sign in. false turns the free tier off entirely: nothing is set # up and nothing is used. "guest": True, + # When Hermes creates the free-tier identity on its own. "auto": on first use, wherever + # nothing else is configured. "on-request": only when the free tier is asked for by name + # (nous/welcome selected as the model, the guided setup, /login or `hermes auth upgrade`); + # other profiles then follow the identity it set up, but never create one on their own. + "guest_setup": "auto", }, # 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). diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 37dc514bcfa03..79b49db6ec982 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -668,6 +668,17 @@ def _resolve_oauth_runtime(provider, requested_provider, model_cfg, target_model request; for "auto" (auto-detected but credentials stale/revoked) log and return None so the ladder falls through to env-var providers (e.g. OpenRouter).""" spec = _OAUTH_RUNTIME_PROVIDERS[provider] + if provider == "nous" and not auth_mod.get_provider_auth_state("nous"): + # No Nous identity anywhere this profile can see (its own store, the global root). A caller + # naming nous/welcome — the guided setup's session (provider=nous, which skips the + # resolver's nothing-configured rung), the free-tier picker row — is asking for the free + # tier: set it up here, on the request, instead of failing "not logged in". Any other + # model is not a request; the resolve below raises as before. + from hermes_cli.anon_auth import ensure_identity_for_model + try: + ensure_identity_for_model(_effective_model(model_cfg, target_model)) + except Exception as exc: + logger.debug("nous: free tier setup for the requested model skipped: %s", exc) try: creds = spec.resolve() except AuthError: diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index cb33fa8a64823..584a2574452c6 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -166,6 +166,61 @@ def test_force_guest_overrides_opt_out_and_new_bypasses_shared_store(self, porta assert portal.minted == 2 +class TestSetupPolicy: + """``nous.guest_setup: on-request``: implicit callers adopt but never mint; a request for the free + tier by name (``nous/welcome`` on an explicit Nous route, a sign-in) mints; a sibling profile + then follows the shared store.""" + + def test_on_request_implicit_callers_never_mint(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="on-request") + assert anon_auth.guest_setup_policy() == anon_auth.GUEST_SETUP_ON_REQUEST + assert anon_auth.ensure_portal_identity(blocking=True) is None + with pytest.raises(anon_auth.AuthError): + resolve_provider("auto") # the resolver's last rung is implicit + assert portal.minted == 0 and portal.calls == [] + + def test_on_request_welcome_model_on_the_nous_route_mints_once(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="on-request") + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) + assert runtime["provider"] == "nous" + assert runtime["base_url"].rstrip("/") == WELCOME + assert portal.minted == 1 + assert anon_auth.has_guest() + resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) + assert portal.minted == 1 # adopted, not re-minted + + def test_on_request_other_model_on_the_nous_route_does_not_mint(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="on-request") + from hermes_cli.runtime_provider import resolve_runtime_provider + with pytest.raises(anon_auth.AuthError): + resolve_runtime_provider(requested="nous", target_model="openai/gpt-5") + assert portal.minted == 0 + + def test_on_request_sibling_profile_adopts_what_the_guided_setup_minted(self, portal, monkeypatch, tmp_path): + _write_config(monkeypatch, guest_setup="on-request") + from hermes_cli.runtime_provider import resolve_runtime_provider + resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) + token = _load_auth_store()["providers"]["nous"]["anon_token"] + sibling = tmp_path / "sibling-profile" + sibling.mkdir() + monkeypatch.setenv("HERMES_HOME", str(sibling)) + _write_config(monkeypatch, guest_setup="on-request") + adopted = anon_auth.ensure_portal_identity(blocking=True) # implicit: adopt only + assert adopted and adopted["anon_token"] == token + assert portal.minted == 1 + + def test_auto_policy_keeps_todays_first_use_mint(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="auto") + assert resolve_provider("auto") == "nous" + assert portal.minted == 1 + + def test_guest_off_beats_an_explicit_request(self, portal, monkeypatch): + _write_config(monkeypatch, guest=False, guest_setup="on-request") + assert anon_auth.ensure_identity_for_model(anon_auth.GUEST_MODEL) is None + assert portal.minted == 0 + + class TestResolverIsUnchanged: def test_guest_is_last_resort_and_explicit_key_wins(self, portal, monkeypatch): anon_auth.ensure_portal_identity(blocking=True) diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 458c4daf31775..4ead6a591df73 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -140,7 +140,8 @@ def _replace_dead_guest_token(dead_state: dict) -> Optional[str]: clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token")) try: - if ensure_portal_identity(blocking=True) is None: + # A replacement continues the identity the user already had: explicit under every policy. + if ensure_portal_identity(blocking=True, on_request=True) is None: return None return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)) except Exception as exc: diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index 297804f2c408a..e80dcc2ef7784 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -192,3 +192,18 @@ exists so inference and connector calls can be authenticated and rate limited. I a command. Signing in (`/login`, or `hermes auth upgrade` in a terminal) 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. + +## Creating the identity only on request + +```bash +hermes config set nous.guest_setup on-request +``` + +By default (`nous.guest_setup: auto`) Hermes creates the free-tier identity the first time it +needs inference or a connector and nothing else is configured. With `on-request`, it creates one +only when the free tier is asked for by name: picking `nous/welcome` as the model (the +**Nous · free tier** row in `hermes model`, or the guided setup on Hermes Desktop, which runs on +that model), or signing in with `/login` or `hermes auth upgrade`. Nothing is created quietly in +the background. Once an identity exists, every profile on the machine follows it as usual, and an +expired one is replaced the next time it is needed. `nous.guest: false` still turns the free tier +off entirely. From 8375015227adec6dffa62a3cc86b74d929fe3b15 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Wed, 9 Sep 2026 00:11:32 +1000 Subject: [PATCH 03/16] feat(auth): the guided setup provisions the free tier explicitly; nous.guest_setup is auto | explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two questions govern the free tier: may it exist (nous.guest) and who may CREATE the identity (nous.guest_setup). "auto" (default) keeps today's first-use setup wherever nothing else is configured. "explicit" means Hermes never creates one on its own: the only creator is the new provision_free_tier() primitive, exposed as the free_tier.provision RPC, which the guided setup on Hermes Desktop calls as its first step — on the root gateway, before the setup profile and before the guided chat exists — so the identity lands in the root store every profile reads through and is there before any session asks for nous/welcome. That closes the race against the backend's own setup, and makes "only when the setup-bot flow is used" literally true. The earlier "on-request" tier is replaced: it minted whenever any caller named nous/welcome (the hermes model row, --provider nous), which treated a model name as intent and was broader than the guided setup. Under "explicit" a nous/welcome request with no identity fails "not logged in" as before the free tier existed, and /login or hermes auth upgrade report nothing to sign in from. Implicit callers still adopt an identity the shared store holds, and a retired credential is replaced (a continuation, not a creation). Co-Authored-By: Claude Fable 5.1 (cherry picked from commit c63d2c935c1e59016164fdfb90cf70b4094466a0) --- hermes_cli/anon_auth.py | 51 +++++++++--------- hermes_cli/anon_sign_in.py | 7 +-- hermes_cli/auth_nous.py | 2 +- hermes_cli/config_defaults.py | 9 ++-- hermes_cli/runtime_provider.py | 11 ---- tests/hermes_cli/test_anon_auth_core.py | 70 +++++++++++++++---------- tests/tui_gateway/test_free_tier_rpc.py | 36 +++++++++++++ tools/managed_tool_gateway.py | 2 +- tui_gateway/methods_free_tier.py | 29 +++++++++- website/docs/user-guide/free-tier.md | 18 +++---- 10 files changed, 152 insertions(+), 83 deletions(-) diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index ea79e2befbda3..5622cdee990a6 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -90,18 +90,20 @@ def guest_enabled() -> bool: GUEST_SETUP_AUTO = "auto" -GUEST_SETUP_ON_REQUEST = "on-request" +GUEST_SETUP_EXPLICIT = "explicit" def guest_setup_policy() -> str: - """``nous.guest_setup``: when Hermes creates a free-tier identity on its own. - - ``"auto"`` (default): on first use, wherever nothing else is configured — the resolver's last - rung, the first-run check, the desktop's status read, the connector token path. - ``"on-request"``: only when something asks for the free tier by name — ``nous/welcome`` selected - as the model (the guided setup's session, the free-tier row in ``hermes model``), ``/login`` and - ``hermes auth upgrade``. Implicit callers still ADOPT an identity the shared store already holds - (so every profile follows the one the guided setup minted); they never mint one. + """``nous.guest_setup``: who may CREATE the free-tier identity. Using one that exists is never + gated here (that is ``nous.guest``); every surface adopts the identity once it is there. + + ``"auto"`` (default): Hermes creates it on its own on first use, wherever nothing else is + configured — the resolver's last rung, the first-run check, the desktop's status read, the + connector token path. + ``"explicit"``: Hermes never creates one on its own. Only an explicit provisioning request + does (:func:`provision_free_tier` — the guided setup on Hermes Desktop calls it through + ``free_tier.provision`` before its first chat). Implicit callers still ADOPT an identity the + shared store already holds, and an identity that existed and was retired is replaced. ``HERMES_FORCE_GUEST`` reads as ``auto``. Unknown values read as ``auto``. """ if force_guest_mode(): @@ -113,7 +115,7 @@ def guest_setup_policy() -> str: return GUEST_SETUP_AUTO raw = nous_cfg.get("guest_setup") if isinstance(nous_cfg, dict) else None value = str(raw or "").strip().lower().replace("_", "-") - return GUEST_SETUP_ON_REQUEST if value in {"on-request", "onrequest", "explicit", "request"} else GUEST_SETUP_AUTO + return GUEST_SETUP_EXPLICIT if value in {"explicit", "provision-only", "on-request"} else GUEST_SETUP_AUTO def is_guest_state(state: Any) -> bool: @@ -358,7 +360,7 @@ def _reconcile_and_provision(*, force: str, timeout_seconds: float, may_mint: bo def ensure_portal_identity( - *, blocking: bool = True, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, on_request: bool = False, + *, blocking: bool = True, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, explicit: bool = False, ) -> 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 / @@ -369,9 +371,10 @@ def ensure_portal_identity( and returns None immediately; a failure there is logged at DEBUG (the guest is a fallback; a fallback failing is not an error). - *on_request* says the caller asked for the free tier by name (``nous/welcome`` requested, a - sign-in from it, a retired identity being replaced). Under ``nous.guest_setup: on-request`` only - those callers mint; the implicit ones (default) still adopt what the shared store holds. + *explicit* says the caller is an explicit provisioning request (:func:`provision_free_tier`) + or is replacing an identity that already existed (a retired credential). Under + ``nous.guest_setup: explicit`` only those callers mint; the implicit ones (default) still adopt + what the shared store holds and otherwise return None. """ global _mint_failed, _forced_new_done if not guest_enabled(): @@ -379,7 +382,7 @@ def ensure_portal_identity( force = force_guest_mode() if force == "new" and _forced_new_done: force = "1" - may_mint = on_request or guest_setup_policy() == GUEST_SETUP_AUTO + may_mint = explicit or guest_setup_policy() == GUEST_SETUP_AUTO if may_mint and _mint_failed and force != "new" and not current_nous_state(): return None # this process already tried and failed; do not hammer the portal @@ -419,15 +422,15 @@ def _run() -> None: return None -def ensure_identity_for_model(model: Any, *, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: - """The free tier, set up because *model* asked for it: ``nous/welcome`` is served to a free-tier - identity and nothing else, so requesting it on a profile with no Nous identity IS the request - for one (the guided setup's session, the free-tier row in ``hermes model``, a bare - ``--provider nous`` pointed at it). Any other model is not a request: None, nothing minted. - Explicit under every ``nous.guest_setup`` policy; ``nous.guest: false`` still wins.""" - if str(model or "").strip() != GUEST_MODEL: - return None - return ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, on_request=True) +def provision_free_tier(*, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: + """The one explicit "set up the free tier now" entry point: adopt what the shared store holds, + else mint, under every ``nous.guest_setup`` policy. ``nous.guest: false`` still wins (None). + + The guided setup on Hermes Desktop calls it (``free_tier.provision``) before creating its first + chat, so the identity exists before any session asks for ``nous/welcome`` — and under + ``guest_setup: explicit`` it is the only way an identity ever gets created. + """ + return ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, explicit=True) def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index 6b4c0acde3901..55247585239b9 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -301,10 +301,11 @@ def run_sign_in( if not _core.guest_enabled(): precondition_state = Unavailable() else: - state = _core.ensure_portal_identity( - blocking=True, timeout_seconds=timeout_seconds, on_request=True) + # Implicit: under ``guest_setup: explicit`` there is nothing to sign in FROM + # unless the free tier was provisioned, so this adopts but never mints. + state = _core.ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) if not _core.is_guest_state(state): - # The free tier is off, or an account appeared mid-flight. + # The free tier is off (or not set up), or an account appeared mid-flight. precondition_state = Unavailable() except Exception as exc: # An AuthError (gate closed, rate limited) and an ordinary failure -- a cold install whose diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 1c0340cd937de..07c15faac9cff 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -983,7 +983,7 @@ def resolve_nous_runtime_credentials( clear_dead_guest("anon_credential_dead", dead_token=dead.get("anon_token")) # Replacing an identity the user already had is a continuation of that request, not a # new implicit setup: allowed under every nous.guest_setup policy. - if ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, on_request=True) is None: + if ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, explicit=True) is None: raise return _resolve_nous_runtime_credentials( timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 41ee0d338e3dc..b55577fac1f22 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2340,10 +2340,11 @@ def _aux(timeout, *, reasoning_effort=True, **extra): # `hermes auth upgrade`) to sign in. false turns the free tier off entirely: nothing is set # up and nothing is used. "guest": True, - # When Hermes creates the free-tier identity on its own. "auto": on first use, wherever - # nothing else is configured. "on-request": only when the free tier is asked for by name - # (nous/welcome selected as the model, the guided setup, /login or `hermes auth upgrade`); - # other profiles then follow the identity it set up, but never create one on their own. + # Who may CREATE the free-tier identity (using one that exists is never gated here). + # "auto": Hermes creates it on its own on first use, wherever nothing else is configured. + # "explicit": Hermes never creates one on its own; only the guided setup on Hermes Desktop + # does (it provisions the free tier as its first step). Every profile then follows that one + # identity, and an expired one is replaced, but nothing is ever created quietly. "guest_setup": "auto", }, # Google Vertex AI (Gemini). Auth is OAuth2 from a service-account JSON or ADC, NOT an API key; diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 79b49db6ec982..37dc514bcfa03 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -668,17 +668,6 @@ def _resolve_oauth_runtime(provider, requested_provider, model_cfg, target_model request; for "auto" (auto-detected but credentials stale/revoked) log and return None so the ladder falls through to env-var providers (e.g. OpenRouter).""" spec = _OAUTH_RUNTIME_PROVIDERS[provider] - if provider == "nous" and not auth_mod.get_provider_auth_state("nous"): - # No Nous identity anywhere this profile can see (its own store, the global root). A caller - # naming nous/welcome — the guided setup's session (provider=nous, which skips the - # resolver's nothing-configured rung), the free-tier picker row — is asking for the free - # tier: set it up here, on the request, instead of failing "not logged in". Any other - # model is not a request; the resolve below raises as before. - from hermes_cli.anon_auth import ensure_identity_for_model - try: - ensure_identity_for_model(_effective_model(model_cfg, target_model)) - except Exception as exc: - logger.debug("nous: free tier setup for the requested model skipped: %s", exc) try: creds = spec.resolve() except AuthError: diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index 584a2574452c6..c89c5cae448be 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -167,57 +167,69 @@ def test_force_guest_overrides_opt_out_and_new_bypasses_shared_store(self, porta class TestSetupPolicy: - """``nous.guest_setup: on-request``: implicit callers adopt but never mint; a request for the free - tier by name (``nous/welcome`` on an explicit Nous route, a sign-in) mints; a sibling profile - then follows the shared store.""" + """``nous.guest_setup: explicit``: nothing creates the identity on its own — not the resolver's + last rung, not a ``nous/welcome`` request, not a sign-in. Only :func:`provision_free_tier` (the + guided setup's ``free_tier.provision``) does; every implicit caller then adopts it.""" - def test_on_request_implicit_callers_never_mint(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="on-request") - assert anon_auth.guest_setup_policy() == anon_auth.GUEST_SETUP_ON_REQUEST + def test_explicit_implicit_callers_never_mint(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="explicit") + assert anon_auth.guest_setup_policy() == anon_auth.GUEST_SETUP_EXPLICIT assert anon_auth.ensure_portal_identity(blocking=True) is None with pytest.raises(anon_auth.AuthError): resolve_provider("auto") # the resolver's last rung is implicit assert portal.minted == 0 and portal.calls == [] - def test_on_request_welcome_model_on_the_nous_route_mints_once(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="on-request") - from hermes_cli.runtime_provider import resolve_runtime_provider - runtime = resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) - assert runtime["provider"] == "nous" - assert runtime["base_url"].rstrip("/") == WELCOME - assert portal.minted == 1 - assert anon_auth.has_guest() - resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) - assert portal.minted == 1 # adopted, not re-minted - - def test_on_request_other_model_on_the_nous_route_does_not_mint(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="on-request") + def test_explicit_welcome_model_on_the_nous_route_does_not_mint(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="explicit") from hermes_cli.runtime_provider import resolve_runtime_provider with pytest.raises(anon_auth.AuthError): - resolve_runtime_provider(requested="nous", target_model="openai/gpt-5") + resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) assert portal.minted == 0 - def test_on_request_sibling_profile_adopts_what_the_guided_setup_minted(self, portal, monkeypatch, tmp_path): - _write_config(monkeypatch, guest_setup="on-request") - from hermes_cli.runtime_provider import resolve_runtime_provider - resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) + def test_explicit_sign_in_has_nothing_to_sign_in_from(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="explicit") + states = list(anon_auth.run_sign_in(timeout_seconds=1.0)) + assert len(states) == 1 and states[0].kind == "unavailable" + assert portal.minted == 0 + + def test_explicit_provision_mints_once_then_everything_adopts(self, portal, monkeypatch, tmp_path): + _write_config(monkeypatch, guest_setup="explicit") + assert anon_auth.is_guest_state(anon_auth.provision_free_tier()) + assert portal.minted == 1 token = _load_auth_store()["providers"]["nous"]["anon_token"] + # A second provision is idempotent, and the runtime now serves nous/welcome on the welcome host. + anon_auth.provision_free_tier() + from hermes_cli.runtime_provider import resolve_runtime_provider + runtime = resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) + assert runtime["base_url"].rstrip("/") == WELCOME + assert resolve_provider("auto") == "nous" + # A sibling profile adopts the same identity implicitly; no second create call. sibling = tmp_path / "sibling-profile" sibling.mkdir() monkeypatch.setenv("HERMES_HOME", str(sibling)) - _write_config(monkeypatch, guest_setup="on-request") - adopted = anon_auth.ensure_portal_identity(blocking=True) # implicit: adopt only + _write_config(monkeypatch, guest_setup="explicit") + adopted = anon_auth.ensure_portal_identity(blocking=True) assert adopted and adopted["anon_token"] == token assert portal.minted == 1 + def test_explicit_retired_identity_is_replaced(self, portal, monkeypatch): + _write_config(monkeypatch, guest_setup="explicit") + anon_auth.provision_free_tier() + first = _load_auth_store()["providers"]["nous"]["anon_token"] + portal.dead_tokens.add(first) + from hermes_cli.auth_nous import resolve_nous_runtime_credentials + assert resolve_nous_runtime_credentials(force_refresh=True)["api_key"] # replaced, not refused + assert _load_auth_store()["providers"]["nous"]["anon_token"] != first + assert portal.minted == 2 + def test_auto_policy_keeps_todays_first_use_mint(self, portal, monkeypatch): _write_config(monkeypatch, guest_setup="auto") assert resolve_provider("auto") == "nous" assert portal.minted == 1 - def test_guest_off_beats_an_explicit_request(self, portal, monkeypatch): - _write_config(monkeypatch, guest=False, guest_setup="on-request") - assert anon_auth.ensure_identity_for_model(anon_auth.GUEST_MODEL) is None + def test_guest_off_beats_an_explicit_provision(self, portal, monkeypatch): + _write_config(monkeypatch, guest=False, guest_setup="explicit") + assert anon_auth.provision_free_tier() is None assert portal.minted == 0 diff --git a/tests/tui_gateway/test_free_tier_rpc.py b/tests/tui_gateway/test_free_tier_rpc.py index 116e9002683ee..ef4dbf5940fcc 100644 --- a/tests/tui_gateway/test_free_tier_rpc.py +++ b/tests/tui_gateway/test_free_tier_rpc.py @@ -83,3 +83,39 @@ def test_status_without_an_identity_starts_the_background_setup_once(tmp_path, m status = _call("free_tier.status") assert status["has_guest"] is False and status["available"] is False assert calls == [{"blocking": False}] + + +def test_provision_sets_the_free_tier_up_through_the_lifecycle_primitive(tmp_path, monkeypatch): + """``free_tier.provision`` is the guided setup's explicit request: it calls provision_free_tier + (the one explicit minting entry point) only when no identity exists, and reports the outcome.""" + monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + calls = [] + + def fake_provision(**kw): + calls.append(kw) + 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_0002"} + _save_auth_store(store) + return store["providers"]["nous"] + + monkeypatch.setattr(anon_auth, "provision_free_tier", fake_provision) + assert _call("free_tier.provision") == {"has_guest": True, "enabled": True} + assert len(calls) == 1 + assert _call("free_tier.provision") == {"has_guest": True, "enabled": True} + assert len(calls) == 1 # idempotent: an identity exists, nothing is minted + + def refused(**kw): + raise anon_auth.AuthError("Nous free tier is not open on this portal.", code="anon_gate_closed") + + with _auth_store_lock(): + store = _load_auth_store(); store["providers"].pop("nous"); _save_auth_store(store) + monkeypatch.setattr(anon_auth, "provision_free_tier", refused) + result = _call("free_tier.provision") + assert result["has_guest"] is False and "not open" in result["error"] + + _set_guest_off(monkeypatch) + monkeypatch.setattr(anon_auth, "provision_free_tier", lambda **kw: (_ for _ in ()).throw(AssertionError("must not run"))) + assert _call("free_tier.provision") == {"has_guest": False, "enabled": False} diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 4ead6a591df73..4c08acdac9040 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -141,7 +141,7 @@ def _replace_dead_guest_token(dead_state: dict) -> Optional[str]: clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token")) try: # A replacement continues the identity the user already had: explicit under every policy. - if ensure_portal_identity(blocking=True, on_request=True) is None: + if ensure_portal_identity(blocking=True, explicit=True) is None: return None return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)) except Exception as exc: diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py index 3cf66efb9c58a..fafbdf0ef9eef 100644 --- a/tui_gateway/methods_free_tier.py +++ b/tui_gateway/methods_free_tier.py @@ -1,5 +1,6 @@ """Nous free-tier JSON-RPC handlers: a renderer reads the profile's local auth state (pull); nothing -is pushed. ``free_tier.status`` answers from the auth store with zero network; ``free_tier.ack_notice`` +is pushed. ``free_tier.status`` answers from the auth store with zero network; ``free_tier.provision`` +is the explicit set-up request (the guided setup's first step); ``free_tier.ack_notice`` persists the one-time notice flag on the free-tier identity itself, so it dies with that identity. Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare. """ @@ -43,6 +44,32 @@ def _(rid, params: dict) -> dict: return _err(rid, 5090, str(e)) +@method("free_tier.provision") +@_profile_scoped +def _(rid, params: dict) -> dict: + """Set the free tier up NOW for the focused profile: adopt the shared store's identity, else mint + one (blocking, short timeout). The guided setup on Hermes Desktop calls this before creating its + first chat, so the identity exists before any session asks for ``nous/welcome``. This is the + explicit provisioning request: under ``nous.guest_setup: explicit`` it is the only way an + identity ever gets created. ``{has_guest, enabled}``; ``error`` when the portal refused.""" + try: + from hermes_cli import anon_auth + enabled = anon_auth.guest_enabled() + error = None + if enabled and not anon_auth.has_guest(): + try: + anon_auth.provision_free_tier() + except Exception as exc: + logger.info("free tier provisioning failed: %s", exc) + error = str(exc) + payload = {"has_guest": anon_auth.has_guest(), "enabled": enabled} + if error: + payload["error"] = error + return _ok(rid, payload) + except Exception as e: + return _err(rid, 5092, str(e)) + + @method("free_tier.ack_notice") @_profile_scoped def _(rid, params: dict) -> dict: diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index e80dcc2ef7784..baaf3a6eb8661 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -193,17 +193,17 @@ a command. Signing in (`/login`, or `hermes auth upgrade` in a terminal) moves w 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. -## Creating the identity only on request +## Creating the identity only through the guided setup ```bash -hermes config set nous.guest_setup on-request +hermes config set nous.guest_setup explicit ``` By default (`nous.guest_setup: auto`) Hermes creates the free-tier identity the first time it -needs inference or a connector and nothing else is configured. With `on-request`, it creates one -only when the free tier is asked for by name: picking `nous/welcome` as the model (the -**Nous · free tier** row in `hermes model`, or the guided setup on Hermes Desktop, which runs on -that model), or signing in with `/login` or `hermes auth upgrade`. Nothing is created quietly in -the background. Once an identity exists, every profile on the machine follows it as usual, and an -expired one is replaced the next time it is needed. `nous.guest: false` still turns the free tier -off entirely. +needs inference or a connector and nothing else is configured. With `explicit`, Hermes never +creates one on its own: only the guided setup on Hermes Desktop does, as its first step. Until +then a fresh install behaves as if the free tier did not exist (no **Nous · free tier** row in +`hermes model`, the usual "no provider configured" guidance, and `/login` or `hermes auth upgrade` +point at `hermes auth add nous`). Once the identity exists, every profile on the machine follows +it as usual, and an expired one is replaced the next time it is needed. `nous.guest: false` still +turns the free tier off entirely. From 2d0a870c77aebdf3ca581c09a98121be63bf1eee Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 10 Sep 2026 00:23:41 +0530 Subject: [PATCH 04/16] fix(auth): remove the nous.guest_setup knob; the free tier is created on first use `nous.guest_setup: auto | explicit` decided who may CREATE the free-tier identity. Under its default every line it added was inert (`may_mint` always true), nothing in tree set `explicit`, unknown values read as `auto`, and under `explicit` a CLI-only install could never get an identity, which contradicts the first-run contract (first command mints, then chats). The mint race the knob accompanied is already benign: every caller takes the profile lock then the shared-store lock, and the loser adopts what the winner wrote. What makes the guided setup win deterministically is `provision_free_tier()` behind the `free_tier.provision` RPC, which stays. `nous.guest` remains the only free-tier policy. Removed: `guest_setup_policy()` and its constants, the `explicit=` / `may_mint=` threading through `ensure_portal_identity` and `_reconcile_and_provision`, the flag at the three replacement call sites (now no-ops), the config default, the docs section, and the four `guest_setup` test-config entries. The three policy tests that hold regardless of the knob are kept under `TestExplicitProvision`; the two that only tested the knob are deleted. (cherry picked from commit d8a50526d93c374c0067dd935b5a65055e0af261) --- hermes_cli/anon_auth.py | 61 +++++-------------------- hermes_cli/anon_sign_in.py | 2 - hermes_cli/auth_nous.py | 4 +- hermes_cli/config_defaults.py | 6 --- tests/hermes_cli/test_anon_auth_core.py | 43 +++-------------- tools/managed_tool_gateway.py | 3 +- tui_gateway/methods_free_tier.py | 5 +- website/docs/user-guide/free-tier.md | 15 ------ 8 files changed, 22 insertions(+), 117 deletions(-) diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 5622cdee990a6..457d7bb919353 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -89,35 +89,6 @@ def guest_enabled() -> bool: return bool(nous_cfg.get("guest", True)) -GUEST_SETUP_AUTO = "auto" -GUEST_SETUP_EXPLICIT = "explicit" - - -def guest_setup_policy() -> str: - """``nous.guest_setup``: who may CREATE the free-tier identity. Using one that exists is never - gated here (that is ``nous.guest``); every surface adopts the identity once it is there. - - ``"auto"`` (default): Hermes creates it on its own on first use, wherever nothing else is - configured — the resolver's last rung, the first-run check, the desktop's status read, the - connector token path. - ``"explicit"``: Hermes never creates one on its own. Only an explicit provisioning request - does (:func:`provision_free_tier` — the guided setup on Hermes Desktop calls it through - ``free_tier.provision`` before its first chat). Implicit callers still ADOPT an identity the - shared store already holds, and an identity that existed and was retired is replaced. - ``HERMES_FORCE_GUEST`` reads as ``auto``. Unknown values read as ``auto``. - """ - if force_guest_mode(): - return GUEST_SETUP_AUTO - try: - from hermes_cli.config import load_config_readonly - nous_cfg = load_config_readonly().get("nous") - except Exception: - return GUEST_SETUP_AUTO - raw = nous_cfg.get("guest_setup") if isinstance(nous_cfg, dict) else None - value = str(raw or "").strip().lower().replace("_", "-") - return GUEST_SETUP_EXPLICIT if value in {"explicit", "provision-only", "on-request"} else GUEST_SETUP_AUTO - - def is_guest_state(state: Any) -> bool: return isinstance(state, dict) and state.get("auth_method") == ANON_AUTH_METHOD @@ -319,15 +290,14 @@ def _mint_locked(client: httpx.Client, portal: str, auth_store: Dict[str, Any]) _forced_new_done = False -def _reconcile_and_provision(*, force: str, timeout_seconds: float, may_mint: bool = True) -> Optional[Dict[str, Any]]: +def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Optional[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 — unless *may_mint* - is False (an implicit caller under ``nous.guest_setup: on-request``), which returns None. + 3. Nothing anywhere: mint, persisting the credential before exchanging it. ``force == "new"`` skips 1 and 2. """ from hermes_cli.auth import ( @@ -352,29 +322,21 @@ def _reconcile_and_provision(*, force: str, timeout_seconds: float, may_mint: bo if not shared: _write_shared_nous_state(profile_state) return profile_state - if not may_mint: - return None 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, explicit: bool = False, + *, 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 / - nothing to adopt and this caller may not mint). + 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). - - *explicit* says the caller is an explicit provisioning request (:func:`provision_free_tier`) - or is replacing an identity that already existed (a retired credential). Under - ``nous.guest_setup: explicit`` only those callers mint; the implicit ones (default) still adopt - what the shared store holds and otherwise return None. """ global _mint_failed, _forced_new_done if not guest_enabled(): @@ -382,13 +344,12 @@ def ensure_portal_identity( force = force_guest_mode() if force == "new" and _forced_new_done: force = "1" - may_mint = explicit or guest_setup_policy() == GUEST_SETUP_AUTO - if may_mint and _mint_failed and force != "new" and not current_nous_state(): + 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, may_mint=may_mint) + result = _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds) except Exception: _mint_failed = True raise @@ -405,7 +366,7 @@ def ensure_portal_identity( def _run() -> None: global _background_started try: - _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds, may_mint=may_mint) + _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 @@ -424,13 +385,13 @@ def _run() -> None: def provision_free_tier(*, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: """The one explicit "set up the free tier now" entry point: adopt what the shared store holds, - else mint, under every ``nous.guest_setup`` policy. ``nous.guest: false`` still wins (None). + else mint. ``nous.guest: false`` still wins (None). The guided setup on Hermes Desktop calls it (``free_tier.provision``) before creating its first - chat, so the identity exists before any session asks for ``nous/welcome`` — and under - ``guest_setup: explicit`` it is the only way an identity ever gets created. + chat, so the identity exists before any session asks for ``nous/welcome``; a concurrent implicit + caller then adopts that identity under the shared-store lock instead of minting a second one. """ - return ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, explicit=True) + return ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index 55247585239b9..3022da2651757 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -301,8 +301,6 @@ def run_sign_in( if not _core.guest_enabled(): precondition_state = Unavailable() else: - # Implicit: under ``guest_setup: explicit`` there is nothing to sign in FROM - # unless the free tier was provisioned, so this adopts but never mints. state = _core.ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) if not _core.is_guest_state(state): # The free tier is off (or not set up), or an account appeared mid-flight. diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 07c15faac9cff..9e7cdd24e70b6 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -981,9 +981,7 @@ def resolve_nous_runtime_credentials( 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")) - # Replacing an identity the user already had is a continuation of that request, not a - # new implicit setup: allowed under every nous.guest_setup policy. - if ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds, explicit=True) is None: + 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) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index b55577fac1f22..84172ebf0aa65 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2340,12 +2340,6 @@ def _aux(timeout, *, reasoning_effort=True, **extra): # `hermes auth upgrade`) to sign in. false turns the free tier off entirely: nothing is set # up and nothing is used. "guest": True, - # Who may CREATE the free-tier identity (using one that exists is never gated here). - # "auto": Hermes creates it on its own on first use, wherever nothing else is configured. - # "explicit": Hermes never creates one on its own; only the guided setup on Hermes Desktop - # does (it provisions the free tier as its first step). Every profile then follows that one - # identity, and an expired one is replaced, but nothing is ever created quietly. - "guest_setup": "auto", }, # 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). diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index c89c5cae448be..c025d5b5b6907 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -166,34 +166,12 @@ def test_force_guest_overrides_opt_out_and_new_bypasses_shared_store(self, porta assert portal.minted == 2 -class TestSetupPolicy: - """``nous.guest_setup: explicit``: nothing creates the identity on its own — not the resolver's - last rung, not a ``nous/welcome`` request, not a sign-in. Only :func:`provision_free_tier` (the - guided setup's ``free_tier.provision``) does; every implicit caller then adopts it.""" - - def test_explicit_implicit_callers_never_mint(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="explicit") - assert anon_auth.guest_setup_policy() == anon_auth.GUEST_SETUP_EXPLICIT - assert anon_auth.ensure_portal_identity(blocking=True) is None - with pytest.raises(anon_auth.AuthError): - resolve_provider("auto") # the resolver's last rung is implicit - assert portal.minted == 0 and portal.calls == [] - - def test_explicit_welcome_model_on_the_nous_route_does_not_mint(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="explicit") - from hermes_cli.runtime_provider import resolve_runtime_provider - with pytest.raises(anon_auth.AuthError): - resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) - assert portal.minted == 0 +class TestExplicitProvision: + """:func:`provision_free_tier` is the guided setup's "set up now" entry point. It mints once; + every implicit caller afterwards adopts that identity through the shared store, so setup and a + concurrent first-run check never create two identities. ``nous.guest: false`` still wins.""" - def test_explicit_sign_in_has_nothing_to_sign_in_from(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="explicit") - states = list(anon_auth.run_sign_in(timeout_seconds=1.0)) - assert len(states) == 1 and states[0].kind == "unavailable" - assert portal.minted == 0 - - def test_explicit_provision_mints_once_then_everything_adopts(self, portal, monkeypatch, tmp_path): - _write_config(monkeypatch, guest_setup="explicit") + def test_provision_mints_once_then_everything_adopts(self, portal, monkeypatch, tmp_path): assert anon_auth.is_guest_state(anon_auth.provision_free_tier()) assert portal.minted == 1 token = _load_auth_store()["providers"]["nous"]["anon_token"] @@ -207,13 +185,11 @@ def test_explicit_provision_mints_once_then_everything_adopts(self, portal, monk sibling = tmp_path / "sibling-profile" sibling.mkdir() monkeypatch.setenv("HERMES_HOME", str(sibling)) - _write_config(monkeypatch, guest_setup="explicit") adopted = anon_auth.ensure_portal_identity(blocking=True) assert adopted and adopted["anon_token"] == token assert portal.minted == 1 - def test_explicit_retired_identity_is_replaced(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="explicit") + def test_retired_identity_is_replaced(self, portal, monkeypatch): anon_auth.provision_free_tier() first = _load_auth_store()["providers"]["nous"]["anon_token"] portal.dead_tokens.add(first) @@ -222,13 +198,8 @@ def test_explicit_retired_identity_is_replaced(self, portal, monkeypatch): assert _load_auth_store()["providers"]["nous"]["anon_token"] != first assert portal.minted == 2 - def test_auto_policy_keeps_todays_first_use_mint(self, portal, monkeypatch): - _write_config(monkeypatch, guest_setup="auto") - assert resolve_provider("auto") == "nous" - assert portal.minted == 1 - def test_guest_off_beats_an_explicit_provision(self, portal, monkeypatch): - _write_config(monkeypatch, guest=False, guest_setup="explicit") + _write_config(monkeypatch, guest=False) assert anon_auth.provision_free_tier() is None assert portal.minted == 0 diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 4c08acdac9040..458c4daf31775 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -140,8 +140,7 @@ def _replace_dead_guest_token(dead_state: dict) -> Optional[str]: clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token")) try: - # A replacement continues the identity the user already had: explicit under every policy. - if ensure_portal_identity(blocking=True, explicit=True) is None: + 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: diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py index fafbdf0ef9eef..777d54d6fea0a 100644 --- a/tui_gateway/methods_free_tier.py +++ b/tui_gateway/methods_free_tier.py @@ -49,9 +49,8 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: """Set the free tier up NOW for the focused profile: adopt the shared store's identity, else mint one (blocking, short timeout). The guided setup on Hermes Desktop calls this before creating its - first chat, so the identity exists before any session asks for ``nous/welcome``. This is the - explicit provisioning request: under ``nous.guest_setup: explicit`` it is the only way an - identity ever gets created. ``{has_guest, enabled}``; ``error`` when the portal refused.""" + first chat, so the identity exists before any session asks for ``nous/welcome``. + ``{has_guest, enabled}``; ``error`` when the portal refused.""" try: from hermes_cli import anon_auth enabled = anon_auth.guest_enabled() diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index baaf3a6eb8661..297804f2c408a 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -192,18 +192,3 @@ exists so inference and connector calls can be authenticated and rate limited. I a command. Signing in (`/login`, or `hermes auth upgrade` in a terminal) 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. - -## Creating the identity only through the guided setup - -```bash -hermes config set nous.guest_setup explicit -``` - -By default (`nous.guest_setup: auto`) Hermes creates the free-tier identity the first time it -needs inference or a connector and nothing else is configured. With `explicit`, Hermes never -creates one on its own: only the guided setup on Hermes Desktop does, as its first step. Until -then a fresh install behaves as if the free tier did not exist (no **Nous · free tier** row in -`hermes model`, the usual "no provider configured" guidance, and `/login` or `hermes auth upgrade` -point at `hermes auth add nous`). Once the identity exists, every profile on the machine follows -it as usual, and an expired one is replaced the next time it is needed. `nous.guest: false` still -turns the free tier off entirely. From 3e64410182fba5d071e5a8c4fe134eec15ea6e6e Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 10 Sep 2026 00:26:18 +0530 Subject: [PATCH 05/16] fix(gateway): a server-driven model switch off nous/welcome does not evict the cached agent When a signed-in account still asks the paid host for `nous/welcome`, the inference gateway serves the current backing model and names it in `x-nous-model-switch`. `apply_model_switch` moves the live session to that model and moves `config.yaml`'s default off the alias in the same step. The messaging gateway's fallback-eviction check compares the agent's model with the config default and evicts on any mismatch that is not a /model override, so when the config write did not land (unreadable config, lock) the cached agent was evicted once per turn, and prompt caching with it. `apply_model_switch` now stamps the alias it moved the session off on the agent, and `_is_intentional_model_switch` treats "agent moved off the alias the config still carries" as deliberate, beside the existing /model override case. The check takes the agent and the config model instead of a bare model string; its one caller in `_run_agent_evict_on_fallback` passes them. (cherry picked from commit 696d1ec86b69db28bf002c841e9389b85178a954) --- gateway/run_agent_cache.py | 12 ++++++--- gateway/run_turn.py | 2 +- hermes_cli/anon_auth.py | 4 +++ .../test_nous_welcome_client_contract.py | 1 + .../gateway/test_model_switch_persistence.py | 25 +++++++++++++++++-- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index 5c15a264a565c..71814d7c9ec4d 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -218,10 +218,16 @@ def _restore_session_model_override(self, session_key: str, snapshot: dict) -> N state.conversation.model_override = None self._evict_cached_agent(session_key) - def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool: - """Return True if *agent_model* matches an active /model session override.""" + def _is_intentional_model_switch(self, session_key: str, agent: Any, config_model: str) -> bool: + """True when *agent* running a model other than *config_model* is deliberate: a /model session + override names that model, or the Nous gateway moved the session off the ``nous/welcome`` + alias that *config_model* still carries (``anon_auth.apply_model_switch``).""" override = self._session_model_override(session_key) - return override is not None and override.get("model") == agent_model + if override is not None and override.get("model") == agent.model: + return True + # Exactly the recorded move (alias -> backing): a later fallback onto some other model is + # ordinary drift and still evicts. + return getattr(agent, "_nous_model_switch", None) == (config_model, agent.model) def _release_running_agent_state( self, session_key: str, *, run_generation: Optional[int] = None diff --git a/gateway/run_turn.py b/gateway/run_turn.py index 1f222be9f9060..472b008a7cbbb 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -3275,7 +3275,7 @@ def _run_agent_evict_on_fallback(self, turn_ctx: TurnContext) -> None: _agent_provider = getattr(_agent, 'provider', '') or '' if _agent_provider and _agent_provider not in _AGGREGATOR_PROVIDERS: _cfg_model = normalize_model_for_provider(_cfg_model, _agent_provider) - if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent.model): + if _agent.model != _cfg_model and not self._is_intentional_model_switch(session_key, _agent, _cfg_model): self._evict_cached_agent(session_key) async def _run_agent_finalize_streaming_tts(self, turn_ctx: TurnContext, adapter: Any) -> None: diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 457d7bb919353..3e56f9633eb8e 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -586,6 +586,10 @@ def apply_model_switch(agent: Any) -> Optional[str]: if str(getattr(agent, "model", "") or "") != requested: return None # the session already moved (a /model, a sign-in sweep) agent.model = backing + # The gateway's cache check compares agent.model with the config default and evicts on a + # mismatch it did not cause; this pair names the move so the check can recognise exactly this + # server-driven switch even when the config write below did not land. + agent._nous_model_switch = (requested, backing) logger.info("Nous gateway asked to switch %s -> %s; applied for this session", requested, backing) try: from hermes_cli.config import load_config_readonly diff --git a/tests/agent/test_nous_welcome_client_contract.py b/tests/agent/test_nous_welcome_client_contract.py index ebf404e68d603..b7863d466f016 100644 --- a/tests/agent/test_nous_welcome_client_contract.py +++ b/tests/agent/test_nous_welcome_client_contract.py @@ -80,6 +80,7 @@ def test_apply_moves_the_session_and_the_config_default(self, monkeypatch): lambda provider, url, default_model=None, **kw: writes.append((provider, url, default_model))) assert anon_auth.apply_model_switch(agent) == "z-ai/glm-5.3-flash" assert agent.model == "z-ai/glm-5.3-flash" + assert agent._nous_model_switch == ("nous/welcome", "z-ai/glm-5.3-flash") # what the gateway cache check reads assert writes == [("nous", PAID, "z-ai/glm-5.3-flash")] assert agent._nous_pending_model_switch is None assert agent.statuses and "z-ai/glm-5.3-flash" in agent.statuses[0] diff --git a/tests/gateway/test_model_switch_persistence.py b/tests/gateway/test_model_switch_persistence.py index ea0a175c1a72d..a668518a3efe9 100644 --- a/tests/gateway/test_model_switch_persistence.py +++ b/tests/gateway/test_model_switch_persistence.py @@ -127,7 +127,9 @@ def test_no_override_returns_originals(self): class TestIsIntentionalModelSwitch: - """Verify fallback detection respects intentional /model overrides.""" + """The fallback-eviction check must not evict a session whose model differs from the config + default for a reason the system produced: a /model override, or the Nous gateway moving the + session off the ``nous/welcome`` alias the config still carries.""" def test_matches_override(self): runner = _make_runner() @@ -141,7 +143,26 @@ def test_matches_override(self): "api_mode": "chat_completions", } - assert runner._is_intentional_model_switch(sk, "gpt-5.4") is True + agent = SimpleNamespace(model="gpt-5.4") + assert runner._is_intentional_model_switch(sk, agent, "openai/gpt-5") is True + + def test_server_model_switch_off_the_welcome_alias_is_intentional(self): + runner = _make_runner() + sk = build_session_key(_make_source()) + # apply_model_switch moved the session and recorded the move (alias -> backing). + agent = SimpleNamespace(model="z-ai/glm-5.3-flash", _nous_model_switch=("nous/welcome", "z-ai/glm-5.3-flash")) + assert runner._is_intentional_model_switch(sk, agent, "nous/welcome") is True + # A config that names something else is real drift, not the server's move. + assert runner._is_intentional_model_switch(sk, agent, "openai/gpt-5") is False + # A later fallback onto a third model is drift too, even with the config still on the alias. + agent.model = "fallback/model" + assert runner._is_intentional_model_switch(sk, agent, "nous/welcome") is False + + def test_plain_drift_is_not_intentional(self): + runner = _make_runner() + sk = build_session_key(_make_source()) + agent = SimpleNamespace(model="fallback/model") + assert runner._is_intentional_model_switch(sk, agent, "primary/model") is False class TestOneTurnModelOverrideRestore: From 479ece69c96bd447205f14e403f98ab18bdb0d59 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 10 Sep 2026 04:33:47 +0530 Subject: [PATCH 06/16] fix(auth): the free tier outranks implicit host credentials in provider resolution On a fresh install with a leftover ~/.aws profile, resolve_provider("auto") reached the Bedrock rung before the free-tier rung, so the first turn ran on Bedrock and failed 403 while the free tier was still being minted in the background at agent setup (NS-829). Live on a Mac with ~/.aws present: 28 s, three retries, no answer; the next process then switched to nous/welcome. The free-tier rung now sits directly above the Bedrock chain: when nous.guest is on, an existing free-tier identity answers, else a blocking mint runs, and only then does the boto chain get a say. Everything above is unchanged and still wins: CLI creds, config.yaml model.provider, env keys, the OpenRouter pool, a logged-in active_provider. nous.guest: false skips the rung, and a failed mint still falls through to Bedrock and the no-provider guidance. Tests: six precedence cases (identity present, fresh mint, free tier off, env key still wins, sign-in still wins, failed mint falls through). The opt-out test now neutralizes the AWS chain like the precedence tests do; on a machine with ~/.aws it was failing for the same reason as the bug. Live after the fix, same Mac, AWS credentials visible, isolated shared store: identity minted 2 s in, turn on model=nous/welcome provider=nous, answer in 11 s. (cherry picked from commit a04b05260cd334dd7199ad9b6cd5b2538364c75a) --- hermes_cli/auth.py | 28 ++++--- tests/hermes_cli/test_anon_auth_core.py | 2 + tests/hermes_cli/test_provider_precedence.py | 81 ++++++++++++++++++++ 3 files changed, 99 insertions(+), 12 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 19f83acfd64be..0d912167ccb74 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1438,7 +1438,8 @@ def resolve_provider( "auto" priority (explicit intent beats a stale OAuth login): 1. CLI api_key/base_url -> "openrouter"; 2. config.yaml ``model.provider``; 3. OPENAI_API_KEY / OPENROUTER_API_KEY -> "openrouter"; 4. OpenRouter pool; 5. provider env keys; 6. auth.json ``active_provider``; - 7. AWS Bedrock chain; 8. AuthError(no_provider_configured). + 7. Nous free tier when ``nous.guest`` is on (existing identity, else a blocking mint); + 8. AWS Bedrock chain; 9. AuthError(no_provider_configured). 1. 3. 4. 5. Provider-specific API keys (GLM, Kimi, MiniMax, ...) -> that provider 7. 8. Error (no provider configured) See #29285. @@ -1486,23 +1487,26 @@ def resolve_provider( _oauth_active) return _oauth_active - # AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars); after API-key providers - # so explicit keys always win. + # Nous free tier, when it is on: an existing free-tier identity, else mint one (blocking, short + # timeout). This rung sits ABOVE the Bedrock chain on purpose. Every rung above this line is + # explicit user intent (CLI creds, config, env keys, a sign-in); the boto chain below is implicit + # host state, and a leftover ~/.aws profile used to win the first turn of a fresh install and + # fail 403 while the free tier was being minted in the background (NS-829). ``nous.guest: false`` + # skips this rung. A failed mint is not an error: it falls through to Bedrock and the guidance. + try: + from hermes_cli.anon_auth import ensure_portal_identity, guest_enabled, has_guest + if guest_enabled() and (has_guest() or 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) + # AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars): implicit host state, + # below explicit keys and below the free tier. try: from agent.bedrock_adapter import has_aws_credentials if has_aws_credentials(): 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, " diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index c025d5b5b6907..d7d80484a1d6c 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -150,6 +150,8 @@ def test_gate_closed_persists_nothing_and_raises_gate_code(self, portal): def test_opt_out_bool_disables_everything(self, portal, monkeypatch): _write_config(monkeypatch, guest=False) + # A developer machine's ~/.aws would answer the Bedrock rung and hide the AuthError. + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) assert anon_auth.ensure_portal_identity(blocking=True) is None assert portal.calls == [] with pytest.raises(anon_auth.AuthError): diff --git a/tests/hermes_cli/test_provider_precedence.py b/tests/hermes_cli/test_provider_precedence.py index 16a59be469eeb..32beebb506d1a 100644 --- a/tests/hermes_cli/test_provider_precedence.py +++ b/tests/hermes_cli/test_provider_precedence.py @@ -90,3 +90,84 @@ def has_credentials(self): monkeypatch.setattr("agent.credential_pool.load_pool", lambda name: _Pool()) assert resolve_provider("auto") == "openrouter" + + +def _logged_out(monkeypatch): + monkeypatch.setattr("hermes_cli.auth._load_auth_store", lambda: {}) + monkeypatch.setattr("hermes_cli.auth.get_auth_status", lambda p: {"logged_in": False}) + + +def _free_tier(monkeypatch, *, on=True, identity=False): + """Free tier switch + whether a free-tier identity already exists; counts mints.""" + monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: on) + monkeypatch.setattr("hermes_cli.anon_auth.has_guest", lambda: identity) + calls = {"mint": 0} + + def _ensure(*, blocking=True, timeout_seconds=None): + calls["mint"] += 1 + return {"auth_method": "anonymous"} if on else None + + monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", _ensure) + return calls + + +class TestFreeTierBeatsImplicitHostCredentials: + """NS-829: a leftover ~/.aws profile must not pre-empt the free tier on a fresh install. + + Explicit intent (config, env keys, a sign-in) still wins; only the implicit Bedrock chain moved + below the free tier.""" + + def test_existing_free_tier_identity_beats_aws_chain(self, monkeypatch): + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + _logged_out(monkeypatch) + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) + calls = _free_tier(monkeypatch, on=True, identity=True) + assert resolve_provider("auto") == "nous" + assert calls["mint"] == 0 + + def test_fresh_install_mints_before_bedrock(self, monkeypatch): + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + _logged_out(monkeypatch) + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) + calls = _free_tier(monkeypatch, on=True, identity=False) + assert resolve_provider("auto") == "nous" + assert calls["mint"] == 1 + + def test_bedrock_wins_when_free_tier_is_off(self, monkeypatch): + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + _logged_out(monkeypatch) + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) + calls = _free_tier(monkeypatch, on=False) + assert resolve_provider("auto") == "bedrock" + assert calls["mint"] == 0 + + def test_env_key_still_beats_free_tier(self, monkeypatch): + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + _logged_out(monkeypatch) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test-key") + calls = _free_tier(monkeypatch, on=True, identity=True) + assert resolve_provider("auto") == "openrouter" + assert calls["mint"] == 0 + + def test_signed_in_provider_still_beats_free_tier(self, monkeypatch): + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + _login(monkeypatch, "anthropic") + _no_aws(monkeypatch) + calls = _free_tier(monkeypatch, on=True, identity=True) + assert resolve_provider("auto") == "anthropic" + assert calls["mint"] == 0 + + def test_failed_mint_falls_through_to_bedrock(self, monkeypatch): + _clear_provider_env(monkeypatch) + _config(monkeypatch, "") + _logged_out(monkeypatch) + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) + monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: True) + monkeypatch.setattr("hermes_cli.anon_auth.has_guest", lambda: False) + monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", lambda **kw: None) + assert resolve_provider("auto") == "bedrock" From a346a0fe6b8f32023c982f27f6f6d4fdafc77691 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Thu, 10 Sep 2026 04:44:20 +0530 Subject: [PATCH 07/16] fix(auth): review follow-ups for the free-tier rung (NS-829) - tests/agent/test_bedrock_integration.py: the Bedrock auto-detect test switches the free tier off; its contract is the boto chain, and the free tier now sits above it. - gateway/run_notifications.py: the free-tier startup line reads auth.json before consulting the resolver, so a gateway boot on a machine with AWS credentials never mints or refreshes over the network. - hermes_cli/anon_auth.py: module docstring says where the free tier sits in the ladder instead of "the ladder is untouched". - tests/hermes_cli/test_provider_precedence.py: two invariant tests instead of six (parametrized ladder cases; a failed mint that returns None or raises falls through to Bedrock). scripts/run_tests.sh on the five affected files: 147 passed, 0 failed. (cherry picked from commit 10790d148c60ada11b9ecdde2cd2c836c6a82a11) --- gateway/run_notifications.py | 10 ++- hermes_cli/anon_auth.py | 8 +- tests/agent/test_bedrock_integration.py | 3 + tests/hermes_cli/test_provider_precedence.py | 77 ++++++++------------ 4 files changed, 44 insertions(+), 54 deletions(-) diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index fb0fc4995cee4..41f8a55ee6913 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -747,14 +747,16 @@ def _free_tier_startup_line(self) -> Optional[str]: 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. + # Persisted state only. The free-tier check reads auth.json; it runs FIRST so the resolver + # is only consulted when a free-tier identity already exists and its own free-tier rung + # (which may mint on a fresh install, NS-829) answers from that identity without a network + # call. No token refresh at boot either way. 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 + if resolve_provider("auto") != "nous": + return None except Exception as exc: logger.debug("Free tier startup line skipped: %s", exc) return None diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 3e56f9633eb8e..1b2daca4bd120 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -3,10 +3,10 @@ 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. +``providers.nous`` *and* ``active_provider``. In the resolver ladder (``resolve_provider``) the free +tier sits directly above the implicit AWS Bedrock chain (NS-829): any explicit provider (env key, +``model.provider``, OpenRouter pool, a logged-in ``active_provider``) 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 diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index fb4476447b2ca..31c21cf49d61b 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -127,6 +127,9 @@ def test_auto_detect_with_aws_credentials(self, monkeypatch): monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE") monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY") + # The Nous free tier counts as a configured provider and sits above the Bedrock chain + # (NS-829); this test's contract is the chain itself, so switch the free tier off. + monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: False) # Mock the auth store to have no active provider with patch("hermes_cli.auth._load_auth_store", return_value={}): result = resolve_provider("auto") diff --git a/tests/hermes_cli/test_provider_precedence.py b/tests/hermes_cli/test_provider_precedence.py index 32beebb506d1a..685a70324ac08 100644 --- a/tests/hermes_cli/test_provider_precedence.py +++ b/tests/hermes_cli/test_provider_precedence.py @@ -114,60 +114,45 @@ def _ensure(*, blocking=True, timeout_seconds=None): class TestFreeTierBeatsImplicitHostCredentials: """NS-829: a leftover ~/.aws profile must not pre-empt the free tier on a fresh install. - Explicit intent (config, env keys, a sign-in) still wins; only the implicit Bedrock chain moved - below the free tier.""" - - def test_existing_free_tier_identity_beats_aws_chain(self, monkeypatch): + Two invariants. (1) The ladder: explicit intent still wins, the free tier sits above the + implicit Bedrock chain, and the free tier off restores Bedrock. (2) A failed mint, whether it + returns None or raises, falls through to Bedrock.""" + + @pytest.mark.parametrize("free_tier_on, identity, env_key, login, expected, mints", [ + (True, True, None, None, "nous", 0), # existing identity beats the AWS chain + (True, False, None, None, "nous", 1), # fresh install mints before Bedrock + (False, False, None, None, "bedrock", 0), # free tier off: Bedrock as before + (True, True, "OPENAI_API_KEY", None, "openrouter", 0), # env key still wins + (True, True, None, "anthropic", "anthropic", 0), # a sign-in still wins + ]) + def test_free_tier_sits_above_the_bedrock_chain(self, monkeypatch, free_tier_on, identity, + env_key, login, expected, mints): _clear_provider_env(monkeypatch) _config(monkeypatch, "") - _logged_out(monkeypatch) - monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) - calls = _free_tier(monkeypatch, on=True, identity=True) - assert resolve_provider("auto") == "nous" - assert calls["mint"] == 0 - - def test_fresh_install_mints_before_bedrock(self, monkeypatch): - _clear_provider_env(monkeypatch) - _config(monkeypatch, "") - _logged_out(monkeypatch) + if login: + _login(monkeypatch, login) + else: + _logged_out(monkeypatch) + if env_key: + monkeypatch.setenv(env_key, "sk-test-key") monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) - calls = _free_tier(monkeypatch, on=True, identity=False) - assert resolve_provider("auto") == "nous" - assert calls["mint"] == 1 + calls = _free_tier(monkeypatch, on=free_tier_on, identity=identity) + assert resolve_provider("auto") == expected + assert calls["mint"] == mints - def test_bedrock_wins_when_free_tier_is_off(self, monkeypatch): - _clear_provider_env(monkeypatch) - _config(monkeypatch, "") - _logged_out(monkeypatch) - monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) - calls = _free_tier(monkeypatch, on=False) - assert resolve_provider("auto") == "bedrock" - assert calls["mint"] == 0 - - def test_env_key_still_beats_free_tier(self, monkeypatch): - _clear_provider_env(monkeypatch) - _config(monkeypatch, "") - _logged_out(monkeypatch) - monkeypatch.setenv("OPENAI_API_KEY", "sk-test-key") - calls = _free_tier(monkeypatch, on=True, identity=True) - assert resolve_provider("auto") == "openrouter" - assert calls["mint"] == 0 - - def test_signed_in_provider_still_beats_free_tier(self, monkeypatch): - _clear_provider_env(monkeypatch) - _config(monkeypatch, "") - _login(monkeypatch, "anthropic") - _no_aws(monkeypatch) - calls = _free_tier(monkeypatch, on=True, identity=True) - assert resolve_provider("auto") == "anthropic" - assert calls["mint"] == 0 - - def test_failed_mint_falls_through_to_bedrock(self, monkeypatch): + @pytest.mark.parametrize("failure", ["returns_none", "raises"]) + def test_failed_mint_falls_through_to_bedrock(self, monkeypatch, failure): _clear_provider_env(monkeypatch) _config(monkeypatch, "") _logged_out(monkeypatch) monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: True) monkeypatch.setattr("hermes_cli.anon_auth.has_guest", lambda: False) - monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", lambda **kw: None) + + def _mint(**kw): + if failure == "raises": + raise RuntimeError("portal 429") + return None + + monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", _mint) assert resolve_provider("auto") == "bedrock" From dd09b33e004657f31274c458d017805ec318db1c Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 00:54:13 +0530 Subject: [PATCH 08/16] feat(auth): HERMES_GUEST_ONBOARDING=1 is the one launch gate for the free tier; HERMES_FORCE_GUEST is gone The free tier is pre-GA. Until GA it must not exist for anyone who did not ask for it: no identity minted, no portal traffic, no free-tier copy on any surface. One environment variable now decides that, and one function reads it. `guest_enabled()` returns False unless `HERMES_GUEST_ONBOARDING` is exactly "1"; only then does `nous.guest` (the user's off switch) get consulted. Every free-tier site already funnels through `guest_enabled()`, so the gate closes minting, routing, connector entitlement, status lines and the picker row in one place. With the variable unset, `resolve_provider("auto")` on a fresh install raises `no_provider_configured` exactly as upstream does. `HERMES_FORCE_GUEST` and `force_guest_mode()` are removed. They inverted the gate (forced the tier ON over `nous.guest: false`), their "new" value re-minted identities as a side effect of provider resolution, and `_has_any_provider_ configured` read them ahead of every other check, making the CLI a second reader of a flag that must have exactly one. `_forced_new_done` and the `force` parameter of `_reconcile_and_provision` go with them. Supersedes the dev lever introduced in fcf9d11679 (rung 1) and hardened in b5c162c3ec. Ruling: NS-845 Q1.1 (recorded on NS-847). Not a user preference: the variable is never written to config.yaml or .env and never shown in setup. It is deleted at GA together with its comment in anon_auth.py. This is a deliberate, temporary exception to the "no new HERMES_* env vars for non-secret config" rule. Tests: fixtures set the gate instead of deleting the old lever; one new invariant (`test_launch_gate_off_means_no_free_tier_at_all`) proves that "", "0", "true" and "new" all leave the tier off with zero portal calls, red on the previous commit. The `HERMES_FORCE_GUEST=new` re-mint test is deleted with the feature. --- hermes_cli/anon_auth.py | 73 ++++++++----------- hermes_cli/main.py | 10 --- .../gateway/test_free_tier_startup_notice.py | 2 +- tests/gateway/test_status_free_tier_line.py | 2 +- tests/hermes_cli/test_anon_auth_core.py | 26 ++++--- tests/hermes_cli/test_anon_first_notice.py | 2 +- tests/hermes_cli/test_anon_picker.py | 2 +- tests/hermes_cli/test_anon_surfaces.py | 2 +- tests/hermes_cli/test_anon_upgrade.py | 3 +- tests/tui_gateway/test_free_tier_rpc.py | 5 +- 10 files changed, 53 insertions(+), 74 deletions(-) diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 1b2daca4bd120..120cc32d44b50 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -43,9 +43,11 @@ # 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" +# Launch gate for the whole free tier while it is pre-GA: exactly "1" turns it on for this process +# (CLI, gateway, serve backend alike); anything else leaves every surface behaving as if the free +# tier did not exist. ``guest_enabled`` is the only reader. Not a user preference: never written to +# config.yaml or .env, never shown in setup. Deleted at GA together with this comment. +GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING" 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" @@ -66,18 +68,11 @@ 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 + """The free tier is on for this process: the launch gate is set AND ``nous.guest`` (default + True) has not switched it off. The only place either is read.""" + if (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() != "1": + return False try: from hermes_cli.config import load_config_readonly nous_cfg = load_config_readonly().get("nous") @@ -282,15 +277,13 @@ def _mint_locked(client: httpx.Client, portal: str, auth_store: Dict[str, Any]) _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. +# Per-process memo for the blocking path: 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. _mint_failed = False -_forced_new_done = False -def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Optional[Dict[str, Any]]: +def _reconcile_and_provision(*, timeout_seconds: float) -> Optional[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 @@ -298,7 +291,6 @@ def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Optional[ 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, @@ -310,18 +302,17 @@ def _reconcile_and_provision(*, force: str, timeout_seconds: float) -> Optional[ 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 + 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) @@ -333,29 +324,23 @@ def ensure_portal_identity( """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 + Order: ``guest_enabled`` 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 + global _mint_failed 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(): + if _mint_failed 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) + return _reconcile_and_provision(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: @@ -366,7 +351,7 @@ def ensure_portal_identity( def _run() -> None: global _background_started try: - _reconcile_and_provision(force=force, timeout_seconds=timeout_seconds) + _reconcile_and_provision(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 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ca0cd6f906e22..5e9e6ad54ad47 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -968,16 +968,6 @@ 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 "" diff --git a/tests/gateway/test_free_tier_startup_notice.py b/tests/gateway/test_free_tier_startup_notice.py index 2f50710a3a882..0d703d9fefa1d 100644 --- a/tests/gateway/test_free_tier_startup_notice.py +++ b/tests/gateway/test_free_tier_startup_notice.py @@ -48,7 +48,7 @@ def _account_state() -> dict: 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") # 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) diff --git a/tests/gateway/test_status_free_tier_line.py b/tests/gateway/test_status_free_tier_line.py index 4202668362966..f979e2613e638 100644 --- a/tests/gateway/test_status_free_tier_line.py +++ b/tests/gateway/test_status_free_tier_line.py @@ -74,7 +74,7 @@ def _account_state() -> dict: @pytest.fixture(autouse=True) def isolated_auth_store(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) - monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") @pytest.mark.asyncio diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index d7d80484a1d6c..a8fc400e8b7f8 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -71,7 +71,7 @@ def portal(monkeypatch, tmp_path): 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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 @@ -90,7 +90,6 @@ def __init__(self, *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 @@ -157,15 +156,20 @@ def test_opt_out_bool_disables_everything(self, portal, monkeypatch): 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 + def test_launch_gate_off_means_no_free_tier_at_all(self, portal, monkeypatch): + """Without ``HERMES_GUEST_ONBOARDING=1`` the free tier does not exist: no mint, no portal + traffic, ``nous.guest``'s default is never consulted, and an identity already on disk is + not treated as enabled. The env var is the only lever; ``0``/``true``/anything but ``1`` is off.""" + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + for raw in ("", "0", "true", "yes", "new"): + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", raw) + assert anon_auth.guest_enabled() is False + assert anon_auth.ensure_portal_identity(blocking=True) is None + assert portal.calls == [] + with pytest.raises(anon_auth.AuthError): + resolve_provider("auto") + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") + assert anon_auth.guest_enabled() is True class TestExplicitProvision: diff --git a/tests/hermes_cli/test_anon_first_notice.py b/tests/hermes_cli/test_anon_first_notice.py index b147952fa7f89..6d0c956cd5f64 100644 --- a/tests/hermes_cli/test_anon_first_notice.py +++ b/tests/hermes_cli/test_anon_first_notice.py @@ -51,7 +51,7 @@ def _console_print(self, *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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") def test_notice_prints_once_after_identity_appears_and_persists_the_flag(): diff --git a/tests/hermes_cli/test_anon_picker.py b/tests/hermes_cli/test_anon_picker.py index ee7f0803f89ac..4004f9b3b16bd 100644 --- a/tests/hermes_cli/test_anon_picker.py +++ b/tests/hermes_cli/test_anon_picker.py @@ -29,7 +29,7 @@ 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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)}}) diff --git a/tests/hermes_cli/test_anon_surfaces.py b/tests/hermes_cli/test_anon_surfaces.py index 165b63945b2f5..19cf5d140f9ca 100644 --- a/tests/hermes_cli/test_anon_surfaces.py +++ b/tests/hermes_cli/test_anon_surfaces.py @@ -72,7 +72,7 @@ def _account_state() -> dict: @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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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. diff --git a/tests/hermes_cli/test_anon_upgrade.py b/tests/hermes_cli/test_anon_upgrade.py index 1b1fc5c120da9..03a2367abb811 100644 --- a/tests/hermes_cli/test_anon_upgrade.py +++ b/tests/hermes_cli/test_anon_upgrade.py @@ -99,7 +99,7 @@ def portal(monkeypatch, tmp_path): 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) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") 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 @@ -117,7 +117,6 @@ def __init__(self, *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 diff --git a/tests/tui_gateway/test_free_tier_rpc.py b/tests/tui_gateway/test_free_tier_rpc.py index ef4dbf5940fcc..0e1d43dddb8be 100644 --- a/tests/tui_gateway/test_free_tier_rpc.py +++ b/tests/tui_gateway/test_free_tier_rpc.py @@ -29,7 +29,7 @@ def _call(method: str, params: dict | None = None) -> dict: @pytest.fixture def guest(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) - monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") with _auth_store_lock(): store = _load_auth_store() store.setdefault("providers", {})["nous"] = { @@ -78,6 +78,7 @@ def test_billing_state_answers_the_free_tier_locally(guest, monkeypatch): def test_status_without_an_identity_starts_the_background_setup_once(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") calls = [] monkeypatch.setattr(anon_auth, "ensure_portal_identity", lambda **kw: calls.append(kw) or None) status = _call("free_tier.status") @@ -89,7 +90,7 @@ def test_provision_sets_the_free_tier_up_through_the_lifecycle_primitive(tmp_pat """``free_tier.provision`` is the guided setup's explicit request: it calls provision_free_tier (the one explicit minting entry point) only when no identity exists, and reports the outcome.""" monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) - monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False) + monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") calls = [] def fake_provision(**kw): From 57c99de07c8dd9ffcf56407f510202e0d35258a3 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 01:23:51 +0530 Subject: [PATCH 09/16] feat(auth): the free-tier identity is created in one place, at boot; every other site is a read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this commit eight sites could create a Nous free-tier identity as a side effect of something else: resolving a provider, the CLI's first-run check, the CLI's session setup (in the background beside an own key), a connector bearer read, the desktop polling `free_tier.status`, the sign-in precondition, the desktop's `free_tier.provision`, and the dead-credential re-mint. A poll could mint. Provider resolution could hit the network. Two of them raced each other on a fresh install. Now `hermes_cli/free_tier_bootstrap.py::run_bootstrap` is the only creator. `hermes serve` runs it on a daemon thread from `_lifespan` beside the other background boots; `cmd_chat` runs it synchronously before the first-run guard. It inventories credentials first (`resolve_provider("auto", skip_free_tier=True)`: what would carry inference if the free tier did not exist), creates the identity only when `guest_enabled()`, resolves inference, records a `SetupRecord` in process memory and broadcasts ONE `setup.ready` event. It runs on every boot; only the mint is gated. `ensure_portal_identity` now requires `explicit=True` and raises otherwise. Its callers are the bootstrap, the desktop's `free_tier.provision` (the explicit retry when the boot could not create the identity) and the two dead-credential replacements (`auth_nous.resolve_nous_runtime_credentials`, `managed_tool_gateway._replace_dead_guest_token`). The background thread path and `provision_free_tier` are deleted with their last callers. Reads that used to mint and now only read: `auth.py::resolve_provider` rung 7 (an existing identity still outranks the Bedrock chain, NS-829 ordering kept), `main.py::_has_any_provider_configured`, `cli_agent_setup_mixin._ensure_runtime_credentials`, `managed_tool_gateway.read_nous_access_token` (no identity -> None), `anon_sign_in.run_sign_in` (no identity -> Unavailable), `methods_free_tier` `free_tier.status`. `setup.status` answers from the record for the launch profile, blocking up to 8 s while the bootstrap is in flight so a client's first poll lands after the identity exists rather than racing it; a named profile, or a process that never ran the bootstrap, keeps today's live probe. The record's fields ride along additively (`ready`, `free_tier`, `other_providers`, `inference_provider`). Identity and inference are decoupled (NS-845 Q1.3): the mint sets `active_provider="nous"` only when the inventory found nothing else usable (`_mint_locked(carries_inference=)`); an adopted account always does. A token refresh no longer re-elects the provider it refreshed (`_save_provider_state_to_source` writes credentials, not the user's choice) — that write was how an own-key install ended up on the free tier after the first connector call. Supersedes the mint sites in fcf9d11679, a42d0748fc (first-run check), bbbaa8935a (CLI background setup), 0179efc989 (`free_tier.status` mint), 62ad1ff3ab / c63d2c935c / d8a50526d9 (the `nous.guest_setup` knob and `provision_free_tier`), and a04b05260c (blocking mint in the resolver). Ruling: NS-845 Q1.2 + Q1.3, recorded on NS-847. Tests: `TestBootstrapIsTheOneCreator` (one mint per process; own key keeps inference; reads never reach the portal; a refused mint is memoised), `free_tier.status` fails loudly if it ever calls the creator, the resolver stub fails loudly if resolution ever mints, `setup.status` reads the record, `skip_free_tier` proves the inventory question. The three sign-in tests for the deleted pre-mint collapse into one (`no identity -> Unavailable, zero portal calls`). Live: real `_lifespan` boot with a fake portal, gate on and off (/tmp/ns847-recon/evidence/e2e-rung5-c2-serve-boot.txt), and the CLI matrix incl. an own-key cell (e2e-rung5-c2-bootstrap.txt), 20/20. --- hermes_cli/anon_auth.py | 122 ++++++-------- hermes_cli/anon_sign_in.py | 24 ++- hermes_cli/auth.py | 52 +++--- hermes_cli/auth_nous.py | 2 +- hermes_cli/cli_agent_setup_mixin.py | 13 +- hermes_cli/free_tier_bootstrap.py | 157 +++++++++++++++++++ hermes_cli/main.py | 20 ++- hermes_cli/web_server.py | 8 + tests/hermes_cli/test_anon_auth_core.py | 150 +++++++++++------- tests/hermes_cli/test_anon_desktop_signin.py | 8 +- tests/hermes_cli/test_anon_sign_in_flow.py | 42 ++--- tests/hermes_cli/test_anon_upgrade.py | 13 +- tests/hermes_cli/test_provider_precedence.py | 57 +++---- tests/test_tui_gateway_server.py | 30 ++++ tests/tui_gateway/test_free_tier_rpc.py | 23 +-- tools/managed_tool_gateway.py | 19 +-- tui_gateway/methods_config.py | 22 ++- tui_gateway/methods_free_tier.py | 36 ++--- 18 files changed, 486 insertions(+), 312 deletions(-) create mode 100644 hermes_cli/free_tier_bootstrap.py diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index 120cc32d44b50..5460034dccdb2 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -1,12 +1,14 @@ -"""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``. In the resolver ladder (``resolve_provider``) the free -tier sits directly above the implicit AWS Bedrock chain (NS-829): any explicit provider (env key, -``model.provider``, OpenRouter pool, a logged-in ``active_provider``) beats the guest for inference, -while the guest keeps carrying the tool-gateway JWT for connectors. +"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider. + +The identity is created in exactly one place, at boot (``hermes_cli.free_tier_bootstrap``), and only +while ``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``). The bootstrap mints an anonymous Nous +account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived +JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``; it +becomes ``active_provider`` only when the bootstrap's inventory found nothing else usable, so an +install with its own key keeps that key for inference and uses the identity for connectors only. In +the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the +implicit AWS Bedrock chain (NS-829): any explicit provider (env key, ``model.provider``, OpenRouter +pool, a logged-in ``active_provider``) beats it, and the ladder never creates one. 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 @@ -25,7 +27,6 @@ import logging import os -import threading import time from datetime import datetime, timedelta, timezone from typing import Any, Callable, Dict, Optional @@ -254,11 +255,17 @@ def _shared_identity_key(state: Any) -> Optional[str]: 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]: +def _mint_locked( + client: httpx.Client, portal: str, auth_store: Dict[str, Any], *, carries_inference: bool = True, +) -> 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 + next attempt exchanges the stored one instead of minting again). + + ``carries_inference`` decides whether the new identity also becomes ``active_provider``. The + bootstrap passes False when its inventory found another usable provider: the identity exists for + connectors, the user's own provider keeps carrying inference (NS-845 Q1.3).""" + from hermes_cli.auth import _store_provider_state, _save_auth_store from hermes_cli.auth_nous import _write_shared_nous_state minted = mint_guest(client, portal) state: Dict[str, Any] = { @@ -268,33 +275,32 @@ def _mint_locked(client: httpx.Client, portal: str, auth_store: Dict[str, Any]) "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) + _store_provider_state(auth_store, "nous", state, set_active=carries_inference) _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 memo for the blocking path: 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. +# Per-process memo: one failed mint is enough for a process (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. _mint_failed = False -def _reconcile_and_provision(*, timeout_seconds: float) -> Optional[Dict[str, Any]]: +def _reconcile_and_provision(*, timeout_seconds: float, carries_inference: bool = True) -> Optional[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). + sibling profile's sign-in, and never overwrites it). An adopted free-tier identity claims + ``active_provider`` under the same rule as a mint; an adopted ACCOUNT always does (the user + signed in somewhere on this machine). 2. Otherwise the profile's own identity stands. 3. Nothing anywhere: mint, persisting the credential before exchanging it. """ from hermes_cli.auth import ( _auth_store_lock, _load_auth_store, _load_provider_state, _save_auth_store, - _save_provider_state, _resolve_verify) + _store_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() @@ -305,7 +311,9 @@ def _reconcile_and_provision(*, timeout_seconds: float) -> Optional[Dict[str, An 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) + _store_provider_state( + auth_store, "nous", state, + set_active=carries_inference or not is_guest_state(state)) _save_auth_store(auth_store) logger.debug("Nous identity adopted from the shared store") return state @@ -315,68 +323,40 @@ def _reconcile_and_provision(*, timeout_seconds: float) -> Optional[Dict[str, An 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) + return _mint_locked(client, portal, auth_store, carries_inference=carries_inference) def ensure_portal_identity( - *, blocking: bool = True, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, + *, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, + carries_inference: bool = True, ) -> 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). + store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already). + + ``explicit`` is required and must be True: the only callers are the boot bootstrap + (``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, and the + dead-credential replacements (``auth_nous.resolve_nous_runtime_credentials``, + ``managed_tool_gateway._replace_dead_guest_token``). Nothing creates an identity as a side effect + of reading status, resolving a provider or fetching a connector bearer (NS-845 Q1.2). Order: ``guest_enabled`` 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). + first, then shared, matching every other Nous path. ``carries_inference=False`` leaves + ``active_provider`` alone (the identity is for connectors; another provider does inference). + Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread. """ + if not explicit: + raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)") global _mint_failed if not guest_enabled(): return None if _mint_failed and not current_nous_state(): return None # this process already tried and failed; do not hammer the portal - - if blocking: - try: - return _reconcile_and_provision(timeout_seconds=timeout_seconds) - except Exception: - _mint_failed = True - raise - - global _background_started - with _background_lock: - if _background_started: - return None - _background_started = True - - def _run() -> None: - global _background_started - try: - _reconcile_and_provision(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 provision_free_tier(*, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS) -> Optional[Dict[str, Any]]: - """The one explicit "set up the free tier now" entry point: adopt what the shared store holds, - else mint. ``nous.guest: false`` still wins (None). - - The guided setup on Hermes Desktop calls it (``free_tier.provision``) before creating its first - chat, so the identity exists before any session asks for ``nous/welcome``; a concurrent implicit - caller then adopts that identity under the shared-store lock instead of minting a second one. - """ - return ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) + return _reconcile_and_provision( + timeout_seconds=timeout_seconds, carries_inference=carries_inference) + except Exception: + _mint_failed = True + raise def refresh_guest_state(state: Dict[str, Any], client: httpx.Client) -> None: diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index 3022da2651757..d7e788ca83b7f 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -288,8 +288,10 @@ def run_sign_in( post_promotion_cancelled = is_cancelled if cancel_wins_after_promotion else (lambda: False) open_scope = scope or contextlib.nullcontext - # Preconditions and the mint run inside the scope; the state they produce is yielded outside - # it, because a scope must never be held across a ``yield``. + # Preconditions run inside the scope; the state they produce is yielded outside it, because a + # scope must never be held across a ``yield``. A sign-in never creates the identity it signs in + # from: with none on disk there is nothing to promote and the answer is ``Unavailable`` (the boot + # bootstrap is the only creator, NS-845 Q1.2). precondition_state: Optional[SignInState] = None state: Optional[Dict[str, Any]] = None try: @@ -297,20 +299,12 @@ def run_sign_in( state = _core.current_nous_state() if state and not _core.is_guest_state(state): precondition_state = AlreadySignedIn() - elif not state: - if not _core.guest_enabled(): - precondition_state = Unavailable() - else: - state = _core.ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds) - if not _core.is_guest_state(state): - # The free tier is off (or not set up), or an account appeared mid-flight. - precondition_state = Unavailable() + elif not state or not _core.guest_enabled(): + precondition_state = Unavailable() except Exception as exc: - # An AuthError (gate closed, rate limited) and an ordinary failure -- a cold install whose - # mint cannot reach the portal, an unreadable auth store -- mean the same thing here: there - # is no free tier to sign in from. Both become the one precondition state, so nothing - # escapes ``next()``. KeyboardInterrupt and GeneratorExit are not Exceptions: they still - # propagate. + # An unreadable auth store means the same thing here: there is no free tier to sign in from. + # It becomes the one precondition state, so nothing escapes ``next()``. KeyboardInterrupt + # and GeneratorExit are not Exceptions: they still propagate. precondition_state = Unavailable(detail=str(exc)) if precondition_state is not None: yield precondition_state diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0d912167ccb74..4424058574339 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -834,12 +834,16 @@ def _persist_provider_state_to_store( def _save_provider_state_to_source( auth_store: Dict[str, Any], provider_id: str, state: Dict[str, Any], source_path: Optional[Path], ) -> None: - """Persist provider state back to the auth store it was read from.""" + """Persist provider state back to the auth store it was read from. + + A token refresh rewrites credentials, not the user's choice of provider: ``active_provider`` is + left as it is (a Nous free-tier identity refreshed for a connector call must not become the + inference provider of an install that has its own key).""" if source_path is None or _same_path(source_path, _auth_file_path()): - _save_provider_state(auth_store, provider_id, state) + _store_provider_state(auth_store, provider_id, state, set_active=False) _save_auth_store(auth_store) else: - _persist_provider_state_to_store(provider_id, state, source_path, set_active=True) + _persist_provider_state_to_store(provider_id, state, source_path, set_active=False) def mark_provider_active_if_unset(provider_id: str) -> None: @@ -1367,14 +1371,14 @@ def _openrouter_auto_detected(scoped_key_env: Callable[[str], str]) -> bool: return False -def _logged_in_oauth_active_provider() -> Optional[str]: +def _logged_in_oauth_active_provider(*, skip_free_tier: bool = False) -> 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 has_guest() and (skip_free_tier or not guest_enabled()): + return None # the free tier is off (or being discounted), 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: @@ -1432,15 +1436,20 @@ def resolve_provider( requested: Optional[str] = None, *, explicit_api_key: Optional[str] = None, - explicit_base_url: Optional[str] = None) -> str: + explicit_base_url: Optional[str] = None, + skip_free_tier: bool = False) -> str: """Determine which inference provider to use. "auto" priority (explicit intent beats a stale OAuth login): 1. CLI api_key/base_url -> "openrouter"; 2. config.yaml ``model.provider``; 3. OPENAI_API_KEY / OPENROUTER_API_KEY -> "openrouter"; 4. OpenRouter pool; 5. provider env keys; 6. auth.json ``active_provider``; - 7. Nous free tier when ``nous.guest`` is on (existing identity, else a blocking mint); + 7. Nous free tier when it is on and its identity exists (never created here); 8. AWS Bedrock chain; 9. AuthError(no_provider_configured). + ``skip_free_tier`` hides rungs 6-for-a-free-tier-identity and 7: the boot bootstrap asks + "what would carry inference if the free tier did not exist?" to decide whether a fresh identity + may become ``active_provider``. + 1. 3. 4. 5. Provider-specific API keys (GLM, Kimi, MiniMax, ...) -> that provider 7. 8. Error (no provider configured) See #29285. """ @@ -1469,7 +1478,7 @@ def resolve_provider( # Determined up front so the env-key tier can warn when an exported key preempts it; the actual # OAuth fallback still happens after the env-key tier. - _oauth_active = _logged_in_oauth_active_provider() + _oauth_active = _logged_in_oauth_active_provider(skip_free_tier=skip_free_tier) env_pid = _env_key_auto_detected(_scoped_key_env, _oauth_active) if env_pid: return env_pid @@ -1487,18 +1496,19 @@ def resolve_provider( _oauth_active) return _oauth_active - # Nous free tier, when it is on: an existing free-tier identity, else mint one (blocking, short - # timeout). This rung sits ABOVE the Bedrock chain on purpose. Every rung above this line is - # explicit user intent (CLI creds, config, env keys, a sign-in); the boto chain below is implicit - # host state, and a leftover ~/.aws profile used to win the first turn of a fresh install and - # fail 403 while the free tier was being minted in the background (NS-829). ``nous.guest: false`` - # skips this rung. A failed mint is not an error: it falls through to Bedrock and the guidance. - try: - from hermes_cli.anon_auth import ensure_portal_identity, guest_enabled, has_guest - if guest_enabled() and (has_guest() or 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) + # Nous free tier, when it is on and its identity already exists. This rung sits ABOVE the Bedrock + # chain on purpose: every rung above this line is explicit user intent (CLI creds, config, env + # keys, a sign-in); the boto chain below is implicit host state, and a leftover ~/.aws profile + # used to win the first turn of a fresh install (NS-829). The rung never CREATES the identity: + # that is the boot bootstrap's job (free_tier_bootstrap), so provider resolution stays free of + # network and a fresh install without the bootstrap resolves exactly as upstream does. + if not skip_free_tier: + try: + from hermes_cli.anon_auth import guest_enabled, has_guest + if guest_enabled() and has_guest(): + return "nous" + except Exception as exc: + logger.debug("free tier check during provider resolution skipped: %s", exc) # AWS Bedrock via the boto3 credential chain (IAM roles, SSO, env vars): implicit host state, # below explicit keys and below the free tier. try: diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 9e7cdd24e70b6..97057274f3d30 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -981,7 +981,7 @@ def resolve_nous_runtime_credentials( 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: + if ensure_portal_identity(explicit=True, timeout_seconds=timeout_seconds) is None: raise return _resolve_nous_runtime_credentials( timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle) diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index 1dc6432bdf410..93bdd481c70ea 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -199,16 +199,9 @@ def _ensure_runtime_credentials(self) -> bool: 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. + # An explicit provider carries inference. The free-tier identity (for connectors) was + # created by the boot bootstrap before this point, never here; this prints the one-time + # "free tier is here" notice the first time an identity is seen beside an own key. self._maybe_print_free_tier_available_notice() resolved_routing = ( resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"), diff --git a/hermes_cli/free_tier_bootstrap.py b/hermes_cli/free_tier_bootstrap.py new file mode 100644 index 0000000000000..eca44e5b2f6e1 --- /dev/null +++ b/hermes_cli/free_tier_bootstrap.py @@ -0,0 +1,157 @@ +"""Serve-start bootstrap for the Nous free tier: the ONE place a free-tier identity is created. + +Every Hermes process that may need the free tier runs this once at boot (``hermes serve`` on a +daemon thread beside the other background boots; the CLI first-run guard synchronously). It +inventories credentials cheap-first, creates the identity only when the launch gate is open +(:func:`hermes_cli.anon_auth.guest_enabled`), resolves which provider carries inference, records +the answer in process memory, and tells every connected client with one ``setup.ready`` event. + +Nothing else mints. ``free_tier.status`` and ``setup.status`` read the record; provider resolution +never reaches the portal; a dead credential is replaced by the explicit re-mint in +``auth_nous.resolve_nous_runtime_credentials``. Ruling: NS-845 Q1.2 (recorded on NS-847). +""" + +from __future__ import annotations + +import logging +import threading +import time +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, Optional + +logger = logging.getLogger("hermes_cli.auth") + +# The desktop's first ``setup.status`` waits this long for the record before falling back to a live +# probe. The mint budget is 5 s (``GUEST_MINT_TIMEOUT_SECONDS``); the rest covers the inventory. +SETUP_READY_WAIT_SECONDS = 8.0 +SETUP_READY_EVENT = "setup.ready" + + +@dataclass(frozen=True) +class SetupRecord: + """What the bootstrap found. One shape for every reader; no version field (renderer and backend + ship together).""" + + provider_configured: bool # some provider can carry inference (free tier included) + inference_provider: str # ``resolve_provider("auto")``'s answer, "" when nothing resolves + free_tier: bool # the identity that exists is the free tier AND the tier is on + has_identity: bool # a Nous identity (free tier or account) is on disk + other_providers: bool # the inventory found something usable BESIDES the free tier + error: str = "" # why the mint did not happen, when it did not; "" otherwise + finished_at: float = field(default_factory=time.time) + + def as_payload(self) -> Dict[str, Any]: + return asdict(self) + + +_lock = threading.Lock() +_record: Optional[SetupRecord] = None +_done = threading.Event() +_started = False + + +def current_record() -> Optional[SetupRecord]: + """The record, or None until the first bootstrap finishes.""" + return _record + + +def wait_for_record(timeout: float = SETUP_READY_WAIT_SECONDS) -> Optional[SetupRecord]: + """Block up to ``timeout`` seconds for a bootstrap that is IN FLIGHT, then return whatever it + produced. Returns None at once when no bootstrap ever started in this process (a bare + ``tui_gateway`` under test, an old serve without the boot hook): the caller falls back to its + live probe instead of paying the wait for nothing.""" + if not _started: + return None + _done.wait(timeout) + return _record + + +def reset_for_tests() -> None: + global _record, _started + with _lock: + _record = None + _started = False + _done.clear() + + +def _inventory_other_providers() -> bool: + """Is anything usable configured BESIDES the free tier? Asks the resolver ladder itself (the + thing that picks the provider for a turn) with the free-tier rung hidden: an explicit key, a + config pin, a sign-in or a host credential answers; nothing else falls through to + ``no_provider_configured``. Not ``_has_any_provider_configured``: that first-run guard counts + keyless catalog providers as "configured" and is True on a blank machine.""" + from hermes_cli.auth import resolve_provider + try: + return resolve_provider("auto", skip_free_tier=True) != "nous" + except Exception as exc: + logger.debug("free tier bootstrap: nothing else carries inference (%s)", exc) + return False + + +def _resolve_inference() -> str: + from hermes_cli.auth import resolve_provider + try: + return str(resolve_provider("auto") or "") + except Exception: + return "" + + +def run_bootstrap(*, announce: bool = True) -> SetupRecord: + """Inventory -> ensure identity (gate permitting) -> resolve inference -> record -> broadcast. + + Runs every boot; only the mint is gated. Idempotent per process: a second call returns the + existing record without touching the portal. Never raises. ``announce=False`` skips the + ``setup.ready`` event: the plain CLI has no client to tell and its stdout is the user's terminal. + """ + global _record, _started + with _lock: + if _record is not None: + return _record + if _started: + _done.wait(SETUP_READY_WAIT_SECONDS) + if _record is not None: + return _record + _started = True + + from hermes_cli import anon_auth + + other = _inventory_other_providers() + error = "" + state: Optional[Dict[str, Any]] = anon_auth.current_nous_state() + if anon_auth.guest_enabled(): + try: + # ``other`` decides whether the mint may also claim ``active_provider`` (NS-845 Q1.3). + state = anon_auth.ensure_portal_identity(explicit=True, carries_inference=not other) + except Exception as exc: + error = str(exc) + logger.info("Nous free tier not set up at boot: %s", exc) + free_tier = bool(state) and anon_auth.is_guest_state(state) and anon_auth.guest_enabled() + record = SetupRecord( + provider_configured=other or free_tier or (bool(state) and not anon_auth.is_guest_state(state)), + inference_provider=_resolve_inference(), + free_tier=free_tier, + has_identity=bool(state), + other_providers=other, + error=error, + ) + with _lock: + _record = record + _done.set() + if announce: + _broadcast(record) + return record + + +def _broadcast(record: SetupRecord) -> None: + try: + from tui_gateway.server import _broadcast_global_event + _broadcast_global_event(SETUP_READY_EVENT, record.as_payload()) + except Exception as exc: # no serve process (plain CLI): nobody to tell + logger.debug("setup.ready not broadcast: %s", exc) + + +def start_background_bootstrap() -> threading.Thread: + """``hermes serve`` entry: run on a daemon thread so a slow portal never delays the socket.""" + thread = threading.Thread(target=run_bootstrap, daemon=True, name="free-tier-bootstrap") + thread.start() + return thread diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5e9e6ad54ad47..167830d641dca 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -958,7 +958,9 @@ def _auth_store_logged_in(auth_file: Path, registry, strict_profile_scope: bool) def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool: - """Check if at least one inference provider is usable. + """Check if at least one inference provider is usable. Never creates one: the Nous free tier + counts only once its identity exists, and the boot bootstrap (``hermes_cli.free_tier_bootstrap``) + is the only thing that creates it; ``cmd_chat`` runs the bootstrap before asking. ``strict_profile_scope``: the caller has bound a NAMED profile's home and secret scope and wants an answer for that profile only — launch-process @@ -1043,14 +1045,12 @@ 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. + # Nothing explicit anywhere: an existing Nous free-tier identity counts while the tier is on. try: - from hermes_cli.anon_auth import ensure_portal_identity - return ensure_portal_identity(blocking=True) is not None + from hermes_cli.anon_auth import guest_enabled, has_guest + return guest_enabled() and has_guest() except Exception as exc: - logger.debug("free tier setup on first run skipped: %s", exc) + logger.debug("free tier check on first run skipped: %s", exc) return False @@ -1695,7 +1695,11 @@ def cmd_chat(args): _warn_retired_xai_models() - # First-run guard: check if any provider is configured before launching + # First-run guard: the free-tier bootstrap runs first (synchronously here; it is the only thing + # that may create the identity), then the inventory decides whether setup is needed. + from hermes_cli.free_tier_bootstrap import run_bootstrap + + run_bootstrap(announce=False) if not _has_any_provider_configured(): _first_run_setup_guard(args) return diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 5ffffc9a69ead..72bfab2b4c49d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -235,6 +235,14 @@ def _boot_local_runtime(): threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start() + # Nous free tier: the ONE place its identity is created. Inventories credentials, mints only + # when HERMES_GUEST_ONBOARDING=1, records the answer for setup.status / free_tier.status and + # broadcasts `setup.ready`. Off-thread so a slow portal never delays the socket; the desktop's + # first setup.status waits on the record (bounded) instead. + from hermes_cli.free_tier_bootstrap import start_background_bootstrap + + start_background_bootstrap() + try: yield finally: diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index a8fc400e8b7f8..390c9a36a695b 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -88,8 +88,9 @@ def __init__(self, *a, **kw): 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 + from hermes_cli import free_tier_bootstrap as _fb + _fb.reset_for_tests() # 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 @@ -113,7 +114,7 @@ def _shared_store(tmp_path) -> dict: 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) + state = anon_auth.ensure_portal_identity(explicit=True) assert anon_auth.is_guest_state(state) assert "refresh_token" not in state store = _load_auth_store() @@ -123,16 +124,16 @@ def test_fresh_install_mints_once_and_is_the_active_provider(self, portal, tmp_p 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 anon_auth.ensure_portal_identity(explicit=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) + first = anon_auth.ensure_portal_identity(explicit=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) + second = anon_auth.ensure_portal_identity(explicit=True) assert second["anon_token"] == first["anon_token"] assert len(portal.calls) == before, "adoption must not touch the network" assert portal.minted == 1 @@ -140,18 +141,18 @@ def test_second_profile_under_same_root_adopts_from_shared_store(self, portal, t 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) + anon_auth.ensure_portal_identity(explicit=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 anon_auth.ensure_portal_identity(explicit=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) # A developer machine's ~/.aws would answer the Bedrock rung and hide the AuthError. monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) - assert anon_auth.ensure_portal_identity(blocking=True) is None + assert anon_auth.ensure_portal_identity(explicit=True) is None assert portal.calls == [] with pytest.raises(anon_auth.AuthError): resolve_provider("auto") @@ -164,7 +165,7 @@ def test_launch_gate_off_means_no_free_tier_at_all(self, portal, monkeypatch): for raw in ("", "0", "true", "yes", "new"): monkeypatch.setenv("HERMES_GUEST_ONBOARDING", raw) assert anon_auth.guest_enabled() is False - assert anon_auth.ensure_portal_identity(blocking=True) is None + assert anon_auth.ensure_portal_identity(explicit=True) is None assert portal.calls == [] with pytest.raises(anon_auth.AuthError): resolve_provider("auto") @@ -173,16 +174,16 @@ def test_launch_gate_off_means_no_free_tier_at_all(self, portal, monkeypatch): class TestExplicitProvision: - """:func:`provision_free_tier` is the guided setup's "set up now" entry point. It mints once; - every implicit caller afterwards adopts that identity through the shared store, so setup and a - concurrent first-run check never create two identities. ``nous.guest: false`` still wins.""" + """``ensure_portal_identity(explicit=True)`` is the one creator (the boot bootstrap and the desktop + retry call it). It mints once; every later caller adopts that identity through the shared store, + so two boots never create two identities. ``nous.guest: false`` still wins.""" def test_provision_mints_once_then_everything_adopts(self, portal, monkeypatch, tmp_path): - assert anon_auth.is_guest_state(anon_auth.provision_free_tier()) + assert anon_auth.is_guest_state(anon_auth.ensure_portal_identity(explicit=True)) assert portal.minted == 1 token = _load_auth_store()["providers"]["nous"]["anon_token"] # A second provision is idempotent, and the runtime now serves nous/welcome on the welcome host. - anon_auth.provision_free_tier() + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider(requested="nous", target_model=anon_auth.GUEST_MODEL) assert runtime["base_url"].rstrip("/") == WELCOME @@ -191,12 +192,12 @@ def test_provision_mints_once_then_everything_adopts(self, portal, monkeypatch, sibling = tmp_path / "sibling-profile" sibling.mkdir() monkeypatch.setenv("HERMES_HOME", str(sibling)) - adopted = anon_auth.ensure_portal_identity(blocking=True) + adopted = anon_auth.ensure_portal_identity(explicit=True) assert adopted and adopted["anon_token"] == token assert portal.minted == 1 def test_retired_identity_is_replaced(self, portal, monkeypatch): - anon_auth.provision_free_tier() + anon_auth.ensure_portal_identity(explicit=True) first = _load_auth_store()["providers"]["nous"]["anon_token"] portal.dead_tokens.add(first) from hermes_cli.auth_nous import resolve_nous_runtime_credentials @@ -206,19 +207,19 @@ def test_retired_identity_is_replaced(self, portal, monkeypatch): def test_guest_off_beats_an_explicit_provision(self, portal, monkeypatch): _write_config(monkeypatch, guest=False) - assert anon_auth.provision_free_tier() is None + assert anon_auth.ensure_portal_identity(explicit=True) is None assert portal.minted == 0 class TestResolverIsUnchanged: def test_guest_is_last_resort_and_explicit_key_wins(self, portal, monkeypatch): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=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) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider() assert runtime["provider"] == "nous" @@ -231,7 +232,7 @@ class TestRouteFallback: def test_exchange_without_inference_url_routes_to_welcome_literal(self, portal): portal.inference_base_url = None - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider() assert runtime["base_url"].rstrip("/") == WELCOME @@ -240,7 +241,7 @@ def test_exchange_without_inference_url_routes_to_welcome_literal(self, portal): def test_disallowed_inference_host_heals_to_welcome_literal(self, portal): portal.inference_base_url = "https://welcome-api.staging-nousresearch.com/v1" - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.runtime_provider import resolve_runtime_provider runtime = resolve_runtime_provider() assert runtime["base_url"].rstrip("/") == WELCOME @@ -261,7 +262,7 @@ def test_shared_store_shape_keeps_a_guest_on_the_welcome_host(self, portal): class TestTokenAcquisitionSeam: def test_expired_guest_jwt_reexchanges_and_never_hits_oauth_token(self, portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.auth import _auth_store_lock, _save_auth_store with _auth_store_lock(): store = _load_auth_store() @@ -278,7 +279,7 @@ def test_expired_guest_jwt_reexchanges_and_never_hits_oauth_token(self, portal): 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) + first = anon_auth.ensure_portal_identity(explicit=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) @@ -288,7 +289,7 @@ def test_dead_credential_is_replaced_by_a_fresh_identity(self, portal): assert portal.minted == 2 def test_tool_gateway_token_path_reexchanges(self, portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.auth import _auth_store_lock, _save_auth_store, resolve_nous_access_token with _auth_store_lock(): store = _load_auth_store() @@ -305,13 +306,13 @@ class TestModelPin: 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 + anon_auth.ensure_portal_identity(explicit=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) + anon_auth.ensure_portal_identity(explicit=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) @@ -325,7 +326,7 @@ 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) + anon_auth.ensure_portal_identity(explicit=True) before = _auth_file_path().read_bytes() logout_command(SimpleNamespace(provider=None)) out = capsys.readouterr().out.lower() @@ -348,7 +349,7 @@ def test_logout_of_real_account_clears_shared_store(self, portal, tmp_path): 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) + anon_auth.ensure_portal_identity(explicit=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) @@ -387,51 +388,78 @@ def test_welcome_conversation_may_move_to_the_portal_host(self, portal): 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 +class TestBootstrapIsTheOneCreator: + """``free_tier_bootstrap.run_bootstrap`` is the only place an identity is created. Every other + site is a read. One mint per process; the record says who carries inference.""" + + def _fresh(self): + from hermes_cli import free_tier_bootstrap as fb + fb.reset_for_tests() + return fb + + def test_bootstrap_mints_once_records_and_a_second_run_is_free(self, portal): + fb = self._fresh() + record = fb.run_bootstrap() + assert record.free_tier and record.has_identity and record.provider_configured + assert record.inference_provider == "nous" and record.other_providers is False + assert portal.minted == 1 + again = fb.run_bootstrap() + assert again is record and portal.minted == 1, "a second boot in the same process adopts, never mints" + assert fb.wait_for_record(timeout=0) is record + + def test_own_key_keeps_inference_and_the_identity_stays_off_active_provider(self, portal, monkeypatch): + monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-own-key") + fb = self._fresh() + record = fb.run_bootstrap() + assert record.other_providers is True and record.has_identity is True + assert record.free_tier is True, "the identity exists for connectors" + assert record.inference_provider != "nous" + assert _load_auth_store().get("active_provider") != "nous", "a mint beside an own key must not hijack inference" + assert portal.minted == 1 + + def test_reads_never_mint(self, portal, monkeypatch): + """status, provider resolution and the connector bearer are reads: with no identity they + answer 'nothing' and touch no network.""" + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + from tools.managed_tool_gateway import read_nous_access_token + assert read_nous_access_token() is None + with pytest.raises(anon_auth.AuthError): + resolve_provider("auto") + from hermes_cli.main import _has_any_provider_configured + _has_any_provider_configured() + assert not anon_auth.has_guest() + assert portal.calls == [], "no read path may reach the portal" + with pytest.raises(ValueError): + anon_auth.ensure_portal_identity(explicit=False) + + def test_bootstrap_with_the_gate_closed_records_the_refusal_and_stops(self, portal, monkeypatch): + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) 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 + fb = self._fresh() + record = fb.run_bootstrap() + assert record.has_identity is False and record.free_tier is False and record.error + assert [p for _, p in portal.calls].count("/api/anonymous/create") == 1 + # The explicit retry (desktop free_tier.provision) is also memoised for the process. + assert anon_auth.ensure_portal_identity(explicit=True) is None + assert [p for _, p in portal.calls].count("/api/anonymous/create") == 1 class TestIdentityOfRecordIsTheSharedStore: def test_stale_profile_guest_adopts_a_newer_shared_account(self, portal, tmp_path): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=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) + state = anon_auth.ensure_portal_identity(explicit=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) + first = anon_auth.ensure_portal_identity(explicit=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 @@ -441,7 +469,7 @@ def test_mint_persists_before_any_exchange_and_first_use_exchanges_once(self, po 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) + anon_auth.ensure_portal_identity(explicit=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"}) @@ -468,13 +496,13 @@ def 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) + anon_auth.ensure_portal_identity(explicit=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) + anon_auth.ensure_portal_identity(explicit=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 @@ -484,7 +512,7 @@ def test_opt_out_hides_the_free_tier_from_connectors_including_cached_tokens(sel 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) + first = anon_auth.ensure_portal_identity(explicit=True) from hermes_cli.auth import _auth_store_lock, _save_auth_store with _auth_store_lock(): store = _load_auth_store() diff --git a/tests/hermes_cli/test_anon_desktop_signin.py b/tests/hermes_cli/test_anon_desktop_signin.py index 819817a57ecbd..f1849a7fcea90 100644 --- a/tests/hermes_cli/test_anon_desktop_signin.py +++ b/tests/hermes_cli/test_anon_desktop_signin.py @@ -37,7 +37,7 @@ def _wait_for_terminal(session_id: str, timeout: float = 10.0) -> dict: def test_start_registers_the_transfer_and_completion_settles_the_account(portal, free_account): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME}) resp = client.post("/api/providers/oauth/nous/start", headers=HEADERS) @@ -62,7 +62,7 @@ def test_start_registers_the_transfer_and_completion_settles_the_account(portal, def test_a_transfer_the_user_declined_is_reported_with_its_reason_and_keeps_the_free_tier(portal, free_account): - guest = anon_auth.ensure_portal_identity(blocking=True) + guest = anon_auth.ensure_portal_identity(explicit=True) portal.status_sequence = [{"status": "voided", "reason": "user_declined"}] start = client.post("/api/providers/oauth/nous/start", headers=HEADERS).json() @@ -75,7 +75,7 @@ def test_a_transfer_the_user_declined_is_reported_with_its_reason_and_keeps_the_ def test_status_routes_report_the_free_tier(portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) portal_status = client.get("/api/portal", headers=HEADERS).json() assert portal_status["free_tier"] is True assert portal_status["account_tier"] == "anonymous" @@ -89,7 +89,7 @@ def test_a_sign_in_cancelled_while_waiting_never_persists_the_account(portal, fr nothing may reach the auth store.""" import threading from hermes_cli import web_server_oauth - guest = anon_auth.ensure_portal_identity(blocking=True) + guest = anon_auth.ensure_portal_identity(explicit=True) release = threading.Event() def _wait_until_released(client, portal_base_url, claim_code, *, expires_in, interval, cancelled=None): diff --git a/tests/hermes_cli/test_anon_sign_in_flow.py b/tests/hermes_cli/test_anon_sign_in_flow.py index fa27ee3fbe014..5cc28a621152d 100644 --- a/tests/hermes_cli/test_anon_sign_in_flow.py +++ b/tests/hermes_cli/test_anon_sign_in_flow.py @@ -28,7 +28,7 @@ def _drain(**kwargs): def _seed_free_tier() -> dict: - return anon_auth.ensure_portal_identity(blocking=True) + return anon_auth.ensure_portal_identity(explicit=True) def _stub_wait(monkeypatch, outcome, *, before=None): @@ -249,42 +249,20 @@ def test_free_tier_off_yields_unavailable(portal, monkeypatch): assert portal.calls == [] -def test_an_auth_error_while_provisioning_names_the_cause_in_the_terminal_form_only(portal, monkeypatch): - def _closed(**kwargs): - raise AuthError("gate closed") - monkeypatch.setattr(anon_auth, "ensure_portal_identity", _closed) - - state = _drain()[-1] - - assert state.kind == "unavailable" - assert state.copy_terminal == f"{anon_auth.UPGRADE_UNAVAILABLE} (gate closed)" - assert state.copy == anon_auth.UPGRADE_UNAVAILABLE_CHAT - - -def test_a_transport_failure_while_provisioning_yields_unavailable_rather_than_raising(portal, monkeypatch): - def _offline(**kwargs): - raise httpx.ConnectError("connection refused") - monkeypatch.setattr(anon_auth, "ensure_portal_identity", _offline) +def test_no_identity_on_disk_yields_unavailable_without_touching_the_portal(portal, monkeypatch): + """A sign-in never creates the identity it signs in from: the boot bootstrap is the only creator. + With nothing on disk the flow yields ``Unavailable`` and makes no portal call and no mint attempt.""" + monkeypatch.setattr(anon_auth, "ensure_portal_identity", + lambda **kwargs: (_ for _ in ()).throw(AssertionError("run_sign_in must not mint"))) + monkeypatch.setattr(anon_auth, "mint_guest", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("run_sign_in must not mint"))) states = _drain() assert [s.kind for s in states] == ["unavailable"] - assert states[-1].copy_terminal == f"{anon_auth.UPGRADE_UNAVAILABLE} (connection refused)" - assert states[-1].copy == anon_auth.UPGRADE_UNAVAILABLE_CHAT - - -def test_a_transport_failure_while_minting_yields_unavailable(portal, monkeypatch): - # ``mint_guest`` is called positionally from inside the store lock, so the stub takes *args: - # the failure under test must be the transport error, not a signature mismatch. - def _offline(*args, **kwargs): - raise httpx.ConnectError("connection refused") - monkeypatch.setattr(anon_auth, "mint_guest", _offline) - - states = _drain() - - assert [s.kind for s in states] == ["unavailable"] - assert states[-1].copy_terminal == f"{anon_auth.UPGRADE_UNAVAILABLE} (connection refused)" + assert states[-1].copy_terminal == anon_auth.UPGRADE_UNAVAILABLE assert states[-1].copy == anon_auth.UPGRADE_UNAVAILABLE_CHAT + assert portal.calls == [] def test_cancelling_before_the_wait_persists_nothing(portal, tmp_path): diff --git a/tests/hermes_cli/test_anon_upgrade.py b/tests/hermes_cli/test_anon_upgrade.py index 03a2367abb811..77807ade4df21 100644 --- a/tests/hermes_cli/test_anon_upgrade.py +++ b/tests/hermes_cli/test_anon_upgrade.py @@ -115,7 +115,6 @@ def __init__(self, *a, **kw): 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 return fake @@ -131,7 +130,7 @@ def _args(): class TestUpgrade: def test_intent_carries_both_device_codes_from_the_code_request(self, portal): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) anon_auth.upgrade_guest(_args()) assert len(portal.intent_bodies) == 1 body = portal.intent_bodies[0] @@ -143,7 +142,7 @@ def test_intent_carries_both_device_codes_from_the_code_request(self, portal): 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) + anon_auth.ensure_portal_identity(explicit=True) before = _auth_file_path().read_bytes() shared_before = _shared_store(tmp_path) portal.status_sequence = [{"status": "pending"}, {"status": "voided", "reason": "user_declined"}] @@ -156,7 +155,7 @@ def test_declined_in_browser_prints_copy_and_leaves_auth_store_untouched(self, p 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) + guest = anon_auth.ensure_portal_identity(explicit=True) assert _shared_store(tmp_path).get("anon_token") == guest["anon_token"] code = anon_auth.upgrade_guest(_args()) out = capsys.readouterr().out @@ -208,7 +207,7 @@ def _model_config() -> dict: class TestSignInCompletionSettlesTheModel: def test_config_on_the_free_tier_route_moves_to_the_account_host_and_the_recommended_free_model( self, portal, free_account, capsys): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) # What picking the free-tier row leaves behind: the welcome model pinned to the welcome host. _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME}) assert anon_auth.upgrade_guest(_args()) == 0 @@ -219,7 +218,7 @@ def test_config_on_the_free_tier_route_moves_to_the_account_host_and_the_recomme assert f"Default model is now {FREE_PICK}." in capsys.readouterr().out def test_config_on_the_users_own_model_is_left_alone(self, portal, free_account, capsys): - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) own = {"provider": "openrouter", "default": "anthropic/claude-sonnet-4"} _write_model_config(own) assert anon_auth.upgrade_guest(_args()) == 0 @@ -229,7 +228,7 @@ def test_config_on_the_users_own_model_is_left_alone(self, portal, free_account, def test_no_eligible_recommendation_leaves_no_default_rather_than_a_model_the_account_may_not_use( self, portal, free_account, monkeypatch, capsys): from hermes_cli import models as m - anon_auth.ensure_portal_identity(blocking=True) + anon_auth.ensure_portal_identity(explicit=True) _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME}) def _portal_down(): raise RuntimeError("recommended models unavailable") diff --git a/tests/hermes_cli/test_provider_precedence.py b/tests/hermes_cli/test_provider_precedence.py index 685a70324ac08..4f3f13d4c6d3b 100644 --- a/tests/hermes_cli/test_provider_precedence.py +++ b/tests/hermes_cli/test_provider_precedence.py @@ -98,35 +98,30 @@ def _logged_out(monkeypatch): def _free_tier(monkeypatch, *, on=True, identity=False): - """Free tier switch + whether a free-tier identity already exists; counts mints.""" + """Free tier switch + whether a free-tier identity already exists. The resolver is a READ: any + call into the creator from inside it is a bug, so the stub fails loudly.""" monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: on) monkeypatch.setattr("hermes_cli.anon_auth.has_guest", lambda: identity) - calls = {"mint": 0} - - def _ensure(*, blocking=True, timeout_seconds=None): - calls["mint"] += 1 - return {"auth_method": "anonymous"} if on else None - - monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", _ensure) - return calls + monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", + lambda **kw: (_ for _ in ()).throw(AssertionError("resolve_provider must not mint"))) class TestFreeTierBeatsImplicitHostCredentials: """NS-829: a leftover ~/.aws profile must not pre-empt the free tier on a fresh install. - Two invariants. (1) The ladder: explicit intent still wins, the free tier sits above the - implicit Bedrock chain, and the free tier off restores Bedrock. (2) A failed mint, whether it - returns None or raises, falls through to Bedrock.""" + The ladder: explicit intent still wins, an EXISTING free-tier identity sits above the implicit + Bedrock chain, the free tier off (or its identity absent) restores Bedrock. The resolver never + creates the identity; the boot bootstrap does, before any turn asks.""" - @pytest.mark.parametrize("free_tier_on, identity, env_key, login, expected, mints", [ - (True, True, None, None, "nous", 0), # existing identity beats the AWS chain - (True, False, None, None, "nous", 1), # fresh install mints before Bedrock - (False, False, None, None, "bedrock", 0), # free tier off: Bedrock as before - (True, True, "OPENAI_API_KEY", None, "openrouter", 0), # env key still wins - (True, True, None, "anthropic", "anthropic", 0), # a sign-in still wins + @pytest.mark.parametrize("free_tier_on, identity, env_key, login, expected", [ + (True, True, None, None, "nous"), # existing identity beats the AWS chain + (True, False, None, None, "bedrock"), # no identity yet: Bedrock, nothing minted + (False, True, None, None, "bedrock"), # free tier off: Bedrock as before + (True, True, "OPENAI_API_KEY", None, "openrouter"), # env key still wins + (True, True, None, "anthropic", "anthropic"), # a sign-in still wins ]) def test_free_tier_sits_above_the_bedrock_chain(self, monkeypatch, free_tier_on, identity, - env_key, login, expected, mints): + env_key, login, expected): _clear_provider_env(monkeypatch) _config(monkeypatch, "") if login: @@ -136,23 +131,17 @@ def test_free_tier_sits_above_the_bedrock_chain(self, monkeypatch, free_tier_on, if env_key: monkeypatch.setenv(env_key, "sk-test-key") monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) - calls = _free_tier(monkeypatch, on=free_tier_on, identity=identity) + _free_tier(monkeypatch, on=free_tier_on, identity=identity) assert resolve_provider("auto") == expected - assert calls["mint"] == mints - @pytest.mark.parametrize("failure", ["returns_none", "raises"]) - def test_failed_mint_falls_through_to_bedrock(self, monkeypatch, failure): + def test_skip_free_tier_answers_what_else_would_carry_inference(self, monkeypatch): + """The bootstrap's question: with the free tier hidden, an existing identity is not an + answer and the ladder falls through to the next real rung.""" _clear_provider_env(monkeypatch) _config(monkeypatch, "") _logged_out(monkeypatch) - monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: True) - monkeypatch.setattr("hermes_cli.anon_auth.guest_enabled", lambda: True) - monkeypatch.setattr("hermes_cli.anon_auth.has_guest", lambda: False) - - def _mint(**kw): - if failure == "raises": - raise RuntimeError("portal 429") - return None - - monkeypatch.setattr("hermes_cli.anon_auth.ensure_portal_identity", _mint) - assert resolve_provider("auto") == "bedrock" + monkeypatch.setattr("agent.bedrock_adapter.has_aws_credentials", lambda: False) + _free_tier(monkeypatch, on=True, identity=True) + assert resolve_provider("auto") == "nous" + with pytest.raises(AuthError): + resolve_provider("auto", skip_free_tier=True) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 69cdc03432899..443f3e5ce8ac0 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -8974,6 +8974,36 @@ def test_setup_status_reports_provider_config(monkeypatch): assert resp["result"]["provider_configured"] is False +def test_setup_status_answers_from_the_bootstrap_record_once_it_exists(monkeypatch): + """Under ``hermes serve`` the boot bootstrap owns the free-tier identity; ``setup.status`` reports + its record (blocking for it while it is in flight) instead of re-probing, so a client's first poll + sees the identity that exists rather than racing the mint.""" + import threading + from hermes_cli import free_tier_bootstrap as fb + fb.reset_for_tests() + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", + lambda **_kw: pytest.fail("setup.status must read the record, not re-probe")) + release = threading.Event() + + def slow_bootstrap(): + release.wait(5) + with fb._lock: + fb._record = fb.SetupRecord(provider_configured=True, inference_provider="nous", free_tier=True, + has_identity=True, other_providers=False) + fb._done.set() + with fb._lock: + fb._started = True + threading.Thread(target=slow_bootstrap, daemon=True).start() + try: + release.set() + resp = server.handle_request({"id": "1", "method": "setup.status", "params": {}}) + assert resp["result"]["provider_configured"] is True + assert resp["result"]["ready"] is True and resp["result"]["free_tier"] is True + assert resp["result"]["inference_provider"] == "nous" + finally: + fb.reset_for_tests() + + def test_probe_credentials_emits_exact_empty_key_warning(): agent = types.SimpleNamespace(api_key="", provider="openrouter") diff --git a/tests/tui_gateway/test_free_tier_rpc.py b/tests/tui_gateway/test_free_tier_rpc.py index 0e1d43dddb8be..bbe94cfbb5c12 100644 --- a/tests/tui_gateway/test_free_tier_rpc.py +++ b/tests/tui_gateway/test_free_tier_rpc.py @@ -76,19 +76,20 @@ def test_billing_state_answers_the_free_tier_locally(guest, monkeypatch): assert res["free_tier"] is False and res["free_tier_model"] is None -def test_status_without_an_identity_starts_the_background_setup_once(tmp_path, monkeypatch): +def test_status_without_an_identity_is_a_pure_read(tmp_path, monkeypatch): + """The desktop polls ``free_tier.status`` every status round; a poll must never create the identity + (that is the boot bootstrap's job).""" monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") - calls = [] - monkeypatch.setattr(anon_auth, "ensure_portal_identity", lambda **kw: calls.append(kw) or None) + monkeypatch.setattr(anon_auth, "ensure_portal_identity", + lambda **kw: (_ for _ in ()).throw(AssertionError("free_tier.status must not mint"))) status = _call("free_tier.status") - assert status["has_guest"] is False and status["available"] is False - assert calls == [{"blocking": False}] + assert status["has_guest"] is False and status["available"] is False and status["enabled"] is True def test_provision_sets_the_free_tier_up_through_the_lifecycle_primitive(tmp_path, monkeypatch): - """``free_tier.provision`` is the guided setup's explicit request: it calls provision_free_tier - (the one explicit minting entry point) only when no identity exists, and reports the outcome.""" + """``free_tier.provision`` is the desktop's explicit retry: it calls the one creator + (``ensure_portal_identity(explicit=True)``) only when no identity exists, and reports the outcome.""" monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store")) monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1") calls = [] @@ -102,9 +103,9 @@ def fake_provision(**kw): _save_auth_store(store) return store["providers"]["nous"] - monkeypatch.setattr(anon_auth, "provision_free_tier", fake_provision) + monkeypatch.setattr(anon_auth, "ensure_portal_identity", fake_provision) assert _call("free_tier.provision") == {"has_guest": True, "enabled": True} - assert len(calls) == 1 + assert calls == [{"explicit": True}] assert _call("free_tier.provision") == {"has_guest": True, "enabled": True} assert len(calls) == 1 # idempotent: an identity exists, nothing is minted @@ -113,10 +114,10 @@ def refused(**kw): with _auth_store_lock(): store = _load_auth_store(); store["providers"].pop("nous"); _save_auth_store(store) - monkeypatch.setattr(anon_auth, "provision_free_tier", refused) + monkeypatch.setattr(anon_auth, "ensure_portal_identity", refused) result = _call("free_tier.provision") assert result["has_guest"] is False and "not open" in result["error"] _set_guest_off(monkeypatch) - monkeypatch.setattr(anon_auth, "provision_free_tier", lambda **kw: (_ for _ in ()).throw(AssertionError("must not run"))) + monkeypatch.setattr(anon_auth, "ensure_portal_identity", lambda **kw: (_ for _ in ()).throw(AssertionError("must not run"))) assert _call("free_tier.provision") == {"has_guest": False, "enabled": False} diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 458c4daf31775..5c303c57aec8d 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -98,23 +98,16 @@ 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. - 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. + A read: with no Nous identity there is no bearer and the answer is None. The free-tier identity + is created by the boot bootstrap (``hermes_cli.free_tier_bootstrap``), never on a token-read + path (NS-845 Q1.2). A retired free-tier credential IS replaced here, once: that is the explicit + dead-credential rule, shared with inference. """ 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 + 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 @@ -140,7 +133,7 @@ def _replace_dead_guest_token(dead_state: dict) -> Optional[str]: clear_dead_guest("anon_credential_dead", dead_token=dead_state.get("anon_token")) try: - if ensure_portal_identity(blocking=True) is None: + if ensure_portal_identity(explicit=True) is None: return None return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS)) except Exception as exc: diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py index 9458478448c3b..424fb48210395 100644 --- a/tui_gateway/methods_config.py +++ b/tui_gateway/methods_config.py @@ -266,12 +266,26 @@ def _readiness_check(rid, params, probe): @method("setup.status") def _(rid, params: dict) -> dict: - """Loose provider check; ``profile`` (optional) scopes it to that profile's home.""" + """Loose provider check; ``profile`` (optional) scopes it to that profile's home. + + For the launch profile the answer is the boot bootstrap's record (``free_tier_bootstrap``): + the call blocks up to ``SETUP_READY_WAIT_SECONDS`` for it, so a client's first poll lands after + the free-tier identity exists (or has been refused) rather than racing the mint. If the record + is still missing after the wait, or a named profile is asked about, today's live probe answers. + The record's fields ride along additively (``ready``, ``free_tier``, ``other_providers``).""" try: from hermes_cli.main import _has_any_provider_configured - return _readiness_check(rid, params, lambda profile, scoped: { - "provider_configured": bool(_has_any_provider_configured(strict_profile_scope=bool(profile))), - **scoped}) + from hermes_cli.free_tier_bootstrap import wait_for_record + + def probe(profile, scoped): + record = None if profile else wait_for_record() + if record is None: + return {"provider_configured": bool(_has_any_provider_configured(strict_profile_scope=bool(profile))), + **scoped} + return {"provider_configured": record.provider_configured, "ready": True, + "free_tier": record.free_tier, "other_providers": record.other_providers, + "inference_provider": record.inference_provider, **scoped} + return _readiness_check(rid, params, probe) except Exception as e: return _err(rid, 5016, str(e)) diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py index 777d54d6fea0a..c71590ce5de73 100644 --- a/tui_gateway/methods_free_tier.py +++ b/tui_gateway/methods_free_tier.py @@ -1,6 +1,7 @@ """Nous free-tier JSON-RPC handlers: a renderer reads the profile's local auth state (pull); nothing -is pushed. ``free_tier.status`` answers from the auth store with zero network; ``free_tier.provision`` -is the explicit set-up request (the guided setup's first step); ``free_tier.ack_notice`` +is pushed except the boot bootstrap's one ``setup.ready`` event. ``free_tier.status`` answers from the +auth store with zero network and zero side effects; ``free_tier.provision`` is the explicit retry when +the boot bootstrap could not create the identity (desktop-only entry); ``free_tier.ack_notice`` persists the one-time notice flag on the free-tier identity itself, so it dies with that identity. Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare. """ @@ -19,23 +20,17 @@ @_profile_scoped def _(rid, params: dict) -> dict: """``{has_guest, enabled, available, notice_pending, model, label}`` for the focused profile. - ``available`` = an identity exists AND ``nous.guest`` is on: the free tier (connectors, and the - model when nothing else carries inference) is there for this install. Whether inference actually - runs on it is a ROUTE question answered by ``setup.runtime_check.free_tier``, never by this flag. - ``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity.""" + ``available`` = an identity exists AND the tier is on: the free tier (connectors, and the model + when nothing else carries inference) is there for this install. Whether inference actually runs + on it is a ROUTE question answered by ``setup.runtime_check.free_tier``, never by this flag. + ``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity. + + A pure read. The identity is created by the boot bootstrap (``free_tier_bootstrap``), never as + a side effect of a client polling this method (NS-845 Q1.2).""" try: from hermes_cli import anon_auth has_guest = anon_auth.has_guest() enabled = anon_auth.guest_enabled() - if enabled and not has_guest: - # The CLI sets the free tier up in the background beside an explicit provider at session - # setup (cli_agent_setup_mixin); a served backend has no such moment, so this read is the - # desktop's. One attempt per process, nothing waits on it: the answer below is the state - # as it stands, and a later read sees the identity once it lands. - try: - anon_auth.ensure_portal_identity(blocking=False) - except Exception as exc: - logger.debug("free tier background setup skipped: %s", exc) return _ok(rid, { "has_guest": has_guest, "enabled": enabled, "available": has_guest and enabled, "notice_pending": bool(has_guest and enabled and anon_auth.guest_notice_pending()), @@ -47,17 +42,18 @@ def _(rid, params: dict) -> dict: @method("free_tier.provision") @_profile_scoped def _(rid, params: dict) -> dict: - """Set the free tier up NOW for the focused profile: adopt the shared store's identity, else mint - one (blocking, short timeout). The guided setup on Hermes Desktop calls this before creating its - first chat, so the identity exists before any session asks for ``nous/welcome``. - ``{has_guest, enabled}``; ``error`` when the portal refused.""" + """Explicit retry of the free-tier set-up for the focused profile: adopt the shared store's + identity, else mint one (blocking, short timeout). The boot bootstrap normally did this already; + the desktop calls this when the record says the identity is missing (portal down at boot, gate + turned on later) and the user asks again. ``{has_guest, enabled}``; ``error`` when the portal + refused.""" try: from hermes_cli import anon_auth enabled = anon_auth.guest_enabled() error = None if enabled and not anon_auth.has_guest(): try: - anon_auth.provision_free_tier() + anon_auth.ensure_portal_identity(explicit=True) except Exception as exc: logger.info("free tier provisioning failed: %s", exc) error = str(exc) From b9bc170b34b4c76b37014bd1403e6510d560c48b Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 01:26:25 +0530 Subject: [PATCH 10/16] fix(credits): the welcome host is free-tier evidence, so a free-tier identity never sees "run /topup" A free-tier identity carries $0 by design, so the portal seed reports `paid_access=False` for it. `is_free_tier_model` did not know the welcome host, read that as a depleted account, and every free-tier turn ended with the credits-depleted notice telling the user to top up an account they do not have. Rule (4) in `is_free_tier_model`: a `base_url` on the Nous welcome host (`anon_auth.route_is_welcome_host`) is the free tier. The host is the evidence, not the model name: the paid inference host can serve `nous/welcome` to a named account and that account's depletion is real, so `("nous/welcome", )` stays False. Local data only, like the three rules above it. Restores the two contracts dropped by hermes-magic 674e11d1eaa (the prototype line ran without unit tests): the welcome host is free without any pricing evidence; the model name alone is not. The first is red without this fix. --- agent/credits_tracker.py | 16 ++++++++++++++++ tests/agent/test_credits_policy.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index cd6005cbd8b55..91f3114b7d84c 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -118,6 +118,17 @@ def _sticky_notice(text: str, level: str, key: str) -> AgentNotice: return AgentNotice(text=text, level=level, kind=CREDITS_NOTICE_KIND, key=key, id=key) +def _is_nous_welcome_route(base_url: str) -> bool: + """True when *base_url* is the Nous welcome host, which serves only the free tier. Local data only; + False wherever the free tier is not built in. The host is the evidence, not the model name: the paid + inference host can serve ``nous/welcome`` to a named account, and that account's depletion is real.""" + try: + from hermes_cli.anon_auth import route_is_welcome_host + except ImportError: + return False + return route_is_welcome_host(base_url) + + def is_free_tier_model(model: str, base_url: str = "") -> bool: """True when *model* is a Nous free-tier model, using ONLY local data: (1) ``:free`` suffix — canonical Nous free SKU marker; (2) ``stealth/`` prefix — stealth-preview SKUs are free without the suffix @@ -130,6 +141,11 @@ def is_free_tier_model(model: str, base_url: str = "") -> bool: return True if not base_url: return False + # (4) the Nous free tier: the welcome host serves only the free tier. A free-tier identity carries $0 + # by design, so the portal seed reports paid_access=False for it; that is not a depleted account, and + # "run /topup" means nothing to it. Local data only, same as the rules above. + if _is_nous_welcome_route(base_url): + return True try: from hermes_cli.models import _is_model_free from hermes_cli.models_pricing import peek_cached_pricing diff --git a/tests/agent/test_credits_policy.py b/tests/agent/test_credits_policy.py index 231d900ec1d4f..d7db358e8988b 100644 --- a/tests/agent/test_credits_policy.py +++ b/tests/agent/test_credits_policy.py @@ -339,6 +339,28 @@ def test_pricing_cache_peek_zero_priced_model(self, monkeypatch): assert is_free_tier_model("some/zero-priced", "https://inference-api.nousresearch.com/v1/") is True + def test_nous_welcome_host_is_free_without_pricing(self, monkeypatch): + """Anything the welcome host serves is the free tier, with no pricing lookup: the portal seeds + paid_access=False for a free-tier identity ($0 by design), and that must never raise + credits.depleted ("run /topup") on a surface that cannot top up.""" + from agent.credits_tracker import is_free_tier_model + from hermes_cli import models_pricing + + monkeypatch.setattr(models_pricing, "_pricing_cache", {}) + assert is_free_tier_model("nous/welcome", "https://welcome-api.nousresearch.com/v1") is True + assert is_free_tier_model("some/other", "https://welcome-api.nousresearch.com") is True + + def test_paid_nous_host_still_needs_pricing_evidence(self, monkeypatch): + """The free-tier rule is the host, not the model name: the paid inference host can serve + nous/welcome to a named account, and a depleted named account still sees the notice.""" + from agent.credits_tracker import is_free_tier_model + from hermes_cli import models_pricing + + monkeypatch.setattr(models_pricing, "_pricing_cache", {}) + assert is_free_tier_model("nous/welcome", "https://inference-api.nousresearch.com/v1") is False + assert is_free_tier_model("some/paid", "https://inference-api.nousresearch.com/v1") is False + assert is_free_tier_model("nous/welcome", "") is False + def test_exception_fails_open_to_false(self, monkeypatch): from agent.credits_tracker import is_free_tier_model import hermes_cli.models as models_mod From 0e272bde42adac40095a66a554266e3fcddfecf5 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 01:32:04 +0530 Subject: [PATCH 11/16] fix(copy): free-tier text stops promising a connector transfer and never names the config key Sign-in copy on every surface said "Sign in to keep your connectors" and ended with "Your connectors are kept." The transfer registry that would make that true is empty (NS-821): nothing carries over today. The copy now says what signing in does give ("unlock more models and tools") and the completion line names the account, not a transfer. The docs page loses the "connectors carry over" paragraph for the same reason. The picker's off-state line exposed `nous.guest: false` and the word "guest"; user copy names the free tier only (R-USR-1). The docs page gains the pre-rollout note: until GA nothing on it happens without `HERMES_GUEST_ONBOARDING=1`. Its "first command mints" and "replaced on next use" sentences now describe the boot bootstrap. zh is a strict locale: the `freeTier` block was English placeholder text copied from `en`; it is now Chinese. `connectorsKept` is renamed `completedBody` since it no longer talks about connectors. --- .../app/settings/billing/use-billing-state.ts | 2 +- .../free-tier/sign-in-dialog.test.tsx | 2 +- .../components/free-tier/sign-in-dialog.tsx | 2 +- apps/desktop/src/i18n/en.ts | 8 +- apps/desktop/src/i18n/types.ts | 2 +- apps/desktop/src/i18n/zh.ts | 78 +++++++++---------- hermes_cli/anon_sign_in.py | 9 +-- hermes_cli/model_setup_flows.py | 2 +- tests/gateway/test_login_command.py | 6 +- tests/hermes_cli/test_anon_upgrade.py | 2 +- tests/hermes_cli/test_login_cli_command.py | 8 +- website/docs/reference/slash-commands.md | 4 +- website/docs/user-guide/free-tier.md | 35 +++++---- 13 files changed, 83 insertions(+), 77 deletions(-) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index d333dbf3dca99..c30a5816ac4d3 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -331,7 +331,7 @@ function freeTierView(billing: BillingStateResponse): BillingView { return { notice: { action: { label: 'Sign in', onSelect: openFreeTierSignIn }, - message: 'Sign in to keep your connectors and unlock more.', + message: 'Sign in with a Nous account to unlock more models and tools.', title: "You're on the Nous free tier", tone: 'info' }, diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx index 8918be7a855d1..0d67ac637010f 100644 --- a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx @@ -74,7 +74,7 @@ describe('FreeTierSignInDialog', () => { }) await waitFor(() => expect(screen.getByText('Signed in as someone@example.com')).toBeTruthy()) - expect(screen.getByText('Your connectors are kept.')).toBeTruthy() + expect(screen.getByText('Your account now carries inference and tools.')).toBeTruthy() expect(screen.getByText('Hermes-4-405B')).toBeTruthy() }) }) diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx index 2a765e49b4134..1983739b8a847 100644 --- a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx +++ b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx @@ -147,7 +147,7 @@ export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProp {state.status === 'completed' && ( diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0948fa36181d1..959dad2026c91 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -3276,7 +3276,7 @@ export const en: Translations = { freeTier: { providerRowTitle: 'Nous · free tier', - providerRowPitch: 'Sign in to keep your connectors and unlock more.', + providerRowPitch: 'Sign in with a Nous account to unlock more models and tools.', readyTitle: 'Hermes is ready.', readyCaption: 'Free · connectors included', begin: 'Begin', @@ -3288,7 +3288,7 @@ export const en: Translations = { dismiss: 'Dismiss', statusLabel: model => `Nous · free tier · ${model}`, signIn: 'Sign in', - signInHeading: 'Sign in to keep your connectors and unlock more.', + signInHeading: 'Sign in with a Nous account to unlock more models and tools.', settingUp: 'Setting up free inference…', codeBody: 'Enter this code in your browser to finish signing in.', copyLink: 'Copy link', @@ -3298,7 +3298,7 @@ export const en: Translations = { finishingBody: 'Approved in the browser. Collecting your account tokens.', signedInAs: email => `Signed in as ${email}`, signedIn: 'Signed in.', - connectorsKept: 'Your connectors are kept.', + completedBody: 'Your account now carries inference and tools.', defaultModel: 'Default model', change: 'Change', done: 'Done', @@ -3310,7 +3310,7 @@ export const en: Translations = { supersededBody: 'A newer sign-in code replaced this one.', timedOutHeading: 'Sign-in timed out', timedOutBody: 'The code was not used in time. You are still on the free tier.', - retiredBody: 'This free-tier identity was already used or expired; a new one is set up on next use.', + retiredBody: 'This free-tier identity was already used or expired; a new one is set up on the next start.', errorBody: 'Sign-in did not complete; run it again.', alreadySignedInHeading: 'Already signed in.', alreadySignedInBody: 'This Hermes is already signed in to a Nous account.' diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 0ebca0ec6a284..3820b8b181f0a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2829,7 +2829,7 @@ export interface Translations { finishingBody: string signedInAs: (email: string) => string signedIn: string - connectorsKept: string + completedBody: string defaultModel: string change: string done: string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index ae9a80e520df4..ff405d9d97a4b 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3414,45 +3414,45 @@ export const zh: Translations = { // Not yet translated — English fallbacks so the free-tier surfaces stay // readable until a zh pass lands. freeTier: { - providerRowTitle: 'Nous · free tier', - providerRowPitch: 'Sign in to keep your connectors and unlock more.', - readyTitle: 'Hermes is ready.', - readyCaption: 'Free · connectors included', - begin: 'Begin', - signInInstead: 'Sign in with a Nous account instead', - otherProviders: 'Other providers', - stripTitle: 'Free Nous inference and connectors are now available.', - stripBody: 'Open the model picker to try them, or sign in with a Nous account.', - openModelPicker: 'Open model picker', - dismiss: 'Dismiss', - statusLabel: model => `Nous · free tier · ${model}`, - signIn: 'Sign in', - signInHeading: 'Sign in to keep your connectors and unlock more.', - settingUp: 'Setting up free inference…', - codeBody: 'Enter this code in your browser to finish signing in.', - copyLink: 'Copy link', - doNotShare: 'Do not share this code.', - waiting: 'Waiting for sign-in…', - finishingHeading: 'Finishing sign-in…', - finishingBody: 'Approved in the browser. Collecting your account tokens.', - signedInAs: email => `Signed in as ${email}`, - signedIn: 'Signed in.', - connectorsKept: 'Your connectors are kept.', - defaultModel: 'Default model', - change: 'Change', - done: 'Done', - notNow: 'Not now', - tryAgain: 'Try again', - startAgain: 'Start again', - didNotComplete: 'Sign-in did not complete', - rejectedBody: 'Sign-in was rejected in the browser. You are still on the free tier.', - supersededBody: 'A newer sign-in code replaced this one.', - timedOutHeading: 'Sign-in timed out', - timedOutBody: 'The code was not used in time. You are still on the free tier.', - retiredBody: 'This free-tier identity was already used or expired; a new one is set up on next use.', - errorBody: 'Sign-in did not complete; run it again.', - alreadySignedInHeading: 'Already signed in.', - alreadySignedInBody: 'This Hermes is already signed in to a Nous account.' + providerRowTitle: 'Nous · 免费层', + providerRowPitch: '登录 Nous 账户以解锁更多模型和工具。', + readyTitle: 'Hermes 已就绪。', + readyCaption: '免费 · 含连接器', + begin: '开始', + signInInstead: '改为登录 Nous 账户', + otherProviders: '其他提供方', + stripTitle: '免费的 Nous 推理和连接器现已可用。', + stripBody: '打开模型选择器试用,或登录 Nous 账户。', + openModelPicker: '打开模型选择器', + dismiss: '关闭', + statusLabel: model => `Nous · 免费层 · ${model}`, + signIn: '登录', + signInHeading: '登录 Nous 账户以解锁更多模型和工具。', + settingUp: '正在设置免费推理…', + codeBody: '在浏览器中输入此代码以完成登录。', + copyLink: '复制链接', + doNotShare: '请勿分享此代码。', + waiting: '等待登录…', + finishingHeading: '正在完成登录…', + finishingBody: '已在浏览器中批准。正在获取账户令牌。', + signedInAs: email => `已登录为 ${email}`, + signedIn: '已登录。', + completedBody: '现在由你的账户提供推理和工具。', + defaultModel: '默认模型', + change: '更改', + done: '完成', + notNow: '暂不', + tryAgain: '重试', + startAgain: '重新开始', + didNotComplete: '登录未完成', + rejectedBody: '登录在浏览器中被拒绝。你仍在免费层。', + supersededBody: '一个更新的登录代码替换了此代码。', + timedOutHeading: '登录超时', + timedOutBody: '代码未在有效期内使用。你仍在免费层。', + retiredBody: '此免费层身份已被使用或已过期;下次启动时会重新设置。', + errorBody: '登录未完成;请重试。', + alreadySignedInHeading: '已登录。', + alreadySignedInBody: '此 Hermes 已登录 Nous 账户。' }, modelPicker: { diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py index d7e788ca83b7f..c1d677e21755d 100644 --- a/hermes_cli/anon_sign_in.py +++ b/hermes_cli/anon_sign_in.py @@ -11,7 +11,7 @@ from hermes_cli.auth_constants import httpx -UPGRADE_START = "Sign in to keep your connectors and unlock more." +UPGRADE_START = "Sign in with a Nous account to unlock more models and tools." 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." @@ -20,8 +20,8 @@ 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_retired": "This free-tier identity was already used or expired; a new one is set up on the next start.", + "account_not_anonymous": "This free-tier identity was already used or expired; a new one is set up on the next start.", "account_busy": "The transfer could not run; run the command again.", } _RETIRED_REASONS = frozenset({"account_retired", "account_not_anonymous"}) @@ -116,8 +116,7 @@ class Completed(SignInState): ok: ClassVar[bool] = True def _lines(self, no_default: str) -> str: - lines = [f"Signed in as {self.email}. Your connectors are kept." if self.email - else "Signed in. Your connectors are kept."] + lines = [f"Signed in as {self.email}." if self.email else "Signed in."] if self.model_changed: lines.append(f"Default model is now {self.model}." if self.model else no_default) return "\n".join(lines) diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index a3dbbe35fa5dd..45fc4f47e0ad5 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -292,7 +292,7 @@ def _login_then_offer_gateway(login_args, pconfig): 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.") + print("The Nous free tier is off for this install; 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, diff --git a/tests/gateway/test_login_command.py b/tests/gateway/test_login_command.py index da2b0b825f6b9..4fb522c074cc7 100644 --- a/tests/gateway/test_login_command.py +++ b/tests/gateway/test_login_command.py @@ -188,7 +188,7 @@ async def deliver(_source, text): assert delivered[-2:] == [ "Do not share this code. Waiting for sign-in, up to 1 minute.", - "Signed in as person@example.test. Your connectors are kept.\nDefault model is now model-1.", + "Signed in as person@example.test.\nDefault model is now model-1.", ] assert "push failed" in caplog.text @@ -447,7 +447,7 @@ async def test_the_sweep_is_skipped_when_the_model_did_not_change(monkeypatch): runner._evict_cached_agent.assert_not_called() runner.async_session_store.set_model_override.assert_not_awaited() assert runner._deliver_platform_notice.await_args_list[-1].args[1] == ( - "Signed in as person@example.test. Your connectors are kept.") + "Signed in as person@example.test.") @pytest.mark.asyncio @@ -460,7 +460,7 @@ async def test_a_completion_with_no_default_names_the_slash_command(monkeypatch) ) assert runner._deliver_platform_notice.await_args_list[-1].args[1] == ( - "Signed in as person@example.test. Your connectors are kept.\n" + "Signed in as person@example.test.\n" "No default model is set yet; run /model to pick one.") diff --git a/tests/hermes_cli/test_anon_upgrade.py b/tests/hermes_cli/test_anon_upgrade.py index 77807ade4df21..15ffa1df59bf7 100644 --- a/tests/hermes_cli/test_anon_upgrade.py +++ b/tests/hermes_cli/test_anon_upgrade.py @@ -160,7 +160,7 @@ def test_completed_promotion_signs_in_and_keeps_no_free_tier_fields(self, portal 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 + assert f"Signed in as {EMAIL}." 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}" diff --git a/tests/hermes_cli/test_login_cli_command.py b/tests/hermes_cli/test_login_cli_command.py index d914051728e14..1af19eca7e2b0 100644 --- a/tests/hermes_cli/test_login_cli_command.py +++ b/tests/hermes_cli/test_login_cli_command.py @@ -59,7 +59,7 @@ def test_the_cli_handler_prints_the_code_then_drains_off_thread(monkeypatch): ] assert workers[0][0].started is True assert workers[0][0].target() == ( - "Signed in as person@example.test. Your connectors are kept.\nDefault model is now model-1.") + "Signed in as person@example.test.\nDefault model is now model-1.") @pytest.mark.parametrize( @@ -178,7 +178,7 @@ def flow(**_kwargs): gate.set() threads[0].join(timeout=2) - assert "Signed in as person@example.test. Your connectors are kept." in old_buf.getvalue() + assert "Signed in as person@example.test." in old_buf.getvalue() assert new_buf.getvalue() == "" @@ -225,7 +225,7 @@ def flow(**_kwargs): assert old_buf.getvalue() == "" assert new_buf.getvalue() == "" assert " Sign-in" in output - assert any("Signed in as person@example.test. Your connectors are kept." in line for line in output) + assert any("Signed in as person@example.test." in line for line in output) def test_upgrade_guest_keeps_the_terminal_timeout(monkeypatch): @@ -255,6 +255,6 @@ def test_the_terminal_renderer_keeps_the_original_line_sequence(monkeypatch, cap " 2. If prompted, enter code: CODE-1", f" {anon_auth.UPGRADE_DO_NOT_SHARE}", anon_auth.UPGRADE_WAITING, - "Signed in as person@example.test. Your connectors are kept.", + "Signed in as person@example.test.", "Default model is now model-1.", ] diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 7557e2aabe08a..8e92c56f33495 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -133,7 +133,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/usage` | Show token usage, cost breakdown, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits / plan usage pulled live from the provider's API. | | `/topup` | Show your Nous balance and manage billing on the portal (replaces the old `/credits` and `/billing` commands). | | `/subscription` (alias: `/upgrade`) | **CLI only.** View your Nous plan and change it in the browser. | -| `/login` | Sign in with a Nous account, keeping your connectors. Runs off-turn: the consent link and code arrive in the session, and the sign-in settles when you approve it in the browser. See [Nous free tier](/user-guide/free-tier). | +| `/login` | Sign in with a Nous account. Runs off-turn: the consent link and code arrive in the session, and the sign-in settles when you approve it in the browser. See [Nous free tier](/user-guide/free-tier). | | `/insights` | Show usage insights and analytics (last 30 days) | | `/update` | Update Hermes Agent to the latest version. | | `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status (CLI-only summary view). | @@ -258,7 +258,7 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/sessions [all] [search ]` | List previous sessions for this chat; the active session appears with a `(current)` marker. `/sessions search ` filters by title/id match (most recently active first); `/sessions all` lists across origins (admin only — non-admins get a notice and the chat-scoped list). | | `/usage` | Show token usage, estimated cost breakdown (input/output), context window state, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits pulled live from the provider's API. | | `/topup` | Show your Nous balance and manage billing on the portal. | -| `/login` | Sign in with a Nous account, keeping your connectors. **Paired direct messages only** — in a group, channel, or broadcast-shaped platform Hermes refuses. On Slack use `/hermes login`. See [Nous free tier](/user-guide/free-tier). | +| `/login` | Sign in with a Nous account. **Paired direct messages only** — in a group, channel, or broadcast-shaped platform Hermes refuses. On Slack use `/hermes login`. See [Nous free tier](/user-guide/free-tier). | | `/whoami` | Show your slash command access level (admin / user). | | `/insights [days]` | Show usage analytics. | | `/reasoning [level\|show\|hide\|full\|clamp] [--global]` | Change reasoning effort (levels up to `max` / `ultra`) or toggle reasoning display (`full` / `clamp` included). `--global` persists to config. | diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index 297804f2c408a..124ecdbde2eb6 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -6,10 +6,17 @@ description: "What Hermes gives you before you add a key or sign in, how the fre # 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. +:::note Not on yet +The free tier is being rolled out. Until it is on for everyone, nothing on this page happens +unless the process was started with `HERMES_GUEST_ONBOARDING=1` in its environment; without it a +fresh install behaves exactly as before (the provider picker on first run). This note goes away +when the rollout completes. +::: + +A fresh Hermes install works before you paste an API key or sign in anywhere. When Hermes starts +it 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 @@ -54,9 +61,9 @@ The free tier is the last resort, never a preference. Any provider you configure | `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: +On an install that already has a provider, Hermes still sets the free tier up once at start so +connectors have something to authenticate with; your provider keeps doing inference. A one-time +notice says so: ```text Free Nous inference and connectors are now available. /model to try them, /login to sign in. @@ -95,12 +102,12 @@ hermes auth upgrade 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.` - If your default model was `nous/welcome`, a fourth line names the model your account now +3. Back in the terminal: `Signed in as you@example.com.` + If your default model was `nous/welcome`, a second line names the model your account now uses, for example `Default model is now upstage/solar-pro4:free.` -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. +Inference moves to your account's model catalog, paid tools unlock, and `hermes auth status` +shows your account instead of the free-tier line. `nous/welcome` stays with the free tier: an account that was using it lands on the recommended model for its plan (the same one a fresh `hermes model` pick would suggest), and a default model you chose yourself is left alone. If no recommendation is available at that moment, no default is @@ -129,7 +136,7 @@ The desktop app runs on the same free tier as the CLI and shows it in four place Signing in from any of those places opens one dialog. It shows a code and a link; open the link (or the browser the app opened), confirm in the portal, and the dialog ends with "Signed in as -you@example.com. Your connectors are kept." and the default model your account now uses. A +you@example.com." and the default model your account now uses. A sign-in you reject in the browser, a code that timed out, or a code replaced by a newer one each show their own message and leave you on the free tier. The model picker lists the free tier as one row, "Nous · free tier", with the single model `nous/welcome`; there is no sign-in action inside the @@ -165,7 +172,7 @@ needs it. | 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. | +| 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 at its next start. | | 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 @@ -180,7 +187,7 @@ itself. | `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 needs a Nous account.` | You called a paid Tool Gateway tool on the free tier. | `/login` in a chat, `hermes auth upgrade` in a terminal, 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. | +| The free tier stopped working after two weeks away | The free-tier identity expired (see below) and is replaced at the next start, or the next time a turn or connector finds it retired. | Nothing; start Hermes again. Connectors linked before the gap need to be linked again unless you had signed in. | ## Privacy From 6df20a3218e47a1dcc22570823692a2004673070 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 01:44:30 +0530 Subject: [PATCH 12/16] feat(desktop): the free-tier launch flag is decided once in Electron and stamped onto every backend spawn The Python backend reads `HERMES_GUEST_ONBOARDING` and treats exactly "1" as on. Until now nothing in the desktop set it, so a packaged app could never turn the free tier on, and a backend spawned by the app could disagree with the app about whether the tier was live. `electron/guest-onboarding.ts` owns the decision: `guestOnboardingEnabled` is true when the launch env has `HERMES_GUEST_ONBOARDING=1` or argv has `--guest-onboarding` (the packaged-app spelling). It is read ONCE at launch into a module constant. `desktopBackendSpawnEnv` wraps every backend env as the outermost call and writes the flag LAST, as "1" or an explicit "0", so no earlier spread (`process.env`, `backend.env`) can resurrect a stray value from the parent shell. Stamped onto all three spawn sites: the primary `serve` spawn, the pooled per-profile spawn, and the remote SSH `exec env ...` command (which gains ` HERMES_GUEST_ONBOARDING=1` only when on). The embedded terminal PTY and the backend probes are not backend spawns and do not get it: a `hermes --tui` typed in the pane must not mint. The renderer learns the same fact read-only through the existing `hermes:launch-flags` sync IPC (`guestOnboarding`) and preload (`window.hermesDesktop.guestOnboardingEnabled`). Ruling: NS-845 Q1.1 / Q2 (env var is the contract, `--guest-onboarding` maps to it in main). Two invariant tests on the pure helpers: only "1" or the argv flag enables; the spawn env carries "1"/"0" as the last word and preserves every other key. --- .../electron/guest-onboarding-flag.test.ts | 49 +++++++++ apps/desktop/electron/guest-onboarding.ts | 23 ++++ apps/desktop/electron/main.ts | 102 ++++++++++-------- apps/desktop/electron/preload.ts | 4 + apps/desktop/electron/remote-lifecycle.ts | 15 ++- apps/desktop/src/global.d.ts | 4 + 6 files changed, 151 insertions(+), 46 deletions(-) create mode 100644 apps/desktop/electron/guest-onboarding-flag.test.ts create mode 100644 apps/desktop/electron/guest-onboarding.ts diff --git a/apps/desktop/electron/guest-onboarding-flag.test.ts b/apps/desktop/electron/guest-onboarding-flag.test.ts new file mode 100644 index 0000000000000..95954fbb9acdc --- /dev/null +++ b/apps/desktop/electron/guest-onboarding-flag.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding' +import { buildSpawnCommand } from './remote-lifecycle' + +test('guestOnboardingEnabled: exactly "1" in env or --guest-onboarding on argv turns the free tier on', () => { + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '1' }), true) + assert.equal(guestOnboardingEnabled(['electron', '.', '--guest-onboarding'], {}), true) + + assert.equal(guestOnboardingEnabled([], {}), false) + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: 'true' }), false) + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '0' }), false) + assert.equal(guestOnboardingEnabled(['electron', '.', '--local'], { HERMES_GUEST_ONBOARDING: '' }), false) +}) + +test('desktopBackendSpawnEnv stamps the launch decision last and never lets an inherited value leak', () => { + const base = { + HERMES_HOME: '/tmp/home', + HERMES_DESKTOP: '1', + HERMES_GUEST_ONBOARDING: '1', + PATH: '/usr/bin' + } + + const on = desktopBackendSpawnEnv({ ...base, HERMES_GUEST_ONBOARDING: '0' }, true) + assert.equal(on.HERMES_GUEST_ONBOARDING, '1') + + const off = desktopBackendSpawnEnv(base, false) + assert.equal(off.HERMES_GUEST_ONBOARDING, '0', 'a stray inherited "1" must not turn the free tier on') + + for (const env of [on, off]) { + assert.equal(env.HERMES_HOME, base.HERMES_HOME) + assert.equal(env.HERMES_DESKTOP, base.HERMES_DESKTOP) + assert.equal(env.PATH, base.PATH) + } +}) + +test('remote SSH spawn command carries HERMES_GUEST_ONBOARDING=1 only when the launch decided on', () => { + const on = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: true }) + assert.match(on, /exec env HERMES_DESKTOP=1 HERMES_GUEST_ONBOARDING=1 /) + + const off = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: false }) + assert.match(off, /exec env HERMES_DESKTOP=1 /) + assert.doesNotMatch(off, /HERMES_GUEST_ONBOARDING/) + + const unset = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log' }) + assert.doesNotMatch(unset, /HERMES_GUEST_ONBOARDING/) +}) diff --git a/apps/desktop/electron/guest-onboarding.ts b/apps/desktop/electron/guest-onboarding.ts new file mode 100644 index 0000000000000..0610dd5c47051 --- /dev/null +++ b/apps/desktop/electron/guest-onboarding.ts @@ -0,0 +1,23 @@ +// The Nous free tier is gated by ONE launch-time decision. The Python backend +// reads HERMES_GUEST_ONBOARDING and treats exactly "1" as on; the desktop +// decides once at launch (env or `--guest-onboarding` argv) and stamps that +// answer onto every backend it spawns, so the app and its backends can never +// disagree about whether the free tier is live. + +export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING' +export const GUEST_ONBOARDING_FLAG = '--guest-onboarding' + +export function guestOnboardingEnabled( + argv: readonly string[] = process.argv, + env: NodeJS.ProcessEnv = process.env +): boolean { + return env[GUEST_ONBOARDING_ENV] === '1' || argv.includes(GUEST_ONBOARDING_FLAG) +} + +// Outermost wrapper for a backend spawn env: the flag is written LAST so no +// earlier spread (process.env, backend.env) can resurrect a stray value, and +// "off" is an explicit '0' rather than an absent key so a '1' inherited from +// the parent's environment cannot leak into a backend the launch decided off. +export function desktopBackendSpawnEnv(base: NodeJS.ProcessEnv, guestOnboarding: boolean): NodeJS.ProcessEnv { + return { ...base, [GUEST_ONBOARDING_ENV]: guestOnboarding ? '1' : '0' } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d0c2e18513a5a..fdf7239b7b5fc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -202,6 +202,7 @@ import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gatewa import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' import { clearStaleGitLocks } from './gitlock' +import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding' import { readAndConsumeHandoffResult } from './handoff-result' import { ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, @@ -903,6 +904,9 @@ const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || '' // nobody to answer a modal, so the active-work confirmation would hang the // caller instead of letting the process exit. Force quits set this. const SKIP_QUIT_CONFIRM = process.env.HERMES_DESKTOP_SKIP_QUIT_CONFIRM === '1' +// Nous free tier gate, decided ONCE here and stamped onto every backend spawn +// (desktopBackendSpawnEnv) and the renderer (hermes:launch-flags). +const GUEST_ONBOARDING = guestOnboardingEnabled() const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -10737,6 +10741,9 @@ async function bootstrapSshConnectionInner(profile, sshConfig, reuseToken, sourc probeReuseProof: sshProbeReuseProof, adoptServedToken: adoptServedDashboardToken, rememberLog: sshRememberLog, + // Same launch-time free-tier decision the local spawns get; the POSIX + // spawn command adds HERMES_GUEST_ONBOARDING=1 only when this is on. + guestOnboarding: GUEST_ONBOARDING, signal: lease.signal }) } catch (error: any) { @@ -12666,25 +12673,28 @@ async function spawnPoolBackend(profile, entry, opts: { forceLocal?: boolean; po backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, - env: { - ...process.env, - HERMES_HOME, - ...backend.env, - // Pin the gateway's tool/terminal cwd to the same directory we chose for - // the child process. Inherited TERMINAL_CWD (or a stale config bridge) - // can still point at the install dir even when spawn cwd is home. - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - // Exact parent identity lets the backend self-exit after an unclean - // Desktop death without mistaking a reused PID for its owner. If the - // optional marker probe fails, retain legacy PID-only tracking. - ...parentIdentityEnv, - HERMES_WEB_DIST: webDist, - ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) - }, + env: desktopBackendSpawnEnv( + { + ...process.env, + HERMES_HOME, + ...backend.env, + // Pin the gateway's tool/terminal cwd to the same directory we chose for + // the child process. Inherited TERMINAL_CWD (or a stale config bridge) + // can still point at the install dir even when spawn cwd is home. + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + // Exact parent identity lets the backend self-exit after an unclean + // Desktop death without mistaking a reused PID for its owner. If the + // optional marker probe fails, retain legacy PID-only tracking. + ...parentIdentityEnv, + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) + }, + GUEST_ONBOARDING + ), shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -13083,30 +13093,33 @@ async function startHermes() { backend.args, hiddenWindowsChildOptions({ cwd: hermesCwd, - env: { - ...process.env, - // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() - // resolves to the SAME location our resolveHermesHome() picked. Without - // this pin, Python falls back to ~/.hermes on every platform — fine on - // mac/linux (where our default matches), but on Windows our default is - // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. - // Mismatch would split config / sessions / .env / logs across two - // directories. install.ps1 sets HERMES_HOME via setx; the desktop - // can't reliably do that, so we set it inline for every spawn. - HERMES_HOME, - ...backend.env, - TERMINAL_CWD: hermesCwd, - HERMES_DASHBOARD_SESSION_TOKEN: token, - // Marks this dashboard backend as desktop-spawned so it runs the cron - // scheduler tick loop (the gateway isn't running under the app). - HERMES_DESKTOP: '1', - // Exact parent identity lets the backend self-exit after an unclean - // Desktop death without mistaking a reused PID for its owner. If the - // optional marker probe fails, retain legacy PID-only tracking. - ...parentIdentityEnv, - HERMES_WEB_DIST: webDist, - ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) - }, + env: desktopBackendSpawnEnv( + { + ...process.env, + // Explicitly pin HERMES_HOME for the child so Python's get_hermes_home() + // resolves to the SAME location our resolveHermesHome() picked. Without + // this pin, Python falls back to ~/.hermes on every platform — fine on + // mac/linux (where our default matches), but on Windows our default is + // %LOCALAPPDATA%\hermes, which differs from C:\Users\\.hermes. + // Mismatch would split config / sessions / .env / logs across two + // directories. install.ps1 sets HERMES_HOME via setx; the desktop + // can't reliably do that, so we set it inline for every spawn. + HERMES_HOME, + ...backend.env, + TERMINAL_CWD: hermesCwd, + HERMES_DASHBOARD_SESSION_TOKEN: token, + // Marks this dashboard backend as desktop-spawned so it runs the cron + // scheduler tick loop (the gateway isn't running under the app). + HERMES_DESKTOP: '1', + // Exact parent identity lets the backend self-exit after an unclean + // Desktop death without mistaking a reused PID for its owner. If the + // optional marker probe fails, retain legacy PID-only tracking. + ...parentIdentityEnv, + HERMES_WEB_DIST: webDist, + ...(readyFile ? { HERMES_DESKTOP_READY_FILE: readyFile } : {}) + }, + GUEST_ONBOARDING + ), shell: backend.shell, stdio: ['ignore', 'pipe', 'pipe'] }) @@ -17202,7 +17215,8 @@ ipcMain.on('hermes:translucency:support', event => { // only strips internal flags. ipcMain.on('hermes:launch-flags', event => { event.returnValue = { - localModels: process.argv.includes('--local') || process.platform === 'win32' || process.platform === 'darwin' + localModels: process.argv.includes('--local') || process.platform === 'win32' || process.platform === 'darwin', + guestOnboarding: GUEST_ONBOARDING } }) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index a8ebb1d71f1d2..09519f21c8cf2 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -18,6 +18,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { // Launch-flag fact: the app was started with --local, so the renderer may // show the local-models surfaces. Static for the window's lifetime. localModelsEnabled: launchFlags?.localModels === true, + // Launch-flag fact: the Nous free tier is on for this launch + // (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only; the same + // decision is stamped onto every backend the app spawns. + guestOnboardingEnabled: launchFlags?.guestOnboarding === true, getConnection: (profile, opts) => ipcRenderer.invoke('hermes:connection', profile, opts), // Registry-scoped backend resolution: { connectionId, profile } → descriptor. getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload), diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts index a9b2d9b4fd1bb..cf38b4752d8fb 100644 --- a/apps/desktop/electron/remote-lifecycle.ts +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -1065,7 +1065,7 @@ function buildSpawnCommand(hermesPath, profile, opts: any = {}) { const dashCmd = `ulimit -n ${REMOTE_NOFILE_SOFT_LIMIT} 2>/dev/null || true; ` + - `exec env HERMES_DESKTOP=1 ${hermes} ${profileArgs}${subCmd}` + `exec env HERMES_DESKTOP=1${opts.guestOnboarding === true ? ' HERMES_GUEST_ONBOARDING=1' : ''} ${hermes} ${profileArgs}${subCmd}` const detachedShell = `eval "exec $1>&-"; ${dashCmd} > ${logPath} 2>&1 & echo $!` const detachedSpawn = `child=$("$(command -v setsid || echo nohup)" sh -c ${shq(detachedShell)} hermes-update-child "$1" & echo $!)` @@ -1171,7 +1171,15 @@ async function scrapeReadyPort(ssh, logPath, { timeoutMs = DEFAULT_READY_TIMEOUT async function spawnRemoteDashboard( ssh, - { hermesPath, profile, token, ownershipId, hermesHome = '~/.hermes', assertInstallClear = async () => {} } + { + hermesPath, + profile, + token, + ownershipId, + hermesHome = '~/.hermes', + guestOnboarding = false, + assertInstallClear = async () => {} + } ) { if (!(await remoteSupportsSshOwnership(ssh, hermesPath))) { const err: any = new Error( @@ -1241,6 +1249,7 @@ async function spawnRemoteDashboard( tokenFilePath, logPath, hermesHome, + guestOnboarding, ownershipId, reservationNonce: spawnNonce, lockMetadata: { @@ -1390,6 +1399,7 @@ async function connect(deps) { adoptServedToken, rememberLog = () => {}, readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS, + guestOnboarding = false, signal } = deps @@ -1543,6 +1553,7 @@ async function connect(deps) { token: spawnToken, ownershipId, hermesHome, + guestOnboarding, assertInstallClear: () => assertRemoteInstallUpdateClear(ssh, hermesHome) }) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 0089b100df37d..c5538e3bc5241 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -323,6 +323,10 @@ declare global { /** Launch flag: the app was started with --local, enabling the * local-models GUI surfaces. Absent/false = every local surface hides. */ localModelsEnabled?: boolean + /** Launch flag: the Nous free tier is on for this launch + * (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only fact the + * main process also stamps onto every backend it spawns. */ + guestOnboardingEnabled?: boolean setTranslucency?: (payload: TranslucencyState) => void setKeepAwake?: (on: boolean) => void setDisableF12?: (blocked: boolean) => void From 3a0eb5dbf87252582758d991a9ab8b530a1f73fc Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 01:44:48 +0530 Subject: [PATCH 13/16] feat(desktop): the renderer learns free-tier readiness from one `setup.ready` push, not a 60 s poll The backend's boot bootstrap now announces `setup.ready` once, after it has created (or refused) the free-tier identity and resolved the inference route. The renderer used to discover both by polling `setup.status`, `setup.runtime_check` and `free_tier.status` every 60 s from `useStatusSnapshot`; a fresh install's chip, notice strip and onboarding overlay could sit stale for up to a minute after boot, and three RPCs a minute per window kept asking a question whose answer changes only at boundaries the backend already announces. `handleLifecycleEvent` routes `setup.ready` (active source only, like `skin.changed`) to `notifySetupReady()`, a one-shot tick atom in `live-sync.ts` beside the other change ticks. `useStatusSnapshot` listens to it and runs one readiness round at once (`setup.status` + `setup.runtime_check` + `free_tier.status`). The readiness legs also run once on open and on return from another app, as today. The 60 s tick keeps only `getStatus()`. `SetupStatusSnapshot` types the record's additive fields (`ready`, `free_tier`, `other_providers`, `inference_provider`); readiness semantics are unchanged and still key on `provider_configured` + `runtime_check`. Ruling: NS-845 Q1.2 (renderer half). Tests: the lifecycle branch fires one refresh from the active source and none from another; the snapshot hook's contract is three legs on open, one leg on the tick. --- .../gateway-event/lifecycle.test.ts | 105 ++++++++++++++++++ .../gateway-event/lifecycle.ts | 17 ++- .../shell/hooks/use-status-snapshot.test.ts | 54 ++++++++- .../app/shell/hooks/use-status-snapshot.ts | 84 +++++++++----- apps/desktop/src/lib/runtime-readiness.ts | 7 ++ apps/desktop/src/store/live-sync.ts | 10 ++ 6 files changed, 243 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts new file mode 100644 index 0000000000000..7d655fe74e653 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.test.ts @@ -0,0 +1,105 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useStatusSnapshot } from '@/app/shell/hooks/use-status-snapshot' +import { getStatus } from '@/hermes' +import { $setupReadyTick } from '@/store/live-sync' + +import { handleLifecycleEvent } from './lifecycle' +import type { GatewayEventContext } from './types' + +vi.mock(import('@/hermes'), async importOriginal => ({ + ...(await importOriginal()), + getStatus: vi.fn() +})) + +type GatewayRequester = (method: string, params?: Record) => Promise + +function setupReadyContext(fromActiveSource: boolean): GatewayEventContext { + const payload = { + error: '', + finished_at: 1_700_000_100, + free_tier: true, + has_identity: true, + inference_provider: 'nous', + other_providers: false, + provider_configured: true + } + + return { + deps: {} as GatewayEventContext['deps'], + event: { payload, type: 'setup.ready' }, + explicitSid: '', + fromActiveSource: () => fromActiveSource, + isActiveEvent: false, + occurredAt: 1_700_000_100, + payload: payload as GatewayEventContext['payload'], + scheduleConfigRefresh: vi.fn(), + sessionId: null + } +} + +async function flushAsync() { + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) +} + +/** Mount the status snapshot on an open gateway and return its requester with + * the open-time readiness round already consumed. */ +async function mountedStatusSnapshot() { + const requestGateway = vi.fn( + async (method: string) => (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never + ) + + renderHook(() => useStatusSnapshot('open', requestGateway as unknown as GatewayRequester)) + await flushAsync() + requestGateway.mockClear() + vi.mocked(getStatus).mockClear() + + return requestGateway +} + +function callsTo(requestGateway: ReturnType, method: string) { + return requestGateway.mock.calls.filter(([called]) => called === method) +} + +describe('handleLifecycleEvent setup.ready', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + vi.mocked(getStatus) + .mockReset() + .mockResolvedValue({} as never) + $setupReadyTick.set(0) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('claims the event and triggers one free-tier refresh plus one readiness evaluation from the active source', async () => { + const requestGateway = await mountedStatusSnapshot() + + expect(handleLifecycleEvent(setupReadyContext(true))).toBe(true) + await flushAsync() + + expect(callsTo(requestGateway, 'free_tier.status')).toHaveLength(1) + expect(callsTo(requestGateway, 'setup.runtime_check')).toHaveLength(1) + expect(callsTo(requestGateway, 'setup.status')).toHaveLength(1) + // The push is a readiness seam, not a status tick. + expect(getStatus).not.toHaveBeenCalled() + }) + + it('claims but ignores setup.ready from a non-active source', async () => { + const requestGateway = await mountedStatusSnapshot() + + expect(handleLifecycleEvent(setupReadyContext(false))).toBe(true) + await flushAsync() + + expect(requestGateway).not.toHaveBeenCalled() + expect($setupReadyTick.get()).toBe(0) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts index 2395498d9402d..16633910dceb8 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts @@ -6,6 +6,7 @@ import { notifyPetChanged, notifyPlatformsChanged, notifySessionsChanged, + notifySetupReady, type PetChangeMeta, setChangeEventsAvailable } from '@/store/live-sync' @@ -17,7 +18,7 @@ import { ingestBackendSkin } from '@/themes/backend-sync' import type { GatewayEventContext } from './types' -/** gateway.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */ +/** gateway.ready / setup.ready / skin.changed / change-watcher broadcasts / session.reclaimed. */ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { const { deps, event, payload, fromActiveSource } = ctx @@ -32,6 +33,20 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { return true } + if (event.type === 'setup.ready') { + // The boot bootstrap (hermes_cli/free_tier_bootstrap.py) resolved the + // free-tier identity and the inference route, and broadcast once. The + // payload is only a hint — the status snapshot re-reads `setup.status` / + // `setup.runtime_check` / `free_tier.status` through its own scoped + // requester so the chip, strip and onboarding react now rather than on + // the next ambient tick. Only the active source's boot matters here. + if (fromActiveSource()) { + notifySetupReady() + } + + return true + } + if (event.type === 'skin.changed') { // A runtime skin switch (Hermes activating an authored skin, or `/skin` // on another surface). Only the active source+profile's change repaints. diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index 820cc285c6f8d..f05979d30657b 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -2,6 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getStatus } from '@/hermes' +import { $setupReadyTick, notifySetupReady } from '@/store/live-sync' import { deferred } from '../../../test/deferred' @@ -25,6 +26,7 @@ beforeEach(() => { vi.mocked(getStatus) .mockReset() .mockResolvedValue({} as never) + $setupReadyTick.set(0) }) afterEach(() => { @@ -200,13 +202,15 @@ describe('useStatusSnapshot', () => { renderHook(() => useStatusSnapshot('open', requestGateway)) await flushAsync() - // Three legs per round: setup.status, setup.runtime_check, free_tier.status. + // Open runs the readiness legs once: setup.status, setup.runtime_check, free_tier.status. + expect(getStatus).toHaveBeenCalledOnce() expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) + expect(getStatus).toHaveBeenCalledOnce() expect(requestGatewayMock).toHaveBeenCalledTimes(3) await act(async () => { @@ -218,11 +222,55 @@ describe('useStatusSnapshot', () => { await act(async () => { await vi.advanceTimersByTimeAsync(59_999) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(3) + expect(getStatus).toHaveBeenCalledOnce() await act(async () => { await vi.advanceTimersByTimeAsync(1) }) - expect(requestGatewayMock).toHaveBeenCalledTimes(6) + + // The periodic tick is status-only: readiness and the free-tier verdict + // arrive by `setup.ready` push plus the one-shots on open and on return. + expect(getStatus).toHaveBeenCalledTimes(2) + expect(requestGatewayMock).toHaveBeenCalledTimes(3) + }) + + it('re-reads readiness and the free-tier verdict once per setup.ready, off the status tick', async () => { + const requestGatewayMock = vi.fn( + async (method: string) => + (method === 'setup.runtime_check' ? { ok: true } : { provider_configured: true }) as never + ) + + const requestGateway = requestGatewayMock as unknown as GatewayRequester + + renderHook(() => useStatusSnapshot('open', requestGateway)) + await flushAsync() + requestGatewayMock.mockClear() + vi.mocked(getStatus).mockClear() + + await act(async () => { + notifySetupReady() + await vi.advanceTimersByTimeAsync(0) + }) + + const methods = requestGatewayMock.mock.calls.map(([method]) => method) + expect(methods.filter(method => method === 'free_tier.status')).toHaveLength(1) + expect(methods.filter(method => method === 'setup.runtime_check')).toHaveLength(1) + expect(methods.filter(method => method === 'setup.status')).toHaveLength(1) + expect(getStatus).not.toHaveBeenCalled() + }) + + it('ignores setup.ready while the gateway is not open', async () => { + const requestGatewayMock = vi.fn(async () => ({}) as never) + const requestGateway = requestGatewayMock as unknown as GatewayRequester + + renderHook(() => useStatusSnapshot('connecting', requestGateway)) + await flushAsync() + + await act(async () => { + notifySetupReady() + await vi.advanceTimersByTimeAsync(0) + }) + + expect(requestGatewayMock).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index c0917e930977b..bf8b3b6b1634b 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import { getStatus } from '@/hermes' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier' +import { $setupReadyTick } from '@/store/live-sync' import type { StatusResponse } from '@/types/hermes' // Statusbar health is ambient chrome, not live data — nothing the user acts on @@ -39,33 +40,65 @@ export function useStatusSnapshot( const scheduleRefresh = () => { if (!cancelled) { - timer = window.setTimeout(() => void refresh(), REFRESH_MS) + timer = window.setTimeout(() => void refresh({ readiness: false }), REFRESH_MS) } } - const refresh = async () => { + const isViewed = () => // macOS commonly leaves an occluded BrowserWindow `visible`; focus is // the missing signal that prevents status + readiness RPCs while the // user is working in another app. - if (document.visibilityState !== 'visible' || !document.hasFocus()) { + document.visibilityState === 'visible' && document.hasFocus() + + // Inference readiness + the free-tier verdict. Not on the periodic tick: + // both change only at seams the backend announces (`setup.ready` at boot) + // or that this window crosses (open, return from another app), so they + // run once per seam instead of every 60s. + const refreshReadiness = async () => { + if (gatewayState !== 'open') { + return + } + + // The free-tier verdict is a local, zero-network read that writes + // straight to its own store and swallows its failures — nothing here + // waits on it or reads the result. + const [inferenceResult] = await Promise.allSettled([ + evaluateRuntimeReadiness(requestGateway), + refreshFreeTierStatus(requestGateway) + ]) + + if (cancelled || inferenceResult.status !== 'fulfilled') { + return + } + + const inference = inferenceResult.value + + if (inference.source !== 'fallback') { + // runtime_check/setup_status returned an authoritative boolean. + // A fallback means both RPCs failed or returned no boolean, so it + // is a transient/unknown transport state, not proof that inference + // became unconfigured. Keep the last authoritative result instead + // of flashing "Inference not ready" during a gateway flap. + setInferenceStatus(inference) + setFreeTierRoute(inference.freeTier) + } + } + + const refresh = async ({ readiness }: { readiness: boolean }) => { + if (!isViewed()) { scheduleRefresh() return } try { - // Wait for both legs before scheduling the next refresh. setInterval + // Wait for every leg before scheduling the next refresh. setInterval // allowed a slow runtime check to overlap with later polls, which // multiplied load on an already-busy gateway and let stale failures // race newer healthy results. - const [statusResult, inferenceResult] = await Promise.allSettled([ + const [statusResult] = await Promise.allSettled([ getStatus(), - gatewayState === 'open' ? evaluateRuntimeReadiness(requestGateway) : Promise.resolve(null), - // The free-tier verdict is a local, zero-network read, so it rides - // this cadence rather than earning a poll of its own. It writes - // straight to its own store and swallows its failures — nothing here - // waits on it or reads the result. - gatewayState === 'open' ? refreshFreeTierStatus(requestGateway) : Promise.resolve(null) + readiness ? refreshReadiness() : Promise.resolve() ]) if (cancelled) { @@ -75,43 +108,34 @@ export function useStatusSnapshot( if (statusResult.status === 'fulfilled') { setStatusSnapshot(statusResult.value) } - - if (inferenceResult.status === 'fulfilled') { - const inference = inferenceResult.value - - if (inference === null) { - setInferenceStatus(null) - } else if (inference.source !== 'fallback') { - // runtime_check/setup_status returned an authoritative boolean. - // A fallback means both RPCs failed or returned no boolean, so it - // is a transient/unknown transport state, not proof that inference - // became unconfigured. Keep the last authoritative result instead - // of flashing "Inference not ready" during a gateway flap. - setInferenceStatus(inference) - setFreeTierRoute(inference.freeTier) - } - } } finally { scheduleRefresh() } } const onReturn = () => { - if (document.visibilityState === 'visible' && document.hasFocus() && !cancelled) { + if (isViewed() && !cancelled) { if (timer !== undefined) { window.clearTimeout(timer) } - void refresh() + void refresh({ readiness: true }) } } + // `setup.ready` (routed by the gateway-event lifecycle handler for the + // active source only) says the boot bootstrap just settled the route: one + // readiness round now, so the chip/strip/onboarding move at once. Rides + // outside the status tick so it neither resets nor waits on the timer. + const unsubscribeSetupReady = $setupReadyTick.listen(() => void refreshReadiness()) + document.addEventListener('visibilitychange', onReturn) window.addEventListener('focus', onReturn) - void refresh() + void refresh({ readiness: true }) return () => { cancelled = true + unsubscribeSetupReady() document.removeEventListener('visibilitychange', onReturn) window.removeEventListener('focus', onReturn) diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts index e07c150842684..cf8d9665ea910 100644 --- a/apps/desktop/src/lib/runtime-readiness.ts +++ b/apps/desktop/src/lib/runtime-readiness.ts @@ -1,5 +1,12 @@ export interface SetupStatusSnapshot { provider_configured?: boolean + /** Additive launch-profile fields (newer backends only; absent on older + * ones). Carried for consumers that read the record — readiness itself + * still keys on `provider_configured` + `setup.runtime_check`. */ + ready?: boolean + free_tier?: boolean + other_providers?: boolean + inference_provider?: string } export interface RuntimeCheckSnapshot { diff --git a/apps/desktop/src/store/live-sync.ts b/apps/desktop/src/store/live-sync.ts index 844c4d39f2b21..d06bc35c0e488 100644 --- a/apps/desktop/src/store/live-sync.ts +++ b/apps/desktop/src/store/live-sync.ts @@ -33,6 +33,12 @@ export interface PetChangeMeta { export const $petChange = atom<{ meta?: PetChangeMeta; tick: number }>({ tick: 0 }) +/** `setup.ready` — the boot bootstrap (free-tier identity + provider resolution) + * finished, so inference readiness and the free-tier verdict may have just + * changed. One-shot: the status snapshot re-reads both legs once instead of + * waiting for its next ambient tick. */ +export const $setupReadyTick = atom(0) + export function setChangeEventsAvailable(available: boolean): void { $changeEventsAvailable.set(available) } @@ -57,6 +63,10 @@ export function notifyPairingChanged(): void { $pairingChangeTick.set($pairingChangeTick.get() + 1) } +export function notifySetupReady(): void { + $setupReadyTick.set($setupReadyTick.get() + 1) +} + /** Reset on gateway wipe/reconnect — a new backend re-advertises capability on * its own gateway.ready, and stale ticks must not fire refreshes into stores * the wipe just cleared. */ From c25611b3740ac2be2b2b3f2155ca1799b512d409 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 01:52:16 +0530 Subject: [PATCH 14/16] fix(cli): the banner names the free tier's model instead of "no model configured" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The welcome banner prints before credentials resolve, so on a fresh install `model` is empty and the banner said, in red, "no model configured — run /model or hermes setup". Under the free tier that is false: the route is already known from local state (identity on disk, tier on), and the first message will run on `nous/welcome`. `_banner_left_lines` now asks the route the same question when `model` is empty (`guest_carries_inference()`, a local read) and shows `welcome · Nous Research`. When nothing resolves the red line stays. Ruling: NS-845 ("the banner's 'no model configured' line reads the resolved route"). Live: fresh HERMES_HOME + fake portal, gate on -> `welcome · Nous Research`; gate off -> the red line, zero portal calls. --- hermes_cli/banner.py | 14 ++++++++++++++ tests/hermes_cli/test_banner.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index b4410592e6f28..c97d5dd15c2c3 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -755,6 +755,16 @@ def _active_profile_name() -> Optional[str]: return get_active_profile_name() +def _route_model_for_banner(provider: Any) -> str: + """The model the resolved route will actually serve when config names none: today only the Nous + free tier (welcome host -> ``nous/welcome``). Read from the boot record and local auth state; + no network. Empty when nothing resolves, so the caller keeps its "no model configured" line.""" + if (provider or "auto").strip().lower() not in ("auto", "nous"): + return "" + from hermes_cli.anon_auth import GUEST_MODEL, guest_carries_inference + return GUEST_MODEL if guest_carries_inference() else "" + + def _banner_left_lines(model: str, cwd: str, session_id, context_length, provider, *, accent: str, dim: str) -> list: """Model / cwd / session lines under the hero art.""" def _dim_sep(label: str) -> str: @@ -762,6 +772,10 @@ def _dim_sep(label: str) -> str: lines = [] ctx_str = _dim_sep(f"{_format_context_length(context_length)} context") if context_length else "" nous_str = _dim_sep("Nous Research") + if not (model or "").strip(): + # Credentials resolve lazily on the first message; the banner prints first. Ask the route + # the same question so a fresh free-tier install shows its model, not a red "unconfigured". + model = _quiet(lambda: _route_model_for_banner(provider), "") or model if (provider or "").strip().lower() == "moa": # MoA virtual provider: ``model`` is a preset name; show it with its aggregator. agg_label = _quiet(lambda: _moa_aggregator_label(model), "") diff --git a/tests/hermes_cli/test_banner.py b/tests/hermes_cli/test_banner.py index 9127935dd6d0f..4ac834166c603 100644 --- a/tests/hermes_cli/test_banner.py +++ b/tests/hermes_cli/test_banner.py @@ -84,3 +84,28 @@ def test_build_welcome_banner_non_moa_unchanged(tmp_path, monkeypatch): out = console.export_text() assert "claude-opus-4.8" in out assert "MoA:" not in out + + +def test_empty_model_shows_the_free_tier_route_when_it_carries_inference(tmp_path, monkeypatch): + """The banner prints before credentials resolve, so ``model`` is empty on a fresh install. On the + free tier the route is known locally (identity on disk + tier on): the banner shows its model. + When nothing resolves the red "no model configured" line stays.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + import hermes_cli.anon_auth as anon_auth + + def render(carries: bool) -> str: + with ( + patch.object(model_tools, "check_tool_availability", return_value=([], [])), + patch.object(banner, "get_available_skills", return_value={}), + patch.object(banner, "get_update_result", return_value=None), + patch.object(tools.mcp_tool_discovery, "get_mcp_status", return_value=[]), + patch.object(anon_auth, "guest_carries_inference", return_value=carries), + ): + console = Console(record=True, force_terminal=False, color_system=None, width=160) + banner.build_welcome_banner(console=console, model="", cwd="/tmp/project", tools=[], + enabled_toolsets=[], provider="auto") + return console.export_text() + + assert "welcome" in render(True) and "no model configured" not in render(True) + assert "no model configured" in render(False) From da4be17783f594dc1f8ec61229ca50a64c3e8f1d Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Tue, 8 Sep 2026 15:48:46 +1000 Subject: [PATCH 15/16] fix(aux): vision on the free tier uses nous/welcome too The text-only modality on the gateway's `nous/welcome` row is DeepSeek V4 Flash's, the backing model until the repoint; `z-ai/glm-5.3-flash` is natively multimodal and the repoint declares the welcome row `text+image->text`. Skipping Nous for vision on the welcome host would have sent every image step past the free tier for no reason, so the auxiliary client pins the route's one model for every lane. A backing model that takes no images answers with the upstream's own error, which the ladder handles as it always has. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 7456e028faba55480db43015dc2c8df3e393a415) --- agent/auxiliary_client.py | 10 ++++------ tests/agent/test_nous_welcome_client_contract.py | 9 +++++---- website/docs/user-guide/free-tier.md | 5 ++--- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 3401b58c4f7c4..21fbc2150784a 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2249,15 +2249,13 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: (nous or {}).get("inference_base_url") or os.getenv("NOUS_INFERENCE_BASE_URL", _NOUS_DEFAULT_BASE_URL) ).rstrip("/") lane = "vision" if vision else "text" - # The free tier's host serves exactly one model (text only): asking it for the Portal's - # recommended aux model is a guaranteed 429 ``model_not_free``. Pin the route's model instead, - # and leave vision to the next provider in the ladder (the welcome model takes no images). + # The free tier's host serves exactly one model, for every lane: asking it for the Portal's + # recommended aux model is a guaranteed 429 ``model_not_free``. Pin the route's model instead. + # Vision rides the same id (the backing model is multimodal; a backing that is not answers + # the request with the upstream's own error, which the ladder handles like any other). from hermes_cli.anon_auth import GUEST_MODEL, route_is_welcome_host global auxiliary_is_nous if route_is_welcome_host(base_url): - if vision: - logger.debug("Auxiliary/vision: Nous free tier serves no vision model; skipping Nous") - return None, None auxiliary_is_nous = True logger.debug("Auxiliary/%s: Nous free tier; using %s", lane, GUEST_MODEL) return _create_openai_client(api_key=api_key, base_url=base_url), GUEST_MODEL diff --git a/tests/agent/test_nous_welcome_client_contract.py b/tests/agent/test_nous_welcome_client_contract.py index b7863d466f016..356ea223bd4cc 100644 --- a/tests/agent/test_nous_welcome_client_contract.py +++ b/tests/agent/test_nous_welcome_client_contract.py @@ -36,12 +36,13 @@ def test_text_aux_on_welcome_host_pins_the_welcome_model(self): recommended.assert_not_called() # the Portal's pick would be a guaranteed 429 model_not_free assert ac.auxiliary_is_nous is True - def test_vision_aux_on_welcome_host_skips_nous(self): + def test_vision_aux_on_welcome_host_uses_the_same_model(self): import agent.auxiliary_client as ac with patch.object(ac, "_resolve_nous_runtime_api", return_value=("jwt", WELCOME)), \ - patch.object(ac, "_create_openai_client") as create: - assert ac._try_nous(vision=True) == (None, None) - create.assert_not_called() + patch.object(ac, "_create_openai_client", return_value="client"), \ + patch("hermes_cli.models.get_nous_recommended_aux_model") as recommended: + assert ac._try_nous(vision=True) == ("client", anon_auth.GUEST_MODEL) + recommended.assert_not_called() def test_paid_host_keeps_the_portal_recommendation(self): import agent.auxiliary_client as ac diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index 124ecdbde2eb6..c03e502406b34 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -30,9 +30,8 @@ answers on the `nous/welcome` model. Nothing to configure, no wizard to click th "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. -Background work (conversation compaction, chat titles, and similar) runs on `nous/welcome` too. -Image understanding is not part of that model: on the free tier, a step that needs to look at an -image falls through to any other provider you have configured, or is skipped. +Background work (conversation compaction, chat titles, image understanding, and similar) runs on +`nous/welcome` too. 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 From 8b31d1bc6aa95150792cd78c04419cd7ac443ca2 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 11 Sep 2026 03:06:51 +0530 Subject: [PATCH 16/16] fix(gateway): hermes gateway run is a boot owner of the free tier too Rung 5 made every demand-time free-tier site a read: resolve_provider, the connector token, the /login precondition. That is only correct if every process that can reach those sites ran the bootstrap first. The CLI (cmd_chat) and hermes serve (_lifespan) did; the standalone messaging gateway did not. A fresh HERMES_HOME with the gate on and `hermes gateway run` reached provider resolution with no identity to consume, and /login returned Unavailable. Reported by @andrexibiza on #107697 (P1). GatewayRunner.start now runs `free_tier_bootstrap.run_bootstrap` on an executor thread right after startup recovery and BEFORE any adapter connects, so a fast first DM cannot arrive with nothing to resolve. It is its own step, not part of the turn-machinery warm-up: the warm-up is an optimisation with an off switch (HERMES_STARTUP_WARMUP_TIMEOUT<=0); the bootstrap is correctness and must always run. With the gate unset it is a local inventory and no network. Live, real GatewayRunner.start against a fake portal in a fresh home: gate on -> 1 create, identity persisted, resolve_runtime_provider=nous, /login precondition sees the identity gate off -> 0 portal calls, no identity, no_provider_configured Before the fix the gate-on row was identical to the gate-off row. Test: the bootstrap seam runs before _start_prefilter_platforms and delegates to the one creator. Red on 5554eb6993 (no seam), green here. --- gateway/run_startup.py | 13 +++++ tests/gateway/test_free_tier_gateway_boot.py | 52 ++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/gateway/test_free_tier_gateway_boot.py diff --git a/gateway/run_startup.py b/gateway/run_startup.py index 29fd2c252fd31..8ce482df1eb88 100644 --- a/gateway/run_startup.py +++ b/gateway/run_startup.py @@ -90,6 +90,13 @@ async def _drain_startup_restore_queue(self) -> int: drained += 1 return drained + @staticmethod + def _start_free_tier_bootstrap() -> None: + """One bootstrap per process. `run_bootstrap` already records its own failure in the boot record + and never raises, so this is a plain call; it exists as a method so tests can seam it.""" + from hermes_cli.free_tier_bootstrap import run_bootstrap + run_bootstrap(announce=False) + def _start_startup_warmup(self) -> None: """Kick off the boot turn-machinery warm-up so it overlaps the network-bound platform connects; ``_finish_startup_restore`` awaits it (bounded).""" @@ -1317,6 +1324,12 @@ async def start(self) -> bool: if self._start_check_access_policy(): return True await self._start_recover_previous_run() + # The gateway is a boot owner of the Nous free tier, beside `cmd_chat` and `hermes serve`: every + # demand-time site (provider resolution, /login, the connector token) is a read that needs the + # identity to already exist. Blocking here, before any adapter connects, is what keeps a fast + # first DM from arriving with nothing to resolve. With the launch gate unset this is a local + # inventory and no network. + await asyncio.get_running_loop().run_in_executor(None, self._start_free_tier_bootstrap) # Serialize startup restore against inbound: adapters receive as soon as they connect, so inbound # queues until every synthetic resume turn has finished. self._startup_restore_in_progress = True diff --git a/tests/gateway/test_free_tier_gateway_boot.py b/tests/gateway/test_free_tier_gateway_boot.py new file mode 100644 index 0000000000000..793197f56b1ae --- /dev/null +++ b/tests/gateway/test_free_tier_gateway_boot.py @@ -0,0 +1,52 @@ +"""The messaging gateway is a boot owner of the Nous free tier. + +Rung 5 made every demand-time site a read (provider resolution, ``/login``, the connector token), so a +process that never runs the bootstrap can never have an identity. `cmd_chat` and `hermes serve` run it; +this file pins that `hermes gateway run` does too, and does it BEFORE any adapter connects, so a fast +first DM cannot arrive with nothing to resolve. +""" + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.run import GatewayRunner +import gateway.run_startup as run_startup + + +@pytest.mark.asyncio +async def test_gateway_boot_runs_the_free_tier_bootstrap_before_any_adapter_connects(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + order: list[str] = [] + + def fake_bootstrap() -> None: + order.append("bootstrap") + + async def fake_prefilter(self): + order.append("prefilter-platforms") + # No pending connects: startup exits cleanly at the no-connections check, which is exactly the + # path the cold cell drives. Shape mirrors the real return. + return (False, 0, [], []) + + monkeypatch.setattr(GatewayRunner, "_start_free_tier_bootstrap", staticmethod(fake_bootstrap)) + monkeypatch.setattr(run_startup.GatewayStartupMixin, "_start_prefilter_platforms", fake_prefilter) + + config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=False)}, + sessions_dir=tmp_path / "sessions", + ) + runner = GatewayRunner(config) + ok = await runner.start() + + assert ok is True + assert order[:2] == ["bootstrap", "prefilter-platforms"], order + + +def test_gateway_bootstrap_seam_calls_the_one_creator(monkeypatch): + """The seam delegates to `free_tier_bootstrap.run_bootstrap`; nothing else in the gateway may mint.""" + import hermes_cli.free_tier_bootstrap as ftb + calls: list[dict] = [] + monkeypatch.setattr(ftb, "run_bootstrap", lambda **kw: calls.append(kw)) + + GatewayRunner._start_free_tier_bootstrap() + + assert calls == [{"announce": False}]