feat(gateway): sign in with a Nous account from a chat (/login), one shared sign-in flow (#105261)
* refactor(auth): one sign-in flow behind SignInState, rendered by the CLI and the desktop * feat(gateway): /signin signs the free tier into a Nous account from a DM * feat(cli): chat surfaces name /signin as the sign-in verb * fix(auth): review follow-ups for the shared sign-in flow and /signin * fix(i18n): carry the /status free-tier line in every locale catalog * refactor(cli): the chat sign-in command is /login * fix(auth): durable override cleanup in the /login sweep, and the sign-in flow in its own modules
This commit is contained in:
@@ -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 ""
|
||||
|
||||
|
||||
@@ -225,9 +225,12 @@ def nous_rate_limit_guard(
|
||||
)
|
||||
_nous_remaining = nous_rate_limit_remaining()
|
||||
if _nous_remaining is not None and _nous_remaining > 0:
|
||||
_nous_msg = (
|
||||
f"Nous Portal rate limit active — resets in {_fmt_nous_remaining(_nous_remaining)}."
|
||||
)
|
||||
from hermes_cli import anon_auth
|
||||
reset = _fmt_nous_remaining(_nous_remaining)
|
||||
if anon_auth.route_is_welcome_host(getattr(agent, "base_url", "")):
|
||||
_nous_msg = anon_auth.FREE_TIER_RATE_LIMIT_CHAT.format(reset=reset)
|
||||
else:
|
||||
_nous_msg = f"Nous Portal rate limit active — resets in {reset}."
|
||||
agent._buffer_vprint(f"⏳ {_nous_msg} Trying fallback...")
|
||||
agent._buffer_status(f"⏳ {_nous_msg}")
|
||||
if agent._try_activate_fallback():
|
||||
|
||||
@@ -171,6 +171,12 @@ describe('desktop slash command curation', () => {
|
||||
expect(isDesktopSlashCommand('/pets')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not run /login on desktop before the catalog is loaded', () => {
|
||||
rememberDesktopCommandsCatalog(undefined)
|
||||
expect(isDesktopSlashCommand('/login')).toBe(false)
|
||||
expect(desktopSlashUnavailableMessage('/login')).toBe('/login is managed from the desktop sidebar.')
|
||||
})
|
||||
|
||||
it('routes /wake through the desktop wake action instead of the slash worker', () => {
|
||||
expect(resolveDesktopCommand('/wake')?.surface).toEqual({ kind: 'action', action: 'wake' })
|
||||
expect(desktopSlashCommandArgumentMode('/wake')).toBe('options')
|
||||
|
||||
@@ -317,7 +317,7 @@ const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> =
|
||||
'/verbose'
|
||||
],
|
||||
messaging: ['/approve', '/deny'],
|
||||
settings: ['/skills', '/pets'],
|
||||
settings: ['/skills', '/pets', '/login'],
|
||||
advanced: [
|
||||
'/curator',
|
||||
'/fast',
|
||||
|
||||
@@ -81,6 +81,38 @@ or opted-in explicit targets. `all` expansions do not gain home mirror eligibili
|
||||
briefs are labelled user turns appended at a turn boundary, preserving role alternation
|
||||
(`cron/AGENTS.md`).
|
||||
|
||||
## `/login` (off-turn, paired DM only)
|
||||
|
||||
`/login` is registered in `hermes_cli/commands.py` with `busy_policy="dispatch"` and
|
||||
`desktop="settings"`, listed in `run_busy.py::_PLAIN_COMMANDS`, and handled by
|
||||
`GatewayLoginCommandsMixin` (`gateway/slash_commands_login.py`). It refuses outside a paired DM:
|
||||
`chat_type in {"dm","private"}`, a truthy `chat_id`, and a platform whose `"dm"` really is a paired
|
||||
conversation — ntfy, raft and a2a all report `chat_type="dm"` for a broadcast topic, a channel and
|
||||
an agent peer, so posting a consent link there would publish it.
|
||||
|
||||
**It binds the whole install.** The sign-in writes the singleton `providers.nous`, so whoever
|
||||
approves the code owns this gateway's inference and connectors for every chat it serves. Slash
|
||||
gating is opt-in (`gateway/slash_access.py`): with no `allow_admin_from` set, every user allowed to
|
||||
DM the bot can run it. Operators of shared gateways must set it.
|
||||
|
||||
The handler returns its ack at once and drains `anon_auth.run_sign_in` on a **private single-worker
|
||||
executor** (`_login_executor`), never the shared 10-thread gateway pool — a promotion wait can last
|
||||
the code's full expiry, and a live worker in the shared pool makes shutdown skip the SessionDB
|
||||
close/checkpoint. One attempt per process, stamped with the identity that started it: the same
|
||||
identity's second `/login` supersedes (the loser ends with the superseded copy pushed into its own
|
||||
chat); a different identity is refused. `task.cancel()` cannot interrupt a blocking poll inside a
|
||||
worker thread, so shutdown and supersede both work through `attempt.cancelled`, which
|
||||
`wait_for_promotion` now polls on a ≤1 s tick. A shutdown-cancelled attempt pushes nothing — the
|
||||
task is dying and its adapters may already be gone — but if the server had already completed the
|
||||
transfer it still persists, because that transfer is irreversible.
|
||||
|
||||
On completion the handler **evicts** every cached agent still on `nous/welcome` and clears any
|
||||
session model override pinned to it. It does not switch agents in place and does not write a model
|
||||
override: `settle_after_upgrade` already moved `model.default`/`model.base_url` in the config, every
|
||||
turn re-resolves the config and the credentials, and `_agent_config_signature` already forces a
|
||||
rebuild when the route changes — so an in-place swap buys no cache warmth and an override would pin
|
||||
an expiring access token that nothing refreshes.
|
||||
|
||||
## Gateway lifecycle vs. the Desktop app
|
||||
|
||||
`hermes serve` (control plane, desktop-spawned child) dies with the app — by design. The messaging
|
||||
|
||||
+1
-1
@@ -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 = (
|
||||
|
||||
@@ -758,7 +758,7 @@ class GatewayNotificationsMixin:
|
||||
except Exception as exc:
|
||||
logger.debug("Free tier startup line skipped: %s", exc)
|
||||
return None
|
||||
return "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade"
|
||||
return "Inference: Nous free tier (nous/welcome). Sign in for more: /login"
|
||||
|
||||
async def _send_home_channel_startup_notifications(
|
||||
self, *, skip_targets: Optional[set[tuple[str, str, Optional[str]]]] = None
|
||||
|
||||
+12
-5
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -137,3 +137,25 @@ Sign-in completion is one function, `settle_after_upgrade`, called by every call
|
||||
account over a free-tier identity (CLI `upgrade_guest`, the desktop poller): it moves a config on the
|
||||
welcome route to the account's host and the tier's recommended default
|
||||
(`models.recommended_nous_default_model`, shared with `GET /api/model/recommended-default`).
|
||||
|
||||
The shared flow, states, and copy live in `anon_sign_in.py`; CLI rendering lives in
|
||||
`anon_sign_in_cli.py`. `anon_auth.py` keeps identity, promotion polling, and settlement, and
|
||||
re-exports the existing sign-in API. The flow resolves identity and persistence collaborators
|
||||
through `anon_auth` at call time to preserve module-attribute monkeypatch seams.
|
||||
|
||||
The sign-in itself is one composition: `anon_auth.run_sign_in()` yields `SignInState`s (`Code`,
|
||||
`Waiting`, `Completed`, `Declined`, `Superseded`, `TimedOut`, `Retired`, `Failed`,
|
||||
`AlreadySignedIn`, `Unavailable`). It reads the current state itself, holds one absolute deadline
|
||||
across both waits, persists only after a completed promotion **and** a token grant, runs
|
||||
`settle_after_upgrade` exactly once per completion, and never lets a persist or settle failure
|
||||
escape as an exception — it becomes `Failed`. Every state carries its own `.copy` (the chat form,
|
||||
which never contains a raw exception, a URL or a `hermes` verb) and `.copy_terminal`, so no caller
|
||||
maps a reason to a string. `cancelled()` stops an attempt; `cancel_wins_after_promotion` decides
|
||||
what happens when the server had already completed the transfer — the desktop keeps `True` (a
|
||||
DELETE means "not on this machine"), the gateway passes `False` (a supersede must not discard a
|
||||
transfer the user actually approved). `scope` is entered only around the precondition and persist
|
||||
blocks, never across a `yield` or a network wait, because `run_in_executor` does not carry
|
||||
contextvars. `upgrade_guest` (`hermes auth upgrade`), the CLI `/login` handler and the desktop
|
||||
promotion poller are renderers over it; a surface that needs the cancel check and the save to be
|
||||
atomic passes `persist_guard`. The desktop's plain "connect another Nous account" device-code login
|
||||
is a separate path (`_nous_plain_poller`) and must stay one.
|
||||
|
||||
+105
-111
@@ -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"
|
||||
|
||||
@@ -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")))
|
||||
@@ -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)
|
||||
@@ -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)))
|
||||
|
||||
|
||||
|
||||
@@ -359,17 +359,22 @@ def _db_unavailable_line() -> str:
|
||||
return f" {format_session_db_unavailable()}"
|
||||
|
||||
|
||||
def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_note) -> None:
|
||||
"""Print a worker-thread result (/bg, /btw) into the scrollback: accent rules around
|
||||
def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_note, console=None) -> None:
|
||||
"""Print a worker-thread result (/bg, /btw, /login) into the scrollback: accent rules around
|
||||
``header_lines``, then ``body`` in a skinned Rich panel (or ``empty_note``).
|
||||
Forces a TUI refresh first so the spinner/status bar don't overlap the output."""
|
||||
from cli import ChatConsole, _accent_hex, _maybe_remap_for_light_mode, _render_final_assistant_content
|
||||
_refresh_tui_before_print(cli)
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
_cp(*header_lines)
|
||||
ChatConsole().print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
rich_console = console or ChatConsole()
|
||||
rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
if console is None:
|
||||
_cp(*header_lines)
|
||||
else:
|
||||
for line in header_lines:
|
||||
console.print(line)
|
||||
rich_console.print(f"[{_accent_hex()}]{'─' * 40}[/]")
|
||||
if not body:
|
||||
return _cp(empty_note)
|
||||
return _cp(empty_note) if console is None else console.print(empty_note)
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
@@ -378,7 +383,7 @@ def _print_side_result_panel(cli, *, header_lines, body, title_suffix, empty_not
|
||||
_resp_text = _maybe_remap_for_light_mode(_skin.get_color("banner_text", "#FFF8DC"))
|
||||
except Exception:
|
||||
label, _resp_color, _resp_text = "⚕ Hermes", "#CD7F32", "#FFF8DC"
|
||||
ChatConsole().print(Panel(
|
||||
rich_console.print(Panel(
|
||||
_render_final_assistant_content(body, mode=cli.final_response_markdown),
|
||||
title=f"[{_resp_color} bold]{label} {title_suffix}[/]", title_align="left",
|
||||
border_style=_resp_color, style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 4),
|
||||
@@ -1982,20 +1987,26 @@ class CLICommandsMixin:
|
||||
thread.start()
|
||||
|
||||
def _side_worker(self, produce, *, name, fail_label, header_lines, title_suffix, empty_note,
|
||||
bell=False, on_done=None) -> threading.Thread:
|
||||
"""Daemon thread for /bg and /btw: ``produce()`` returns the body to print in a side-result
|
||||
bell=False, on_done=None, console=None) -> threading.Thread:
|
||||
"""Daemon thread for /bg, /btw and /login: ``produce()`` returns the body to print in a side-result
|
||||
panel; failures print ``fail_label`` failed; the TUI is always re-invalidated afterwards."""
|
||||
def run():
|
||||
try:
|
||||
body = produce()
|
||||
_print_side_result_panel(self, header_lines=header_lines, body=body,
|
||||
title_suffix=title_suffix, empty_note=empty_note)
|
||||
title_suffix=title_suffix, empty_note=empty_note,
|
||||
console=console)
|
||||
if bell and self.bell_on_complete:
|
||||
sys.stdout.write("\a")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
_refresh_tui_before_print(self)
|
||||
_cp(f" ❌ {fail_label} failed: {e}")
|
||||
line = f" ❌ {fail_label} failed: {e}"
|
||||
# Same console the caller captured, so a late failure can't splice into a later command.
|
||||
if console is not None:
|
||||
console.print(line, markup=False)
|
||||
else:
|
||||
_cp(line)
|
||||
finally:
|
||||
if on_done is not None:
|
||||
on_done()
|
||||
@@ -2004,6 +2015,49 @@ class CLICommandsMixin:
|
||||
|
||||
return threading.Thread(target=run, daemon=True, name=name)
|
||||
|
||||
def _handle_login_command(self, cmd_original: str) -> None:
|
||||
"""Start an in-chat sign-in without blocking the input loop while approval is pending."""
|
||||
from hermes_cli import anon_auth
|
||||
# Pin the output target now. Under the live TUI ``self.console`` writes straight to
|
||||
# patch_stdout's StdoutProxy, which mangles Rich's escapes — there ``None`` keeps the
|
||||
# panel on the ``_cprint`` path. Only the slash worker (``_app`` is None) swaps the console.
|
||||
console = None if getattr(self, "_app", None) else getattr(self, "console", None)
|
||||
_cp(f" {anon_auth.LOGIN_STARTING}")
|
||||
gen = anon_auth.run_sign_in(timeout_seconds=8.0)
|
||||
try:
|
||||
first = next(gen, None)
|
||||
except KeyboardInterrupt:
|
||||
with suppress(Exception):
|
||||
gen.close()
|
||||
return _cp(anon_auth.UPGRADE_CANCELLED)
|
||||
if first is None:
|
||||
return
|
||||
if first.terminal:
|
||||
return _cp(f" {first.copy}")
|
||||
anon_auth.render_sign_in_cli_code(first, chat=True, printer=_cp)
|
||||
|
||||
def _settle_session_model(state) -> None:
|
||||
"""A completed sign-in moved this profile onto the account: the welcome host is gone and
|
||||
the portal serves ``nous/welcome`` as a paid model, so a session still carrying it must
|
||||
move too — the CLI counterpart of the gateway's on-``Completed`` sweep. Only the free
|
||||
tier's own model is replaced; a model the user picked while the sign-in was pending
|
||||
stands. Writing ``self.model`` is enough: ``chat()`` compares the turn-route signature
|
||||
and rebuilds the agent on the next turn, so a turn already in flight keeps the agent it
|
||||
started with. ``getattr``: tests drive this handler with minimal shells.
|
||||
"""
|
||||
if state.kind != "completed" or not getattr(state, "model_changed", False):
|
||||
return
|
||||
if str(getattr(self, "model", "") or "") == anon_auth.GUEST_MODEL:
|
||||
# "" when the settle cleared the default: _ensure_runtime_credentials then applies
|
||||
# the provider's silent default, which is what settle_after_upgrade documents.
|
||||
self.model = state.model or ""
|
||||
|
||||
thread = self._side_worker(
|
||||
lambda: anon_auth.drain_sign_in_copy(gen, chat=True, on_terminal=_settle_session_model),
|
||||
name="login", fail_label="Sign-in", header_lines=[" Sign-in"],
|
||||
title_suffix="(sign-in)", empty_note=" (No result)", console=console)
|
||||
thread.start()
|
||||
|
||||
def _handle_btw_command(self, cmd: str):
|
||||
"""Handle /btw <question> — answer a side question about this conversation from a
|
||||
history snapshot via a one-shot auxiliary call. The live session is never touched
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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`)_"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,7 +48,8 @@ def test_registry_names_resolve_into_the_table():
|
||||
assert cmd is not None and HermesCLI._slash_handler(cmd.name) is not None, name
|
||||
# registry commands the CLI never handled inline must still fall through
|
||||
dispatched = {c.name for c in COMMAND_REGISTRY if HermesCLI._slash_handler(c.name)}
|
||||
assert dispatched == set(OLD_CHAIN_COMMANDS) - {"exit"} | {"quit"}
|
||||
# /login has no old branch; it resolves through the naming-convention fallback.
|
||||
assert dispatched == set(OLD_CHAIN_COMMANDS) - {"exit"} | {"quit", "login"}
|
||||
|
||||
|
||||
def _cli():
|
||||
|
||||
@@ -14,7 +14,7 @@ from hermes_cli import anon_auth
|
||||
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
|
||||
from tests.gateway.restart_test_helpers import make_restart_runner
|
||||
|
||||
FREE_TIER_LINE = "Inference: Nous free tier (nous/welcome). Sign in for more: hermes auth upgrade"
|
||||
FREE_TIER_LINE = "Inference: Nous free tier (nous/welcome). Sign in for more: /login"
|
||||
|
||||
|
||||
def _jwt(**claims) -> str:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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]
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -133,6 +133,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|
||||
| `/usage` | Show token usage, cost breakdown, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits / plan usage pulled live from the provider's API. |
|
||||
| `/topup` | Show your Nous balance and manage billing on the portal (replaces the old `/credits` and `/billing` commands). |
|
||||
| `/subscription` (alias: `/upgrade`) | **CLI only.** View your Nous plan and change it in the browser. |
|
||||
| `/login` | Sign in with a Nous account, keeping your connectors. Runs off-turn: the consent link and code arrive in the session, and the sign-in settles when you approve it in the browser. See [Nous free tier](/user-guide/free-tier). |
|
||||
| `/insights` | Show usage insights and analytics (last 30 days) |
|
||||
| `/update` | Update Hermes Agent to the latest version. |
|
||||
| `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status (CLI-only summary view). |
|
||||
@@ -257,6 +258,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
||||
| `/sessions [all] [search <query>]` | List previous sessions for this chat; the active session appears with a `(current)` marker. `/sessions search <query>` filters by title/id match (most recently active first); `/sessions all` lists across origins (admin only — non-admins get a notice and the chat-scoped list). |
|
||||
| `/usage` | Show token usage, estimated cost breakdown (input/output), context window state, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits pulled live from the provider's API. |
|
||||
| `/topup` | Show your Nous balance and manage billing on the portal. |
|
||||
| `/login` | Sign in with a Nous account, keeping your connectors. **Paired direct messages only** — in a group, channel, or broadcast-shaped platform Hermes refuses. On Slack use `/hermes login`. See [Nous free tier](/user-guide/free-tier). |
|
||||
| `/whoami` | Show your slash command access level (admin / user). |
|
||||
| `/insights [days]` | Show usage analytics. |
|
||||
| `/reasoning [level\|show\|hide\|full\|clamp] [--global]` | Change reasoning effort (levels up to `max` / `ultra`) or toggle reasoning display (`full` / `clamp` included). `--global` persists to config. |
|
||||
@@ -310,7 +312,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact.
|
||||
- `/sethome`, `/restart`, `/approve`, `/deny`, `/topic`, `/platform`, and `/commands` are **messaging-only** commands.
|
||||
- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/status`, `/egress`, `/version`, `/whoami`, `/bg`, `/btw`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/reload-skills`, `/rollback`, `/diff`, `/debug`, `/fast`, `/approvals`, `/busy`, `/footer`, `/curator`, `/kanban`, `/topup`, `/login`, `/suggestions`, `/blueprint`, `/learn`, `/init`, `/sessions`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
|
||||
- In the TUI, `/sessions` shows live sessions in the current TUI process. Use `/resume [name]` or `hermes --tui --resume <id-or-title>` for saved or closed transcripts.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user