feat(gateway): sign in with a Nous account from a chat (/login), one shared sign-in flow (#105261)

* refactor(auth): one sign-in flow behind SignInState, rendered by the CLI and the desktop

* feat(gateway): /signin signs the free tier into a Nous account from a DM

* feat(cli): chat surfaces name /signin as the sign-in verb

* fix(auth): review follow-ups for the shared sign-in flow and /signin

* fix(i18n): carry the /status free-tier line in every locale catalog

* refactor(cli): the chat sign-in command is /login

* fix(auth): durable override cleanup in the /login sweep, and the sign-in flow in its own modules
This commit is contained in:
Siddharth Balyan
2026-09-11 03:45:33 +05:30
committed by GitHub
parent 3b01b4ce0f
commit cbcf7b72f7
56 changed files with 2903 additions and 242 deletions
+3 -1
View File
@@ -445,7 +445,9 @@ def _nous_entitlement_message(capability: str) -> str:
get_nous_portal_account_info,
)
account_info = get_nous_portal_account_info(force_fresh=True)
return format_nous_portal_entitlement_message(account_info, capability=capability) or ""
return format_nous_portal_entitlement_message(
account_info, capability=capability, in_chat=True
) or ""
except Exception:
return ""
+6 -3
View File
@@ -225,9 +225,12 @@ def nous_rate_limit_guard(
)
_nous_remaining = nous_rate_limit_remaining()
if _nous_remaining is not None and _nous_remaining > 0:
_nous_msg = (
f"Nous Portal rate limit active — resets in {_fmt_nous_remaining(_nous_remaining)}."
)
from hermes_cli import anon_auth
reset = _fmt_nous_remaining(_nous_remaining)
if anon_auth.route_is_welcome_host(getattr(agent, "base_url", "")):
_nous_msg = anon_auth.FREE_TIER_RATE_LIMIT_CHAT.format(reset=reset)
else:
_nous_msg = f"Nous Portal rate limit active — resets in {reset}."
agent._buffer_vprint(f"⏳ {_nous_msg} Trying fallback...")
agent._buffer_status(f"⏳ {_nous_msg}")
if agent._try_activate_fallback():
@@ -171,6 +171,12 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashCommand('/pets')).toBe(false)
})
it('does not run /login on desktop before the catalog is loaded', () => {
rememberDesktopCommandsCatalog(undefined)
expect(isDesktopSlashCommand('/login')).toBe(false)
expect(desktopSlashUnavailableMessage('/login')).toBe('/login is managed from the desktop sidebar.')
})
it('routes /wake through the desktop wake action instead of the slash worker', () => {
expect(resolveDesktopCommand('/wake')?.surface).toEqual({ kind: 'action', action: 'wake' })
expect(desktopSlashCommandArgumentMode('/wake')).toBe('options')
@@ -317,7 +317,7 @@ const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> =
'/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills', '/pets'],
settings: ['/skills', '/pets', '/login'],
advanced: [
'/curator',
'/fast',
+32
View File
@@ -81,6 +81,38 @@ or opted-in explicit targets. `all` expansions do not gain home mirror eligibili
briefs are labelled user turns appended at a turn boundary, preserving role alternation
(`cron/AGENTS.md`).
## `/login` (off-turn, paired DM only)
`/login` is registered in `hermes_cli/commands.py` with `busy_policy="dispatch"` and
`desktop="settings"`, listed in `run_busy.py::_PLAIN_COMMANDS`, and handled by
`GatewayLoginCommandsMixin` (`gateway/slash_commands_login.py`). It refuses outside a paired DM:
`chat_type in {"dm","private"}`, a truthy `chat_id`, and a platform whose `"dm"` really is a paired
conversation — ntfy, raft and a2a all report `chat_type="dm"` for a broadcast topic, a channel and
an agent peer, so posting a consent link there would publish it.
**It binds the whole install.** The sign-in writes the singleton `providers.nous`, so whoever
approves the code owns this gateway's inference and connectors for every chat it serves. Slash
gating is opt-in (`gateway/slash_access.py`): with no `allow_admin_from` set, every user allowed to
DM the bot can run it. Operators of shared gateways must set it.
The handler returns its ack at once and drains `anon_auth.run_sign_in` on a **private single-worker
executor** (`_login_executor`), never the shared 10-thread gateway pool — a promotion wait can last
the code's full expiry, and a live worker in the shared pool makes shutdown skip the SessionDB
close/checkpoint. One attempt per process, stamped with the identity that started it: the same
identity's second `/login` supersedes (the loser ends with the superseded copy pushed into its own
chat); a different identity is refused. `task.cancel()` cannot interrupt a blocking poll inside a
worker thread, so shutdown and supersede both work through `attempt.cancelled`, which
`wait_for_promotion` now polls on a ≤1 s tick. A shutdown-cancelled attempt pushes nothing — the
task is dying and its adapters may already be gone — but if the server had already completed the
transfer it still persists, because that transfer is irreversible.
On completion the handler **evicts** every cached agent still on `nous/welcome` and clears any
session model override pinned to it. It does not switch agents in place and does not write a model
override: `settle_after_upgrade` already moved `model.default`/`model.base_url` in the config, every
turn re-resolves the config and the credentials, and `_agent_config_signature` already forces a
rebuild when the route changes — so an in-place swap buys no cache warmth and an override would pin
an expiring access token that nothing refreshes.
## Gateway lifecycle vs. the Desktop app
`hermes serve` (control plane, desktop-spawned child) dies with the app — by design. The messaging
+1 -1
View File
@@ -757,7 +757,7 @@ class GatewayBusySessionMixin:
_PLAIN_COMMANDS = (
"status", "context", "restart", "approve", "deny", "pause", "agents", "bg", "btw",
"kanban", "subgoal", "heartbeat", "busy", "yolo", "verbose", "footer", "help",
"commands", "profile", "update", "version",
"commands", "profile", "login", "update", "version",
)
# Dispatched only on the idle path (busy dispatch has its own allowlist).
_IDLE_COMMANDS = (
+1 -1
View File
@@ -758,7 +758,7 @@ class GatewayNotificationsMixin:
except Exception as exc:
logger.debug("Free tier startup line skipped: %s", exc)
return None
return "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade"
return "Inference: Nous free tier (nous/welcome). Sign in for more: /login"
async def _send_home_channel_startup_notifications(
self, *, skip_targets: Optional[set[tuple[str, str, Optional[str]]]] = None
+12 -5
View File
@@ -1050,15 +1050,22 @@ class SessionStore(
def set_model_override(self, session_key: str, override: Optional[Dict[str, Any]]) -> None:
"""Persist (or clear, with ``None``) the /model override; non-secret keys only."""
from dataclasses import replace
cleaned = sanitize_model_override(override)
def _apply(entry: SessionEntry):
if entry.model_override == cleaned:
return False
with self._lock:
entry = self._entry_locked(session_key)
if entry is None or entry.model_override == cleaned:
return
# Publish only after persistence so a failed clear remains retryable.
data, generation = self._snapshot_routing_locked()
# Snapshot reconciliation may replace the entry after database recovery.
entry = self._entries[session_key]
data[session_key] = replace(entry, model_override=cleaned).to_dict()
self._persist_routing_data(data, generation)
entry.model_override = cleaned
self._update_entry(session_key, _apply)
def get_model_override(self, session_key: str) -> Optional[Dict[str, str]]:
"""Return the persisted /model override for *session_key*, if any."""
with self._lock:
+2
View File
@@ -28,6 +28,7 @@ from gateway.session_transcript import TranscriptReadError
from gateway.slash_commands_goals import GatewayGoalCommandsMixin
from gateway.slash_commands_model import GatewayModelCommandsMixin
from gateway.slash_commands_session import GatewaySessionCommandsMixin
from gateway.slash_commands_login import GatewayLoginCommandsMixin
from gateway.slash_commands_status import HISTORY_UNREADABLE, GatewayStatusCommandsMixin
from hermes_cli.config import atomic_config_write, cfg_get
from utils import atomic_json_write, is_truthy_value
@@ -158,6 +159,7 @@ def _home_thread_from_source(source) -> Optional[str]:
class GatewaySlashCommandsMixin(
GatewayLoginCommandsMixin,
GatewayModelCommandsMixin,
GatewaySessionCommandsMixin,
GatewayStatusCommandsMixin,
+210
View File
@@ -0,0 +1,210 @@
"""The off-turn, paired-DM-only ``/login`` command."""
from __future__ import annotations
import asyncio
import concurrent.futures
import contextlib
import dataclasses
import logging
import threading
from contextvars import copy_context
from typing import Any
from uuid import uuid4
from gateway.run_agent_cache import _first_agent
from gateway.slash_access import policy_for_source
from hermes_cli import anon_auth
logger = logging.getLogger("gateway.run")
_LOGIN_SINGLE = "login"
_LOCK_TYPE = type(threading.Lock())
@dataclasses.dataclass
class _SignInAttempt:
attempt_id: str
key: tuple
source: Any
cancelled: bool = False
# These adapters use "dm" for a broadcast topic, a channel, or an agent peer. Sending a consent
# link and sign-in code there would publish them rather than deliver them to one person.
_LOGIN_BLOCKED_PLATFORMS = frozenset({"ntfy", "raft", "a2a"})
class GatewayLoginCommandsMixin:
_LOGIN_SINGLE = _LOGIN_SINGLE
def _login_registry(self):
"""Return the lazily created registry used by normal and bare test runners."""
lock = getattr(self, "_login_lock", None)
if not isinstance(lock, _LOCK_TYPE):
lock = self._login_lock = threading.Lock()
attempts = getattr(self, "_login_attempts", None)
if not isinstance(attempts, dict):
attempts = self._login_attempts = {}
return lock, attempts
def _login_executor(self):
executor = getattr(self, "_login_exec", None)
if executor is None or getattr(executor, "_shutdown", False):
executor = self._login_exec = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix="hermes-login")
return executor
async def _run_login_blocking(self, func):
ctx = copy_context()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._login_executor(), ctx.run, func)
async def _handle_login_command(self, event) -> str:
src = event.source
platform = str(getattr(src.platform, "value", src.platform)).lower()
paired_dm = (
getattr(src, "chat_type", None) in {"dm", "private"}
and bool(getattr(src, "chat_id", None))
and platform not in _LOGIN_BLOCKED_PLATFORMS
)
if not paired_dm:
return anon_auth.LOGIN_DM_ONLY
policy = policy_for_source(self.config, src)
if policy.enabled and not policy.is_admin(getattr(src, "user_id", None)):
return anon_auth.LOGIN_NOT_ALLOWED
key = (str(getattr(src.platform, "value", src.platform)), str(src.chat_id),
str(getattr(src, "user_id", "") or ""))
lock, attempts = self._login_registry()
# The private executor has one worker and the live attempt may be polling on it for the
# code's full lifetime. Trip its hook before queueing the local state read, otherwise a
# replacement command would wait behind the very attempt it needs to stop.
with lock:
live = attempts.get(_LOGIN_SINGLE)
if live is not None and live.key != key:
return anon_auth.LOGIN_BUSY_ELSEWHERE
if live is not None:
live.cancelled = True
state = await self._run_login_blocking(anon_auth.current_nous_state)
if state and not anon_auth.is_guest_state(state):
return anon_auth.UPGRADE_ALREADY_SIGNED_IN
with lock:
live = attempts.get(_LOGIN_SINGLE)
if live is not None and live.key != key:
return anon_auth.LOGIN_BUSY_ELSEWHERE
if live is not None:
live.cancelled = True
attempt = _SignInAttempt(
attempt_id=uuid4().hex[:8], key=key, source=src)
attempts[_LOGIN_SINGLE] = attempt
self._retain_background_task(asyncio.create_task(self._run_login(attempt)))
return anon_auth.UPGRADE_START
async def _run_login(self, attempt: _SignInAttempt) -> None:
lock, attempts = self._login_registry()
def _cancelled() -> bool:
with lock:
return attempt.cancelled
gen = anon_auth.run_sign_in(
timeout_seconds=15.0,
cancelled=_cancelled,
cancel_wins_after_promotion=False,
)
pushed_terminal = False
try:
while True:
try:
state = await self._run_login_blocking(lambda: next(gen, None))
except RuntimeError as exc:
logger.warning(
"/login %s: executor unavailable, stopping: %s", attempt.attempt_id, exc)
return
if state is None:
return
await self._render_login_state(attempt, state)
if state.terminal:
pushed_terminal = True
return
except asyncio.CancelledError:
with lock:
attempt.cancelled = True
raise
except Exception:
logger.warning("/login attempt %s failed", attempt.attempt_id, exc_info=True)
if not pushed_terminal:
with contextlib.suppress(Exception):
await self._push_login(attempt, anon_auth.UPGRADE_NOT_COMPLETED)
finally:
with lock:
if attempts.get(_LOGIN_SINGLE) is attempt:
attempts.pop(_LOGIN_SINGLE, None)
with contextlib.suppress(Exception):
gen.close()
async def _push_login(self, attempt: _SignInAttempt, text: str) -> None:
"""Push one notice without allowing a transport failure to abort the state drain."""
try:
await self._deliver_platform_notice(attempt.source, text)
except Exception:
logger.warning("/login %s: push failed", attempt.attempt_id, exc_info=True)
async def _render_login_state(self, attempt: _SignInAttempt, state) -> None:
if isinstance(state, anon_auth.Code):
await self._push_login(attempt, state.link)
await self._push_login(attempt, state.code)
await self._push_login(attempt, state.copy_with_wait)
return
if isinstance(state, anon_auth.Waiting):
return
if isinstance(state, anon_auth.Completed):
copy = state.copy
if state.model_changed and state.model:
failed = await self._sweep_sessions_off_welcome()
if failed:
copy += "\nSome chats are still on the free tier; use /model in those chats to switch."
await self._push_login(attempt, copy)
return
await self._push_login(attempt, state.copy)
async def _sweep_sessions_off_welcome(self) -> int:
"""Clear durable overrides before eviction; return the number of failed clears."""
lock = getattr(self, "_agent_cache_lock", None)
cache = getattr(self, "_agent_cache", None) or {}
with (lock or contextlib.nullcontext()):
entries = list(cache.items())
keys = [
key for key, entry in entries
if (agent := _first_agent(entry)) is not None
and str(getattr(agent, "provider", "")) == "nous"
and str(getattr(agent, "model", "")) == anon_auth.GUEST_MODEL
]
failed = 0
for key in keys:
overrides = self._session_model_overrides
override = overrides.get(key) or {}
if str(override.get("model") or "") == anon_auth.GUEST_MODEL:
for attempt in range(2):
try:
await self.async_session_store.set_model_override(key, None)
break
except Exception:
if attempt == 1:
logger.warning(
"/login: failed to clear free-tier override for %s", key, exc_info=True)
else:
# Retain the live route while disk still pins it, including on a rebuild.
failed += 1
continue
overrides.pop(key, None)
try:
self._evict_cached_agent(key)
except Exception:
logger.warning("/login: failed to evict free-tier session %s", key, exc_info=True)
return failed
+11
View File
@@ -247,6 +247,17 @@ class GatewayStatusCommandsMixin:
lines.append(t("gateway.status.model_provider", model=model_name, provider=provider_name))
elif model_name:
lines.append(t("gateway.status.model", model=model_name))
try:
from hermes_cli.auth import resolve_provider
from hermes_cli.anon_auth import guest_carries_inference
free_tier_active = await self._run_in_executor_with_context(
lambda: resolve_provider("auto") == "nous" and guest_carries_inference()
)
if free_tier_active:
lines.append(t("gateway.status.free_tier"))
except Exception:
pass
from agent.context_breakdown import context_display_source
mark = "~" if context_display_source(getattr(status_agent, "context_compressor", None)) != "provider_usage" else ""
if context_total:
+22
View File
@@ -137,3 +137,25 @@ Sign-in completion is one function, `settle_after_upgrade`, called by every call
account over a free-tier identity (CLI `upgrade_guest`, the desktop poller): it moves a config on the
welcome route to the account's host and the tier's recommended default
(`models.recommended_nous_default_model`, shared with `GET /api/model/recommended-default`).
The shared flow, states, and copy live in `anon_sign_in.py`; CLI rendering lives in
`anon_sign_in_cli.py`. `anon_auth.py` keeps identity, promotion polling, and settlement, and
re-exports the existing sign-in API. The flow resolves identity and persistence collaborators
through `anon_auth` at call time to preserve module-attribute monkeypatch seams.
The sign-in itself is one composition: `anon_auth.run_sign_in()` yields `SignInState`s (`Code`,
`Waiting`, `Completed`, `Declined`, `Superseded`, `TimedOut`, `Retired`, `Failed`,
`AlreadySignedIn`, `Unavailable`). It reads the current state itself, holds one absolute deadline
across both waits, persists only after a completed promotion **and** a token grant, runs
`settle_after_upgrade` exactly once per completion, and never lets a persist or settle failure
escape as an exception — it becomes `Failed`. Every state carries its own `.copy` (the chat form,
which never contains a raw exception, a URL or a `hermes` verb) and `.copy_terminal`, so no caller
maps a reason to a string. `cancelled()` stops an attempt; `cancel_wins_after_promotion` decides
what happens when the server had already completed the transfer — the desktop keeps `True` (a
DELETE means "not on this machine"), the gateway passes `False` (a supersede must not discard a
transfer the user actually approved). `scope` is entered only around the precondition and persist
blocks, never across a `yield` or a network wait, because `run_in_executor` does not carry
contextvars. `upgrade_guest` (`hermes auth upgrade`), the CLI `/login` handler and the desktop
promotion poller are renderers over it; a surface that needs the cancel check and the save to be
atomic passes `persist_guard`. The desktop's plain "connect another Nous account" device-code login
is a separate path (`_nous_plain_poller`) and must stay one.
+105 -111
View File
@@ -13,8 +13,8 @@ token acquisition (re-exchange the ``anon_`` credential; there is no refresh tok
(the welcome inference host, single model ``nous/welcome``).
Users are never shown the words guest / anonymous / account for this state: surfaces say
"Nous · free tier". The one user-facing verb is ``hermes auth upgrade`` (sign in, keeping the
identity's connectors).
"Nous · free tier". Two user-facing verbs reach the same flow, both keeping the identity's
connectors: ``hermes auth upgrade`` in a terminal and ``/login`` inside a chat.
Lifecycle lives in ONE primitive, :func:`ensure_portal_identity`: adopt what the shared store already
holds, else mint under the shared-store lock. It is the only minter; nothing else calls
@@ -28,7 +28,7 @@ import os
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from typing import Any, Callable, Dict, Optional
from hermes_cli.auth_constants import (
AuthError, DEFAULT_NOUS_PORTAL_URL, _decode_jwt_claims, httpx)
@@ -49,7 +49,7 @@ FORCE_GUEST_ENV = "HERMES_FORCE_GUEST"
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"
UPGRADE_HINT = "Run `hermes auth upgrade` to sign in with a Nous account."
UPGRADE_HINT = "Run `hermes auth upgrade` to sign in with a Nous account, or /login inside a chat."
FREE_TIER_NOT_SIGNED_IN = (
"You're not signed in. Free inference and connectors are always on. "
"Run `hermes auth` to sign in with a Nous account.")
@@ -431,7 +431,7 @@ def clear_dead_guest(reason: str, *, dead_token: Optional[str] = None) -> None:
GUEST_NOTICE_FLAG = "guest_notice_shown"
FREE_TIER_AVAILABLE_NOTICE = (
"Free Nous inference and connectors are now available. "
"`hermes model` to try them, `hermes auth upgrade` to sign in.")
"/model to try them, /login to sign in.")
def guest_notice_pending() -> bool:
@@ -474,20 +474,6 @@ def mark_guest_notice_shown() -> bool:
# over the guest singleton and the shared store. The server never reports expiry: our own
# ``expires_in`` clock ends the wait. User-facing copy never says guest / anonymous / claim.
UPGRADE_START = "Sign in to keep your connectors and unlock more."
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."
UPGRADE_NOT_COMPLETED = "Sign-in did not complete; run the command again."
UPGRADE_UNAVAILABLE = "The free tier is not available right now; run `hermes auth add nous` to sign in."
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_busy": "The transfer could not run; run the command again.",
}
_RETIRED_REASONS = frozenset({"account_retired", "account_not_anonymous"})
UPGRADED_AUTH_METHOD = "oauth_device_code"
@@ -512,27 +498,68 @@ def _retry_after_seconds(response: httpx.Response, default: float) -> float:
return default
def _sleep_until(wake: float, cancelled: Optional[Callable[[], bool]]) -> bool:
"""Sleep until the monotonic time *wake*. Returns True when *cancelled* fired first.
Without a hook this is one plain :func:`time.sleep`. With one the sleep is cut into <= 1 s
ticks so an attempt stopped from outside ends in about a second instead of blocking to the
sign-in code's own expiry.
"""
if cancelled is None:
remaining = wake - time.monotonic()
if remaining > 0:
time.sleep(remaining)
return False
while True:
if cancelled():
return True
remaining = wake - time.monotonic()
if remaining <= 0:
return False
time.sleep(min(1.0, remaining))
def wait_for_promotion(
client: httpx.Client, portal_base_url: str, claim_code: str, *, expires_in: int, interval: int,
cancelled: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]:
"""Poll ``POST /api/anonymous/promotion-status`` until it leaves ``pending`` or our clock runs out.
Returns the final status payload; ``{"status": "timeout"}`` when ``expires_in`` elapsed. 429 honours
``Retry-After``; other non-2xx statuses raise through :func:`_raise_for_anon_status`.
*cancelled* is an optional hook a surface passes to stop an attempt it no longer wants (a newer
sign-in replaced it, the user cancelled, the process is shutting down). It is polled at the top
of every iteration and on a <= 1 s tick while sleeping; once it has fired this call returns
``{"status": "cancelled"}`` for every outcome except a ``completed`` transfer already in hand,
which is reported so the caller's ``cancel_wins_after_promotion`` ruling can decide it.
Passing nothing is today's behaviour.
"""
deadline = time.monotonic() + max(1, int(expires_in))
wait = max(0, int(interval))
while time.monotonic() < deadline:
if cancelled is not None and cancelled():
return {"status": "cancelled"}
response = client.post(
f"{portal_base_url.rstrip('/')}/api/anonymous/promotion-status", headers=_anon_headers(),
json={"claim_code": claim_code})
if response.status_code == 429:
time.sleep(min(_retry_after_seconds(response, default=max(1, wait)), max(0.0, deadline - time.monotonic())))
retry = min(_retry_after_seconds(response, default=max(1, wait)),
max(0.0, deadline - time.monotonic()))
if _sleep_until(time.monotonic() + retry, cancelled):
return {"status": "cancelled"}
continue
payload = _raise_for_anon_status(response, action="sign-in")
if str(payload.get("status") or "unknown") != "pending":
status = str(payload.get("status") or "unknown")
if status != "pending":
# A completed transfer is already committed on the account service; report it even when
# the hook fired during this request. run_sign_in's cancel_wins_after_promotion
# rules what each surface does with it. Every other terminal outcome loses to a cancel.
if status != "completed" and cancelled is not None and cancelled():
return {"status": "cancelled"}
return payload
time.sleep(wait)
if _sleep_until(time.monotonic() + wait, cancelled):
return {"status": "cancelled"}
return {"status": "timeout"}
@@ -613,95 +640,62 @@ def settle_after_upgrade(account_state: Dict[str, Any]) -> Dict[str, Any]:
return {"model": model, "changed": True}
def _print_promotion_outcome(outcome: Dict[str, Any]) -> None:
status = str(outcome.get("status") or "unknown")
reason = str(outcome.get("reason") or "")
if status == "timeout":
print(UPGRADE_TIMED_OUT)
return
print(UPGRADE_REASON_COPY.get(reason, UPGRADE_NOT_COMPLETED))
if reason in _RETIRED_REASONS:
clear_dead_guest("retired")
def _poll_for_token(*args, **kwargs) -> Dict[str, Any]:
"""Keep both the sign-in module seam and the device-flow seam live at call time."""
from hermes_cli.auth_device_flow import _poll_for_token as poll
return poll(*args, **kwargs)
def upgrade_guest(args) -> int:
"""``hermes auth upgrade``: sign in with a Nous account, transferring the free tier's connectors.
def persist_nous_credentials(*args, **kwargs):
"""Keep the existing auth_nous persistence seam behind the sign-in entry point."""
from hermes_cli.auth_nous import persist_nous_credentials as persist
return persist(*args, **kwargs)
Returns 0 on success (or when already signed in), 1 otherwise. Never persists anything unless the
promotion completed AND the token grant succeeded.
"""
from hermes_cli.auth import PROVIDER_REGISTRY, _resolve_verify
from hermes_cli.auth_device_flow import (
_is_remote_session, _poll_for_token, _print_device_code_instructions, _request_device_code)
from hermes_cli.auth_nous import _nous_http_client, persist_nous_credentials
timeout_seconds = float(getattr(args, "timeout", None) or 15.0)
open_browser = not getattr(args, "no_browser", False) and not _is_remote_session()
state = current_nous_state()
if state and not is_guest_state(state):
print(UPGRADE_ALREADY_SIGNED_IN)
return 0
if not state:
try:
state = ensure_portal_identity(blocking=True, timeout_seconds=timeout_seconds)
except AuthError as exc:
print(f"{UPGRADE_UNAVAILABLE} ({exc})")
return 1
if not is_guest_state(state):
print(UPGRADE_UNAVAILABLE)
return 1
anon_token = str(state.get("anon_token") or "")
portal = (state.get("portal_base_url") or _portal_base_url()).rstrip("/")
pconfig = PROVIDER_REGISTRY["nous"]
client_id, scope = pconfig.client_id, pconfig.scope
verify = _resolve_verify(insecure=None, ca_bundle=None, auth_state=None)
print(UPGRADE_START)
try:
with _nous_http_client(timeout_seconds, verify) as client:
device = _request_device_code(client, portal, client_id, scope)
intent = 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 Move button. Relative paths are
# portal-relative.
claim_url = str(intent.get("claim_url") or "")
if claim_url.startswith("/"):
claim_url = f"{portal}{claim_url}"
_print_device_code_instructions(
claim_url or str(device["verification_uri_complete"]), str(intent["claim_code"]),
open_browser=open_browser, swallow_open_errors=True)
print(f" {UPGRADE_DO_NOT_SHARE}")
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)
print("Waiting for sign-in...")
outcome = wait_for_promotion(client, portal, intent["claim_code"], expires_in=expires_in, interval=interval)
if str(outcome.get("status")) != "completed":
_print_promotion_outcome(outcome)
return 1
token_data = _poll_for_token(
client=client, portal_base_url=portal, client_id=client_id,
device_code=str(device["device_code"]), expires_in=max(1, expires_in), poll_interval=interval)
account_state = _account_state_from_token(
token_data, portal_base_url=portal, client_id=client_id, scope=scope, verify=verify,
timeout_seconds=timeout_seconds)
except AnonCredentialDead:
print(UPGRADE_REASON_COPY["account_retired"])
clear_dead_guest("retired")
return 1
except TimeoutError:
print(UPGRADE_TIMED_OUT)
return 1
except KeyboardInterrupt:
print("\nSign-in cancelled.")
return 130
except Exception as exc:
print(f"Sign-in failed: {exc}")
return 1
persist_nous_credentials(account_state)
settled = settle_after_upgrade(account_state)
email = str(outcome.get("account_email") or "").strip()
print(f"Signed in as {email}. Your connectors are kept." if email else "Signed in. Your connectors are kept.")
if settled["changed"]:
print(f"Default model is now {settled['model']}." if settled["model"]
else "No default model is set yet; run `hermes model` to pick one.")
return 0
# Public sign-in imports remain here for existing callers and module-attribute patches.
# The flow imports this module only inside calls, so either module can be imported first.
from hermes_cli.anon_sign_in import ( # noqa: E402
AlreadySignedIn as AlreadySignedIn,
Code as Code,
Completed as Completed,
Declined as Declined,
FREE_TIER_RATE_LIMIT_CHAT as FREE_TIER_RATE_LIMIT_CHAT,
Failed as Failed,
LOGIN_BUSY_ELSEWHERE as LOGIN_BUSY_ELSEWHERE,
LOGIN_COMMAND as LOGIN_COMMAND,
LOGIN_DM_ONLY as LOGIN_DM_ONLY,
LOGIN_NOT_ALLOWED as LOGIN_NOT_ALLOWED,
LOGIN_STARTING as LOGIN_STARTING,
Retired as Retired,
SignInState as SignInState,
Superseded as Superseded,
TimedOut as TimedOut,
UPGRADE_ALREADY_SIGNED_IN as UPGRADE_ALREADY_SIGNED_IN,
UPGRADE_CANCELLED as UPGRADE_CANCELLED,
UPGRADE_DO_NOT_SHARE as UPGRADE_DO_NOT_SHARE,
UPGRADE_NOT_COMPLETED as UPGRADE_NOT_COMPLETED,
UPGRADE_NO_DEFAULT_CHAT as UPGRADE_NO_DEFAULT_CHAT,
UPGRADE_NO_DEFAULT_TERMINAL as UPGRADE_NO_DEFAULT_TERMINAL,
UPGRADE_REASON_COPY as UPGRADE_REASON_COPY,
UPGRADE_START as UPGRADE_START,
UPGRADE_TIMED_OUT as UPGRADE_TIMED_OUT,
UPGRADE_UNAVAILABLE as UPGRADE_UNAVAILABLE,
UPGRADE_UNAVAILABLE_CHAT as UPGRADE_UNAVAILABLE_CHAT,
UPGRADE_WAITING as UPGRADE_WAITING,
UPGRADE_WAITING_UP_TO as UPGRADE_WAITING_UP_TO,
Unavailable as Unavailable,
Waiting as Waiting,
_RETIRED_REASONS as _RETIRED_REASONS,
_default_persist_guard as _default_persist_guard,
_outcome_state as _outcome_state,
format_wait_line as format_wait_line,
run_sign_in as run_sign_in,
)
from hermes_cli.anon_sign_in_cli import ( # noqa: E402
drain_sign_in_copy as drain_sign_in_copy,
render_sign_in_cli as render_sign_in_cli,
render_sign_in_cli_code as render_sign_in_cli_code,
upgrade_guest as upgrade_guest,
)
FREE_TIER_STATUS_LINE = f"{FREE_TIER_LABEL} \u00b7 {GUEST_MODEL} \u00b7 {LOGIN_COMMAND} to sign in"
+418
View File
@@ -0,0 +1,418 @@
"""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 to keep your connectors and unlock more."
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."
UPGRADE_NOT_COMPLETED = "Sign-in did not complete; run the command again."
UPGRADE_UNAVAILABLE = "The free tier is not available right now; run `hermes auth add nous` to sign in."
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_busy": "The transfer could not run; run the command again.",
}
_RETIRED_REASONS = frozenset({"account_retired", "account_not_anonymous"})
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."
FREE_TIER_RATE_LIMIT_CHAT = (
"Nous free tier rate limit active \u2014 resets in {reset}. "
"Sign in with a Nous account for higher limits: /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}. Your connectors are kept." if self.email
else "Signed in. Your connectors are kept."]
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: str = ""
detail: str = ""
kind: ClassVar[str] = "failed"
@property
def copy(self) -> str:
return UPGRADE_REASON_COPY.get(self.reason, UPGRADE_NOT_COMPLETED)
@property
def copy_terminal(self) -> str:
ruled = UPGRADE_REASON_COPY.get(self.reason)
if ruled:
return ruled
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 _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 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``.
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:
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 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
# 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.
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(reason="", detail=str(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")))
+82
View File
@@ -0,0 +1,82 @@
"""Terminal and chat rendering for the shared sign-in flow."""
from __future__ import annotations
import contextlib
from typing import Iterator, Optional
from hermes_cli.anon_sign_in import Code, SignInState, UPGRADE_CANCELLED, UPGRADE_START
def render_sign_in_cli_code(
state: Code, *, open_browser: bool = False, chat: bool = True, printer=print) -> None:
"""Print the ``Code`` state alone: the link, the code, and the do-not-share line."""
from hermes_cli.auth_device_flow import _print_device_code_instructions
_print_device_code_instructions(
state.link, state.code, open_browser=open_browser, swallow_open_errors=True)
printer(f" {state.copy_with_wait if chat else state.copy}")
def drain_sign_in_copy(gen: Iterator[SignInState], *, chat: bool = True, on_terminal=None) -> str:
"""Iterate *gen* to its terminal state, discarding ``Waiting``, and return that state's copy.
``on_terminal`` is called with each terminal state — the CLI uses it to move the running
session off the free tier's model. A raising hook never costs the caller its copy.
"""
copy = ""
for state in gen:
if state.terminal:
copy = state.copy if chat else state.copy_terminal
if on_terminal is not None:
with contextlib.suppress(Exception):
on_terminal(state)
return copy
def render_sign_in_cli(
*, timeout_seconds: float = 15.0, open_browser: bool = False, chat: bool = False,
states: Optional[Iterator[SignInState]] = None, printer=print) -> int:
"""Print a sign-in; returns the process exit code (0 ok, 1 not, 130 interrupted).
``chat=True`` renders the in-chat wording; *states* lets a caller hand in a partially drained
:func:`run_sign_in` generator; *printer* lets a caller pin the output target.
"""
from hermes_cli import anon_auth as _core
gen = states if states is not None else _core.run_sign_in(timeout_seconds=timeout_seconds)
started = False
try:
for state in gen:
if not started and not state.precondition:
printer(UPGRADE_START)
started = True
if state.kind == "code":
_core.render_sign_in_cli_code(state, open_browser=open_browser, chat=chat, printer=printer)
continue
if state.kind == "waiting":
if not chat: # in a chat the wait line already rode the code
printer(state.copy)
continue
if state.terminal:
printer(state.copy if chat else state.copy_terminal)
return 0 if state.ok else 1
except KeyboardInterrupt:
with contextlib.suppress(Exception):
gen.close()
printer(UPGRADE_CANCELLED)
return 130
return 1
def upgrade_guest(args) -> int:
"""``hermes auth upgrade``: sign in with a Nous account, transferring the free tier's connectors.
Returns 0 on success (or when already signed in), 1 otherwise, 130 on Ctrl-C. Never persists
anything unless the transfer completed AND the token grant succeeded.
"""
from hermes_cli import anon_auth as _core
from hermes_cli.auth_device_flow import _is_remote_session
timeout_seconds = float(getattr(args, "timeout", None) or 15.0)
open_browser = not getattr(args, "no_browser", False) and not _is_remote_session()
return _core.render_sign_in_cli(
timeout_seconds=timeout_seconds, open_browser=open_browser, chat=False)
+1 -1
View File
@@ -287,7 +287,7 @@ def _poll_for_token(
on_non_json_error=lambda _r: RuntimeError(
"Token endpoint returned a non-JSON error response"),
# Enriched at the SOURCE so the CLI login and the dashboard/desktop poller
# (web_server._nous_poller surfaces str(e) to the UI) both inherit the guidance.
# (web_server_oauth._nous_promotion_poller surfaces it to the UI) both inherit the guidance.
on_timeout=lambda: TimeoutError(_nous_device_auth_timeout_message(portal_base_url)))
+65 -11
View File
@@ -359,17 +359,22 @@ def _db_unavailable_line() -> str:
return f" {format_session_db_unavailable()}"
def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_note) -> None:
"""Print a worker-thread result (/bg, /btw) into the scrollback: accent rules around
def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_note, console=None) -> None:
"""Print a worker-thread result (/bg, /btw, /login) into the scrollback: accent rules around
``header_lines``, then ``body`` in a skinned Rich panel (or ``empty_note``).
Forces a TUI refresh first so the spinner/status bar don't overlap the output."""
from cli import ChatConsole, _accent_hex, _maybe_remap_for_light_mode, _render_final_assistant_content
_refresh_tui_before_print(cli)
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
_cp(*header_lines)
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
rich_console = console or ChatConsole()
rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
if console is None:
_cp(*header_lines)
else:
for line in header_lines:
console.print(line)
rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
if not body:
return _cp(empty_note)
return _cp(empty_note) if console is None else console.print(empty_note)
try:
from hermes_cli.skin_engine import get_active_skin
_skin = get_active_skin()
@@ -378,7 +383,7 @@ def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_not
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
except Exception:
label, _resp_color, _resp_text = "⚕ Hermes", "#CD7F32", "#FFF8DC"
ChatConsole().print(Panel(
rich_console.print(Panel(
_render_final_assistant_content(body, mode=cli.final_response_markdown),
title=f"[{_resp_color} bold]{label} {title_suffix}[/]", title_align="left",
border_style=_resp_color, style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4),
@@ -1982,20 +1987,26 @@ class CLICommandsMixin:
thread.start()
def _side_worker(self, produce, *, name, fail_label, header_lines, title_suffix, empty_note,
bell=False, on_done=None) -> threading.Thread:
"""Daemon thread for /bg and /btw: ``produce()`` returns the body to print in a side-result
bell=False, on_done=None, console=None) -> threading.Thread:
"""Daemon thread for /bg, /btw and /login: ``produce()`` returns the body to print in a side-result
panel; failures print ``fail_label`` failed; the TUI is always re-invalidated afterwards."""
def run():
try:
body = produce()
_print_side_result_panel(self, header_lines=header_lines, body=body,
title_suffix=title_suffix, empty_note=empty_note)
title_suffix=title_suffix, empty_note=empty_note,
console=console)
if bell and self.bell_on_complete:
sys.stdout.write("\a")
sys.stdout.flush()
except Exception as e:
_refresh_tui_before_print(self)
_cp(f" ❌ {fail_label} failed: {e}")
line = f" ❌ {fail_label} failed: {e}"
# Same console the caller captured, so a late failure can't splice into a later command.
if console is not None:
console.print(line, markup=False)
else:
_cp(line)
finally:
if on_done is not None:
on_done()
@@ -2004,6 +2015,49 @@ class CLICommandsMixin:
return threading.Thread(target=run, daemon=True, name=name)
def _handle_login_command(self, cmd_original: str) -> None:
"""Start an in-chat sign-in without blocking the input loop while approval is pending."""
from hermes_cli import anon_auth
# Pin the output target now. Under the live TUI ``self.console`` writes straight to
# patch_stdout's StdoutProxy, which mangles Rich's escapes — there ``None`` keeps the
# panel on the ``_cprint`` path. Only the slash worker (``_app`` is None) swaps the console.
console = None if getattr(self, "_app", None) else getattr(self, "console", None)
_cp(f" {anon_auth.LOGIN_STARTING}")
gen = anon_auth.run_sign_in(timeout_seconds=8.0)
try:
first = next(gen, None)
except KeyboardInterrupt:
with suppress(Exception):
gen.close()
return _cp(anon_auth.UPGRADE_CANCELLED)
if first is None:
return
if first.terminal:
return _cp(f" {first.copy}")
anon_auth.render_sign_in_cli_code(first, chat=True, printer=_cp)
def _settle_session_model(state) -> None:
"""A completed sign-in moved this profile onto the account: the welcome host is gone and
the portal serves ``nous/welcome`` as a paid model, so a session still carrying it must
move too — the CLI counterpart of the gateway's on-``Completed`` sweep. Only the free
tier's own model is replaced; a model the user picked while the sign-in was pending
stands. Writing ``self.model`` is enough: ``chat()`` compares the turn-route signature
and rebuilds the agent on the next turn, so a turn already in flight keeps the agent it
started with. ``getattr``: tests drive this handler with minimal shells.
"""
if state.kind != "completed" or not getattr(state, "model_changed", False):
return
if str(getattr(self, "model", "") or "") == anon_auth.GUEST_MODEL:
# "" when the settle cleared the default: _ensure_runtime_credentials then applies
# the provider's silent default, which is what settle_after_upgrade documents.
self.model = state.model or ""
thread = self._side_worker(
lambda: anon_auth.drain_sign_in_copy(gen, chat=True, on_terminal=_settle_session_model),
name="login", fail_label="Sign-in", header_lines=[" Sign-in"],
title_suffix="(sign-in)", empty_note=" (No result)", console=console)
thread.start()
def _handle_btw_command(self, cmd: str):
"""Handle /btw <question> — answer a side question about this conversation from a
history snapshot via a one-shot auxiliary call. The live session is never touched
+9
View File
@@ -307,6 +307,15 @@ class CLISessionMixin:
if title:
lines.append(f"Title: {title}")
lines.append(f"Model: {model} ({provider})")
try:
from agent.i18n import t
from hermes_cli.auth import resolve_provider
from hermes_cli.anon_auth import guest_carries_inference
if resolve_provider("auto") == "nous" and guest_carries_inference():
lines.append(t("gateway.status.free_tier"))
except Exception:
pass
optional = (("Reasoning", reasoning_label), ("Approvals", approval_label), ("Context", ctx_label))
for label, value in optional:
if value:
+2
View File
@@ -278,6 +278,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[reset [--force]]"),
CommandDef("subscription", "View your Nous plan and change it in the browser", "Info",
cli_only=True, aliases=("upgrade",)),
CommandDef("login", "Sign in with a Nous account (keeps your connectors)", "Info",
busy_policy="dispatch", desktop="settings"),
CommandDef("topup", "Show your Nous balance and manage billing on the portal", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]", desktop="advanced"),
+1 -1
View File
@@ -368,7 +368,7 @@ _SLACK_RESERVED_COMMANDS = frozenset({
# parity test reads this set. Aliases are never pinned ahead of canonicals.
_SLACK_VIA_HERMES_ONLY = frozenset({
"topup", "moa", "debug", "egress", "init", "version", "diff", "update", "heartbeat",
"refine", "review", "pause", "whoami", "platform", "insights"})
"refine", "review", "pause", "whoami", "platform", "insights", "login"})
def _sanitize_slack_name(raw: str) -> str:
+3 -2
View File
@@ -2336,8 +2336,9 @@ DEFAULT_CONFIG = {
# NousResearch/api#227), so chat is the default until that is fixed.
"anthropic_wire": "chat",
# Nous free tier: with no other provider configured, Hermes sets up a free Nous identity on
# first use (inference on nous/welcome + connectors) and offers `hermes auth upgrade` to
# sign in. false turns the free tier off entirely: nothing is set up and nothing is used.
# first use (inference on nous/welcome + connectors) and offers `/login` (terminal:
# `hermes auth upgrade`) to sign in. false turns the free tier off entirely: nothing is set
# up and nothing is used.
"guest": True,
},
# Google Vertex AI (Gemini). Auth is OAuth2 from a service-account JSON or ADC, NOT an API key;
+1 -1
View File
@@ -1227,7 +1227,7 @@ def _route_from_model_input(st: _Switch) -> Optional[ModelSwitchResult]:
if route_is_welcome_host(st.current_base_url) and st.new_model != GUEST_MODEL:
return st.fail(
f"{st.new_model} needs a Nous account or an API key. "
"Run `hermes auth upgrade` to sign in, or `hermes model` to pick another provider.")
"Use /login to sign in, or /model to pick another provider.")
config_routed = _route_configured_provider(st) # d.5 — deliberately NOT gated on ``not is_custom``
if isinstance(config_routed, ModelSwitchResult):
return config_routed
+8 -4
View File
@@ -70,6 +70,7 @@ class NousToolAccessInfo:
_ANON_ACCOUNT_TIER = "anonymous"
# Every billing / top-up / entitlement surface says exactly this for the free tier (R-USR-1).
FREE_TIER_NEEDS_ACCOUNT = "This needs a Nous account. Run `hermes auth upgrade`."
FREE_TIER_NEEDS_ACCOUNT_CHAT = "This needs a Nous account. Use /login to sign in."
def _is_anonymous_tier(account_info: Optional["NousPortalAccountInfo"]) -> bool:
@@ -161,6 +162,7 @@ def nous_portal_topup_url(account_info: Optional[NousPortalAccountInfo] = None)
def format_nous_portal_entitlement_message(
account_info: Optional[NousPortalAccountInfo], *, capability: str = "this feature",
include_refresh_hint: bool = True, coverage_category: Optional[str] = None,
in_chat: bool = False,
) -> Optional[str]:
"""User-facing guidance for a missing Nous tool-gateway entitlement; ``None`` when entitled.
@@ -171,7 +173,7 @@ def format_nous_portal_entitlement_message(
pool-vs-paid distinction is never surfaced.
"""
if _is_anonymous_tier(account_info):
return FREE_TIER_NEEDS_ACCOUNT
return FREE_TIER_NEEDS_ACCOUNT_CHAT if in_chat else FREE_TIER_NEEDS_ACCOUNT
billing_url = nous_portal_billing_url(account_info)
if account_info is not None:
@@ -216,7 +218,7 @@ def format_nous_portal_entitlement_message(
f"is unavailable. Run `hermes model` to authenticate again; if the problem persists, contact Nous support."
)
if reason == "no_usable_credits" or account_info.paid_service_access is False:
message = _no_paid_access_message(account_info, capability, billing_url)
message = _no_paid_access_message(account_info, capability, billing_url, in_chat=in_chat)
if include_refresh_hint and not account_info.fresh:
message += " If you recently bought credits, run `hermes model` to refresh Hermes."
return message
@@ -226,9 +228,11 @@ def format_nous_portal_entitlement_message(
)
def _no_paid_access_message(account_info: NousPortalAccountInfo, capability: str, billing_url: str) -> str:
def _no_paid_access_message(
account_info: NousPortalAccountInfo, capability: str, billing_url: str, *, in_chat: bool = False,
) -> str:
if _is_anonymous_tier(account_info):
return FREE_TIER_NEEDS_ACCOUNT
return FREE_TIER_NEEDS_ACCOUNT_CHAT if in_chat else FREE_TIER_NEEDS_ACCOUNT
access = account_info.paid_service_access_info or NousPaidServiceAccessInfo()
active, paid = access.has_active_subscription, access.active_subscription_is_paid
labelled = (
+78 -35
View File
@@ -5,6 +5,7 @@ Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch o
"""
import asyncio
import contextlib
import logging
import os
import secrets
@@ -17,7 +18,7 @@ from fastapi import APIRouter, HTTPException, Request
from hermes_cli.web_deps import LateState, late
from hermes_cli.web_server_oauth import (
_external_process_cli_command, _minimax_poller, _nous_poller, _oauth_profile_name, _oauth_sessions, _oauth_sessions_lock, _truncate_token, _xai_device_poller,
_external_process_cli_command, _minimax_poller, _nous_plain_poller, _nous_promotion_poller, _oauth_profile_name, _oauth_sessions, _oauth_sessions_lock, _truncate_token, _xai_device_poller,
)
from hermes_cli.web_models import OAuthSubmitBody
from hermes_cli.web_routers._common import scoped_to_thread
@@ -90,6 +91,12 @@ def _new_oauth_session(provider_id: str, flow: str, profile: Optional[str] = Non
return sid, sess
def _drop_oauth_session(sid: str) -> None:
"""Forget a session that never got started, so the dashboard does not poll a corpse forever."""
with _oauth_sessions_lock:
_oauth_sessions.pop(sid, None)
def _start_poller(target, sid: str, prefix: str = "oauth-poll") -> None:
threading.Thread(target=target, args=(sid,), daemon=True, name=f"{prefix}-{sid[:6]}").start()
@@ -324,50 +331,86 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
async def _start_nous_device_code(profile: Optional[str]) -> Dict[str, Any]:
"""Start a Nous sign-in. Over a free-tier identity (``nous.guest`` on) the same start also registers
the connector transfer with the account service, so the browser leg is that transfer's consent
page and its code, and the poller waits for the transfer before the token grant. Without one it is
the plain device-code flow."""
"""Start a Nous sign-in. Over a free-tier identity (``nous.guest`` on) the whole sign-in is the
shared ``anon_auth.run_sign_in`` flow: this route creates the generator, pulls its first state
(the transfer's consent link and code) and hands that to the UI, then the poller drains the rest.
Without a free-tier identity it is the plain device-code flow."""
from hermes_cli import anon_auth
from hermes_cli.auth import PROVIDER_REGISTRY, _request_device_code
from hermes_cli.web_server_profiles import _profile_scope
from hermes_cli.web_server_profiles import _config_profile_scope, _profile_scope
pconfig = PROVIDER_REGISTRY["nous"]
portal_base_url = (
os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") or pconfig.portal_base_url
).rstrip("/")
with _profile_scope(_oauth_profile_name(profile)):
guest = anon_auth.current_nous_state() if anon_auth.guest_enabled() else None
anon_token = str(guest.get("anon_token") or "") if anon_auth.is_guest_state(guest) else ""
def _start(client):
device = _request_device_code(
client=client, portal_base_url=portal_base_url, client_id=pconfig.client_id, scope=pconfig.scope)
if not anon_token:
return device, None
intent = anon_auth.register_promotion_intent(
client, portal_base_url, anon_token, user_code=str(device["user_code"]),
device_code=str(device["device_code"]))
return device, intent
if not anon_auth.is_guest_state(guest):
device_data = await _httpx_call(lambda client: _request_device_code(
client=client, portal_base_url=portal_base_url, client_id=pconfig.client_id,
scope=pconfig.scope))
expires_in, interval = int(device_data["expires_in"]), int(device_data["interval"])
fields = dict(
device_code=str(device_data["device_code"]), portal_base_url=portal_base_url,
client_id=pconfig.client_id, scope=pconfig.scope,
interval=interval, expires_at=time.time() + expires_in)
return _device_session_started(
"nous", profile, _nous_plain_poller, fields, str(device_data["user_code"]),
str(device_data["verification_uri_complete"]), expires_in, interval)
device_data, intent = await _httpx_call(_start)
user_code = str(device_data["user_code"])
verification_url = str(device_data["verification_uri_complete"])
expires_in, interval = int(device_data["expires_in"]), int(device_data["interval"])
fields = dict(
device_code=str(device_data["device_code"]), portal_base_url=portal_base_url,
client_id=pconfig.client_id, scope=pconfig.scope)
if intent is not None:
claim_url = str(intent.get("claim_url") or "")
if claim_url.startswith("/"):
claim_url = f"{portal_base_url}{claim_url}"
user_code = str(intent["claim_code"])
verification_url = claim_url or verification_url
expires_in = min(expires_in, int(intent.get("expires_in") or expires_in))
interval = int(intent.get("interval") or interval)
fields["claim_code"] = user_code
fields.update(interval=interval, expires_at=time.time() + expires_in)
return _device_session_started(
"nous", profile, _nous_poller, fields, user_code, verification_url, expires_in, interval)
# The session is registered BEFORE the generator exists, so a cancel landing in the start
# window is already visible to the flow's own cancel check and persist guard.
sid, sess = _new_oauth_session("nous", "device_code", profile=profile)
def _cancelled() -> bool:
with _oauth_sessions_lock:
return bool(sess.get("cancelled"))
@contextlib.contextmanager
def _persist_guard():
# The desktop's guarantee: the final cancellation check and the save share one lock.
with _oauth_sessions_lock:
if sess.get("cancelled"):
sess["status"] = "cancelled"
yield False
else:
yield True
gen = anon_auth.run_sign_in(
timeout_seconds=15.0,
cancelled=_cancelled,
# A DELETE from this machine means "not here": nothing is persisted and the install
# re-mints a free tier on next use.
cancel_wins_after_promotion=True,
persist_guard=_persist_guard,
# Config + auth store only, so the light contextvar scope -- never the skills-module one,
# whose process-global lock would be held across the whole wait.
scope=lambda: _config_profile_scope(_oauth_profile_name(profile)),
)
try:
first = await _httpx_call(lambda _client: next(gen))
except Exception:
with contextlib.suppress(Exception):
gen.close()
_drop_oauth_session(sid)
raise
if getattr(first, "kind", "") != "code": # already signed in, or the free tier is unavailable
with contextlib.suppress(Exception):
gen.close()
_drop_oauth_session(sid)
raise HTTPException(400, detail=first.copy_terminal)
with _oauth_sessions_lock:
# ``device_code`` stays present because other routes read it; the generator owns the real one.
sess.update(dict(
portal_base_url=portal_base_url, client_id=pconfig.client_id, scope=pconfig.scope,
device_code="", claim_code=first.code, interval=first.interval,
expires_at=time.time() + first.expires_in, _sign_in=gen))
_start_poller(_nous_promotion_poller, sid) # last: nothing observes `sess` before it is complete
return {
"session_id": sid, "flow": "device_code", "user_code": first.code,
"verification_url": first.link, "expires_in": first.expires_in,
"poll_interval": first.interval,
}
async def _start_codex_device_code(profile: Optional[str]) -> Dict[str, Any]:
+78 -41
View File
@@ -2,6 +2,7 @@
Anthropic/Copilot/Claude-Code status probes.
"""
import contextlib
import logging
import functools
import os
@@ -216,40 +217,90 @@ def _oauth_poller(label: str):
return deco
def _settle_promotion_failure(sess: Dict[str, Any], outcome: Dict[str, Any]) -> None:
"""Record a transfer that did not complete on the session: ``denied`` when the user rejected it in
the browser, else ``error``; ``reason`` and the ruled copy ride along for the renderer."""
from hermes_cli import anon_auth
status = str(outcome.get("status") or "unknown")
reason = "timeout" if status == "timeout" else str(outcome.get("reason") or status)
message = (anon_auth.UPGRADE_TIMED_OUT if reason == "timeout"
else anon_auth.UPGRADE_REASON_COPY.get(reason, anon_auth.UPGRADE_NOT_COMPLETED))
def _record_sign_in_state(sess: Dict[str, Any], state: Any) -> None:
"""Write one ``anon_auth.SignInState`` onto the dashboard session, under the sessions lock.
The whole desktop mapping lives here: the state carries its own copy, so nothing below turns a
reason into a string. ``completed`` deliberately leaves ``status`` on ``"pending"`` so the
:func:`_oauth_poller` wrapper stamps ``"approved"`` when the poller returns.
"""
kind = getattr(state, "kind", "")
if kind in ("code", "waiting"):
return # the start route already published the code
with _oauth_sessions_lock:
sess["status"] = "denied" if reason == "user_declined" else "error"
sess["reason"] = reason
sess["error_message"] = message
if reason in anon_auth._RETIRED_REASONS:
anon_auth.clear_dead_guest("retired")
if kind == "completed":
sess["account_email"] = state.email or None
# None when the config was left on the user's own model, as the poll route documents.
sess["model"] = state.model if state.model_changed else None
return
if kind == "declined":
sess["status"], sess["reason"] = "denied", "user_declined"
sess["error_message"] = state.copy
return
if kind == "timed_out":
sess["status"], sess["reason"] = "error", "timeout"
# The enriched device-auth guidance, which the dashboard has room for.
sess["error_message"] = state.detail or state.copy
return
if kind == "retired":
sess["status"], sess["reason"] = "error", "account_retired"
sess["error_message"] = state.copy
return
if kind == "superseded":
# Two producers, two screens: the user's own DELETE is a cancellation, while a sign-in
# started somewhere else (a chat, the terminal) voided this code and is an error the
# renderer has a dedicated screen for.
if sess.get("cancelled"):
sess["status"] = "cancelled"
else:
sess["status"], sess["reason"] = "error", "superseded"
sess["error_message"] = state.copy
return
if kind == "failed":
sess["status"] = "error"
sess["reason"] = state.reason or "error"
sess["error_message"] = state.copy # the chat form: no raw exception reaches the UI
return
# already_signed_in / unavailable: the start route refuses these, so this is unreachable
# through the dashboard; record rather than crash.
sess["status"] = "error"
sess["reason"] = kind or "error"
sess["error_message"] = state.copy
@_oauth_poller("nous")
def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None:
"""Background poller that drives a Nous device-code flow to completion.
def _nous_promotion_poller(session_id: str, sess: Dict[str, Any]) -> None:
"""Drain the sign-in the start route began: one shared flow, rendered onto the session.
A session started over a free-tier identity carries ``claim_code``: the transfer of that identity's
connectors into the account is watched first (``wait_for_promotion``), and only a completed
transfer is followed by the token grant, so an install never loses its connectors to a sign-in the
user did not confirm. Every completion then runs ``settle_after_upgrade`` so a config still on the
free tier's route moves to the account's host and model; ``account_email`` and ``model`` land on
the session for the poll response.
The generator was created and advanced to its ``Code`` state by ``_start_nous_device_code``, so
it is already holding the transfer's codes and its HTTP client. Nothing here is wrapped in
``_profile_scope``: that context manager holds a process-global lock and swaps module
attributes across its ``yield``, and this loop can last the sign-in code's whole expiry. The
generator scopes its own short config/auth-store sections instead.
"""
gen = sess.get("_sign_in")
if gen is None:
return
try:
for state in gen:
_record_sign_in_state(sess, state)
finally:
with contextlib.suppress(Exception):
gen.close() # unwinds the suspended HTTP client if we leave early
@_oauth_poller("nous")
def _nous_plain_poller(session_id: str, sess: Dict[str, Any]) -> None:
"""Background poller for a plain Nous device-code login (no free-tier identity to transfer).
A sign-in that carries the free tier's connectors runs through ``anon_auth.run_sign_in`` and
``_nous_promotion_poller`` instead; this is the "connect another Nous account" path.
"""
from hermes_cli.web_server_profiles import _profile_scope
from hermes_cli.auth import _poll_for_token, persist_nous_credentials, refresh_nous_oauth_from_state
from hermes_cli import anon_auth
import httpx
portal_base_url, client_id = sess["portal_base_url"], sess["client_id"]
claim_code = str(sess.get("claim_code") or "")
outcome: Dict[str, Any] = {}
def _cancelled() -> bool:
# The user abandoned this sign-in (DELETE /sessions/{id}) while this thread was blocked
@@ -261,22 +312,9 @@ def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None:
return False
with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
expires_in = max(60, int(sess["expires_at"] - time.time()))
if claim_code:
try:
outcome = anon_auth.wait_for_promotion(
client, portal_base_url, claim_code, expires_in=expires_in, interval=int(sess["interval"]))
except anon_auth.AnonCredentialDead:
outcome = {"status": "voided", "reason": "account_retired"}
if _cancelled():
return
if str(outcome.get("status")) != "completed":
with _profile_scope(_oauth_session_profile(session_id)):
_settle_promotion_failure(sess, outcome)
return
token_data = _poll_for_token(
client=client, portal_base_url=portal_base_url, client_id=client_id,
device_code=sess["device_code"], expires_in=expires_in,
device_code=sess["device_code"], expires_in=max(60, int(sess["expires_at"] - time.time())),
poll_interval=sess["interval"],
)
if _cancelled():
@@ -301,18 +339,17 @@ def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None:
}
with _profile_scope(_oauth_session_profile(session_id)):
full_state = refresh_nous_oauth_from_state(auth_state, timeout_seconds=15.0, force_refresh=False)
if claim_code:
full_state["auth_method"] = anon_auth.UPGRADED_AUTH_METHOD
# The final cancellation check and the save share the session lock, so a cancel cannot
# land between them; the settle step (which may contact the portal) runs after the lock.
# land between them.
with _oauth_sessions_lock:
if sess.get("cancelled"):
sess["status"] = "cancelled"
return
persist_nous_credentials(full_state)
# A config left on the free tier's route by a retired identity still has to move.
settled = anon_auth.settle_after_upgrade(full_state)
with _oauth_sessions_lock:
sess["account_email"] = str(outcome.get("account_email") or "") or None
sess["account_email"] = None
sess["model"] = settled.get("model") or None
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Laaste aktiwiteit:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · gratis vlak · nous/welcome · /login om aan te meld"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -343,6 +343,7 @@ gateway:
last_activity: "**آخر نشاط:** {timestamp}"
model: "**النموذج:** `{model}`"
model_provider: "**النموذج:** `{model}` ({provider})"
free_tier: "Nous · الخطة المجانية · nous/welcome · /login لتسجيل الدخول"
context: "**السياق:** {used} / {total} ({pct}%)"
context_used: "**السياق:** ~{used} رمزًا"
tokens: "**رموز API التراكمية (تُعاد كل استدعاء):** {tokens}"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Letzte Aktivität:** {timestamp}"
model: "**Modell:** `{model}`"
model_provider: "**Modell:** `{model}` ({provider})"
free_tier: "Nous · kostenloser Tarif · nous/welcome · /login zum Anmelden"
context: "**Kontext:** {used} / {total} ({pct}%)"
context_used: "**Kontext:** ~{used} Tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -354,6 +354,7 @@ gateway:
last_activity: "**Last Activity:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · free tier · nous/welcome · /login to sign in"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -338,6 +338,7 @@ gateway:
last_activity: "**Última actividad:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · plan gratuito · nous/welcome · /login para iniciar sesión"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Dernière activité :** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · offre gratuite · nous/welcome · /login pour se connecter"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -345,6 +345,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Gníomhaíocht is déanaí:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · plean saor in aisce · nous/welcome · /login chun síniú isteach"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Utolsó tevékenység:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · ingyenes csomag · nous/welcome · /login a bejelentkezéshez"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Ultima attività:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · piano gratuito · nous/welcome · /login per accedere"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**最終アクティビティ:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · 無料プラン · nous/welcome · /login でログイン"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**최종 활동:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · 무료 요금제 · nous/welcome · /login 명령으로 로그인"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Última atividade:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · plano gratuito · nous/welcome · /login para entrar"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Последняя активность:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · бесплатный тариф · nous/welcome · /login для входа"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Son etkinlik:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · ücretsiz plan · nous/welcome · /login ile giriş yapın"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**Остання активність:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · безкоштовний тариф · nous/welcome · /login для входу"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**最近活動:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · 免費方案 · nous/welcome · /login 登入"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+1
View File
@@ -341,6 +341,7 @@ Future messages in this room will use that transcript until `/reset` or another
last_activity: "**最近活动:** {timestamp}"
model: "**Model:** `{model}`"
model_provider: "**Model:** `{model}` ({provider})"
free_tier: "Nous · 免费套餐 · nous/welcome · /login 登录"
context: "**Context:** {used} / {total} ({pct}%)"
context_used: "**Context:** ~{used} tokens"
tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_"
+82
View File
@@ -199,7 +199,28 @@ class TestIsGenuineNousRateLimit:
}
assert is_genuine_nous_rate_limit(headers=headers) is True
def test_a_welcome_host_429_with_exhausted_buckets_trips_the_breaker(
self, rate_guard_env, monkeypatch,
):
from agent.nous_rate_guard import (
is_genuine_nous_rate_limit,
nous_rate_limit_remaining,
record_nous_rate_limit,
)
headers = {
"x-ratelimit-limit-requests-1h": "800",
"x-ratelimit-remaining-requests-1h": "0",
"x-ratelimit-reset-requests-1h": "600",
}
assert is_genuine_nous_rate_limit(headers=headers) is True
record_nous_rate_limit(headers=headers)
assert nous_rate_limit_remaining() > 0
verdict, _buffered, statuses = TestWelcomeRouteCopy._drive_guard(
"https://welcome-api.nousresearch.com/v1", monkeypatch
)
assert verdict.action == "return"
assert "/login" in statuses[0]
def test_bare_429_with_no_headers_is_upstream(self):
from agent.nous_rate_guard import is_genuine_nous_rate_limit
@@ -239,6 +260,67 @@ class TestIsGenuineNousRateLimit:
class TestWelcomeRouteCopy:
@staticmethod
def _drive_guard(base_url, monkeypatch):
from types import SimpleNamespace
from agent import nous_rate_guard
from agent.turn_api_call import nous_rate_limit_guard
monkeypatch.setattr(nous_rate_guard, "nous_rate_limit_remaining", lambda: 600)
buffered = []
statuses = []
agent = SimpleNamespace(
provider="nous",
base_url=base_url,
log_prefix="",
_buffer_vprint=buffered.append,
_buffer_status=statuses.append,
_try_activate_fallback=lambda: False,
_flush_status_buffer=lambda: None,
_persist_session=lambda *_args: None,
)
verdict = nous_rate_limit_guard(
agent,
_retry=None,
api_messages=[],
messages=[],
conversation_history=[],
active_system_prompt="system",
retry_count=0,
compression_attempts=0,
api_call_count=0,
)
return verdict, buffered, statuses
def test_the_welcome_host_rate_limit_message_names_the_slash_command(self, monkeypatch):
from hermes_cli import anon_auth
verdict, buffered, statuses = self._drive_guard(
"https://welcome-api.nousresearch.com/v1", monkeypatch
)
expected = anon_auth.FREE_TIER_RATE_LIMIT_CHAT.format(reset="10m")
assert verdict.action == "return"
assert statuses == [f"⏳ {expected}"]
assert expected in verdict.result["final_response"]
assert "/login" in expected
assert "Nous Portal" not in expected
assert buffered == [f"⏳ {expected} Trying fallback..."]
def test_a_non_welcome_route_keeps_todays_sentence(self, monkeypatch):
verdict, buffered, statuses = self._drive_guard(
"https://inference-api.nousresearch.com/v1", monkeypatch
)
expected = "Nous Portal rate limit active — resets in 10m."
assert verdict.action == "return"
assert statuses == [f"⏳ {expected}"]
assert verdict.result["final_response"].startswith(f"⏳ {expected}\n\n")
assert buffered == [f"⏳ {expected} Trying fallback..."]
class TestRateGuardStateEncoding:
"""Regression for #18637: the cross-session rate-limit state file was
opened without ``encoding="utf-8"`` on both the atomic write (os.fdopen)
+2 -1
View File
@@ -48,7 +48,8 @@ def test_registry_names_resolve_into_the_table():
assert cmd is not None and HermesCLI._slash_handler(cmd.name) is not None, name
# registry commands the CLI never handled inline must still fall through
dispatched = {c.name for c in COMMAND_REGISTRY if HermesCLI._slash_handler(c.name)}
assert dispatched == set(OLD_CHAIN_COMMANDS) - {"exit"} | {"quit"}
# /login has no old branch; it resolves through the naming-convention fallback.
assert dispatched == set(OLD_CHAIN_COMMANDS) - {"exit"} | {"quit", "login"}
def _cli():
@@ -14,7 +14,7 @@ from hermes_cli import anon_auth
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
from tests.gateway.restart_test_helpers import make_restart_runner
FREE_TIER_LINE = "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade"
FREE_TIER_LINE = "Inference: Nous free tier (nous/welcome). Sign in for more: /login"
def _jwt(**claims) -> str:
+545
View File
@@ -0,0 +1,545 @@
import asyncio
import logging
import threading
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, call
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.run import GatewayRunner
from gateway.session import AsyncSessionStore, SessionSource, SessionStore
from hermes_cli import anon_auth
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
def _source(*, chat_type="dm", chat_id="chat-1", user_id="user-1", platform=Platform.TELEGRAM):
return SessionSource(
platform=platform, chat_id=chat_id, chat_type=chat_type, user_id=user_id)
def _runner(monkeypatch, *, extra=None):
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token="token", extra=extra or {})})
runner.adapters = {}
runner._agent_cache = {}
runner._agent_cache_lock = threading.Lock()
runner._session_model_overrides = {}
runner.session_store = MagicMock()
runner._async_session_store = MagicMock(set_model_override=AsyncMock())
runner._async_session_store._store = runner.session_store
runner._deliver_platform_notice = AsyncMock()
runner._evict_cached_agent = MagicMock()
async def inline(func):
return func()
runner._run_login_blocking = inline
monkeypatch.setattr(anon_auth, "current_nous_state", lambda: None)
return runner
def _event(**source_kwargs):
return SimpleNamespace(source=_source(**source_kwargs))
async def _finish_tasks(runner):
tasks = list(getattr(runner, "_background_tasks", ()))
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("chat_type", ["group", "channel", "thread", "webhook"])
async def test_non_direct_chats_are_refused_without_starting_anything(monkeypatch, chat_type):
runner = _runner(monkeypatch)
flow = MagicMock()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
result = await runner._handle_login_command(_event(chat_type=chat_type))
assert result == anon_auth.LOGIN_DM_ONLY
flow.assert_not_called()
assert not hasattr(runner, "_background_tasks")
@pytest.mark.asyncio
async def test_a_dm_without_a_chat_id_is_refused(monkeypatch):
runner = _runner(monkeypatch)
assert await runner._handle_login_command(_event(chat_id="")) == anon_auth.LOGIN_DM_ONLY
@pytest.mark.asyncio
@pytest.mark.parametrize("platform", ["ntfy", "raft", "a2a"])
async def test_broadcast_shaped_platforms_are_refused(monkeypatch, platform):
runner = _runner(monkeypatch)
flow = MagicMock()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
source = SimpleNamespace(
platform=SimpleNamespace(value=platform), chat_id="chat-1", chat_type="dm", user_id="user-1")
assert await runner._handle_login_command(SimpleNamespace(source=source)) == anon_auth.LOGIN_DM_ONLY
flow.assert_not_called()
@pytest.mark.asyncio
async def test_a_private_chat_type_is_accepted(monkeypatch):
runner = _runner(monkeypatch)
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter([anon_auth.Declined()]))
assert await runner._handle_login_command(_event(chat_type="private")) == anon_auth.UPGRADE_START
assert "login" in runner._login_attempts
await _finish_tasks(runner)
@pytest.mark.asyncio
async def test_a_dm_posts_the_code_as_three_messages_then_the_terminal_copy(monkeypatch):
runner = _runner(monkeypatch)
states = [
anon_auth.Code("https://example.test/sign-in", "CODE-1", 900, 5),
anon_auth.Waiting(),
anon_auth.Declined(),
]
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter(states))
assert await runner._handle_login_command(_event()) == anon_auth.UPGRADE_START
await _finish_tasks(runner)
assert [call.args[1] for call in runner._deliver_platform_notice.await_args_list] == [
"https://example.test/sign-in",
"CODE-1",
"Do not share this code. Waiting for sign-in, up to 15 minutes.",
anon_auth.Declined().copy,
]
@pytest.mark.asyncio
async def test_the_start_ack_arrives_before_the_flow_is_advanced(monkeypatch):
runner = _runner(monkeypatch)
advanced = False
def flow(**_kwargs):
nonlocal advanced
advanced = True
yield anon_auth.Declined()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
assert await runner._handle_login_command(_event()) == anon_auth.UPGRADE_START
assert advanced is False
await _finish_tasks(runner)
@pytest.mark.asyncio
@pytest.mark.parametrize("state", [
anon_auth.Declined(),
anon_auth.Superseded(),
anon_auth.TimedOut(),
anon_auth.Retired(),
anon_auth.Failed("account_busy"),
anon_auth.Unavailable(),
])
async def test_each_terminal_outcome_is_pushed_to_the_same_dm(monkeypatch, state):
runner = _runner(monkeypatch)
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter([state]))
event = _event()
await runner._handle_login_command(event)
await _finish_tasks(runner)
runner._deliver_platform_notice.assert_awaited_once_with(event.source, state.copy)
@pytest.mark.asyncio
async def test_a_drain_that_raises_still_pushes_a_terminal_notice(monkeypatch):
runner = _runner(monkeypatch)
def flow(**_kwargs):
yield anon_auth.Code("https://example.test/sign-in", "CODE", 60, 1)
raise ValueError("broken flow")
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
await runner._handle_login_command(_event())
await _finish_tasks(runner)
assert runner._deliver_platform_notice.await_args_list[-1].args[1] == anon_auth.UPGRADE_NOT_COMPLETED
@pytest.mark.asyncio
async def test_a_failed_code_push_does_not_abort_the_drain(monkeypatch, caplog):
runner = _runner(monkeypatch)
delivered = []
async def deliver(_source, text):
delivered.append(text)
if len(delivered) == 2:
raise RuntimeError("transport unavailable")
runner._deliver_platform_notice = deliver
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter([
anon_auth.Code("https://example.test/sign-in", "CODE-1", 45, 5),
anon_auth.Completed(email="person@example.test", model="model-1", model_changed=True),
]))
await runner._handle_login_command(_event())
await _finish_tasks(runner)
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.",
]
assert "push failed" in caplog.text
@pytest.mark.asyncio
async def test_already_signed_in_starts_no_task(monkeypatch):
runner = _runner(monkeypatch)
monkeypatch.setattr(anon_auth, "current_nous_state", lambda: {"auth_method": "oauth"})
flow = MagicMock()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
assert await runner._handle_login_command(_event()) == anon_auth.UPGRADE_ALREADY_SIGNED_IN
flow.assert_not_called()
assert not hasattr(runner, "_background_tasks")
@pytest.mark.asyncio
async def test_a_non_admin_is_refused_when_gating_is_on(monkeypatch):
runner = _runner(monkeypatch, extra={"allow_admin_from": ["operator"]})
flow = MagicMock()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
assert await runner._handle_login_command(_event(user_id="other")) == anon_auth.LOGIN_NOT_ALLOWED
flow.assert_not_called()
@pytest.mark.asyncio
async def test_a_different_identity_cannot_supersede_the_live_attempt(monkeypatch):
runner = _runner(monkeypatch)
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter(()))
assert await runner._handle_login_command(_event(user_id="one")) == anon_auth.UPGRADE_START
live = runner._login_attempts["login"]
assert await runner._handle_login_command(_event(user_id="two")) == anon_auth.LOGIN_BUSY_ELSEWHERE
assert live.cancelled is False
for task in list(runner._background_tasks):
task.cancel()
await _finish_tasks(runner)
@pytest.mark.asyncio
async def test_a_second_login_from_the_same_identity_supersedes_the_first(monkeypatch):
runner = _runner(monkeypatch)
seen = []
def flow(**kwargs):
seen.append(kwargs)
yield anon_auth.Code("https://example.test/sign-in", "CODE", 60, 1)
yield anon_auth.Superseded() if kwargs["cancelled"]() else anon_auth.Declined()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
await runner._handle_login_command(_event())
first = runner._login_attempts["login"]
await runner._handle_login_command(_event())
assert first.cancelled is True
assert runner._login_attempts["login"] is not first
await _finish_tasks(runner)
assert seen[0]["cancel_wins_after_promotion"] is False
# The replaced attempt tells its own DM why its code stopped working.
assert any(
pushed.args[0] is first.source and pushed.args[1] == anon_auth.Superseded().copy
for pushed in runner._deliver_platform_notice.await_args_list)
@pytest.mark.asyncio
async def test_a_superseded_attempt_that_already_completed_still_pushes_its_completion(monkeypatch):
runner = _runner(monkeypatch)
seen = []
completed = anon_auth.Completed(
email="person@example.test", model="model-1", model_changed=False)
def flow(**kwargs):
seen.append(kwargs)
yield anon_auth.Code("https://example.test/sign-in", "CODE", 60, 1)
# The user approved this attempt in the browser before it lost the race, so the grant is
# already spent and the flow completes anyway (S-2b, S-10).
yield completed if kwargs["cancelled"]() else anon_auth.Declined()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
await runner._handle_login_command(_event())
first = runner._login_attempts["login"]
await runner._handle_login_command(_event())
assert runner._login_attempts["login"] is not first
await _finish_tasks(runner)
assert seen[0]["cancel_wins_after_promotion"] is False
assert any(
pushed.args[0] is first.source and pushed.args[1] == completed.copy
for pushed in runner._deliver_platform_notice.await_args_list)
# Completing after the race is lost never puts the loser back in the registry.
assert runner._login_attempts.get("login") is not first
@pytest.mark.asyncio
async def test_a_replacement_reaches_a_poll_running_on_the_private_executor(monkeypatch):
runner = _runner(monkeypatch)
del runner.__dict__["_run_login_blocking"]
polling = threading.Event()
def flow(**kwargs):
yield anon_auth.Code("https://example.test/sign-in", "CODE", 60, 1)
yield anon_auth.Waiting()
polling.set()
while not kwargs["cancelled"]():
time.sleep(0.01)
yield anon_auth.Superseded()
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
await runner._handle_login_command(_event())
assert await asyncio.to_thread(polling.wait, 2)
assert await asyncio.wait_for(
runner._handle_login_command(_event()), timeout=2) == anon_auth.UPGRADE_START
for task in list(runner._background_tasks):
task.cancel()
await _finish_tasks(runner)
runner._login_exec.shutdown(wait=True)
@pytest.mark.asyncio
async def test_a_completion_evicts_welcome_and_clears_its_override(monkeypatch):
runner = _runner(monkeypatch)
agent = SimpleNamespace(provider="nous", model=anon_auth.GUEST_MODEL)
runner._agent_cache = {"k1": (agent, "signature")}
runner._session_model_overrides["k1"] = {"model": anon_auth.GUEST_MODEL}
await runner._render_login_state(
SimpleNamespace(source=_source(), attempt_id="attempt"),
anon_auth.Completed(email="person@example.test", model="model-1", model_changed=True),
)
runner._evict_cached_agent.assert_called_once_with("k1")
assert "k1" not in runner._session_model_overrides
runner.async_session_store.set_model_override.assert_awaited_once_with("k1", None)
def _durable_sweep_runner(monkeypatch, tmp_path):
import hermes_state
def no_sqlite(**_kwargs):
raise RuntimeError("Exercise sessions.json persistence")
monkeypatch.setattr(hermes_state, "SessionDB", no_sqlite)
runner = _runner(monkeypatch)
store = SessionStore(sessions_dir=tmp_path, config=runner.config)
key = store.get_or_create_session(_source()).session_key
override = {"model": anon_auth.GUEST_MODEL}
store.set_model_override(key, override)
runner.session_store = store
runner._async_session_store = AsyncSessionStore(store)
runner._session_model_overrides[key] = dict(override)
runner._agent_cache[key] = (
SimpleNamespace(provider="nous", model=anon_auth.GUEST_MODEL), "signature")
return runner, store, key, override
@pytest.mark.asyncio
async def test_durable_clear_fails_once_then_succeeds_before_eviction(monkeypatch, tmp_path):
runner, store, key, override = _durable_sweep_runner(monkeypatch, tmp_path)
save = store._save_sessions_json
writes = []
def fail_once(data):
writes.append(data)
assert runner._session_model_overrides[key] == override
runner._evict_cached_agent.assert_not_called()
if len(writes) == 1:
raise OSError("disk temporarily unavailable")
save(data)
monkeypatch.setattr(store, "_save_sessions_json", fail_once)
attempt = SimpleNamespace(source=_source(), attempt_id="attempt")
state = anon_auth.Completed(model="model-1", model_changed=True)
await runner._render_login_state(attempt, state)
assert len(writes) == 2
assert key not in runner._session_model_overrides
runner._evict_cached_agent.assert_called_once_with(key)
reloaded = SessionStore(sessions_dir=tmp_path, config=runner.config)
assert reloaded.get_model_override(key) is None
runner._deliver_platform_notice.assert_awaited_once_with(attempt.source, state.copy)
@pytest.mark.asyncio
async def test_durable_clear_fails_twice_keeps_override_and_warns(monkeypatch, tmp_path, caplog):
runner, store, key, override = _durable_sweep_runner(monkeypatch, tmp_path)
# Multiple failed sessions still produce exactly one extra completion line.
second_key = store.get_or_create_session(_source(chat_id="chat-2")).session_key
store.set_model_override(second_key, override)
runner._session_model_overrides[second_key] = dict(override)
runner._agent_cache[second_key] = runner._agent_cache[key]
writes = []
def fail_always(data):
writes.append(data)
raise OSError("disk unavailable")
monkeypatch.setattr(store, "_save_sessions_json", fail_always)
attempt = SimpleNamespace(source=_source(), attempt_id="attempt")
state = anon_auth.Completed(model="model-1", model_changed=True)
with caplog.at_level(logging.WARNING, logger="gateway.run"):
await runner._render_login_state(attempt, state)
assert len(writes) == 4
reloaded = SessionStore(sessions_dir=tmp_path, config=runner.config)
rebuilt = _runner(monkeypatch)
rebuilt.session_store = reloaded
for session_key in (key, second_key):
assert runner._session_model_overrides[session_key] == override
assert reloaded.get_model_override(session_key) == override
rebuilt._rehydrate_session_model_override(session_key)
assert rebuilt._session_model_overrides[session_key]["model"] == override["model"]
assert any(
record.levelno == logging.WARNING and session_key in record.getMessage()
and "failed to clear free-tier override" in record.getMessage()
for record in caplog.records)
runner._evict_cached_agent.assert_not_called()
runner._deliver_platform_notice.assert_awaited_once_with(
attempt.source,
state.copy + "\nSome chats are still on the free tier; use /model in those chats to switch.")
@pytest.mark.asyncio
async def test_the_sweep_leaves_sessions_on_other_models_alone(monkeypatch):
runner = _runner(monkeypatch)
runner._agent_cache = {
"k1": (SimpleNamespace(provider="nous", model=anon_auth.GUEST_MODEL), "signature"),
"k2": (SimpleNamespace(provider="openrouter", model="openrouter/x"), "signature"),
}
runner._session_model_overrides["k2"] = {"model": "openrouter/x"}
await runner._render_login_state(
SimpleNamespace(source=_source(), attempt_id="attempt"),
anon_auth.Completed(email="person@example.test", model="model-1", model_changed=True),
)
assert runner._evict_cached_agent.call_args_list == [call("k1")]
assert runner._session_model_overrides["k2"] == {"model": "openrouter/x"}
runner.async_session_store.set_model_override.assert_not_awaited()
@pytest.mark.asyncio
async def test_the_sweep_is_skipped_when_the_model_did_not_change(monkeypatch):
runner = _runner(monkeypatch)
runner._agent_cache = {
"k1": (SimpleNamespace(provider="nous", model=anon_auth.GUEST_MODEL), "signature")}
await runner._render_login_state(
SimpleNamespace(source=_source(), attempt_id="attempt"),
anon_auth.Completed(email="person@example.test", model="model-1", model_changed=False),
)
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.")
@pytest.mark.asyncio
async def test_a_completion_with_no_default_names_the_slash_command(monkeypatch):
runner = _runner(monkeypatch)
await runner._render_login_state(
SimpleNamespace(source=_source(), attempt_id="attempt"),
anon_auth.Completed(email="person@example.test", model="", model_changed=True),
)
assert runner._deliver_platform_notice.await_args_list[-1].args[1] == (
"Signed in as person@example.test. Your connectors are kept.\n"
"No default model is set yet; run /model to pick one.")
@pytest.mark.asyncio
async def test_a_failing_sweep_still_pushes_the_completion(monkeypatch, caplog):
runner = _runner(monkeypatch)
runner._evict_cached_agent = MagicMock(side_effect=RuntimeError("boom"))
runner._agent_cache = {
"k1": (SimpleNamespace(provider="nous", model=anon_auth.GUEST_MODEL), "signature")}
attempt = SimpleNamespace(source=_source(), attempt_id="attempt")
state = anon_auth.Completed(email="person@example.test", model="model-1", model_changed=True)
await runner._render_login_state(attempt, state)
runner._deliver_platform_notice.assert_awaited_once_with(attempt.source, state.copy)
assert "failed to evict free-tier session" in caplog.text
@pytest.mark.asyncio
async def test_shutdown_cancellation_marks_the_attempt_cancelled(monkeypatch):
runner = _runner(monkeypatch)
started = asyncio.Event()
release = asyncio.Event()
async def blocked(_func):
started.set()
await release.wait()
runner._run_login_blocking = blocked
attempt = SimpleNamespace(
attempt_id="attempt", key=("telegram", "chat-1", "user-1"),
source=_source(), cancelled=False)
task = asyncio.create_task(runner._run_login(attempt))
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert attempt.cancelled is True
@pytest.mark.asyncio
async def test_the_drain_never_touches_the_shared_executor(monkeypatch):
runner = _runner(monkeypatch)
runner._get_executor = MagicMock(side_effect=AssertionError("shared executor used"))
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter([anon_auth.Declined()]))
del runner.__dict__["_run_login_blocking"]
await runner._run_login(SimpleNamespace(
attempt_id="attempt", key=("telegram", "chat-1", "user-1"),
source=_source(), cancelled=False))
runner._get_executor.assert_not_called()
runner._login_exec.shutdown(wait=True)
def test_the_registry_row_and_alias_match_the_command_contract():
command = resolve_command("login")
assert command.name == "login"
assert resolve_command("signin") is None
assert command.desktop == "settings"
assert command.aliases == ()
assert command.busy_policy == "dispatch"
assert not command.cli_only and not command.gateway_only
assert "login" in GATEWAY_KNOWN_COMMANDS
assert resolve_command("upgrade").name == "subscription"
def test_the_handler_table_builds():
runner = object.__new__(GatewayRunner)
assert runner._command_handler_table(("login",))["login"] == runner._handle_login_command
@pytest.mark.asyncio
async def test_login_dispatches_mid_turn():
runner = object.__new__(GatewayRunner)
runner._handle_login_command = AsyncMock(return_value="started")
command = resolve_command("login")
event = _event()
assert await runner._dispatch_busy_slash_command(event, command, "", event.source) == "started"
runner._handle_login_command.assert_awaited_once_with(event)
+124
View File
@@ -0,0 +1,124 @@
"""The in-chat /status line identifies an active Nous free-tier route."""
import base64
import json
import time
from datetime import datetime
import pytest
from agent.i18n import t
from gateway.config import Platform
from gateway.session import SessionEntry, build_session_key
from hermes_cli import anon_auth
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
from tests.gateway.test_status_command import _make_event, _make_runner, _make_source
def _runner():
source = _make_source()
entry = SessionEntry(
session_key=build_session_key(source),
session_id="sess-free-tier-status",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
return _make_runner(entry)
def _jwt(**claims) -> str:
def segment(value):
return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode()
payload = {
"sub": "nas_user:status",
"client_id": "nas-anonymous",
"account_tier": "anonymous",
"scope": "inference:invoke tool:invoke",
"exp": int(time.time()) + 900,
**claims,
}
return f"{segment({'alg': 'RS256'})}.{segment(payload)}.sig"
def _seed_nous(state: dict) -> None:
with _auth_store_lock():
store = _load_auth_store()
store.setdefault("providers", {})["nous"] = state
store["active_provider"] = "nous"
_save_auth_store(store)
def _free_tier_state() -> dict:
return {
"auth_method": anon_auth.ANON_AUTH_METHOD,
"account_tier": "anonymous",
"anon_token": "anon_status",
"access_token": _jwt(),
"expires_at": "2999-01-01T00:00:00+00:00",
"inference_base_url": "https://welcome-api.nousresearch.com/v1",
}
def _account_state() -> dict:
return {
"auth_method": "oauth_device_code",
"access_token": _jwt(client_id="hermes-cli", account_tier="standard"),
"refresh_token": "refresh-status",
"expires_at": "2999-01-01T00:00:00+00:00",
}
@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)
@pytest.mark.asyncio
async def test_status_names_the_free_tier_and_the_slash_command_when_the_free_tier_carries_inference(
):
runner = _runner()
_seed_nous(_free_tier_state())
result = await runner._handle_message(_make_event("/status"))
assert anon_auth.FREE_TIER_STATUS_LINE in result
@pytest.mark.asyncio
async def test_status_omits_the_line_for_a_real_account():
runner = _runner()
_seed_nous(_account_state())
result = await runner._handle_message(_make_event("/status"))
assert anon_auth.FREE_TIER_STATUS_LINE not in result
@pytest.mark.asyncio
async def test_a_status_gate_failure_never_breaks_status(monkeypatch):
runner = _runner()
_seed_nous(_free_tier_state())
monkeypatch.setattr(anon_auth, "guest_carries_inference", lambda: False)
expected = await runner._handle_message(_make_event("/status"))
def broken_store():
raise RuntimeError("broken store")
monkeypatch.setattr(anon_auth, "guest_carries_inference", broken_store)
result = await runner._handle_message(_make_event("/status"))
assert result == expected
def test_the_line_comes_from_the_catalog_in_every_language():
assert t("gateway.status.free_tier") == anon_auth.FREE_TIER_STATUS_LINE
# Every catalog carries its own translation; the product name, the route and the verb stay verbatim.
for lang in ("ja", "de"):
line = t("gateway.status.free_tier", lang=lang)
assert line != anon_auth.FREE_TIER_STATUS_LINE
assert line.startswith("Nous · ") and " · nous/welcome · " in line and "/login" in line
+1 -1
View File
@@ -273,7 +273,7 @@ class TestModelSwitchCopy:
result = model_switch.switch_model("gpt-5", "nous", anon_auth.GUEST_MODEL, WELCOME)
assert not result.success
msg = (result.error_message or "").lower()
assert "hermes auth upgrade" in msg
assert "/login" in msg
assert "openrouter" not in msg and "switching" not in msg
+1 -1
View File
@@ -92,7 +92,7 @@ def test_a_sign_in_cancelled_while_waiting_never_persists_the_account(portal, fr
guest = anon_auth.ensure_portal_identity(blocking=True)
release = threading.Event()
def _wait_until_released(client, portal_base_url, claim_code, *, expires_in, interval):
def _wait_until_released(client, portal_base_url, claim_code, *, expires_in, interval, cancelled=None):
release.wait(10)
return {"status": "completed", "user_id": "nas_user:9", "account_email": EMAIL}
monkeypatch.setattr(anon_auth, "wait_for_promotion", _wait_until_released)
+526
View File
@@ -0,0 +1,526 @@
"""``anon_auth.run_sign_in``: the one sign-in composition every surface renders.
Driven directly against the same fake account service ``hermes auth upgrade`` is tested with, so the
states, the persistence rules and the cancellation rules are exercised on the real wire rather than
mocked away. Each test asserts a single ruled property of the flow.
"""
from __future__ import annotations
import threading
import time
import httpx
import pytest
from hermes_cli import anon_auth
from hermes_cli.auth import _auth_file_path
from hermes_cli.auth_constants import AuthError
from tests.hermes_cli.test_anon_upgrade import ( # noqa: F401 (fixtures used by name)
EMAIL, FREE_PICK, PORTAL, WELCOME, _shared_store, _write_model_config, free_account, portal)
__all__ = ["free_account", "portal"]
def _drain(**kwargs):
"""Run a sign-in to its end and return every state it yielded."""
return list(anon_auth.run_sign_in(**kwargs))
def _seed_free_tier() -> dict:
return anon_auth.ensure_portal_identity(blocking=True)
def _stub_wait(monkeypatch, outcome, *, before=None):
"""Replace the promotion wait with one that returns *outcome* (running *before* first)."""
def _wait(client, portal_base_url, claim_code, *, expires_in, interval, cancelled=None):
if before is not None:
before()
return dict(outcome)
monkeypatch.setattr(anon_auth, "wait_for_promotion", _wait)
def _voided(reason: str) -> dict:
return {"status": "voided", "reason": reason}
def test_a_completed_sign_in_yields_code_waiting_then_completed(portal, free_account):
_seed_free_tier()
_write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME})
states = _drain()
assert [s.kind for s in states] == ["code", "waiting", "completed"]
code, completed = states[0], states[-1]
assert code.link.startswith(PORTAL)
assert code.code == "clm_1"
assert completed.email == EMAIL
assert completed.model == FREE_PICK
assert completed.model_changed is True
def test_declined_yields_declined_and_persists_nothing(portal, tmp_path):
_seed_free_tier()
before = _auth_file_path().read_bytes()
shared_before = _shared_store(tmp_path)
portal.status_sequence = [{"status": "pending"}, _voided("user_declined")]
terminal = [s for s in _drain() if s.terminal]
assert len(terminal) == 1
assert terminal[0].kind == "declined"
assert terminal[0].copy == anon_auth.UPGRADE_REASON_COPY["user_declined"]
assert portal.token_grants == 0
assert _auth_file_path().read_bytes() == before
assert _shared_store(tmp_path) == shared_before
def test_a_timeout_yields_timed_out_and_keeps_the_enriched_detail(portal, monkeypatch):
_seed_free_tier()
_stub_wait(monkeypatch, {"status": "timeout"})
state = _drain()[-1]
assert state.kind == "timed_out"
assert state.copy == anon_auth.UPGRADE_TIMED_OUT
assert portal.token_grants == 0
# The token poll can time out too, and its guidance is enriched at the source.
from hermes_cli import auth_device_flow
enriched = auth_device_flow._nous_device_auth_timeout_message(PORTAL)
_stub_wait(monkeypatch, {"status": "completed", "account_email": EMAIL})
def _timeout(**kwargs):
raise TimeoutError(enriched)
monkeypatch.setattr(auth_device_flow, "_poll_for_token", _timeout)
state = _drain()[-1]
assert state.kind == "timed_out"
assert state.detail == enriched
assert state.copy == anon_auth.UPGRADE_TIMED_OUT
assert portal.token_grants == 0
def test_a_retired_identity_yields_retired_and_clears_the_free_tier(portal, monkeypatch):
guest = _seed_free_tier()
cleared = []
real_clear = anon_auth.clear_dead_guest
def _spy(reason, *, dead_token=None):
cleared.append((reason, dead_token))
real_clear(reason, dead_token=dead_token)
monkeypatch.setattr(anon_auth, "clear_dead_guest", _spy)
portal.status_sequence = [_voided("account_not_anonymous")]
state = _drain()[-1]
assert state.kind == "retired"
assert state.copy == anon_auth.UPGRADE_REASON_COPY["account_retired"]
assert cleared == [("retired", guest["anon_token"])]
from hermes_cli.auth import _load_auth_store
assert "nous" not in _load_auth_store().get("providers", {})
def test_a_server_superseded_outcome_yields_superseded(portal, tmp_path):
_seed_free_tier()
before = _auth_file_path().read_bytes()
portal.status_sequence = [_voided("superseded")]
state = _drain()[-1]
assert state.kind == "superseded"
assert state.copy == anon_auth.UPGRADE_REASON_COPY["superseded"]
assert portal.token_grants == 0
assert _auth_file_path().read_bytes() == before
def test_a_retired_identity_yields_retired_even_when_cleanup_fails(portal, monkeypatch):
_seed_free_tier()
def _retired(*args, **kwargs):
raise anon_auth.AnonCredentialDead("retired")
def _read_only(*args, **kwargs):
raise OSError("read-only store")
monkeypatch.setattr(anon_auth, "register_promotion_intent", _retired)
monkeypatch.setattr(anon_auth, "clear_dead_guest", _read_only)
states = _drain()
assert [s.kind for s in states] == ["retired"]
@pytest.mark.parametrize(
"reason,expected",
[("account_busy", anon_auth.UPGRADE_REASON_COPY["account_busy"]),
("wat", anon_auth.UPGRADE_NOT_COMPLETED)])
def test_an_unknown_reason_yields_failed_with_the_generic_copy(portal, reason, expected):
_seed_free_tier()
portal.status_sequence = [_voided(reason)]
state = _drain()[-1]
assert state.kind == "failed"
assert state.copy == expected
def test_a_transport_error_yields_failed_without_leaking_the_detail_into_chat_copy(
portal, monkeypatch, tmp_path):
_seed_free_tier()
before = _auth_file_path().read_bytes()
detail = "boom at https://portal.example.test/api/anonymous/promotion-intent"
def _boom(*a, **kw):
raise RuntimeError(detail)
monkeypatch.setattr(anon_auth, "register_promotion_intent", _boom)
state = _drain()[-1]
assert state.kind == "failed"
assert state.copy == anon_auth.UPGRADE_NOT_COMPLETED
lowered = state.copy.lower()
for banned in ("http://", "https://", "/anonymous/", "anonymous"):
assert banned not in lowered
assert state.copy_terminal == f"Sign-in failed: {detail}"
assert portal.token_grants == 0
assert _auth_file_path().read_bytes() == before
def test_a_persist_failure_yields_failed_rather_than_raising(portal, free_account, monkeypatch):
_seed_free_tier()
settles = []
from hermes_cli import auth_nous
monkeypatch.setattr(
auth_nous, "persist_nous_credentials", lambda *a, **kw: (_ for _ in ()).throw(OSError("read-only home")))
monkeypatch.setattr(anon_auth, "settle_after_upgrade", lambda state: settles.append(state) or {})
states = _drain()
assert states[-1].kind == "failed"
assert states[-1].terminal is True
assert "read-only home" in states[-1].copy_terminal
assert settles == []
def test_a_settle_failure_yields_failed_rather_than_raising(portal, free_account, monkeypatch):
_seed_free_tier()
persists = []
from hermes_cli import auth_nous
real_persist = auth_nous.persist_nous_credentials
monkeypatch.setattr(
auth_nous, "persist_nous_credentials",
lambda state, **kw: (persists.append(state), real_persist(state, **kw))[1])
def _boom(state):
raise RuntimeError("config is read-only")
monkeypatch.setattr(anon_auth, "settle_after_upgrade", _boom)
states = _drain()
assert states[-1].kind == "failed"
assert "config is read-only" in states[-1].copy_terminal
assert len(persists) == 1
def test_already_signed_in_short_circuits_before_any_network(portal):
from hermes_cli.auth import _load_auth_store, _save_auth_store, _save_provider_state
store = _load_auth_store()
_save_provider_state(store, "nous", {"auth_method": "oauth_device_code", "access_token": "x"})
_save_auth_store(store)
portal.calls.clear()
states = _drain()
assert [s.kind for s in states] == ["already_signed_in"]
assert states[0].ok is True
assert states[0].precondition is True
assert portal.calls == []
def test_free_tier_off_yields_unavailable(portal, monkeypatch):
monkeypatch.setattr(anon_auth, "guest_enabled", lambda: False)
portal.calls.clear()
states = _drain()
assert [s.kind for s in states] == ["unavailable"]
assert states[0].copy == anon_auth.UPGRADE_UNAVAILABLE_CHAT
assert states[0].copy_terminal == anon_auth.UPGRADE_UNAVAILABLE
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)
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 == anon_auth.UPGRADE_UNAVAILABLE_CHAT
def test_cancelling_before_the_wait_persists_nothing(portal, tmp_path):
_seed_free_tier()
before = _auth_file_path().read_bytes()
stop = []
# Driven by hand so the flag flips exactly between the code and the wait.
gen = anon_auth.run_sign_in(cancelled=lambda: bool(stop))
first = next(gen)
assert first.kind == "code"
stop.append(True)
rest = list(gen)
assert [s.kind for s in rest] == ["superseded"]
assert portal.token_grants == 0
assert _auth_file_path().read_bytes() == before
def test_cancelling_during_the_wait_ends_it_within_a_second(portal, tmp_path):
_seed_free_tier()
before = _auth_file_path().read_bytes()
portal.status_sequence = [{"status": "pending"}]
stop = threading.Event()
outcomes = []
real_wait = anon_auth.wait_for_promotion
def _recording_wait(*args, **kwargs):
outcome = real_wait(*args, **kwargs)
outcomes.append(outcome)
return outcome
gen = anon_auth.run_sign_in(cancelled=stop.is_set)
first = next(gen)
assert first.kind == "code"
anon_auth.wait_for_promotion = _recording_wait
try:
timer = threading.Timer(0.2, stop.set)
timer.start()
started = time.monotonic()
rest = list(gen)
elapsed = time.monotonic() - started
finally:
anon_auth.wait_for_promotion = real_wait
timer.cancel()
assert elapsed < 3.0
assert outcomes == [{"status": "cancelled"}]
assert [s.kind for s in rest] == ["waiting", "superseded"]
assert portal.token_grants == 0
assert _auth_file_path().read_bytes() == before
@pytest.mark.parametrize("cancel_wins", [False, True])
def test_cancelling_during_a_completed_status_request_obeys_the_surface_policy(
portal, free_account, cancel_wins):
_seed_free_tier()
calls = 0
def _cancelled():
nonlocal calls
calls += 1
return calls > 2
states = _drain(cancelled=_cancelled, cancel_wins_after_promotion=cancel_wins)
assert states[-1].kind == ("superseded" if cancel_wins else "completed")
assert portal.token_grants == (0 if cancel_wins else 1)
from hermes_cli.auth import _load_auth_store
state = _load_auth_store()["providers"]["nous"]
assert anon_auth.is_guest_state(state) is cancel_wins
def _cancel_after_a_completed_promotion(portal, monkeypatch, *, cancel_wins: bool):
_seed_free_tier()
stop = threading.Event()
def _wait(client, portal_base_url, claim_code, *, expires_in, interval, cancelled=None):
stop.set() # the surface cancels while this call is blocked
return {"status": "completed", "user_id": "nas_user:9", "account_email": EMAIL}
monkeypatch.setattr(anon_auth, "wait_for_promotion", _wait)
return list(anon_auth.run_sign_in(
cancelled=stop.is_set, cancel_wins_after_promotion=cancel_wins))
def test_a_desktop_style_cancel_after_a_completed_promotion_persists_nothing(
portal, free_account, monkeypatch, tmp_path):
_seed_free_tier()
before = _auth_file_path().read_bytes()
states = _cancel_after_a_completed_promotion(portal, monkeypatch, cancel_wins=True)
assert states[-1].kind == "superseded"
assert portal.token_grants == 0
assert _auth_file_path().read_bytes() == before
def test_a_gateway_style_supersede_after_a_completed_promotion_still_signs_in(
portal, free_account, monkeypatch):
states = _cancel_after_a_completed_promotion(portal, monkeypatch, cancel_wins=False)
assert states[-1].kind == "completed"
assert portal.token_grants == 1
from hermes_cli.auth import _load_auth_store
state = _load_auth_store()["providers"]["nous"]
assert not anon_auth.is_guest_state(state)
def test_a_persist_guard_that_refuses_persists_nothing_and_never_settles(
portal, free_account, monkeypatch, tmp_path):
import contextlib
_seed_free_tier()
before = _auth_file_path().read_bytes()
settles = []
monkeypatch.setattr(anon_auth, "settle_after_upgrade", lambda state: settles.append(state) or {})
@contextlib.contextmanager
def _refuse():
yield False
states = list(anon_auth.run_sign_in(persist_guard=_refuse))
assert states[-1].kind == "superseded"
assert settles == []
assert _auth_file_path().read_bytes() == before
def test_settle_runs_exactly_once_per_completion(portal, free_account, monkeypatch):
_seed_free_tier()
settles, persists = [], []
real_settle = anon_auth.settle_after_upgrade
from hermes_cli import auth_nous
real_persist = auth_nous.persist_nous_credentials
monkeypatch.setattr(
anon_auth, "settle_after_upgrade",
lambda state: (settles.append(state), real_settle(state))[1])
monkeypatch.setattr(
auth_nous, "persist_nous_credentials",
lambda state, **kw: (persists.append(state), real_persist(state, **kw))[1])
states = list(anon_auth.run_sign_in())
assert states[-1].kind == "completed"
assert len(settles) == 1
assert len(persists) == 1
def test_persistence_happens_only_after_the_promotion_and_the_token_grant(portal, free_account):
_seed_free_tier()
before = _auth_file_path().read_bytes()
seen = {}
for state in anon_auth.run_sign_in():
seen[state.kind] = _auth_file_path().read_bytes()
assert seen["code"] == before
assert seen["waiting"] == before
assert seen["completed"] != before
paths = [p for _, p in portal.calls]
assert "/api/oauth/token" in paths
def test_the_budget_shrinks_across_the_two_waits(portal, free_account, monkeypatch):
"""One absolute deadline: what the promotion wait spends, the token poll no longer has."""
_seed_free_tier()
captured = {}
from hermes_cli import auth_device_flow
real_request, real_poll = auth_device_flow._request_device_code, auth_device_flow._poll_for_token
def _short_lived_code(client, portal_base_url, client_id, scope):
return {**real_request(client, portal_base_url, client_id, scope), "expires_in": 10}
monkeypatch.setattr(auth_device_flow, "_request_device_code", _short_lived_code)
def _wait(client, portal_base_url, claim_code, *, expires_in, interval, cancelled=None):
time.sleep(3.0)
return {"status": "completed", "account_email": EMAIL}
monkeypatch.setattr(anon_auth, "wait_for_promotion", _wait)
def _poll(**kwargs):
captured.update(kwargs)
return real_poll(**kwargs)
monkeypatch.setattr(auth_device_flow, "_poll_for_token", _poll)
states = list(anon_auth.run_sign_in())
assert states[-1].kind == "completed"
assert 1 <= captured["expires_in"] <= 7
def test_the_scope_is_entered_for_the_preconditions_and_the_persist_but_never_around_a_wait(
portal, free_account, monkeypatch):
import contextlib
_seed_free_tier()
events = []
@contextlib.contextmanager
def _scope():
events.append(("enter", time.monotonic(), threading.get_ident()))
try:
yield None
finally:
events.append(("exit", time.monotonic(), threading.get_ident()))
def _wait(client, portal_base_url, claim_code, *, expires_in, interval, cancelled=None):
events.append(("wait-start", time.monotonic(), threading.get_ident()))
time.sleep(0.2)
events.append(("wait-end", time.monotonic(), threading.get_ident()))
return {"status": "completed", "account_email": EMAIL}
monkeypatch.setattr(anon_auth, "wait_for_promotion", _wait)
states = []
yields = []
for state in anon_auth.run_sign_in(scope=_scope):
yields.append(time.monotonic())
states.append(state)
assert states[-1].kind == "completed"
pairs = [e for e in events if e[0] in ("enter", "exit")]
assert [e[0] for e in pairs] == ["enter", "exit", "enter", "exit"]
for first, second in (pairs[0:2], pairs[2:4]):
assert first[2] == second[2] # one thread per scope
wait_start = next(e[1] for e in events if e[0] == "wait-start")
wait_end = next(e[1] for e in events if e[0] == "wait-end")
for first, second in (pairs[0:2], pairs[2:4]):
assert not (first[1] <= wait_start and wait_end <= second[1])
# and never held across a yield, which would hand the scope to the consumer's thread
assert not any(first[1] <= at <= second[1] for at in yields)
def test_wait_for_promotion_without_a_cancel_hook_is_unchanged(portal):
import httpx
_seed_free_tier()
portal.status_sequence = [{"status": "completed", "account_email": EMAIL}]
portal.calls.clear()
with httpx.Client(transport=httpx.MockTransport(portal.handler), base_url=PORTAL) as client:
outcome = anon_auth.wait_for_promotion(
client, PORTAL, "clm_1", expires_in=30, interval=0)
assert outcome == {"status": "completed", "account_email": EMAIL}
assert [p for _, p in portal.calls] == ["/api/anonymous/promotion-status"]
+144 -1
View File
@@ -9,16 +9,27 @@ rendering. The keepalive has nothing to keep alive for the free tier and must no
from __future__ import annotations
import base64
import dataclasses
import json
import re
import threading
import time
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
from unittest.mock import AsyncMock, call
from hermes_cli import anon_auth, auth_commands, nous_account, nous_auth_keepalive, portal_cli, status_auth
from hermes_cli import (
anon_auth,
auth_commands,
model_setup_flows,
nous_account,
nous_auth_keepalive,
portal_cli,
status_auth,
)
from hermes_cli.auth import _load_auth_store # noqa: F401 (store import name kept for parity with core tests)
from hermes_constants import get_hermes_home
@@ -142,3 +153,135 @@ def test_keepalive_does_not_start_for_free_tier(isolated_store, monkeypatch):
thread = nous_auth_keepalive.start_nous_auth_keepalive(interval_seconds=900)
assert thread is not None and started == ["nous-auth-keepalive"]
monkeypatch.setattr(nous_auth_keepalive, "_keepalive_thread", None)
def test_every_in_chat_free_tier_string_names_the_slash_command(monkeypatch):
from gateway.run_notifications import GatewayNotificationsMixin
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda _requested: "nous")
monkeypatch.setattr(anon_auth, "guest_carries_inference", lambda: True)
startup = GatewayNotificationsMixin._free_tier_startup_line(object())
command_copy = (
anon_auth.FREE_TIER_AVAILABLE_NOTICE,
anon_auth.FREE_TIER_STATUS_LINE,
anon_auth.UPGRADE_UNAVAILABLE_CHAT,
anon_auth.FREE_TIER_RATE_LIMIT_CHAT,
nous_account.FREE_TIER_NEEDS_ACCOUNT_CHAT,
startup,
)
refusal_copy = (
anon_auth.LOGIN_DM_ONLY,
anon_auth.LOGIN_BUSY_ELSEWHERE,
anon_auth.LOGIN_NOT_ALLOWED,
)
assert all("/login" in text for text in command_copy)
for text in (*command_copy, *refusal_copy):
# The ruled refusal uses Hermes as the grammatical subject; only that exact product-name
# phrase is exempt from the broad top-level-command gate.
assert "hermes " not in text.replace("this Hermes can", "this product can").lower()
def test_no_chat_copy_of_any_sign_in_state_leaks_a_terminal_verb_or_a_forbidden_word():
placeholders = {
"link": "https://example.test/sign-in",
"code": "code-1",
"expires_in": 900,
"interval": 1,
"email": "person@example.test",
"model": "model-1",
"model_changed": True,
"reason": "unknown",
"detail": "private detail",
}
forbidden = re.compile(r"claim|nous portal|anonymous|guest", re.IGNORECASE)
terminal_or_url = re.compile(r"hermes |https?://", re.IGNORECASE)
for state_type in anon_auth.SignInState.__subclasses__():
kwargs = {field.name: placeholders[field.name] for field in dataclasses.fields(state_type)}
copy = state_type(**kwargs).copy
assert _FORBIDDEN.search(copy) is None, state_type.__name__
assert forbidden.search(copy) is None, state_type.__name__
assert terminal_or_url.search(copy) is None, state_type.__name__
def test_terminal_only_strings_keep_the_terminal_verb(monkeypatch, capsys):
assert "hermes " in nous_account.FREE_TIER_NEEDS_ACCOUNT
assert "hermes " in anon_auth.UPGRADE_UNAVAILABLE
assert "hermes " in anon_auth.FREE_TIER_NOT_SIGNED_IN
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda _provider: {"access_token": "token"})
monkeypatch.setattr("hermes_cli.model_switch_providers._free_tier_nous_row", lambda _provider: None)
model_setup_flows._model_flow_nous({})
assert "hermes " in capsys.readouterr().out
def test_the_paid_tool_notice_switches_wording_inside_a_chat():
info = nous_account.NousPortalAccountInfo(
logged_in=True, source="token", fresh=True, account_tier="anonymous"
)
assert nous_account.format_nous_portal_entitlement_message(
info, in_chat=True
) == nous_account.FREE_TIER_NEEDS_ACCOUNT_CHAT
assert nous_account.format_nous_portal_entitlement_message(
info, in_chat=False
) == nous_account.FREE_TIER_NEEDS_ACCOUNT
@pytest.mark.asyncio
async def test_the_wait_line_is_only_composed_on_the_state(monkeypatch):
from gateway.slash_commands_login import GatewayLoginCommandsMixin
state = anon_auth.Code(
link="https://example.test/sign-in", code="code-1", expires_in=900, interval=1
)
assert state.copy == anon_auth.UPGRADE_DO_NOT_SHARE
assert state.copy_with_wait == (
f"{anon_auth.UPGRADE_DO_NOT_SHARE} {anon_auth.format_wait_line(state.expires_in)}"
)
monkeypatch.setattr(
"hermes_cli.auth_device_flow._print_device_code_instructions", lambda *_args, **_kwargs: None
)
cli_copy = []
anon_auth.render_sign_in_cli_code(state, printer=cli_copy.append)
runner = SimpleNamespace(_push_login=AsyncMock())
attempt = object()
await GatewayLoginCommandsMixin._render_login_state(runner, attempt, state)
assert cli_copy == [f" {state.copy_with_wait}"]
assert runner._push_login.await_args_list == [
call(attempt, state.link),
call(attempt, state.code),
call(attempt, state.copy_with_wait),
]
# The wait line is composed once, on the state. A renderer that called
# format_wait_line itself would emit the same text and pass the assertions
# above, so the renderers are checked by source instead.
repo = Path(anon_auth.__file__).resolve().parents[1]
for rel in ("gateway/slash_commands_login.py", "hermes_cli/cli_commands_mixin.py"):
assert "format_wait_line" not in (repo / rel).read_text(encoding="utf-8"), rel
def test_cli_chat_status_names_the_free_tier(isolated_store):
from hermes_cli.cli_session_mixin import CLISessionMixin
_write_auth(_guest_state())
rendered = []
cli = SimpleNamespace(
_session_db=None,
session_id="cli-free-tier-status",
session_start=datetime.now(),
agent=SimpleNamespace(session_total_tokens=0, reasoning_config=None),
provider="nous",
model=anon_auth.GUEST_MODEL,
_agent_running=False,
reasoning_config=None,
show_reasoning=None,
session_key="cli:free-tier-status",
_get_status_bar_snapshot=lambda: {},
_console_print=lambda text, **_kwargs: rendered.append(text),
)
CLISessionMixin._show_session_status(cli)
assert anon_auth.FREE_TIER_STATUS_LINE in rendered[0]
+260
View File
@@ -0,0 +1,260 @@
import threading
from io import StringIO
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from rich.console import Console
from hermes_cli import anon_auth
from hermes_cli import cli_commands_mixin as commands
class _Thread:
def __init__(self, target):
self.target = target
self.started = False
def start(self):
self.started = True
def join(self):
self.target()
def _cli(monkeypatch):
cli = SimpleNamespace(console=MagicMock())
workers = []
def side_worker(produce, **kwargs):
thread = _Thread(produce)
workers.append((thread, kwargs))
return thread
cli._side_worker = side_worker
cli._handle_login_command = commands.CLICommandsMixin._handle_login_command.__get__(cli)
output = []
monkeypatch.setattr(commands, "_cp", lambda *lines: output.extend(lines))
return cli, workers, output
def test_the_cli_handler_prints_the_code_then_drains_off_thread(monkeypatch):
cli, workers, output = _cli(monkeypatch)
states = iter([
anon_auth.Code("https://example.test/sign-in", "CODE-1", 900, 5),
anon_auth.Completed(email="person@example.test", model="model-1", model_changed=True),
])
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: states)
monkeypatch.setattr(
anon_auth, "render_sign_in_cli_code",
lambda state, **kwargs: kwargs["printer"](state.link, state.code, f" {state.copy_with_wait}"))
cli._handle_login_command("/login")
assert output == [
" Starting sign-in...",
"https://example.test/sign-in",
"CODE-1",
" Do not share this code. Waiting for sign-in, up to 15 minutes.",
]
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.")
@pytest.mark.parametrize(
"terminal,initial_model,expected_model",
[
(anon_auth.Completed(model="model-1", model_changed=True), anon_auth.GUEST_MODEL, "model-1"),
(anon_auth.Completed(model="model-1", model_changed=True),
"openrouter/some-model", "openrouter/some-model"),
(anon_auth.Declined(), anon_auth.GUEST_MODEL, anon_auth.GUEST_MODEL),
],
)
def test_the_drain_only_moves_the_free_tier_model_on_completion(
monkeypatch, terminal, initial_model, expected_model):
cli, workers, _output = _cli(monkeypatch)
cli.model = initial_model
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter([
anon_auth.Code("https://example.test/sign-in", "CODE-1", 900, 5),
terminal,
]))
monkeypatch.setattr(anon_auth, "render_sign_in_cli_code", lambda *_args, **_kwargs: None)
cli._handle_login_command("/login")
workers[0][0].join()
assert cli.model == expected_model
def test_a_precondition_prints_without_starting_a_thread(monkeypatch):
cli, workers, output = _cli(monkeypatch)
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter([anon_auth.AlreadySignedIn()]))
cli._handle_login_command("/login")
assert output == [" Starting sign-in...", " Already signed in."]
assert workers == []
def test_ctrl_c_during_the_first_advance_prints_the_cancelled_copy(monkeypatch):
cli, workers, output = _cli(monkeypatch)
closed = threading.Event()
def flow():
try:
raise KeyboardInterrupt
yield
finally:
closed.set()
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: flow())
cli._handle_login_command("/login")
assert output[-1] == anon_auth.UPGRADE_CANCELLED
assert closed.is_set()
assert workers == []
def test_the_handler_never_calls_input_and_uses_the_short_timeout(monkeypatch):
cli, _workers, _output = _cli(monkeypatch)
seen = []
monkeypatch.setattr("builtins.input", lambda *_args: (_ for _ in ()).throw(AssertionError("input called")))
def flow(**kwargs):
seen.append(kwargs)
return iter([anon_auth.AlreadySignedIn()])
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
cli._handle_login_command("/login")
assert seen == [{"timeout_seconds": 8.0}]
def test_the_in_chat_and_terminal_completion_use_their_own_copy():
state = anon_auth.Completed(email="", model="", model_changed=True)
assert "run /model to pick one" in anon_auth.drain_sign_in_copy(iter([state]), chat=True)
assert "run `hermes model` to pick one" in anon_auth.drain_sign_in_copy(iter([state]), chat=False)
def test_the_command_resolves_through_the_cli_fallback():
from cli import HermesCLI
assert HermesCLI._slash_handler("login") == ("_handle_login_command", True)
def test_the_drain_writes_to_the_console_captured_at_start(monkeypatch):
old_buf, new_buf = StringIO(), StringIO()
gate = threading.Event()
threads = []
cli = SimpleNamespace(
console=Console(file=old_buf, force_terminal=False, width=100),
_app=None,
bell_on_complete=False,
final_response_markdown=False,
_scrollback_box_width=lambda: 80,
_invalidate=lambda **_kwargs: None,
)
real_side_worker = commands.CLICommandsMixin._side_worker.__get__(cli)
def side_worker(*args, **kwargs):
thread = real_side_worker(*args, **kwargs)
threads.append(thread)
return thread
cli._side_worker = side_worker
cli._handle_login_command = commands.CLICommandsMixin._handle_login_command.__get__(cli)
monkeypatch.setattr(commands, "_cp", lambda *_lines: None)
monkeypatch.setattr(anon_auth, "render_sign_in_cli_code", lambda *_args, **_kwargs: None)
def flow(**_kwargs):
yield anon_auth.Code("https://example.test/sign-in", "CODE", 60, 1)
gate.wait()
yield anon_auth.Completed(email="person@example.test")
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
cli._handle_login_command("/login")
cli.console = Console(file=new_buf, force_terminal=False, width=100)
gate.set()
threads[0].join(timeout=2)
assert "Signed in as person@example.test. Your connectors are kept." in old_buf.getvalue()
assert new_buf.getvalue() == ""
def test_the_live_tui_drain_prints_through_cprint_instead_of_the_captured_console(monkeypatch):
import cli as cli_module
old_buf, new_buf = StringIO(), StringIO()
gate = threading.Event()
threads = []
output = []
cli = SimpleNamespace(
console=Console(file=old_buf, force_terminal=False, width=100),
_app=SimpleNamespace(invalidate=lambda: None),
bell_on_complete=False,
final_response_markdown=False,
_scrollback_box_width=lambda: 80,
_invalidate=lambda **_kwargs: None,
)
real_side_worker = commands.CLICommandsMixin._side_worker.__get__(cli)
def side_worker(*args, **kwargs):
thread = real_side_worker(*args, **kwargs)
threads.append(thread)
return thread
cli._side_worker = side_worker
cli._handle_login_command = commands.CLICommandsMixin._handle_login_command.__get__(cli)
monkeypatch.setattr(commands, "_cp", lambda *lines: output.extend(lines))
monkeypatch.setattr(cli_module, "_cprint", lambda *lines, **_kwargs: output.extend(lines))
monkeypatch.setattr(anon_auth, "render_sign_in_cli_code", lambda *_args, **_kwargs: None)
def flow(**_kwargs):
yield anon_auth.Code("https://example.test/sign-in", "CODE", 60, 1)
gate.wait()
yield anon_auth.Completed(email="person@example.test")
monkeypatch.setattr(anon_auth, "run_sign_in", flow)
cli._handle_login_command("/login")
cli.console = Console(file=new_buf, force_terminal=False, width=100)
gate.set()
threads[0].join(timeout=2)
assert not threads[0].is_alive()
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)
def test_upgrade_guest_keeps_the_terminal_timeout(monkeypatch):
seen = []
monkeypatch.setattr(anon_auth, "render_sign_in_cli", lambda **kwargs: seen.append(kwargs) or 0)
monkeypatch.setattr("hermes_cli.auth_device_flow._is_remote_session", lambda: True)
assert anon_auth.upgrade_guest(SimpleNamespace(timeout=None, no_browser=False)) == 0
assert seen[0]["timeout_seconds"] == 15.0
assert seen[0]["chat"] is False
def test_the_terminal_renderer_keeps_the_original_line_sequence(monkeypatch, capsys):
monkeypatch.setattr("hermes_cli.auth_device_flow._is_remote_session", lambda: True)
monkeypatch.setattr(anon_auth, "run_sign_in", lambda **_kwargs: iter([
anon_auth.Code("https://example.test/sign-in", "CODE-1", 900, 5),
anon_auth.Waiting(),
anon_auth.Completed(email="person@example.test", model="model-1", model_changed=True),
]))
assert anon_auth.upgrade_guest(SimpleNamespace(timeout=None, no_browser=False)) == 0
assert capsys.readouterr().out.splitlines() == [
anon_auth.UPGRADE_START,
"",
"To continue:",
" 1. Open: https://example.test/sign-in",
" 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.",
"Default model is now model-1.",
]
+1 -1
View File
@@ -524,7 +524,7 @@ def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(
monkeypatch.setattr(auth_mod, "persist_nous_credentials", lambda state: None)
try:
_web_server_oauth._nous_poller(session_id)
_web_server_oauth._nous_plain_poller(session_id)
assert captured_state["scope"] == auth_mod.DEFAULT_NOUS_SCOPE
assert _web_server_oauth._oauth_sessions[session_id]["status"] == "approved"
finally:
+2 -1
View File
@@ -35,7 +35,8 @@ def nous_tool_gateway_unavailable_message(capability: str = "the Nous Tool Gatew
from hermes_cli.nous_account import (
format_nous_portal_entitlement_message, get_nous_portal_account_info)
message = format_nous_portal_entitlement_message(
get_nous_portal_account_info(force_fresh=force_fresh), capability=capability)
get_nous_portal_account_info(force_fresh=force_fresh), capability=capability,
in_chat=True)
if message:
return message
except Exception:
+3 -1
View File
@@ -133,6 +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). |
| `/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). |
@@ -257,6 +258,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
| `/sessions [all] [search <query>]` | List previous sessions for this chat; the active session appears with a `(current)` marker. `/sessions search <query>` 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). |
| `/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. |
@@ -310,7 +312,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
- `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact.
- `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/login`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
- In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume <id-or-title>` for saved or closed transcripts.
+36 -15
View File
@@ -29,11 +29,11 @@ single model. Asking for another model on the free tier prints a pointer instead
silently:
```text
gpt-5 needs a Nous account or an API key. Run `hermes auth upgrade` or `hermes model`.
gpt-5 needs a Nous account or an API key. Use /login to sign in, or /model to pick another provider.
```
Calling a paid tool prints `This tool requires a Nous account. Run hermes auth upgrade.` and the
turn continues without it.
Calling a paid tool says `This needs a Nous account. Use /login to sign in.` inside a chat (and
names `hermes auth upgrade` in the terminal); the turn continues without it.
If `model.default` in `config.yaml` names something other than `nous/welcome` while the free tier
is doing inference, Hermes uses `nous/welcome` anyway and says so in one line. The free tier
@@ -55,19 +55,39 @@ background on the next start so connectors have something to authenticate with,
one-time notice:
```text
Free Nous inference and connectors are now available. `hermes model` to try them, `hermes auth upgrade` to sign in.
Free Nous inference and connectors are now available. /model to try them, /login to sign in.
```
You can pick the free tier explicitly from `hermes model` (or `/model`) like any other provider.
## Signing in
## Signing in from a chat or terminal
### From a chat
Run `/login` in a Hermes DM on Telegram, Discord, or another supported messaging platform (on
Slack use `/hermes login`), or in a CLI chat session. It must be a paired direct message:
elsewhere Hermes replies `Sign in from a direct message with Hermes.` Broadcast-shaped platforms
such as ntfy are refused for the same reason.
The DM gets an acknowledgement, followed by three messages: the consent link, the sign-in code on
its own line, then `Do not share this code. Waiting for sign-in, up to N minutes.` You can keep
chatting while Hermes waits, and the result is pushed into the same DM. Running `/login` again
replaces the first code. Live sessions still on `nous/welcome` move to the settled model on their
next message. In the Ink TUI the code appears but the confirmation does not; check `/status`.
:::warning One account per install
`/login` binds this whole Hermes install to the account that approves the code: its inference, its
connectors, every chat it serves. On a gateway several people can DM, set `allow_admin_from` for
the platform (see the [slash-command access guide](/reference/slash-commands)) so only an operator
can run it.
:::
### From a terminal
```bash
hermes auth upgrade
```
The command name is provisional and may change in a later release; the behaviour will not.
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.
@@ -82,13 +102,14 @@ model for its plan (the same one a fresh `hermes model` pick would suggest), and
you chose yourself is left alone. If no recommendation is available at that moment, no default is
set and Hermes tells you to run `hermes model`.
`hermes auth upgrade` is offered wherever the free tier is present, including installs that
run inference on their own API key. Signing in still unlocks paid tools for those installs.
`/login` in a chat, or `hermes auth upgrade` in a terminal, is offered wherever the free tier is
present, including installs that run inference on their own API key. Signing in still unlocks paid
tools for those installs.
:::note Plain login starts fresh
`hermes auth add nous --type oauth` also signs you in, but it replaces the free tier outright and
does not carry your connectors over. Use `hermes auth upgrade` when you have connectors you want
to keep.
does not carry your connectors over. Use `/login`, or `hermes auth upgrade` in a terminal, when
you have connectors you want to keep.
:::
## On Hermes Desktop
@@ -153,7 +174,7 @@ itself.
| First command prints `It looks like Hermes isn't configured yet` and offers `hermes setup` | The free tier could not be set up within a few seconds: you are offline, or the free tier is not open on the portal Hermes is pointed at, or it is rate limited. | Come back online and run the command again, or run `hermes setup` and add a provider of your own. Nothing is left half-configured. |
| `Nous free tier is not open on this portal.` | The portal Hermes is pointed at is not offering the free tier right now. If you set `HERMES_PORTAL_BASE_URL`, that portal may not have it at all. | Sign in with an account, unset a portal override you no longer need, or add your own key with `hermes setup`. |
| `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 tool requires a Nous account.` | You called a paid Tool Gateway tool on the free tier. | `hermes auth upgrade`, or configure that tool with your own key in `hermes tools`. |
| `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. |
@@ -164,6 +185,6 @@ needs one and stores the credential in your Hermes directory, shared across the
that directory. That identity holds no email address, no name, and no other personal data; it
exists so inference and connector calls can be authenticated and rate limited. It expires after
14 days without use, at which point Hermes transparently creates a new one the next time you run
a command. Signing in with `hermes auth upgrade` 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.
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.