Files
hermes-agent/hermes_cli/anon_sign_in.py
Robin Fernandes 2a94ca80e7 fix(free-tier): review round 2 — route-gate the allowance verdict, keep policy/billing 403s, pool the provision RPC, guard the retry race
Should-fix
- _is_genuine_nous_rate_limit: the structured rate_limited verdict counts only
  on the welcome host; a paid-host 429 keeps main's exhausted-bucket rule.
- _nous_welcome_tier: the route-keyed dark-tier 403 applies only to a 403 that
  matches neither the content-policy nor the billing patterns, so a safety
  refusal or billing wall on the welcome host keeps its own recovery.
- free_tier.provision joins _LONG_HANDLERS (a forced mint + lock waits +
  re-inventory no longer block the RPC reader).
- retry_bootstrap_mint: under the lock, a build that found no identity never
  overwrites a record that has one (the loop racing the user's click).

Simplifications from the review
- _raise_for_anon_status is a (status, error) table; retryable derives from
  ANON_TERMINAL_CODES once (a bare 401 on sign-up now rides the ladder
  instead of dying for the process).
- classify_mint_exception is public and pure; the hand-built failure dict in
  free_tier.provision is gone (the memo is the one source).
- SetupRecord carries the memo payload as one `failure` dict instead of three
  unpacked fields.
- _welcome_surface_kind is a closed table with a "refused" default;
  _welcome_outage_copy excludes the classifier's `unknown` catch-all.
- FREE_TIER_RATE_LIMIT_CHAT is CARD + the sign-in tail, not a slice.
- Copy tests assert the contract (model named, tail present/absent) instead
  of freezing whole sentences.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:44:42 +05:30

460 lines
20 KiB
Python

"""Shared sign-in states, copy, and connector-preserving account promotion flow."""
from __future__ import annotations
import contextlib
import math
import time
from dataclasses import dataclass
from typing import Any, Callable, ClassVar, ContextManager, Dict, Iterator, Optional
from hermes_cli.auth_constants import httpx
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 = "That sign-in link has expired. Start again whenever you're ready."
UPGRADE_NOT_COMPLETED = "Sign-in didn't finish. Try again whenever you're ready."
UPGRADE_UNAVAILABLE = "The free tier is not available right now; run `hermes auth add nous` to sign in."
UPGRADE_REASON_COPY = {
"user_declined": "No problem, you're still on the free Nous service. Sign in whenever you're ready.",
"superseded": "A newer sign-in code replaced this one. Use the newest one, or start again.",
"account_retired": "Your session ended before the sign-in finished. A new one starts on its own; "
"sign in again whenever you're ready.",
"account_not_anonymous": "Your session ended before the sign-in finished. A new one starts on its own; "
"sign in again whenever you're ready.",
"account_busy": "Something's still finishing up on your account. Give it a few seconds, then try signing in again.",
}
_RETIRED_REASONS = frozenset({"account_retired", "account_not_anonymous"})
# Reasons a later attempt can succeed at: the desktop offers "try again" after the named wait.
RETRYABLE_SIGN_IN_REASONS = frozenset({"account_busy"})
# The account service was busy or unreachable mid sign-in (an ``anon_*`` code from
# ``anon_auth``): the identity is untouched, so the copy reassures before the way forward.
UPGRADE_SERVICE_BUSY = ("Signing in couldn't finish because the Nous service is busy. "
"Try again in {wait}. Your session is still here in the meantime.")
UPGRADE_SERVICE_UNREACHABLE = ("The Nous service couldn't be reached to finish signing you in. "
"Check your internet connection and try again. Your session is still here.")
UPGRADE_NO_DEFAULT_TERMINAL = "No default model is set yet; run `hermes model` to pick one."
UPGRADE_NO_DEFAULT_CHAT = "No default model is set yet; run /model to pick one."
UPGRADE_WAITING = "Waiting for sign-in..."
UPGRADE_WAITING_UP_TO = "Waiting for sign-in, up to {minutes}."
UPGRADE_CANCELLED = "\nSign-in cancelled."
UPGRADE_UNAVAILABLE_CHAT = "The free tier is not available right now. Try /login again in a moment."
LOGIN_COMMAND = "/login"
LOGIN_STARTING = "Starting sign-in..."
LOGIN_DM_ONLY = "Sign in from a direct message with Hermes."
LOGIN_BUSY_ELSEWHERE = "Another sign-in is already running on this Hermes. Try again in a few minutes."
LOGIN_NOT_ALLOWED = "Only an operator of this Hermes can sign it in."
# The card form (a surface with its own sign-in button) and the chat form (names /login).
FREE_TIER_RATE_LIMIT_CARD = (
"You've used up the allowance for chatting without signing in. It refreshes in {reset}. "
"Sign in for a bigger allowance, it's free.")
FREE_TIER_RATE_LIMIT_CHAT = FREE_TIER_RATE_LIMIT_CARD + " To sign in: /login."
def format_wait_line(expires_in: int) -> str:
"""The honest "up to N minutes" line; N = ceil(expires_in / 60), singular at 1."""
n = max(1, math.ceil(max(0, int(expires_in)) / 60))
return UPGRADE_WAITING_UP_TO.format(minutes=f"{n} minute" if n == 1 else f"{n} minutes")
# --- Sign-in states -----------------------------------------------------------------------------
#
# One sign-in composition (:func:`run_sign_in`) yields these; every surface is a renderer over them.
# Each state carries its own user copy, so no renderer ever maps a reason to a string: ``.copy`` is
# the in-chat form (never a raw exception, a URL or a ``hermes`` verb) and ``.copy_terminal`` the
# form a top-level terminal command prints.
@dataclass(frozen=True)
class SignInState:
"""One step of a sign-in. Every state carries its own user copy; renderers never map."""
kind: ClassVar[str] = ""
terminal: ClassVar[bool] = True # False only for Code and Waiting
ok: ClassVar[bool] = False # exit-code 0 / "this ended well"
precondition: ClassVar[bool] = False # True only for AlreadySignedIn and Unavailable
@property
def copy(self) -> str:
"""The in-chat form."""
return ""
@property
def copy_terminal(self) -> str:
"""The terminal form; differs for Completed, Failed and Unavailable."""
return self.copy
@dataclass(frozen=True)
class Code(SignInState):
"""The consent link and the sign-in code, plus the clock the caller may show."""
link: str
code: str
expires_in: int
interval: int
kind: ClassVar[str] = "code"
terminal: ClassVar[bool] = False
@property
def copy(self) -> str:
return UPGRADE_DO_NOT_SHARE
@property
def copy_with_wait(self) -> str:
"""The do-not-share line plus the honest "up to N minutes"; composed here, never in a renderer."""
return f"{UPGRADE_DO_NOT_SHARE} {format_wait_line(self.expires_in)}"
@dataclass(frozen=True)
class Waiting(SignInState):
kind: ClassVar[str] = "waiting"
terminal: ClassVar[bool] = False
@property
def copy(self) -> str:
return UPGRADE_WAITING
@dataclass(frozen=True)
class Completed(SignInState):
email: str = ""
model: str = ""
model_changed: bool = False
kind: ClassVar[str] = "completed"
ok: ClassVar[bool] = True
def _lines(self, no_default: str) -> str:
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)
@property
def copy(self) -> str:
return self._lines(UPGRADE_NO_DEFAULT_CHAT)
@property
def copy_terminal(self) -> str:
return self._lines(UPGRADE_NO_DEFAULT_TERMINAL)
@dataclass(frozen=True)
class Declined(SignInState):
kind: ClassVar[str] = "declined"
@property
def copy(self) -> str:
return UPGRADE_REASON_COPY["user_declined"]
@dataclass(frozen=True)
class Superseded(SignInState):
"""Stopped from outside before it could persist: a newer code, a cancel, or a shutdown."""
kind: ClassVar[str] = "superseded"
@property
def copy(self) -> str:
return UPGRADE_REASON_COPY["superseded"]
@dataclass(frozen=True)
class TimedOut(SignInState):
#: The enriched device-auth guidance, for a surface with room for it. Never shown in a chat.
detail: str = ""
kind: ClassVar[str] = "timed_out"
@property
def copy(self) -> str:
return UPGRADE_TIMED_OUT
@dataclass(frozen=True)
class Retired(SignInState):
"""The identity this sign-in started from is gone; run_sign_in already cleared it."""
kind: ClassVar[str] = "retired"
@property
def copy(self) -> str:
return UPGRADE_REASON_COPY["account_retired"]
@dataclass(frozen=True)
class Failed(SignInState):
"""``reason``: a promotion outcome reason (``account_busy``...), an ``anon_*`` code from the
account service (busy, paused, unreachable...), or "" for anything unnamed. ``retry_after``:
the wait the service asked for, in seconds (0 when it named none). ``retryable``: whether a
later attempt can succeed, so a renderer knows to offer "try again" and when."""
reason: str = ""
detail: str = ""
retry_after: float = 0.0
kind: ClassVar[str] = "failed"
@property
def retryable(self) -> bool:
from hermes_cli import anon_auth as _core
if self.reason in RETRYABLE_SIGN_IN_REASONS:
return True
return bool(self.reason.startswith("anon_") and self.reason not in _core.ANON_TERMINAL_CODES)
@property
def copy(self) -> str:
from hermes_cli import anon_auth as _core
ruled = UPGRADE_REASON_COPY.get(self.reason)
if ruled:
return ruled
if self.reason in _core.ANON_UNREACHABLE_CODES:
return UPGRADE_SERVICE_UNREACHABLE
if self.reason in (_core.ANON_RATE_LIMITED, _core.ANON_GATE_PAUSED):
return UPGRADE_SERVICE_BUSY.format(wait=_core.friendly_wait(self.retry_after or 60))
if self.reason in _core.ANON_FAILURE_COPY:
return _core.anon_failure_copy(self.reason, retry_after=self.retry_after)
return UPGRADE_NOT_COMPLETED
@property
def copy_terminal(self) -> str:
if self.reason and self.copy != UPGRADE_NOT_COMPLETED:
return self.copy
return f"Sign-in failed: {self.detail}" if self.detail else UPGRADE_NOT_COMPLETED
@dataclass(frozen=True)
class AlreadySignedIn(SignInState):
kind: ClassVar[str] = "already_signed_in"
ok: ClassVar[bool] = True
precondition: ClassVar[bool] = True
@property
def copy(self) -> str:
return UPGRADE_ALREADY_SIGNED_IN
@dataclass(frozen=True)
class Unavailable(SignInState):
detail: str = ""
kind: ClassVar[str] = "unavailable"
precondition: ClassVar[bool] = True
@property
def copy(self) -> str:
return UPGRADE_UNAVAILABLE_CHAT
@property
def copy_terminal(self) -> str:
return f"{UPGRADE_UNAVAILABLE} ({self.detail})" if self.detail else UPGRADE_UNAVAILABLE
def _failed_from_exception(exc: BaseException) -> Failed:
"""A ``Failed`` that keeps the account service's own verdict: the ``anon_*`` code and wait hint
an ``AuthError`` carries, or the wire's shape for a transport error. The raw detail never
reaches a chat; ``copy_terminal`` may show it when nothing better is known."""
from hermes_cli import anon_auth as _core
err = _core.classify_mint_exception(exc)
reason = str(err.code or "")
if reason == _core.ANON_SERVER_ERROR and not isinstance(exc, _core.AuthError):
# An unnamed local failure (a bad CA bundle, a lock timeout): keep today's generic copy
# and its terminal detail rather than blaming the service.
return Failed(reason="", detail=str(exc))
return Failed(reason=reason, detail=str(exc), retry_after=float(err.retry_after or 0.0))
def _outcome_state(outcome: Dict[str, Any], anon_token: str) -> SignInState:
"""The one reason -> state mapping in the tree, for a promotion that did not complete.
A retiring outcome clears the dead identity here, pinned to the token this attempt started
from, so a losing attempt can never remove a newer one.
"""
from hermes_cli import anon_auth as _core
status = str(outcome.get("status") or "unknown")
reason = str(outcome.get("reason") or "")
if status == "timeout":
return TimedOut()
if reason in _RETIRED_REASONS:
_core.clear_dead_guest("retired", dead_token=anon_token or None)
return Retired()
if reason == "user_declined":
return Declined()
if reason == "superseded":
return Superseded()
return Failed(reason=reason)
def _default_persist_guard(is_cancelled: Callable[[], bool]) -> Callable[[], ContextManager[bool]]:
"""The persist guard used when a surface brings none: proceed unless the attempt was stopped."""
@contextlib.contextmanager
def _guard():
yield not is_cancelled()
return _guard
def run_sign_in(
*,
timeout_seconds: float = 15.0,
cancelled: Optional[Callable[[], bool]] = None,
cancel_wins_after_promotion: bool = True,
persist_guard: Optional[Callable[[], ContextManager[bool]]] = None,
scope: Optional[Callable[[], ContextManager[Any]]] = None,
client_factory: Optional[Callable[[float, Any], ContextManager[httpx.Client]]] = None,
) -> Iterator[SignInState]:
"""Sign the free tier into a Nous account, keeping its connectors. Yields :class:`SignInState`s.
One composition behind every surface: it reads the current identity itself, mints one when there
is none, registers the connector transfer, holds ONE absolute deadline across both waits,
persists only after a completed transfer AND a token grant, and runs
:func:`settle_after_upgrade` exactly once per completion. It always ends by yielding exactly one
state whose ``terminal`` is True -- a persist or settle failure becomes ``Failed``, never an
exception out of ``next()``.
*cancelled* is polled between round trips and inside :func:`wait_for_promotion`.
*cancel_wins_after_promotion* rules what a cancel means once the account service has already
transferred the connectors: True (the desktop) aborts and persists nothing -- the install
re-mints a free tier on next use; False (a chat that was superseded) finishes, because a
transfer the user approved in the browser is irreversible and discarding it would leave the
connectors moved with no account to reach them.
*persist_guard* lets a surface make its own cancel check and the save atomic under its own lock.
*scope* is entered only around the two non-network blocks (preconditions/mint, persist/settle),
never across a ``yield``: ``run_in_executor`` does not carry contextvars, so the scope has to be
entered inside the generator, on whichever thread is advancing it.
*client_factory* is the HTTP client seam, ``client_factory(timeout_seconds, verify)``.
"""
from hermes_cli import anon_auth as _core
from hermes_cli.auth import PROVIDER_REGISTRY, _resolve_verify
from hermes_cli.auth_device_flow import _request_device_code
from hermes_cli.auth_nous import _nous_http_client
is_cancelled = cancelled or (lambda: False)
# Once the server says "completed" the transfer has happened; a cancel only undoes it where the
# surface says it does.
post_promotion_cancelled = is_cancelled if cancel_wins_after_promotion else (lambda: False)
open_scope = scope or contextlib.nullcontext
# 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:
with open_scope():
state = _core.current_nous_state()
if state and not _core.is_guest_state(state):
precondition_state = AlreadySignedIn()
elif not state or not _core.guest_enabled():
precondition_state = Unavailable()
except Exception as exc:
# 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
return
anon_token = str(state.get("anon_token") or "")
portal = (state.get("portal_base_url") or _core._portal_base_url()).rstrip("/")
outcome: Dict[str, Any] = {}
account_state: Optional[Dict[str, Any]] = None
try:
pconfig = PROVIDER_REGISTRY["nous"]
client_id, scope_str = pconfig.client_id, pconfig.scope
# A malformed CA bundle raises here, before the wire: inside the try, so it lands on Failed.
verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None)
open_client = client_factory or _nous_http_client
with open_client(timeout_seconds, verify) as client:
device = _request_device_code(client, portal, client_id, scope_str)
intent = _core.register_promotion_intent(
client, portal, anon_token, user_code=str(device["user_code"]),
device_code=str(device["device_code"]))
# The browser leg is the consent page for THIS sign-in (claim_url), not the generic
# device page: it shows both identities and the button. Relative paths are
# portal-relative.
link = str(intent.get("claim_url") or "")
if link.startswith("/"):
link = f"{portal}{link}"
link = link or str(device["verification_uri_complete"])
expires_in = min(int(device["expires_in"]), int(intent.get("expires_in") or device["expires_in"]))
interval = int(intent.get("interval") or device.get("interval") or 5)
deadline = time.monotonic() + max(1, expires_in)
yield Code(link=link, code=str(intent["claim_code"]), expires_in=expires_in, interval=interval)
if is_cancelled(): # nothing is approved anywhere yet: a cancel always wins here
yield Superseded()
return
yield Waiting()
remaining = max(1, int(deadline - time.monotonic()))
outcome = _core.wait_for_promotion(
client, portal, str(intent["claim_code"]),
expires_in=remaining, interval=interval, cancelled=is_cancelled)
status = str(outcome.get("status") or "unknown")
if status != "completed":
# A cancel is authoritative for every non-completed outcome, including the
# {"status": "cancelled"} the hook returns.
if is_cancelled():
yield Superseded()
return
# Scoped: a retiring outcome clears the identity out of this profile's auth store.
with open_scope():
ended = _outcome_state(outcome, anon_token)
yield ended
return
if post_promotion_cancelled():
yield Superseded()
return
remaining = max(1, int(deadline - time.monotonic()))
token_data = _core._poll_for_token(
client=client, portal_base_url=portal, client_id=client_id,
device_code=str(device["device_code"]), expires_in=remaining, poll_interval=interval)
account_state = _core._account_state_from_token(
token_data, portal_base_url=portal, client_id=client_id, scope=scope_str,
verify=verify, timeout_seconds=timeout_seconds)
except _core.AnonCredentialDead:
# Best effort: the credential is provably dead at the account service, so the outcome is
# Retired whatever the local write does. A clear that fails (locked or read-only store)
# self-heals on the next rejection, and must not cost this run its terminal state.
with contextlib.suppress(Exception):
with open_scope():
_core.clear_dead_guest("retired", dead_token=anon_token or None)
yield Retired()
return
except TimeoutError as exc:
yield TimedOut(detail=str(exc))
return
except Exception as exc:
yield _failed_from_exception(exc)
return
try:
if post_promotion_cancelled():
yield Superseded()
return
guard = persist_guard or _default_persist_guard(post_promotion_cancelled)
with open_scope():
with guard() as may_persist:
if may_persist:
_core.persist_nous_credentials(account_state)
if may_persist:
settled = _core.settle_after_upgrade(account_state)
except Exception as exc:
# persist_nous_credentials takes the auth-store lock, writes auth.json, takes the shared
# store's file lock and reseeds the credential pool: a lock timeout or a read-only home
# must not escape next(gen).
yield Failed(reason="", detail=str(exc))
return
if not may_persist: # yielded outside the scope, never across it
yield Superseded()
return
yield Completed(
email=str(outcome.get("account_email") or "").strip(),
model=str(settled.get("model") or ""),
model_changed=bool(settled.get("changed")))