Files
hermes-agent/hermes_cli/anon_challenge.py
2026-10-08 17:22:54 +02:00

492 lines
23 KiB
Python

"""Nous free tier: the browser challenge the account service may put in front of a token exchange.
NAS can answer ``POST /api/anonymous/token`` with **428** ``challenge_required`` and a list of
challenges. Hermes implements exactly one primitive, ``browser-v1``: *open this portal URL, poll
until it clears, exchange again*. Everything the page does (bot detection, an in-browser proof of
work, an interactive fallback, its copy) belongs to the portal and changes without a Hermes release.
Who opens the URL depends on the surface:
* **desktop** (``HERMES_DESKTOP=1``): this process never opens anything. It publishes the challenge
(``free_tier.challenge`` global event, and ``free_tier.status``'s ``challenge`` field for a client
that connects later); the renderer hands it to the Electron main process, which loads it in a
HIDDEN window and reveals that window only if the page asks for the human.
* **terminal**: print the URL, and open the system browser when there is a graphical one to open.
The wait runs OUTSIDE the auth-store and shared-store locks: the exchange that hit the 428 has
already unwound (the exception left the ``with`` blocks), so a slow page never stalls a sibling
profile or process. One challenge is worked at a time per process; a second thread that hits the
same 428 waits for the first and then simply exchanges again (NAS hands a retried mint the same
ticket, and a cleared credential a token).
Only a caller someone is waiting on waits for a challenge. A background reader (the keepalive tick,
a status paint) runs inside :func:`background_caller`: it still gets the challenge in front of a
desktop client (so the hidden window can clear it before anyone needs a token) but never blocks,
prints, or opens a browser.
An *optional* challenge (``required: false`` on a successful exchange: the service is measuring, not
enforcing) is only ever announced to a desktop client, never opened in a user's browser, and
nothing waits on it.
"""
from __future__ import annotations
import contextlib
import contextvars
import logging
import math
import platform
import sys
import threading
import time
from dataclasses import dataclass, field, replace
from typing import Any, Callable, Dict, Optional, TypeVar
from urllib.parse import urlparse
from hermes_cli.anon_auth import (
ANON_CHALLENGE_REQUIRED, ANON_FAILURE_COPY, ANON_SIGNIN_REQUIRED, _anon_headers, _mint_memo_key)
from hermes_cli.auth_constants import AuthError, httpx
logger = logging.getLogger("hermes_cli.auth")
BROWSER_CAPABILITY = "browser-v1"
CHALLENGE_EVENT = "free_tier.challenge"
# How long one exchange attempt waits for its challenge. The page normally clears in seconds; this
# bounds the interactive case (a human solving a check) without parking a request for the ticket's
# whole ten minutes. A later attempt resumes the same ticket.
CHALLENGE_WAIT_SECONDS = 120.0
_POLL_MIN_SECONDS, _POLL_MAX_SECONDS = 1.0, 5.0
# ``expires_in`` is network input that ends up in timers on two runtimes: keep it finite and sane.
_EXPIRES_MIN_SECONDS, _EXPIRES_MAX_SECONDS, _EXPIRES_DEFAULT_SECONDS = 30.0, 900.0, 600.0
_MESSAGE_MAX_CHARS = 300
# After one attempt ran out of patience, the callers queued behind it fail fast for this long
# instead of each parking for another full wait (boot fires a burst of token reads).
_GAVE_UP_COOLDOWN_SECONDS = 30.0
CHALLENGE_COPY = "Nous needs to run a quick check before starting your free session."
T = TypeVar("T")
@dataclass(frozen=True)
class BrowserChallenge:
url: str
required: bool
expires_in: float
interval: float
message: str
attempt: int = 0
def as_payload(self) -> dict[str, Any]:
return {"type": "browser", "url": self.url, "required": self.required,
"expires_in": int(self.expires_in), "message": self.message, "attempt": self.attempt}
class AnonChallengeRequired(AuthError):
"""The exchange needs a browser challenge first. Carries what :func:`run_with_challenge` needs
to work it: the challenge, and the portal, credential and auth state (its ``tls`` block) to poll with."""
def __init__(self, challenge: BrowserChallenge, *, portal_base_url: str, anon_token: str,
auth_state: dict[str, Any]) -> None:
super().__init__(challenge.message, provider="nous", code=ANON_CHALLENGE_REQUIRED, retryable=True)
self.challenge = challenge
self.portal_base_url = portal_base_url
self.anon_token = anon_token
self.auth_state = auth_state
def signin_required_error(message: Any = None) -> AuthError:
"""The service will not serve this client without an account (or asked for something this
version cannot do). Terminal for the process, like a closed gate."""
return AuthError(server_message(message) or ANON_FAILURE_COPY[ANON_SIGNIN_REQUIRED], provider="nous",
code=ANON_SIGNIN_REQUIRED, retryable=False)
def server_message(value: Any) -> str:
"""User-facing copy the service sent, if it is plausibly that: a short single paragraph."""
if not isinstance(value, str):
return ""
# Printable text only: this reaches a terminal, and a control character there is an escape
# sequence the service (or whoever answered as it) gets to run.
text = " ".join("".join(ch if ch.isprintable() else " " for ch in value).split())
return text if 0 < len(text) <= _MESSAGE_MAX_CHARS else ""
# --- What this client tells the service about itself -------------------------------------------------
def client_surface() -> str:
"""``gateway`` in the process that holds the messaging gateway's runtime lock (nobody is at its
console), ``desktop`` for the backend the desktop app spawned, else ``cli``. ``HERMES_DESKTOP``
alone is inherited by every terminal-pane shell, so desktop ownership needs the spawn credential."""
status = sys.modules.get("gateway.status")
if status is not None and status.owns_gateway_runtime_lock():
return "gateway"
from hermes_cli.process_identity import is_desktop_owned_backend
return "desktop" if is_desktop_owned_backend() else "cli"
def client_info() -> dict[str, Any]:
"""The self-reported ``client`` block on a token exchange. It sorts honest clients (which
surface, which challenge primitives) for the service's rules; it proves nothing, by design."""
from hermes_cli.version_info import get_version_info
return {"name": "hermes-agent", "version": get_version_info().base_version, "surface": client_surface(),
"platform": sys.platform, "capabilities": [BROWSER_CAPABILITY]}
def user_agent() -> str:
from hermes_cli.version_info import get_version_info
return (f"hermes-agent/{get_version_info().base_version} "
f"({client_surface()}; {sys.platform}; {platform.machine() or 'unknown'})")
# --- Parsing ------------------------------------------------------------------------------------------
def _same_origin(url: str, portal_base_url: str) -> bool:
"""A challenge URL is only ever opened on the portal the exchange itself went to: the response
is network input, and "open this URL" must not become "open any URL"."""
try:
target, portal = urlparse(url), urlparse(portal_base_url)
except ValueError:
return False
return (target.scheme in ("https", "http") and target.scheme == portal.scheme
and bool(target.netloc) and target.netloc.lower() == portal.netloc.lower())
def _number(value: Any, default: float) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
return default
return float(value)
def parse_browser_challenge(payload: dict[str, Any], portal_base_url: str) -> Optional[BrowserChallenge]:
"""The first challenge in ``payload["challenges"]`` this client can run, or None."""
entries = payload.get("challenges")
if not isinstance(entries, list):
return None
message = server_message(payload.get("message")) or CHALLENGE_COPY
for entry in entries:
if not isinstance(entry, dict) or entry.get("type") != "browser":
continue
url = entry.get("url")
if not isinstance(url, str) or not url.isprintable() or not _same_origin(url, portal_base_url):
logger.info("Nous free tier: ignoring a challenge URL outside the portal origin")
continue
return BrowserChallenge(
url=url, required=entry.get("required") is not False,
expires_in=min(_EXPIRES_MAX_SECONDS, max(
_EXPIRES_MIN_SECONDS, _number(entry.get("expires_in"), _EXPIRES_DEFAULT_SECONDS))),
interval=min(_POLL_MAX_SECONDS, max(_POLL_MIN_SECONDS, _number(entry.get("interval"), 2.0))),
message=message)
return None
def challenge_error(payload: dict[str, Any], *, portal_base_url: str, anon_token: str,
auth_state: dict[str, Any]) -> AuthError:
"""The error for a 428 ``challenge_required``: a challenge to work, or (nothing offered that
this version can run) the sign-in fallback."""
challenge = parse_browser_challenge(payload, portal_base_url)
if challenge is None:
# The payload's ``message`` describes the challenge, so it is not reused for the fallback.
return signin_required_error()
return AnonChallengeRequired(challenge, portal_base_url=portal_base_url, anon_token=anon_token,
auth_state=auth_state)
# --- Per-profile state: what a status read shows, and who is working it ---------------------------------
@dataclass
class _ProfileChallenge:
"""One profile's challenge state, behind ``_state_lock``. ``work_lock`` serialises waits PER
PROFILE (a sibling profile's wait must not queue behind it)."""
work_lock: threading.Lock = field(default_factory=threading.Lock)
pending: Optional[BrowserChallenge] = None
deadline: float = 0.0
outcome: Optional[str] = None # how the host's window ended, for ``pending``'s attempt
gave_up_until: float = 0.0
_state_lock = threading.Lock()
_profiles: dict[str, _ProfileChallenge] = {}
_opened_urls: set[str] = set() # challenge URLs whose browser tab was opened (one per ticket)
# Set by :func:`background_caller`: this caller has nobody waiting on it.
_background: contextvars.ContextVar[bool] = contextvars.ContextVar("anon_challenge_background", default=False)
@contextlib.contextmanager
def background_caller():
"""Mark the enclosed token reads as background work (a keepalive tick, a status paint): a
challenge is announced to a desktop client but never waited on, printed, or opened."""
token = _background.set(True)
try:
yield
finally:
_background.reset(token)
def _profile() -> _ProfileChallenge:
"""This profile's record. Callers hold ``_state_lock`` for anything but ``work_lock``."""
with _state_lock:
return _profiles.setdefault(_mint_memo_key(), _ProfileChallenge())
def _live(record: _ProfileChallenge) -> Optional[BrowserChallenge]:
if record.pending and record.deadline <= time.monotonic():
record.pending, record.outcome = None, None
return record.pending
def pending_challenge() -> Optional[dict[str, Any]]:
"""The challenge this profile is waiting on, for ``free_tier.status`` (a client that connected
after the event fired still learns it has a window to open). None once cleared or expired, or
once the host said how its window ended (a window the user closed must not come back)."""
record = _profile()
with _state_lock:
challenge = _live(record)
if challenge is None or record.outcome not in (None, "timeout", "error"):
return None
return {**challenge.as_payload(), "expires_in": math.ceil(record.deadline - time.monotonic())}
def _clear_pending() -> None:
record = _profile()
with _state_lock:
record.pending, record.outcome = None, None
def _record(challenge: BrowserChallenge, *, new_attempt: bool) -> BrowserChallenge:
"""Record *challenge* as the profile's pending one. A replay of the same ticket (a background
read, a second 428) keeps the original deadline, attempt and host outcome; ``new_attempt`` (a
foreground caller about to wait) bumps the attempt, which lets it reopen a window the user closed."""
record = _profile()
with _state_lock:
previous = _live(record)
deadline = time.monotonic() + challenge.expires_in
if previous is None or previous.url != challenge.url:
record.pending, record.deadline, record.outcome = replace(challenge, attempt=0), deadline, None
else:
attempt = previous.attempt + 1 if new_attempt else previous.attempt
record.pending, record.deadline = replace(challenge, attempt=attempt), min(deadline, record.deadline)
if new_attempt:
record.outcome = None
return record.pending
def record_host_outcome(url: str, attempt: int, outcome: str) -> bool:
"""A presentation hint: wakes a waiter to re-mint, never grants clearance locally. A late
reply belongs to the attempt that opened the window, not to a newer retry."""
record = _profile()
with _state_lock:
challenge = _live(record)
if challenge is None or (challenge.url, challenge.attempt) != (url, attempt):
return False
record.outcome = outcome
return True
def _host_outcome(challenge: BrowserChallenge) -> Optional[str]:
record = _profile()
with _state_lock:
pending = record.pending
matches = pending is not None and (pending.url, pending.attempt) == (challenge.url, challenge.attempt)
return record.outcome if matches else None
def _give_up() -> None:
record = _profile()
with _state_lock:
record.gave_up_until = time.monotonic() + _GAVE_UP_COOLDOWN_SECONDS
def _gave_up_recently() -> bool:
record = _profile()
with _state_lock:
return time.monotonic() < record.gave_up_until
def reset_for_tests() -> None:
with _state_lock:
_profiles.clear()
_opened_urls.clear()
# --- Presenting ---------------------------------------------------------------------------------------
def _announce(challenge: BrowserChallenge) -> bool:
"""Broadcast to connected desktop clients. False when this process is not a gateway at all
(``HERMES_DESKTOP`` is an env var, and a CLI the desktop spawned inherits it): importing the
gateway here just to find nobody listening would also write an event frame to stdout."""
server = sys.modules.get("tui_gateway.server")
if server is None:
return False
try:
server._broadcast_global_event(CHALLENGE_EVENT, challenge.as_payload())
except (OSError, ValueError) as exc:
# The stdio channel re-raises host I/O errors (ENOSPC, a locale that can't encode the
# server's copy); the caller falls back to the terminal instead of losing the link.
logger.debug("%s not broadcast: %s", CHALLENGE_EVENT, exc)
return False
return True
def _present_in_terminal(challenge: BrowserChallenge) -> None:
from hermes_cli.auth_device_flow import _can_open_graphical_browser, _is_remote_session
opened = False
# One tab per ticket, however many attempts resume it: a user who has not got to it yet is
# not helped by a second copy. A gateway has no console of its own: it logs the link only.
already_open = challenge.url in _opened_urls
if (not already_open and client_surface() != "gateway"
and not _is_remote_session() and _can_open_graphical_browser()):
import webbrowser
try:
opened = bool(webbrowser.open(challenge.url))
except (webbrowser.Error, OSError) as exc:
logger.debug("could not open the challenge in a browser: %s", exc)
if opened:
_opened_urls.add(challenge.url)
print(f"\n{challenge.message}", file=sys.stderr)
print(f" Open: {challenge.url}", file=sys.stderr)
print(" (Opened in your browser.)\n" if opened or already_open
else " (Open that link in any browser; this continues on its own.)\n", file=sys.stderr)
def present(challenge: BrowserChallenge) -> None:
"""Get the URL in front of something that can load it.
The desktop backend hands every challenge to its hidden window. The stdio TUI gateway (whose
stderr the TUI keeps as a log, never shows) hands its client a required one a foreground caller
is waiting on: a background read there must not pop a browser any more than in a terminal."""
if client_surface() == "desktop" and _announce(challenge):
return
if not challenge.required or _background.get():
return
server = sys.modules.get("tui_gateway.server")
if server is not None and server._stdio_is_rpc_channel and _announce(challenge):
return
_present_in_terminal(challenge)
# --- Working a challenge ----------------------------------------------------------------------------
def _poll_status(client: httpx.Client, portal_base_url: str, anon_token: str) -> str:
"""One status read. Anything that is not a clear ``pending`` / ``needs_interaction`` ends the
wait: the exchange that follows is the authority on whether the credential is cleared. (A
passed challenge reads ``none``: passing detaches the ticket.)"""
try:
response = client.post(f"{portal_base_url.rstrip('/')}/api/anonymous/challenge/status",
headers=_anon_headers(), json={"token": anon_token})
if 400 <= response.status_code < 500 and response.status_code != 429:
# The service will not answer this poll (credential reaped, surface off): waiting cannot
# change that. End the wait; the exchange's own verdict says what happened.
return "none"
body = response.json() if response.status_code == 200 else None
except (httpx.HTTPError, ValueError) as exc:
logger.debug("challenge status poll failed: %s", exc)
body = None
status = body.get("status") if isinstance(body, dict) else None
# Only a 200 that names a status is a verdict. A 429, a 5xx or a dropped connection is a
# blip: keep waiting (the deadline bounds it) rather than abandon a check the user is on.
return status if isinstance(status, str) else "pending"
_WAITING = ("pending", "needs_interaction")
_sleep = time.sleep # seam for tests (same idiom as ``free_tier_bootstrap._sleep``)
def wait_for_challenge(exc: AnonChallengeRequired) -> bool:
"""Present *exc*'s challenge and poll until status or a host hint ends the wait.
True = a result arrived; False = patience ran out. Re-mint is authoritative in either case.
Called under the profile's work lock. A caller that queued behind another finds the ticket
already settled on its first status read and returns without presenting anything."""
from hermes_cli.auth import _resolve_verify
from hermes_cli.auth_nous import _nous_http_client
if _gave_up_recently():
return False
challenge = exc.challenge
deadline = time.monotonic() + min(CHALLENGE_WAIT_SECONDS, challenge.expires_in)
# Poll under the trust the credential was minted with: a guest's ``tls`` block is written from
# the mint's own verify, so this is the context the 428 exchange just used, not a narrowing.
verify = _resolve_verify(auth_state=exc.auth_state)
told_interactive = False
with _nous_http_client(10.0, verify) as client:
if _poll_status(client, exc.portal_base_url, exc.anon_token) not in _WAITING:
return True
challenge = exc.challenge = _record(challenge, new_attempt=True)
present(challenge)
while time.monotonic() < deadline:
if _host_outcome(challenge) is not None:
return True
_sleep(challenge.interval)
status = _poll_status(client, exc.portal_base_url, exc.anon_token)
if status not in _WAITING:
return True
if status == "needs_interaction" and not told_interactive:
told_interactive = True
if client_surface() != "desktop":
print(" Finish the quick check in your browser to continue.", file=sys.stderr)
_give_up()
return False
def _still_pending(cause: AnonChallengeRequired) -> AuthError:
message = ("The quick check couldn't finish. Try again." if _host_outcome(cause.challenge)
else ANON_FAILURE_COPY[ANON_CHALLENGE_REQUIRED])
error = AuthError(message, provider="nous", code=ANON_CHALLENGE_REQUIRED, retryable=True)
error.__cause__ = cause
return error
def run_with_challenge(exchange: Callable[[], T]) -> T:
"""Run *exchange*; if the service asks for a browser challenge, work it (outside every lock:
the exception has already unwound them) and run *exchange* once more.
A background caller (:func:`background_caller`) never waits: the challenge is announced so a
desktop client can start clearing it, and the retryable error goes straight back.
A second ``challenge_required`` is not looped on: it surfaces as a retryable error whose copy
says what the user can do, the callers queued behind it fail fast for a while, and the next
attempt resumes the same ticket."""
try:
return exchange()
except AnonChallengeRequired as exc:
first = exc
# A messaging gateway has nobody at its console to clear a check, and its token reads can run
# on the event loop: it never waits either.
if _background.get() or client_surface() == "gateway":
present(_record(first.challenge, new_attempt=False))
raise _still_pending(first)
with _profile().work_lock:
wait_for_challenge(first)
# Even a timed-out status poll can lag a committed clearance. Mint is
# authoritative and gets one final attempt before we report a pending check.
try:
return exchange()
except AnonChallengeRequired as again:
# Whatever ended the wait (status, a host hint, patience), a second 428 starts the fail-fast
# cooldown; the attempt after it opens a fresh window (``new_attempt``).
again.challenge = _record(again.challenge, new_attempt=False)
_give_up()
raise _still_pending(again)
except AuthError as error:
if not error.retryable:
_clear_pending()
raise
def note_optional_challenges(payload: dict[str, Any], portal_base_url: str) -> None:
"""A successful exchange may advertise a challenge nobody has to pass (the service is measuring
before it enforces). Only a desktop client runs it, hidden; a terminal never opens a browser
for something optional. Fire and forget."""
challenge = parse_browser_challenge(payload, portal_base_url)
if challenge is None:
_clear_pending()
elif not challenge.required and client_surface() == "desktop":
_announce(_record(challenge, new_attempt=False))