feat(free-tier): the free model is a desktop first-install trial, claimed once per machine

HERMES_GUEST_ONBOARDING was a process-wide launch gate: with it set, every CLI,
gateway and serve boot minted an anonymous Nous account and any guest (a
connector guest included) carried inference. It is gone from Python.

- hermes_cli/welcome_trial.py: claim_desktop_free_tier() mints a guest, stamps
  welcome_claim on it and marks the machine. A table of blockers decides
  eligibility: the desktop's local backend only (never SSH, URL, cloud, CLI or
  gateway), nous.guest on, no Nous identity, no provider, and a machine that
  never used Hermes (install_state; the unpackaged desktop may pass
  assume_fresh). grandfather_existing_guest() keeps the trial for a guest that
  already chatted on nous/welcome; connector-only guests never qualify.
- anon_auth.free_inference_active() replaces guest_enabled(): a stamped guest on
  the local desktop backend. The resolver rung, the active-provider check, the
  picker row, the sign-in offer and guest_without_free_tier (inference, status,
  account, pool readers) all read it. ensure_portal_identity is connectors-only
  plus the claim; every token exchange names its purpose.
- No boot mint: free_tier_bootstrap only records who carries inference (and runs
  the grandfather check); the CLI and gateway boot hooks are deleted, and so is
  the inference re-mint after a dead credential (a lost claim is never
  re-claimed).
- onboarding.start claims for a new run before the chat exists; a failed claim
  is error 5079 and writes nothing, so the next launch tries again. A rerun
  never claims. onboarding.state answers eligible only on the local desktop
  backend when the trial or a configured provider can carry the first chat.
- Sign-in from any guest takes the promotion path (connected apps carry over).
- Deleted CLI/gateway free-tier copy: the startup line, both /status lines, the
  one-time CLI notice, the banner's route model and their locale keys.
  `hermes model` points a guest at `hermes auth upgrade`.
This commit is contained in:
alt-glitch committed 2026-10-14 00:32:21 +05:30
1 parent 88be7bd28f
commit 4920f87b3f
47 files changed
+582 -386

No files matched your search

