Files
hermes-agent/hermes_cli/nous_auth_keepalive.py
T
Siddharth Balyan a2db110ccc feat(auth): Nous free tier: free inference and connectors out of the box, one command to sign in (#105258)
* feat(auth): Nous free tier core: anonymous identity minted on first use, welcome inference, shared-store scoping

A fresh install with no provider sets up a free Nous identity (anonymous auth method of the nous
provider) instead of forcing the setup wizard. The identity is persisted through the same path a
real login uses, so the resolver ladder is unchanged. Two seams differ: token acquisition
re-exchanges the anon credential (no refresh token), and routing pins the welcome host's single
model nous/welcome. One identity per shared store; nous.guest: false turns the free tier off.

* test(auth): free tier core contracts: lifecycle, resolver precedence, exchange seam, model pin

* docs(user-guide): free tier and signing in

New page explaining what a fresh install gets before any key or sign-in
(free inference on nous/welcome plus connectors), how the free tier
coexists with a user's own API key, how to sign in with hermes auth
upgrade and keep connectors, how to turn the free tier off with
nous.guest, what hermes logout does in each state, a troubleshooting
table, and a plain privacy note. Wired into the Using Hermes sidebar.

* fix(auth): logout leaves the free tier alone and clears the shared store for a real Nous account

Logging out of the free tier is a no-op: it is not a login, so nothing is cleared and the user
is told they were never signed in. Logging out of a real Nous account now also clears the
cross-profile store, so a profile logout is not silently re-adopted on the next boot.

* fix(model): switching off the free tier points at signing in, never hops providers

* Name the free tier in the gateway startup notice and tell explicit-provider installs about it once

* Render the Nous free tier as free tier on auth status, auth list, hermes status and portal info, short-circuit billing copy for it, and skip the keepalive when there is no refresh token

* fix(auth): free tier is set up where nothing is configured: resolver last rung and first-run check

Both the provider resolver's terminal rung and the CLI first-run check now try to set up the free
tier before declaring nothing configured. On a fresh install the first command lands in chat on
nous/welcome; a failed setup still falls through to the existing guidance.

* Add hermes auth upgrade: sign the free tier into a Nous account while keeping its connectors

The device-code flow runs as usual, with a promotion intent registered on the portal between the
code request and the token poll so the account that approves the code inherits the free tier's
connectors. The promotion status decides the outcome: only a completed one is followed by the
token grant, which is persisted over the free-tier singleton and the shared store. Declined,
superseded, retired and busy outcomes each print their own plain copy, and a retired identity is
cleared so the next use sets up a fresh one. User-facing text never names the free tier's internals.

* Show the Nous free tier as one picker row with nous/welcome and hide it when nous.guest is off

* fix(auth): upgrade opens the consent page for this sign-in; one mint attempt per process; forced free tier wins the first-run check

The browser leg of hermes auth upgrade now prints and opens the promotion claim URL with the
claim code, not the generic device page. A failed mint is attempted once per process so several
bootstrap sites cannot hit a closed gate or a 429 twice; a retired credential resets that so
re-minting still happens. HERMES_FORCE_GUEST is honoured ahead of the first-run provider check.

* fix(auth): pin the welcome model on the selected route, not on profile state; background setup retries after a failure

A credential-pool entry can select a paid Nous key while the profile singleton is still the free
tier. The model pin now keys on the resolved endpoint (welcome host) in agent init and /model, and
the pin in model normalization is removed since it had no route to look at. A failed background
identity setup releases its latch so a later attempt in the same process can try again.

* fix(auth): decide the Nous model together with the route on every credential-pool swap

The credential pool can move a Nous agent between the welcome host and the portal host after
init. One helper, pin_model_for_route, now runs at init and inside every pool swap, so the
welcome host always carries nous/welcome and a paid endpoint always keeps the caller's model.

* fix(auth): apply the route model policy on every wire mode during a pool swap; release the setup latch if the thread cannot start

* fix(auth): free-tier lifecycle takes profile then shared lock, reconciles with the shared store, persists the mint before exchanging, and clears only the identity that died

The shared store is the identity of record for a Hermes root: a profile holding a stale free-tier
identity adopts a sibling's newer sign-in instead of keeping the guest, and never overwrites the
shared account. Locks are taken in the documented order (profile, then shared). A minted credential
is stored as soon as create succeeds, so a rate-limited or timed-out exchange does not lose it and
trigger a second mint. Retiring a dead credential removes only that credential from both stores.
Guest exchange uses the resolver's canonical portal URL.

* fix(auth): a credential rotation never rewrites the conversation model; connectors honour the off switch and replace a retired free-tier credential

The welcome host serves one model, so a rotation onto it is refused for any conversation on another
model instead of silently switching that conversation to nous/welcome (the model pin applies only
when a route is first chosen). The connector token path now treats the free tier as absent when
nous.guest is false, including cached tokens, and shares the one dead-credential rule with
inference: a retired identity is replaced once rather than returning its stale token.

* fix(auth): plain login never imports the free tier as OAuth credentials; the gateway startup line reads persisted state only

A free-tier identity in the shared store is not an OAuth credential to offer for import; a real
sign-in replaces it. The gateway's startup notice now answers provider precedence from persisted
state (no token refresh at boot), so an expired free-tier token cannot stall the online message.
2026-09-11 03:45:31 +05:30

238 lines
9.6 KiB
Python

"""Background keepalive for long-lived Nous Portal sessions."""
from __future__ import annotations
import logging
import os
import threading
from typing import Optional
from hermes_cli.auth import (
ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
AuthError,
_agent_key_is_usable,
_is_expiring,
get_provider_auth_state,
resolve_nous_runtime_credentials,
)
logger = logging.getLogger(__name__)
# Two things must line up for the keepalive to keep anything alive:
# 1. The tick must be frequent enough to see the credential before it dies. Lifetimes vary by
# account (~3594s and ~899s observed), so the tick derives from the lifetime the server issued,
# capped by the configured interval and floored so a pathological lifetime can't spin the thread.
# 2. The refresh must fire while the tick can still act on it: refresh triggers only within a skew
# window of expiry, so the keepalive widens that window to "will this credential outlive my
# next tick?" instead of the request path's 120s. Ticking faster alone never closes the gap.
NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS = 15 * 60
NOUS_AUTH_KEEPALIVE_MIN_INTERVAL_SECONDS = 60
# Ticks per credential lifetime: four keeps refresh comfortably ahead of expiry without chatter.
NOUS_AUTH_KEEPALIVE_TICKS_PER_LIFETIME = 4
NOUS_AUTH_KEEPALIVE_INITIAL_DELAY_SECONDS = 60
NOUS_AUTH_KEEPALIVE_INTERVAL_CONFIG_KEY = "keepalive_interval_seconds"
_keepalive_lock = threading.Lock()
_keepalive_stop = threading.Event()
_keepalive_thread: Optional[threading.Thread] = None
def _timeout_seconds(value: Optional[float]) -> float:
if value is not None:
return float(value)
try:
return float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15"))
except (TypeError, ValueError):
return 15.0
def _nous_config() -> dict:
"""The ``nous:`` section of config.yaml, or {} on any failure (config loader imported lazily)."""
try:
from hermes_cli.config import load_config
section = load_config().get("nous")
return section if isinstance(section, dict) else {}
except Exception:
return {}
def _interval_seconds(value: Optional[int]) -> int:
"""Tick interval: explicit argument, then ``nous.keepalive_interval_seconds`` in config.yaml,
then the module default. Non-positive disables the keepalive thread (the documented way off).
"""
if value is not None:
try:
return int(value)
except (TypeError, ValueError):
return NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS
raw = _nous_config().get(NOUS_AUTH_KEEPALIVE_INTERVAL_CONFIG_KEY)
if raw is None or (isinstance(raw, str) and not raw.strip()):
return NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS
try:
return int(float(raw))
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid nous.%s=%r; using %ds",
NOUS_AUTH_KEEPALIVE_INTERVAL_CONFIG_KEY, raw, NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS,
)
return NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS
def _observed_lifetime_seconds() -> Optional[int]:
"""Server-issued lifetime (seconds) of the current Nous credentials; the shorter of the access
token and the invoke agent key governs. None when nothing usable is stored.
"""
state = get_provider_auth_state("nous") or {}
lifetimes = []
for key in ("expires_in", "agent_key_expires_in"):
try:
value = int(float(state.get(key)))
except (TypeError, ValueError):
continue
if value > 0:
lifetimes.append(value)
return min(lifetimes, default=None)
def _tick_seconds(configured_interval: int, lifetime: Optional[int]) -> int:
"""Tick fast enough to refresh several times per credential lifetime."""
if not lifetime or lifetime <= 0:
return configured_interval
derived = lifetime // NOUS_AUTH_KEEPALIVE_TICKS_PER_LIFETIME
return max(NOUS_AUTH_KEEPALIVE_MIN_INTERVAL_SECONDS, min(configured_interval, derived))
def _refresh_horizon_seconds(tick_seconds: int, floor_seconds: int) -> int:
"""Life a credential needs to be left alone this tick: it must survive until the next tick
(nothing looks at it again before then), hence tick + skew rather than the bare skew.
"""
return max(floor_seconds, tick_seconds + ACCESS_TOKEN_REFRESH_SKEW_SECONDS)
def _entry_state(entry: object) -> dict:
return {k: getattr(entry, k, None) for k in ("agent_key", "agent_key_expires_at", "scope")}
def _refresh_selected_pool_entry(*, min_key_ttl_seconds: int, min_access_ttl_seconds: Optional[int] = None) -> Optional[bool]:
"""Refresh the current pool entry when stale. True = usable/refreshed; False = pool exists but
no usable entry; None = no Nous pool.
"""
try:
from agent.credential_pool import load_pool
pool = load_pool("nous")
except Exception as exc:
logger.debug("Nous auth keepalive: credential pool unavailable: %s", exc)
return None
if not pool or not pool.has_credentials():
return None
try:
entry = pool.select()
except Exception as exc:
logger.debug("Nous auth keepalive: credential pool selection failed: %s", exc)
return False
if entry is None:
return False
if min_access_ttl_seconds is None:
min_access_ttl_seconds = ACCESS_TOKEN_REFRESH_SKEW_SECONDS
access_expiring = _is_expiring(getattr(entry, "expires_at", None), min_access_ttl_seconds)
key_usable = _agent_key_is_usable(_entry_state(entry), min_key_ttl_seconds)
if access_expiring or not key_usable:
if pool.try_refresh_current() is None:
return False
logger.debug("Nous auth keepalive: refreshed credential pool entry")
return True
def refresh_nous_auth_keepalive_once(
*, min_key_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
min_access_ttl_seconds: Optional[int] = None, timeout_seconds: Optional[float] = None,
) -> bool:
"""Refresh Nous auth once if credentials are configured (pool entry first, then singleton state)."""
pool_result = _refresh_selected_pool_entry(
min_key_ttl_seconds=max(60, int(min_key_ttl_seconds)), min_access_ttl_seconds=min_access_ttl_seconds
)
if pool_result is not None:
return pool_result
if not get_provider_auth_state("nous"):
return False
try:
resolve_nous_runtime_credentials(timeout_seconds=_timeout_seconds(timeout_seconds))
logger.debug("Nous auth keepalive: refreshed singleton auth state")
return True
except Exception as exc:
if isinstance(exc, AuthError) and exc.relogin_required:
logger.info("Nous auth keepalive requires re-login: %s", exc)
else:
logger.debug("Nous auth keepalive failed: %s", exc)
return False
def _keepalive_loop(
stop_event: threading.Event, *, interval_seconds: int, initial_delay_seconds: int,
min_key_ttl_seconds: int, timeout_seconds: Optional[float],
) -> None:
if initial_delay_seconds > 0 and stop_event.wait(initial_delay_seconds):
return
while not stop_event.is_set():
# Re-read each pass: the lifetime changes with account/plan/policy; caching it would go
# stale in exactly the case the keepalive exists to cover.
tick = _tick_seconds(interval_seconds, _observed_lifetime_seconds())
horizon = _refresh_horizon_seconds(tick, min_key_ttl_seconds)
refresh_nous_auth_keepalive_once(
min_key_ttl_seconds=horizon, min_access_ttl_seconds=horizon, timeout_seconds=timeout_seconds
)
stop_event.wait(tick)
def start_nous_auth_keepalive(
*, interval_seconds: Optional[int] = None,
initial_delay_seconds: int = NOUS_AUTH_KEEPALIVE_INITIAL_DELAY_SECONDS,
min_key_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS, timeout_seconds: Optional[float] = None,
) -> Optional[threading.Thread]:
"""Start the process-wide Nous auth keepalive thread (idempotent; None when disabled)."""
interval_seconds = _interval_seconds(interval_seconds)
if interval_seconds <= 0:
return None
# The free tier has no refresh token to keep alive: its access token is re-minted from the
# anon credential on demand by the request path, so a background refresher has nothing to do.
from hermes_cli.anon_auth import is_guest_state
try:
if is_guest_state(get_provider_auth_state("nous")):
logger.debug("Nous auth keepalive skipped: free tier has no refresh token")
return None
except Exception:
pass
global _keepalive_thread
with _keepalive_lock:
if _keepalive_thread is not None and _keepalive_thread.is_alive():
return _keepalive_thread
_keepalive_stop.clear()
_keepalive_thread = threading.Thread(
target=_keepalive_loop, args=(_keepalive_stop,), daemon=True, name="nous-auth-keepalive",
kwargs={
"interval_seconds": int(interval_seconds),
"initial_delay_seconds": max(0, int(initial_delay_seconds)),
"min_key_ttl_seconds": max(60, int(min_key_ttl_seconds)),
"timeout_seconds": timeout_seconds,
},
)
_keepalive_thread.start()
logger.debug("Nous auth keepalive started")
return _keepalive_thread
def stop_nous_auth_keepalive(timeout: float = 5.0) -> None:
"""Stop the keepalive thread. Intended for graceful shutdown/tests."""
global _keepalive_thread
with _keepalive_lock:
thread = _keepalive_thread
_keepalive_stop.set()
if thread is not None and thread.is_alive():
thread.join(timeout=timeout)
with _keepalive_lock:
if _keepalive_thread is thread:
_keepalive_thread = None