Files

311 lines
11 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 have to line up for the keepalive to actually keep anything alive.
#
# 1. The tick has to be frequent enough to see the credential before it dies.
# Nous credential lifetimes are not fixed: this varies by account, and
# installs have been observed at both ~3594s and ~899s. The tick therefore
# derives from the lifetime the server actually issued rather than assuming
# an hour, capped by the configured interval and floored so a pathological
# lifetime cannot spin the thread.
#
# 2. The refresh has to fire while the tick can still act on it. The refresh
# only triggers once a credential is within a skew window of expiry, so a
# tick spaced wider than that window steps straight over it. The keepalive
# widens the window to "will this credential outlive my next tick?" instead
# of the request-path default of 120s. Without this, ticking faster only
# narrows the gap; it never closes it.
#
# The original 6-hour tick against a one-hour credential failed both tests, so
# in practice every hour expired reactively into a 401 plus a re-auth retry.
NOUS_AUTH_KEEPALIVE_INTERVAL_SECONDS = 15 * 60
NOUS_AUTH_KEEPALIVE_MIN_INTERVAL_SECONDS = 60
# Ticks per credential lifetime. Four keeps the refresh comfortably ahead of
# expiry without making the thread chatty.
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:
"""Return the ``nous:`` section of config.yaml, or {} on any failure.
Imported lazily: this module is loaded by the gateway and the web server
during startup, and the config loader pulls in a wider dependency graph
than the keepalive itself needs.
"""
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:
"""Resolve the keepalive tick interval.
Explicit argument wins, then ``nous.keepalive_interval_seconds`` in
config.yaml, then the module default. This is a behavioural threshold
rather than a credential, so it lives in config.yaml and not in .env.
A non-positive result disables the keepalive thread entirely, which is
the documented way to turn it 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]:
"""Lifetime the server issued for the current Nous credentials, in seconds.
Both the access token and the invoke agent key carry their own lifetime and
they are not always equal, so the shorter one governs. Returns None when no
usable value is stored, in which case the caller keeps its configured tick.
"""
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) if lifetimes else 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:
"""How much life a credential needs to be left alone this tick.
A credential that will not survive until the next tick has to be refreshed
now, because nothing will look at it again before it expires. Hence
tick + skew rather than the request path's bare skew.
"""
return max(floor_seconds, tick_seconds + ACCESS_TOKEN_REFRESH_SKEW_SECONDS)
def _entry_state(entry: object) -> dict:
return {
"agent_key": getattr(entry, "agent_key", None),
"agent_key_expires_at": getattr(entry, "agent_key_expires_at", None),
"scope": getattr(entry, "scope", None),
}
def _refresh_selected_pool_entry(
*,
min_key_ttl_seconds: int,
min_access_ttl_seconds: Optional[int] = None,
) -> Optional[bool]:
"""Refresh the current Nous credential pool entry when it is stale.
Returns True when a pool entry exists and is usable/refreshed, False when a
pool exists but no entry can be used, and None when no Nous pool exists.
"""
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:
refreshed = pool.try_refresh_current()
if refreshed is None:
return False
logger.debug("Nous auth keepalive: refreshed credential pool entry")
return True
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."""
min_key_ttl_seconds = max(60, int(min_key_ttl_seconds))
pool_result = _refresh_selected_pool_entry(
min_key_ttl_seconds=min_key_ttl_seconds,
min_access_ttl_seconds=min_access_ttl_seconds,
)
if pool_result is not None:
return pool_result
state = get_provider_auth_state("nous")
if not state:
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 AuthError as exc:
if exc.relogin_required:
logger.info("Nous auth keepalive requires re-login: %s", exc)
else:
logger.debug("Nous auth keepalive failed: %s", exc)
return False
except Exception as exc:
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 can change when the account, plan, or
# server-side policy does, and a keepalive that caches it would go stale
# in exactly the case it 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."""
interval_seconds = _interval_seconds(interval_seconds)
if interval_seconds <= 0:
return None
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,),
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,
},
daemon=True,
name="nous-auth-keepalive",
)
_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