@@ -2180,7 +2180,11 @@ export interface ProfilesGetAssetResult {
size?: number | null
data?: string | null
}
/** ``run``: onboarding opens on this launch (root config ``onboarding.run``, else a fresh install). ``eligible``: the free tier is on for this backend (``anon_auth.guest_enabled``). ``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it; ``run_id``: that run's id, which ``onboarding.ended`` names. */
/** ``assume_fresh``: the unpackaged desktop's ``--fresh-install`` launch; the backend treats this machine as one that never used Hermes. Packaged builds never send it. */
export interface OnboardingStateParams {
assume_fresh?: boolean
}
/** ``run``: onboarding opens on this launch (root config ``onboarding.run``, else a machine that never used Hermes). ``eligible``: this is the desktop's local backend and the welcome trial or a configured provider can carry the first chat. ``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it; ``run_id``: that run's id, which ``onboarding.ended`` names. */
export interface OnboardingRunStateResult {
run: boolean
eligible: boolean
@@ -2192,10 +2196,11 @@ export interface OnboardingSetRunParams {
run: boolean
mark_profile_offered?: boolean
}
/** ``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding again). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not count, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS locale. */
/** ``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding again; it never claims the welcome trial). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not count, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS locale. ``assume_fresh``: as on ``onboarding.state``. A new run on a first install claims the welcome trial before the chat exists; a failed claim is error 5079 and writes nothing. */
export interface OnboardingStartParams {
rerun?: boolean
fresh_launch?: boolean
assume_fresh?: boolean
facts_source?: OnboardingFactsSource
locale?: string | null
}
@@ -5345,7 +5350,7 @@ export interface RpcMethods {
/** Open the onboarding chat in the default profile: create it, write its run record and play the opening. */
'onboarding.start': { params: OnboardingStartParams; result: OnboardingStartResult }
/** Whether desktop first-run onboarding is due, and the chat in progress; reads the root profile's config. */
'onboarding.state': { params: Params; result: OnboardingRunStateResult }
'onboarding.state': { params: OnboardingStateParams; result: OnboardingRunStateResult }
/** Spill a large paste to a file and hand back the inline placeholder. */
'paste.collapse': { params: PasteCollapseParams; result: PasteCollapseResult }
/** Render a PDF's pages to PNG and queue them as images for the next turn. */
+21 -3
View File
@@ -2083,7 +2083,7 @@
{
"name": "params",
"schema": {
"$ref": "#/components/schemas/Params"
"$ref": "#/components/schemas/OnboardingStateParams"
}
}
],
@@ -20333,7 +20333,7 @@
},
"OnboardingRunStateResult": {
"additionalProperties": false,
"description": "``run``: onboarding opens on this launch (root config ``onboarding.run``, else a fresh\ninstall). ``eligible``: the free tier is on for this backend (``anon_auth.guest_enabled``).\n``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it;\n``run_id``: that run's id, which ``onboarding.ended`` names.",
"description": "``run``: onboarding opens on this launch (root config ``onboarding.run``, else a machine that\nnever used Hermes). ``eligible``: this is the desktop's local backend and the welcome trial or a\nconfigured provider can carry the first chat.\n``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it;\n``run_id``: that run's id, which ``onboarding.ended`` names.",
"properties": {
"run": {
"title": "Run",
@@ -20437,7 +20437,7 @@
},
"OnboardingStartParams": {
"additionalProperties": false,
"description": "``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding\nagain). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not\ncount, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs\non the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS\nlocale.",
"description": "``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding\nagain; it never claims the welcome trial). ``fresh_launch``: the app's once-per-launch start; a run\nthe app was closed on does not count, so its chat is deleted and the run starts over.\n``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI\nlanguage, preferred over the backend OS locale. ``assume_fresh``: as on ``onboarding.state``. A new\nrun on a first install claims the welcome trial before the chat exists; a failed claim is error 5079\nand writes nothing.",
"properties": {
"rerun": {
"default": false,
@@ -20449,6 +20449,11 @@
"title": "Fresh Launch",
"type": "boolean"
},
"assume_fresh": {
"default": false,
"title": "Assume Fresh",
"type": "boolean"
},
"facts_source": {
"$ref": "#/components/schemas/OnboardingFactsSource",
"default": "local"
@@ -20499,6 +20504,19 @@
"title": "OnboardingStartResult",
"type": "object"
},
"OnboardingStateParams": {
"additionalProperties": false,
"description": "``assume_fresh``: the unpackaged desktop's ``--fresh-install`` launch; the backend treats this\nmachine as one that never used Hermes. Packaged builds never send it.",
"properties": {
"assume_fresh": {
"default": false,
"title": "Assume Fresh",
"type": "boolean"
}
},
"title": "OnboardingStateParams",
"type": "object"
},
"OpenRequestEntry": {
"additionalProperties": false,
"description": "One unanswered server\u2192client request (``server_requests.Request.snapshot``); the reconnecting\nclient re-delivers it to its request handlers.",
-20
View File
@@ -786,23 +786,6 @@ class GatewayNotificationsMixin(GatewayNotificationOwnershipMixin):
logger.warning(failure_fmt, platform.value, home.chat_id, exc)
return False
def _free_tier_startup_line(self) -> Optional[str]:
"""Extra startup line when the gateway's inference is carried by the Nous free tier; None otherwise.
Best-effort: a resolution failure (no provider, auth error) must not block the online notice."""
try:
# Persisted state only. The free-tier check reads auth.json; it runs FIRST so the resolver
# is only consulted when a free-tier identity already exists and its own free-tier rung
# (which may mint on a fresh install, NS-829) answers from that identity without a network
# call. No token refresh at boot either way.
from hermes_cli.anon_auth import free_tier_route
if not free_tier_route():
return None
except Exception as exc:
logger.debug("Free tier startup line skipped: %s", exc)
return None
return t("gateway.startup.free_tier_line")
_planned_restart_notice_lock: Optional[asyncio.Lock] = None
async def _replay_pending_planned_restart_notification(self) -> None:
@@ -858,9 +841,6 @@ class GatewayNotificationsMixin(GatewayNotificationOwnershipMixin):
delivered: set[tuple[str, str, Optional[str]]] = set()
skipped = skip_targets or set()
message = t("gateway.startup.online")
free_tier_line = self._free_tier_startup_line()
if free_tier_line:
message = f"{message}\n{free_tier_line}"
targets = list(self._served_home_channel_transports())
# A chat already notified for ANOTHER profile is not notified again.
notified_chats = {
+1 -20
View File
@@ -140,13 +140,6 @@ class GatewayStartupMixin:
drained += 1
return drained
@staticmethod
def _start_free_tier_bootstrap() -> None:
"""One bootstrap per process. `run_bootstrap` already records its own failure in the boot record
and never raises, so this is a plain call; it exists as a method so tests can seam it."""
from hermes_cli.free_tier_bootstrap import run_bootstrap
run_bootstrap(announce=False)
def _start_startup_warmup(self) -> None:
"""Kick off the boot turn-machinery warm-up so it overlaps the network-bound platform
connects; ``_finish_startup_restore`` awaits it (bounded)."""
@@ -158,8 +151,7 @@ class GatewayStartupMixin:
async def _run_boot_probe_in_launch_scope(self, fn):
"""Run a boot-time probe on the default executor under the LAUNCH profile's scope when
multiplexing (the ``_discover_gateway_mcp_tools`` shape). Boot probes (``check_fn``s, the
free-tier bootstrap) read profile-scoped secrets; an unscoped read under multiplex fails
multiplexing (the ``_discover_gateway_mcp_tools`` shape). Boot probes (``check_fn``s) read profile-scoped secrets; an unscoped read under multiplex fails
closed, so routing overrides resolve as absent and default routing applies. ``copy_context``
carries the contextvars across the executor hop; single-profile keeps environ semantics."""
loop = asyncio.get_running_loop()
@@ -176,11 +168,6 @@ class GatewayStartupMixin:
getattr(fn, "__name__", fn), exc_info=True)
return await loop.run_in_executor(None, copy_context().run, fn)
async def _run_free_tier_bootstrap(self) -> None:
"""The free-tier bootstrap mints the Portal identity through the same profile-scoped routing
overrides the warm-up resolves, so it takes the same launch-profile binding."""
await self._run_boot_probe_in_launch_scope(self._start_free_tier_bootstrap)
async def _warm_turn_prerequisites(self) -> None:
"""Initialize turn machinery on an executor thread before the gate opens. Never raises: a
failed warm-up degrades to lazy init and must not block startup."""
@@ -1624,12 +1611,6 @@ class GatewayStartupMixin:
if self._start_check_access_policy():
return True
await self._start_recover_previous_run()
# The gateway is a boot owner of the Nous free tier, beside `cmd_chat` and `hermes serve`: every
# demand-time site (provider resolution, /login, the connector token) is a read that needs the
# identity to already exist. Blocking here, before any adapter connects, is what keeps a fast
# first DM from arriving with nothing to resolve. With the launch gate unset this is a local
# inventory and no network.
await self._run_free_tier_bootstrap()
# Serialize startup restore against inbound: adapters receive as soon as they connect, so inbound
# queues until every synthetic resume turn has finished.
self._startup_restore_in_progress = True
-8
View File
@@ -287,14 +287,6 @@ class GatewayStatusCommandsMixin:
lines.append(t("gateway.status.model_provider", model=fields["model"], provider=fields["provider"]))
elif fields["model"]:
lines.append(t("gateway.status.model", model=fields["model"]))
try:
from hermes_cli.anon_auth import free_tier_route
free_tier_active = await self._run_in_executor_with_context(free_tier_route)
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:
+65 -56
View File
@@ -1,14 +1,16 @@
"""Nous free-tier identity: the ``anonymous`` auth method of the ``nous`` provider.
The identity is created in two places: at boot (``hermes_cli.free_tier_bootstrap``) while
``HERMES_GUEST_ONBOARDING=1`` (see ``guest_enabled``), and on the first hosted connector action for
anyone without a Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``, gated only
by ``guest_allowed``). Either mints an anonymous Nous
account (``POST /api/anonymous/create``); its ``anon_`` credential is later exchanged for short-lived
JWTs (``POST /api/anonymous/token``). The result is persisted as the singleton ``providers.nous``. In
the resolver ladder (``resolve_provider``) an existing free-tier identity sits directly above the
implicit AWS Bedrock chain (NS-829): any explicit provider (env key, ``model.provider``, OpenRouter
pool, a logged-in ``active_provider``) beats it, and the ladder never creates one.
The identity is created in two places: by the desktop's first install, which claims the welcome
trial (``hermes_cli.welcome_trial``), and on the first hosted connector action for anyone without a
Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``, gated by ``guest_allowed``).
Either mints an anonymous Nous account (``POST /api/anonymous/create``); its ``anon_`` credential is
later exchanged for short-lived JWTs (``POST /api/anonymous/token``). The result is persisted as the
singleton ``providers.nous``. Only a guest stamped with the trial claim carries inference, and only on
the backend the desktop spawned on this machine (:func:`free_inference_active`); every other guest
serves connectors and managed tools. In the resolver ladder (``resolve_provider``) a guest that
carries inference sits directly above the implicit AWS Bedrock chain (NS-829): any explicit provider
(env key, ``model.provider``, OpenRouter pool, a logged-in ``active_provider``) beats it, and the
ladder never creates one.
Only two mechanics differ from an OAuth login and both are isolated behind ``is_guest_state``:
token acquisition (re-exchange the ``anon_`` credential; there is no refresh token) and routing
@@ -18,9 +20,9 @@ Users are never shown the words guest / anonymous / account for this state: surf
"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
:func:`mint_guest`.
Lifecycle lives in ONE primitive, :func:`_reconcile_and_provision`: adopt what the shared store already
holds, else mint under the shared-store lock. :func:`ensure_portal_identity` (connectors) and the
welcome-trial claim are its only callers; nothing else calls :func:`mint_guest`.
"""
from __future__ import annotations
@@ -48,15 +50,12 @@ ANON_SECRET_HEADER = "x-anonymous-api-secret"
# The shared secret gates the anonymous surface during its integration phase. It is a deployment
# secret (Sid's), read from the environment only.
ANON_SECRET_ENV = "HERMES_ANON_API_SECRET"
# Launch gate for the free tier while it is pre-GA: exactly "1" turns it on for this process (CLI,
# gateway, serve backend alike): the boot mint, the free model and the free-tier surfaces. A guest
# for connectors only needs ``nous.guest`` (``guest_allowed``). Not a user preference: never written to
# config.yaml or .env, never shown in setup. Deleted at GA together with this comment.
GUEST_ONBOARDING_ENV = "HERMES_GUEST_ONBOARDING"
# Set on a guest's ``providers.nous`` state when the desktop's first install claims the welcome trial
# (``hermes_cli.welcome_trial``). Only a stamped guest carries inference; connector mints never set it.
WELCOME_CLAIM_FLAG = "welcome_claim"
# Preview cohort for the free tier's connector set, sent once on account creation so the account
# service can record it on the account. "1" / "true" send true and "0" / "false" send false ("1" is
# GUEST_ONBOARDING_ENV's spelling, so one bundle command reads the same for both); anything else (unset
# included) omits the field and the service applies its default. Self-reported and
# service can record it on the account. "1" / "true" send true and "0" / "false" send false; anything
# else (unset included) omits the field and the service applies its default. Self-reported and
# baked into desktop bundles in plain text: the service must treat it as a preference, never proof.
PREVIEW_FULL_CONNECTORS_ENV = "HERMES_PREVIEW_FULL_CONNECTORS"
GUEST_MINT_TIMEOUT_SECONDS = 5.0
@@ -156,14 +155,8 @@ def anon_failure_copy(code: str, *, retry_after: Any = None) -> str:
return template.format(wait=friendly_wait(retry_after if retry_after else 60))
def guest_enabled() -> bool:
"""The free tier is on for this process: the launch gate is set AND :func:`guest_allowed`."""
return (os.environ.get(GUEST_ONBOARDING_ENV) or "").strip() == "1" and guest_allowed()
def guest_allowed() -> bool:
"""``nous.guest`` (default True) has not switched guests off. Enough for a guest used only for
connectors; the free model and the boot mint also need the launch gate (:func:`guest_enabled`)."""
"""``nous.guest`` (default True) has not switched guests off: no connector guest, no trial."""
try:
from hermes_cli.config import load_config_readonly
nous_cfg = load_config_readonly().get("nous")
@@ -208,20 +201,36 @@ def has_guest() -> bool:
return is_guest_state(current_nous_state())
def on_local_desktop_backend() -> bool:
"""This process is the backend the desktop app spawned on this machine: never an SSH, URL or
cloud backend, and never a gateway or shell the desktop merely launched."""
from hermes_cli.anon_challenge import client_surface
from hermes_cli.process_identity import is_local_desktop_backend
return client_surface() == "desktop" and is_local_desktop_backend()
def free_inference_active(state: Any = None) -> bool:
"""The welcome trial carries inference here: a guest that claimed it (or was grandfathered),
guests still allowed, and this is the desktop's local backend. *state* defaults to the profile's."""
state = current_nous_state() if state is None else state
return (is_guest_state(state) and bool(state.get(WELCOME_CLAIM_FLAG)) and guest_allowed()
and on_local_desktop_backend())
def has_free_tier_account() -> bool:
"""True when the profile's Nous identity is the free tier and the free tier is on.
"""True when the profile's Nous identity is a guest that carries inference here.
Profile-level: use for status, picker and notice surfaces. Routing decisions (which model a
request may carry) must use :func:`route_is_welcome_host` on the SELECTED runtime instead: a
credential-pool entry can pick a paid Nous key while the profile singleton is still a guest.
"""
return guest_enabled() and has_guest()
return free_inference_active()
def guest_without_free_tier(state: Any) -> bool:
"""A guest identity while the free tier is off: it serves connectors and managed tools only, so
"""A guest that does not carry inference here: it serves connectors and managed tools only, so
inference, account and status readers treat the profile as not signed in."""
return is_guest_state(state) and not guest_enabled()
return is_guest_state(state) and not free_inference_active(state)
def free_tier_route() -> bool:
@@ -392,7 +401,7 @@ def held_connectors_token(state: Any, *, skew_seconds: Optional[int] = None) ->
def exchange_anon_jwt(
client: httpx.Client, portal_base_url: str, anon_token: str, *, auth_state: dict[str, Any],
purpose: str = "inference",
purpose: str,
) -> dict[str, Any]:
"""``POST /api/anonymous/token {token}`` -> ``{access_token, expires_in, inference_base_url, ...}``.
@@ -647,32 +656,30 @@ def _reconcile_and_provision(*, timeout_seconds: float, force: bool = False) ->
def ensure_portal_identity(
*, explicit: bool, timeout_seconds: float = GUEST_MINT_TIMEOUT_SECONDS, force: bool = False,
for_connectors: bool = False,
) -> Optional[dict[str, Any]]:
"""Make sure this profile has a Nous identity (guest or account); mint a guest only if the shared
store has none. Returns the ``providers.nous`` state, or None (disabled / failed once already).
``explicit`` is required and must be True: the only callers are the boot bootstrap
(``free_tier_bootstrap.run_bootstrap``), the desktop's ``free_tier.provision`` retry, the first
hosted connector action (``tools.connectors.gateway.config.ensure_guest_identity``), and the
dead-credential replacements
(``auth_nous.resolve_nous_runtime_credentials``, ``managed_tool_gateway._replace_dead_guest_token``).
``explicit`` is required and must be True: the only callers are the first hosted connector action
(``tools.connectors.gateway.config.ensure_guest_identity``), the connectors dead-credential
replacement (``managed_tool_gateway._replace_dead_guest_token``) and the desktop's welcome-trial
claim (``welcome_trial.claim_desktop_free_tier``), which stamps the guest it gets back. An
unstamped guest serves connectors only.
Nothing creates an identity as a side effect of reading status, resolving a provider or fetching a
connector bearer (NS-845 Q1.2).
Order: gate -> reconcile with the shared store -> mint. The gate is ``guest_enabled``, or only
``guest_allowed`` with ``for_connectors=True``: a connectors guest never opens the free model. Locks are taken profile
first, then shared, matching every other Nous path. Blocking, bounded by ``timeout_seconds``; the bootstrap puts it on its own thread.
Order: gate (``guest_allowed``) -> reconcile with the shared store -> mint. Locks are taken
profile first, then shared, matching every other Nous path. Blocking, bounded by ``timeout_seconds``.
A failed mint is memoised with a cooldown (``MintFailure``): until it passes, and for a
terminal code forever, this returns None without touching the portal. ``force=True`` is the
user's own retry (the desktop's ``free_tier.provision`` button): it makes exactly one attempt
user's own retry: it makes exactly one attempt
regardless of the cooldown. Every failure raises an ``AuthError`` whose ``code`` is one of the
``ANON_*`` codes and whose ``retry_after`` / ``retryable`` say what a caller may do next.
"""
if not explicit:
raise ValueError("ensure_portal_identity: only explicit creators may call this (explicit=True)")
if not (guest_allowed() if for_connectors else guest_enabled()):
if not guest_allowed():
return None
failure = _mint_failure_for_profile()
if failure and not force and not current_nous_state() and time.monotonic() < failure.not_before:
@@ -692,9 +699,10 @@ def ensure_portal_identity(
return state
def refresh_guest_state(state: dict[str, Any], client: httpx.Client, *, purpose: str = "inference") -> str:
def refresh_guest_state(state: dict[str, Any], client: httpx.Client, *, purpose: str) -> str:
"""Token-acquisition seam for a guest: re-exchange the ``anon_`` credential in place and return
the minted token (``purpose="connectors"`` asks for a connectors-only one).
the minted token. Every caller names its *purpose*: ``"connectors"`` for connector and managed-tool
bearers, ``"inference"`` only for a guest that carries the welcome trial.
The portal URL is the resolver's canonical one (env override, else the validated stored URL,
else the default), never a raw stored value on its own.
@@ -945,31 +953,34 @@ def apply_model_switch(agent: Any) -> Optional[str]:
# the free tier (inference + connectors) now exists. The flag lives on the guest state itself so it
# dies with the identity; a fresh guest (re-mint, new profile) may announce itself once more.
GUEST_NOTICE_FLAG = "guest_notice_shown"
FREE_TIER_AVAILABLE_NOTICE = (
"Free Nous inference and connectors are now available. "
"/model to try them, /login to sign in.")
def guest_notice_pending() -> bool:
"""True when a guest identity exists and the one-time availability notice has not been shown."""
state = current_nous_state()
return guest_enabled() and is_guest_state(state) and not bool(state.get(GUEST_NOTICE_FLAG))
return free_inference_active(state) and not bool(state.get(GUEST_NOTICE_FLAG))
def mark_guest_notice_shown() -> bool:
"""Persist ``guest_notice_shown`` on the guest's ``providers.nous`` state (whichever store holds it).
Returns True when a flag was written; False when there is no guest to mark."""
return set_guest_flag(GUEST_NOTICE_FLAG) is not None
def set_guest_flag(flag: str) -> Optional[dict[str, Any]]:
"""Set *flag* on the guest's ``providers.nous`` state in whichever store holds it, and return the
updated state; None when there is no guest."""
from hermes_cli.auth import (
_auth_file_path, _load_auth_store, _provider_state_transaction, _same_path, _save_auth_store,
_store_section)
with _provider_state_transaction("nous") as (auth_store, state, source_path):
if not is_guest_state(state) or source_path is None:
return False
if state.get(GUEST_NOTICE_FLAG):
return True
return None
if state.get(flag):
return state
state = dict(state)
state[GUEST_NOTICE_FLAG] = True
state[flag] = True
if _same_path(source_path, _auth_file_path()):
_store_section(auth_store, "providers")["nous"] = state
_save_auth_store(auth_store)
@@ -977,7 +988,7 @@ def mark_guest_notice_shown() -> bool:
source_store = _load_auth_store(source_path)
_store_section(source_store, "providers")["nous"] = state
_save_auth_store(source_store, target_path=source_path)
return True
return state
# --- ``hermes auth upgrade``: sign the guest into a real Nous account, keeping its connectors ---------
@@ -1212,5 +1223,3 @@ from hermes_cli.anon_sign_in_cli import (
render_sign_in_cli_code,
upgrade_guest,
)
FREE_TIER_STATUS_LINE = f"{FREE_TIER_LABEL} \u00b7 {GUEST_MODEL} \u00b7 {LOGIN_COMMAND} to sign in"
+3 -3
View File
@@ -337,8 +337,8 @@ def run_sign_in(
# Preconditions run inside the scope; the state they produce is yielded outside it, because a
# scope must never be held across a ``yield``. A sign-in never creates the identity it signs in
# from: with none on disk there is nothing to promote and the answer is ``Unavailable`` (the boot
# bootstrap is the only creator, NS-845 Q1.2).
# from: with none on disk there is nothing to promote and the answer is ``Unavailable`` (the
# trial claim and connector use are the only creators, NS-845 Q1.2).
precondition_state: Optional[SignInState] = None
state: Optional[dict[str, Any]] = None
try:
@@ -346,7 +346,7 @@ def run_sign_in(
state = _core.current_nous_state()
if state and not _core.is_guest_state(state):
precondition_state = AlreadySignedIn()
elif not state or not _core.guest_enabled():
elif not state or not _core.guest_allowed():
precondition_state = Unavailable()
except Exception as exc:
# An unreadable auth store means the same thing here: there is no free tier to sign in from.
+9 -9
View File
@@ -1544,9 +1544,9 @@ def _logged_in_oauth_active_provider(*, skip_free_tier: bool = False) -> Optiona
try:
_maybe = _load_auth_store().get("active_provider")
if _maybe == "nous":
from hermes_cli.anon_auth import guest_enabled, has_guest
if has_guest() and (skip_free_tier or not guest_enabled()):
return None # the free tier is off (or being discounted), so a guest is not a login
from hermes_cli.anon_auth import free_inference_active, has_guest
if has_guest() and (skip_free_tier or not free_inference_active()):
return None # this guest carries no inference here (or is being discounted): not a login
if _maybe and _maybe in PROVIDER_REGISTRY and get_auth_status(_maybe).get("logged_in"):
return _maybe
except Exception as e:
@@ -1637,7 +1637,7 @@ def resolve_provider(
"auto" priority (explicit intent beats a stale OAuth login): 1. CLI api_key/base_url ->
"openrouter"; 2. config.yaml ``model.provider``; 3. OPENROUTER_API_KEY (or an sk-or- key in
OPENAI_API_KEY) -> "openrouter"; 4. OpenRouter pool; 5. provider env keys; 6. auth.json ``active_provider``;
7. Nous free tier when it is on and its identity exists (never created here);
7. Nous free tier when a claimed guest exists on the desktop's local backend (never created here);
8. AWS Bedrock chain; 9. AuthError(no_provider_configured).
``skip_free_tier`` hides rungs 6-for-a-free-tier-identity and 7: the boot bootstrap asks
@@ -1690,16 +1690,16 @@ def resolve_provider(
_oauth_active)
return _oauth_active
# Nous free tier, when it is on and its identity already exists. This rung sits ABOVE the Bedrock
# Nous free tier, when this guest claimed the welcome trial and this is the desktop's local
# backend (``anon_auth.free_inference_active``). This rung sits ABOVE the Bedrock
# chain on purpose: every rung above this line is explicit user intent (CLI creds, config, env
# keys, a sign-in); the boto chain below is implicit host state, and a leftover ~/.aws profile
# used to win the first turn of a fresh install (NS-829). The rung never CREATES the identity:
# that is the boot bootstrap's job (free_tier_bootstrap), so provider resolution stays free of
# network and a fresh install without the bootstrap resolves exactly as upstream does.
# only the desktop's trial claim does (welcome_trial), so provider resolution stays free of network.
if not skip_free_tier:
try:
from hermes_cli.anon_auth import guest_enabled, has_guest
if guest_enabled() and has_guest():
from hermes_cli.anon_auth import free_inference_active
if free_inference_active():
return "nous"
except Exception as exc:
logger.debug("free tier check during provider resolution skipped: %s", exc)
+6 -12
View File
@@ -1037,7 +1037,7 @@ class _NousRuntimeResolve:
# Guest seam: the anon_ credential is the refresh material; re-exchange instead of
# redeeming a rotating refresh token. Quarantine never applies to a guest.
if self.force_refresh or self.invoke_jwt_status() is not None:
refresh_guest_state(self.state, client)
refresh_guest_state(self.state, client, purpose="inference")
self.access_token = self.state["access_token"]
self.stored_inference_base_url = self.state.get("inference_base_url") or self.stored_inference_base_url
self.inference_base_url = _nous_inference_env_override() or self.stored_inference_base_url
@@ -1069,10 +1069,11 @@ def resolve_nous_runtime_credentials(
stale_access_token: Optional[str] = None) -> dict[str, Any]:
"""Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock).
A guest whose ``anon_`` credential NAS no longer knows (reaped or claimed) is retired and a new
identity is set up once, transparently -- the one client rule covering both reap and claim.
A guest whose ``anon_`` credential NAS no longer knows (reaped or claimed) is retired and never
replaced here: the welcome trial is claimed once per machine, so that user goes on to sign-in or
provider setup.
"""
from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest, ensure_portal_identity
from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest
from hermes_cli.anon_challenge import run_with_challenge
try:
# A free-tier exchange may be answered with a browser challenge; it is worked here, after
@@ -1082,16 +1083,9 @@ def resolve_nous_runtime_credentials(
force_refresh=force_refresh, stale_access_token=stale_access_token))
except AnonCredentialDead as dead_exc:
from hermes_cli.auth import get_provider_auth_state
from hermes_cli.anon_auth import ANON_ACCOUNT_LOCKED
dead = get_provider_auth_state("nous") or {}
clear_dead_guest(str(dead_exc.code or "anon_credential_dead"), dead_token=dead.get("anon_token"))
# A locked account is retired but never silently replaced: the way forward is a sign-in.
if dead_exc.code == ANON_ACCOUNT_LOCKED:
raise
if ensure_portal_identity(explicit=True, timeout_seconds=timeout_seconds) is None:
raise
return run_with_challenge(lambda: _resolve_nous_runtime_credentials(
timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle))
raise
def _resolve_nous_runtime_credentials(
-14
View File
@@ -596,16 +596,6 @@ def _active_profile_name() -> Optional[str]:
return get_active_profile_name()
def _route_model_for_banner(provider: Any) -> str:
"""The model the resolved route will actually serve when config names none: today only the Nous
free tier (welcome host -> ``nous/welcome``). Read from the boot record and local auth state;
no network. Empty when nothing resolves, so the caller keeps its "no model configured" line."""
if (provider or "auto").strip().lower() not in ("auto", "nous"):
return ""
from hermes_cli.anon_auth import GUEST_MODEL, free_tier_route
return GUEST_MODEL if free_tier_route() else ""
def _banner_left_lines(model: str, cwd: str, session_id, context_length, provider, *, accent: str, dim: str,
context_pinned: bool = False) -> list:
"""Model / cwd / session lines under the hero art. ``context_pinned`` marks a
@@ -616,10 +606,6 @@ def _banner_left_lines(model: str, cwd: str, session_id, context_length, provide
pin = " (pinned)" if context_pinned else ""
ctx_str = _dim_sep(f"{_format_context_length(context_length)} context{pin}") if context_length else ""
nous_str = _dim_sep("Nous Research")
if not (model or "").strip():
# Credentials resolve lazily on the first message; the banner prints first. Ask the route
# the same question so a fresh free-tier install shows its model, not a red "unconfigured".
model = _quiet(lambda: _route_model_for_banner(provider), "") or model
if (provider or "").strip().lower() == "moa":
# MoA virtual provider: ``model`` is a preset name; show it with its aggregator.
agg_label = _quiet(lambda: _moa_aggregator_label(model), "")
-19
View File
@@ -268,11 +268,6 @@ class CLIAgentSetupMixin:
api_key = runtime.get("api_key")
base_url = runtime.get("base_url")
resolved_provider = runtime.get("provider", "openrouter")
if resolved_provider != "nous":
# An explicit provider carries inference. The free-tier identity (for connectors) was
# created by the boot bootstrap before this point, never here; this prints the one-time
# "free tier is here" notice the first time an identity is seen beside an own key.
self._maybe_print_free_tier_available_notice()
resolved_routing = (
resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"),
list(runtime.get("args") or []))
@@ -347,20 +342,6 @@ class CLIAgentSetupMixin:
self._active_agent_route_signature = None
return True
def _maybe_print_free_tier_available_notice(self) -> None:
"""One-time notice for installs whose inference is carried by an explicit provider: the free
tier (inference + connectors) now exists. Printed the first time an identity is present, then
flagged on that identity so it never repeats. Never blocks or raises."""
from cli import logger
try:
from hermes_cli import anon_auth
if not anon_auth.guest_notice_pending():
return
self._console_print(f"[dim]{anon_auth.FREE_TIER_AVAILABLE_NOTICE}[/]")
anon_auth.mark_guest_notice_shown()
except Exception as exc:
logger.debug("free tier availability notice skipped: %s", exc)
def _resolve_fallback_runtime(self, primary_exc):
"""Primary provider resolution failed: on an AuthError try each fallback entry in
order and switch the CLI's requested_provider/model to the first that resolves.
-7
View File
@@ -284,13 +284,6 @@ class CLISessionMixin:
ctx_label = None
lines = [t("cli.session.status_title"), "", *status_lines(fields, "session_id", "path", "title", "model")]
try:
from hermes_cli.anon_auth import free_tier_route
if free_tier_route():
lines.append(t("gateway.status.free_tier"))
except Exception:
pass
optional = ((t("cli.session.label_reasoning"), reasoning_label),
(t("cli.session.label_approvals"), approval_label),
(t("cli.session.label_context"), ctx_label))
+35 -94
View File
@@ -1,16 +1,12 @@
"""Serve-start bootstrap for the Nous free tier: the boot-time creator of its identity.
"""Serve-start record of who carries inference, for ``setup.status`` and ``setup.ready``.
Every Hermes process that may need the free tier runs this once at boot (``hermes serve`` on a
daemon thread beside the other background boots; the CLI first-run guard synchronously). It
inventories credentials cheap-first, creates the identity only when the launch gate is open
(:func:`hermes_cli.anon_auth.guest_enabled`), resolves which provider carries inference, records
the answer in process memory, and tells every connected client with one ``setup.ready`` event.
Besides this boot path, only the first hosted connector action mints, and only for a user with no
Nous identity (``tools.connectors.gateway.config.ensure_guest_identity``; with the launch gate
closed that guest never opens the free model). ``free_tier.status`` and ``setup.status`` read the record; provider resolution
never reaches the portal; a dead credential is replaced by the explicit re-mint in
``auth_nous.resolve_nous_runtime_credentials``. Ruling: NS-845 Q1.2 (recorded on NS-847).
``hermes serve`` runs this once at boot on a daemon thread. It never creates an identity: the
desktop's welcome-trial claim (``hermes_cli.welcome_trial``) and the first hosted connector action
are the only creators. On the desktop's local backend it first keeps the trial for a guest that
already ran on the free model (``welcome_trial.grandfather_existing_guest``). Then it inventories
credentials cheap-first, resolves which provider carries inference, records the answer in process
memory, and tells every connected client with one ``setup.ready`` event. ``free_tier.status`` and
``setup.status`` read the record; provider resolution never reaches the portal.
"""
from __future__ import annotations
@@ -23,8 +19,7 @@ from typing import Any, Dict, Optional
logger = logging.getLogger("hermes_cli.auth")
# The desktop's first ``setup.status`` waits this long for the record before falling back to a live
# probe. The mint budget is 5 s (``GUEST_MINT_TIMEOUT_SECONDS``); the rest covers the inventory.
# The desktop's first ``setup.status`` waits this long for the record before falling back to a live probe.
SETUP_READY_WAIT_SECONDS = 8.0
SETUP_READY_EVENT = "setup.ready"
@@ -36,12 +31,12 @@ class SetupRecord:
provider_configured: bool # some provider can carry inference (free tier included)
inference_provider: str # ``resolve_provider("auto")``'s answer, "" when nothing resolves
free_tier_account: bool # the identity that exists is the free tier AND the tier is on
free_tier_account: bool # the identity that exists is a guest that carries inference here
has_identity: bool # a Nous identity (free tier or account) is on disk
other_providers: bool # the inventory found something usable BESIDES the free tier
error: str = "" # why the mint did not happen, when it did not; "" otherwise
error: str = "" # why the last trial claim failed, when it did; "" otherwise
# The mint memo's verdict, verbatim (``anon_auth.MintFailure.as_payload``):
# ``{error, error_code, retryable, retry_after}`` when the mint did not happen, else ``{}``.
# ``{error, error_code, retryable, retry_after}`` after a failed claim, else ``{}``.
# One wire shape: every status RPC spreads it as is.
failure: dict[str, Any] = field(default_factory=dict)
finished_at: float = field(default_factory=time.time)
@@ -93,8 +88,7 @@ def reconcile_record() -> Optional[SetupRecord]:
"""Let a provider configured AFTER boot count: a record that says ``provider_configured:
false`` is re-inventoried once ``config.yaml`` / ``.env`` / ``auth.json`` moved since the
inventory that built it, and replaced (+ ``setup.ready``) when something now carries
inference. The mint verdict (identity, failure block) is kept as is: only the boot bootstrap
and its retries mint. A record that already says ``True`` is never re-probed, so the answer
inference. The identity and failure block are kept as is. A record that already says ``True`` is never re-probed, so the answer
only moves false -> true here. Every write path that assigns the main model (the Models page,
a picker key save) calls this for the immediate broadcast; ``setup.status`` calls it for
writes this process never saw (``hermes setup`` / ``hermes model`` from a shell, a hand edit).
@@ -170,44 +164,28 @@ def _resolve_inference() -> str:
return ""
def _build_record(*, other: bool, force: bool) -> SetupRecord:
"""One inventory-then-mint pass into a record. ``force`` is the user's own retry: it makes one
attempt even inside the mint memo's cooldown (``anon_auth.ensure_portal_identity``)."""
def _build_record(*, other: bool) -> SetupRecord:
"""One inventory pass into a record. Reads only: no identity is created here."""
from hermes_cli import anon_auth
error = ""
failure: dict[str, Any] = {}
state: Optional[dict[str, Any]] = anon_auth.current_nous_state()
if anon_auth.guest_enabled():
try:
state = anon_auth.ensure_portal_identity(explicit=True, force=force)
except Exception as exc:
error = str(exc)
logger.info("Nous free tier not set up at boot: %s", exc)
if state is None:
# Either this attempt failed (the memo now holds why) or an earlier one did and its
# cooldown still runs: the record carries that verdict either way.
failure = anon_auth.last_mint_failure() or {}
error = error or str(failure.get("error") or "")
free_tier_account = bool(state) and anon_auth.is_guest_state(state) and anon_auth.guest_enabled()
failure: dict[str, Any] = {} if state else (anon_auth.last_mint_failure() or {})
free_tier_account = anon_auth.free_inference_active(state) if state else False
return SetupRecord(
provider_configured=other or free_tier_account or (bool(state) and not anon_auth.is_guest_state(state)),
inference_provider=_resolve_inference(),
free_tier_account=free_tier_account,
has_identity=bool(state),
other_providers=other,
error=error,
error=str(failure.get("error") or ""),
failure=failure,
)
def run_bootstrap(*, announce: bool = True) -> SetupRecord:
"""Inventory -> ensure identity (gate permitting) -> resolve inference -> record -> broadcast.
Runs every boot; only the mint is gated. Idempotent per process: a second call returns the
existing record without touching the portal. Never raises. ``announce=False`` skips the
``setup.ready`` event: the plain CLI has no client to tell and its stdout is the user's terminal.
"""
"""Grandfather an existing free-model guest -> inventory -> resolve inference -> record ->
broadcast. Idempotent per process: a second call returns the existing record. Never raises.
``announce=False`` skips the ``setup.ready`` event."""
global _record, _started
with _lock:
if _record is not None:
@@ -218,7 +196,12 @@ def run_bootstrap(*, announce: bool = True) -> SetupRecord:
return _record
_started = True
record = _build_record(other=_inventory_other_providers(), force=False)
try:
from hermes_cli.welcome_trial import grandfather_existing_guest
grandfather_existing_guest()
except Exception as exc: # the record still says who carries inference without it
logger.info("free tier: could not check for an existing free-model guest: %s", exc)
record = _build_record(other=_inventory_other_providers())
with _lock:
_record = record
_done.set()
@@ -227,62 +210,20 @@ def run_bootstrap(*, announce: bool = True) -> SetupRecord:
return record
# Background retries after a boot-time mint failure: the memo's cooldown decides WHEN (a server
# ``Retry-After``, the ops-breaker floor, or the unreachable ladder), this decides HOW MANY before
# the process stops trying on its own (the user's retry button, ``free_tier.provision``, is not
# counted). A terminal code (gate closed, proof of work, locked) is never retried.
BOOTSTRAP_RETRY_ATTEMPTS = 3
_sleep = time.sleep # seam for tests
def retry_bootstrap_mint(*, force: bool = False, announce: bool = True) -> SetupRecord:
"""Re-run the mint once (``force`` bypasses the cooldown), replace the record and announce it.
The desktop's ``free_tier.provision`` calls this with ``force=True``; the background loop calls
it as each cooldown passes. Returns the existing record untouched when no bootstrap ran yet
(nothing to replace) or when an identity already exists."""
def refresh_record(*, announce: bool = True) -> Optional[SetupRecord]:
"""Rebuild the launch profile's record after the identity changed (a trial claim), so
``setup.status`` and the ``setup.ready`` listeners move with it. None before the first bootstrap."""
global _record
current = _record
if current is None:
return run_bootstrap(announce=announce)
if current.has_identity:
return current
# Re-inventory: a provider the user connected during the cooldown must keep inference; the
# boot-time answer is stale by now.
record = _build_record(other=_inventory_other_providers(), force=force)
if _record is None:
return None
record = _build_record(other=_inventory_other_providers())
with _lock:
# Two retries can race (the background loop and the user's click): a build that found no
# identity must not overwrite one that did.
if _record is not None and _record.has_identity and not record.has_identity:
return _record
_record = record
if announce:
_broadcast(record)
return record
def _retry_until_settled() -> None:
"""The background loop behind ``start_background_bootstrap``: wait out each cooldown and try
again, up to ``BOOTSTRAP_RETRY_ATTEMPTS``, while the record says a later attempt can succeed."""
for _ in range(BOOTSTRAP_RETRY_ATTEMPTS):
record = _record
if record is None or record.has_identity or not record.failure.get("retryable"):
return
_sleep(max(1, int(record.failure.get("retry_after") or 0)))
record = retry_bootstrap_mint(force=False)
if record.has_identity:
logger.info("Nous free tier set up after a boot-time retry")
return
def _bootstrap_then_retry() -> None:
run_bootstrap()
try:
_retry_until_settled()
except Exception as exc: # the loop is best effort; the record already says what happened
logger.debug("free tier bootstrap retry loop stopped: %s", exc)
def _broadcast(record: SetupRecord) -> None:
try:
from tui_gateway.server import _broadcast_global_event
@@ -292,7 +233,7 @@ def _broadcast(record: SetupRecord) -> None:
def start_background_bootstrap() -> threading.Thread:
"""``hermes serve`` entry: run on a daemon thread so a slow portal never delays the socket."""
thread = threading.Thread(target=_bootstrap_then_retry, daemon=True, name="free-tier-bootstrap")
"""``hermes serve`` entry: run on a daemon thread so the inventory never delays the socket."""
thread = threading.Thread(target=run_bootstrap, daemon=True, name="free-tier-bootstrap")
thread.start()
return thread
+1 -1
View File
@@ -47,7 +47,7 @@ def _transaction() -> Iterator[tuple[dict[str, Any], Any]]:
def _on_free_tier() -> bool:
from hermes_cli import anon_auth
return anon_auth.has_guest() and anon_auth.guest_enabled()
return anon_auth.free_inference_active()
def _due_at(state: dict[str, Any]) -> Optional[float]:
-11
View File
@@ -1177,12 +1177,6 @@ def _has_any_provider_configured(*, strict_profile_scope: bool = False) -> bool:
except Exception:
pass
# Nothing explicit anywhere: an existing Nous free-tier identity counts while the tier is on.
try:
from hermes_cli.anon_auth import guest_enabled, has_guest
return guest_enabled() and has_guest()
except Exception as exc:
logger.debug("free tier check on first run skipped: %s", exc)
return False
@@ -1745,11 +1739,6 @@ def cmd_chat(args):
_warn_retired_xai_models()
# First-run guard: the free-tier bootstrap runs first (synchronously here; it is the only thing
# that may create the identity), then the inventory decides whether setup is needed.
from hermes_cli.free_tier_bootstrap import run_bootstrap
run_bootstrap(announce=False)
# The TUI owns its first-run state: it renders "Setup Required" with in-place /setup.
# The classic prompt only fronts the classic CLI.
provider_configured = _has_any_provider_configured()
+5 -15
View File
@@ -284,6 +284,11 @@ def _model_flow_nous(config, current_model="", args=None):
from hermes_cli.config import load_config
from hermes_cli.nous_subscription import prompt_enable_tool_gateway
state = get_provider_auth_state("nous")
from hermes_cli import anon_auth
if anon_auth.guest_without_free_tier(state):
# A guest that carries no inference here: signing in promotes it and keeps its connectors.
print(anon_auth.UPGRADE_HINT)
return
if not state or not state.get("access_token"):
_say("Not logged into Nous Portal. Starting login...", "")
@@ -301,21 +306,6 @@ def _model_flow_nous(config, current_model="", args=None):
# instead of the hundreds returned by the live /models endpoint.
from hermes_cli.models import check_nous_free_tier, get_curated_nous_model_ids
from hermes_cli.models_pricing import get_pricing_for_provider
from hermes_cli.model_switch_providers import _free_tier_nous_row
tier_row = _free_tier_nous_row({"name": "Nous Portal", "models": []})
if tier_row is None:
print("The Nous free tier is off for this install; sign in with `hermes auth upgrade` to use Nous models.")
return
if tier_row["models"]:
# Free-tier identity: the welcome host serves the single pinned model; no Portal catalog,
# pricing, or account lookups apply.
creds = _nous_verified_credentials()
if creds is None:
return
selected = tier_row["models"][0]
_nous_persist_selection(selected, creds)
print(f"Default model set to: {selected} (via {tier_row['name']})")
return
model_ids = get_curated_nous_model_ids()
if not model_ids:
print("No curated models available for Nous Portal.")
+2 -2
View File
@@ -500,8 +500,8 @@ def _free_tier_nous_row(row: dict) -> dict | None:
return row
if not anon_auth.guest_allowed():
return None
if not anon_auth.guest_enabled():
return row # a guest kept for connectors only: the row is whatever real Nous login exists
if not anon_auth.free_inference_active():
return row # a guest without the welcome trial here: the row is whatever real Nous login exists
out = dict(row)
out["name"] = anon_auth.FREE_TIER_LABEL
out["models"] = [anon_auth.GUEST_MODEL]
+4 -3
View File
@@ -17,9 +17,10 @@ def _root_config() -> Path:
return get_default_hermes_root() / "config.yaml"
def should_run() -> bool:
def should_run(*, assume_fresh: bool = False) -> bool:
"""The root config's explicit ``onboarding.run`` (managed overlay included) when it is a bool,
else whether this machine has never used Hermes (``install_state``)."""
else whether this machine has never used Hermes (``install_state``; *assume_fresh* is the
unpackaged desktop's ``--fresh-install`` switch)."""
from hermes_cli.config_effective import load_user_config_effective
# Not load_config(): it resolves the launch profile and fills the default None for an unset flag.
@@ -29,7 +30,7 @@ def should_run() -> bool:
return value
from hermes_cli.install_state import probe
return probe().fresh
return assume_fresh or probe().fresh
def set_run(value: bool, *, mark_profile_offered: bool = False) -> None:
+6
View File
@@ -259,6 +259,12 @@ def is_desktop_owned_backend(argv: Optional[Sequence[str]] = None) -> bool:
return is_desktop_ssh_backend_argv(list(sys.argv[1:] if argv is None else argv))
def is_local_desktop_backend() -> bool:
"""The backend Desktop spawned on this machine: the local pool spawn's credential, never the
SSH spawn (its token arrives as a file on argv, and that host is another computer)."""
return os.environ.get("HERMES_DESKTOP") == "1" and bool(os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN"))
def _desktop_spawner_identity() -> tuple[Optional[int], Optional[float]]:
"""Spawner ``(pid, create_time)`` from the Electron app's HERMES_PARENT_PID (+ optional
``winms:<ms>`` start marker) parent-death watchdog vars, so ledger lineage works with every
+1 -1
View File
@@ -440,7 +440,7 @@ async def _start_nous_device_code(profile: Optional[str]) -> dict[str, Any]:
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
guest = anon_auth.current_nous_state() if anon_auth.guest_allowed() else None
if not anon_auth.is_guest_state(guest):
device_data = await _httpx_call(lambda client: _request_device_code(
+3 -4
View File
@@ -295,10 +295,9 @@ async def _lifespan(app: FastAPI):
threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start()
# Nous free tier: the one boot-time creator of its identity. Inventories credentials, mints only
# when HERMES_GUEST_ONBOARDING=1, records the answer for setup.status / free_tier.status and
# broadcasts `setup.ready`. Off-thread so a slow portal never delays the socket; the desktop's
# first setup.status waits on the record (bounded) instead.
# Records who carries inference for setup.status / free_tier.status and broadcasts `setup.ready`
# (no identity is created at boot). Off-thread; the desktop's first setup.status waits on the
# record (bounded) instead.
from hermes_cli.free_tier_bootstrap import start_background_bootstrap
start_background_bootstrap()
+135
View File
@@ -0,0 +1,135 @@
"""The desktop's welcome trial: free inference on ``nous/welcome`` for a first install.
Only the backend the desktop spawned on this machine can claim it, only when this machine has never
used Hermes (``install_state``), and only with no provider and no Nous identity yet. The claim mints a
guest, stamps ``WELCOME_CLAIM_FLAG`` on it and marks the machine, so the trial is never claimed twice
here. A claim that is lost later (the guest is reaped, ``auth.json`` is deleted, the user signs in) is
never made again: that user lands on provider setup and sign-in like anyone else.
Guests that ran on the free model before the claim existed keep it (:func:`grandfather_existing_guest`).
Enforcement is client-side; the account service's own entitlement check follows separately.
"""
from __future__ import annotations
import contextlib
import logging
import sqlite3
from collections.abc import Callable
from pathlib import Path
from typing import Any, Optional
from hermes_cli import anon_auth
logger = logging.getLogger("hermes_cli.auth")
def _has_nous_identity() -> bool:
from hermes_cli.auth_nous import _read_shared_nous_state
return anon_auth.current_nous_state() is not None or _read_shared_nous_state() is not None
def provider_configured() -> bool:
"""Something besides the welcome trial carries inference: a key, a config pin, a sign-in, a host
credential. The resolver itself answers, with the free-tier rung hidden."""
from hermes_cli.auth import resolve_provider
try:
provider = resolve_provider("auto", skip_free_tier=True)
except Exception: # no_provider_configured, or an unreadable store: nothing usable here
return False
return provider != "nous" or not anon_auth.has_guest()
def inference_available(*, assume_fresh: bool = False) -> bool:
"""Something can carry a first chat on this backend: the trial (claimed, or claimable now) or a
configured provider."""
return (anon_auth.free_inference_active() or provider_configured()
or claim_blocker(assume_fresh=assume_fresh) is None)
def _machine_is_used(assume_fresh: bool) -> bool:
from hermes_cli.install_state import probe
return not assume_fresh and probe().existing
# Cheapest first; the last one reads the disk. Each is a reason the trial cannot be claimed.
_CLAIM_BLOCKERS: tuple[tuple[str, Callable[[bool], bool]], ...] = (
("not the desktop's local backend", lambda _fresh: not anon_auth.on_local_desktop_backend()),
("guests are switched off", lambda _fresh: not anon_auth.guest_allowed()),
("a Nous identity exists", lambda _fresh: _has_nous_identity()),
("a provider is configured", lambda _fresh: provider_configured()),
("Hermes was used on this machine", _machine_is_used),
)
def claim_blocker(*, assume_fresh: bool = False) -> Optional[str]:
"""Why the trial cannot be claimed here, or None when it can. *assume_fresh* is the unpackaged
desktop's ``--fresh-install`` switch: it skips only the machine check."""
return next((reason for reason, blocks in _CLAIM_BLOCKERS if blocks(assume_fresh)), None)
def claim_desktop_free_tier(*, assume_fresh: bool = False, force: bool = False) -> Optional[dict[str, Any]]:
"""Claim the trial when nothing blocks it: mint the guest, stamp the claim, mark the machine.
Returns the claimed state, or None when the trial is not available here. A failed mint raises
the coded ``AuthError`` from :func:`anon_auth.ensure_portal_identity` (memoised with its cooldown;
*force* is the user's own retry)."""
if reason := claim_blocker(assume_fresh=assume_fresh):
logger.debug("welcome trial not claimed: %s", reason)
return None
state = anon_auth.ensure_portal_identity(explicit=True, force=force)
if state is None: # still inside the cooldown of the last failure
failure = anon_auth.last_mint_failure() or {}
raise anon_auth._anon_err(str(failure.get("error") or anon_auth.ANON_FAILURE_COPY[anon_auth.ANON_SERVER_ERROR]),
str(failure.get("error_code") or anon_auth.ANON_SERVER_ERROR),
retry_after=failure.get("retry_after"))
claimed = _record_claim()
logger.info("Nous free tier: welcome trial claimed on this machine")
return claimed
def grandfather_existing_guest() -> bool:
"""A guest from before the claim existed keeps the free model when this home already chatted on
it. Connector-only guests never qualify. Records the claim once; True when it did."""
if not anon_auth.on_local_desktop_backend():
return False
state = anon_auth.current_nous_state()
if not anon_auth.is_guest_state(state) or state.get(anon_auth.WELCOME_CLAIM_FLAG):
return False
from hermes_constants import get_default_hermes_root, get_hermes_home
homes = dict.fromkeys((get_hermes_home(), get_default_hermes_root()))
if not any(_chatted_on_welcome(home / "state.db") for home in homes):
return False
_record_claim()
logger.info("Nous free tier: existing free-model guest kept its trial")
return True
def _record_claim() -> Optional[dict[str, Any]]:
from hermes_cli.auth_nous import _write_shared_nous_state
from hermes_cli.install_state import mark_machine_used
state = anon_auth.set_guest_flag(anon_auth.WELCOME_CLAIM_FLAG)
if state is not None:
_write_shared_nous_state(state)
mark_machine_used()
return state
def _chatted_on_welcome(db: Path) -> bool:
"""A session on the free model with at least one message. Read-only; a missing or unreadable
database is no."""
if not db.is_file():
return False
try:
with contextlib.closing(sqlite3.connect(f"{db.as_uri()}?mode=ro", uri=True, timeout=1)) as conn:
row = conn.execute(
"SELECT 1 FROM sessions s WHERE s.model = ? "
"AND EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id) LIMIT 1",
(anon_auth.GUEST_MODEL,)).fetchone()
except sqlite3.Error:
return False
return row is not None
-2
View File
@@ -435,7 +435,6 @@ gateway:
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: "**Konteks:** {used} / {total} ({pct}%)"
context_used: "**Konteks:** ~{used} tokens"
tokens: "**Lewenslange tokens gefaktureer:** {tokens} _(nie jou huidige konteksgrootte nie; gebruik `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway suksesvol herbegin. Jou sessie gaan voort."
free_tier_line: "Inferensie: Nous gratis vlak (nous/welcome). Meld aan vir meer: /login"
online: "♻️ Gateway aanlyn — Hermes is terug en gereed."
db_corrupt: "⚠️ Sessie-databasis-korrupsie opgespoor. Boodskappe word moontlik nie bewaar nie. Herstelopsies:\n1. Voer `hermes {profile_arg}doctor --fix` uit\n2. Stop die gateway en herstel dan met:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (as dit herstelbaar rapporteer) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — herstel neem eers 'n momentopname van die beskadigde lêer; voer NIE `sqlite3 ... \".recover\"` teen die lewendige state.db uit nie, 'n kwesbare sqlite3 CLI kan dit verder korrupteer\n3. Herstel vanaf 'n rugsteun in {backups_dir}/\nVoer `hermes {profile_arg}doctor` uit vir gesuiwerde diagnostiek."
db_fts_corrupt: "⚠️ Sessie-databasis het 'n korrupsiefout gerapporteer wat beperk is tot die soekindeks (FTS5); die boodskaptabelle is nie beskadig nie. Boodskappe word moontlik nie bewaar totdat dit herstel is nie: voer `hermes {profile_arg}doctor --fix` uit en herbegin dan die gateway. Voer nie herstelgereedskap uit of herstel 'n rugsteun nie tensy `hermes {profile_arg}doctor` skade bevestig."
-2
View File
@@ -435,7 +435,6 @@ 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}"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ أُعيد تشغيل البوّابة بنجاح. جلستك مستمرة."
free_tier_line: "الاستدلال: الطبقة المجانية من Nous (nous/welcome). سجّل الدخول للمزيد: /login"
online: "♻️ البوّابة متصلة — Hermes عاد وجاهز."
db_corrupt: "⚠️ رُصد تلف في قاعدة بيانات الجلسات. قد لا تُحفظ الرسائل. خيارات الاستعادة:\n1. شغّل `hermes {profile_arg}doctor --fix`\n2. أوقف البوّابة، ثم استعد بـ:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (إذا أفاد بإمكانية الاستعادة) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — تأخذ الاستعادة لقطة من الملف المتضرر أولًا؛ لا تشغّل `sqlite3 ... \".recover\"` على state.db الحي، فقد يزيد sqlite3 CLI المعرّض للثغرات من تلفه\n3. استعد من نسخة احتياطية في {backups_dir}/\nشغّل `hermes {profile_arg}doctor` للحصول على تشخيصات منقّحة."
db_fts_corrupt: "⚠️ أفادت قاعدة بيانات الجلسات بخطأ تلف محصور في فهرس البحث (FTS5)؛ جداول الرسائل غير متضررة. قد لا تُحفظ الرسائل حتى إصلاحه: شغّل `hermes {profile_arg}doctor --fix` ثم أعد تشغيل البوّابة. لا تشغّل أدوات الاستعادة ولا تستعد نسخة احتياطية إلا إذا أكّد `hermes {profile_arg}doctor` وجود تلف."
-2
View File
@@ -435,7 +435,6 @@ gateway:
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: "**Insgesamt abgerechnete Tokens:** {tokens} _(nicht Ihre aktuelle Kontextgröße; verwenden Sie `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway erfolgreich neu gestartet. Ihre Sitzung wird fortgesetzt."
free_tier_line: "Inferenz: Nous Free Tier (nous/welcome). Für mehr anmelden: /login"
online: "♻️ Gateway online — Hermes ist zurück und bereit."
db_corrupt: "⚠️ Beschädigung der Sitzungsdatenbank erkannt. Nachrichten werden möglicherweise nicht gespeichert. Wiederherstellungsoptionen:\n1. Führen Sie `hermes {profile_arg}doctor --fix` aus\n2. Stoppen Sie das Gateway und stellen Sie dann wieder her mit:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (falls als wiederherstellbar gemeldet) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — die Wiederherstellung sichert zuerst einen Schnappschuss der beschädigten Datei; führen Sie NICHT `sqlite3 ... \\\".recover\\\"` gegen die laufende state.db aus, eine anfällige sqlite3-CLI kann sie weiter beschädigen\n3. Stellen Sie ein Backup aus {backups_dir}/ wieder her\nFühren Sie `hermes {profile_arg}doctor` für bereinigte Diagnosen aus."
db_fts_corrupt: "⚠️ Die Sitzungsdatenbank hat einen Beschädigungsfehler gemeldet, der auf den Suchindex (FTS5) beschränkt ist; die Nachrichtentabellen sind nicht beschädigt. Nachrichten werden bis zur Reparatur möglicherweise nicht gespeichert: Führen Sie `hermes {profile_arg}doctor --fix` aus und starten Sie dann das Gateway neu. Verwenden Sie keine Wiederherstellungstools und stellen Sie kein Backup wieder her, sofern `hermes {profile_arg}doctor` keinen Schaden bestätigt."
-2
View File
@@ -439,7 +439,6 @@ 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`)_"
@@ -867,7 +866,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway restarted successfully. Your session continues."
free_tier_line: "Inference: Nous free tier (nous/welcome). Sign in for more: /login"
online: "♻️ Gateway online — Hermes is back and ready."
db_corrupt: "⚠️ Session database corruption detected. Messages may not be persisted. Recovery options:\n1. Run `hermes {profile_arg}doctor --fix`\n2. Stop the gateway, then recover with:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (if it reports recoverable) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — recovery snapshots the damaged file first; do NOT run `sqlite3 ... \".recover\"` against the live state.db, a vulnerable sqlite3 CLI can corrupt it further\n3. Restore from a backup in {backups_dir}/\nRun `hermes {profile_arg}doctor` for sanitized diagnostics."
db_fts_corrupt: "⚠️ Session database reported a corruption error confined to the search index (FTS5); the message tables are not damaged. Messages may not be persisted until it is repaired: run `hermes {profile_arg}doctor --fix`, then restart the gateway. Do not run recovery tools or restore a backup unless `hermes {profile_arg}doctor` confirms damage."
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**Última actividad:** {timestamp}"
model: "**Modelo:** `{model}`"
model_provider: "**Modelo:** `{model}` ({provider})"
free_tier: "Nous · plan gratuito · nous/welcome · /login para iniciar sesión"
context: "**Contexto:** {used} / {total} ({pct}%)"
context_used: "**Contexto:** ~{used} tokens"
tokens: "**Tokens facturados en total:** {tokens} _(no es el tamaño actual de tu contexto; usa `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway reiniciado correctamente. Tu sesión continúa."
free_tier_line: "Inferencia: nivel gratuito de Nous (nous/welcome). Inicia sesión para más: /login"
online: "♻️ Gateway en línea — Hermes está de vuelta y listo."
db_corrupt: "⚠️ Se detectó corrupción en la base de datos de sesiones. Es posible que los mensajes no se guarden. Opciones de recuperación:\n1. Ejecuta `hermes {profile_arg}doctor --fix`\n2. Detén el gateway y luego recupera con:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (si indica que es recuperable) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — la recuperación primero toma una instantánea del archivo dañado; NO ejecutes `sqlite3 ... \".recover\"` sobre el state.db en uso, una CLI sqlite3 vulnerable puede dañarlo aún más\n3. Restaura una copia de seguridad desde {backups_dir}/\nEjecuta `hermes {profile_arg}doctor` para obtener diagnósticos anonimizados."
db_fts_corrupt: "⚠️ La base de datos de sesiones informó un error de corrupción limitado al índice de búsqueda (FTS5); las tablas de mensajes no están dañadas. Es posible que los mensajes no se guarden hasta repararlo: ejecuta `hermes {profile_arg}doctor --fix` y luego reinicia el gateway. No ejecutes herramientas de recuperación ni restaures una copia de seguridad a menos que `hermes {profile_arg}doctor` confirme daños."
-2
View File
@@ -475,7 +475,6 @@ gateway:
last_activity: "**Dernière activité :** {timestamp}"
model: "**Modèle :** `{model}`"
model_provider: "**Modèle :** `{model}` ({provider})"
free_tier: "Nous · offre gratuite · nous/welcome · /login pour se connecter"
context: "**Contexte :** {used} / {total} ({pct}%)"
context_used: "**Contexte :** ~{used} jetons"
tokens: "**Jetons facturés au total :** {tokens} _(pas la taille actuelle de votre contexte ; utilisez `/context`)_"
@@ -997,7 +996,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway redémarré avec succès. Votre session continue."
free_tier_line: "Inférence : offre gratuite Nous (nous/welcome). Connectez-vous pour en obtenir plus : /login"
online: "♻️ Gateway en ligne — Hermes est de retour et prêt."
db_corrupt: |-
⚠️ Corruption détectée dans la base de données des sessions. Les messages risquent de ne pas être persistés. Options de récupération :
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**Gníomhaíocht is déanaí:** {timestamp}"
model: "**Samhail:** `{model}`"
model_provider: "**Samhail:** `{model}` ({provider})"
free_tier: "Nous · plean saor in aisce · nous/welcome · /login chun síniú isteach"
context: "**Comhthéacs:** {used} / {total} ({pct}%)"
context_used: "**Comhthéacs:** ~{used} comhartha"
tokens: "**Comharthaí billeáilte ar feadh an tsaoil:** {tokens} _(ní méid do chomhthéacs reatha; úsáid `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Atosaíodh an gateway go rathúil. Leanann do sheisiún ar aghaidh."
free_tier_line: "Tátal: plean saor in aisce Nous (nous/welcome). Sínigh isteach le haghaidh tuilleadh: /login"
online: "♻️ Gateway ar líne — tá Hermes ar ais agus réidh."
db_corrupt: "⚠️ Braitheadh truailliú i mbunachar sonraí na seisiún. Seans nach sábhálfar teachtaireachtaí. Roghanna athshlánaithe:\n1. Rith `hermes {profile_arg}doctor --fix`\n2. Stop an gateway, ansin athshlánaigh le:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (má thuairiscíonn sé gur féidir é a athshlánú) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — glacann an t-athshlánú roghchóip den chomhad damáistithe ar dtús; NÁ rith `sqlite3 ... \".recover\"` i gcoinne an state.db bheo, is féidir le CLI sqlite3 leochaileach é a thruailliú níos mó\n3. Athchóirigh ó chúltaca i {backups_dir}/\nRith `hermes {profile_arg}doctor` le haghaidh diagnóisic sláintithe."
db_fts_corrupt: "⚠️ Thuairiscigh bunachar sonraí na seisiún earráid truaillithe atá teoranta don innéacs cuardaigh (FTS5); níl damáiste ar na táblaí teachtaireachtaí. Seans nach sábhálfar teachtaireachtaí go dtí go ndeiseofar é: rith `hermes {profile_arg}doctor --fix`, ansin atosaigh an gateway. Ná rith uirlisí athshlánaithe agus ná hathchóirigh cúltaca mura ndearbhaíonn `hermes {profile_arg}doctor` damáiste."
-2
View File
@@ -438,7 +438,6 @@ gateway:
last_activity: "**Utolsó tevékenység:** {timestamp}"
model: "**Modell:** `{model}`"
model_provider: "**Modell:** `{model}` ({provider})"
free_tier: "Nous · ingyenes csomag · nous/welcome · /login a bejelentkezéshez"
context: "**Kontextus:** {used} / {total} ({pct}%)"
context_used: "**Kontextus:** ~{used} token"
tokens: "**Összes számlázott token:** {tokens} _(nem az aktuális kontextusméret; lásd `/context`)_"
@@ -853,7 +852,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Az átjáró sikeresen újraindult. A munkameneted folytatódik."
free_tier_line: "Inferencia: Nous ingyenes szint (nous/welcome). Többért jelentkezz be: /login"
online: "♻️ Az átjáró elérhető — a Hermes visszatért, és készen áll."
db_corrupt: "⚠️ A munkamenet-adatbázis sérülését észleltük. Az üzenetek mentése sikertelen lehet. Helyreállítási lehetőségek:\n1. Futtasd: `hermes {profile_arg}doctor --fix`\n2. Állítsd le az átjárót, majd állítsd helyre így:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (ha helyreállíthatónak jelzi) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — a helyreállítás először pillanatképet készít a sérült fájlról; NE futtasd a `sqlite3 ... \".recover\"` parancsot az élő state.db fájlon, egy sebezhető sqlite3 CLI tovább rongálhatja\n3. Állítsd vissza egy biztonsági mentésből innen: {backups_dir}/\nA `hermes {profile_arg}doctor` parancs tisztított diagnosztikát ad."
db_fts_corrupt: "⚠️ A munkamenet-adatbázis a keresőindexre (FTS5) korlátozódó sérülési hibát jelzett; az üzenettáblák nem sérültek. A javításig az üzenetek mentése sikertelen lehet: futtasd a `hermes {profile_arg}doctor --fix` parancsot, majd indítsd újra az átjárót. Ne futtass helyreállító eszközöket, és ne állíts vissza biztonsági mentést, hacsak a `hermes {profile_arg}doctor` nem igazol sérülést."
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**Ultima attività:** {timestamp}"
model: "**Modello:** `{model}`"
model_provider: "**Modello:** `{model}` ({provider})"
free_tier: "Nous · piano gratuito · nous/welcome · /login per accedere"
context: "**Contesto:** {used} / {total} ({pct}%)"
context_used: "**Contesto:** ~{used} token"
tokens: "**Token fatturati in totale:** {tokens} _(non è la dimensione attuale del contesto; usa `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway riavviato correttamente. La tua sessione continua."
free_tier_line: "Inferenza: piano gratuito Nous (nous/welcome). Accedi per avere di più: /login"
online: "♻️ Gateway online — Hermes è tornato ed è pronto."
db_corrupt: "⚠️ Rilevata corruzione del database delle sessioni. I messaggi potrebbero non essere salvati. Opzioni di ripristino:\n1. Esegui `hermes {profile_arg}doctor --fix`\n2. Ferma il gateway, poi ripristina con:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (se segnala che è recuperabile) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — il ripristino crea prima uno snapshot del file danneggiato; NON eseguire `sqlite3 ... \".recover\"` sullo state.db attivo, una CLI sqlite3 vulnerabile può corromperlo ulteriormente\n3. Ripristina da un backup in {backups_dir}/\nEsegui `hermes {profile_arg}doctor` per una diagnostica sanificata."
db_fts_corrupt: "⚠️ Il database delle sessioni ha segnalato un errore di corruzione limitato all'indice di ricerca (FTS5); le tabelle dei messaggi non sono danneggiate. I messaggi potrebbero non essere salvati finché non viene riparato: esegui `hermes {profile_arg}doctor --fix`, poi riavvia il gateway. Non eseguire strumenti di ripristino né ripristinare un backup a meno che `hermes {profile_arg}doctor` non confermi il danno."
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**最終アクティビティ:** {timestamp}"
model: "**モデル:** `{model}`"
model_provider: "**モデル:** `{model}` ({provider})"
free_tier: "Nous · 無料プラン · nous/welcome · /login でログイン"
context: "**コンテキスト:** {used} / {total} ({pct}%)"
context_used: "**コンテキスト:** ~{used} トークン"
tokens: "**累計請求トークン:** {tokens} _(現在のコンテキストサイズではありません。`/context` を使用してください)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ ゲートウェイの再起動に成功しました。セッションは継続します。"
free_tier_line: "推論: Nous 無料プラン (nous/welcome)。さらに利用するにはログイン: /login"
online: "♻️ ゲートウェイがオンラインです — Hermes が復帰し、準備完了です。"
db_corrupt: "⚠️ セッションデータベースの破損を検出しました。メッセージが永続化されない可能性があります。復旧オプション:\n1. `hermes {profile_arg}doctor --fix` を実行\n2. ゲートウェイを停止し、次で復旧:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (復旧可能と報告された場合) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — 復旧は先に破損ファイルのスナップショットを取ります。稼働中の state.db に対して `sqlite3 ... \".recover\"` を実行しないでください。脆弱な sqlite3 CLI はさらに破損させる可能性があります\n3. {backups_dir}/ のバックアップから復元\nサニタイズ済みの診断情報は `hermes {profile_arg}doctor` で確認できます。"
db_fts_corrupt: "⚠️ セッションデータベースが検索インデックス (FTS5) に限定された破損エラーを報告しました。メッセージテーブルは損傷していません。修復されるまでメッセージが永続化されない可能性があります: `hermes {profile_arg}doctor --fix` を実行し、ゲートウェイを再起動してください。`hermes {profile_arg}doctor` が損傷を確認しない限り、復旧ツールの実行やバックアップからの復元は行わないでください。"
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**최종 활동:** {timestamp}"
model: "**모델:** `{model}`"
model_provider: "**모델:** `{model}` ({provider})"
free_tier: "Nous · 무료 요금제 · nous/welcome · /login 명령으로 로그인"
context: "**컨텍스트:** {used} / {total} ({pct}%)"
context_used: "**컨텍스트:** ~{used} 토큰"
tokens: "**누적 청구 토큰:** {tokens} _(현재 컨텍스트 크기가 아닙니다. `/context`를 사용하세요)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ 게이트웨이가 성공적으로 재시작되었어요. 세션은 계속 이어져요."
free_tier_line: "추론: Nous 무료 등급 (nous/welcome). 더 많이 사용하려면 로그인하세요: /login"
online: "♻️ 게이트웨이 온라인 — Hermes가 돌아왔고 준비되었어요."
db_corrupt: "⚠️ 세션 데이터베이스 손상이 감지되었어요. 메시지가 저장되지 않을 수 있어요. 복구 방법:\n1. `hermes {profile_arg}doctor --fix` 실행\n2. 게이트웨이를 중지한 뒤 다음으로 복구:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (복구 가능하다고 나오면) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — 복구는 먼저 손상된 파일의 스냅샷을 만들어요. 실행 중인 state.db에 `sqlite3 ... \\\".recover\\\"`를 실행하지 마세요. 취약한 sqlite3 CLI가 손상을 더 키울 수 있어요\n3. {backups_dir}/의 백업에서 복원\n정리된 진단 정보는 `hermes {profile_arg}doctor`로 확인하세요."
db_fts_corrupt: "⚠️ 세션 데이터베이스가 검색 인덱스(FTS5)에 한정된 손상 오류를 보고했어요. 메시지 테이블은 손상되지 않았어요. 수리될 때까지 메시지가 저장되지 않을 수 있어요: `hermes {profile_arg}doctor --fix`를 실행한 뒤 게이트웨이를 재시작하세요. `hermes {profile_arg}doctor`가 손상을 확인하지 않는 한 복구 도구를 실행하거나 백업을 복원하지 마세요."
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**Última atividade:** {timestamp}"
model: "**Modelo:** `{model}`"
model_provider: "**Modelo:** `{model}` ({provider})"
free_tier: "Nous · plano gratuito · nous/welcome · /login para entrar"
context: "**Contexto:** {used} / {total} ({pct}%)"
context_used: "**Contexto:** ~{used} tokens"
tokens: "**Total acumulado de tokens faturados:** {tokens} _(não é o tamanho atual do contexto; usa `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway reiniciado com sucesso. Sua sessão continua."
free_tier_line: "Inferência: nível gratuito da Nous (nous/welcome). Entre para obter mais: /login"
online: "♻️ Gateway online — o Hermes voltou e está pronto."
db_corrupt: "⚠️ Corrupção detectada no banco de dados de sessões. As mensagens podem não ser persistidas. Opções de recuperação:\n1. Execute `hermes {profile_arg}doctor --fix`\n2. Pare o gateway e depois recupere com:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (se indicar que é recuperável) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — a recuperação cria um instantâneo do arquivo danificado primeiro; NÃO execute `sqlite3 ... \".recover\"` no state.db em uso, um CLI sqlite3 vulnerável pode corrompê-lo ainda mais\n3. Restaure a partir de um backup em {backups_dir}/\nExecute `hermes {profile_arg}doctor` para diagnósticos sanitizados."
db_fts_corrupt: "⚠️ O banco de dados de sessões relatou um erro de corrupção restrito ao índice de busca (FTS5); as tabelas de mensagens não estão danificadas. As mensagens podem não ser persistidas até o reparo: execute `hermes {profile_arg}doctor --fix` e depois reinicie o gateway. Não execute ferramentas de recuperação nem restaure um backup a menos que `hermes {profile_arg}doctor` confirme o dano."
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**Последняя активность:** {timestamp}"
model: "**Модель:** `{model}`"
model_provider: "**Модель:** `{model}` ({provider})"
free_tier: "Nous · бесплатный тариф · nous/welcome · /login для входа"
context: "**Контекст:** {used} / {total} ({pct}%)"
context_used: "**Контекст:** ~{used} токенов"
tokens: "**Всего токенов списано за всё время:** {tokens} _(это не размер текущего контекста; используйте `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Шлюз успешно перезапущен. Ваш сеанс продолжается."
free_tier_line: "Инференс: бесплатный уровень Nous (nous/welcome). Войдите, чтобы получить больше: /login"
online: "♻️ Шлюз в сети — Hermes снова готов к работе."
db_corrupt: "⚠️ Обнаружено повреждение базы данных сеансов. Сообщения могут не сохраняться. Варианты восстановления:\n1. Выполните `hermes {profile_arg}doctor --fix`\n2. Остановите шлюз, затем восстановите:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (если сообщает, что восстановление возможно) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — восстановление сначала делает снимок повреждённого файла; НЕ запускайте `sqlite3 ... \".recover\"` на рабочем state.db — уязвимый CLI sqlite3 может повредить его ещё сильнее\n3. Восстановите из резервной копии в {backups_dir}/\nВыполните `hermes {profile_arg}doctor` для очищенной диагностики."
db_fts_corrupt: "⚠️ База данных сеансов сообщила об ошибке повреждения, ограниченной поисковым индексом (FTS5); таблицы сообщений не повреждены. Сообщения могут не сохраняться до починки: выполните `hermes {profile_arg}doctor --fix`, затем перезапустите шлюз. Не запускайте инструменты восстановления и не восстанавливайте резервную копию, пока `hermes {profile_arg}doctor` не подтвердит повреждение."
-2
View File
@@ -435,7 +435,6 @@ gateway:
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: "**Bağlam:** {used} / {total} ({pct}%)"
context_used: "**Bağlam:** ~{used} token"
tokens: "**Faturalandırılan toplam token:** {tokens} _(mevcut bağlam boyutunuz değil; `/context` kullanın)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Gateway başarıyla yeniden başlatıldı. Oturumunuz devam ediyor."
free_tier_line: "Çıkarım: Nous ücretsiz katman (nous/welcome). Daha fazlası için giriş yapın: /login"
online: "♻️ Gateway çevrimiçi — Hermes geri döndü ve hazır."
db_corrupt: "⚠️ Oturum veritabanında bozulma tespit edildi. Mesajlar kalıcı olarak kaydedilmeyebilir. Kurtarma seçenekleri:\n1. `hermes {profile_arg}doctor --fix` çalıştırın\n2. Gateway'i durdurun, ardından şununla kurtarın:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (kurtarılabilir olduğunu bildirirse) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — kurtarma önce hasarlı dosyanın anlık görüntüsünü alır; canlı state.db üzerinde `sqlite3 ... \".recover\"` ÇALIŞTIRMAYIN, açığı olan bir sqlite3 CLI dosyayı daha da bozabilir\n3. {backups_dir}/ içindeki bir yedekten geri yükleyin\nTemizlenmiş tanılama için `hermes {profile_arg}doctor` çalıştırın."
db_fts_corrupt: "⚠️ Oturum veritabanı yalnızca arama dizinini (FTS5) etkileyen bir bozulma hatası bildirdi; mesaj tabloları hasarlı değil. Onarılana kadar mesajlar kalıcı olarak kaydedilmeyebilir: `hermes {profile_arg}doctor --fix` çalıştırıp gateway'i yeniden başlatın. `hermes {profile_arg}doctor` hasarı doğrulamadıkça kurtarma araçlarını çalıştırmayın veya yedek geri yüklemeyin."
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**Остання активність:** {timestamp}"
model: "**Модель:** `{model}`"
model_provider: "**Модель:** `{model}` ({provider})"
free_tier: "Nous · безкоштовний тариф · nous/welcome · /login для входу"
context: "**Контекст:** {used} / {total} ({pct}%)"
context_used: "**Контекст:** ~{used} токенів"
tokens: "**Усього токенів виставлено за весь час:** {tokens} _(це не поточний розмір контексту; використайте `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ Шлюз успішно перезапущено. Ваш сеанс продовжується."
free_tier_line: "Інференс: безкоштовний тариф Nous (nous/welcome). Увійдіть, щоб отримати більше: /login"
online: "♻️ Шлюз онлайн — Hermes повернувся й готовий до роботи."
db_corrupt: "⚠️ Виявлено пошкодження бази даних сеансів. Повідомлення можуть не зберігатися. Варіанти відновлення:\n1. Виконайте `hermes {profile_arg}doctor --fix`\n2. Зупиніть шлюз, потім відновіть командами:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (якщо повідомляє, що відновлення можливе) hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — відновлення спочатку робить знімок пошкодженого файлу; НЕ запускайте `sqlite3 ... \".recover\"` на робочому state.db — вразливий CLI sqlite3 може пошкодити його ще більше\n3. Відновіть із резервної копії в {backups_dir}/\nВиконайте `hermes {profile_arg}doctor` для очищеної діагностики."
db_fts_corrupt: "⚠️ База даних сеансів повідомила про пошкодження, обмежене пошуковим індексом (FTS5); таблиці повідомлень не пошкоджено. Повідомлення можуть не зберігатися, доки його не полагоджено: виконайте `hermes {profile_arg}doctor --fix`, потім перезапустіть шлюз. Не запускайте інструменти відновлення й не відновлюйте резервну копію, якщо `hermes {profile_arg}doctor` не підтвердить пошкодження."
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**最近活動:** {timestamp}"
model: "**模型:** `{model}`"
model_provider: "**模型:** `{model}`({provider})"
free_tier: "Nous · 免費方案 · nous/welcome · /login 登入"
context: "**上下文:** {used} / {total}({pct}%)"
context_used: "**上下文:** ~{used} tokens"
tokens: "**累計計費 token:** {tokens} _(非目前上下文大小;請使用 `/context`)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes — {title}"
restarted: "♻ 閘道已成功重新啟動。你的工作階段將繼續。"
free_tier_line: "推論:Nous 免費方案(nous/welcome)。登入以取得更多:/login"
online: "♻️ 閘道已上線 — Hermes 已恢復並準備就緒。"
db_corrupt: "⚠️ 偵測到工作階段資料庫損毀。訊息可能無法保存。復原選項:\n1. 執行 `hermes {profile_arg}doctor --fix`\n2. 停止閘道,然後以下列指令復原:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (若回報可復原)hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n — 復原前會先為受損檔案建立快照;切勿對線上的 state.db 執行 `sqlite3 ... \".recover\"`,有漏洞的 sqlite3 CLI 可能使其進一步損毀\n3. 從 {backups_dir}/ 中的備份還原\n執行 `hermes {profile_arg}doctor` 可取得去識別化的診斷資訊。"
db_fts_corrupt: "⚠️ 工作階段資料庫回報的損毀錯誤僅限於搜尋索引(FTS5);訊息資料表並未受損。修復前訊息可能無法保存:請執行 `hermes {profile_arg}doctor --fix`,然後重新啟動閘道。除非 `hermes {profile_arg}doctor` 確認有損毀,否則請勿執行復原工具或還原備份。"
-2
View File
@@ -435,7 +435,6 @@ gateway:
last_activity: "**最近活动:** {timestamp}"
model: "**模型:** `{model}`"
model_provider: "**模型:** `{model}`({provider})"
free_tier: "Nous · 免费套餐 · nous/welcome · /login 登录"
context: "**上下文:** {used} / {total}({pct}%)"
context_used: "**上下文:** 约 {used} tokens"
tokens: "**累计计费 tokens:** {tokens} _(并非当前上下文大小;请使用 `/context` 查看)_"
@@ -841,7 +840,6 @@ gateway:
startup:
handoff_thread_title: "Hermes——{title}"
restarted: "♻ 网关已成功重启。你的会话继续有效。"
free_tier_line: "推理:Nous 免费套餐(nous/welcome)。登录以获得更多:/login"
online: "♻️ 网关已上线——Hermes 已恢复就绪。"
db_corrupt: "⚠️ 检测到会话数据库损坏。消息可能无法持久化。恢复方案:\n1. 运行 `hermes {profile_arg}doctor --fix`\n2. 停止网关,然后执行恢复:\n hermes {profile_arg}sessions recover --source {db_path} --inspect-only\n (若报告可恢复)hermes {profile_arg}sessions recover --source {db_path} --output recovered-state.db\n ——恢复会先对损坏文件做快照;切勿对正在使用的 state.db 运行 `sqlite3 ... \\\".recover\\\"`,有漏洞的 sqlite3 CLI 可能使其进一步损坏\n3. 从 {backups_dir}/ 中的备份恢复\n运行 `hermes {profile_arg}doctor` 获取脱敏诊断信息。"
db_fts_corrupt: "⚠️ 会话数据库报告了仅限于搜索索引(FTS5)的损坏错误;消息表未受损。修复前消息可能无法持久化:请运行 `hermes {profile_arg}doctor --fix`,然后重启网关。除非 `hermes {profile_arg}doctor` 确认存在损坏,否则不要运行恢复工具或还原备份。"
+17 -1
View File
@@ -124,7 +124,6 @@ def install_portal(monkeypatch, tmp_path, fake: FakePortal | None = None) -> Fak
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", PORTAL)
monkeypatch.setenv("HERMES_ANON_API_SECRET", "test-secret")
monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store"))
monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1")
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "NOUS_API_KEY"):
monkeypatch.delenv(var, raising=False)
real_client = httpx.Client
@@ -146,3 +145,20 @@ def install_portal(monkeypatch, tmp_path, fake: FakePortal | None = None) -> Fak
# by an earlier test must not be served to this one.
monkeypatch.setattr(auth_mod, "_RESOLVE_TOKEN_CACHE", {})
return fake
def as_local_desktop_backend(monkeypatch) -> None:
"""This process is the backend the desktop spawned on this machine (the local spawn's credential)."""
monkeypatch.setenv("HERMES_DESKTOP", "1")
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "test-spawn-token")
def claimed_trial(monkeypatch) -> dict:
"""A guest that claimed the welcome trial, read on the desktop's local backend: the state every
free-model surface needs. Mints through the fake portal installed by ``install_portal``."""
from hermes_cli import welcome_trial
as_local_desktop_backend(monkeypatch)
state = welcome_trial.claim_desktop_free_tier()
assert state is not None, welcome_trial.claim_blocker()
return state
+91
View File
@@ -0,0 +1,91 @@
"""The desktop's welcome trial (``hermes_cli.welcome_trial``): who may claim it, what a claim leaves
behind, which guests keep the free model, and that a connector guest never asks for inference."""
import pytest
from hermes_cli import anon_auth, install_state, welcome_trial
from tests.hermes_cli.anon_portal import as_local_desktop_backend, claimed_trial, install_portal
@pytest.fixture
def portal(monkeypatch, tmp_path):
return install_portal(monkeypatch, tmp_path)
def test_a_first_install_on_the_local_desktop_backend_claims_the_trial(portal, monkeypatch):
state = claimed_trial(monkeypatch)
assert state[anon_auth.WELCOME_CLAIM_FLAG] is True
assert anon_auth.free_inference_active()
assert install_state.existing_install() # the machine is marked: never claimed twice here
assert welcome_trial.claim_blocker() is not None
assert portal.creates() == 1
BLOCKED = {
"the CLI": lambda mp, home: None,
"an SSH backend": lambda mp, home: (mp.setenv("HERMES_DESKTOP", "1"), mp.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)),
"guests switched off": lambda mp, home: (as_local_desktop_backend(mp),
(home / "config.yaml").write_text("nous:\n guest: false\n", encoding="utf-8")),
"a provider key": lambda mp, home: (as_local_desktop_backend(mp), mp.setenv("OPENROUTER_API_KEY", "sk-or-test")),
"a used machine": lambda mp, home: (as_local_desktop_backend(mp), install_state.mark_machine_used()),
}
@pytest.mark.parametrize("case", list(BLOCKED))
def test_the_trial_is_never_claimed_outside_a_first_desktop_install(portal, monkeypatch, case):
from hermes_constants import get_hermes_home
BLOCKED[case](monkeypatch, get_hermes_home())
assert welcome_trial.claim_desktop_free_tier() is None
assert portal.creates() == 0
assert not anon_auth.free_inference_active()
def test_a_connector_guest_neither_claims_nor_carries_inference(portal, monkeypatch):
as_local_desktop_backend(monkeypatch)
anon_auth.ensure_portal_identity(explicit=True) # the first hosted connector action
assert welcome_trial.claim_desktop_free_tier() is None
assert not anon_auth.free_inference_active()
from hermes_cli.auth import resolve_nous_access_token
resolve_nous_access_token()
assert [r["body"]["purpose"] for r in portal.token_requests] == ["connectors"]
def test_a_claimed_guest_carries_inference_only_on_the_local_desktop_backend(portal, monkeypatch):
state = claimed_trial(monkeypatch)
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN") # the same home, read by a CLI process
assert not anon_auth.free_inference_active()
assert anon_auth.guest_without_free_tier(state)
def _chat(db_path, model: str) -> None:
from hermes_state import SessionDB
db = SessionDB(db_path)
db.create_session("s1", "desktop", model=model)
db.append_message("s1", "user", "hi")
db.close()
@pytest.mark.parametrize(("model", "kept"), [(anon_auth.GUEST_MODEL, True), ("anthropic/claude", False)])
def test_a_guest_that_chatted_on_the_free_model_keeps_it(portal, monkeypatch, model, kept):
from hermes_constants import get_hermes_home
as_local_desktop_backend(monkeypatch)
anon_auth.ensure_portal_identity(explicit=True)
_chat(get_hermes_home() / "state.db", model)
assert welcome_trial.grandfather_existing_guest() is kept
assert anon_auth.free_inference_active() is kept
def test_a_lost_claim_is_not_replaced(portal, monkeypatch):
claimed_trial(monkeypatch)
anon_auth.clear_dead_guest("anon_credential_dead")
assert welcome_trial.claim_desktop_free_tier() is None
assert portal.creates() == 1
@@ -0,0 +1,95 @@
"""``onboarding.state`` / ``onboarding.start`` and the desktop's welcome trial: a new run on a first
install claims it before the chat exists, a rerun never does, a failed claim writes nothing, and only
the desktop's local backend opens onboarding by itself."""
from __future__ import annotations
from pathlib import Path
import httpx
import pytest
from agent.onboarding_flow import Facts
from hermes_cli import anon_auth, install_state
from tests.hermes_cli.anon_portal import as_local_desktop_backend, install_portal
from tui_gateway import server
@pytest.fixture
def root(tmp_path, monkeypatch) -> Path:
home = tmp_path / "hermes-root"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(server, "_hermes_home", str(home))
monkeypatch.setattr(server, "_db", None)
monkeypatch.setattr("agent.onboarding_facts.read_facts", lambda **kw: Facts(os="macos", arch="arm64"))
return home
@pytest.fixture
def portal(root, tmp_path, monkeypatch):
fake = install_portal(monkeypatch, tmp_path)
monkeypatch.setitem(server._methods, "session.resume",
lambda rid, params: {"result": {"session_id": f"rt-{params['session_id']}"}})
monkeypatch.setitem(server._methods, "prompt.submit", lambda rid, params: {"result": {"status": "streaming"}})
return fake
def _call(method: str, params: dict | None = None) -> dict:
return server.handle_request({"jsonrpc": "2.0", "id": 9, "method": method, "params": params or {}})
def test_a_new_run_on_a_first_install_claims_the_trial_once(portal, monkeypatch):
as_local_desktop_backend(monkeypatch)
assert _call("onboarding.state")["result"]["run"] is True
assert _call("onboarding.state")["result"]["eligible"] is True
_call("onboarding.start")
_call("onboarding.start") # a relaunch reopens the run: no second claim
_call("onboarding.start", {"rerun": True}) # Settings: runs on the model it has
assert portal.creates() == 1
assert anon_auth.free_inference_active()
def test_a_rerun_on_a_first_install_never_claims(portal, monkeypatch):
as_local_desktop_backend(monkeypatch)
_call("onboarding.start", {"rerun": True})
assert portal.creates() == 0
def test_a_failed_claim_opens_nothing_and_the_next_launch_tries_again(portal, monkeypatch):
as_local_desktop_backend(monkeypatch)
portal.create_response = httpx.Response(503, json={"error": "unavailable"})
failed = _call("onboarding.start")
assert failed["error"]["code"] == 5079
assert _call("onboarding.state")["result"]["session_id"] is None
assert install_state.probe().fresh
portal.create_response = None
anon_auth.reset_mint_memo_for_tests()
assert "result" in _call("onboarding.start")
assert anon_auth.free_inference_active()
def test_only_the_local_desktop_backend_opens_onboarding_by_itself(portal, monkeypatch):
assert _call("onboarding.state")["result"]["eligible"] is False # CLI / SSH / URL backend
as_local_desktop_backend(monkeypatch)
install_state.mark_machine_used()
assert _call("onboarding.state")["result"] == {"run": False, "eligible": False, "session_id": None, "run_id": None}
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
assert _call("onboarding.state")["result"]["eligible"] is True
def test_the_dev_fresh_install_switch_treats_a_used_machine_as_new(portal, monkeypatch):
as_local_desktop_backend(monkeypatch)
install_state.mark_machine_used()
assert _call("onboarding.state", {"assume_fresh": True})["result"]["run"] is True
assert "result" in _call("onboarding.start", {"assume_fresh": True})
assert portal.creates() == 1
+1 -1
View File
@@ -145,7 +145,7 @@ def ensure_guest_identity() -> Optional[str]:
from hermes_cli.auth_constants import AuthError
try:
if ensure_portal_identity(explicit=True, for_connectors=True) is not None:
if ensure_portal_identity(explicit=True) is not None:
return None
except AuthError as exc:
return _SETUP_FAILED + str(exc)
+1 -2
View File
@@ -125,8 +125,7 @@ def _replace_dead_guest_token(dead_state: dict, code: str = "anon_credential_dea
if code == ANON_ACCOUNT_LOCKED:
return None
try:
# The identity this replaces served connectors and managed tools: the connectors gate applies.
if ensure_portal_identity(explicit=True, for_connectors=True) is None:
if ensure_portal_identity(explicit=True) is None:
return None
return _clean(resolve_nous_access_token(refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS))
except Exception as exc:
@@ -355,9 +355,17 @@ method("profiles.get_asset", params=ProfilesGetAssetParams, result=ProfilesGetAs
# ── onboarding (methods_onboarding) ───────────────────────────────────────────────────────────
class OnboardingStateParams(Params):
"""``assume_fresh``: the unpackaged desktop's ``--fresh-install`` launch; the backend treats this
machine as one that never used Hermes. Packaged builds never send it."""
assume_fresh: bool = False
class OnboardingRunStateResult(Result):
"""``run``: onboarding opens on this launch (root config ``onboarding.run``, else a fresh
install). ``eligible``: the free tier is on for this backend (``anon_auth.guest_enabled``).
"""``run``: onboarding opens on this launch (root config ``onboarding.run``, else a machine that
never used Hermes). ``eligible``: this is the desktop's local backend and the welcome trial or a
configured provider can carry the first chat.
``session_id``: the stored id of the onboarding chat still in progress, so a relaunch reopens it;
``run_id``: that run's id, which ``onboarding.ended`` names."""
@@ -367,7 +375,7 @@ class OnboardingRunStateResult(Result):
run_id: str | None = None
method("onboarding.state", params=Params, result=OnboardingRunStateResult,
method("onboarding.state", params=OnboardingStateParams, result=OnboardingRunStateResult,
doc="Whether desktop first-run onboarding is due, and the chat in progress; reads the root profile's config.")
@@ -390,13 +398,16 @@ class OnboardingFactsSource(WireEnum):
class OnboardingStartParams(Params):
"""``rerun``: start a new onboarding chat even when one is in progress (Settings, Run onboarding
again). ``fresh_launch``: the app's once-per-launch start; a run the app was closed on does not
count, so its chat is deleted and the run starts over. ``facts_source``: whether the backend runs
on the user's own computer. ``locale``: the desktop's UI language, preferred over the backend OS
locale."""
again; it never claims the welcome trial). ``fresh_launch``: the app's once-per-launch start; a run
the app was closed on does not count, so its chat is deleted and the run starts over.
``facts_source``: whether the backend runs on the user's own computer. ``locale``: the desktop's UI
language, preferred over the backend OS locale. ``assume_fresh``: as on ``onboarding.state``. A new
run on a first install claims the welcome trial before the chat exists; a failed claim is error 5079
and writes nothing."""
rerun: bool = False
fresh_launch: bool = False
assume_fresh: bool = False
facts_source: OnboardingFactsSource = OnboardingFactsSource.local
locale: str | None = None
+29 -30
View File
@@ -1,6 +1,6 @@
"""Nous free-tier JSON-RPC handlers. ``free_tier.status`` answers from the
auth store with zero network and zero side effects; ``free_tier.provision`` is the explicit retry when
the boot bootstrap could not create the identity (desktop-only entry); ``free_tier.ack_notice``
auth store with zero network and zero side effects; ``free_tier.provision`` retries a welcome-trial claim
that failed (desktop-only entry); ``free_tier.ack_notice``
persists the one-time notice flag on the free-tier identity itself, so it dies with that identity.
``free_tier.challenge_result`` records a presentation hint for the pending attempt; the
backend still exchanges with the portal to determine whether the credential is cleared.
@@ -21,28 +21,28 @@ _profile_scoped = _registry.profile_scoped
@_profile_scoped
def _(rid, params: dict) -> dict:
"""``{has_guest, enabled, available, notice_pending, model, label}`` for the focused profile.
``available`` = an identity exists AND the tier is on: the free tier (connectors, and the model
when nothing else carries inference) is there for this install. Whether inference actually runs
on it is a ROUTE question answered by ``setup.runtime_check.free_tier``, never by this flag.
``available``: a guest that carries inference here (the welcome trial, on the desktop's local
backend). ``enabled``: available, or the trial can still be claimed on this machine. Whether
inference actually runs on it is a ROUTE question answered by ``setup.runtime_check.free_tier``.
``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity.
``nudge_due_in``: seconds until the sign-in offer after a finished task is due (install-wide,
``hermes_cli.free_tier_offer``); absent when none is pending.
A pure read. The identity is created by the boot bootstrap (``free_tier_bootstrap``), never as
a side effect of a client polling this method (NS-845 Q1.2)."""
A pure read: an identity is never created as a side effect of polling this (NS-845 Q1.2)."""
try:
from hermes_cli import anon_auth
has_guest = anon_auth.has_guest()
enabled = anon_auth.guest_enabled()
available = anon_auth.free_inference_active()
enabled = available or _trial_claimable()
payload = {
"has_guest": has_guest, "enabled": enabled, "available": has_guest and enabled,
"notice_pending": bool(has_guest and enabled and anon_auth.guest_notice_pending()),
"has_guest": has_guest, "enabled": enabled, "available": available,
"notice_pending": bool(available and anon_auth.guest_notice_pending()),
"model": anon_auth.GUEST_MODEL, "label": anon_auth.FREE_TIER_LABEL}
from hermes_cli import free_tier_offer
if (nudge_due_in := free_tier_offer.offer_due_in()) is not None:
payload["nudge_due_in"] = nudge_due_in
if enabled and not has_guest:
# Why there is no identity, when the last attempt to make one failed:
# Why there is no identity, when the last claim failed:
# ``{error, error_code, retryable, retry_after}`` (the mint memo's verdict).
payload.update(anon_auth.last_mint_failure() or {})
# A browser challenge the account service is waiting on (``anon_challenge``): the same
@@ -56,30 +56,29 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5090, str(e))
def _trial_claimable() -> bool:
from hermes_cli.welcome_trial import claim_blocker
return claim_blocker() is None
@method("free_tier.provision")
@_profile_scoped
def _(rid, params: dict) -> dict:
"""Explicit retry of the free-tier set-up for the focused profile: adopt the shared store's
identity, else mint one (blocking, short timeout). The boot bootstrap normally did this already;
the desktop calls this when the record says the identity is missing (portal down at boot, gate
turned on later) and the user asks again. The user's click is the one attempt that may run
inside the mint memo's cooldown. ``{has_guest, enabled}``, plus
``{error, error_code, retryable, retry_after}`` when the portal refused."""
"""The user's retry of a welcome-trial claim that failed: claims it when nothing blocks it here
(blocking, short timeout; the one attempt allowed inside the mint memo's cooldown). ``{has_guest,
enabled}``, plus ``{error, error_code, retryable, retry_after}`` when the portal refused."""
try:
from hermes_cli import anon_auth
from hermes_cli import free_tier_bootstrap
enabled = anon_auth.guest_enabled()
if enabled and not anon_auth.has_guest():
if free_tier_bootstrap.current_record() is not None and not params.get("profile"):
# The launch profile: refresh the boot record too, so ``setup.status`` and the
# ``setup.ready`` listeners move with the outcome.
free_tier_bootstrap.retry_bootstrap_mint(force=True)
else:
try:
anon_auth.ensure_portal_identity(explicit=True, force=True)
except Exception as exc: # memoised by the primitive before it re-raised
logger.info("free tier provisioning failed: %s", exc)
from hermes_cli import anon_auth, free_tier_bootstrap, welcome_trial
if _trial_claimable():
try:
welcome_trial.claim_desktop_free_tier(force=True)
except Exception as exc: # memoised by the mint primitive before it re-raised
logger.info("welcome trial claim failed: %s", exc)
if not params.get("profile"):
# The launch profile: ``setup.status`` and the ``setup.ready`` listeners move with it.
free_tier_bootstrap.refresh_record()
has_guest = anon_auth.has_guest()
enabled = anon_auth.free_inference_active() or _trial_claimable()
payload = {"has_guest": has_guest, "enabled": enabled}
if enabled and not has_guest:
payload.update(anon_auth.last_mint_failure() or {})
+25 -6
View File
@@ -9,7 +9,7 @@ method = _registry.method
# flag live in the root profile's config.yaml whichever profile this backend was launched under.
@method("onboarding.state")
def _(rid, params: dict) -> dict:
return _ok(rid, _run_state())
return _ok(rid, _run_state(assume_fresh=bool(params.get("assume_fresh"))))
@method("onboarding.set_run")
@@ -25,11 +25,18 @@ def _(rid, params: dict) -> dict:
return _ok(rid, _run_state())
def _run_state() -> dict:
from hermes_cli.anon_auth import guest_enabled
def _run_state(*, assume_fresh: bool = False) -> dict:
"""``run``: the root config says so, else this machine never used Hermes (or the unpackaged
desktop's ``--fresh-install`` says to assume that). ``eligible``: this is the desktop's local
backend and something can carry the first chat (the welcome trial or a configured provider);
remote backends run onboarding only from Settings."""
from hermes_cli.anon_auth import on_local_desktop_backend
from hermes_cli.onboarding_run import should_run
from hermes_cli.welcome_trial import inference_available
return {"run": should_run(), "eligible": guest_enabled(), **_active_run()}
return {"run": should_run(assume_fresh=assume_fresh),
"eligible": on_local_desktop_backend() and inference_available(assume_fresh=assume_fresh),
**_active_run()}
# ── first-run onboarding: one chat in the default profile, driven by setup_beat ───────────────
@@ -54,6 +61,8 @@ def _(rid, params: dict) -> dict:
from agent.onboarding_flow import End, Facts
from agent.onboarding_record import advance, load, open_session_ids, start
from agent.onboarding_text import CHAT_TITLE, opening_prompt
from hermes_cli.auth_constants import AuthError
from hermes_cli.welcome_trial import claim_desktop_free_tier
# The app's once-per-launch start: closing the app mid-run does not count, so that run's chat goes
# and the run starts over. A second window or a reconnect sends no flag and gets the run back.
fresh_launch = bool(params.get("fresh_launch"))
@@ -69,8 +78,13 @@ def _(rid, params: dict) -> dict:
if created:
# The tours setting is the default profile's, whichever profile launched the backend.
home = _profile_home("default")
with _session_profile_runtime_scope({"profile_home": str(home) if home else None},
hydrate_secrets=False):
scope = {"profile_home": str(home) if home else None}
if not params.get("rerun"):
# Before the chat exists, so onboarding's own writes are not what the machine
# check sees. A rerun never claims: it runs on the configured model.
with _session_profile_runtime_scope(scope):
claim_desktop_free_tier(assume_fresh=bool(params.get("assume_fresh")))
with _session_profile_runtime_scope(scope, hydrate_secrets=False):
facts = read_facts(facts_source=params.get("facts_source") or "local", locale=params.get("locale"))
stored = db.create_session(new_session_id(), "desktop")
# The record exists before the agent is built, so the build folds the onboarding toolset in.
@@ -91,6 +105,11 @@ def _(rid, params: dict) -> dict:
db.set_session_title(stored, db.get_next_title_in_lineage(CHAT_TITLE))
# A chat with no messages never got its opening (a restart between start and the first turn).
opening = None if (db.get_session(stored) or {}).get("message_count") else opening_prompt(facts)
except AuthError as e:
# The trial claim failed (portal down, rate limited): nothing was written, so the next
# launch is still a first install and tries again.
logger.info("onboarding.start: welcome trial not claimed: %s", e)
return _err(rid, 5079, str(e))
except Exception as e:
logger.exception("onboarding.start failed")
return _err(rid, 5078, str(e))