diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index f27245eba4..6739c096e4 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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 "" diff --git a/agent/turn_api_call.py b/agent/turn_api_call.py index 0aa3519289..194ee22f95 100644 --- a/agent/turn_api_call.py +++ b/agent/turn_api_call.py @@ -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(): diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index a68e37a3c5..c45a78d0a4 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -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') diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 79f295c780..696eb401ef 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -317,7 +317,7 @@ const NO_DESKTOP_SURFACE: Record = '/verbose' ], messaging: ['/approve', '/deny'], - settings: ['/skills', '/pets'], + settings: ['/skills', '/pets', '/login'], advanced: [ '/curator', '/fast', diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index ef22c4cf31..c45fbb45da 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -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 diff --git a/gateway/run_busy.py b/gateway/run_busy.py index 2696831cfb..f6a8139e34 100644 --- a/gateway/run_busy.py +++ b/gateway/run_busy.py @@ -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 = ( diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 1d9f821c4f..fb0fc4995c 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -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 diff --git a/gateway/session.py b/gateway/session.py index d102e59388..001d27f3b4 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -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: diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index f2f7bf6963..27d688203c 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -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, diff --git a/gateway/slash_commands_login.py b/gateway/slash_commands_login.py new file mode 100644 index 0000000000..c16f7f1ba4 --- /dev/null +++ b/gateway/slash_commands_login.py @@ -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 diff --git a/gateway/slash_commands_status.py b/gateway/slash_commands_status.py index de26ffa739..ced692caf3 100644 --- a/gateway/slash_commands_status.py +++ b/gateway/slash_commands_status.py @@ -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: diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index a36be73a88..ee19866993 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -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. diff --git a/hermes_cli/anon_auth.py b/hermes_cli/anon_auth.py index ffe4e8c93a..0953877f92 100644 --- a/hermes_cli/anon_auth.py +++ b/hermes_cli/anon_auth.py @@ -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" diff --git a/hermes_cli/anon_sign_in.py b/hermes_cli/anon_sign_in.py new file mode 100644 index 0000000000..dce6fa8ab4 --- /dev/null +++ b/hermes_cli/anon_sign_in.py @@ -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"))) diff --git a/hermes_cli/anon_sign_in_cli.py b/hermes_cli/anon_sign_in_cli.py new file mode 100644 index 0000000000..93b6b62d94 --- /dev/null +++ b/hermes_cli/anon_sign_in_cli.py @@ -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) diff --git a/hermes_cli/auth_device_flow.py b/hermes_cli/auth_device_flow.py index 1951869388..6f68dfe1f5 100644 --- a/hermes_cli/auth_device_flow.py +++ b/hermes_cli/auth_device_flow.py @@ -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))) diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 85f0665ee9..625ace7d41 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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 — answer a side question about this conversation from a history snapshot via a one-shot auxiliary call. The live session is never touched diff --git a/hermes_cli/cli_session_mixin.py b/hermes_cli/cli_session_mixin.py index 7281d1a3ff..b1e7fcc963 100644 --- a/hermes_cli/cli_session_mixin.py +++ b/hermes_cli/cli_session_mixin.py @@ -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: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 838aa5a109..5bc9eabeb4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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"), diff --git a/hermes_cli/commands_platforms.py b/hermes_cli/commands_platforms.py index db664155c6..2c90acf8a2 100644 --- a/hermes_cli/commands_platforms.py +++ b/hermes_cli/commands_platforms.py @@ -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: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index d2902f298b..84172ebf0a 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -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; diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 69b40092b1..08fe16e0ac 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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 diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index a7cb2f2cec..926bba25b4 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -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 = ( diff --git a/hermes_cli/web_routers/oauth.py b/hermes_cli/web_routers/oauth.py index 1a13e84fd1..77f2902376 100644 --- a/hermes_cli/web_routers/oauth.py +++ b/hermes_cli/web_routers/oauth.py @@ -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]: diff --git a/hermes_cli/web_server_oauth.py b/hermes_cli/web_server_oauth.py index 947f9c6c99..ebabee383e 100644 --- a/hermes_cli/web_server_oauth.py +++ b/hermes_cli/web_server_oauth.py @@ -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 diff --git a/locales/af.yaml b/locales/af.yaml index 363563e9a4..6e5376ae62 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -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`)_" diff --git a/locales/ar.yaml b/locales/ar.yaml index abdc371ead..ce9bc2663f 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -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}" diff --git a/locales/de.yaml b/locales/de.yaml index d6e1528088..0259e786fa 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -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`)_" diff --git a/locales/en.yaml b/locales/en.yaml index 9b06ae1e96..621bfb7705 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -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`)_" diff --git a/locales/es.yaml b/locales/es.yaml index 06cd2e9e23..434f1aca34 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -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`)_" diff --git a/locales/fr.yaml b/locales/fr.yaml index 4f1faa6cbf..8fa2daa16a 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -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`)_" diff --git a/locales/ga.yaml b/locales/ga.yaml index 843dc5a1c2..f96570eda0 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -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`)_" diff --git a/locales/hu.yaml b/locales/hu.yaml index d134d4372f..e26e211088 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -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`)_" diff --git a/locales/it.yaml b/locales/it.yaml index a3480541bc..9e1ecb6d71 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -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`)_" diff --git a/locales/ja.yaml b/locales/ja.yaml index b691daf93d..0819b688c2 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -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`)_" diff --git a/locales/ko.yaml b/locales/ko.yaml index f7f4f25a06..1608fcb00f 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -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`)_" diff --git a/locales/pt.yaml b/locales/pt.yaml index f18100340e..8e0e595b4b 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -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`)_" diff --git a/locales/ru.yaml b/locales/ru.yaml index 7fd2c285f2..dc971f61f0 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -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`)_" diff --git a/locales/tr.yaml b/locales/tr.yaml index 7af791fb74..cdeba56588 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -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`)_" diff --git a/locales/uk.yaml b/locales/uk.yaml index 5e63f0bdad..1f4a9dd7b3 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -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`)_" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index b9091c2aa2..0ae999885c 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -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`)_" diff --git a/locales/zh.yaml b/locales/zh.yaml index dde25d0e82..5ef9fd0612 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -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`)_" diff --git a/tests/agent/test_nous_rate_guard.py b/tests/agent/test_nous_rate_guard.py index decdda5483..2e68b2f4d1 100644 --- a/tests/agent/test_nous_rate_guard.py +++ b/tests/agent/test_nous_rate_guard.py @@ -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) diff --git a/tests/cli/test_slash_dispatch_table.py b/tests/cli/test_slash_dispatch_table.py index 62660b35d1..5bf91fbb68 100644 --- a/tests/cli/test_slash_dispatch_table.py +++ b/tests/cli/test_slash_dispatch_table.py @@ -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(): diff --git a/tests/gateway/test_free_tier_startup_notice.py b/tests/gateway/test_free_tier_startup_notice.py index 036e26b617..2f50710a3a 100644 --- a/tests/gateway/test_free_tier_startup_notice.py +++ b/tests/gateway/test_free_tier_startup_notice.py @@ -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: diff --git a/tests/gateway/test_login_command.py b/tests/gateway/test_login_command.py new file mode 100644 index 0000000000..da2b0b825f --- /dev/null +++ b/tests/gateway/test_login_command.py @@ -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) diff --git a/tests/gateway/test_status_free_tier_line.py b/tests/gateway/test_status_free_tier_line.py new file mode 100644 index 0000000000..4202668362 --- /dev/null +++ b/tests/gateway/test_status_free_tier_line.py @@ -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 diff --git a/tests/hermes_cli/test_anon_auth_core.py b/tests/hermes_cli/test_anon_auth_core.py index e1f4941b02..144a44869c 100644 --- a/tests/hermes_cli/test_anon_auth_core.py +++ b/tests/hermes_cli/test_anon_auth_core.py @@ -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 diff --git a/tests/hermes_cli/test_anon_desktop_signin.py b/tests/hermes_cli/test_anon_desktop_signin.py index 8364b130eb..819817a57e 100644 --- a/tests/hermes_cli/test_anon_desktop_signin.py +++ b/tests/hermes_cli/test_anon_desktop_signin.py @@ -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) diff --git a/tests/hermes_cli/test_anon_sign_in_flow.py b/tests/hermes_cli/test_anon_sign_in_flow.py new file mode 100644 index 0000000000..fa27ee3fbe --- /dev/null +++ b/tests/hermes_cli/test_anon_sign_in_flow.py @@ -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"] diff --git a/tests/hermes_cli/test_anon_surfaces.py b/tests/hermes_cli/test_anon_surfaces.py index 9bd5ea10d1..165b63945b 100644 --- a/tests/hermes_cli/test_anon_surfaces.py +++ b/tests/hermes_cli/test_anon_surfaces.py @@ -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] diff --git a/tests/hermes_cli/test_login_cli_command.py b/tests/hermes_cli/test_login_cli_command.py new file mode 100644 index 0000000000..d914051728 --- /dev/null +++ b/tests/hermes_cli/test_login_cli_command.py @@ -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.", + ] diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index 04b1242652..7d04fa1070 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -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: diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index f5d9bf8abd..427a8b3584 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -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: diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 005be52a9a..7557e2aabe 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -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 ]` | List previous sessions for this chat; the active session appears with a `(current)` marker. `/sessions search ` 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 ` for saved or closed transcripts. diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md index a39a5dc19c..e784809cbb 100644 --- a/website/docs/user-guide/free-tier.md +++ b/website/docs/user-guide/free-tier.md @@ -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.