Files
hermes-agent/hermes_cli/auth_nous.py
Siddharth Balyan 4bdd64b334 The free tier is created in one place, at boot, only behind HERMES_GUEST_ONBOARDING=1 (NS-847) (#107697)
* fix(auth): close the free tier's gaps against the gateway's welcome-tier contract

The inference gateway's welcome tier (NousResearch/api DOCS/anon-tier/plan.md) serves an
anonymous account exactly one model on its own host, refuses everything else with a structured
429, cross-refuses a request on the wrong host with a 400 (403 while the tier is dark), and
tells a signed-in account that still asks for `nous/welcome` what to switch to in an
`x-nous-model-switch` header. Four client-side gaps against that contract:

- Auxiliary calls were refused on every session. The auxiliary client asked the welcome host
  for the Portal's recommended compaction/vision model, a guaranteed 429 `model_not_free`
  before each fallback. On the welcome host it now uses `nous/welcome` (its backing model
  covers auxiliary work) and skips Nous for vision, which the welcome model does not take.

- The structured 429 body was never read. The classifier now parses `reason` /
  `retry_after` / `alternates` / `upgrade_url`: `model_not_free` and `feature_not_free` are
  non-retryable gates that fall back; `at_capacity`, `admission_closed` and `rate_limited`
  are rate limits that honour `retry_after` and never rotate the free tier's only credential.
  The wrong-host 400 and the dark-tier 403 are deterministic, so they abort this route and
  fall back instead of retrying or re-exchanging. The terminal paths say what happened and
  name the sign-in (`/login` in a chat, `hermes auth upgrade` in a terminal).

- The `x-nous-model-switch` header was ignored. The chat-completions transport records it
  beside the rate-limit and credits headers; the next call moves the session, and the config
  default when it still names `nous/welcome`, to the backing model the gateway named.

- A guest fell back to the paid host. With `inference_base_url` absent from the exchange or
  outside the host allowlist, routing defaulted to inference-api, where every request is a
  400. A guest now defaults to the welcome literal at the exchange, in the shared store's
  shape, and in effective routing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit fc758aad7efceff6223fc144a9b5c69f13e41bd8)

* feat(auth): the free tier is set up on request; nous.guest_setup decides whether also on first use

A caller that names nous/welcome on a Nous route with no Nous identity in reach — the guided
setup's session (provider=nous, which skips the resolver's nothing-configured rung), the free-tier
picker row, a bare --provider nous pointed at it — is asking for the free tier. The OAuth runtime
rung now sets it up there instead of failing "not logged in", so the guided chat no longer races
the root profile's first-run mint.

nous.guest_setup is the policy seam: "auto" (default) keeps today's first-use setup wherever
nothing else is configured; "on-request" mints only when the free tier is asked for by name
(nous/welcome, /login, hermes auth upgrade, replacing a retired identity). Implicit callers —
the resolver's last rung, the first-run check, free_tier.status, the CLI's background setup, the
connector token path — still adopt what the shared store holds, so every profile follows the one
identity the guided setup created, but never create one on their own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit ae915ddc65ecdb81b81e29b604671d15cd49233c)
(cherry picked from commit 62ad1ff3ab200ea064975a32c502041b25910165)

* feat(auth): the guided setup provisions the free tier explicitly; nous.guest_setup is auto | explicit

Two questions govern the free tier: may it exist (nous.guest) and who may CREATE the identity
(nous.guest_setup). "auto" (default) keeps today's first-use setup wherever nothing else is
configured. "explicit" means Hermes never creates one on its own: the only creator is the new
provision_free_tier() primitive, exposed as the free_tier.provision RPC, which the guided setup
on Hermes Desktop calls as its first step — on the root gateway, before the setup profile and
before the guided chat exists — so the identity lands in the root store every profile reads
through and is there before any session asks for nous/welcome. That closes the race against the
backend's own setup, and makes "only when the setup-bot flow is used" literally true.

The earlier "on-request" tier is replaced: it minted whenever any caller named nous/welcome
(the hermes model row, --provider nous), which treated a model name as intent and was broader
than the guided setup. Under "explicit" a nous/welcome request with no identity fails "not
logged in" as before the free tier existed, and /login or hermes auth upgrade report nothing to
sign in from. Implicit callers still adopt an identity the shared store holds, and a retired
credential is replaced (a continuation, not a creation).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit c63d2c935c1e59016164fdfb90cf70b4094466a0)

* fix(auth): remove the nous.guest_setup knob; the free tier is created on first use

`nous.guest_setup: auto | explicit` decided who may CREATE the free-tier identity. Under its
default every line it added was inert (`may_mint` always true), nothing in tree set `explicit`,
unknown values read as `auto`, and under `explicit` a CLI-only install could never get an
identity, which contradicts the first-run contract (first command mints, then chats).

The mint race the knob accompanied is already benign: every caller takes the profile lock then
the shared-store lock, and the loser adopts what the winner wrote. What makes the guided setup
win deterministically is `provision_free_tier()` behind the `free_tier.provision` RPC, which
stays. `nous.guest` remains the only free-tier policy.

Removed: `guest_setup_policy()` and its constants, the `explicit=` / `may_mint=` threading through
`ensure_portal_identity` and `_reconcile_and_provision`, the flag at the three replacement call
sites (now no-ops), the config default, the docs section, and the four `guest_setup` test-config
entries. The three policy tests that hold regardless of the knob are kept under
`TestExplicitProvision`; the two that only tested the knob are deleted.

(cherry picked from commit d8a50526d93c374c0067dd935b5a65055e0af261)

* fix(gateway): a server-driven model switch off nous/welcome does not evict the cached agent

When a signed-in account still asks the paid host for `nous/welcome`, the inference gateway
serves the current backing model and names it in `x-nous-model-switch`. `apply_model_switch`
moves the live session to that model and moves `config.yaml`'s default off the alias in the
same step. The messaging gateway's fallback-eviction check compares the agent's model with the
config default and evicts on any mismatch that is not a /model override, so when the config
write did not land (unreadable config, lock) the cached agent was evicted once per turn, and
prompt caching with it.

`apply_model_switch` now stamps the alias it moved the session off on the agent, and
`_is_intentional_model_switch` treats "agent moved off the alias the config still carries" as
deliberate, beside the existing /model override case. The check takes the agent and the config
model instead of a bare model string; its one caller in `_run_agent_evict_on_fallback` passes them.

(cherry picked from commit 696d1ec86b69db28bf002c841e9389b85178a954)

* fix(auth): the free tier outranks implicit host credentials in provider resolution

On a fresh install with a leftover ~/.aws profile, resolve_provider("auto")
reached the Bedrock rung before the free-tier rung, so the first turn ran on
Bedrock and failed 403 while the free tier was still being minted in the
background at agent setup (NS-829). Live on a Mac with ~/.aws present: 28 s,
three retries, no answer; the next process then switched to nous/welcome.

The free-tier rung now sits directly above the Bedrock chain: when nous.guest
is on, an existing free-tier identity answers, else a blocking mint runs, and
only then does the boto chain get a say. Everything above is unchanged and
still wins: CLI creds, config.yaml model.provider, env keys, the OpenRouter
pool, a logged-in active_provider. nous.guest: false skips the rung, and a
failed mint still falls through to Bedrock and the no-provider guidance.

Tests: six precedence cases (identity present, fresh mint, free tier off, env
key still wins, sign-in still wins, failed mint falls through). The opt-out
test now neutralizes the AWS chain like the precedence tests do; on a machine
with ~/.aws it was failing for the same reason as the bug.

Live after the fix, same Mac, AWS credentials visible, isolated shared store:
identity minted 2 s in, turn on model=nous/welcome provider=nous, answer in
11 s.

(cherry picked from commit a04b05260cd334dd7199ad9b6cd5b2538364c75a)

* fix(auth): review follow-ups for the free-tier rung (NS-829)

- tests/agent/test_bedrock_integration.py: the Bedrock auto-detect test switches
  the free tier off; its contract is the boto chain, and the free tier now
  sits above it.
- gateway/run_notifications.py: the free-tier startup line reads auth.json
  before consulting the resolver, so a gateway boot on a machine with AWS
  credentials never mints or refreshes over the network.
- hermes_cli/anon_auth.py: module docstring says where the free tier sits in
  the ladder instead of "the ladder is untouched".
- tests/hermes_cli/test_provider_precedence.py: two invariant tests instead of
  six (parametrized ladder cases; a failed mint that returns None or raises
  falls through to Bedrock).

scripts/run_tests.sh on the five affected files: 147 passed, 0 failed.

(cherry picked from commit 10790d148c60ada11b9ecdde2cd2c836c6a82a11)

* feat(auth): HERMES_GUEST_ONBOARDING=1 is the one launch gate for the free tier; HERMES_FORCE_GUEST is gone

The free tier is pre-GA. Until GA it must not exist for anyone who did not
ask for it: no identity minted, no portal traffic, no free-tier copy on any
surface. One environment variable now decides that, and one function reads it.

`guest_enabled()` returns False unless `HERMES_GUEST_ONBOARDING` is exactly
"1"; only then does `nous.guest` (the user's off switch) get consulted. Every
free-tier site already funnels through `guest_enabled()`, so the gate closes
minting, routing, connector entitlement, status lines and the picker row in
one place. With the variable unset, `resolve_provider("auto")` on a fresh
install raises `no_provider_configured` exactly as upstream does.

`HERMES_FORCE_GUEST` and `force_guest_mode()` are removed. They inverted the
gate (forced the tier ON over `nous.guest: false`), their "new" value re-minted
identities as a side effect of provider resolution, and `_has_any_provider_
configured` read them ahead of every other check, making the CLI a second
reader of a flag that must have exactly one. `_forced_new_done` and the
`force` parameter of `_reconcile_and_provision` go with them.

Supersedes the dev lever introduced in fcf9d11679 (rung 1) and hardened in
b5c162c3ec. Ruling: NS-845 Q1.1 (recorded on NS-847).

Not a user preference: the variable is never written to config.yaml or .env
and never shown in setup. It is deleted at GA together with its comment in
anon_auth.py. This is a deliberate, temporary exception to the "no new
HERMES_* env vars for non-secret config" rule.

Tests: fixtures set the gate instead of deleting the old lever; one new
invariant (`test_launch_gate_off_means_no_free_tier_at_all`) proves that "",
"0", "true" and "new" all leave the tier off with zero portal calls, red on the
previous commit. The `HERMES_FORCE_GUEST=new` re-mint test is deleted with the
feature.

* feat(auth): the free-tier identity is created in one place, at boot; every other site is a read

Before this commit eight sites could create a Nous free-tier identity as a
side effect of something else: resolving a provider, the CLI's first-run
check, the CLI's session setup (in the background beside an own key), a
connector bearer read, the desktop polling `free_tier.status`, the sign-in
precondition, the desktop's `free_tier.provision`, and the dead-credential
re-mint. A poll could mint. Provider resolution could hit the network. Two
of them raced each other on a fresh install.

Now `hermes_cli/free_tier_bootstrap.py::run_bootstrap` is the only creator.
`hermes serve` runs it on a daemon thread from `_lifespan` beside the other
background boots; `cmd_chat` runs it synchronously before the first-run
guard. It inventories credentials first (`resolve_provider("auto",
skip_free_tier=True)`: what would carry inference if the free tier did not
exist), creates the identity only when `guest_enabled()`, resolves inference,
records a `SetupRecord` in process memory and broadcasts ONE `setup.ready`
event. It runs on every boot; only the mint is gated.

`ensure_portal_identity` now requires `explicit=True` and raises otherwise.
Its callers are the bootstrap, the desktop's `free_tier.provision` (the
explicit retry when the boot could not create the identity) and the two
dead-credential replacements (`auth_nous.resolve_nous_runtime_credentials`,
`managed_tool_gateway._replace_dead_guest_token`). The background thread
path and `provision_free_tier` are deleted with their last callers.

Reads that used to mint and now only read: `auth.py::resolve_provider`
rung 7 (an existing identity still outranks the Bedrock chain, NS-829
ordering kept), `main.py::_has_any_provider_configured`,
`cli_agent_setup_mixin._ensure_runtime_credentials`,
`managed_tool_gateway.read_nous_access_token` (no identity -> None),
`anon_sign_in.run_sign_in` (no identity -> Unavailable),
`methods_free_tier` `free_tier.status`.

`setup.status` answers from the record for the launch profile, blocking up
to 8 s while the bootstrap is in flight so a client's first poll lands after
the identity exists rather than racing it; a named profile, or a process
that never ran the bootstrap, keeps today's live probe. The record's fields
ride along additively (`ready`, `free_tier`, `other_providers`,
`inference_provider`).

Identity and inference are decoupled (NS-845 Q1.3): the mint sets
`active_provider="nous"` only when the inventory found nothing else usable
(`_mint_locked(carries_inference=)`); an adopted account always does. A token
refresh no longer re-elects the provider it refreshed
(`_save_provider_state_to_source` writes credentials, not the user's
choice) — that write was how an own-key install ended up on the free tier
after the first connector call.

Supersedes the mint sites in fcf9d11679, a42d0748fc (first-run check),
bbbaa8935a (CLI background setup), 0179efc989 (`free_tier.status` mint),
62ad1ff3ab / c63d2c935c / d8a50526d9 (the `nous.guest_setup` knob and
`provision_free_tier`), and a04b05260c (blocking mint in the resolver).
Ruling: NS-845 Q1.2 + Q1.3, recorded on NS-847.

Tests: `TestBootstrapIsTheOneCreator` (one mint per process; own key keeps
inference; reads never reach the portal; a refused mint is memoised),
`free_tier.status` fails loudly if it ever calls the creator, the resolver
stub fails loudly if resolution ever mints, `setup.status` reads the record,
`skip_free_tier` proves the inventory question. The three sign-in tests for
the deleted pre-mint collapse into one (`no identity -> Unavailable, zero
portal calls`). Live: real `_lifespan` boot with a fake portal, gate on and
off (/tmp/ns847-recon/evidence/e2e-rung5-c2-serve-boot.txt), and the CLI
matrix incl. an own-key cell (e2e-rung5-c2-bootstrap.txt), 20/20.

* fix(credits): the welcome host is free-tier evidence, so a free-tier identity never sees "run /topup"

A free-tier identity carries $0 by design, so the portal seed reports
`paid_access=False` for it. `is_free_tier_model` did not know the welcome
host, read that as a depleted account, and every free-tier turn ended with
the credits-depleted notice telling the user to top up an account they do
not have.

Rule (4) in `is_free_tier_model`: a `base_url` on the Nous welcome host
(`anon_auth.route_is_welcome_host`) is the free tier. The host is the
evidence, not the model name: the paid inference host can serve
`nous/welcome` to a named account and that account's depletion is real, so
`("nous/welcome", <inference host>)` stays False. Local data only, like the
three rules above it.

Restores the two contracts dropped by hermes-magic 674e11d1eaa (the
prototype line ran without unit tests): the welcome host is free without
any pricing evidence; the model name alone is not. The first is red without
this fix.

* fix(copy): free-tier text stops promising a connector transfer and never names the config key

Sign-in copy on every surface said "Sign in to keep your connectors" and
ended with "Your connectors are kept." The transfer registry that would
make that true is empty (NS-821): nothing carries over today. The copy now
says what signing in does give ("unlock more models and tools") and the
completion line names the account, not a transfer. The docs page loses the
"connectors carry over" paragraph for the same reason.

The picker's off-state line exposed `nous.guest: false` and the word
"guest"; user copy names the free tier only (R-USR-1).

The docs page gains the pre-rollout note: until GA nothing on it happens
without `HERMES_GUEST_ONBOARDING=1`. Its "first command mints" and
"replaced on next use" sentences now describe the boot bootstrap.

zh is a strict locale: the `freeTier` block was English placeholder text
copied from `en`; it is now Chinese. `connectorsKept` is renamed
`completedBody` since it no longer talks about connectors.

* feat(desktop): the free-tier launch flag is decided once in Electron and stamped onto every backend spawn

The Python backend reads `HERMES_GUEST_ONBOARDING` and treats exactly "1"
as on. Until now nothing in the desktop set it, so a packaged app could
never turn the free tier on, and a backend spawned by the app could
disagree with the app about whether the tier was live.

`electron/guest-onboarding.ts` owns the decision: `guestOnboardingEnabled`
is true when the launch env has `HERMES_GUEST_ONBOARDING=1` or argv has
`--guest-onboarding` (the packaged-app spelling). It is read ONCE at launch
into a module constant. `desktopBackendSpawnEnv` wraps every backend env
as the outermost call and writes the flag LAST, as "1" or an explicit "0",
so no earlier spread (`process.env`, `backend.env`) can resurrect a stray
value from the parent shell.

Stamped onto all three spawn sites: the primary `serve` spawn, the pooled
per-profile spawn, and the remote SSH `exec env ...` command (which gains
` HERMES_GUEST_ONBOARDING=1` only when on). The embedded terminal PTY and
the backend probes are not backend spawns and do not get it: a
`hermes --tui` typed in the pane must not mint.

The renderer learns the same fact read-only through the existing
`hermes:launch-flags` sync IPC (`guestOnboarding`) and preload
(`window.hermesDesktop.guestOnboardingEnabled`).

Ruling: NS-845 Q1.1 / Q2 (env var is the contract, `--guest-onboarding`
maps to it in main). Two invariant tests on the pure helpers: only "1" or
the argv flag enables; the spawn env carries "1"/"0" as the last word and
preserves every other key.

* feat(desktop): the renderer learns free-tier readiness from one `setup.ready` push, not a 60 s poll

The backend's boot bootstrap now announces `setup.ready` once, after it has
created (or refused) the free-tier identity and resolved the inference
route. The renderer used to discover both by polling `setup.status`,
`setup.runtime_check` and `free_tier.status` every 60 s from
`useStatusSnapshot`; a fresh install's chip, notice strip and onboarding
overlay could sit stale for up to a minute after boot, and three RPCs a
minute per window kept asking a question whose answer changes only at
boundaries the backend already announces.

`handleLifecycleEvent` routes `setup.ready` (active source only, like
`skin.changed`) to `notifySetupReady()`, a one-shot tick atom in
`live-sync.ts` beside the other change ticks. `useStatusSnapshot` listens
to it and runs one readiness round at once (`setup.status` +
`setup.runtime_check` + `free_tier.status`). The readiness legs also run
once on open and on return from another app, as today. The 60 s tick keeps
only `getStatus()`.

`SetupStatusSnapshot` types the record's additive fields (`ready`,
`free_tier`, `other_providers`, `inference_provider`); readiness semantics
are unchanged and still key on `provider_configured` + `runtime_check`.

Ruling: NS-845 Q1.2 (renderer half). Tests: the lifecycle branch fires one
refresh from the active source and none from another; the snapshot hook's
contract is three legs on open, one leg on the tick.

* fix(cli): the banner names the free tier's model instead of "no model configured"

The welcome banner prints before credentials resolve, so on a fresh install
`model` is empty and the banner said, in red, "no model configured — run
/model or hermes setup". Under the free tier that is false: the route is
already known from local state (identity on disk, tier on), and the first
message will run on `nous/welcome`.

`_banner_left_lines` now asks the route the same question when `model` is
empty (`guest_carries_inference()`, a local read) and shows `welcome · Nous
Research`. When nothing resolves the red line stays. Ruling: NS-845 ("the
banner's 'no model configured' line reads the resolved route").

Live: fresh HERMES_HOME + fake portal, gate on -> `welcome · Nous Research`;
gate off -> the red line, zero portal calls.

* fix(aux): vision on the free tier uses nous/welcome too

The text-only modality on the gateway's `nous/welcome` row is DeepSeek V4 Flash's, the
backing model until the repoint; `z-ai/glm-5.3-flash` is natively multimodal and the
repoint declares the welcome row `text+image->text`. Skipping Nous for vision on the
welcome host would have sent every image step past the free tier for no reason, so the
auxiliary client pins the route's one model for every lane. A backing model that takes
no images answers with the upstream's own error, which the ladder handles as it always has.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 7456e028faba55480db43015dc2c8df3e393a415)

* fix(gateway): hermes gateway run is a boot owner of the free tier too

Rung 5 made every demand-time free-tier site a read: resolve_provider,
the connector token, the /login precondition. That is only correct if
every process that can reach those sites ran the bootstrap first. The
CLI (cmd_chat) and hermes serve (_lifespan) did; the standalone
messaging gateway did not. A fresh HERMES_HOME with the gate on and
`hermes gateway run` reached provider resolution with no identity to
consume, and /login returned Unavailable. Reported by @andrexibiza on
#107697 (P1).

GatewayRunner.start now runs `free_tier_bootstrap.run_bootstrap` on an
executor thread right after startup recovery and BEFORE any adapter
connects, so a fast first DM cannot arrive with nothing to resolve. It
is its own step, not part of the turn-machinery warm-up: the warm-up is
an optimisation with an off switch (HERMES_STARTUP_WARMUP_TIMEOUT<=0);
the bootstrap is correctness and must always run. With the gate unset it
is a local inventory and no network.

Live, real GatewayRunner.start against a fake portal in a fresh home:
  gate on   -> 1 create, identity persisted, resolve_runtime_provider=nous,
               /login precondition sees the identity
  gate off  -> 0 portal calls, no identity, no_provider_configured
Before the fix the gate-on row was identical to the gate-off row.

Test: the bootstrap seam runs before _start_prefilter_platforms and
delegates to the one creator. Red on 5554eb6993 (no seam), green here.

---------

Co-authored-by: Robin Fernandes <robin@soal.org>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 03:45:33 +05:30

1545 lines
77 KiB
Python

"""Nous Portal OAuth: device-code login, refresh, shared-store mirroring, JWT selection, status.
Split out of ``hermes_cli/auth.py``; origin helpers are imported lazily inside each function
so ``hermes_cli.auth.<name>`` patches still intercept (and no import cycle).
"""
from __future__ import annotations
import logging
import hashlib
import json
import os
import threading
import time
import uuid
from contextlib import contextmanager, suppress
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, FrozenSet, List, Optional
from urllib.parse import urlparse
from hermes_cli.auth_codex import _pool_entries
from hermes_cli.auth_constants import (
_decode_jwt_claims, AUTH_LOCK_TIMEOUT_SECONDS, AuthError, DEFAULT_NOUS_CLIENT_ID,
DEFAULT_NOUS_INFERENCE_URL, DEFAULT_NOUS_PORTAL_URL, DEFAULT_NOUS_SCOPE, DEFAULT_NOUS_WELCOME_URL,
DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS, NOUS_AUTH_PATH_INVOKE_JWT, NOUS_BILLING_MANAGE_SCOPE,
NOUS_DEVICE_CODE_SOURCE, NOUS_INFERENCE_INVOKE_SCOPE, NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
_nous_err, httpx)
if TYPE_CHECKING: # annotation-only; the runtime import would be a cycle
from hermes_cli.auth import ProviderConfig
# Log-record parity with the origin module (caplog tests pin "hermes_cli.auth").
logger = logging.getLogger("hermes_cli.auth")
_UNUSABLE_JWT_RELOGIN = "Re-authenticate with: hermes auth add nous"
def _unusable_invoke_jwt_error(reason: str, *, no_refresh_token: bool = False) -> AuthError:
"""Shared ``relogin=True`` error for an access token that is not a usable inference JWT."""
detail = " and no refresh token is available" if no_refresh_token else ""
return _nous_err(
f"Nous Portal access token is not a usable inference JWT ({reason}){detail}. "
f"{_UNUSABLE_JWT_RELOGIN}",
reason, relogin=True)
def _token_fingerprint(token: Any) -> Optional[str]:
"""Return a short hash fingerprint for telemetry without leaking token bytes."""
cleaned = token.strip() if isinstance(token, str) else ""
return hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:12] if cleaned else None
def _oauth_trace(event: str, *, sequence_id: Optional[str] = None, **fields: Any) -> None:
if os.getenv("HERMES_OAUTH_TRACE", "").strip().lower() not in {"1", "true", "yes", "on"}:
return
payload: Dict[str, Any] = {"event": event}
if sequence_id:
payload["sequence_id"] = sequence_id
payload.update(fields)
logger.info("oauth_trace %s", json.dumps(payload, sort_keys=True, ensure_ascii=False))
def _iso_after(now: datetime, ttl_seconds: int) -> str:
"""ISO timestamp *ttl_seconds* after *now* (UTC)."""
return datetime.fromtimestamp(now.timestamp() + ttl_seconds, tz=timezone.utc).isoformat()
# Nous agent-key slots; a fresh login persists them as None, quarantine strips them.
_NOUS_EMPTY_AGENT_KEY_FIELDS: Dict[str, Any] = {
"agent_key": None, "agent_key_id": None, "agent_key_expires_at": None,
"agent_key_expires_in": None, "agent_key_reused": None, "agent_key_obtained_at": None}
_NOUS_STALE_PORTAL_HOSTS: FrozenSet[str] = frozenset({"api.nousresearch.com"})
def _portal_entitlement_message(capability: str) -> str:
"""Portal entitlement notice for *capability* (fresh account data), "" when unavailable."""
from hermes_cli.nous_account import (
format_nous_portal_entitlement_message, get_nous_portal_account_info)
account_info = get_nous_portal_account_info(force_fresh=True)
return format_nous_portal_entitlement_message(account_info, capability=capability) or ""
def _format_nous_entitlement_auth_error(error: AuthError) -> str:
with suppress(Exception):
if message := _portal_entitlement_message("Nous model access"):
return message
return f"{error} Check credits or billing in Nous Portal, then retry."
def _migrate_stale_nous_portal_url(providers: Dict[str, Any]) -> None:
nous = providers.get("nous")
if not isinstance(nous, dict):
return
stored = (nous.get("portal_base_url") or "").strip()
if stored and urlparse(stored).hostname in _NOUS_STALE_PORTAL_HOSTS:
logger.warning(
"auth: migrating stale nous portal_base_url %s -> %s", stored, DEFAULT_NOUS_PORTAL_URL)
nous["portal_base_url"] = DEFAULT_NOUS_PORTAL_URL
# Allowlist of hosts the Nous Portal proxy will forward inference JWTs to — a bearer sent anywhere
# else would leak. Consulted only for URLs from the NETWORK side (Portal refresh responses);
# the NOUS_INFERENCE_BASE_URL env override bypasses it (documented dev/staging escape hatch, the
# user set it themselves).
_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({
"inference-api.nousresearch.com",
# Free-tier (anonymous) host: serves the single ``nous/welcome`` model.
"welcome-api.nousresearch.com"})
def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]:
"""Validate a Portal-returned inference URL against the host allowlist.
Defense-in-depth: a compromised refresh response (MITM, response injection) could otherwise
redirect every proxy request — bearing the user's inference JWT — to an attacker endpoint.
"""
cleaned = url.strip() if isinstance(url, str) else ""
if not cleaned:
return None
try:
parsed = urlparse(cleaned)
except Exception:
return None
if parsed.scheme != "https":
logger.warning(
"nous: refusing non-https inference URL scheme %r from Portal response", parsed.scheme)
return None
if parsed.hostname not in _ALLOWED_NOUS_INFERENCE_HOSTS:
logger.warning(
"nous: refusing inference URL host %r from Portal response "
"(not in allowlist); falling back to default",
parsed.hostname)
return None
return cleaned.rstrip("/")
def _nous_inference_env_override() -> Optional[str]:
"""User-set ``NOUS_INFERENCE_BASE_URL`` override (trailing slash stripped) or None.
Documented dev/staging escape hatch; the env source is trusted, so unlike Portal-returned URLs
it is intentionally NOT gated by the network host allowlist.
"""
from hermes_cli.auth import _optional_base_url
return _optional_base_url(os.getenv("NOUS_INFERENCE_BASE_URL"))
def _nous_portal_env_override() -> Optional[str]:
"""``HERMES_PORTAL_BASE_URL`` / ``NOUS_PORTAL_BASE_URL`` override or None.
Documented dev/staging escape hatch (e.g. hosted agents on the staging Portal). Trusted env
source: must NOT be gated by ``_NOUS_PORTAL_ALLOWED_HOSTS``, which rejects untrusted
NETWORK-provided values persisted to auth.json, not operator config.
"""
from hermes_cli.auth import _optional_base_url
return _optional_base_url(
os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL"))
def _scope_values(raw_scope: Any) -> set[str]:
# OAuth token responses return a space-separated string; collections are kept for JWT ``scp``
# claims and older stored fixtures.
scopes: set[str] = set()
if isinstance(raw_scope, str):
scopes.update(part for part in raw_scope.replace(",", " ").split() if part.strip())
elif isinstance(raw_scope, (list, tuple, set, frozenset)):
scopes.update(*(_scope_values(item) for item in raw_scope if isinstance(item, str)))
return scopes
def _nous_invoke_jwt_status(
token: Any, *, scope: Any = None, expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS) -> Optional[str]:
"""Return None when the token can be used for inference, else a reason."""
from hermes_cli.auth import _is_expiring
claims = _decode_jwt_claims(token)
if not claims:
return "access_token_not_jwt"
scopes = (_scope_values(scope) | _scope_values(claims.get("scope"))
| _scope_values(claims.get("scp")))
if NOUS_INFERENCE_INVOKE_SCOPE not in scopes:
return "missing_inference_invoke_scope"
exp = claims.get("exp")
skew = max(0, int(min_ttl_seconds))
if isinstance(exp, (int, float)):
return "invoke_jwt_expiring" if float(exp) <= (time.time() + skew) else None
return "invoke_jwt_expiry_unknown_or_expiring" if _is_expiring(expires_at, skew) else None
def _nous_invoke_jwt_is_usable(
token: Any, *, scope: Any = None, expires_at: Any = None,
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS) -> bool:
from hermes_cli.auth import _nous_invoke_jwt_status
return _nous_invoke_jwt_status(
token, scope=scope, expires_at=expires_at, min_ttl_seconds=min_ttl_seconds) is None
def _state_invoke_jwt_status(state: Dict[str, Any], token: Any) -> Optional[str]:
"""``_nous_invoke_jwt_status`` for *token* using *state*'s scope / expires_at (patchable)."""
from hermes_cli.auth import _nous_invoke_jwt_status
return _nous_invoke_jwt_status(
token, scope=state.get("scope"), expires_at=state.get("expires_at"))
def _assert_nous_inference_jwt_usable(state: Dict[str, Any], *, access_token: Any = None) -> None:
token = state.get("access_token") if access_token is None else access_token
reason = _state_invoke_jwt_status(state, token)
if reason is not None:
raise _unusable_invoke_jwt_error(reason)
def _remaining_ttl(expires_at: Any, fallback_expires_in: Any) -> int:
"""Seconds until *expires_at* (ISO), else *fallback_expires_in* coerced to a TTL."""
from hermes_cli.auth import _coerce_ttl_seconds, _parse_iso_timestamp
expires_epoch = _parse_iso_timestamp(expires_at)
if expires_epoch is not None:
return max(0, int(expires_epoch - time.time()))
return _coerce_ttl_seconds(fallback_expires_in)
def _nous_jwt_expires_at(token: Any, fallback_expires_at: Any = None) -> Optional[str]:
claims = _decode_jwt_claims(token)
exp = claims.get("exp")
if isinstance(exp, (int, float)):
with suppress(Exception):
return datetime.fromtimestamp(float(exp), tz=timezone.utc).isoformat()
return fallback_expires_at if isinstance(fallback_expires_at, str) else None
def _set_nous_agent_key_from_invoke_jwt(
state: Dict[str, Any], *, obtained_at: Optional[str] = None) -> None:
from hermes_cli.auth import _nonempty_str
access_token = state.get("access_token")
if not _nonempty_str(access_token):
return
existing_obtained_at = state.get("agent_key_obtained_at")
if not obtained_at:
reuse = state.get("agent_key") == access_token and _nonempty_str(existing_obtained_at)
obtained_at = existing_obtained_at if reuse else datetime.now(timezone.utc).isoformat()
expires_at = _nous_jwt_expires_at(access_token, state.get("expires_at"))
expires_in = _remaining_ttl(expires_at, state.get("expires_in"))
if expires_at:
state["expires_at"] = expires_at
state["expires_in"] = expires_in
state.update(
agent_key=access_token, agent_key_id=None, agent_key_expires_at=expires_at,
agent_key_expires_in=expires_in, agent_key_reused=False, agent_key_obtained_at=obtained_at)
def _select_nous_invoke_jwt(
state: Dict[str, Any], *, access_token: Any = None, sequence_id: Optional[str] = None) -> None:
from hermes_cli.auth import _nonempty_str
if _nonempty_str(access_token):
state["access_token"] = access_token
_set_nous_agent_key_from_invoke_jwt(state)
logger.debug("Nous inference auth: using NAS invoke JWT")
_oauth_trace(
"nous_invoke_jwt_selected", sequence_id=sequence_id,
access_token_fp=_token_fingerprint(state.get("access_token")))
# Derived from expires_at/JWT exp and tick down between reads; persisting only these changes makes
# auth.json noisy and defeats the mtime-keyed auth-status cache.
_NOUS_EFFECTIVE_STATE_IGNORED_KEYS = frozenset({"expires_in", "agent_key_expires_in"})
def _nous_effective_provider_state(state: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in state.items() if k not in _NOUS_EFFECTIVE_STATE_IGNORED_KEYS}
NOUS_SHARED_STORE_FILENAME = "nous_auth.json"
_nous_shared_lock_holder = threading.local()
def _nous_shared_auth_dir() -> Path:
"""Directory of the shared Nous token store: ``HERMES_SHARED_AUTH_DIR`` or ``<root>/shared/``.
Outside any named profile so all profiles share it (``hermes --profile X auth add nous --type
oauth`` one-tap imports it). Written on login AND every runtime refresh so the refresh_token
stays current across profiles; a stale token just falls back to device-code.
"""
override = os.getenv("HERMES_SHARED_AUTH_DIR", "").strip()
if override:
return Path(override).expanduser()
from hermes_constants import get_default_hermes_root
return get_default_hermes_root() / "shared"
def _nous_shared_store_path() -> Path:
path = _nous_shared_auth_dir() / NOUS_SHARED_STORE_FILENAME
# Seat belt (mirrors the _auth_file_path() guard): under pytest, refuse a path under the real
# user's Hermes root so a test that forgot HERMES_SHARED_AUTH_DIR fails loudly instead of
# corrupting cross-profile state.
if os.environ.get("PYTEST_CURRENT_TEST"):
from hermes_constants import get_default_hermes_root
real_home_shared = (
get_default_hermes_root() / "shared" / NOUS_SHARED_STORE_FILENAME).resolve(strict=False)
try:
resolved = path.resolve(strict=False)
except Exception:
resolved = path
if resolved == real_home_shared:
raise RuntimeError(
f"Refusing to touch real user shared Nous auth store during test run: "
f"{path}. Set HERMES_SHARED_AUTH_DIR to a tmp_path in your test fixture.")
return path
@contextmanager
def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS):
"""Cross-profile lock for the shared Nous OAuth store.
Lock ordering invariant: if both this and ``_auth_store_lock`` need to be held, acquire
``_auth_store_lock`` FIRST. All runtime refresh paths follow this order.
"""
from hermes_cli.auth import _file_lock
try:
lock_path = _nous_shared_store_path().with_suffix(".lock")
except RuntimeError:
yield # No HERMES_HOME yet (pre-setup): fall through without locking.
return
with _file_lock(
lock_path, _nous_shared_lock_holder, timeout_seconds,
"Timed out waiting for shared Nous auth lock"):
yield
def _shared_lock_timeout(timeout_seconds: float) -> float:
return max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)
# OAuth fields mirrored between a profile's Nous state and the shared cross-profile store.
_NOUS_SHARED_STATE_KEYS = (
"access_token", "refresh_token", "token_type", "scope", "client_id", "portal_base_url",
"inference_base_url", "obtained_at", "expires_at",
# Guest (``auth_method: anonymous``) identity: the ``anon_`` credential is the refresh material.
"auth_method", "account_tier", "anon_token", "user_id", "org_id")
def _merge_shared_nous_oauth_state(state: Dict[str, Any]) -> bool:
"""Copy fresher shared OAuth tokens into a profile-local Nous state."""
from hermes_cli.auth import _nonempty_str, _parse_iso_timestamp, _read_shared_nous_state
shared = _read_shared_nous_state() or {}
shared_refresh = shared.get("refresh_token")
if not _nonempty_str(shared_refresh):
return False # a free-tier identity has no refresh token; nothing to merge into an OAuth state
shared_access_exp = _parse_iso_timestamp(shared.get("expires_at")) or 0.0
local_access_exp = _parse_iso_timestamp(state.get("expires_at")) or 0.0
refresh_changed = shared_refresh.strip() != str(state.get("refresh_token") or "").strip()
if not refresh_changed and not shared_access_exp > local_access_exp:
return False
for key in _NOUS_SHARED_STATE_KEYS:
value = shared.get(key)
if value not in {None, ""}:
state[key] = value
return True
def _nous_shared_shape(src: Dict[str, Any]) -> Dict[str, Any]:
"""The defaulted OAuth core (tokens + routing + expiry) shared across profiles."""
return {
"access_token": src.get("access_token"), "refresh_token": src.get("refresh_token"),
"token_type": src.get("token_type") or "Bearer",
"scope": src.get("scope") or DEFAULT_NOUS_SCOPE,
"client_id": src.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": src.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL,
# A guest's route defaults to the welcome host: the paid host cross-refuses its JWT.
"inference_base_url": src.get("inference_base_url") or (
DEFAULT_NOUS_WELCOME_URL if src.get("auth_method") == "anonymous" else DEFAULT_NOUS_INFERENCE_URL),
"obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at"),
**{k: src[k] for k in ("auth_method", "account_tier", "anon_token", "user_id", "org_id")
if src.get(k) not in (None, "")}}
def _write_shared_nous_state(state: Dict[str, Any]) -> None:
"""Persist a minimal copy of the Nous OAuth state to the shared store.
Best-effort: failures are logged and swallowed; per-profile auth.json stays the source of truth.
"""
from hermes_cli.auth import _nonempty_str, _write_private_file_atomic
refresh_token = state.get("refresh_token")
# Nothing worth sharing without refresh material: an OAuth refresh_token (with its access token),
# or a guest's anon_ credential, which is the whole identity and may not have been exchanged yet.
is_guest = _nonempty_str(state.get("anon_token"))
if not is_guest and not (_nonempty_str(refresh_token) and _nonempty_str(state.get("access_token"))):
return
shared = {
"_schema": 1, **_nous_shared_shape(state),
"updated_at": datetime.now(timezone.utc).isoformat()}
try:
with _nous_shared_store_lock():
path = _nous_shared_store_path()
_write_private_file_atomic(
path, json.dumps(shared, indent=2, sort_keys=True), replace=os.replace)
_oauth_trace(
"nous_shared_store_written", path=str(path),
refresh_token_fp=_token_fingerprint(refresh_token))
except Exception as exc:
logger.debug("Failed to write shared Nous auth store: %s", exc)
def _read_shared_nous_state() -> Optional[Dict[str, Any]]:
"""Shared Nous OAuth state when present and well-formed, else None.
None (missing / unreadable / malformed / lacking tokens) means "fall through to device-code".
"""
from hermes_cli.auth import _nonempty_str
try:
path = _nous_shared_store_path()
except RuntimeError:
return None # Test seat belt tripped — treat as missing
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as exc:
logger.debug("Shared Nous auth store at %s is unreadable: %s", path, exc)
return None
if not isinstance(payload, dict):
return None
has_tokens = _nonempty_str(payload.get("anon_token")) or (
_nonempty_str(payload.get("access_token")) and _nonempty_str(payload.get("refresh_token")))
return payload if has_tokens else None
def _clear_shared_nous_state(reason: str) -> None:
"""Remove the shared Nous OAuth store after a terminal token failure."""
try:
with _nous_shared_store_lock():
_nous_shared_store_path().unlink(missing_ok=True)
_oauth_trace("nous_shared_store_cleared", reason=reason)
except Exception as exc:
logger.debug("Failed to clear shared Nous auth store: %s", exc)
def _quarantine_forensics(state: Dict[str, Any], error: AuthError, reason: str) -> Dict[str, Any]:
"""Redaction-safe forensic record for a quarantine: fingerprints, sizes and booleans only.
NEVER include a raw token/agent_key (credential-shaped literals get corrupted in logs). The
12-char SHA-256 prefix correlates to NAS's refreshTokenHash without leaking the secret;
provenance is client_id + agent_key_id (Nous state has no session_id).
"""
from hermes_cli.auth import _auth_file_path
forensic: Dict[str, Any] = {
"reason": reason, "error_code": error.code, "client_id": state.get("client_id"),
"agent_key_id": state.get("agent_key_id"),
"refresh_token_fp": _token_fingerprint(state.get("refresh_token"))}
# On-disk integrity of the auth store at the moment of quarantine.
try:
auth_path = _auth_file_path()
forensic["auth_json_path"] = str(auth_path)
try:
st = os.stat(auth_path)
forensic.update(
auth_json_size=st.st_size, auth_json_mtime=st.st_mtime, auth_json_exists=True)
except FileNotFoundError:
forensic["auth_json_exists"] = False
except Exception as exc: # pragma: no cover - never let logging break quarantine
forensic["auth_json_stat_error"] = repr(exc)
# Was the token already past its own expiry when it was rejected?
already_expired: Optional[bool] = None
expires_at_raw = state.get("expires_at")
if isinstance(expires_at_raw, str) and expires_at_raw:
try:
parsed = datetime.fromisoformat(expires_at_raw)
already_expired = (parsed.replace(tzinfo=parsed.tzinfo or timezone.utc)
< datetime.now(timezone.utc))
except ValueError:
already_expired = None
forensic["token_already_expired"] = already_expired
return forensic
def _quarantine_nous_oauth_state(state: Dict[str, Any], error: AuthError, *, reason: str) -> None:
"""Keep routing metadata but remove dead OAuth material so it is not replayed.
Only for terminal errors (``*_refresh_failed`` = HTTP 400/401/403 invalid_grant / revoked /
refresh_token_reused; ``*_auth_missing_refresh_token``), all ``relogin_required=True`` —
transient 429/5xx never quarantine.
"""
from hermes_cli.auth import (
_FLAT_OAUTH_TOKEN_KEYS, _last_auth_error_marker, invalidate_nous_auth_status_cache)
# Forensics BEFORE clearing token material: a hosted agent quarantined here is otherwise only
# visible as a later "No access token found" WARNING, too late to root-cause. Managed log
# drains may be WARNING-only, so this MUST be logger.warning.
logger.warning(
"Nous OAuth state quarantined (terminal auth death): %s",
json.dumps(_quarantine_forensics(state, error, reason), sort_keys=True, ensure_ascii=False))
for key in (*_FLAT_OAUTH_TOKEN_KEYS, *_NOUS_EMPTY_AGENT_KEY_FIELDS):
state.pop(key, None)
state["last_auth_error"] = _last_auth_error_marker("nous", error, reason=reason)
_clear_shared_nous_state(reason)
invalidate_nous_auth_status_cache()
def _quarantine_nous_pool_entries(
auth_store: Dict[str, Any], error: AuthError, *, reason: str) -> bool:
"""Remove singleton-seeded Nous pool entries that contain dead OAuth state."""
entries = _pool_entries(auth_store, "nous")
if entries is None:
return False
singleton_sources = {NOUS_DEVICE_CODE_SOURCE, f"manual:{NOUS_DEVICE_CODE_SOURCE}"}
retained = [
e for e in entries if not (isinstance(e, dict) and e.get("source") in singleton_sources)]
removed = len(retained) != len(entries)
if removed:
auth_store["credential_pool"]["nous"] = retained
_oauth_trace("nous_pool_device_code_quarantined", reason=reason, error_code=error.code)
return removed
def _try_import_shared_nous_state(*, timeout_seconds: float = 15.0) -> Optional[Dict[str, Any]]:
"""Rehydrate Nous OAuth state from the shared store via a forced refresh.
Returns auth_state ready for ``persist_nous_credentials()``; None on any failure (expired
token, portal unreachable) so the caller falls through to device-code.
"""
from hermes_cli.auth import (
_read_shared_nous_state, _write_shared_nous_state, refresh_nous_oauth_from_state,
_is_terminal_nous_refresh_error)
try:
with _nous_shared_store_lock(timeout_seconds=_shared_lock_timeout(timeout_seconds)):
shared = _read_shared_nous_state()
if not shared:
return None
# Full state dict so refresh_nous_oauth_from_state has every field it needs.
state: Dict[str, Any] = {
**_nous_shared_shape(shared), "agent_key": None, "agent_key_expires_at": None,
"tls": {"insecure": False, "ca_bundle": None}}
refreshed = refresh_nous_oauth_from_state(
state, timeout_seconds=timeout_seconds, force_refresh=True,
on_state_update=lambda updated, _reason: _write_shared_nous_state(updated))
_write_shared_nous_state(refreshed)
except Exception as exc:
is_auth = isinstance(exc, AuthError)
_oauth_trace(
"nous_shared_import_failed", error_type=type(exc).__name__,
**({"error_code": getattr(exc, "code", None)} if is_auth else {}))
if is_auth and _is_terminal_nous_refresh_error(exc):
_clear_shared_nous_state("shared_import_terminal_refresh_failure")
logger.debug("Shared Nous import failed: %s", exc)
return None
return refreshed
def _refresh_access_token(
*, client: httpx.Client, portal_base_url: str, client_id: str, refresh_token: str,
) -> Dict[str, Any]:
response = client.post(
f"{portal_base_url}/api/oauth/token",
headers={"x-nous-refresh-token": refresh_token},
data={"grant_type": "refresh_token", "client_id": client_id})
if response.status_code == 200:
payload = response.json()
if "access_token" not in payload:
raise _nous_err("Refresh response missing access_token", "invalid_token", relogin=True)
return payload
try:
error_payload = response.json()
except Exception as exc:
raise _nous_err("Refresh token exchange failed", relogin=True) from exc
code = str(error_payload.get("error", "invalid_grant"))
description = str(error_payload.get("error_description") or "Refresh token exchange failed")
relogin = code in {"invalid_grant", "invalid_token", "refresh_token_reused"}
# OAuth 2.1 "refresh token reuse": an external process (health check, monitoring tool, custom
# self-heal hook) redeemed Hermes's refresh_token without persisting the rotated token, so the
# server retired the original and revoked the whole session chain as a token-theft signal.
if code == "refresh_token_reused" or "reuse" in description.lower():
description = (
"Nous Portal detected refresh-token reuse and revoked this session.\n"
"This usually means an external process (monitoring script, "
"custom self-heal hook, or another Hermes install sharing "
"~/.hermes/auth.json) called POST /api/oauth/token with Hermes's "
"refresh token without persisting the rotated token back.\n"
"Nous refresh tokens are single-use — only Hermes may call the "
"refresh endpoint. For health checks, use `hermes auth status` "
"instead.\n"
"Re-authenticate with: hermes auth add nous")
relogin = True
raise _nous_err(description, code, relogin=relogin)
def _refresh_nous_or_quarantine(
*, client: httpx.Client, auth_store: Dict[str, Any], state: Dict[str, Any],
portal_base_url: str, client_id: str, refresh_token: str, reason: str,
persist: Callable[[], None]) -> Dict[str, Any]:
"""Redeem the refresh token; on terminal failure quarantine state + pool, persist, re-raise."""
from hermes_cli.auth import _refresh_access_token, _is_terminal_nous_refresh_error
try:
return _refresh_access_token(
client=client, portal_base_url=portal_base_url, client_id=client_id,
refresh_token=refresh_token)
except AuthError as exc:
if _is_terminal_nous_refresh_error(exc):
_quarantine_nous_oauth_state(state, exc, reason=reason)
_quarantine_nous_pool_entries(auth_store, exc, reason=reason)
persist()
raise
def _apply_nous_refreshed_tokens(
state: Dict[str, Any], refreshed: Dict[str, Any], refresh_token: str, *,
inference_base_url: Optional[str] = None) -> None:
"""Write a successful Nous token-refresh payload into *state* (tokens + expiry fields).
*inference_base_url*, when given, is the healed network-provenance URL to persist alongside
the rotated tokens (key order in auth.json is preserved from the original login shape).
"""
from hermes_cli.auth import _coerce_ttl_seconds
now = datetime.now(timezone.utc)
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
state["access_token"] = refreshed["access_token"]
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
state["scope"] = refreshed.get("scope") or state.get("scope")
if inference_base_url is not None:
state["inference_base_url"] = inference_base_url
state["obtained_at"] = now.isoformat()
state["expires_in"] = access_ttl
state["expires_at"] = _iso_after(now, access_ttl)
def _healed_nous_inference_url(refreshed: Dict[str, Any]) -> str:
"""Validated network-provenance inference URL from a refresh payload, healed to the default.
A Portal URL rejected by the allowlist resets to the production default instead of leaving a
previously-persisted bad host (e.g. a stale staging URL) in place — otherwise a poisoned
auth.json re-validates to None on every refresh and silently re-uses the dead endpoint.
"""
url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
return url or DEFAULT_NOUS_INFERENCE_URL
def _nous_http_client(timeout_seconds: float, verify: Any) -> httpx.Client:
return httpx.Client(
timeout=httpx.Timeout(timeout_seconds), headers={"Accept": "application/json"},
verify=verify)
def _model_priority(mid: str) -> tuple:
"""Sort key: opus > pro > haiku/flash > sonnet (sonnet is cheap/fast; best model first)."""
low = mid.lower()
rank = (0 if "opus" in low else 1 if "pro" in low and "sonnet" not in low
else 3 if "sonnet" in low else 2)
return (rank, mid)
def fetch_nous_models(
*, inference_base_url: str, api_key: str, timeout_seconds: float = 15.0,
verify: bool | str = True) -> List[str]:
"""Fetch available model IDs from the Nous inference API."""
from hermes_cli.auth import _nonempty_str
with _nous_http_client(timeout_seconds, verify) as client:
response = client.get(
f"{inference_base_url.rstrip('/')}/models",
headers={"Authorization": f"Bearer {api_key}"})
if response.status_code != 200:
description = f"/models request failed with status {response.status_code}"
try:
err = response.json()
description = str(err.get("error_description") or err.get("error") or description)
except Exception as e:
logger.debug("Could not parse error response JSON: %s", e)
raise _nous_err(description, "models_fetch_failed")
data = response.json().get("data")
if not isinstance(data, list):
return []
model_ids: List[str] = []
for item in data:
model_id = item.get("id") if isinstance(item, dict) else None
# Hermes models aren't reliable for agentic tool-calling
if _nonempty_str(model_id) and "hermes" not in model_id.lower():
model_ids.append(model_id.strip())
model_ids.sort(key=_model_priority)
return list(dict.fromkeys(model_ids))
def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
from hermes_cli.auth import _nonempty_str
key = state.get("agent_key")
return _nonempty_str(key) and _nous_invoke_jwt_is_usable(
key, scope=state.get("scope"), expires_at=state.get("agent_key_expires_at"),
min_ttl_seconds=max(0, int(min_ttl_seconds)))
def refresh_nous_oauth_pure(
access_token: str, refresh_token: str, client_id: str, portal_base_url: str,
inference_base_url: str, *, token_type: str = "Bearer", scope: str = DEFAULT_NOUS_SCOPE,
obtained_at: Optional[str] = None, expires_at: Optional[str] = None,
agent_key: Optional[str] = None, agent_key_expires_at: Optional[str] = None,
timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None,
force_refresh: bool = False,
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None) -> Dict[str, Any]:
"""Refresh Nous OAuth state without mutating auth.json directly.
``on_state_update`` fires after a successful access-token refresh so callers owning persistent
state can save the rotated refresh token before later validation can fail.
"""
return refresh_nous_oauth_from_state(
{
"access_token": access_token, "refresh_token": refresh_token, "client_id": client_id,
"portal_base_url": portal_base_url, "inference_base_url": inference_base_url,
"token_type": token_type, "scope": scope, "obtained_at": obtained_at,
"expires_at": expires_at, "agent_key": agent_key,
"agent_key_expires_at": agent_key_expires_at,
"tls": {"insecure": insecure, "ca_bundle": ca_bundle}},
timeout_seconds=timeout_seconds, force_refresh=force_refresh,
on_state_update=on_state_update)
def refresh_nous_oauth_from_state(
src: Dict[str, Any], *, timeout_seconds: float = 15.0, force_refresh: bool = False,
on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None) -> Dict[str, Any]:
"""Refresh Nous OAuth from a state dict (defaults filled in) without mutating auth.json."""
from hermes_cli.auth import (
_assert_nous_inference_jwt_usable, _refresh_access_token, _resolve_verify,
_select_nous_invoke_jwt)
tls = src.get("tls") or {}
insecure, ca_bundle = tls.get("insecure"), tls.get("ca_bundle")
state: Dict[str, Any] = {
"access_token": src.get("access_token", ""), "refresh_token": src.get("refresh_token", ""),
"client_id": src.get("client_id") or DEFAULT_NOUS_CLIENT_ID,
"portal_base_url": (src.get("portal_base_url") or DEFAULT_NOUS_PORTAL_URL).rstrip("/"),
"inference_base_url": (
src.get("inference_base_url") or DEFAULT_NOUS_INFERENCE_URL).rstrip("/"),
"token_type": src.get("token_type") or "Bearer",
"scope": src.get("scope") or DEFAULT_NOUS_SCOPE,
"obtained_at": src.get("obtained_at"), "expires_at": src.get("expires_at"),
"agent_key": src.get("agent_key"), "agent_key_expires_at": src.get("agent_key_expires_at"),
"tls": {"insecure": bool(insecure), "ca_bundle": ca_bundle}}
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
with _nous_http_client(timeout_seconds or 15.0, verify) as client:
current_invoke_jwt_status = _state_invoke_jwt_status(state, state.get("access_token"))
if force_refresh or current_invoke_jwt_status is not None:
refresh_token_value = state.get("refresh_token")
if not isinstance(refresh_token_value, str) or not refresh_token_value:
if current_invoke_jwt_status is not None:
raise _unusable_invoke_jwt_error(
current_invoke_jwt_status, no_refresh_token=True)
raise _nous_err("No refresh token is available for Nous Portal.", relogin=True)
refreshed = _refresh_access_token(
client=client, portal_base_url=state["portal_base_url"],
client_id=state["client_id"], refresh_token=refresh_token_value)
_apply_nous_refreshed_tokens(
state, refreshed, refresh_token_value,
inference_base_url=_healed_nous_inference_url(refreshed))
if on_state_update is not None:
on_state_update(dict(state), "post_refresh_access_token")
_assert_nous_inference_jwt_usable(state)
_select_nous_invoke_jwt(state)
return state
def persist_nous_credentials(creds: Dict[str, Any], *, label: Optional[str] = None):
"""Persist Nous OAuth credentials as the singleton provider state.
Nous credentials are read from ``providers.nous`` (401 recovery, pool seeding) AND
``credential_pool.nous`` (runtime ``pool.select()``); a pool-only write broke expiry recovery.
So: write the singleton, mirror to the shared store, then ``load_pool("nous")`` upserts the
canonical ``device_code`` entry in place. ``label`` rides in the singleton so re-seeding keeps
it.
"""
from hermes_cli.auth import _save_active_provider_state, _write_shared_nous_state
from agent.credential_pool import load_pool
state = dict(creds)
if label and str(label).strip():
state["label"] = str(label).strip()
_save_active_provider_state("nous", state)
_write_shared_nous_state(state)
pool = load_pool("nous")
return next((e for e in pool.entries() if e.source == NOUS_DEVICE_CODE_SOURCE), None)
def _sync_nous_pool_from_auth_store() -> None:
"""Best-effort pool reseed after providers.nous changes; never fail login."""
try:
from agent.credential_pool import load_pool
load_pool("nous")
except Exception as exc:
logger.debug("Failed to sync Nous credential pool from auth store: %s", exc)
def _nous_effective_routing(state: Dict[str, Any]) -> tuple[str, str, str, str]:
"""``(portal_url, stored_inference_url, effective_inference_url, client_id)`` from *state*.
The stored inference URL is re-validated network-provenance (persisted); the effective one
layers the runtime-only ``NOUS_INFERENCE_BASE_URL`` override on top and is never persisted.
"""
from hermes_cli.auth import _NOUS_PORTAL_ALLOWED_HOSTS, _optional_base_url
portal_url = (
_optional_base_url(state.get("portal_base_url")) or os.getenv("HERMES_PORTAL_BASE_URL")
or os.getenv("NOUS_PORTAL_BASE_URL") or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
# A persisted/stale portal_base_url is where the refresh token gets POSTed — reject any host
# outside the allowlist so a poisoned value can't exfiltrate the bearer, healing to the
# default. Trusted operator env overrides bypass this network-value gate.
env_portal_override = _nous_portal_env_override()
if env_portal_override:
portal_url = env_portal_override.rstrip("/")
else:
parsed_portal_url = urlparse(portal_url)
portal_host, scheme = parsed_portal_url.hostname, parsed_portal_url.scheme
trusted_scheme = scheme == "https" or (
scheme == "http" and portal_host in {"localhost", "127.0.0.1"})
if not portal_host or portal_host not in _NOUS_PORTAL_ALLOWED_HOSTS or not trusted_scheme:
logger.warning(
"auth: ignoring invalid portal_base_url %r "
"(host %r or scheme not allowed), using default",
portal_url, portal_host)
portal_url = DEFAULT_NOUS_PORTAL_URL
# A guest never falls back to the paid host: the gateway cross-refuses an anonymous JWT there
# (400 naming the welcome host), so an absent or disallowed URL heals to the welcome literal.
from hermes_cli.anon_auth import is_guest_state
stored_inference_url = (
_validate_nous_inference_url_from_network(
_optional_base_url(state.get("inference_base_url")))
or (DEFAULT_NOUS_WELCOME_URL if is_guest_state(state) else DEFAULT_NOUS_INFERENCE_URL))
return (
portal_url, stored_inference_url, _nous_inference_env_override() or stored_inference_url,
str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID))
class _NousRuntimeResolve:
"""Working set for one ``resolve_nous_runtime_credentials`` call.
Holds the token pair + routing tuple that shared-store merges / refreshes replace mid-flight.
``persist`` skips writes where only derived TTL countdowns changed (keeps the mtime-keyed
auth-status cache warm) and mirrors every real write to the shared store (best-effort).
"""
def __init__(
self, auth_store: Dict[str, Any], state: Dict[str, Any], state_source_path: Optional[Path],
*, force_refresh: bool, stale_access_token: Optional[str], timeout_seconds: float) -> None:
self.auth_store, self.state, self._source_path = auth_store, state, state_source_path
self.force_refresh, self.stale_access_token = force_refresh, stale_access_token
self.timeout_seconds = timeout_seconds
self.sequence_id = uuid.uuid4().hex[:12]
self._persisted_state = dict(state)
self.persisted_any = False
self.access_token = state.get("access_token")
self.refresh_token = state.get("refresh_token")
self._reload_routing()
def _reload_routing(self) -> None:
(self.portal_base_url, self.stored_inference_base_url, self.inference_base_url,
self.client_id) = _nous_effective_routing(self.state)
def persist(self, reason: str) -> None:
from hermes_cli.auth import _save_provider_state_to_source, _write_shared_nous_state
state = self.state
persisted = _nous_effective_provider_state(self._persisted_state)
if _nous_effective_provider_state(state) == persisted:
_oauth_trace("nous_state_persist_skipped", sequence_id=self.sequence_id, reason=reason)
return
try:
_save_provider_state_to_source(self.auth_store, "nous", state, self._source_path)
except Exception as exc:
_oauth_trace(
"nous_state_persist_failed", sequence_id=self.sequence_id, reason=reason,
error_type=type(exc).__name__)
raise
_oauth_trace(
"nous_state_persisted", sequence_id=self.sequence_id, reason=reason,
refresh_token_fp=_token_fingerprint(state.get("refresh_token")),
access_token_fp=_token_fingerprint(state.get("access_token")))
self._persisted_state = dict(state)
self.persisted_any = True
_write_shared_nous_state(state)
def shared_lock(self):
return _nous_shared_store_lock(timeout_seconds=_shared_lock_timeout(self.timeout_seconds))
def has_access_token(self) -> bool:
return isinstance(self.access_token, str) and bool(self.access_token)
def invoke_jwt_status(self) -> Optional[str]:
return _state_invoke_jwt_status(self.state, self.access_token)
def merge_shared(self) -> bool:
"""Adopt fresher shared-store tokens (caller holds the shared lock). True when merged."""
if not _merge_shared_nous_oauth_state(self.state):
return False
self.access_token = self.state.get("access_token")
self.refresh_token = self.state.get("refresh_token")
self._reload_routing()
return True
def skip_refresh_if_peer_rotated(self) -> None:
"""Skip the refresh when a peer already rotated the grant.
Under the store lock: if the bearer that failed upstream is no longer the one on disk and
the on-disk one is usable, adopt it — never re-POST the shared grant.
"""
token = self.access_token
if (self.force_refresh and self.stale_access_token and isinstance(token, str) and token
and token != self.stale_access_token and self.invoke_jwt_status() is None):
_oauth_trace(
"refresh_skipped_peer_rotated", sequence_id=self.sequence_id,
access_token_fp=_token_fingerprint(token))
self.force_refresh = False
def refresh(self, client: httpx.Client, invoke_jwt_status: Optional[str]) -> None:
"""Redeem the refresh token, apply + persist the rotated pair (caller holds both locks)."""
if not isinstance(self.refresh_token, str) or not self.refresh_token:
raise _unusable_invoke_jwt_error(
invoke_jwt_status or "force_refresh", no_refresh_token=True)
refresh_reason = (
"force_refresh" if self.force_refresh else (invoke_jwt_status or "access_unusable"))
_oauth_trace(
"refresh_start", sequence_id=self.sequence_id, reason=refresh_reason,
refresh_token_fp=_token_fingerprint(self.refresh_token))
refreshed = _refresh_nous_or_quarantine(
client=client, auth_store=self.auth_store, state=self.state,
portal_base_url=self.portal_base_url, client_id=self.client_id,
refresh_token=self.refresh_token, reason="runtime_access_refresh_failure",
persist=lambda: self.persist("terminal_runtime_access_refresh_failure"))
previous_refresh_token = self.refresh_token
# The validated, network-provenance URL is what gets persisted (with the rotated tokens,
# so a later JWT validation failure cannot leave the stores on stale metadata). The
# NOUS_INFERENCE_BASE_URL env override is layered on for the client/return value only.
self.stored_inference_base_url = _healed_nous_inference_url(refreshed)
self.inference_base_url = _nous_inference_env_override() or self.stored_inference_base_url
_apply_nous_refreshed_tokens(
self.state, refreshed, self.refresh_token,
inference_base_url=self.stored_inference_base_url)
self.access_token = self.state["access_token"]
self.refresh_token = self.state["refresh_token"]
_oauth_trace(
"refresh_success", sequence_id=self.sequence_id, reason=refresh_reason,
previous_refresh_token_fp=_token_fingerprint(previous_refresh_token),
new_refresh_token_fp=_token_fingerprint(self.refresh_token))
# Persist immediately so validation failures cannot drop rotated refresh tokens.
self.persist("post_refresh_access_token")
def ensure_usable_access_token(self, client: httpx.Client) -> None:
"""Merge from the shared store / refresh until the access token is a usable invoke JWT."""
from hermes_cli.anon_auth import is_guest_state, refresh_guest_state
if is_guest_state(self.state):
# 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)
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
self.persist("guest_exchange")
return
if not self.has_access_token():
with self.shared_lock():
if self.merge_shared():
self.persist("runtime_shared_merge_missing_access_token")
if not self.has_access_token():
raise _nous_err("No access token found for Nous Portal login.", relogin=True)
invoke_jwt_status = self.invoke_jwt_status()
self.skip_refresh_if_peer_rotated()
if not (self.force_refresh or invoke_jwt_status is not None):
return
with self.shared_lock():
if self.merge_shared():
invoke_jwt_status = self.invoke_jwt_status()
self.persist("post_shared_merge_access_unusable")
self.skip_refresh_if_peer_rotated()
if self.force_refresh or invoke_jwt_status is not None:
self.refresh(client, invoke_jwt_status)
def resolve_nous_runtime_credentials(
*, timeout_seconds: float = 15.0, insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None, force_refresh: bool = False,
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.
"""
from hermes_cli.anon_auth import AnonCredentialDead, clear_dead_guest, ensure_portal_identity
try:
return _resolve_nous_runtime_credentials(
timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle,
force_refresh=force_refresh, stale_access_token=stale_access_token)
except AnonCredentialDead:
from hermes_cli.auth import get_provider_auth_state
dead = get_provider_auth_state("nous") or {}
clear_dead_guest("anon_credential_dead", dead_token=dead.get("anon_token"))
if ensure_portal_identity(explicit=True, timeout_seconds=timeout_seconds) is None:
raise
return _resolve_nous_runtime_credentials(
timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle)
def _resolve_nous_runtime_credentials(
*, timeout_seconds: float = 15.0, insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None, force_refresh: bool = False,
stale_access_token: Optional[str] = None) -> Dict[str, Any]:
"""Resolve Nous inference credentials for runtime use (refreshing under the auth-store lock).
``stale_access_token`` is the bearer that just failed upstream (401): with ``force_refresh``,
the refresh POST is skipped if the store (re-read under the lock) already holds a *different*
usable token — a peer won the rotation; adopt it rather than invalidate a sibling's token.
"""
from hermes_cli.auth import (
_assert_nous_inference_jwt_usable, _auth_file_path, _provider_state_transaction,
_resolve_verify, _select_nous_invoke_jwt, _sync_nous_pool_from_auth_store,
_tls_state_from_verify)
with _provider_state_transaction("nous") as (auth_store, state, state_source_path):
if not state:
raise _nous_err("Hermes is not logged into Nous Portal.", relogin=True)
run = _NousRuntimeResolve(
auth_store, state, state_source_path, force_refresh=force_refresh,
stale_access_token=stale_access_token, timeout_seconds=timeout_seconds)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
_oauth_trace(
"nous_runtime_credentials_start", sequence_id=run.sequence_id,
refresh_token_fp=_token_fingerprint(state.get("refresh_token")))
with _nous_http_client(timeout_seconds or 15.0, verify) as client:
run.ensure_usable_access_token(client)
_assert_nous_inference_jwt_usable(state, access_token=run.access_token)
_select_nous_invoke_jwt(
state, access_token=run.access_token, sequence_id=run.sequence_id)
# Persist routing and TLS metadata for non-interactive refresh — the validated,
# network-provenance URL, NEVER the env override (a runtime-only overlay; persisting
# it would leak a dev/staging host into auth.json and survive unsetting it).
state.update(
portal_base_url=run.portal_base_url, client_id=run.client_id,
inference_base_url=run.stored_inference_base_url,
tls=_tls_state_from_verify(verify))
run.persist("resolve_nous_runtime_credentials_final")
if run.persisted_any:
_sync_nous_pool_from_auth_store()
api_key = state.get("agent_key")
if not isinstance(api_key, str) or not api_key:
raise _nous_err("Failed to resolve a Nous inference API key", "server_error")
expires_at = state.get("agent_key_expires_at")
return {
"provider": "nous", "base_url": run.inference_base_url, "api_key": api_key,
"key_id": state.get("agent_key_id"), "expires_at": expires_at,
"expires_in": _remaining_ttl(expires_at, state.get("agent_key_expires_in")),
"source": NOUS_AUTH_PATH_INVOKE_JWT,
# Public semantic source label; the concrete store is exposed separately for diagnostics.
# Refresh persistence uses state_source_path internally and must not overload this field.
"auth_path": NOUS_AUTH_PATH_INVOKE_JWT,
"state_path": str(state_source_path or _auth_file_path())}
def _empty_nous_auth_status() -> Dict[str, Any]:
return {
"logged_in": False, "portal_base_url": None, "inference_base_url": None,
"access_expires_at": None, "agent_key_expires_at": None, "has_refresh_token": False,
"inference_credential_present": False, "credential_source": None}
def _snapshot_nous_pool_status() -> Dict[str, Any]:
"""Best-effort status from the credential pool.
Fallback only: the auth-store provider state is the runtime source of truth because it is what
``resolve_nous_runtime_credentials()`` refreshes.
"""
from hermes_cli.auth import _parse_iso_timestamp
try:
from agent.credential_pool import load_pool
pool = load_pool("nous")
entries = list(pool.entries()) if pool and pool.has_credentials() else []
if not entries:
return _empty_nous_auth_status()
entry = max(entries, key=lambda e: (
_parse_iso_timestamp(getattr(e, "agent_key_expires_at", None)) or 0.0,
_parse_iso_timestamp(getattr(e, "expires_at", None)) or 0.0,
-int(getattr(e, "priority", 0) or 0)))
attr = lambda name, default=None: getattr(entry, name, default) # noqa: E731
if not attr("runtime_api_key"):
return _empty_nous_auth_status()
access_token, refresh_token = attr("access_token"), attr("refresh_token")
auth_type = str(attr("auth_type", "") or "").strip().lower()
is_portal_oauth = bool(access_token) and (
auth_type.startswith("oauth") or bool(refresh_token))
label = attr("label", "unknown")
return {
"logged_in": is_portal_oauth,
"portal_base_url": (
(attr("portal_base_url") or DEFAULT_NOUS_PORTAL_URL) if is_portal_oauth else None),
"inference_base_url": (
attr("inference_base_url") or attr("runtime_base_url") or attr("base_url")),
"access_token": access_token if is_portal_oauth else None,
"access_expires_at": attr("expires_at"),
"agent_key_expires_at": attr("agent_key_expires_at"),
"has_refresh_token": bool(refresh_token), "inference_credential_present": True,
"credential_source": f"pool:{label}", "source": f"pool:{label}"}
except Exception:
return _empty_nous_auth_status()
def _nous_status_from_state(
state: Dict[str, Any], *, logged_in: bool, source: str) -> Dict[str, Any]:
"""Auth-store-backed Nous status snapshot (shared by the live and refresh-free variants)."""
from hermes_cli.anon_auth import is_guest_state
access_token = state.get("access_token")
account_tier = state.get("account_tier")
return {
"logged_in": logged_in, "portal_base_url": state.get("portal_base_url"),
"inference_base_url": state.get("inference_base_url"),
"access_expires_at": state.get("expires_at"),
"agent_key_expires_at": state.get("agent_key_expires_at"),
"has_refresh_token": bool(state.get("refresh_token")), "access_token": access_token,
"inference_credential_present": bool(access_token or state.get("agent_key")),
"credential_source": "auth_store", "source": source,
# Free tier: display surfaces render it with the free-tier copy, never as an account login.
"account_tier": account_tier if isinstance(account_tier, str) else None,
"free_tier": is_guest_state(state)}
def _compute_nous_auth_status() -> Dict[str, Any]:
"""Uncached implementation of get_nous_auth_status(). See that function."""
from hermes_cli.auth import get_provider_auth_state, resolve_nous_runtime_credentials
state = get_provider_auth_state("nous")
if not state:
return _snapshot_nous_pool_status()
base_status = _nous_status_from_state(
state, logged_in=bool(state.get("access_token")), source="auth_store")
try:
creds = resolve_nous_runtime_credentials()
refreshed_state = get_provider_auth_state("nous") or state
base_status.update({
"logged_in": True,
"portal_base_url": (
refreshed_state.get("portal_base_url") or base_status.get("portal_base_url")),
"inference_base_url": (
creds.get("base_url") or refreshed_state.get("inference_base_url")
or base_status.get("inference_base_url")),
"access_expires_at": (
refreshed_state.get("expires_at") or base_status.get("access_expires_at")),
"agent_key_expires_at": (
creds.get("expires_at") or refreshed_state.get("agent_key_expires_at")
or base_status.get("agent_key_expires_at")),
"has_refresh_token": bool(refreshed_state.get("refresh_token")),
"inference_credential_present": True, "credential_source": "auth_store",
"source": f"runtime:{creds.get('source', 'portal')}", "key_id": creds.get("key_id")})
except AuthError as exc:
base_status.update({
"logged_in": False, "error": str(exc),
"relogin_required": bool(getattr(exc, "relogin_required", False)),
"error_code": getattr(exc, "code", None)})
return base_status
def _terminal_quarantine_marker(state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""The persisted ``last_auth_error`` when it is a terminal quarantine with no credential left.
Only terminal while there is no usable credential: if a later login repopulated tokens the stale
marker must not keep reporting terminal.
"""
last_err = state.get("last_auth_error")
if (isinstance(last_err, dict) and last_err.get("relogin_required")
and not (state.get("access_token") or state.get("refresh_token"))):
return last_err
return None
def get_nous_auth_status_local() -> Dict[str, Any]:
"""Refresh-free Nous auth snapshot for read-only display surfaces.
NEVER calls ``resolve_nous_runtime_credentials()`` (no refresh POST / single-use token spent);
``logged_in`` = usable invoke JWT, or a refresh token not terminally quarantined — not proof
the server still accepts it.
"""
from hermes_cli.auth import get_provider_auth_state
try:
state = get_provider_auth_state("nous")
except Exception:
state = None
if not state:
return _snapshot_nous_pool_status()
jwt_reason = _state_invoke_jwt_status(state, state.get("access_token"))
last_err = _terminal_quarantine_marker(state)
logged_in = (jwt_reason is None) or (bool(state.get("refresh_token")) and last_err is None)
status = _nous_status_from_state(state, logged_in=logged_in, source="auth_store_local")
if last_err is not None:
status.update(
relogin_required=True, error_code=last_err.get("code"),
error=last_err.get("message") or "re-login required")
return status
# Enum values reported on the dashboard /api/status as ``nous_session_valid``. NAS's health sweep
# re-mints the bootstrap session ONLY on "terminal"; "valid" and "unknown" are no-ops. Keep this
# set small and stable — NAS parses it permissively, so new members are non-breaking but rare.
NOUS_SESSION_VALID = "valid"
NOUS_SESSION_TERMINAL = "terminal"
NOUS_SESSION_UNKNOWN = "unknown"
def get_nous_session_validity() -> str:
"""Classify the Nous bootstrap session for the dashboard /api/status probe.
Local auth-store state only; polled frequently, so it never resolves or refreshes. ANTI-FLAP:
only a *terminal* failure maps to "terminal" — a rotation blip, network error, or expiring
token must NOT (that would trigger a spurious NAS re-mint on a healthy box).
"""
from hermes_cli.auth import get_provider_auth_state
try:
state = get_provider_auth_state("nous")
except Exception:
state = None
if not state:
return NOUS_SESSION_UNKNOWN
# The persisted quarantine marker (`last_auth_error.relogin_required=True`, written when the
# refresh path clears dead tokens) is the strongest, most stable terminal signal — report
# "terminal" even after the in-memory AuthError is long gone.
if _terminal_quarantine_marker(state) is not None:
return NOUS_SESSION_TERMINAL
if _state_invoke_jwt_status(state, state.get("access_token")) is None:
return NOUS_SESSION_VALID
# Missing, malformed, expired, or merely expiring credentials are not proof of a terminal
# session. Runtime paths own refreshes; the health endpoint stays side-effect free.
return NOUS_SESSION_UNKNOWN
def _pool_first_oauth_status(
provider_id: str, *, is_expiring: Callable[[str, int], bool], auth_mode: str,
resolve: Callable[[], Dict[str, Any]],
on_pool_miss: Optional[Callable[[], Optional[Dict[str, Any]]]] = None) -> Dict[str, Any]:
"""Status snapshot for a store-backed OAuth provider (Codex, xAI).
Pool first (where `hermes auth` / `hermes model` store device_code tokens), then
*on_pool_miss* for a pool-derived degraded status, then the legacy state via *resolve*.
"""
from hermes_cli.auth import _auth_file_path
try:
from agent.credential_pool import load_pool
pool = load_pool(provider_id)
if pool and pool.has_credentials():
entry = pool.select()
if entry is not None:
api_key = (
getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", ""))
if api_key and not is_expiring(api_key, 0):
return {
"logged_in": True, "auth_store": str(_auth_file_path()),
"last_refresh": getattr(entry, "last_refresh", None),
"auth_mode": auth_mode,
"source": f"pool:{getattr(entry, 'label', 'unknown')}", "api_key": api_key}
if on_pool_miss is not None and (degraded := on_pool_miss()):
return degraded
except Exception:
pass
try:
creds = resolve()
return {
"logged_in": True, "auth_store": str(_auth_file_path()),
"last_refresh": creds.get("last_refresh"),
"auth_mode": creds.get("auth_mode"), "source": creds.get("source"),
"api_key": creds.get("api_key")}
except AuthError as exc:
return {"logged_in": False, "auth_store": str(_auth_file_path()), "error": str(exc)}
def _nous_device_code_login(
*, portal_base_url: Optional[str] = None, inference_base_url: Optional[str] = None,
client_id: Optional[str] = None, scope: Optional[str] = None, open_browser: bool = True,
timeout_seconds: float = 15.0, insecure: bool = False, ca_bundle: Optional[str] = None,
on_verification: Optional[Callable[[str, str], None]] = None) -> Dict[str, Any]:
"""Run the Nous device-code flow and return full OAuth state without persisting."""
from hermes_cli.auth import (
PROVIDER_REGISTRY, _coerce_ttl_seconds, _is_remote_session, _optional_base_url,
_poll_for_token, _print_device_code_instructions, _request_device_code,
_tls_state_from_verify, format_auth_error, refresh_nous_oauth_from_state)
pconfig = PROVIDER_REGISTRY["nous"]
portal_base_url = (
portal_base_url or os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL")
or pconfig.portal_base_url).rstrip("/")
requested_inference_url = (
inference_base_url or os.getenv("NOUS_INFERENCE_BASE_URL")
or pconfig.inference_base_url).rstrip("/")
client_id = client_id or pconfig.client_id
scope = scope or pconfig.scope
verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True)
if _is_remote_session():
open_browser = False
print(f"Starting Hermes login via {pconfig.name}...")
print(f"Portal: {portal_base_url}")
if insecure:
print("TLS verification: disabled (--insecure)")
elif ca_bundle:
print(f"TLS verification: custom CA bundle ({ca_bundle})")
with _nous_http_client(timeout_seconds, verify) as client:
device_data = _request_device_code(
client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope)
verification_url = str(device_data["verification_uri_complete"])
user_code = str(device_data["user_code"])
expires_in = int(device_data["expires_in"])
interval = int(device_data["interval"])
_print_device_code_instructions(
verification_url, user_code, open_browser=open_browser, failure_dash="—")
# Out-of-band consumer (e.g. the TUI gateway, whose stdout is a JSON-RPC pipe): fired AFTER
# the print/browser block and BEFORE polling so it can render the link while we wait.
if on_verification is not None:
with suppress(Exception):
on_verification(verification_url, user_code)
effective_interval = max(1, min(interval, DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS))
print(f"Waiting for approval (polling every {effective_interval}s)...")
token_data = _poll_for_token(
client=client, portal_base_url=portal_base_url, client_id=client_id,
device_code=str(device_data["device_code"]), expires_in=expires_in,
poll_interval=interval)
now = datetime.now(timezone.utc)
token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0))
resolved_inference_url = (
_optional_base_url(token_data.get("inference_base_url")) or requested_inference_url)
if resolved_inference_url != requested_inference_url:
print(f"Using portal-provided inference URL: {resolved_inference_url}")
auth_state = {
"portal_base_url": portal_base_url, "inference_base_url": resolved_inference_url,
"client_id": client_id, "scope": token_data.get("scope") or scope,
"token_type": token_data.get("token_type", "Bearer"),
"access_token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"),
"obtained_at": now.isoformat(), "expires_at": _iso_after(now, token_expires_in),
"expires_in": token_expires_in, "tls": _tls_state_from_verify(verify),
**_NOUS_EMPTY_AGENT_KEY_FIELDS}
try:
return refresh_nous_oauth_from_state(
auth_state, timeout_seconds=timeout_seconds, force_refresh=False)
except AuthError as exc:
if exc.code == "subscription_required":
portal_url = auth_state.get("portal_base_url", DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print()
print(format_auth_error(exc))
print(f" Subscribe here: {portal_url}/billing")
print()
print("After subscribing, run `hermes model` again to finish setup.")
raise SystemExit(1)
raise
def _mirror_nous_state_best_effort(auth_state: Dict[str, Any]) -> None:
"""Mirror to the shared store + reseed the pool, swallowing all errors (same as _login_nous)."""
from hermes_cli.auth import _sync_nous_pool_from_auth_store, _write_shared_nous_state
with suppress(Exception):
_write_shared_nous_state(auth_state)
with suppress(Exception):
_sync_nous_pool_from_auth_store()
def step_up_nous_billing_scope(
*, open_browser: bool = True, timeout_seconds: float = 15.0,
on_verification: Optional[Callable[[str, str], None]] = None) -> bool:
"""Re-run the device flow requesting ``billing:manage`` (step-up on 403 insufficient_scope).
The user must be ADMIN/OWNER and select "Allow Remote Spending" in the portal, else the
server silently downscopes and this returns False. Persists like ``_login_nous`` minus the
model picker.
"""
from hermes_cli.auth import (
PROVIDER_REGISTRY, _nous_device_code_login, _save_active_provider_state,
get_provider_auth_state)
prior = get_provider_auth_state("nous") or {}
pconfig = PROVIDER_REGISTRY["nous"]
# Step-up scope: existing scopes (if any) + billing:manage, deduped, order-stable. Falls back
# to the standard inference+tool+billing set.
_raw_scope = prior.get("scope")
prior_scope = _raw_scope.split() if isinstance(_raw_scope, str) else []
requested = list(dict.fromkeys([
*(prior_scope or [NOUS_INFERENCE_INVOKE_SCOPE, "tool:invoke"]), NOUS_BILLING_MANAGE_SCOPE]))
auth_state = _nous_device_code_login(
portal_base_url=prior.get("portal_base_url") or None,
inference_base_url=prior.get("inference_base_url") or None,
client_id=prior.get("client_id") or pconfig.client_id, scope=" ".join(requested),
open_browser=open_browser, timeout_seconds=timeout_seconds, on_verification=on_verification)
_save_active_provider_state("nous", auth_state)
_mirror_nous_state_best_effort(auth_state)
granted = auth_state.get("scope")
return isinstance(granted, str) and NOUS_BILLING_MANAGE_SCOPE in granted.split()
def _pick_nous_model_after_login(
auth_state: Dict[str, Any], inference_base_url: str) -> Optional[str]:
"""Fetch the curated Nous model list (tier/policy-filtered) and run the interactive picker.
Returns the selected model id, or None when the user skipped / nothing was selectable.
Raises on any fetch failure so the caller can print the "Login succeeded, but..." notice.
"""
from hermes_cli.auth import _prompt_model_selection
runtime_key = auth_state.get("agent_key") or auth_state.get("access_token")
if not isinstance(runtime_key, str) or not runtime_key:
raise _nous_err("No runtime API key available to fetch models", "invalid_token")
from hermes_cli.models import (
get_curated_nous_model_ids,
check_nous_free_tier,
partition_nous_models_by_tier,
union_with_portal_free_recommendations,
union_with_portal_paid_recommendations,
)
from hermes_cli.models_pricing import (
get_pricing_for_provider,
nous_policy_allowed_ids,
restrict_to_nous_policy,
)
model_ids = get_curated_nous_model_ids()
_portal = auth_state.get("portal_base_url", "")
print()
unavailable_models: list = []
unavailable_message = ""
_policy_narrowed = False
if model_ids:
pricing = get_pricing_for_provider("nous")
# Force fresh account data so recent credit purchases are reflected immediately.
free_tier = check_nous_free_tier(force_fresh=True)
# Narrow before the tier split, so a rescued id still has to pass the free/paid predicate.
_policy_allowed = nous_policy_allowed_ids()
if free_tier:
with suppress(Exception):
unavailable_message = _portal_entitlement_message("paid Nous models")
# The Portal's free/paidRecommendedModels endpoint is the source of truth for what's
# available *right now*: newly-launched models show without a CLI release.
union = (
union_with_portal_free_recommendations if free_tier
else union_with_portal_paid_recommendations)
model_ids, pricing = union(model_ids, pricing, _portal)
_before_policy = model_ids
model_ids = restrict_to_nous_policy(model_ids, _policy_allowed, rescue_empty=True)
_policy_narrowed = model_ids != _before_policy
if free_tier:
model_ids, unavailable_models = partition_nous_models_by_tier(
model_ids, pricing, free_tier=True)
if model_ids:
from hermes_cli.nous_account import nous_policy_notice
_policy_notice = nous_policy_notice(removed=_policy_narrowed)
if _policy_notice:
print(_policy_notice)
print(
f"Showing {len(model_ids)} curated models — "
"use \"Enter custom model name\" for others.")
return _prompt_model_selection(
model_ids, pricing=pricing, unavailable_models=unavailable_models, portal_url=_portal,
unavailable_message=unavailable_message, confirm_provider="nous",
confirm_base_url=inference_base_url, confirm_api_key=runtime_key)
if unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
print("No free models currently available.")
print(unavailable_message or f"Upgrade at {_url} to access paid models.")
else:
print("No curated models available for Nous Portal.")
return None
def _offer_shared_nous_import(timeout_seconds: float) -> Optional[Dict[str, Any]]:
"""Codex-style auto-import: offer to rehydrate a Nous credential from another profile.
Checks the shared store before launching a fresh device-code flow. Returns the refreshed
auth state when the user accepted and the import succeeded, else None.
"""
from hermes_cli.auth import _prompt_yes_no, _read_shared_nous_state
from hermes_cli.anon_auth import is_guest_state
shared = _read_shared_nous_state()
if not shared or is_guest_state(shared):
# A free-tier identity is not an OAuth credential to import; a real sign-in replaces it
# (persist_nous_credentials overwrites the singleton and the shared store).
return None
try:
shared_path = _nous_shared_store_path()
except RuntimeError:
shared_path = None
print()
print(f"Found existing Nous OAuth credentials at {shared_path}" if shared_path
else "Found existing shared Nous OAuth credentials")
if not _prompt_yes_no("Import these credentials? [Y/n]: ", default="y"):
return None
print("Rehydrating Nous session from shared credentials...")
auth_state = _try_import_shared_nous_state(timeout_seconds=timeout_seconds)
if auth_state is None:
print("Could not refresh shared credentials — falling back to device-code login.")
return auth_state
def _restore_active_provider(prior_active_provider: Any) -> None:
"""Undo the ``active_provider="nous"`` that ``_save_provider_state`` wrote during login."""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
with _auth_store_lock():
auth_store = _load_auth_store()
if prior_active_provider:
auth_store["active_provider"] = prior_active_provider
else:
auth_store.pop("active_provider", None)
_save_auth_store(auth_store)
def _login_nous(args, pconfig: ProviderConfig) -> None:
"""Nous Portal device authorization flow."""
from hermes_cli.auth import (
_auth_store_lock, _load_auth_store, _nous_device_code_login, _save_active_provider_state,
_save_model_choice, _sync_nous_pool_from_auth_store, _update_config_for_provider,
_write_shared_nous_state, format_auth_error)
timeout_seconds = getattr(args, "timeout", None) or 15.0
ca_bundle = (
getattr(args, "ca_bundle", None) or os.getenv("HERMES_CA_BUNDLE")
or os.getenv("SSL_CERT_FILE"))
try:
auth_state = _offer_shared_nous_import(timeout_seconds)
if auth_state is None:
auth_state = _nous_device_code_login(
portal_base_url=getattr(args, "portal_url", None),
inference_base_url=getattr(args, "inference_url", None),
client_id=getattr(args, "client_id", None) or pconfig.client_id,
scope=getattr(args, "scope", None),
open_browser=not getattr(args, "no_browser", False),
timeout_seconds=timeout_seconds, insecure=bool(getattr(args, "insecure", False)),
ca_bundle=ca_bundle)
inference_base_url = auth_state["inference_base_url"]
# Snapshot BEFORE _save_provider_state overwrites active_provider to "nous", so a
# model-picker "Skip (keep current)" can restore the user's previous provider.
with _auth_store_lock():
prior_active_provider = _load_auth_store().get("active_provider")
saved_to = _save_active_provider_state("nous", auth_state)
# Mirror to the shared store so other profiles can one-tap import (best-effort inside).
_write_shared_nous_state(auth_state)
_sync_nous_pool_from_auth_store()
print()
print("Login successful!")
print(f" Auth state: {saved_to}")
# Pick the model BEFORE writing the provider to config.yaml so config is never half-updated.
selected_model = None
try:
selected_model = _pick_nous_model_after_login(auth_state, inference_base_url)
except Exception as exc:
message = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc)
print()
print(f"Login succeeded, but could not fetch available models. Reason: {message}")
# No model (Skip, fetch failed, nothing curated): keep the previous provider rather than
# switch to Nous with a mismatched model; the Nous tokens stay saved for future use.
if not selected_model:
_restore_active_provider(prior_active_provider)
print()
print("No provider change. Nous credentials saved for future use.")
print(" Run `hermes model` again to switch to Nous Portal.")
return
config_path = _update_config_for_provider(
"nous", inference_base_url, default_model=selected_model)
_save_model_choice(selected_model)
print(f"Default model set to: {selected_model}")
print(f" Config updated: {config_path} (model.provider=nous)")
except KeyboardInterrupt:
print("\nLogin cancelled.")
raise SystemExit(130)
except Exception as exc:
print(f"Login failed: {exc}")
raise SystemExit(1)