refactor(web): dashboard_auth/web_routers/kanban dedupe + _common helpers (resume — verified partial work)

This commit is contained in:
Teknium
2026-09-02 09:20:55 -07:00
parent a3d33fe22f
commit f0f7f55f76
18 changed files with 2172 additions and 4606 deletions
+67 -207
View File
@@ -8,11 +8,10 @@ from typing import Optional
@dataclass(frozen=True)
class Session:
"""A verified identity. Returned by ``complete_login`` and ``verify_session``.
"""A verified interactive identity (from ``complete_login`` / ``verify_session``).
All fields are mandatory. Providers that don't have a concept of orgs
should set ``org_id`` to an empty string. ``access_token`` and
``refresh_token`` are opaque to Hermes — provider-specific.
All fields mandatory; providers without orgs set ``org_id=""``. The tokens
are opaque to Hermes.
"""
user_id: str
@@ -27,25 +26,11 @@ class Session:
@dataclass(frozen=True)
class TokenPrincipal:
"""A verified non-interactive (service-to-service) caller.
"""A verified non-interactive (service-to-service) caller — the token analog
of :class:`Session`: one bearer token on one request, no login/cookie/refresh.
The token analog of :class:`Session`. Where a ``Session`` represents an
interactive human identity behind a session cookie, a ``TokenPrincipal``
represents a machine/service caller that authenticated by presenting a
bearer token in the ``Authorization`` request header on a single
request — no login, no cookie, no refresh.
Returned by :meth:`DashboardAuthProvider.verify_token` and attached to
``request.state.token_principal`` by the token-auth middleware seam so a
route handler can see *who* called it.
Fields:
* ``principal`` — stable identifier for the caller (e.g. the provider
name, a service account id, or an agent id). Opaque to the seam.
* ``provider`` — the ``name`` of the provider that verified the token.
* ``scopes`` — capability strings this principal is authorised for.
Empty tuple means "unscoped" (the provider vouches for the caller but
attaches no capability list); a route MAY enforce a required scope.
``principal`` is an opaque stable caller id; ``scopes`` empty means
"unscoped" (a route MAY enforce a required scope).
"""
principal: str
@@ -57,20 +42,11 @@ class TokenPrincipal:
class LoginStart:
"""First leg of the OAuth round trip.
``redirect_url`` is the URL the browser must navigate to (e.g. the
Portal's ``/oauth/authorize``). ``cookie_payload`` is a dict of cookie
name → serialised value that the auth route will ``Set-Cookie`` on the
response. Used for PKCE state, CSRF nonces, etc. Cookies set here MUST
be HttpOnly + Secure (when over HTTPS) with a TTL ≤ 10 minutes (the
login lifetime).
SameSite: use ``Lax`` by default. The one exception is the PKCE state
cookie, which is ``SameSite=None; Secure`` over HTTPS — it is set on
the ``/auth/login`` 302 and has to survive the cross-site redirect
chain back from the IDP, which Chromium drops intermittently under
``Lax`` (crbug 40508226). Over plain HTTP it stays ``Lax``, since
``SameSite=None`` requires ``Secure``. See
:func:`hermes_cli.dashboard_auth.cookies.set_pkce_cookie`.
``redirect_url`` is where the browser goes (the IDP's authorize endpoint);
``cookie_payload`` maps cookie name -> serialised PKCE/CSRF state that the
auth route sets (HttpOnly, Secure over HTTPS, TTL <= 10 min). The PKCE
cookie is ``SameSite=None; Secure`` over HTTPS so it survives the cross-site
redirect chain — see :func:`hermes_cli.dashboard_auth.cookies.set_pkce_cookie`.
"""
redirect_url: str
@@ -78,61 +54,35 @@ class LoginStart:
class ProviderError(Exception):
"""IDP unreachable, network error, or other transient failure.
Middleware translates this to HTTP 503.
"""
"""IDP unreachable / transient failure. Middleware -> HTTP 503."""
class InvalidCodeError(Exception):
"""The OAuth callback ``code`` / ``state`` failed validation.
Middleware translates this to HTTP 400.
"""
"""OAuth callback ``code``/``state`` failed validation. Middleware -> HTTP 400."""
class InvalidCredentialsError(Exception):
"""A username/password pair was rejected by a password provider.
Raised by :meth:`DashboardAuthProvider.complete_password_login`. The
``/auth/password-login`` route translates this to HTTP 401 with a
deliberately generic detail (never distinguishing "unknown user" from
"wrong password") so the endpoint can't be used as a username oracle.
"""
"""Username/password rejected. The route answers a generic 401 (never
distinguishing unknown user from wrong password — no username oracle)."""
class RefreshExpiredError(Exception):
"""This provider rejects the refresh token as dead or invalid.
In a multi-provider deployment this does not prove token ownership, so
middleware may try remaining providers. It clears cookies and forces
re-login only after every reachable provider rejects the token.
"""
"""This provider rejects the refresh token. Not proof of ownership in a
multi-provider deployment, so middleware tries the remaining providers and
forces re-login only after every reachable one rejects it."""
def classify_jwks_lookup_error(exc: BaseException) -> Exception:
"""Map a ``PyJWKClient.get_signing_key_from_jwt`` failure to the protocol.
Only a genuine transport failure (the IDP's JWKS endpoint could not be
fetched) is a :class:`ProviderError` — middleware turns that into 503
"auth provider unreachable" so a flaky IDP never forces a logout.
Everything else means the token itself cannot be verified by this
provider and is an :class:`InvalidCodeError` (``verify_session`` returns
``None``, the middleware tries the next provider / refresh / 401):
* ``jwt.DecodeError`` — the bearer is not a JWT at all (an opaque peer
key, a legacy session token, garbage). #94558: hosted agents answered
every non-JWT bearer with a fast 503 ``Auth provider 'nous'
unreachable`` even though Portal was healthy, because "cannot parse"
and "cannot reach" were folded into one branch.
* ``jwt.PyJWKSetError`` — the JWKS was fetched fine but holds no key for
this token's ``kid`` (rotated/foreign key). The provider was reached;
the token is simply not one of ours.
``PyJWKClientConnectionError`` is the only ``PyJWKClientError`` subclass
that denotes unreachability; a bare ``PyJWKClientError`` (unexpected
JWKS shape) is kept as a provider fault since the IDP misbehaved.
Only a genuine transport failure (``PyJWKClientConnectionError``, or an
unexpected JWKS shape = IDP misbehaving) is a :class:`ProviderError` (503,
never forces logout). A non-JWT bearer (``DecodeError``), a JWKS with no key
for this ``kid`` (``PyJWKSetError``) or any other invalid token means the
token is simply not verifiable by this provider -> :class:`InvalidCodeError`
(``verify_session`` returns ``None``; next provider / refresh / 401).
Folding "cannot parse" into "cannot reach" once made every opaque bearer a
fast 503 against a healthy Portal.
"""
try:
import jwt
@@ -152,83 +102,37 @@ def classify_jwks_lookup_error(exc: BaseException) -> Exception:
class DashboardAuthProvider(ABC):
"""Protocol every dashboard-auth provider plugin implements.
Lifecycle:
1. ``start_login`` — user clicks "Log in with X" on the login page.
Provider returns a redirect URL and any PKCE/CSRF state to stash
in short-lived cookies.
2. Browser bounces through the OAuth IDP and lands at /auth/callback.
3. ``complete_login`` — exchange the code + verifier for a Session.
4. ``verify_session`` — called on every request to validate the
access token in the cookie. Returns ``None`` if the token is
expired or invalid (middleware then triggers refresh or logout).
5. ``refresh_session`` — called when the access token is near expiry.
Returns a new Session with rotated tokens.
6. ``revoke_session`` — called on /auth/logout. Best-effort.
Lifecycle: ``start_login`` (redirect URL + PKCE state for cookies) ->
IDP -> ``complete_login`` (code + verifier -> Session) ->
``verify_session`` on every request -> ``refresh_session`` near expiry ->
``revoke_session`` on logout (best-effort, must not raise).
Failure semantics:
* ``start_login`` may raise ``ProviderError`` if the IDP is
unreachable.
* ``complete_login`` raises ``InvalidCodeError`` on bad code/state;
``ProviderError`` if the IDP is unreachable.
* ``verify_session`` returns ``None`` on expiry / unknown token;
raises ``ProviderError`` if the IDP is unreachable. Middleware
treats expiry and unreachable differently (expiry → refresh;
unreachable → 503).
* ``refresh_session`` raises ``RefreshExpiredError`` when the refresh
token is invalid for that provider. Middleware tries the remaining
providers because an opaque foreign token can be indistinguishable
from an expired one; it forces re-login only after every reachable
provider rejects the token. Raises ``ProviderError`` on network
failure; middleware still tries remaining providers, but returns 503
without clearing cookies if none succeeds and any was unavailable.
* ``revoke_session`` is best-effort and must not raise.
* ``start_login`` / ``complete_login`` raise ``ProviderError`` when the
IDP is unreachable; ``complete_login`` raises ``InvalidCodeError`` on
a bad code/state.
* ``verify_session`` returns ``None`` for expired/unknown tokens and
raises ``ProviderError`` when unreachable — middleware refreshes on
the former and answers 503 on the latter.
* ``refresh_session`` raises ``RefreshExpiredError`` when the token is
invalid for that provider (an opaque foreign token is
indistinguishable from an expired one, so middleware tries the rest)
and ``ProviderError`` on network failure (503 without clearing cookies
if none succeeds).
Subclasses MUST set ``name`` (lowercase identifier, stable forever)
and ``display_name`` (user-facing label on the login page).
Password (non-redirect) providers:
A provider that authenticates with a username + password instead of
an OAuth redirect sets ``supports_password = True`` and implements
``complete_password_login``. The login page then renders a
credential form (POSTing to ``/auth/password-login``) instead of a
"Log in with X" redirect button. Everything downstream of login —
``verify_session`` / ``refresh_session`` / ``revoke_session``, the
session cookies, the WS-ticket mint — is identical to the OAuth
path, because a password session is just a :class:`Session` with
provider-minted opaque tokens. The OAuth methods (``start_login`` /
``complete_login``) remain abstract; a pure-password provider that
will never be reached via the redirect flow may implement them as
stubs that raise ``NotImplementedError``.
Subclasses MUST set ``name`` (stable lowercase id) and ``display_name``.
Capability flags: ``supports_password`` (renders a credential form and
implements ``complete_password_login``; ``start_login``/``complete_login``
may be ``NotImplementedError`` stubs), ``supports_token`` (implements
``verify_token`` for the token-auth seam), ``supports_session`` (False for
token-only credentials such as drain, which are never offered a login).
Everything downstream of login is identical for every kind of session.
"""
name: str = ""
display_name: str = ""
# When True, this provider authenticates via username + password
# (``complete_password_login``) rather than (or in addition to) the
# OAuth redirect flow. The login page renders a credential form for
# such providers; the ``/auth/password-login`` route dispatches to
# ``complete_password_login``. OAuth-only providers leave this False
# and are completely unaffected.
supports_password: bool = False
# When True, this provider can verify a non-interactive bearer token
# (``verify_token``) presented on a single request by a service-to-service
# caller — no login, no cookie, no refresh. This is the generic
# API-token capability flag, mirroring ``supports_password``: a route
# opts into token auth (see ``token_auth`` middleware seam) and the
# gate consults every ``supports_token`` provider in turn until one
# recognises the token. OAuth/password providers leave this False and
# are completely unaffected. The drain bearer-secret plugin is the
# first consumer, but the capability is deliberately generic so any
# future machine-credential provider drops in without core changes.
supports_token: bool = False
# When True, this provider does the interactive cookie-session flow (login,
# verify, refresh). The login page, /auth/login, and the gate's
# verify/refresh loops consult only supports_session providers, so a
# token-only credential (e.g. drain) is never offered a login. Mirrors
# supports_token.
supports_session: bool = True
@abstractmethod
@@ -258,24 +162,11 @@ class DashboardAuthProvider(ABC):
) -> "Session":
"""Verify a username/password pair and mint a :class:`Session`.
Only called when ``supports_password`` is True (the
``/auth/password-login`` route guards on the flag). The default
raises ``NotImplementedError`` so an OAuth-only provider that
forgets to set the flag fails loudly rather than silently
accepting credentials.
The returned ``Session`` carries provider-minted opaque
``access_token`` / ``refresh_token`` exactly like the OAuth path,
so all downstream session handling (cookies, verify, refresh,
ws-tickets, logout) is identical.
Failure semantics:
* ``InvalidCredentialsError`` — username/password rejected. The
route surfaces a generic 401 (no user-vs-password
distinction). Implementations SHOULD spend constant time on
unknown users (dummy hash verify) to avoid a timing oracle.
* ``ProviderError`` — the backing credential store is
unreachable (LDAP/DB down); the route surfaces 503.
Only called when ``supports_password`` is True. Raise
``InvalidCredentialsError`` on rejection (SHOULD spend constant time
on unknown users to avoid a timing oracle) and ``ProviderError`` when
the credential store is unreachable. The default raises so a provider
that forgets the flag fails loudly rather than accepting credentials.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support password login "
@@ -286,30 +177,12 @@ class DashboardAuthProvider(ABC):
def verify_token(self, *, token: str) -> "Optional[TokenPrincipal]":
"""Verify a non-interactive bearer token; return its principal.
The token analog of ``verify_session``. Only consulted when
``supports_token`` is True. Called by the ``token_auth`` middleware
seam for every request to a token-authable route, in registration
order, until one provider returns a non-None principal.
Contract (mirrors ``verify_session`` stacking semantics):
* Return a :class:`TokenPrincipal` if this provider recognises and
accepts the token.
* Return ``None`` for a token this provider does NOT recognise —
never raise, so the seam can fall through to the next provider.
A malformed/expired/wrong token is "not recognised" → ``None``.
* Raise ``ProviderError`` ONLY for a genuine backing-store outage
(the provider can neither confirm nor deny). The seam treats this
like ``verify_session``: remember it, keep trying other providers,
and surface 503 only if NO provider accepts the token AND at least
one was unreachable.
Implementations MUST use a constant-time comparison
(``hmac.compare_digest``) when matching a shared secret so the
endpoint isn't a timing oracle.
The default raises ``NotImplementedError`` so a provider that sets
``supports_token`` but forgets to implement this fails loudly rather
than silently accepting every caller.
Stacking mirrors ``verify_session``: return ``None`` (never raise) for
a token this provider does not recognise so the seam falls through;
raise ``ProviderError`` ONLY for a genuine backing-store outage (the
seam surfaces 503 only if no provider accepts the token). Shared
secrets MUST be compared with ``hmac.compare_digest``. The default
raises so a mis-flagged provider fails loudly.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support token auth "
@@ -320,32 +193,19 @@ class DashboardAuthProvider(ABC):
def assert_protocol_compliance(cls: type) -> None:
"""Raise ``TypeError`` if ``cls`` doesn't fully implement the provider protocol.
Call this in every provider plugin's unit tests::
def test_protocol_compliance():
assert_protocol_compliance(MyProvider)
Returns ``None`` on success so callers can assert it explicitly.
Call it from every provider plugin's unit tests.
"""
required_methods = (
"start_login",
"complete_login",
"verify_session",
"refresh_session",
"revoke_session",
)
required_attrs = ("name", "display_name")
for attr in required_attrs:
val = getattr(cls, attr, "")
if not val:
for attr in ("name", "display_name"):
if not getattr(cls, attr, ""):
raise TypeError(
f"{cls.__name__} missing or empty attribute: {attr!r}"
)
for method in required_methods:
for method in (
"start_login", "complete_login", "verify_session",
"refresh_session", "revoke_session",
):
if not callable(getattr(cls, method, None)):
raise TypeError(f"{cls.__name__} missing method: {method}")
# Also catch the ABC-not-overridden case.
if getattr(cls, "__abstractmethods__", None):
raise TypeError(
f"{cls.__name__} has unimplemented abstract methods: "
+116 -317
View File
@@ -1,68 +1,28 @@
"""Cookie helpers for dashboard auth.
Three cookies in play:
- hermes_session_at: the OAuth access token
(HttpOnly, lifetime = token TTL, ~15 min)
- hermes_session_rt: the OAuth refresh token
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
Nous Portal issues a rotating refresh token for the
dashboard auth-code grant (Portal NAS #293 / hermes
#37247). ``set_session_cookies`` writes this cookie
whenever the provider returns a non-empty
``refresh_token``; the middleware uses it to rotate a
fresh access token transparently on AT expiry. A
provider that omits the refresh token (empty string)
degrades gracefully to access-token-only sessions —
the RT cookie is simply not written.
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
hint (HttpOnly, lifetime = 10 minutes)
Cookies (all HttpOnly, ``SameSite=Lax`` unless noted, Path = proxy prefix or /):
- hermes_session_at access token; Max-Age = token TTL (~15 min)
- hermes_session_rt rotating refresh token; written only when the
provider returned one (AT-only sessions degrade
gracefully), always cleared on logout/expiry
- hermes_session_provider non-secret routing hint so a refresh token is
not handed to the wrong provider when several
auth plugins are enabled
- hermes_session_pkce PKCE state + CSRF nonce + provider hint, 10 min.
``SameSite=None; Secure`` over HTTPS because it is
set on the /auth/login 302 and must survive the
cross-site redirect chain out to the IDP and back
(Chromium drops Lax cookies set on such a 302,
crbug 40508226); plain HTTP falls back to Lax.
- hermes_sso_attempt one-shot auto-SSO loop-guard marker (60 s)
The two session cookies are ``SameSite=Lax`` and live under the prefix's
Path. The PKCE cookie is the exception: ``SameSite=None`` over HTTPS,
falling back to ``Lax`` on plain HTTP (where ``SameSite=None`` is invalid
without ``Secure``). It is set on the ``/auth/login`` 302 and must survive
the cross-site redirect chain out to the IDP and back to
``/auth/callback``; Chromium intermittently drops ``Lax`` cookies set on a
302 in such a chain (crbug 40508226), which surfaces as "Missing PKCE
state cookie". ``Secure`` is set ONLY when the dashboard was reached over
HTTPS — detected via the request URL scheme, which honours
``X-Forwarded-Proto`` upstream of Fly's TLS terminator when uvicorn is
configured with ``proxy_headers=True``. Loopback dev traffic is always
HTTP so ``Secure`` would lock the cookies out of the browser.
NOTE: uvicorn only honours ``X-Forwarded-Proto`` from a peer inside its
``forwarded_allow_ips`` (default: ``127.0.0.1``). A TLS terminator that
reaches the dashboard from a non-loopback address — e.g. a reverse proxy
in its own container — is not trusted, so the request still looks like
HTTP here and these cookies are written in their HTTP shape.
Cookie prefix selection (browser hardening per
https://datatracker.ietf.org/doc/html/draft-west-cookie-prefixes):
* Loopback HTTP — bare name. ``__Host-`` / ``__Secure-`` require
``Secure``, which is incompatible with HTTP.
* Gated HTTPS, direct deploy (Path=/) — ``__Host-`` prefix. Binds the
cookie to the exact origin (no Domain attribute) — strongest spec
guarantee.
* Gated HTTPS, behind a reverse-proxy prefix (Path=/hermes) —
``__Secure-`` prefix. ``__Host-`` is disallowed when Path != "/";
``__Secure-`` keeps the Secure-required hardening without the
Path constraint, and the explicit ``Path=/hermes`` covers
same-origin app isolation.
The setters and readers BOTH consult the active prefix because the
cookie *name* changes — a reader that looked up the bare name when the
setter wrote ``__Secure-hermes_session_at`` would never find the value.
Refresh-token handling:
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
it) and silently skips writing the RT cookie in that case, so a
refresh-token-less provider degrades to access-token-only sessions.
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
cookie on logout / session expiry so a stale cookie from an earlier
deployment gets cleared. The transparent rotation flow ("expired AT +
live RT → rotate server-side, else 401 → /login") lives in
``middleware._attempt_refresh``.
``Secure`` is set only when the request arrived over HTTPS
(``request.url.scheme``, which honours ``X-Forwarded-Proto`` only from a peer
in uvicorn's ``forwarded_allow_ips``). Cookie-prefix hardening follows
draft-west-cookie-prefixes: bare name over HTTP; ``__Host-`` on gated HTTPS
with Path=/; ``__Secure-`` behind a proxy prefix (``__Host-`` forbids
Path != /). Setters and readers BOTH resolve the name through
:func:`_resolved_name` — a mismatch silently breaks sessions.
"""
from __future__ import annotations
@@ -76,76 +36,36 @@ from urllib.parse import unquote
from fastapi import Request
from fastapi.responses import Response
# Bare cookie names — the request-scoped ``_resolved_name`` helper
# decides whether to prepend ``__Host-`` / ``__Secure-`` based on the
# request's HTTPS + prefix combination.
SESSION_AT_COOKIE = "hermes_session_at"
SESSION_RT_COOKIE = "hermes_session_rt"
# Provider that minted the session. This non-secret routing hint prevents a
# refresh token from being handed to the wrong provider when several dashboard
# auth plugins are enabled (for example Basic + Nous OAuth).
SESSION_PROVIDER_COOKIE = "hermes_session_provider"
PKCE_COOKIE = "hermes_session_pkce"
# One-shot loop-guard marker for the auto-SSO redirect (Phase 1,
# cloud-auto-discovery). Set when the gate auto-initiates the portal OAuth
# redirect on an unauthenticated document load; its mere PRESENCE on the next
# unauthenticated load tells the gate "we already bounced once" so a genuinely
# absent portal session degrades to the /login page instead of ping-ponging.
# Carries no secret — it's a boolean breadcrumb — but is set HttpOnly/Lax/Secure
# like the others for consistency. Short TTL so a user who returns later gets a
# fresh silent attempt rather than a permanently-disabled one.
SSO_ATTEMPT_COOKIE = "hermes_sso_attempt"
# Possible name variants we may have to read back. Sorted so most-strict
# wins on iteration when both happen to be present (shouldn't happen in
# practice — a single request emits exactly one variant).
# Name variants a reader may have to try; most strict first.
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
# real authority — once the RT itself expires/rotates out, a refresh attempt
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
# lifetime to a server-side TTL that can change independently; revisit if the
# stale-cookie refresh churn ever matters.)
# RT cookie lifetime is a generous browser-side upper bound; the provider's own
# RT TTL is the real authority (an expired RT -> RefreshExpiredError -> re-login).
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60
# Auto-SSO loop-guard marker TTL. Just long enough to cover one redirect
# round trip to the portal and back (a few seconds in practice); kept at 60s
# so a slow portal hop or a manual back-button still trips the guard, while a
# user returning minutes later gets a fresh silent attempt rather than being
# stuck on /login forever. The marker is also cleared explicitly on a
# successful callback and whenever the gate falls back to /login.
# Long enough for one portal round trip / back-button; short enough that a
# user returning later gets a fresh silent attempt rather than a stuck /login.
_SSO_ATTEMPT_MAX_AGE = 60
def _resolved_name(bare: str, *, use_https: bool, prefix: str) -> str:
"""Pick the cookie-prefix variant for the active request shape.
See module docstring for the prefix selection rules. Mismatch
between setter and reader would silently break sessions, so this
function is the single source of truth for naming.
"""
"""Cookie-prefix variant for the request shape (see module docstring)."""
if not use_https:
return bare
if prefix:
# Path != "/" forbids __Host-; fall back to __Secure-.
return f"__Secure-{bare}"
return f"__Host-{bare}"
def _cookie_path(prefix: str) -> str:
"""Cookie ``Path`` attribute for the active deploy shape.
Under ``X-Forwarded-Prefix: /hermes`` we want ``Path=/hermes`` so:
a) the browser sends the cookie back on requests under the prefix
(browsers omit the cookie if request path doesn't start with
Path);
b) the cookie doesn't leak to other apps on the same origin
(``mission-control.tilos.com/billing/...``).
Direct-deploy (no proxy prefix) gets ``Path=/``.
"""
"""``Path=/hermes`` under a proxy prefix (so the browser sends the cookie
back under the prefix and it does not leak to sibling apps), else ``/``."""
return prefix if prefix else "/"
@@ -160,6 +80,25 @@ def _common_attrs(*, use_https: bool, prefix: str) -> dict:
return attrs
def _pkce_attrs(*, use_https: bool, prefix: str) -> dict:
"""Attributes shared by the PKCE set AND clear paths (a shape mismatch
means the browser silently keeps the stale cookie)."""
attrs = _common_attrs(use_https=use_https, prefix=prefix)
if use_https:
attrs["samesite"] = "none"
return attrs
def _set(response: Response, bare: str, value: str, *, max_age: int,
use_https: bool, prefix: str, attrs: dict | None = None) -> None:
response.set_cookie(
_resolved_name(bare, use_https=use_https, prefix=prefix),
value,
max_age=max_age,
**(attrs if attrs is not None else _common_attrs(use_https=use_https, prefix=prefix)),
)
def set_session_provider_cookie(
response: Response,
*,
@@ -170,12 +109,8 @@ def set_session_provider_cookie(
"""Persist the non-secret provider routing hint for token refresh."""
if not provider:
return
response.set_cookie(
_resolved_name(SESSION_PROVIDER_COOKIE, use_https=use_https, prefix=prefix),
provider,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
_set(response, SESSION_PROVIDER_COOKIE, provider, max_age=_RT_MAX_AGE,
use_https=use_https, prefix=prefix)
def set_session_cookies(
@@ -188,42 +123,20 @@ def set_session_cookies(
prefix: str = "",
provider: str = "",
) -> None:
"""Set the session cookies on the response.
"""Set the session cookies.
``access_token_expires_in`` is in seconds. Use the provider's reported
TTL for the access token.
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
issues a 24h rotating refresh token (hermes #37247); a provider that
omits it returns ``Session.refresh_token == ""`` and we simply don't
persist the RT cookie — the session then behaves as access-token-only
until the AT expires. No other branch changes between the two cases.
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
or ``""`` for a direct deploy. It influences both the cookie name
(``__Host-`` vs ``__Secure-`` vs bare) and the ``Path`` attribute.
``access_token_expires_in`` is seconds (the provider's reported TTL). An
empty ``refresh_token`` means "don't persist the RT cookie" — a literal
empty cookie would be dead state at best, attack surface at worst.
``prefix`` is the normalised X-Forwarded-Prefix or ``""``.
"""
response.set_cookie(
_resolved_name(SESSION_AT_COOKIE, use_https=use_https, prefix=prefix),
access_token,
max_age=access_token_expires_in,
**_common_attrs(use_https=use_https, prefix=prefix),
)
# Contract v1: empty refresh token means "don't persist RT cookie".
# Keeping a literal empty-value cookie around would be dead state at
# best, attack surface at worst.
_set(response, SESSION_AT_COOKIE, access_token, max_age=access_token_expires_in,
use_https=use_https, prefix=prefix)
if refresh_token:
response.set_cookie(
_resolved_name(SESSION_RT_COOKIE, use_https=use_https, prefix=prefix),
refresh_token,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
_set(response, SESSION_RT_COOKIE, refresh_token, max_age=_RT_MAX_AGE,
use_https=use_https, prefix=prefix)
set_session_provider_cookie(
response,
provider=provider,
use_https=use_https,
prefix=prefix,
response, provider=provider, use_https=use_https, prefix=prefix,
)
@@ -237,19 +150,14 @@ def _clear_cookie_variants(
) -> None:
"""Emit Max-Age=0 deletions for every plausible name variant of a cookie.
Cookie-prefix rules make the deletion shape load-bearing: a Set-Cookie
for a ``__Host-``/``__Secure-`` name is rejected outright by the
browser unless it carries ``Secure`` (and ``__Host-`` additionally
requires ``Path=/``), so those deletions always carry the attributes
their name demands. The bare-name deletion mirrors the shape the
setter uses (``bare_attrs``) — under RFC 6265bis a deletion sent from
a secure origin may omit ``Secure`` and still delete a Secure cookie,
while a ``Secure`` deletion on a plain-HTTP origin can be ignored, so
matching the setter is the shape that works on both origins.
We don't know which variant the setter used (it depends on the setting
request's shape). Prefixed names are rejected by the browser unless they
carry ``Secure`` (``__Host-`` additionally needs ``Path=/``), so those
deletions always carry them; the bare deletion mirrors the setter's shape
(``bare_attrs``), which works on both HTTP and HTTPS origins.
"""
for variant in _NAME_VARIANTS:
if variant == "__Host-":
# __Host- demands Secure AND Path=/ or the header is invalid.
response.set_cookie(
f"{variant}{bare_name}", "", max_age=0,
path="/", httponly=True, samesite=https_samesite,
@@ -267,18 +175,13 @@ def _clear_cookie_variants(
)
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
"""Emit Max-Age=0 deletions for both session cookies.
def _lax_bare_attrs(prefix: str) -> dict:
return {"path": _cookie_path(prefix), "httponly": True, "samesite": "lax"}
To delete a cookie reliably the deletion's ``Path`` must match the
set path AND the cookie name must match the variant the setter used.
We don't know which variant was originally set (cookie prefix
depends on the request that set it), so we emit deletions for every
plausible variant under the active path.
"""
bare_attrs = {
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
}
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
"""Delete the AT, RT and provider cookies (every name variant, active path)."""
bare_attrs = _lax_bare_attrs(prefix)
for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE):
_clear_cookie_variants(
response, name,
@@ -286,33 +189,14 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
)
def _pkce_attrs(*, use_https: bool, prefix: str) -> dict:
"""Cookie attributes for the PKCE cookie's set AND clear paths.
Single source of truth so a deletion always matches the shape the
setter emitted for the same origin — a shape mismatch means the
browser silently keeps the stale cookie.
"""
attrs = _common_attrs(use_https=use_https, prefix=prefix)
if use_https:
attrs["samesite"] = "none"
return attrs
def encode_pkce_payload(parts: dict[str, str]) -> str:
"""Serialise PKCE segments to the wire value: ``base64url(JSON)``.
"""Serialise PKCE segments to the wire value ``base64url(JSON)``, no padding.
The urlsafe base64 alphabet (``A-Za-z0-9-_``, padding stripped) is a
strict subset of the RFC 6265 cookie-octet set — no ``;`` (attribute
terminator), no ``"`` and no ``\\`` (the chars that make Python's
http.cookies emit the quoted ``\\073`` form, which strict cookie-aware
proxy hops such as Go's net/http reject outright). The ``=`` padding
is stripped because http.cookies treats ``=`` as outside its legal
unquoted set and would re-wrap the value in the quoted form this
codec exists to avoid; the parser restores the padding. JSON carries
the segments, so no delimiter can ever collide with segment values —
the delimiter/quoting bug class this codec replaces (see
:func:`parse_pkce_payload` for the two legacy formats it superseded).
The urlsafe alphabet is a strict subset of RFC 6265 cookie-octets (no
``;``, ``"``, ``\\``), so http.cookies never quotes it (strict proxy hops
such as Go net/http reject the quoted form) and no segment value can
collide with a delimiter. Padding ``=`` would trigger quoting; the parser
restores it.
"""
raw = json.dumps(parts, separators=(",", ":"), sort_keys=True)
return (
@@ -329,47 +213,18 @@ def set_pkce_cookie(
use_https: bool,
prefix: str = "",
) -> None:
# SameSite=None when HTTPS: the PKCE cookie is set on the /auth/login
# 302 response (redirecting to the IDP) and must survive the cross-site
# redirect chain (same-site → IDP → same-site callback). Chromium has a
# long-standing bug (crbug 40508226) where SameSite=Lax cookies set on a
# 302 in a cross-site redirect chain are intermittently dropped, causing
# "Missing PKCE state cookie" on the callback. SameSite=None + Secure
# sidesteps the bug — these cookies are explicitly designed for cross-site
# delivery and Chromium processes them reliably during redirects.
# Loopback HTTP degrades to Lax (SameSite=None requires Secure).
#
# Value encoding: ``payload`` is the segment dict
# (``{"provider": …, "state": …, "verifier": …, "next": …}``) and goes
# on the wire as base64url(JSON) via encode_pkce_payload() — plain
# RFC 6265 cookie-octets end to end, so every cookie-aware hop
# (browsers, Go net/http proxies, Python parsers) passes the value
# through untouched. Readers decode via parse_pkce_payload(), which
# also keeps a compatibility ladder for cookies minted by the two
# earlier wire formats during a rolling upgrade.
response.set_cookie(
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
encode_pkce_payload(payload),
max_age=_PKCE_MAX_AGE,
**_pkce_attrs(use_https=use_https, prefix=prefix),
)
"""Set the PKCE cookie (``payload`` is the segment dict; see module docstring
for the SameSite=None rationale)."""
_set(response, PKCE_COOKIE, encode_pkce_payload(payload), max_age=_PKCE_MAX_AGE,
use_https=use_https, prefix=prefix,
attrs=_pkce_attrs(use_https=use_https, prefix=prefix))
def clear_pkce_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Emit Max-Age=0 deletions for every plausible PKCE cookie variant.
A deletion is only honoured when its shape is acceptable to the
browser on the current origin: a ``Secure`` deletion can be dropped
on a plain-HTTP origin, while the ``__Host-``/``__Secure-`` name
variants REQUIRE ``Secure`` to be valid at all. So the bare-name
deletion mirrors the setter's shape for the active origin (Lax
without ``Secure`` over HTTP; ``SameSite=None; Secure`` over HTTPS,
matching :func:`set_pkce_cookie`), and the prefixed variants — which
can only ever have been set on an HTTPS origin — always carry
``Secure; SameSite=None``.
"""
"""Delete every PKCE cookie variant; the bare deletion mirrors the setter's
shape for the active origin, the prefixed ones carry ``Secure; SameSite=None``."""
_clear_cookie_variants(
response, PKCE_COOKIE,
prefix=prefix, https_samesite="none",
@@ -380,13 +235,8 @@ def clear_pkce_cookie(
def _read_with_fallback(
request: Request, bare_name: str,
) -> Optional[str]:
"""Read a cookie by checking every prefix variant in order.
The setter chooses one variant based on the active request shape;
the reader doesn't know which one fired (the request that READS
the cookie may not be the same shape as the request that SET it
in pathological cases). Trying all three guarantees we find it.
"""
"""Read a cookie trying every prefix variant (the reading request may not
have the same shape as the one that set it)."""
for variant in _NAME_VARIANTS:
value = request.cookies.get(f"{variant}{bare_name}")
if value is not None:
@@ -396,9 +246,10 @@ def _read_with_fallback(
def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]]:
"""Returns (access_token, refresh_token), either may be None."""
at = _read_with_fallback(request, SESSION_AT_COOKIE)
rt = _read_with_fallback(request, SESSION_RT_COOKIE)
return at, rt
return (
_read_with_fallback(request, SESSION_AT_COOKIE),
_read_with_fallback(request, SESSION_RT_COOKIE),
)
def read_session_provider(request: Request) -> Optional[str]:
@@ -410,50 +261,26 @@ def read_pkce_cookie(request: Request) -> Optional[str]:
return _read_with_fallback(request, PKCE_COOKIE)
# base64url wire values are exactly the urlsafe alphabet (padding is
# stripped by the encoder; the decoder restores it). Used as a cheap
# pre-filter before attempting the JSON decode so legacy wire forms
# (which always contain ``%`` or ``;``) never even reach the base64
# decoder.
# Cheap pre-filter: legacy wire forms always contain ``%`` or ``;``, which are
# outside the base64url alphabet, so they never reach the base64 decoder.
_B64URL_RE = re.compile(r"^[A-Za-z0-9_-]+={0,2}$")
def parse_pkce_payload(raw: str) -> dict[str, str]:
"""Decode + parse a PKCE cookie value into its segment dict.
"""Decode a PKCE cookie value into its segment dict (inverse of
:func:`encode_pkce_payload`). EVERY reader must go through this — a reader
that interprets the raw wire value parses zero segments and silently
disables the check it feeds (provider dispatch, CSRF state, broker binding).
Single inverse of :func:`set_pkce_cookie` /
:func:`encode_pkce_payload`. EVERY reader of the PKCE cookie must go
through this helper — a reader that interprets the raw wire value
itself parses zero segments and silently disables whatever check it
was feeding (provider dispatch, CSRF state, native-flow broker
binding).
Compatibility ladder — the PKCE cookie has a 10-minute TTL and is
opaque + server-set, so during a rolling upgrade a cookie minted by
one server version can arrive at another. Three formats, tried in
order; each rung is unambiguous:
1. **base64url(JSON)** (current): the wire value is pure urlsafe
base64 that decodes to a JSON object. Legacy forms can never
match — they always contain ``%`` (URL-encoded, #99176) or a raw
``;`` (oldest flat form), both outside the base64url alphabet.
2. **Oldest flat form** (pre-#99176): raw ``;`` between segments
(``provider=…;state=…;verifier=…``). Split as-is WITHOUT
unquoting the payload — the ``next`` segment carries its own
single URL-encoding, and unquoting here would turn a ``%3B``
inside it into a bogus delimiter and truncate the post-login
target. Neither newer format can contain a raw ``;``.
3. **URL-encoded flat form** (#99176): the whole flat payload passed
through ``quote(payload, safe="")`` — no raw ``;`` possible
(it is ``%3B``); unquote once, then split.
Rollout directions: OLD cookie → NEW server is handled here (rungs
2 and 3 parse both legacy forms correctly). NEW cookie → OLD server
(a rollback, or a mixed fleet routing the callback to a not-yet-
upgraded instance) fails the OAuth state check — the old reader
can't find a ``state`` segment in the base64url blob — and the user
simply retries login against the now-consistent fleet; no data loss,
nothing minted.
Compatibility ladder for cookies minted by an older server during a rolling
upgrade (10-minute TTL), each rung unambiguous:
1. base64url(JSON) — current.
2. Oldest flat form with raw ``;`` delimiters — split WITHOUT unquoting
(the ``next`` segment carries its own URL-encoding; unquoting first
would turn a ``%3B`` inside it into a bogus delimiter).
3. URL-encoded flat form (``quote(payload, safe="")``) — unquote once, split.
A NEW cookie hitting an OLD server fails the state check and the user
simply retries; nothing is minted.
"""
if _B64URL_RE.match(raw):
try:
@@ -465,33 +292,16 @@ def parse_pkce_payload(raw: str) -> dict[str, str]:
decoded = None
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
if ";" in raw:
# Oldest flat form: already flat, split as-is (no unquote).
return dict(
seg.split("=", 1) for seg in raw.split(";") if "=" in seg
)
# #99176 URL-encoded flat form: unquote once, then split.
return dict(
seg.split("=", 1) for seg in unquote(raw).split(";") if "=" in seg
)
flat = raw if ";" in raw else unquote(raw)
return dict(seg.split("=", 1) for seg in flat.split(";") if "=" in seg)
def set_sso_attempt_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Set the one-shot auto-SSO loop-guard marker (Phase 1).
Written by the gate the moment it auto-initiates the portal OAuth
redirect on an unauthenticated document load. The value is a constant
(``"1"``) — only its presence matters. Short Max-Age so a stale marker
can't permanently suppress a future silent attempt.
"""
response.set_cookie(
_resolved_name(SSO_ATTEMPT_COOKIE, use_https=use_https, prefix=prefix),
"1",
max_age=_SSO_ATTEMPT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
"""Set the auto-SSO loop-guard marker; only its presence matters."""
_set(response, SSO_ATTEMPT_COOKIE, "1", max_age=_SSO_ATTEMPT_MAX_AGE,
use_https=use_https, prefix=prefix)
def read_sso_attempt_cookie(request: Request) -> Optional[str]:
@@ -500,26 +310,15 @@ def read_sso_attempt_cookie(request: Request) -> Optional[str]:
def clear_sso_attempt_cookie(response: Response, *, prefix: str = "") -> None:
"""Emit Max-Age=0 deletions for the auto-SSO marker, every name variant.
Called on a successful callback and whenever the gate falls back to
/login, so the marker never lingers to suppress a later silent attempt.
"""
"""Delete the auto-SSO marker (every variant) so it never suppresses a
later silent attempt."""
_clear_cookie_variants(
response, SSO_ATTEMPT_COOKIE,
prefix=prefix, https_samesite="lax",
bare_attrs={
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
},
prefix=prefix, https_samesite="lax", bare_attrs=_lax_bare_attrs(prefix),
)
def detect_https(request: Request) -> bool:
"""Decide whether to set the ``Secure`` cookie flag.
Reads ``request.url.scheme`` — under uvicorn's ``proxy_headers=True``
(which start_server enables when the gate is active), this honours
``X-Forwarded-Proto`` from Fly's TLS terminator. Loopback traffic is
always HTTP so this returns False there.
"""
"""``Secure`` flag decision: ``request.url.scheme == "https"`` (honours
``X-Forwarded-Proto`` under uvicorn ``proxy_headers=True``)."""
return request.url.scheme == "https"
+27 -58
View File
@@ -1,37 +1,22 @@
"""Server-rendered /login page.
"""Server-rendered /login page (no React, no SPA bundle, no injected token).
No React, no JavaScript dependency. Listed providers come from the
registry; clicking a provider sends a GET to
``/auth/login?provider=<name>``.
Providers come from the registry; an OAuth provider renders an anchor to
``/auth/login?provider=<name>``, a ``supports_password`` provider renders a
credential form wired by :data:`_PASSWORD_FORM_SCRIPT`. Styling mirrors the
``@nous-research/ui`` design system; fonts load from the SPA's ``/fonts/``
mount, which the gate allowlists pre-auth.
Visual styling mirrors the Nous Research design system (the
``@nous-research/ui`` package the React dashboard uses): the same
``Collapse`` / ``Rules Compressed`` typeface, amber-on-dark colour
tokens (``#170d02`` / ``#ffac02`` / ``#fff``), uppercase + wide-tracking
brand chrome, and the inset-bevel button shadow. Fonts are served
out of the SPA's ``/fonts/`` directory which the dashboard-auth gate
already allowlists pre-auth (see ``_GATE_PUBLIC_PREFIXES`` in
``middleware.py``), so the page renders without needing the React
bundle loaded.
Test-stable class names: the existing test suite extracts the
``class="provider-btn"`` anchor href to walk the OAuth flow. That
class name MUST NOT change without updating
``tests/hermes_cli/test_dashboard_auth_401_reauth.py``.
The ``class="provider-btn"`` anchor is test-stable: the suite extracts its
href to walk the OAuth flow.
"""
from __future__ import annotations
import html
from urllib.parse import quote
from hermes_cli.dashboard_auth import list_session_providers
# Inline minimal CSS. The dashboard's full skin lives in the React
# bundle, which we deliberately do NOT load here — the login page must
# not depend on the SPA build being present or on the injected session
# token.
#
# Single curly braces are placeholders for ``str.format``; CSS curlies
# are doubled (``{{`` / ``}}``).
# Single curly braces are ``str.format`` placeholders; CSS curlies are doubled.
_LOGIN_HTML_TEMPLATE = """\
<!doctype html>
<html lang="en">
@@ -404,14 +389,10 @@ an SSH tunnel or Tailscale.</p>
"""
# Inline script that wires every password provider form to POST JSON to
# ``/auth/password-login`` and navigate on success. Emitted ONLY when at
# least one ``supports_password`` provider is listed (OAuth-only login
# pages stay script-free, preserving the no-JS contract for that case).
#
# Plain string (NOT run through ``str.format``), so braces are literal —
# do not double them. A single delegated submit handler covers all forms;
# the provider name is read from the form's ``data-provider`` attribute.
# Emitted ONLY when a ``supports_password`` provider is listed, so OAuth-only
# login pages stay script-free. Plain string (not ``str.format``): braces are
# literal. One delegated submit handler covers every form; the provider name
# comes from the form's ``data-provider`` attribute.
_PASSWORD_FORM_SCRIPT = """\
<script>
(function () {
@@ -461,26 +442,19 @@ _PASSWORD_FORM_SCRIPT = """\
def render_login_html(*, next_path: str = "") -> str:
"""Return the full HTML for ``GET /login``.
``next_path`` — when set, the post-login landing path the user
originally requested. Threaded into each provider button's ``href``
as a ``next=`` query parameter so the OAuth round trip carries it
end-to-end. The caller (``routes.login_page``) is responsible for
validating ``next_path`` against the same-origin rules before we
emit it; we still HTML-escape it as defence in depth.
``next_path`` is threaded into each provider button/form so the OAuth round
trip carries it end-to-end. The caller validates it same-origin; it is
HTML-escaped here as defence in depth.
"""
providers = list_session_providers()
if not providers:
return _EMPTY_HTML
if next_path:
# URL-encode then HTML-escape. The URL-encode step matches the
# gate's ``_safe_next_target`` output shape (also URL-encoded),
# so a value that round-tripped from /login?next=... back into
# the button href is byte-identical.
from urllib.parse import quote
next_qs = f"&next={html.escape(quote(next_path, safe=''), quote=True)}"
else:
next_qs = ""
# URL-encode then HTML-escape, matching the gate's ``_safe_next_target``
# shape so a round-tripped value is byte-identical.
next_qs = (
f"&next={html.escape(quote(next_path, safe=''), quote=True)}" if next_path else ""
)
buttons = []
needs_password_script = False
@@ -494,23 +468,18 @@ def render_login_html(*, next_path: str = "") -> str:
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
script = _PASSWORD_FORM_SCRIPT if needs_password_script else ""
return _LOGIN_HTML_TEMPLATE.format(
provider_buttons="\n".join(buttons),
password_script=script,
password_script=_PASSWORD_FORM_SCRIPT if needs_password_script else "",
)
def _render_password_form(provider, next_path: str) -> str:
"""Render a username/password form for a ``supports_password`` provider.
"""Username/password form for a ``supports_password`` provider.
The form is wired by :data:`_PASSWORD_FORM_SCRIPT` (a single delegated
submit handler) to POST JSON to ``/auth/password-login`` and navigate
on success. ``next_path`` is carried in a hidden field; it has already
been validated same-origin by the caller and is HTML-escaped here as
defence in depth. The provider ``name`` is emitted in a ``data-``
attribute (not a hidden input) so the script reads it without trusting
form-field ordering.
``next_path`` rides in a hidden field (already validated by the caller,
HTML-escaped here). The provider name is a ``data-`` attribute so the
script does not depend on field ordering.
"""
pname = html.escape(provider.name, quote=True)
plabel = html.escape(provider.display_name)
+162 -376
View File
@@ -1,23 +1,17 @@
"""Auth-gate middleware for the dashboard.
Engaged when ``app.state.auth_required is True``. The gate's job:
1. Allow a small set of routes through unauthenticated (login page,
``/auth/*`` OAuth round trip, ``/api/auth/providers``, static
assets).
2. For everything else, demand a valid session cookie and attach the
verified :class:`Session` to ``request.state.session``.
3. On HTML routes, redirect missing/invalid cookies to ``/login``.
On ``/api/*`` routes, return 401 JSON.
The middleware is a no-op when ``auth_required`` is False (loopback
mode); the legacy ``_SESSION_TOKEN`` ``auth_middleware`` handles those
binds.
Engaged when ``app.state.auth_required is True``; a no-op otherwise (loopback
mode is handled by the legacy ``_SESSION_TOKEN`` ``auth_middleware``). Allows
the auth-bootstrap routes and static assets through unauthenticated; for
everything else demands a bearer token or a valid session cookie and attaches
the verified :class:`Session` to ``request.state.session``. HTML routes are
redirected to ``/login``; ``/api/*`` routes get a 401 JSON envelope.
"""
from __future__ import annotations
import logging
from typing import Awaitable, Callable
from urllib.parse import quote
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
@@ -30,22 +24,31 @@ from hermes_cli.dashboard_auth.base import (
RefreshExpiredError,
)
from hermes_cli.dashboard_auth.cookies import (
clear_session_cookies,
clear_sso_attempt_cookie,
detect_https,
read_session_cookies,
read_session_provider,
read_sso_attempt_cookie,
set_session_cookies,
set_session_provider_cookie,
set_sso_attempt_cookie,
)
from hermes_cli.dashboard_auth.prefix import prefix_from_request
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
from hermes_cli.dashboard_auth.request_utils import (
access_token_max_age as _expires_in_seconds,
client_ip as _client_ip,
extract_bearer as _extract_bearer,
is_safe_next_path,
unreachable_response,
)
_log = logging.getLogger(__name__)
# Prefixes that bypass the auth gate. Match via ``path == prefix`` or
# ``path.startswith(prefix)`` — so ``/assets/`` (with trailing slash)
# matches ``/assets/foo.css`` but not ``/assetsleak``. Auth-bootstrap
# (login page, OAuth round trip, provider listing) and static asset
# mounts go here.
# Prefix-matched (``path == p or path.startswith(p)``) bypass list: auth
# bootstrap routes and static asset mounts. ``/assets/`` with the trailing
# slash matches ``/assets/foo.css`` but not ``/assetsleak``.
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
"/auth/login",
"/auth/callback",
@@ -66,42 +69,24 @@ _GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
def _path_is_public(path: str) -> bool:
"""True if ``path`` bypasses the OAuth auth gate.
"""True if ``path`` bypasses the gate.
Two sources of public-ness:
* :data:`PUBLIC_API_PATHS` — the shared ``/api/*`` allowlist that
the legacy ``_SESSION_TOKEN`` middleware also honours. Matched
exactly (no prefix expansion) so adding ``/api/status`` doesn't
accidentally expose ``/api/status/secret-extension``.
* :data:`_GATE_PUBLIC_PREFIXES` — auth-bootstrap routes and static
mounts. Prefix-matched so ``/assets/foo.css`` lights up via
``/assets/``.
:data:`PUBLIC_API_PATHS` (shared with the legacy middleware) is matched
exactly so ``/api/status`` never exposes ``/api/status/extension``;
:data:`_GATE_PUBLIC_PREFIXES` is prefix-matched.
"""
if path in PUBLIC_API_PATHS:
return True
return any(
path == prefix or path.startswith(prefix)
for prefix in _GATE_PUBLIC_PREFIXES
)
def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else ""
return any(path == p or path.startswith(p) for p in _GATE_PUBLIC_PREFIXES)
def _ordered_session_providers(
provider_hint: str | None,
) -> list[DashboardAuthProvider]:
"""Prefer the hinted provider without making the hint authoritative.
"""Session providers with the hinted one first (stable sort).
The cookie can outlive a provider rename/removal or become stale after a
deployment change. A stable sort moves a matching provider to the front
while preserving registration order for every remaining candidate; an
unknown hint therefore leaves the normal scan unchanged.
The hint is a routing preference, not authoritative: a stale/unknown hint
(provider renamed or removed) leaves the normal registration order intact.
"""
providers = list_session_providers()
if provider_hint:
@@ -109,46 +94,36 @@ def _ordered_session_providers(
return providers
def _unauth_response(request: Request, *, reason: str) -> Response:
"""API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login.
def _safe_next_target(request: Request) -> str:
"""URL-encoded ``next`` value for the login redirect, or ``""``.
The JSON envelope carries a ``login_url`` field with a ``next=`` query
string so the SPA's global 401 handler can drop the user back where
they were after re-auth. The contract is intentionally simple so any
fetch-wrapper can implement the redirect without parsing details:
if response.status === 401 && body.error in ("unauthenticated",
"session_expired"):
window.location.assign(body.login_url);
HTML redirects also carry the ``next=`` query string so direct
navigation to ``/sessions`` (etc.) without a cookie comes back to
``/sessions`` after login.
Under a reverse proxy with ``X-Forwarded-Prefix: /hermes``, the
``login_url`` is prefixed (``/hermes/login?next=...``) so the
browser's window.location.assign / Location: follow lands on the
proxied login page rather than the bare ``/login`` (which the
proxy doesn't route to the dashboard).
Only same-origin relative paths outside the auth flow and ``/api`` are
kept (see :func:`is_safe_next_path`); the query string is preserved. SPA
deep links that are dropped fall back to the SPA's own
``sessionStorage["hermes.lastLocation"]``.
"""
from hermes_cli.dashboard_auth.prefix import prefix_from_request
path = request.url.path
if not path or not is_safe_next_path(path):
return ""
query = request.url.query
return quote(f"{path}?{query}" if query else path, safe="")
def _unauth_response(request: Request, *, reason: str) -> Response:
"""API routes -> 401 JSON with ``login_url``; HTML routes -> 302 -> /login.
fetch() follows a 302 opaquely into the cross-origin OAuth dance, so API
routes never get redirects; the SPA's global 401 handler navigates to
``login_url`` when ``error`` is ``unauthenticated`` or ``session_expired``.
Both shapes carry ``next=`` and the active proxy prefix.
"""
next_param = _safe_next_target(request)
prefix = prefix_from_request(request)
login_url = (
f"{prefix}/login?next={next_param}" if next_param
else f"{prefix}/login"
)
login_url = f"{prefix}/login?next={next_param}" if next_param else f"{prefix}/login"
if path.startswith("/api/"):
# API routes never get redirects: the browser fetch() API would
# follow a 302 into the cross-origin OAuth dance opaquely. Return
# 401 with a structured envelope so the SPA can full-page-navigate
# to login_url.
if request.url.path.startswith("/api/"):
error_code = (
"session_expired"
if reason == "invalid_or_expired_session"
"session_expired" if reason == "invalid_or_expired_session"
else "unauthenticated"
)
return JSONResponse(
@@ -164,324 +139,161 @@ def _unauth_response(request: Request, *, reason: str) -> Response:
def _auto_sso_response(request: Request) -> Response | None:
"""Maybe auto-initiate the portal OAuth redirect on an unauth HTML load.
"""302 straight to ``/auth/login`` on an unauthenticated HTML load, or ``None``.
Returns a 302 → ``/auth/login`` (the existing OAuth-initiation route)
when ALL of the following hold, else ``None`` (caller falls back to the
ordinary ``/login`` interstitial):
* the request is an HTML document navigation, not an ``/api/*`` fetch
(a fetch() would follow the 302 into the cross-origin OAuth dance
opaquely — same reason ``_unauth_response`` never redirects APIs);
* exactly ONE interactive provider is registered — with two or more we
can't pick for the user, so the ``/login`` chooser must render; with
zero there's nothing to redirect to;
* that provider is OAuth-style, not a password form provider. Password
providers must render ``/login`` so the user can enter credentials;
* the one-shot loop-guard marker is ABSENT. Its presence means we
already bounced to the portal once and came back still
unauthenticated (no portal session) — auto-redirecting again would
ping-pong, so we fall through to ``/login`` and clear the marker.
The portal ``/oauth/authorize`` auto-approves any current member of the
dashboard's org and is a silent 302 when the user already holds a portal
session, so for the common case (clicked a dashboard link while signed
in to the portal) this removes the interstitial CLICK entirely. It
removes a click, not a security check: the redirect lands on
``/auth/login`` which runs the unchanged PKCE auth-code flow.
Only when: the request is a document load (not ``/api/*``); exactly one
interactive provider is registered and it is OAuth-style (a password
provider must render the form); and the one-shot loop-guard cookie is
absent. A present marker means the portal had no session for us last time
— clear it and fall back to ``/login`` rather than ping-pong. Removes the
interstitial click, not a security check: ``/auth/login`` runs the
unchanged PKCE flow.
"""
path = request.url.path
# APIs never auto-redirect (see _unauth_response). Only document loads.
if path.startswith("/api/"):
if request.url.path.startswith("/api/"):
return None
# Already bounced once and still no session → portal has no session for
# this user. Stop here, clear the marker, let /login render.
if read_sso_attempt_cookie(request):
from hermes_cli.dashboard_auth.prefix import prefix_from_request
resp = _unauth_response(request, reason="no_cookie")
clear_sso_attempt_cookie(resp, prefix=prefix_from_request(request))
return resp
# list_session_providers() already filters on supports_session=True, so
# token-only credentials (drain/service providers) are never candidates.
providers = list_session_providers()
if len(providers) != 1:
# Zero → nothing to redirect to. Two+ → user must choose at /login.
return None
from hermes_cli.dashboard_auth.prefix import prefix_from_request
provider = providers[0]
if getattr(provider, "supports_password", False):
return None
prefix = prefix_from_request(request)
next_param = _safe_next_target(request)
from urllib.parse import quote
auth_login = f"{prefix}/auth/login?provider={quote(provider.name, safe='')}"
if next_param:
auth_login = f"{auth_login}&next={next_param}"
resp = RedirectResponse(url=auth_login, status_code=302)
# Drop the one-shot marker so a return trip that's STILL unauthenticated
# (portal had no session) trips the guard above next time instead of
# looping. Detect HTTPS for the Secure flag the same way the auth routes
# do; bind Path via the active prefix.
from hermes_cli.dashboard_auth.cookies import detect_https
set_sso_attempt_cookie(
resp, use_https=detect_https(request), prefix=prefix,
)
set_sso_attempt_cookie(resp, use_https=detect_https(request), prefix=prefix)
audit_log(
AuditEvent.LOGIN_START,
provider=provider.name,
reason="auto_sso",
ip=_client_ip(request),
provider=provider.name, reason="auto_sso", ip=_client_ip(request),
)
return resp
def _safe_next_target(request: Request) -> str:
"""Build the URL-encoded ``next`` query value, or empty string.
def _verify_access_token(
request: Request, *, access_token: str, provider_hint: str | None = None,
audit: bool = True,
):
"""Run ``verify_session`` across the provider stack; Session or ``None``.
Only same-origin relative paths are accepted; absolute URLs or
``//evil.com`` open-redirect attempts are silently dropped. The empty
string return means the caller produces a bare ``/login`` URL — fine,
user lands at the dashboard root after re-auth.
"""
path = request.url.path
# Reject anything that doesn't start with "/" or starts with "//"
# (protocol-relative URL — would open-redirect to an attacker host).
if not path or not path.startswith("/") or path.startswith("//"):
return ""
# Don't redirect back to the auth routes themselves — that loops.
if any(
path == p or path.startswith(p)
for p in ("/login", "/auth/", "/api/auth/")
):
return ""
# Reject ALL ``/api/*`` paths. The 401-envelope code path fires for
# any unauthenticated SPA fetch (e.g. ``GET /api/analytics/models``
# from ModelsPage), and the SPA's global 401 handler full-page
# navigates to ``login_url``. After the OAuth round trip the user
# would land on the API URL and see raw JSON instead of the
# dashboard. SPA routes survive (they don't start with ``/api/``);
# the SPA's own ``sessionStorage["hermes.lastLocation"]`` fallback
# in ``web/src/lib/api.ts`` covers the deep-link case.
if path == "/api" or path.startswith("/api/"):
return ""
# Preserve query string if present (e.g. /sessions?page=2).
query = request.url.query
target = f"{path}?{query}" if query else path
# urlencode the whole thing as a single value.
from urllib.parse import quote
return quote(target, safe="")
def _extract_bearer(request: Request) -> str:
"""Return the ``Authorization: Bearer <token>`` value, or ""."""
auth = request.headers.get("authorization", "")
parts = auth.split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def _verify_bearer(request: Request, *, access_token: str):
"""Verify a native-app bearer access token via the session-provider stack.
Returns the :class:`Session` on success, or ``None`` if no provider
recognises the token (expired/invalid/unknown). Mirrors the cookie path's
verify loop, including the "one provider unreachable ⇒ don't force
re-login" semantics: a transient IDP outage returns a 503 rather than a
401, so the desktop retries instead of dropping the user to full re-login.
Unlike the cookie path there is no server-side refresh — the desktop owns
its refresh token and rotates via ``/auth/native/refresh``.
A provider that does not recognise the token returns ``None`` and the
next is tried. A ``ProviderError`` (IDP/JWKS unreachable) must NOT abort
the chain — the token may belong to a different, reachable provider. If
no provider verifies AND at least one was unreachable, raises
``ProviderError(name)`` so the caller returns 503 instead of forcing a
re-login through a possibly-unreachable refresh.
"""
unreachable_provider: str | None = None
for provider in list_session_providers():
for provider in _ordered_session_providers(provider_hint):
try:
session = provider.verify_session(access_token=access_token)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during bearer verify: %s",
provider.name, e,
"dashboard-auth: provider %r unreachable during %s: %s",
provider.name, "verify" if audit else "bearer verify", e,
)
if unreachable_provider is None:
unreachable_provider = provider.name
continue
if session is not None:
return session
if unreachable_provider is not None:
# Signal transient outage to the caller via a sentinel exception the
# middleware turns into 503. Raising keeps the "don't logout on a
# flaky IDP" contract identical to the cookie path.
raise ProviderError(unreachable_provider)
return None
async def gated_auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Engaged only when ``app.state.auth_required is True``.
No-op pass-through in loopback mode so the legacy auth_middleware can
handle those binds via ``_SESSION_TOKEN``.
"""
if not getattr(request.app.state, "auth_required", False):
return await call_next(request)
# A request already authenticated by the token-auth seam (a service caller
# on a registered token route) carries ``token_authenticated`` — it is NOT
# a cookie session and must not be bounced to /login. Pass it through; the
# seam already attached ``request.state.token_principal``.
if getattr(request.state, "token_authenticated", False):
return await call_next(request)
path = request.url.path
if _path_is_public(path):
return await call_next(request)
# RFC 8252 native-app bearer path (goal: no session cookies). The desktop
# authenticates REST with ``Authorization: Bearer <access_token>`` — the
# SAME provider-minted access token the cookie flow stores in
# ``hermes_session_at``. Verify it with the identical ``verify_session``
# provider stack and attach the Session; on success we're done, with no
# cookie set or read. A missing/expired/invalid bearer falls through to
# the cookie path (a request may legitimately carry neither). Token
# rotation for this path is the desktop's job via /auth/native/refresh —
# the gate never sets a cookie here, so the transparent cookie-rotation
# below must not run for a bearer caller.
bearer = _extract_bearer(request)
if bearer:
try:
bearer_session = _verify_bearer(request, access_token=bearer)
except ProviderError as e:
# At least one provider's IDP/JWKS was unreachable and none
# verified the token — transient outage, not bad credentials.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
)
if bearer_session is not None:
request.state.session = bearer_session
return await call_next(request)
# A bearer was presented but didn't verify (expired/invalid/unknown).
# Return the structured 401 so the desktop knows to refresh or
# re-login, rather than falling through to the cookie/login redirect.
return _unauth_response(request, reason="invalid_or_expired_session")
at, _rt = read_session_cookies(request)
provider_hint = read_session_provider(request)
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh. Before falling back to the /login interstitial, try to
# silently bounce the user through the portal OAuth flow: the portal
# auto-approves org members and 302s straight back when they already
# hold a portal session, so the interstitial click is pure friction
# for the common case. The one-shot loop-guard inside _auto_sso_response
# prevents a ping-pong when the portal genuinely has no session.
auto = _auto_sso_response(request)
if auto is not None:
return auto
return _unauth_response(request, reason="no_cookie")
# Try every registered provider's verify_session in turn. Providers
# MUST return None for tokens they don't recognise (not raise). This
# lets multiple providers stack — the first one that recognises a
# token wins.
#
# When the access-token cookie is absent but a refresh-token cookie is
# present, skip verification and go straight to the refresh path below.
# This is the COMMON expiry case, not an edge case: the access-token
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
# the browser EVICTS it the moment the token lapses, while the
# refresh-token cookie lives for 30 days. From that point the browser
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
# bounce the user to /login on every expiry despite holding a perfectly
# good refresh token — defeating the whole transparent-refresh feature.
session = None
if at:
# Try every registered provider's verify_session in turn. A provider
# that doesn't recognise the token returns None and we move on; the
# first provider that returns a Session wins.
#
# A provider may instead raise ProviderError (its IDP/JWKS is
# unreachable, so it can neither confirm nor deny the token). With
# multiple providers stacked, that MUST NOT abort the chain — the
# token may belong to a *different*, reachable provider. (Concretely:
# a self-hosted-OIDC session hits the `nous` provider first, which
# tries to reach Nous Portal's JWKS; if that's unreachable it raises,
# but the `self-hosted` provider can still verify the token.) So we
# remember the unreachable error and keep going. Only if NO provider
# verifies the token AND at least one was unreachable do we surface a
# 503 — distinguishing "transient IDP outage" (don't force re-login)
# from "token genuinely invalid" (fall through to refresh/relogin).
unreachable_provider: str | None = None
for provider in _ordered_session_providers(provider_hint):
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during verify: %s",
provider.name, e,
)
if audit:
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
if unreachable_provider is None:
unreachable_provider = provider.name
continue
if session is not None:
break
if session is None and unreachable_provider is not None:
# No provider could verify the token and at least one couldn't be
# reached — treat as a transient outage rather than forcing a
# re-login through a (possibly also-unreachable) refresh.
return JSONResponse(
{"detail": f"Auth provider {unreachable_provider!r} unreachable"},
status_code=503,
)
if unreachable_provider is None:
unreachable_provider = provider.name
continue
if session is not None:
return session
if unreachable_provider is not None:
raise ProviderError(unreachable_provider)
return None
if session is None:
# Access token is expired/invalid. Before forcing re-login, try to
# rotate it using the refresh token (if the session cookie carries
# one). On success we re-set the rotated cookies on the response and
# serve the request transparently; only after every provider rejects
# the RT do we fall through to clear-and-relogin.
def _verify_bearer(request: Request, *, access_token: str):
"""Verify a native-app bearer token (no cookie, no server-side refresh —
the desktop rotates via ``/auth/native/refresh``). Same 503-on-outage
semantics as the cookie path.
"""
return _verify_access_token(request, access_token=access_token, audit=False)
async def gated_auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Engaged only when ``app.state.auth_required is True``."""
if not getattr(request.app.state, "auth_required", False):
return await call_next(request)
# Already authenticated by the token-auth seam (service caller on a
# registered token route): not a cookie session, must not bounce to /login.
if getattr(request.state, "token_authenticated", False):
return await call_next(request)
if _path_is_public(request.url.path):
return await call_next(request)
# RFC 8252 native-app bearer path: the same provider-minted access token
# the cookie flow stores, verified with the same provider stack, no cookie
# read or set. A presented-but-invalid bearer gets the structured 401 so
# the desktop refreshes/re-logs instead of following a cookie redirect.
bearer = _extract_bearer(request)
if bearer:
try:
refreshed = _attempt_refresh(
request,
refresh_token=_rt,
provider_hint=provider_hint,
bearer_session = _verify_bearer(request, access_token=bearer)
except ProviderError as e:
return unreachable_response(str(e))
if bearer_session is not None:
request.state.session = bearer_session
return await call_next(request)
return _unauth_response(request, reason="invalid_or_expired_session")
at, _rt = read_session_cookies(request)
provider_hint = read_session_provider(request)
if not at and not _rt:
# No session at all: try the silent portal bounce before /login.
auto = _auto_sso_response(request)
if auto is not None:
return auto
return _unauth_response(request, reason="no_cookie")
# An absent AT with a present RT is the COMMON expiry case (the AT cookie's
# Max-Age tracks the token TTL, so the browser evicts it first) — skip
# straight to refresh rather than bouncing to /login.
session = None
if at:
try:
session = _verify_access_token(
request, access_token=at, provider_hint=provider_hint,
)
except ProviderError as e:
# At least one provider could not confirm or reject the RT, and no
# other provider refreshed it. Preserve the cookies and surface a
# transient outage instead of turning uncertainty into a logout.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
return unreachable_response(str(e))
if session is None:
# Rotate via the refresh token before forcing re-login. On success the
# rotated cookies are re-set and the request served transparently.
try:
refreshed = _attempt_refresh(
request, refresh_token=_rt, provider_hint=provider_hint,
)
except ProviderError as e:
# Uncertain (provider unreachable), not rejected: keep the cookies.
return unreachable_response(str(e))
if refreshed is not None:
new_session, refreshing_provider = refreshed
request.state.session = new_session
response = await call_next(request)
# Persist the ROTATED tokens. Portal rotates the refresh token on
# every refresh and runs reuse-detection, so writing the new RT
# back is mandatory: a stale RT cookie would replay a rotated
# token on the next refresh and (outside Portal's grace) revoke
# the whole session. Bind cookie Secure/Path to the request shape.
from hermes_cli.dashboard_auth.cookies import (
detect_https,
set_session_cookies,
)
from hermes_cli.dashboard_auth.prefix import prefix_from_request
# Writing the ROTATED RT back is mandatory: Portal runs reuse
# detection, so replaying the stale RT would revoke the session.
set_session_cookies(
response,
access_token=new_session.access_token,
@@ -505,23 +317,14 @@ async def gated_auth_middleware(
ip=_client_ip(request),
)
response = _unauth_response(request, reason="invalid_or_expired_session")
# Clear the dead cookies so the browser doesn't keep sending them.
# Refresh already failed (or there was no RT), so the only correct
# next step is full re-auth via /login. Importing locally avoids a
# cycle with cookies → middleware at module load. Pass the active
# prefix so the deletion's Path matches the set-Path (otherwise
# the browser ignores it).
from hermes_cli.dashboard_auth.cookies import clear_session_cookies
from hermes_cli.dashboard_auth.prefix import prefix_from_request
# Refresh failed (or no RT): clear the dead cookies under the active
# prefix so the deletion Path matches the set Path.
clear_session_cookies(response, prefix=prefix_from_request(request))
return response
request.state.session = session
response = await call_next(request)
if not provider_hint and session.provider:
from hermes_cli.dashboard_auth.cookies import detect_https
from hermes_cli.dashboard_auth.prefix import prefix_from_request
set_session_provider_cookie(
response,
provider=session.provider,
@@ -531,30 +334,13 @@ async def gated_auth_middleware(
return response
def _expires_in_seconds(session) -> int:
"""Seconds until the access token's ``exp``, floored at 60.
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
cookie's Max-Age tracks the token lifetime even on a slightly skewed
clock. ``time`` imported locally to keep the module's import surface
minimal.
"""
import time
return max(60, int(session.expires_at) - int(time.time()))
def _attempt_refresh(request: Request, *, refresh_token, provider_hint: str | None = None):
"""Try to rotate an expired session via the refresh token.
"""Rotate an expired session via the refresh token; ``(Session, provider_name)`` or ``None``.
The provider hint only changes candidate order. ``RefreshExpiredError``
rejects the token for that candidate, but cannot prove ownership because
providers such as Basic raise it for foreign opaque tokens too. Likewise,
``ProviderError`` only makes that candidate unavailable. Both are audited
and the remaining providers are tried. Returns ``None`` only when there is
no RT or every reachable provider rejects it. If no provider succeeds and
at least one raised ``ProviderError``, re-raises with that provider's name
so the caller can return 503 without clearing potentially valid cookies.
``RefreshExpiredError`` rejects the token for that candidate only (Basic
raises it for foreign opaque tokens too), so remaining providers are
tried. If none succeeds and any raised ``ProviderError``, re-raises with
that provider's name so the caller returns 503 without clearing cookies.
"""
if not refresh_token:
return None
+58 -138
View File
@@ -1,70 +1,32 @@
"""Gateway-brokered RFC 8252 (OAuth 2.0 for Native Apps) authorization store.
The desktop app is a *native* OAuth client that wants to sign in to a gated
gateway **without an embedded webview and without relying on browser session
cookies**. It cannot be a direct OAuth client of the upstream IDP (Nous
Portal): the Portal ``client_id`` is per-gateway-instance
(``agent:{instance_id}``) and the Portal validates that the ``redirect_uri``
ends in ``/auth/callback`` on the gateway's own public origin — a desktop
loopback ``127.0.0.1`` redirect is rejected. So the **gateway brokers** the
flow: it is the authorization server *to the desktop*, and an OAuth client *to
the Portal*. This is still a textbook RFC 8252 deployment — system browser,
loopback redirect, PKCE, tokens returned to the app (never cookies).
The desktop cannot be a direct OAuth client of the upstream IDP (the Portal
``client_id`` is per gateway instance and only accepts the gateway's own
``/auth/callback`` redirect), so the gateway brokers: it is the authorization
server *to the desktop* and an OAuth client *to the Portal* — still textbook
RFC 8252: system browser, loopback redirect, PKCE, tokens returned to the app
and never as cookies.
Wire shape (all gateway-side state lives in this module):
1. Desktop generates its own PKCE pair (cv_d, cc_d) + ``state``, opens a
loopback listener, and opens the system browser at
``/auth/native/authorize`` with cc_d, state and its loopback redirect_uri.
2. The gateway stashes a *pending authorization* (:func:`register_pending`)
keyed by an opaque ``broker_state`` and runs the EXISTING upstream flow;
broker_state rides inside the gateway's own PKCE cookie, so no desktop
secret reaches the Portal.
3. On the upstream callback (or a successful password login) the gateway
mints a one-time gateway code bound to cc_d (:func:`complete_pending`) and
302s the browser to ``redirect_uri?code=<gw_code>&state=<state>``.
4. The desktop POSTs ``/auth/native/token`` with gw_code + cv_d; the gateway
checks ``S256(cv_d) == cc_d`` (:func:`redeem_code`), consumes the code and
returns the upstream tokens in the JSON body.
5. The desktop keeps them in the OS keychain and uses ``Authorization: Bearer``.
1. Desktop generates its OWN PKCE pair ``(cv_d, cc_d)`` and a ``state``, opens
a loopback listener on ``127.0.0.1:<port>``, and opens the system browser
to the gateway's ``GET /auth/native/authorize?...`` carrying ``cc_d``,
``state``, and its loopback ``redirect_uri``.
2. The gateway ``authorize`` route stashes a **pending authorization**
(``register_pending``) keyed by an opaque ``broker_state`` and runs the
EXISTING upstream PKCE flow (``provider.start_login`` → Portal
``/oauth/authorize`` → gateway ``/auth/callback``). The desktop's
``cc_d`` / ``state`` / loopback ``redirect_uri`` ride through the upstream
round trip inside the gateway's own PKCE cookie, so no desktop secret is
ever exposed to the Portal.
3. On the upstream callback the gateway holds a verified :class:`Session`. It
**mints a one-time gateway authorization code** (``complete_pending``)
bound to the desktop's ``cc_d``, and 302s the browser to the desktop's
``redirect_uri?code=<gw_code>&state=<state>``.
4. The desktop's loopback listener catches ``gw_code``, then POSTs
``/auth/native/token`` with ``gw_code`` + its ``cv_d``. The gateway
verifies ``SHA256(cv_d) == cc_d`` (``redeem_code``), consumes the code
(single use), and returns the upstream ``access_token`` /
``refresh_token`` / ``expires_at`` **in the JSON body**.
5. The desktop stores those in the OS keychain and authenticates REST with
``Authorization: Bearer <access_token>`` (via the existing ``token_auth``
seam) and mints ws-tickets the same way — no cookies anywhere.
Password providers ride the same broker with step 2 swapped: there is no
upstream IDP, so ``/auth/native/authorize`` sends the system browser to the
interactive ``/login`` form (broker_state in the PKCE cookie) and a successful
``/auth/password-login`` plays the role of the upstream callback — it calls
:func:`complete_pending` and bounces the browser to the loopback redirect.
Steps 4–5 are identical. The point of brokering a password login at all is
that the system browser can autofill from the OS password manager (macOS
Passwords, etc.), which no embedded desktop webview can.
Security properties this module guarantees:
* **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client
that presented the matching ``code_challenge``. An attacker who intercepts
the loopback ``gw_code`` (e.g. a hostile process racing the redirect) cannot
exchange it without ``cv_d``, which never leaves the desktop.
* **Single use.** ``redeem_code`` pops the entry; a replay finds nothing.
* **Short TTLs.** A pending authorization lives ``_PENDING_TTL`` seconds (the
interactive login window); a minted code lives ``_CODE_TTL`` seconds (the
loopback round trip is sub-second). Expired entries are refused and GC'd.
* **Opaque, high-entropy handles.** ``broker_state`` and ``gw_code`` are
256-bit ``secrets.token_urlsafe`` values; comparison is constant-time.
* **No secret logging.** The module stores tokens transiently in memory only
between callback and redemption; nothing here writes them to disk (the
audit log strips token fields).
In-memory and process-local: the dashboard is a single process, so no
distributed coordination is needed (mirrors ``ws_tickets``). A functional API
(not a class) keeps ``time.time`` patchable in tests.
Security properties: PKCE binding (an intercepted gw_code is useless without
cv_d), single use (redemption pops the entry), short TTLs, 256-bit opaque
handles compared in constant time, no secret logging. In-memory and
process-local (single dashboard process); functional API keeps ``time.time``
patchable in tests.
"""
from __future__ import annotations
@@ -80,24 +42,15 @@ from typing import Dict, Optional
from hermes_cli.dashboard_auth.base import Session
# TTL for a pending authorization (step 2→3): the whole interactive login,
# including the user typing Portal credentials / approving in the browser.
_PENDING_TTL_SECONDS = 600 # 10 minutes — mirrors the PKCE cookie lifetime.
# TTL for a minted gateway code (step 3→4): only the loopback redirect + the
# desktop's immediate token POST, which is sub-second in practice.
_CODE_TTL_SECONDS = 120 # 2 minutes — generous for a slow local hop.
# Cap the number of concurrent pending/issued entries so a misbehaving or
# malicious client cannot grow the store unbounded. Well above any legitimate
# concurrent-login count for a single desktop user.
# Pending authorization: the whole interactive login (mirrors the PKCE cookie).
_PENDING_TTL_SECONDS = 600
# Minted code: only the loopback redirect + immediate token POST.
_CODE_TTL_SECONDS = 120
# Global cap so a misbehaving client cannot grow the store unbounded.
_MAX_ENTRIES = 256
# Per-IP cap on concurrent PENDING authorizations. /auth/native/authorize is a
# public (pre-auth) route, so without this a single unauthenticated spammer
# could fill the global store (600s TTL each) and lock out legitimate native
# logins for the pending window. A real desktop runs at most a couple of
# concurrent sign-ins from one address; 8 is generous.
# Per-IP cap on PENDING entries: /auth/native/authorize is a public pre-auth
# route, so one spammer must not fill the global store (600 s each) and lock
# out legitimate native logins.
_MAX_PENDING_PER_IP = 8
_lock = threading.Lock()
@@ -105,11 +58,7 @@ _lock = threading.Lock()
@dataclass
class _Pending:
"""An in-flight native authorization awaiting the upstream callback.
Created when the desktop hits ``/auth/native/authorize`` and consumed when
the upstream ``/auth/callback`` completes and mints the gateway code.
"""
"""In-flight native authorization awaiting the upstream callback."""
code_challenge: str # the DESKTOP's S256 challenge (cc_d), base64url no-pad
redirect_uri: str # the desktop's loopback redirect (127.0.0.1:<port>/...)
@@ -127,10 +76,8 @@ class _IssuedCode:
expires_at: int
# broker_state -> _Pending
_pending: Dict[str, _Pending] = {}
# gw_code -> _IssuedCode
_issued: Dict[str, _IssuedCode] = {}
_pending: Dict[str, _Pending] = {} # broker_state -> _Pending
_issued: Dict[str, _IssuedCode] = {} # gw_code -> _IssuedCode
class NativeFlowError(Exception):
@@ -157,18 +104,19 @@ def _s256(verifier: str) -> str:
def _gc_locked(now: int) -> None:
"""Drop expired pending + issued entries. Caller holds ``_lock``."""
expired_p = [k for k, v in _pending.items() if v.expires_at < now]
for k in expired_p:
_pending.pop(k, None)
expired_c = [k for k, v in _issued.items() if v.expires_at < now]
for k in expired_c:
_issued.pop(k, None)
for store in (_pending, _issued):
for k in [k for k, v in store.items() if v.expires_at < now]:
store.pop(k, None)
def _capacity_ok_locked() -> bool:
return (len(_pending) + len(_issued)) < _MAX_ENTRIES
def _now(now: Optional[int]) -> int:
return int(time.time()) if now is None else now
def register_pending(
*,
code_challenge: str,
@@ -179,23 +127,11 @@ def register_pending(
) -> str:
"""Stash a pending native authorization; return an opaque ``broker_state``.
Called by ``/auth/native/authorize``. ``code_challenge`` is the DESKTOP's
S256 challenge (``cc_d``) — we never see the verifier until redemption.
``redirect_uri`` is the desktop's loopback callback and ``client_state`` is
the desktop's own CSRF ``state`` (echoed verbatim on the final redirect).
``client_ip`` is the requester's address, used only for the per-IP pending
cap below.
The returned ``broker_state`` is what the gateway threads through its OWN
upstream PKCE round trip (inside the ``hermes_session_pkce`` cookie), so the
callback can find this entry again via :func:`complete_pending`.
Raises ``NativeFlowError`` if the store is at capacity or the caller's IP
already holds ``_MAX_PENDING_PER_IP`` live pending entries (fail closed —
this is a public pre-auth route, so one spammer must not be able to fill
the global store and deny sign-in to everyone else).
``code_challenge`` is the DESKTOP's cc_d (the verifier is never seen until
redemption). Raises ``NativeFlowError`` (fail closed) when the store is at
capacity or ``client_ip`` already holds ``_MAX_PENDING_PER_IP`` entries.
"""
now = int(time.time()) if now is None else now
now = _now(now)
broker_state = secrets.token_urlsafe(32)
with _lock:
_gc_locked(now)
@@ -219,13 +155,9 @@ def register_pending(
def get_pending(broker_state: str, *, now: Optional[int] = None) -> _Pending:
"""Return the pending authorization for ``broker_state`` without consuming it.
Read-only peek used by the callback to learn the desktop's ``redirect_uri``
and ``client_state`` for the final 302. Raises :class:`PendingNotFound` if
unknown or expired (the entry is GC'd on expiry).
"""
now = int(time.time()) if now is None else now
"""Peek (without consuming) the pending authorization; raises
:class:`PendingNotFound` if unknown or expired."""
now = _now(now)
with _lock:
_gc_locked(now)
entry = _pending.get(broker_state)
@@ -240,16 +172,12 @@ def complete_pending(
session: Session,
now: Optional[int] = None,
) -> str:
"""Consume a pending authorization and mint a one-time gateway code.
Called by ``/auth/callback`` once the upstream :class:`Session` is verified.
Pops the pending entry (single use), binds a fresh ``gw_code`` to the
desktop's ``code_challenge`` + the verified ``session``, and returns the
``gw_code`` for the loopback redirect.
"""Consume a pending authorization (single use) and mint a one-time gateway
code bound to the desktop's challenge + the verified ``session``.
Raises :class:`PendingNotFound` if the broker_state is unknown/expired.
"""
now = int(time.time()) if now is None else now
now = _now(now)
with _lock:
_gc_locked(now)
pending = _pending.pop(broker_state, None)
@@ -274,27 +202,19 @@ def redeem_code(
) -> Session:
"""Verify PKCE + consume a gateway code; return the bound :class:`Session`.
Called by ``/auth/native/token``. Enforces:
* the code exists and is unexpired (else :class:`CodeInvalid`);
* ``S256(code_verifier) == code_challenge`` in constant time (RFC 7636);
* single use — the entry is popped BEFORE the PKCE check so a wrong
verifier cannot be retried against the same code.
On any failure the code is already consumed (no oracle, no replay).
The entry is popped BEFORE the PKCE check so a wrong verifier cannot be
retried against the same code: on any failure the code is already consumed
(no oracle, no replay). Raises :class:`CodeInvalid`.
"""
now = int(time.time()) if now is None else now
now = _now(now)
with _lock:
_gc_locked(now)
issued = _issued.pop(code, None)
# Pop happened under the lock; every return path below has already
# consumed the code, so a replay (valid or not) finds nothing.
if issued is None:
raise CodeInvalid("unknown, expired, or already-redeemed code")
if issued.expires_at < now:
raise CodeInvalid("code expired")
expected = issued.code_challenge
actual = _s256(code_verifier)
if not hmac.compare_digest(expected, actual):
if not hmac.compare_digest(issued.code_challenge, _s256(code_verifier)):
raise CodeInvalid("PKCE verification failed")
return issued.session
+41 -107
View File
@@ -1,21 +1,12 @@
"""Helpers for X-Forwarded-Prefix support.
"""X-Forwarded-Prefix and public-URL resolution for reverse-proxied deploys.
Mission-control style deploys reverse-proxy the dashboard at a path
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> dashboard on
:9119), injecting ``X-Forwarded-Prefix: /hermes`` so the backend can
reconstruct prefixed URLs (Location: headers, OAuth redirect_uri,
cookie Path attributes, SPA asset URLs).
This module is also the home of the ``HERMES_DASHBOARD_PUBLIC_URL`` /
``dashboard.public_url`` resolution — when the operator declares a
complete public URL (scheme + host + optional path prefix), we use
that directly for the OAuth ``redirect_uri`` and skip the
X-Forwarded-Prefix reconstruction. Relief valve for deploys where the
proxy header chain isn't reliable.
The single source of truth for both helpers lives here so the gate
middleware, the OAuth routes, the cookie helpers, and the SPA mount
all agree on validation rules.
Mission-control style deploys proxy the dashboard at a path prefix and inject
``X-Forwarded-Prefix: /hermes`` so the backend can build prefixed URLs
(Location headers, OAuth redirect_uri, cookie Path, SPA asset URLs). When the
operator declares a complete ``HERMES_DASHBOARD_PUBLIC_URL`` /
``dashboard.public_url`` instead, that is used verbatim for the OAuth
redirect_uri (relief valve for unreliable proxy header chains). Single source
of truth so the gate, routes, cookies and SPA mount agree on validation.
"""
from __future__ import annotations
@@ -26,42 +17,30 @@ from typing import Optional
_log = logging.getLogger(__name__)
# Home Assistant Supervisor ingress prefixes are already 63 chars before
# deployments add their own sub-path. Keep a bounded header budget, but leave
# room for mainstream reverse-proxy path mounts.
# Home Assistant ingress prefixes are already 63 chars before a deployment adds
# its own sub-path; keep a bounded header budget with room for real mounts.
_MAX_PREFIX_LENGTH = 256
# Characters that, if present in a public_url or prefix value, indicate
# either a typo or a header-injection attempt. Reject the whole value
# rather than try to sanitise — the operator can fix their config.
# Presence of any of these in a public_url / prefix means a typo or a
# header-injection attempt: reject the whole value, never sanitise.
_REJECT_CHARS = frozenset(('"', "'", "<", ">", " ", "\n", "\r", "\t"))
# Remember which (source, value) pairs we've already warned about.
# ``resolve_public_url`` runs on every authenticated request, so an
# un-deduplicated warning would flood the logs once per request for a
# misconfigured deploy. Keyed on the raw value too, so changing the
# config and reloading surfaces a fresh warning.
# ``resolve_public_url`` runs on every authenticated request, so warnings are
# de-duplicated per distinct (source, value) — a changed value warns afresh.
_warned_malformed_public_urls: set = set()
_warned_malformed_prefixes: set = set()
def _warn_if_malformed(source: str, raw: str) -> None:
"""Warn (once per distinct value) when a non-empty public-url value
was rejected by :func:`_normalise_public_url`.
"""Warn once when a non-empty public-url value was rejected.
A non-empty value that normalises to ``""`` is almost always a
missing scheme (``hermes.example.com`` instead of
``https://hermes.example.com``) — the single most common cause of
"I set HERMES_DASHBOARD_PUBLIC_URL but the OAuth callback is still
http://". Without this warning the value is silently discarded and
the dashboard falls back to reconstructing the redirect URI from
request headers, which behind a reverse proxy can yield the wrong
scheme. Surfacing it turns a silent footgun into a self-diagnosing
one.
Almost always a missing scheme; without this the value is silently
discarded and the OAuth callback falls back to header reconstruction,
which behind a proxy can yield the wrong scheme.
"""
cleaned = raw.strip() if raw else ""
if not cleaned:
return # empty/unset is a legitimate "no override" — not malformed
return # empty/unset is a legitimate "no override"
key = (source, cleaned)
if key in _warned_malformed_public_urls:
return
@@ -96,45 +75,31 @@ def _warn_if_malformed_prefix(raw: Optional[str], reason: str) -> None:
def normalise_prefix(raw: Optional[str]) -> str:
"""Normalise an X-Forwarded-Prefix header value.
Returns a string like ``"/hermes"`` (no trailing slash) or ``""``
when no prefix is set / the header is malformed. We deliberately
reject anything containing ``..`` or non-printable bytes so a
hostile proxy can't inject HTML or path-traversal sequences via the
prefix.
"""Normalise an X-Forwarded-Prefix header to ``"/hermes"`` form (no trailing
slash) or ``""`` when unset/malformed. ``..``, ``//`` and injection
characters are rejected so a hostile proxy cannot smuggle HTML or traversal.
"""
if not raw:
return ""
p = raw.strip()
p = raw.strip() if raw else ""
if not p:
return ""
if not p.startswith("/"):
p = "/" + p
p = p.rstrip("/")
if (
"//" in p
or ".." in p
or any(c in p for c in _REJECT_CHARS)
):
if "//" in p or ".." in p or any(c in p for c in _REJECT_CHARS):
_warn_if_malformed_prefix(
raw,
"it contains a disallowed character or path sequence",
raw, "it contains a disallowed character or path sequence",
)
return ""
if len(p) > _MAX_PREFIX_LENGTH:
_warn_if_malformed_prefix(
raw,
f"it is longer than {_MAX_PREFIX_LENGTH} characters",
raw, f"it is longer than {_MAX_PREFIX_LENGTH} characters",
)
return ""
return p
def prefix_from_request(request) -> str:
"""Convenience wrapper that reads the header off a Starlette/FastAPI
Request and normalises it. Returns ``""`` when no prefix.
"""
"""Normalised ``X-Forwarded-Prefix`` from a Starlette request, or ``""``."""
return normalise_prefix(request.headers.get("x-forwarded-prefix"))
@@ -144,48 +109,26 @@ def prefix_from_request(request) -> str:
def _normalise_public_url(raw: Optional[str]) -> str:
"""Normalise a ``dashboard.public_url`` value.
Returns the cleaned URL (scheme://netloc[/path], trailing slash
removed) on success, or ``""`` when the value is empty, malformed,
or contains characters that suggest header injection. The caller
must treat ``""`` as "fall back to request reconstruction" — never
as "the user explicitly chose no public URL", because the two are
indistinguishable from an empty env var.
"""Cleaned ``scheme://netloc[/path]`` (trailing slash stripped so callers
can append paths) or ``""`` when empty/malformed/injection-suspect. Callers
must treat ``""`` as "fall back to request reconstruction" — an explicit
empty value is indistinguishable from an unset env var.
"""
if not raw:
return ""
url = raw.strip()
if not url:
return ""
# Reject control / quote / whitespace characters before trying to
# parse — urlparse is permissive enough to accept some hostile
# values (e.g. embedded newlines) and we want a hard "no" rather
# than a soft "maybe".
if any(c in url for c in _REJECT_CHARS):
url = raw.strip() if raw else ""
if not url or any(c in url for c in _REJECT_CHARS):
return ""
try:
parsed = urllib.parse.urlparse(url)
except ValueError:
return ""
if parsed.scheme not in {"http", "https"}:
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return ""
if not parsed.netloc:
return ""
# Strip a single trailing slash so callers can append paths without
# producing ``//`` double-slashes.
return url.rstrip("/")
def _load_dashboard_section() -> dict:
"""Return the ``dashboard`` block from ``config.yaml`` if it exists
and is a dict; otherwise an empty dict.
Robust to (a) load_config() raising (malformed YAML, IO error,
config.yaml absent), and (b) ``dashboard`` being absent or non-dict.
Both shapes fall through to ``{}`` so the caller can rely on
``.get(...)`` access.
"""
"""``dashboard`` block of config.yaml as a dict, or ``{}`` when the config
cannot be loaded or the block is absent/non-dict."""
try:
from hermes_cli.config import load_config
except Exception:
@@ -204,21 +147,12 @@ def _load_dashboard_section() -> dict:
def resolve_public_url() -> str:
"""Resolve the operator-declared dashboard public URL.
"""Operator-declared dashboard public URL, or ``""`` (reconstruct from request).
Precedence (mirrors ``dashboard.oauth.client_id``):
1. ``HERMES_DASHBOARD_PUBLIC_URL`` env var (when non-empty after
strip — empty values are treated as unset so a provisioned-but-
not-populated Fly secret can't shadow a valid config.yaml entry).
2. ``dashboard.public_url`` in ``config.yaml``.
3. Empty string — signals "no override, reconstruct from request"
to the caller.
Each candidate value is run through :func:`_normalise_public_url`.
A malformed env var falls through to the config.yaml entry; a
malformed config entry falls through to ``""``. This means a typo
in one surface doesn't prevent the other from working.
Precedence: ``HERMES_DASHBOARD_PUBLIC_URL`` env (empty-after-strip counts
as unset so a provisioned-but-blank secret cannot shadow config.yaml), then
``dashboard.public_url``. A malformed value at either level warns and falls
through to the next, so a typo in one surface never disables the other.
"""
env_raw = os.environ.get("HERMES_DASHBOARD_PUBLIC_URL", "")
env_clean = _normalise_public_url(env_raw)
@@ -0,0 +1,54 @@
"""Request-level helpers shared by the auth routes and both middlewares."""
from __future__ import annotations
import time
from fastapi import Request
from fastapi.responses import JSONResponse
# Paths a post-login redirect must never land on: the auth flow itself (would
# loop) and any ``/api/*`` target (renders raw JSON in the address bar and is
# indistinguishable from an attacker weaponising the redirect).
_NEXT_DENY_PREFIXES = ("/login", "/auth/", "/api/auth/")
def client_ip(request: Request) -> str:
"""First ``X-Forwarded-For`` hop, else the peer address."""
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else ""
def extract_bearer(request: Request) -> str:
"""``Authorization: Bearer <token>`` value (scheme case-insensitive), or ``""``."""
parts = request.headers.get("authorization", "").split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def is_safe_next_path(path: str) -> bool:
"""True if ``path`` is a same-origin post-login target.
Rejects non-relative and protocol-relative (``//evil``) values, the auth
routes themselves, and every ``/api`` path.
"""
if not path.startswith("/") or path.startswith("//"):
return False
if any(path == p or path.startswith(p) for p in _NEXT_DENY_PREFIXES):
return False
return not (path == "/api" or path.startswith("/api/"))
def access_token_max_age(session) -> int:
"""Cookie Max-Age for the access token: seconds to ``exp``, floored at 60."""
return max(60, int(session.expires_at) - int(time.time()))
def unreachable_response(provider_name: str) -> JSONResponse:
"""503 for a transient IDP/backing-store outage (never a forced re-login)."""
return JSONResponse(
{"detail": f"Auth provider {provider_name!r} unreachable"},
status_code=503,
)
File diff suppressed because it is too large Load Diff
+25 -90
View File
@@ -1,40 +1,16 @@
"""Route-agnostic non-interactive (bearer-token) auth seam for the dashboard.
This is the generic API-token capability (decisions.md Q-C): a reusable seam
that ANY service-to-service / machine-credential provider plugs into, NOT a
drain-specific hook. The drain bearer-secret plugin is merely the first
consumer.
The generic API-token capability any machine-credential provider plugs into
(the drain bearer-secret plugin is merely the first consumer). A route opts in
by registering its exact path via :func:`register_token_route`; only registered
paths are token-authable, so the auth surface of existing routes never widens.
How it fits the existing auth framework:
* The interactive gate (``gated_auth_middleware``) authenticates a human
via a session cookie on every non-public route. A service caller has no
cookie — it presents a bearer token in the ``Authorization`` header on a
single request. That is what this seam verifies.
* A route opts in by registering its exact path via
:func:`register_token_route`. Only registered paths are token-authable;
everything else is untouched, so this can never accidentally widen the
auth surface of an existing route.
* :func:`token_auth_middleware` runs OUTERMOST (installed last in
``web_server.py``). For a token route it fully owns the auth decision:
authenticate via the stacked token providers, attach the verified
:class:`~hermes_cli.dashboard_auth.base.TokenPrincipal` to
``request.state.token_principal`` + set ``request.state.token_authenticated``,
and pass through; otherwise reject (401 unauthenticated, or 503 when a
provider's backing store was unreachable). The downstream cookie/session
gates honour ``token_authenticated`` and skip enforcement, so a
token-authed service request is never bounced to ``/login``.
* Fails closed: a token route with no registered token provider, no token,
or an unrecognised token gets 401 — never an open pass-through.
Provider stacking mirrors ``verify_session``: each ``supports_token`` provider
is consulted in registration order until one returns a principal. A provider
that doesn't recognise the token returns ``None`` and the seam moves on; a
provider whose backing store is unreachable raises ``ProviderError``, which the
seam remembers and surfaces as 503 only if NO provider accepts the token.
:func:`token_auth_middleware` runs OUTERMOST (installed last in web_server.py)
and fully owns the decision for a token route: a recognised token attaches
``request.state.token_principal`` + ``token_authenticated`` (the cookie gates
honour that flag and never bounce the request to /login); otherwise 401, or
503 when a provider's backing store was unreachable. Fails closed: no
provider, no token, or an unrecognised token is always 401.
"""
from __future__ import annotations
@@ -48,22 +24,21 @@ from fastapi.responses import JSONResponse, Response
from hermes_cli.dashboard_auth import list_token_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import ProviderError, TokenPrincipal
from hermes_cli.dashboard_auth.request_utils import (
client_ip as _client_ip,
extract_bearer as extract_bearer_token,
unreachable_response,
)
_log = logging.getLogger(__name__)
# Exact paths that accept non-interactive bearer-token auth. A route registers
# itself here at import/startup; the seam only acts on registered paths.
_token_routes: set[str] = set()
_token_routes: set[str] = set() # exact paths that accept bearer-token auth
_lock = threading.Lock()
def register_token_route(path: str) -> None:
"""Mark ``path`` (exact match) as token-authable.
Idempotent. Call at module import / app setup so the seam knows which
routes to guard. Registering a route does NOT make it public — it makes
it authenticate by token instead of by session cookie.
"""
"""Mark ``path`` (exact match) as token-authable. Idempotent; does NOT make
the route public — it authenticates by token instead of by cookie."""
with _lock:
_token_routes.add(path)
@@ -80,40 +55,15 @@ def clear_token_routes() -> None:
_token_routes.clear()
def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else ""
def extract_bearer_token(request: Request) -> str:
"""Return the bearer token from the ``Authorization`` header, or "".
Accepts ``<scheme> <token>`` where scheme is "bearer" (case-insensitive).
Returns an empty string for a missing/malformed header or a non-bearer
scheme — the caller treats "" as "no token presented".
"""
auth = request.headers.get("authorization", "")
parts = auth.split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def authenticate_token(
request: Request,
) -> Tuple[Optional[TokenPrincipal], Optional[str]]:
"""Try every token provider against the request's bearer token.
Returns ``(principal, unreachable_provider_name)``:
* ``(TokenPrincipal, None)`` — a provider recognised and accepted the token.
* ``(None, None)`` — no token, or no provider recognised it (reject 401).
* ``(None, name)`` — no provider accepted it AND at least one provider's
backing store was unreachable (the caller surfaces 503, not 401, so a
transient outage doesn't read as "bad credentials").
Never raises: a provider ``ProviderError`` is caught and remembered.
Returns ``(principal, None)`` on success; ``(None, None)`` for no token or
no recogniser (401); ``(None, name)`` when no provider accepted it AND at
least one was unreachable (caller surfaces 503, not "bad credentials").
Never raises — a buggy provider must not 500 the gate.
"""
token = extract_bearer_token(request)
if not token:
@@ -145,20 +95,8 @@ async def token_auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Outermost auth seam for token-authable routes.
No-op pass-through for any path not registered via
:func:`register_token_route`. For a registered path, token auth is the
only accepted scheme:
* valid token → attach principal + ``token_authenticated`` flag, pass through.
* unreachable → 503 (provider backing store down; not "bad credentials").
* otherwise → 401 unauthenticated.
Runs before the cookie/session gates (installed last in ``web_server.py``).
The cookie gates honour ``request.state.token_authenticated`` and skip
enforcement, so a token-authed request is never redirected to ``/login``.
"""
"""Outermost auth seam: pass-through for unregistered paths; for a token
route, valid token -> attach principal + flag, unreachable -> 503, else 401."""
path = request.url.path
if not is_token_route(path):
return await call_next(request)
@@ -177,10 +115,7 @@ async def token_auth_middleware(
path=path,
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {unreachable!r} unreachable"},
status_code=503,
)
return unreachable_response(unreachable)
audit_log(
AuditEvent.TOKEN_AUTH_FAILURE,
+14 -57
View File
@@ -1,24 +1,14 @@
"""Shared late-binding dependency seam for extracted dashboard routers.
Why this exists
---------------
``hermes_cli/web_server.py`` owns all dashboard runtime state: the ephemeral
``_SESSION_TOKEN``, the ``DASHBOARD_HEALTH`` singleton, config helpers, and a
large set of private helper functions the route handlers call. Extracted
``APIRouter`` modules under ``hermes_cli/web_routers/`` need those helpers, but
* importing ``web_server`` at module import time from a router module would be
a circular import (``web_server`` imports the router modules to mount them),
and
* re-homing the helpers/state here would break the many tests (and any third
party code) that ``monkeypatch.setattr(web_server, "_helper", ...)``.
``hermes_cli/web_server.py`` owns all dashboard runtime state and most helper
functions the route handlers call. Extracted ``APIRouter`` modules under
``hermes_cli/web_routers/`` cannot import it at module import time (web_server
imports them to mount them — a cycle) and must not copy its state (tests
``monkeypatch.setattr(web_server, "_helper", ...)`` and expect that to win).
Design: **late binding, state stays in web_server.** ``late(name)`` returns a
thin proxy that resolves ``hermes_cli.web_server.<name>`` *at call time*. This
is cycle-safe (the import happens inside the call, long after both modules are
initialised) and keeps ``web_server``'s runtime behaviour byte-identical:
monkeypatching an attribute on ``web_server`` is still authoritative because
every call re-reads the attribute from the live module.
proxy that resolves ``hermes_cli.web_server.<name>`` *at call time*, so
monkeypatched replacements and module state are never frozen at import time.
"""
from __future__ import annotations
@@ -36,12 +26,7 @@ def _server():
def late(name: str):
"""Late-binding proxy for a callable defined on ``web_server``.
The returned wrapper looks up ``web_server.<name>`` on every call, so
async/sync nature, monkeypatched replacements, and module state are all
resolved at call time — never frozen at import time.
"""
"""Late-binding proxy for a callable defined on ``web_server``."""
def _proxy(*args: Any, **kwargs: Any):
return getattr(_server(), name)(*args, **kwargs)
@@ -51,25 +36,15 @@ def late(name: str):
return _proxy
def late_attr(name: str) -> Any:
"""Read ``web_server.<name>`` right now (for non-callable state reads)."""
return getattr(_server(), name)
class LateState:
"""Live proxy for module-level state owned by ``web_server``.
Extracted routers can't ``from web_server import _mcp_oauth_flows`` —
that would freeze the object at import time (breaking tests that mutate
or replace it on ``web_server``) and be a circular import besides. Some
of the state is also defined *after* the router's ``include_router``
point in web_server's body, so even a late module-import wouldn't see it
yet. This proxy forwards every operation the extracted handlers actually
perform — attribute access, item get/set/del, iteration, membership,
``len``/truthiness, ``with``-blocks (locks), and rich comparisons
(numeric limits) — to ``web_server.<name>`` resolved at operation time,
so mutating or monkeypatching the attribute on ``web_server`` stays
authoritative.
Forwards every operation the extracted handlers perform — attribute access,
item get/set/del, iteration, membership, ``len``/truthiness, ``with``-blocks
(locks), rich comparisons (numeric limits) — to ``web_server.<name>``
resolved at operation time. Some of that state is defined *after* the
router's ``include_router`` point, so even a late module import would not
see it; this proxy does.
"""
__slots__ = ("_name",)
@@ -133,21 +108,3 @@ class LateState:
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<LateState {object.__getattribute__(self, '_name')} -> {self._target()!r}>"
# --- Named accessors for the shared server state (call-time reads) ---------
def get_session_token() -> str:
"""Current dashboard session token (``web_server._SESSION_TOKEN``)."""
return _server()._SESSION_TOKEN
def get_dashboard_health():
"""The ``DASHBOARD_HEALTH`` singleton owned by web_server."""
return _server().DASHBOARD_HEALTH
def has_valid_session_token(request) -> bool:
"""Late-bound alias for ``web_server._has_valid_session_token``."""
return _server()._has_valid_session_token(request)
+81
View File
@@ -0,0 +1,81 @@
"""Shared plumbing for the extracted dashboard routers.
Everything here is a thin wrapper over the late-binding seam in
:mod:`hermes_cli.web_deps`: web_server still owns the helpers and state, and
every access resolves at call time so ``monkeypatch.setattr(web_server, ...)``
stays authoritative.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from typing import Any, Callable, Optional
from fastapi import HTTPException
from hermes_cli.web_deps import LateState, late
# Same logger the handlers used before extraction (identical logger object).
log = logging.getLogger("hermes_cli.web_server")
_profile_scope = late("_profile_scope")
_spawn_hermes_action = late("_spawn_hermes_action")
_profile_cli_args = late("_profile_cli_args")
# Config read-modify-write serialization for off-loop handlers (live lock —
# LateState supports ``with``-blocks).
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
@contextlib.contextmanager
def config_write_scope(profile: Optional[str]):
"""Profile scope, then the config mutation lock — the write-path nesting
every config-mutating handler uses."""
with _profile_scope(profile):
with _CONFIG_MUTATION_LOCK:
yield
async def scoped_to_thread(profile: Optional[str], fn: Callable[[], Any]) -> Any:
"""Run ``fn()`` inside ``_profile_scope(profile)`` on a worker thread."""
def _run():
with _profile_scope(profile):
return fn()
return await asyncio.to_thread(_run)
@contextlib.contextmanager
def http_failure(log_msg: str, status: int, prefix: str):
"""Map unexpected exceptions to ``HTTPException(status, f"{prefix}: {exc}")``.
``HTTPException`` passes through untouched; anything else is logged with
``log_msg`` (full traceback) first.
"""
try:
yield
except HTTPException:
raise
except Exception as exc:
log.exception(log_msg)
raise HTTPException(status_code=status, detail=f"{prefix}: {exc}")
def spawn_profile_action(
profile: Optional[str], argv: list, name: str, *, log_msg: str, prefix: str
) -> dict:
"""Spawn a background ``hermes -p <profile> <argv>`` action; a spawn
failure is logged and becomes ``500 "<prefix>: <exc>"``."""
with http_failure(log_msg, 500, prefix):
proc = _spawn_hermes_action(_profile_cli_args(profile) + argv, name)
return {"ok": True, "pid": proc.pid, "name": name}
def require(value: Optional[str], detail: str) -> str:
"""Strip ``value``; 400 with ``detail`` when empty."""
stripped = (value or "").strip()
if not stripped:
raise HTTPException(status_code=400, detail=detail)
return stripped
+42 -105
View File
@@ -1,19 +1,16 @@
"""Cron dashboard routes (extracted verbatim from web_server.py).
"""Cron dashboard routes.
Handler bodies are byte-identical. The ``*_sync`` workers, profile resolution
and the threadpool wrapper (``_run_cron_dashboard_io``) still live in
web_server — reached via the late-binding seam in :mod:`hermes_cli.web_deps`
so ``monkeypatch.setattr(web_server, ...)`` keeps working (several cron tests
rely on exactly that).
The ``*_sync`` workers, profile resolution and the threadpool wrapper
(``_run_cron_dashboard_io``) live in web_server and are reached through the
late-binding seam so ``monkeypatch.setattr(web_server, ...)`` keeps working.
"""
import asyncio # noqa: F401 — used by handlers
import functools # noqa: F401
import logging
from typing import Optional # noqa: F401
import asyncio
import functools
from typing import Optional
from fastapi import APIRouter, HTTPException, Request # noqa: F401
from fastapi.responses import JSONResponse # noqa: F401
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
@@ -21,16 +18,10 @@ from hermes_cli.web_models import (
CronJobUpdate,
AutomationBlueprintInstantiate,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
from hermes_cli.web_routers._common import log as _log
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent — includes config readers so existing
# ``monkeypatch.setattr(web_server, "load_config", ...)`` idioms behave
# identically for these routes).
_run_cron_dashboard_io = late("_run_cron_dashboard_io")
_list_cron_jobs_sync = late("_list_cron_jobs_sync")
_get_cron_job_sync = late("_get_cron_job_sync")
@@ -51,12 +42,9 @@ _raise_if_cron_registration_error = late("_raise_if_cron_registration_error")
load_config = late("load_config")
cfg_get = late("cfg_get")
# Retry-After hint (seconds) stamped on retryable cron-fire 503s. Sized to
# clear the common transient windows — a scale-to-zero wake or an s6 gateway
# restart completes well within a minute — so a scheduler that honors it
# spaces its next attempt PAST the outage window instead of burning its whole
# retry budget inside it (OOF-266). Honored by QStash once NAS propagates it;
# harmless (ignored) until then.
# Retry-After hint (seconds) on retryable cron-fire 503s: sized to clear a
# scale-to-zero wake or gateway restart so a scheduler that honors it spaces
# its next attempt past the outage instead of burning its retry budget in it.
_CRON_FIRE_RETRY_AFTER_SECONDS = 60
@@ -82,15 +70,9 @@ async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None):
@router.get("/api/cron/delivery-targets")
async def get_cron_delivery_targets():
"""Delivery targets the cron dropdown should offer.
Always includes the implicit ``local`` option. Beyond that, the list is
derived dynamically from the configured gateway platforms via
``cron.scheduler.cron_delivery_targets()`` — no hardcoded platform list. A
configured platform that hasn't set its cron home channel is still returned
with ``home_target_set: false`` so the UI can surface it as "configure a
home channel first" rather than hiding it.
"""
"""Delivery targets for the cron dropdown: implicit ``local`` plus the
configured gateway platforms (a platform without a cron home channel is
still listed with ``home_target_set: false`` so the UI can say so)."""
targets = [
{
"id": "local",
@@ -137,26 +119,13 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
async def cron_fire_webhook(request: Request):
"""Chronos managed-cron fire webhook (NAS -> agent) — gateway forwarder.
Authenticated by a short-lived NAS-minted JWT (verified by the pluggable
Chronos fire-verifier), NOT the dashboard session cookie — so this path is
in ``PUBLIC_API_PATHS`` to bypass the dashboard auth gate, and the JWT is
the real gate.
The dashboard is only the PUBLIC DOOR here (on hosted deployments the Fly
proxy exposes exactly one port, the dashboard's). Cron execution belongs
to the GATEWAY process, which owns the live platform adapters — required
for relay-fronted logical platforms (their only sender is the live relay
adapter) and E2EE rooms, neither of which the dashboard's standalone send
path can serve. So after verifying the JWT this handler FORWARDS the fire
to the gateway api_server's own ``/api/cron/fire`` on loopback and passes
the gateway's response through (the gateway re-verifies the JWT — defense
in depth, no new trust link).
Gateway unreachable (scale-to-zero wake still booting, restart window,
api_server disabled) → 503, so NAS retries per the Chronos contract
(non-2xx = retryable). The store CAS claim de-dupes the eventual double
fire. Deliberately NO local-execution fallback: delivering from the wrong
process is worse than a delayed retry.
Gated by the NAS-minted JWT (this path is in ``PUBLIC_API_PATHS``), not the
dashboard cookie. The dashboard is only the public door: execution belongs
to the GATEWAY process (it owns the live platform adapters relay-fronted
and E2EE targets need), so the fire is forwarded to the gateway
api_server's own ``/api/cron/fire`` on loopback and its response passed
through (the gateway re-verifies the JWT). Gateway unreachable -> 503 so
NAS retries; deliberately NO local-execution fallback.
"""
from plugins.cron_providers.chronos.verify import get_fire_verifier
@@ -181,23 +150,17 @@ async def cron_fire_webhook(request: Request):
if not job_id:
return JSONResponse({"error": "missing job_id"}, status_code=400)
# _find_cron_job_profile walks every profile and lists its jobs (file
# I/O per profile) — run it off the event loop like the other cron
# dashboard endpoints.
# Walks every profile's job list (file I/O) — off the event loop.
profile = await _run_cron_dashboard_io(_find_cron_job_profile, job_id)
if not profile:
# Job is gone (cancelled / completed) — nothing to fire. 200 so NAS
# does not retry a fire that is intentionally absent.
# Job is gone (cancelled / completed): 200 so NAS does not retry.
return JSONResponse({"status": "gone", "job_id": job_id}, status_code=200)
forwarded = await _forward_cron_fire_to_gateway(profile, job_id, auth)
if forwarded is None:
# Durably stamp the miss on the job record (last_fire_error) so the
# user and their agent can see "scheduled fire could not reach the
# runner" in `cronjob list` / the dashboard — without this, a dead
# 8642 hop is invisible outside gui.log (no execution row is ever
# created because the claim never happens). Best-effort: visibility
# must never break the retry contract below.
# Stamp the miss on the job record (last_fire_error) so the dead hop is
# visible in `cronjob list` / the dashboard, not just gui.log.
# Best-effort: visibility must never break the retry contract below.
try:
await _run_cron_dashboard_io(
_call_cron_for_profile,
@@ -211,25 +174,11 @@ async def cron_fire_webhook(request: Request):
)
except Exception:
_log.debug("could not stamp last_fire_error for %s", job_id, exc_info=True)
# Gateway unreachable. Split by OPERATOR INTENT (OOF-266):
#
# - Deliberately stopped gateway (durable desired_state == "stopped",
# written only by the s6 lifecycle commands): retrying can never
# succeed until a human starts the gateway again, so the retry
# budget is pure waste and the resulting 503→502 storms page the
# NAS on-call for a non-incident. Drop with 200 + a structured log
# line, mirroring NAS's own instance_stopped drop in the relay.
# The fire is NOT lost silently: the log names job and profile,
# and the gateway's Chronos provider reconciles + re-arms every
# job on its next startup (plugins/cron_providers/chronos
# start() -> reconcile()), so fires resume when the operator
# starts the gateway.
#
# - Transient window (scale-to-zero wake, restart, crash loop —
# desired_state is "running" or unknown): keep the retryable 503,
# but stamp Retry-After so a scheduler that honors it spaces its
# next attempt past the wake/restart window instead of exhausting
# the whole retry budget inside it.
# Split by operator intent: a deliberately stopped gateway (durable
# desired_state == "stopped") can never be reached by retrying, so drop
# with 200 + a structured log line — the Chronos provider re-arms every
# job on the next gateway start. A transient window (wake, restart,
# crash loop) keeps the retryable 503 with a Retry-After hint.
if await _run_cron_dashboard_io(_gateway_intentionally_stopped, profile):
_log.info(
"cron fire dropped: gateway for profile %r is deliberately "
@@ -259,24 +208,16 @@ async def cron_fire_webhook(request: Request):
status_code, gateway_body = forwarded
if isinstance(gateway_body, dict):
gateway_body.setdefault("job_id", job_id)
headers = (
# The gateway's own 503s (draining, admission failure) are equally
# transient — give the scheduler the same spacing hint.
{"Retry-After": str(_CRON_FIRE_RETRY_AFTER_SECONDS)}
if status_code == 503
else None
)
# The gateway's own 503s (draining, admission failure) are equally
# transient — same spacing hint.
headers = {"Retry-After": str(_CRON_FIRE_RETRY_AFTER_SECONDS)} if status_code == 503 else None
return JSONResponse(gateway_body, status_code=status_code, headers=headers)
@router.get("/api/cron/blueprints")
async def list_cron_blueprints():
"""Return the blueprint catalog as form schemas for the dashboard gallery.
The ``deliver`` slot's options are rewritten from the user's actually
configured gateway platforms (plus the universal origin/local/all), so the
form never offers a platform that isn't connected.
"""
"""Blueprint catalog as form schemas; the ``deliver`` slot's options are
rewritten from the actually configured gateway platforms."""
try:
from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
@@ -317,18 +258,14 @@ async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: s
except BlueprintFillError as exc:
# Field-level validation error — 422 so the form can show it inline.
raise HTTPException(status_code=422, detail=str(exc)) from exc
# Blueprint-created jobs deliver to the dashboard's configured target by
# Blueprint jobs deliver to the dashboard's configured target by
# default; the form's deliver slot overrides via spec["deliver"].
spec.pop("origin", None)
# create_job does per-profile file I/O — keep it off the event loop
# like the sibling cron endpoints (partial avoids **spec keys ever
# colliding with the wrapper's own parameters).
# Off the event loop like the sibling endpoints; partial keeps **spec
# keys from colliding with the wrapper's own parameters.
_create = functools.partial(_call_cron_for_profile, profile, "create_job", **spec)
created = await _run_cron_dashboard_io(_create)
# Same contract as the other dashboard mutations: reconcile the
# profile-scoped provider (best-effort; fail-closed for external
# providers on a multi-profile dashboard). Off the event loop —
# a Chronos reconcile does file I/O plus NAS network calls.
# Reconcile the profile-scoped provider (file I/O + NAS calls) off-loop.
await _run_cron_dashboard_io(_notify_cron_provider_for_profile, profile)
return created
except HTTPException:
+9 -23
View File
@@ -1,16 +1,11 @@
"""Git dashboard routes (extracted verbatim from web_server.py).
Handler bodies are byte-identical to their previous in-web_server form; the
helpers they call (``_git_op``, ``_git_path``) still live in web_server and are
reached via the late-binding seam in :mod:`hermes_cli.web_deps`, so
``monkeypatch.setattr(web_server, ...)`` keeps working.
"""
"""Git dashboard routes — thin executor-offloaded wrappers over ``web_git``
(``_git_op`` / ``_git_path`` live in web_server, reached via the late seam)."""
from typing import Optional
from fastapi import APIRouter
from hermes_cli import web_git as _web_git # noqa: F401 — used by handlers
from hermes_cli import web_git as _web_git
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
GitPathBody,
@@ -24,8 +19,6 @@ from hermes_cli.web_models import (
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_git_op = late("_git_op")
_git_path = late("_git_path")
@@ -35,13 +28,10 @@ async def git_status_route(path: str):
return await _git_op(_web_git.repo_status, _git_path(path))
# ─── gh CLI auth probe ───────────────────────────────────────────────────────
# Cached `gh auth status` result. Consumed by the desktop composer's GitHub
# suggestion pill: GitHub deliberately has NO MCP catalog entry (its hosted
# MCP requires a per-host OAuth app — generic DCR 404s — and the bundled
# github/* skills via gh CLI are the more capable integration), so the pill
# offers the `/github-auth` skill instead, and only to users who aren't
# already authenticated. The probe is read-only and never prompts.
# Cached `gh auth status` for the desktop composer's GitHub suggestion pill.
# GitHub deliberately has NO MCP catalog entry (hosted MCP needs a per-host
# OAuth app; the gh-CLI skills are the better integration), so the pill offers
# the `/github-auth` skill — only to users who aren't already authenticated.
_GH_AUTH_TTL_S = 300.0
_gh_auth_cache: Optional[tuple] = None # (monotonic_ts, payload)
@@ -49,12 +39,8 @@ _gh_auth_cache: Optional[tuple] = None # (monotonic_ts, payload)
@router.get("/api/git/gh-auth")
async def gh_auth_status_route(refresh: bool = False):
"""Report whether the `gh` CLI is present and authenticated.
Returns ``{"available": bool, "authenticated": bool}``. Cached for five
minutes (`refresh=true` bypasses — the pill uses it after a completed
login so the suggestion withdraws immediately).
"""
"""``{"available", "authenticated"}`` for the `gh` CLI; cached 5 min
(``refresh=true`` bypasses so the pill withdraws right after a login)."""
global _gh_auth_cache
import asyncio
import time
+82 -143
View File
@@ -1,22 +1,18 @@
"""MCP dashboard routes (extracted verbatim from web_server.py).
"""MCP dashboard routes.
Handler bodies are byte-identical. The OAuth flow registry
(``_mcp_oauth_flows`` + lock + pending cap) and the worker/helpers stay in
web_server - reached via the late-binding seam in :mod:`hermes_cli.web_deps`
(``late`` for callables, ``LateState`` for the mutable registry/lock/limit) so
tests that mutate ``web_server._mcp_oauth_flows`` or
``monkeypatch.setattr(web_server, "_run_dashboard_mcp_oauth", ...)`` keep
The OAuth flow registry (``_mcp_oauth_flows`` + lock + pending cap) and the
worker/helpers stay in web_server — reached via the late-binding seam so tests
that mutate ``web_server._mcp_oauth_flows`` or monkeypatch its helpers keep
working unchanged.
"""
import asyncio # noqa: F401 — used by handlers
import logging
import secrets # noqa: F401
import threading # noqa: F401
from typing import Any, Dict, Optional # noqa: F401
import asyncio
import secrets
import threading
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Request # noqa: F401
from fastapi.responses import HTMLResponse # noqa: F401
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
@@ -25,48 +21,39 @@ from hermes_cli.web_models import (
MCPServerCreate,
MCPServersReplace,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
from hermes_cli.web_routers._common import (
_profile_cli_args,
_profile_scope,
_spawn_hermes_action,
config_write_scope,
log as _log,
scoped_to_thread,
)
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_config_profile_scope = late("_config_profile_scope")
_gc_mcp_oauth_flows = late("_gc_mcp_oauth_flows")
_mcp_install_action_name = late("_mcp_install_action_name")
_mcp_oauth_callback_url = late("_mcp_oauth_callback_url")
_mcp_server_summary = late("_mcp_server_summary")
_normalize_mcp_server_create = late("_normalize_mcp_server_create")
_profile_cli_args = late("_profile_cli_args")
_profile_scope = late("_profile_scope")
_require_token = late("_require_token")
_run_dashboard_mcp_oauth = late("_run_dashboard_mcp_oauth")
_spawn_hermes_action = late("_spawn_hermes_action")
load_config = late("load_config")
save_config = late("save_config")
save_env_value = late("save_env_value")
# Live proxies for web_server-owned module state (mutations/monkeypatches
# on web_server remain authoritative; resolved at operation time).
_mcp_oauth_flows = LateState("_mcp_oauth_flows")
_mcp_oauth_flows_lock = LateState("_mcp_oauth_flows_lock")
_MAX_PENDING_MCP_OAUTH_FLOWS = LateState("_MAX_PENDING_MCP_OAUTH_FLOWS")
# Config read-modify-write serialization for off-loop handlers (defined in
# web_server.py; LateState supports ``with``-blocks, so this is the live lock).
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
@router.get("/api/mcp/servers")
async def list_mcp_servers(profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers
def _read():
with _profile_scope(profile):
return _get_mcp_servers()
servers = await asyncio.to_thread(_read)
servers = await scoped_to_thread(profile, _get_mcp_servers)
return {
"servers": [
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
@@ -88,23 +75,21 @@ async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _run():
with _profile_scope(body.profile or profile):
# _save_mcp_server does its own load→mutate→save of config.yaml;
# serialize the whole cycle against other off-loop config writers.
# The duplicate-name check lives under the same lock span so a
# concurrent add of the same name can't slip between check and save.
with _CONFIG_MUTATION_LOCK:
if name in _get_mcp_servers():
raise HTTPException(
status_code=409, detail=f"Server '{name}' already exists"
)
if bearer_token is not None:
server_config["headers"] = _save_bearer_auth_token(name, bearer_token)
if not _save_mcp_server(name, server_config):
raise HTTPException(
status_code=400,
detail=f"Server '{name}' rejected: suspicious command/args configuration",
)
# _save_mcp_server does its own load→mutate→save; the duplicate-name
# check sits under the same lock span so a concurrent add of the same
# name can't slip between check and save.
with config_write_scope(body.profile or profile):
if name in _get_mcp_servers():
raise HTTPException(
status_code=409, detail=f"Server '{name}' already exists"
)
if bearer_token is not None:
server_config["headers"] = _save_bearer_auth_token(name, bearer_token)
if not _save_mcp_server(name, server_config):
raise HTTPException(
status_code=400,
detail=f"Server '{name}' rejected: suspicious command/args configuration",
)
try:
await asyncio.to_thread(_run)
@@ -121,18 +106,15 @@ async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
async def replace_mcp_servers(body: MCPServersReplace, profile: Optional[str] = None):
"""Replace the entire ``mcp_servers`` map (the GUI mcp.json editor's save).
The generic ``/api/config`` endpoint deep-merges maps, so it can never
delete a server key, drop an ``enabled: false`` flag, or remove a nested
field — edits looked saved but the stale entry survived on disk. This
endpoint sets the whole map so removals actually persist. Storage stays
the config.yaml ``mcp_servers`` key the CLI/TUI already read.
The generic ``/api/config`` endpoint deep-merges maps and so can never
delete a key or drop an ``enabled: false``; this sets the whole map so
removals actually persist.
"""
from hermes_cli.mcp_config import _replace_mcp_servers
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
return _replace_mcp_servers(body.servers)
with config_write_scope(body.profile or profile):
return _replace_mcp_servers(body.servers)
ok, issues = await asyncio.to_thread(_run)
if not ok:
@@ -145,9 +127,8 @@ async def remove_mcp_server(name: str, profile: Optional[str] = None):
from hermes_cli.mcp_config import _remove_mcp_server
def _run():
with _profile_scope(profile):
with _CONFIG_MUTATION_LOCK:
return _remove_mcp_server(name)
with config_write_scope(profile):
return _remove_mcp_server(name)
removed = await asyncio.to_thread(_run)
if not removed:
@@ -164,39 +145,28 @@ async def test_mcp_server(name: str, profile: Optional[str] = None):
_probe_single_server,
)
def _read():
with _profile_scope(profile):
return _get_mcp_servers()
servers = await asyncio.to_thread(_read)
servers = await scoped_to_thread(profile, _get_mcp_servers)
if name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
details: Dict[str, Any] = {}
# An `auth: oauth` server that serves tools/list anonymously would probe OK
# with no token — a false green. Require a token on disk for it, matching the
# /auth verification (some providers don't enforce auth on tools/list).
# with no token — a false green. Require a token on disk, matching /auth.
needs_oauth_token = servers[name].get("auth") == "oauth"
def _probe_scoped():
# Home-only scope (contextvar), NOT _profile_scope. A probe blocks for
# as long as the server takes to spawn/connect — a stdio `npx` cold
# start is many seconds — and _profile_scope holds a process-global
# skills lock for its ENTIRE body. Holding that across the probe
# serialized every other endpoint (config/skills/toolsets all take the
# same lock), so a slow server made unrelated requests time out at 15s.
# The probe touches no skills globals; it only needs the HERMES_HOME
# override for .env interpolation + OAuth token resolution, which the
# contextvar provides (copied into this to_thread worker; and
# _run_on_mcp_loop re-wraps it onto the MCP event-loop thread).
# Home-only scope (contextvar), NOT _profile_scope: a probe can block for
# many seconds (stdio `npx` cold start) and _profile_scope holds the
# process-global skills lock for its whole body, which serialized every
# other endpoint behind a slow server. The probe only needs the
# HERMES_HOME override for .env interpolation + OAuth token resolution.
with _config_profile_scope(profile):
tools = _probe_single_server(name, servers[name], details=details)
token_present = _oauth_tokens_present(name) if needs_oauth_token else True
return tools, token_present
try:
# Probe blocks on a dedicated MCP event loop — run in a thread so the
# FastAPI event loop is never blocked.
# Probe blocks on a dedicated MCP event loop — keep it off the FastAPI loop.
tools, token_present = await asyncio.to_thread(_probe_scoped)
except Exception as exc:
return {
@@ -210,9 +180,8 @@ async def test_mcp_server(name: str, profile: Optional[str] = None):
"error": "OAuth authentication required — no token found.",
"tools": [],
}
# Additive-optional per-tool schema size (chars of the converted registry
# schema) — the desktop's cost overlay estimates tokens from it. Older
# renderers ignore the extra key; failed probes simply omit it.
# Optional per-tool schema size (chars) — the desktop's cost overlay
# estimates tokens from it; failed probes simply omit it.
schema_chars = details.get("schema_chars") or {}
return {
"ok": True,
@@ -317,12 +286,10 @@ async def mcp_oauth_flow_status(flow_id: str, request: Request):
@router.delete("/api/mcp/oauth/flows/{flow_id}")
async def cancel_mcp_oauth_flow(flow_id: str, request: Request):
"""Cancel an in-flight MCP OAuth flow (the desktop's inline-card/pill
cancel). mark_error unblocks both worker waits, so the worker exits and
frees the per-server "already in progress" slot — without this, a renderer
that stops polling leaves the flow squatting until its 300s callback
timeout and every retry 409s. Idempotent: an already-settled flow is left
as-is (approved stays approved)."""
"""Cancel an in-flight MCP OAuth flow. mark_error unblocks the worker so
it frees the per-server "already in progress" slot — otherwise a renderer
that stops polling leaves the flow squatting until the 300s callback
timeout and every retry 409s. Idempotent: a settled flow is left as-is."""
_require_token(request)
flow = _mcp_oauth_flows.get(flow_id)
if flow is None:
@@ -378,23 +345,18 @@ async def mcp_oauth_callback(
async def set_mcp_server_enabled(
name: str, body: MCPEnabledToggle, profile: Optional[str] = None
):
"""Enable or disable an MCP server (takes effect on next session/gateway).
Toggles the ``enabled`` key on the server's config.yaml entry — the same
flag the agent reads at startup. Disabled servers stay in config so they
can be re-enabled without re-entering their settings.
"""
"""Toggle the server's ``enabled`` flag (takes effect on next session/gateway);
disabled servers stay in config so they can be re-enabled without re-entry."""
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
with config_write_scope(body.profile or profile):
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
return await asyncio.to_thread(_run)
@@ -402,14 +364,8 @@ async def set_mcp_server_enabled(
@router.get("/api/mcp/catalog")
async def list_mcp_catalog(profile: Optional[str] = None):
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
Each entry reports whether it's already installed and enabled so the UI
can show install / enabled state inline. This is the same catalog
`hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes
the installed/enabled annotations (the catalog itself is repo-shipped
and identical for every profile).
"""
"""Browse the Nous-approved MCP catalog (optional-mcps/ manifests), each
entry annotated with installed/enabled state for ``profile``."""
try:
from hermes_cli import mcp_catalog
except Exception as exc:
@@ -443,10 +399,8 @@ async def list_mcp_catalog(profile: Optional[str] = None):
{"name": e.name, "prompt": e.prompt, "required": e.required}
for e in getattr(auth, "env", []) or []
],
# Transport details so the UI can show exactly what connects/runs.
# The trust model (docs: user-guide/features/mcp) tells users to
# inspect command/args/url and the install bootstrap before
# installing — surface them rather than hiding them in the repo.
# Transport details surfaced on purpose: the trust model asks
# users to inspect command/args/url + bootstrap before installing.
"command": transport.command,
"args": list(transport.args or []),
"url": transport.url,
@@ -489,13 +443,8 @@ async def list_mcp_catalog(profile: Optional[str] = None):
@router.post("/api/mcp/catalog/install")
async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None):
"""Install a catalog MCP into config.yaml.
For HTTP/stdio entries with required env vars, those are written to .env
via the standard env path so the agent can read them at session start.
Entries that need a git bootstrap (``needs_install``) are installed via
the CLI action path because the clone can take time.
"""
"""Install a catalog MCP into config.yaml (declared env vars go to .env
first; git-bootstrap entries run via the background CLI action path)."""
from hermes_cli import mcp_catalog
name = (body.name or "").strip()
@@ -516,9 +465,8 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
),
)
# Validate the complete map before the first write. This preserves the
# existing writer/install flow while ensuring a mixed valid+invalid request
# cannot partially persist credentials.
# Validate the complete map before the first write so a mixed
# valid+invalid request cannot partially persist credentials.
from hermes_cli.config import validate_env_var_name_for_write
try:
@@ -538,13 +486,10 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
await asyncio.to_thread(_write_env)
# Git-bootstrap entries can take a while to clone — run via the background
# action path so the request returns immediately and the UI can tail logs.
# The -p subprocess rebinds HERMES_HOME-derived paths in the child.
# Git-bootstrap entries can take a while to clone — background action path
# so the request returns immediately. Per-entry action name: a shared
# "mcp-install" would let a re-click overwrite the tracked process/log.
if entry.install is not None:
# Unique per-entry action name: a shared "mcp-install" would let a
# re-click (or a second entry) overwrite the tracked process/log while
# the first clone is still running.
action = _mcp_install_action_name(name)
try:
_spawn_hermes_action(
@@ -557,18 +502,12 @@ async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[s
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
return {"ok": True, "name": name, "background": True, "action": action}
# No git step — install synchronously via the catalog API. install_entry
# routes through load_config/save_config + save_env_value, all call-time
# resolvers, so the context override scopes it. Wrap the to_thread body
# in the scope INSIDE the thread (contextvars don't propagate into
# to_thread the other way around — asyncio.to_thread copies context, so
# setting it here works; keep it explicit for clarity).
def _install_scoped():
with _profile_scope(effective_profile):
mcp_catalog.install_entry(entry, enable=body.enable)
# No git step — install synchronously; install_entry goes through the
# call-time config/env resolvers so the profile scope covers it.
try:
await asyncio.to_thread(_install_scoped)
await scoped_to_thread(
effective_profile, lambda: mcp_catalog.install_entry(entry, enable=body.enable)
)
except HTTPException:
raise
except Exception as exc:
+196 -348
View File
@@ -1,25 +1,21 @@
"""Session dashboard routes (extracted verbatim from web_server.py).
"""Session dashboard routes.
Three routers because the original registration points are far apart and
global route order matters: ``list_router`` (GET /api/sessions) was registered
before the profiles ``sessions_router`` include, ``search_router``
(GET /api/sessions/search) right after it, and ``manage_router`` (the
mutation/detail endpoints) thousands of lines later - each is mounted at its
original registration point so the app's route table is byte-identical.
Handler bodies are byte-identical; web_server-owned helpers are reached via
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
``monkeypatch.setattr(web_server, "_helper", ...)`` keep working.
Three routers because global route order matters: ``list_router``
(GET /api/sessions) was registered before the profiles ``sessions_router``
include, ``search_router`` (GET /api/sessions/search) right after it, and
``manage_router`` (mutation/detail endpoints) much later — each is mounted at
its original registration point. web_server-owned helpers are reached via
the late-binding seam so ``monkeypatch.setattr(web_server, ...)`` keeps working.
"""
import asyncio # noqa: F401 — used by handlers
import asyncio
import json
import logging
import re
import sqlite3
import time # noqa: F401
from typing import Any, Dict, List, Optional # noqa: F401
import time
from typing import Callable, List, Optional
from fastapi import APIRouter, HTTPException, Query, Request # noqa: F401
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
@@ -31,17 +27,13 @@ from hermes_cli.web_models import (
SessionPrune,
SessionRename,
)
from hermes_cli.web_routers._common import log as _log
from hermes_state import is_malformed_db_error, is_transient_sqlite_error
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
list_router = APIRouter()
search_router = APIRouter()
manage_router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_cron_default_profile = late("_cron_default_profile")
_cron_profile_home = late("_cron_profile_home")
_import_sessions_for_profile = late("_import_sessions_for_profile")
@@ -52,21 +44,43 @@ _read_session_import_body = late("_read_session_import_body")
_session_latest_descendant = late("_session_latest_descendant")
_strip_session_list_rows = late("_strip_session_list_rows")
_ACTIVE_WINDOW_S = 300
def _csv(value: Optional[str]) -> List[str]:
"""Split a comma-separated query param into stripped, non-empty items."""
return [s.strip() for s in (value or "").split(",") if s.strip()]
def _is_active(row: dict, now: float) -> bool:
return (
row.get("ended_at") is None
and (now - row.get("last_active", row.get("started_at", 0))) < _ACTIVE_WINDOW_S
)
def _with_db(profile: Optional[str], fn: Callable, *, read_only: bool):
"""Open the profile's session DB, run ``fn(db)``, always close."""
db = _open_session_db_for_profile(profile, read_only=read_only)
try:
return fn(db)
finally:
db.close()
def _serving_profile(profile: Optional[str]) -> str:
"""The profile name rows are stamped with: the requested one, else the
serving process's own — so default-profile rows never circulate unowned."""
return _cron_profile_home(profile)[0] if profile else _cron_default_profile()
def _resolve_session_id(db, session_id: str) -> Optional[str]:
"""Resolve *session_id*, distinguishing "absent" from "unreadable".
A corrupt ``state.db`` does not raise on every read. The exact-match
lookup goes through the ``sessions`` primary-key index, so a damaged
index simply *misses* and returns None — indistinguishable from a
session that was never there. Only the prefix fallback scans the base
b-tree and raises ``database disk image is malformed``.
Both outcomes used to end at ``404 Session not found``: one silently, one
as an unhandled 500 that the Desktop surfaced as "session unavailable".
Reporting a corrupt store as an empty one cost a day of looking at
session logic during the 2026-08-31 incident, so classify it here and say
what is actually wrong.
On a corrupt ``state.db`` the exact-match lookup (primary-key index) just
misses, while the prefix fallback scans the b-tree and raises "database
disk image is malformed". Both used to end at 404; report corruption as
503 with the actual problem instead of an empty store.
"""
try:
return db.resolve_session_id(session_id)
@@ -88,9 +102,8 @@ def _resolve_session_id(db, session_id: str) -> Optional[str]:
@list_router.get("/api/sessions")
def get_sessions(
# ``le=100`` caps the page size (idea from #39200): an unbounded limit
# lets one request drag every session row (plus correlated-subquery
# preview work) out of SQLite in a single hit.
# ``le=100``: an unbounded limit lets one request drag every session row
# (plus correlated-subquery preview work) out of SQLite in a single hit.
limit: int = Query(20, ge=0, le=100),
offset: int = Query(0, ge=0),
min_messages: int = 0,
@@ -105,18 +118,11 @@ def get_sessions(
):
"""List sessions.
``archived`` controls how soft-archived sessions are treated:
``exclude`` (default) hides them, ``only`` returns just the archived ones
(used by the desktop "Archived sessions" settings panel), and ``include``
returns both.
``order`` controls pagination order: ``created`` (default, by original
start time) or ``recent`` (by latest activity across the compression
chain). ``recent`` keeps a long-running conversation on the first page
after it auto-compresses into a fresh continuation id.
Rows omit ``system_prompt``/``model_config`` (the payload-dominating
fields no list UI reads) unless ``full=1`` is passed.
``archived``: ``exclude`` (default) / ``only`` / ``include``. ``order``:
``created`` (start time) or ``recent`` (latest activity across the
compression chain, so a long-running chat stays on page one after it
auto-compresses onto a fresh id). Rows omit ``system_prompt`` /
``model_config`` unless ``full=1``.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(
@@ -132,22 +138,18 @@ def get_sessions(
if profile:
profile_name, _ = _cron_profile_home(profile)
try:
# Auto-archive is the only configured write on this GET path. Run it
# through a dedicated maintenance connection, close that writer, then
# open the listing connection read-only.
# Auto-archive is the only write on this GET path: run it on its own
# maintenance connection, then open the listing connection read-only.
_maybe_auto_archive_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
# Optional source scoping: ``source`` includes a single class,
# ``sources`` includes any of several comma-separated classes, and
# ``exclude_sources`` (comma-separated) drops classes. The desktop
# uses these to split recents (exclude=cron) from the cron-jobs
# section (source=cron) into two independent lists.
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
# Source scoping: the desktop splits recents (exclude=cron) from
# the cron-jobs section (source=cron) into two independent lists.
source_list = _csv(sources)
exclude_list = _csv(exclude_sources)
sessions = db.list_sessions_rich(
source=source or None,
sources=source_list or None,
@@ -159,9 +161,8 @@ def get_sessions(
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
# SQL-level projection: when the caller didn't ask for full
# rows, skip the system_prompt blob inside SQLite too (pairs
# with the API-level _strip_session_list_rows below).
# Skip the system_prompt blob inside SQLite too (pairs with
# _strip_session_list_rows below).
compact_rows=not full,
include_pinned=True,
)
@@ -176,18 +177,12 @@ def get_sessions(
exclude_children=True,
)
now = time.time()
# Same ownership contract as get_session_detail: rows are stamped
# with the serving profile even when the request wasn't explicitly
# scoped, so default-profile rows never circulate unowned.
row_profile = profile_name or _cron_default_profile()
for s in sessions:
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
s["is_active"] = _is_active(s, now)
s["profile"] = row_profile
s["is_default_profile"] = row_profile == "default"
# SQLite stores the flag as 0/1; expose a real JSON boolean.
# SQLite stores the flags as 0/1; expose real JSON booleans.
s["archived"] = bool(s.get("archived"))
s["pinned"] = bool(s.get("pinned"))
if not full:
@@ -199,11 +194,9 @@ def get_sessions(
raise
except sqlite3.OperationalError as exc:
_log.exception("GET /api/sessions failed")
# 503, not 500: the store is busy, not gone. The desktop keeps the
# sidebar it already has instead of reading a 500 as an authoritative
# empty list. Retrying the OPEN here is deliberately not done — the
# bounded retry lives in SessionDB's read-only constructor, so every
# read-only opener gets it, not just this route.
# 503, not 500: the store is busy, not gone — the desktop keeps its
# sidebar instead of reading a 500 as an authoritative empty list.
# The bounded open-retry lives in SessionDB's read-only constructor.
transient = is_transient_sqlite_error(exc)
raise HTTPException(
status_code=503 if transient else 500,
@@ -227,15 +220,13 @@ async def search_sessions(
sources: str = None,
exclude_sources: str = None,
):
"""Search sessions by ID plus full-text message content using FTS5.
"""Search sessions by ID plus FTS5 message content.
Direct session-id matches are surfaced first, then FTS message-content
matches. Results are deduped by compression lineage, not by raw
``session_id``. Auto-compression rotates a conversation onto a fresh
session id (and leaves the old segment's messages in the FTS index), so one
logical chat can own many ``sessions`` rows that all match the same query.
Branches also use ``parent_session_id``, but they are real alternate
conversations; don't collapse branch-specific hits back into the parent.
ID matches first, then content matches. Results are deduped by
compression lineage, not raw ``session_id``: auto-compression rotates a
chat onto a fresh id and leaves the old segment in the FTS index, so one
logical chat owns many rows. Branches also use ``parent_session_id`` but
are real alternate conversations — they are NOT collapsed into the parent.
"""
if not q or not q.strip():
return {"results": []}
@@ -244,15 +235,13 @@ async def search_sessions(
try:
safe_limit = max(1, min(int(limit or 20), 100))
source_filter = source or None
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
source_list = _csv(sources)
include_sources = [source_filter] if source_filter else (source_list or None)
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
exclude_list = _csv(exclude_sources)
now = time.time()
# Walk parent_session_id to the compression root, memoized so a
# chain of compression segments only costs one walk. We deliberately
# stop at branch/delegate edges: those sessions may diverge from the
# parent and should remain searchable on their own.
# Walk parent_session_id to the compression root, memoized per
# chain; stops at branch/delegate edges (those stay searchable).
root_cache: dict = {}
def compression_root(session_id: str) -> str:
@@ -319,10 +308,8 @@ async def search_sessions(
tip_cache[root_id] = tip
return tip
# Both ID matches and content matches share one keyspace, keyed by
# compression lineage root, so an id-hit and a content-hit on the
# same logical conversation collapse to a single result. The first
# hit for a lineage wins; ID matches run first and take priority.
# One keyspace for id-hits and content-hits, keyed by lineage root;
# first hit wins, and ID matches run first.
seen: dict = {}
def add_lineage_result(raw_sid: str, payload: dict) -> None:
@@ -366,10 +353,7 @@ async def search_sessions(
payload["id"] = sid
seen[root] = payload
# Direct ID matches first: users often paste a session id from CLI,
# logs, or another Hermes surface. FTS can't find those unless the
# id happens to appear in message text. search_sessions_by_id is
# SQL-bounded, so this stays cheap even with thousands of sessions.
# Direct ID matches first (pasted ids never appear in message text).
for row in db.search_sessions_by_id(
q,
limit=safe_limit,
@@ -392,19 +376,14 @@ async def search_sessions(
},
)
# Auto-add prefix wildcards so partial words match
# e.g. "nimb" → "nimb*" matches "nimby"
# Preserve quoted phrases and existing wildcards as-is
import re
terms = []
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
if token.startswith('"') or token.endswith("*"):
terms.append(token)
else:
terms.append(token + "*")
prefix_query = " ".join(terms)
# Prefix wildcards so partial words match ("nimb" -> "nimb*");
# quoted phrases and existing wildcards are kept as-is.
prefix_query = " ".join(
tok if tok.startswith('"') or tok.endswith("*") else tok + "*"
for tok in re.findall(r'"[^"]*"|\S+', q.strip())
)
# Over-fetch so lineage dedup can still surface `limit` distinct
# conversations even when several hits collapse onto one root.
# conversations when several hits collapse onto one root.
fetch_limit = max(safe_limit * 5, 50)
matches = db.search_messages(
query=prefix_query,
@@ -446,65 +425,32 @@ async def search_sessions(
@manage_router.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in a single DB transaction.
"""Delete every session in ``body.ids`` in one transaction (POST because
many clients refuse a DELETE body).
Backs the dashboard's bulk-select-and-delete flow on the sessions
page. POST (not DELETE) because most HTTP clients refuse to send a
request body on DELETE and a body is the natural shape for a list
of IDs — Starlette accepts both, but POSTing a list keeps proxies,
curl, and the browser ``fetch`` API consistent.
Per-row contract matches :meth:`SessionDB.delete_sessions`:
* Unknown IDs are silently skipped (the response ``deleted`` count
reflects what really happened, not the input length). This is
deliberate — UI selection state can race against another tab's
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
batch.
* Children of every deleted parent are orphaned, not cascade-
deleted.
* Active and archived sessions ARE deleted when explicitly
selected — unlike ``DELETE /api/sessions/empty``, the user
hand-picked the rows so we trust the selection.
* Like the other session-delete endpoints, this does NOT pass a
``sessions_dir`` through; on-disk transcript / request-dump
cleanup runs at the CLI/agent layer on the next prune pass.
The response carries the actual deleted count, so the dashboard
can surface it in a toast. The IDs that were removed are not
echoed back because the client already knows what it asked to
delete (unknown IDs are silently skipped — see contract above)
and can prune its in-memory list directly from the request.
Contract matches :meth:`SessionDB.delete_sessions`: unknown ids are
skipped (``deleted`` reports what really happened — selection state can
race another tab's delete), children are orphaned not cascaded, active
and archived rows ARE deleted because the user hand-picked them, and
on-disk transcript cleanup is left to the next prune pass.
"""
# Enforce a hard cap so a runaway/typo'd selection can't lock the
# DB writer for an extended window. The dashboard pages 20 rows
# at a time; 500 covers a "select all on every page in a
# reasonable scrollback" worst case without opening the door to
# multi-thousand-row transactions.
# Hard cap so a runaway selection can't lock the writer for long; 500
# covers "select all on every page of a reasonable scrollback".
if len(body.ids) > 500:
raise HTTPException(
status_code=400,
detail="ids must contain at most 500 entries",
)
def _delete() -> int:
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
return db.delete_sessions(body.ids)
finally:
db.close()
deleted = await asyncio.to_thread(_delete)
deleted = await asyncio.to_thread(
_with_db, body.profile, lambda db: db.delete_sessions(body.ids), read_only=False
)
return {"ok": True, "deleted": deleted}
@manage_router.post("/api/sessions/import")
async def import_sessions_endpoint(request: Request):
"""Import one or more sessions exported from the dashboard or CLI.
This is intentionally separate from ``/api/ops/import``: that endpoint
restores a whole Hermes backup archive, while this endpoint is scoped to
session rows/messages and is safe to use from the Sessions page.
"""
"""Import sessions exported from the dashboard or CLI (session rows only —
``/api/ops/import`` restores a whole backup archive)."""
try:
raw_body = await _read_session_import_body(request)
body = SessionImport.model_validate_json(raw_body)
@@ -525,111 +471,69 @@ async def import_sessions_endpoint(request: Request):
@manage_router.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
def _count() -> int:
db = _open_session_db_for_profile(profile, read_only=True)
try:
return db.count_empty_sessions()
finally:
db.close()
return {"count": await asyncio.to_thread(_count)}
"""Count of empty, ended, non-archived sessions (drives the "Delete empty
(N)" button, hidden when N is 0)."""
count = await asyncio.to_thread(
_with_db, profile, lambda db: db.count_empty_sessions(), read_only=True
)
return {"count": count}
@manage_router.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
"""Delete every empty, ended, non-archived session in a single
transaction.
"""Delete every empty, ended, non-archived session in one transaction.
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
* "Empty" means the session owns no rows in ``messages`` at all — not
merely ``message_count == 0``. A rewound or in-place-compacted chat
keeps its dropped turns as soft-archived (``active = 0``) rows while
the counter reads zero, and those rows are the only recoverable copy
of the transcript (#95868).
* Active sessions are skipped (``ended_at IS NULL``) so a live
agent isn't yanked mid-handshake.
* Archived sessions are skipped — the user explicitly chose to
keep those rows.
* Children of deleted parents are orphaned, not cascade-deleted.
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
below, this doesn't pass a ``sessions_dir`` through — the on-disk
transcript / request-dump cleanup is wired at the CLI/agent layer
but the web server historically leaves file cleanup to the next
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
Mirrors :meth:`SessionDB.delete_empty_sessions`: "empty" means NO
``messages`` rows at all (a rewound/compacted chat reads
``message_count == 0`` while its soft-archived rows are the only copy of
the transcript); active and archived sessions are skipped; children are
orphaned; on-disk cleanup is left to the next prune pass.
"""
def _delete() -> int:
db = _open_session_db_for_profile(profile, read_only=False)
try:
return db.delete_empty_sessions()
finally:
db.close()
deleted = await asyncio.to_thread(_delete)
deleted = await asyncio.to_thread(
_with_db, profile, lambda db: db.delete_empty_sessions(), read_only=False
)
return {"ok": True, "deleted": deleted}
@manage_router.get("/api/sessions/stats")
async def get_session_stats(profile: Optional[str] = None):
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
db = _open_session_db_for_profile(profile, read_only=True)
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
archived = db.session_count(archived_only=True)
messages = db.message_count()
by_source: Dict[str, int] = {}
"""Session-store statistics (mirrors `hermes sessions stats`). Registered
before ``/api/sessions/{session_id}`` so ``stats`` isn't taken as an id."""
def _stats(db):
out = {
"total": db.session_count(include_archived=True),
"active_store": db.session_count(include_archived=False),
"archived": db.session_count(archived_only=True),
"messages": db.message_count(),
"by_source": {},
}
try:
by_source = db.session_count_by_source(
out["by_source"] = db.session_count_by_source(
include_archived=True,
exclude_children=True,
)
except Exception:
pass
return {
"total": total,
"active_store": active_store,
"archived": archived,
"messages": messages,
"by_source": by_source,
}
finally:
db.close()
return out
return _with_db(profile, _stats, read_only=True)
@manage_router.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile, read_only=True)
try:
def _detail(db):
sid = _resolve_session_id(db, session_id)
session = db.get_session(sid) if sid else None
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Always stamp the owning profile — the serving profile is known even
# when the request carries no ``?profile=`` (it's this process's own
# profile). Stamping only on explicit ``?profile=`` left rows for the
# default/primary profile systematically unowned, so multi-profile
# clients resolved them to whichever gateway happened to be active
# (cross-profile open asymmetry, #67603 family).
session["profile"] = (
_cron_profile_home(profile)[0] if profile else _cron_default_profile()
)
# Always stamp the owner: stamping only on explicit ``?profile=`` left
# default-profile rows unowned, so multi-profile clients resolved them
# to whichever gateway happened to be active.
session["profile"] = _serving_profile(profile)
session["is_default_profile"] = session["profile"] == "default"
return session
finally:
db.close()
return _with_db(profile, _detail, read_only=True)
@manage_router.get("/api/sessions/{session_id}/latest-descendant")
@@ -637,14 +541,9 @@ async def get_session_latest_descendant(
session_id: str,
profile: Optional[str] = None,
):
def _lookup():
db = _open_session_db_for_profile(profile, read_only=True)
try:
return _session_latest_descendant(session_id, db)
finally:
db.close()
latest, path = await asyncio.to_thread(_lookup)
latest, path = await asyncio.to_thread(
_with_db, profile, lambda db: _session_latest_descendant(session_id, db), read_only=True
)
if not latest:
raise HTTPException(status_code=404, detail="Session not found")
return {
@@ -670,32 +569,26 @@ async def get_session_messages(
detail="order must be one of: oldest, latest",
)
def _read():
db = _open_session_db_for_profile(profile, read_only=True)
try:
sid = _resolve_session_id(db, session_id)
if not sid:
return None
sid = db.resolve_resume_session_id(sid)
# Always page this endpoint. An omitted limit used to load an
# entire transcript, which can be hundreds of thousands of rows
# for a runaway session and exhaust the dashboard process. Keep
# explicit pagination anchored at the start, while the default
# dashboard view returns the latest page in chronological order.
default_page = limit is None
latest_page = order == "latest" or (order is None and default_page)
_limit = 500 if default_page else min(limit, 500)
return sid, _limit, db.get_messages(
sid,
limit=_limit,
offset=offset,
latest=latest_page,
include_compacted=include_compacted,
)
finally:
db.close()
def _read(db):
sid = _resolve_session_id(db, session_id)
if not sid:
return None
sid = db.resolve_resume_session_id(sid)
# Always page: an omitted limit used to load whole transcripts (hundreds
# of thousands of rows for a runaway session). Explicit pagination
# anchors at the start; the default view is the latest page.
default_page = limit is None
latest_page = order == "latest" or (order is None and default_page)
_limit = 500 if default_page else min(limit, 500)
return sid, _limit, db.get_messages(
sid,
limit=_limit,
offset=offset,
latest=latest_page,
include_compacted=include_compacted,
)
result = await asyncio.to_thread(_read)
result = await asyncio.to_thread(_with_db, profile, _read, read_only=True)
if result is None:
raise HTTPException(status_code=404, detail="Session not found")
sid, _limit, messages = result
@@ -733,62 +626,38 @@ async def get_session_messages(
@manage_router.delete("/api/sessions/{session_id}")
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
# ``profile`` deletes a session belonging to another (local) profile by
# opening its state.db directly. Remote profiles never reach here — the
# desktop routes their DELETE to the remote backend. Omit for current/default.
def _delete():
db = _open_session_db_for_profile(profile, read_only=False)
try:
# Resolve exact ids / unique prefixes like every other session endpoint
# (detail, messages, rename, export all do). A session that no longer
# exists is an idempotent success: DELETE's contract is "ensure it's
# gone", and the desktop optimistically removes the row then RESTORES it
# on any error — so a 404 on an already-absent row resurrected a ghost
# row and surfaced "session not found". /goal + auto-compression churn
# leaves transient empty rows (reaped by empty-session hygiene) that
# race the sidebar snapshot, which is exactly when this fired. Mirrors
# the bulk-delete endpoint, which already treats ghost ids as success.
sid = _resolve_session_id(db, session_id)
if not sid:
return {"ok": True, "already_absent": True}
db.delete_session(sid)
return {"ok": True}
finally:
db.close()
# ``profile`` opens another local profile's state.db directly (remote
# profiles never reach here — the desktop routes those to the remote backend).
def _delete(db):
# An already-absent session is an idempotent success: the desktop
# optimistically removes the row and RESTORES it on any error, so a 404
# here resurrected ghost rows (transient empties from /goal +
# auto-compression churn racing the sidebar snapshot).
sid = _resolve_session_id(db, session_id)
if not sid:
return {"ok": True, "already_absent": True}
db.delete_session(sid)
return {"ok": True}
return await asyncio.to_thread(_delete)
return await asyncio.to_thread(_with_db, profile, _delete, read_only=False)
@manage_router.post("/api/sessions/owner-backfill")
async def backfill_session_owner_profiles(body: SessionOwnerBackfill):
"""Stamp legacy ``profile_name = NULL`` session rows with this store's own
serving-profile identity (#94724 legacy-session migration).
"""Stamp legacy ``profile_name = NULL`` rows with this store's own
serving-profile identity.
Pre-#95407 rows never recorded an owning profile. That was fine while one
backend served everything, but a Desktop with registry topology (≥2
registered connections) fails closed on unowned rows by design — leaving
every pre-campaign session unresumable with no migration path. Each
profile's ``state.db`` belongs to exactly one profile, so stamping that
store's own name is a single-match backfill, never a guess; the value
written is the SAME serving-profile identity the list endpoints already
stamp onto outgoing rows (``row_profile`` in ``get_sessions``). Idempotent
and one-shot-per-row: non-NULL owners are never overwritten and a second
call reports 0.
A multi-connection Desktop fails closed on unowned rows, leaving legacy
sessions unresumable. Each ``state.db`` belongs to exactly one profile,
so this is a single-match backfill (the same identity ``get_sessions``
stamps on outgoing rows), idempotent: non-NULL owners are never overwritten.
"""
profile_name: Optional[str] = None
if body.profile:
profile_name, _ = _cron_profile_home(body.profile)
stamp = profile_name or _cron_default_profile()
def _backfill():
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
return db.backfill_null_session_profiles(stamp)
finally:
db.close()
stamp = _serving_profile(body.profile)
try:
stamped = await asyncio.to_thread(_backfill)
stamped = await asyncio.to_thread(
_with_db, body.profile, lambda db: db.backfill_null_session_profiles(stamp), read_only=False
)
except HTTPException:
raise
except Exception:
@@ -806,28 +675,16 @@ async def backfill_session_owner_profiles(body: SessionOwnerBackfill):
@manage_router.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename, archive, hide, pin, and/or mark read/unread.
"""Update a session: ``title`` (empty clears), ``archived``, ``hidden``,
``pinned`` (exempts from the auto-archive sweep), ``unread`` (True =
explicitly unread, False = read up to now). Any field may be omitted."""
flags = ("archived", "hidden", "pinned", "unread")
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session; ``hidden`` controls generic list visibility;
``pinned`` sets the durable keep flag (exempts the
session from the auto-archive sweep); ``unread`` toggles the read-state
watermark (True = explicitly unread, False = read up to now — see
``SessionDB.set_session_read``). Any field may be omitted. ``profile``
targets another profile's session.
"""
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
def _update(db):
sid = _resolve_session_id(db, session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
if (
body.title is None
and body.archived is None
and body.hidden is None
and body.pinned is None
and body.unread is None
):
if body.title is None and all(getattr(body, f) is None for f in flags):
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title', 'archived', 'hidden', 'pinned', and/or 'unread'.",
@@ -847,31 +704,22 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
if body.unread is not None:
db.set_session_read(sid, read=not body.unread)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
if body.hidden is not None:
result["hidden"] = bool(body.hidden)
if body.pinned is not None:
result["pinned"] = bool(body.pinned)
if body.unread is not None:
result["unread"] = bool(body.unread)
for f in flags:
if getattr(body, f) is not None:
result[f] = bool(getattr(body, f))
return result
finally:
db.close()
return _with_db(body.profile, _update, read_only=False)
@manage_router.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
"""Stream a single session (metadata + messages) as JSON."""
def _prepare_export():
db = _open_session_db_for_profile(profile, read_only=True)
try:
sid = _resolve_session_id(db, session_id)
return (sid, db.get_session(sid)) if sid else None
finally:
db.close()
def _prepare_export(db):
sid = _resolve_session_id(db, session_id)
return (sid, db.get_session(sid)) if sid else None
prepared = await asyncio.to_thread(_prepare_export)
prepared = await asyncio.to_thread(_with_db, profile, _prepare_export, read_only=True)
if prepared is None or prepared[1] is None:
raise HTTPException(status_code=404, detail="Session not found")
+102 -207
View File
@@ -1,22 +1,16 @@
"""Skills dashboard routes (extracted verbatim from web_server.py).
"""Skills dashboard routes.
Two routers because the original registration points are far apart and global
route order matters: ``hub_router`` (the skills-hub install/search/scan
endpoints) was registered before the profiles ``router`` include in
web_server, the plain skills CRUD ``router`` after it - each is mounted at
its original registration point.
Handler bodies are byte-identical; web_server-owned helpers are reached via
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
``monkeypatch.setattr(web_server, "_spawn_hermes_action", ...)`` keep
working.
Two routers because global route order matters: ``hub_router`` (skills-hub
install/search/scan) was registered before the profiles router include in
web_server, the plain skills CRUD ``router`` after it — each is mounted at its
original registration point. web_server-owned helpers are reached via the
late-binding seam so ``monkeypatch.setattr(web_server, ...)`` keeps working.
"""
import asyncio # noqa: F401 — used by handlers
import logging
from typing import Optional # noqa: F401
import asyncio
from typing import Optional
from fastapi import APIRouter, HTTPException # noqa: F401
from fastapi import APIRouter, HTTPException
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
@@ -27,99 +21,69 @@ from hermes_cli.web_models import (
SkillUninstallRequest,
SkillsUpdateRequest,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
from hermes_cli.web_routers._common import (
_profile_scope,
config_write_scope,
http_failure,
log as _log,
require,
scoped_to_thread,
spawn_profile_action,
)
hub_router = APIRouter()
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_clear_skills_prompt_cache = late("_clear_skills_prompt_cache")
_config_profile_scope = late("_config_profile_scope")
_hub_action_name = late("_hub_action_name")
_installed_hub_identifiers = late("_installed_hub_identifiers")
_profile_cli_args = late("_profile_cli_args")
_profile_scope = late("_profile_scope")
_skill_meta_to_payload = late("_skill_meta_to_payload")
_spawn_hermes_action = late("_spawn_hermes_action")
load_config = late("load_config")
# Live proxies for web_server-owned module state (mutations/monkeypatches
# on web_server remain authoritative; resolved at operation time).
_SKILL_HUB_SOURCE_LABELS = LateState("_SKILL_HUB_SOURCE_LABELS")
# Config read-modify-write serialization for off-loop handlers (see the
# definition in web_server.py). LateState supports ``with``-blocks, so this
# is the live lock object, not a frozen import-time copy.
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
@hub_router.post("/api/skills/hub/install")
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
identifier = (body.identifier or "").strip()
if not identifier:
raise HTTPException(status_code=400, detail="identifier is required")
name = _hub_action_name("install", identifier)
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile or profile)
+ ["skills", "install", identifier, "--yes"],
name,
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn skills install")
raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}")
return {"ok": True, "pid": proc.pid, "name": name}
identifier = require(body.identifier, "identifier is required")
return spawn_profile_action(
body.profile or profile,
["skills", "install", identifier, "--yes"],
_hub_action_name("install", identifier),
log_msg="Failed to spawn skills install",
prefix="Failed to install skill",
)
@hub_router.post("/api/skills/hub/uninstall")
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
action = _hub_action_name("uninstall", name)
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile or profile) + ["skills", "uninstall", name, "--yes"],
action,
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn skills uninstall")
raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}")
return {"ok": True, "pid": proc.pid, "name": action}
name = require(body.name, "name is required")
return spawn_profile_action(
body.profile or profile,
["skills", "uninstall", name, "--yes"],
_hub_action_name("uninstall", name),
log_msg="Failed to spawn skills uninstall",
prefix="Failed to uninstall skill",
)
@hub_router.post("/api/skills/hub/update")
async def update_skills_hub(
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
):
try:
effective = (body.profile if body else None) or profile
proc = _spawn_hermes_action(
_profile_cli_args(effective) + ["skills", "update"], "skills-update"
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn skills update")
raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}")
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
return spawn_profile_action(
(body.profile if body else None) or profile,
["skills", "update"],
"skills-update",
log_msg="Failed to spawn skills update",
prefix="Failed to update skills",
)
@hub_router.get("/api/skills/hub/official")
async def list_official_skills(profile: Optional[str] = None):
"""List the ENTIRE built-in optional-skills catalog shipped with the repo.
Backs the desktop Capabilities → Skills list: every official optional
skill appears alongside the installed skills with an install affordance.
Local-checkout scan only (no network), plus the per-profile installed
map so the UI can mark rows that are already installed.
"""
"""The ENTIRE built-in optional-skills catalog (local scan, no network),
each row marked installed-or-not for ``profile``."""
def _run():
from tools.skills_hub import OptionalSkillSource
@@ -129,33 +93,23 @@ async def list_official_skills(profile: Optional[str] = None):
for m in OptionalSkillSource().list_local():
payload = _skill_meta_to_payload(m)
ident = payload.get("identifier") or ""
# identifier format: official/<category>/<skill> (category dirs
# mirror skills/): surface the category for row subtitles.
# identifier format: official/<category>/<skill> — surface the
# category for row subtitles.
rel = ident.split("/", 1)[-1] if "/" in ident else ident
payload["category"] = rel.split("/", 1)[0] if "/" in rel else "general"
payload["installed"] = ident in installed
out.append(payload)
return {"skills": out}
try:
with http_failure("official skills catalog listing failed", 502, "Official catalog failed"):
return await asyncio.to_thread(_run)
except HTTPException:
raise
except Exception as exc:
_log.exception("official skills catalog listing failed")
raise HTTPException(status_code=502, detail=f"Official catalog failed: {exc}")
@hub_router.get("/api/skills/hub/sources")
async def list_skills_hub_sources(profile: Optional[str] = None):
"""List the configured skill-hub sources and installed-skill provenance.
Gives the dashboard something to show BEFORE a search runs — which hubs
are wired up, their trust tier, and a set of featured skills pulled from
the centralized index (zero extra API calls). Without this the Browse-hub
tab is a blank page with no indication it's even connected to anything.
``profile`` scopes the installed-skill provenance to that profile.
"""
"""Configured skill-hub sources + installed-skill provenance, so the
Browse-hub tab has something to show before a search runs. ``profile``
scopes the installed-skill provenance."""
def _run():
from tools.skills_hub import create_source_router
@@ -192,12 +146,10 @@ async def list_skills_hub_sources(profile: Optional[str] = None):
except Exception:
featured = []
out.append(entry)
# Tell the UI which sources are worth searching individually (for its
# progressive per-source fan-out). Mirror parallel_search_sources: when
# the centralized index is available it already subsumes the external
# API sources, so they're redundant — skipping them avoids ~70 GitHub
# calls per keystroke. Keep this set in sync with that function's
# ``_api_source_ids``.
# Which sources are worth searching individually (progressive per-source
# fan-out). Mirrors parallel_search_sources: an available index already
# subsumes the external API sources, so skipping them saves ~70 GitHub
# calls per keystroke. Keep in sync with that function's _api_source_ids.
_api_source_ids = frozenset(
{"github", "skills-sh", "clawhub", "lobehub", "well-known"}
)
@@ -210,26 +162,16 @@ async def list_skills_hub_sources(profile: Optional[str] = None):
"installed": _installed_hub_identifiers(profile),
}
try:
with http_failure("skills hub sources listing failed", 502, "Hub sources failed"):
return await asyncio.to_thread(_run)
except HTTPException:
raise
except Exception as exc:
_log.exception("skills hub sources listing failed")
raise HTTPException(status_code=502, detail=f"Hub sources failed: {exc}")
@hub_router.get("/api/skills/hub/search")
async def search_skills_hub(
q: str = "", source: str = "all", limit: int = 20, profile: Optional[str] = None
):
"""Search the skill hub across all configured sources.
Network-bound (parallel source search); runs in a thread so the FastAPI
loop isn't blocked. Returns structured results the UI installs by
identifier via POST /api/skills/hub/install, previews via
/api/skills/hub/preview, and scans via /api/skills/hub/scan.
"""
"""Search the skill hub across all configured sources (network-bound,
runs in a thread). Results install by identifier via /hub/install."""
query = (q or "").strip()
if not query:
return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}}
@@ -261,30 +203,16 @@ async def search_skills_hub(
"installed": _installed_hub_identifiers(profile),
}
try:
with http_failure("skills hub search failed", 502, "Hub search failed"):
return await asyncio.to_thread(_run)
except HTTPException:
raise
except Exception as exc:
_log.exception("skills hub search failed")
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
@hub_router.get("/api/skills/hub/preview")
async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None):
"""Fetch a hub skill's SKILL.md content + metadata for in-dashboard reading.
Resolves the identifier across configured sources (same path the CLI
installer uses), then returns the rendered SKILL.md text and the file
manifest WITHOUT installing anything. This is the 'read the actual skill
before installing' affordance the Browse-hub tab was missing.
Scoped to ``profile`` so a non-default profile with different hub taps
resolves against ITS source router, not the default profile's.
"""
ident = (identifier or "").strip()
if not ident:
raise HTTPException(status_code=400, detail="identifier is required")
"""A hub skill's SKILL.md + file manifest WITHOUT installing it. Scoped to
``profile`` so a profile with different hub taps resolves against ITS
source router."""
ident = require(identifier, "identifier is required")
def _run():
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle
@@ -301,9 +229,8 @@ async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None)
if bundle:
for rel, content in (bundle.files or {}).items():
if isinstance(content, bytes):
# Some sources (e.g. official optional skills) store every
# file as bytes. Decode text so SKILL.md / docs render;
# only fall back to a placeholder for genuinely-binary data.
# Some sources store every file as bytes; decode text so
# SKILL.md renders, placeholder only for genuinely-binary data.
try:
files[rel] = content.decode("utf-8")
except UnicodeDecodeError:
@@ -337,20 +264,11 @@ async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None)
@hub_router.get("/api/skills/hub/scan")
async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
"""Run the install-time security scan on a hub skill WITHOUT installing it.
Fetches the bundle, quarantines it, and runs the same `scan_skill` /
`should_allow_install` pipeline the CLI installer uses — then cleans up the
quarantine. Returns the verdict, per-finding detail, trust tier, and the
install-policy decision so the dashboard can show a visual safety result
on demand (the 'scan' button the Browse-hub tab was missing).
Scoped to ``profile`` so the bundle resolves against that profile's hub
source router, matching where an install would pull it from.
"""
ident = (identifier or "").strip()
if not ident:
raise HTTPException(status_code=400, detail="identifier is required")
"""Run the install-time security scan on a hub skill WITHOUT installing it
(same ``scan_skill`` / ``should_allow_install`` pipeline as the CLI, on a
quarantined bundle that is cleaned up afterwards). Scoped to ``profile``
so the bundle resolves where an install would pull it from."""
ident = require(identifier, "identifier is required")
def _run():
import shutil as _shutil
@@ -379,9 +297,8 @@ async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
try:
q_path = quarantine_bundle(bundle)
result = scan_skill(q_path, source=scan_source)
# Advisory SkillEvaluator Tier 1 second opinion (same contract
# as the CLI installer: optional binary, never blocks, errors
# degrade to no data).
# Advisory SkillEvaluator Tier 1 second opinion: optional binary,
# never blocks, errors degrade to no data (same as the CLI installer).
try:
from tools.skillevaluator_scan import (
run_tier1_scan, tier1_advisory_enabled,
@@ -411,15 +328,8 @@ async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
if q_path is not None:
_shutil.rmtree(q_path, ignore_errors=True)
allowed, reason = should_allow_install(result, force=False)
# `allowed` may be None ("ask") for agent-created/dangerous gates.
if allowed is True:
policy = "allow"
elif allowed is None:
policy = "ask"
else:
policy = "block"
allowed, reason = should_allow_install(result, force=False)
findings = [
{
"severity": f.severity,
@@ -430,7 +340,6 @@ async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
}
for f in result.findings
]
# Per-severity tally for an at-a-glance summary.
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in result.findings:
if f.severity in counts:
@@ -443,13 +352,11 @@ async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
"trust_level": result.trust_level,
"verdict": result.verdict,
"summary": result.summary,
"policy": policy,
"policy": "allow" if allowed is True else "ask" if allowed is None else "block",
"policy_reason": reason,
"findings": findings,
"severity_counts": counts,
# Advisory SkillEvaluator Tier 1 block, or None when the
# optional scanner isn't installed/enabled.
"tier1": tier1,
"tier1": tier1, # None when the optional scanner isn't installed/enabled
}
try:
@@ -502,15 +409,14 @@ async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
disabled.discard(body.name)
else:
disabled.add(body.name)
save_disabled_skills(config, disabled)
with config_write_scope(body.profile or profile):
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
disabled.discard(body.name)
else:
disabled.add(body.name)
save_disabled_skills(config, disabled)
return {"ok": True, "name": body.name, "enabled": body.enabled}
return await asyncio.to_thread(_run)
@@ -518,42 +424,35 @@ async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
@router.get("/api/skills/content")
async def get_skill_content(name: str, profile: Optional[str] = None):
"""Return the raw SKILL.md text for a skill, for the dashboard editor."""
"""Raw SKILL.md text for the dashboard editor."""
from tools.skill_manager_tool import _find_skill
def _run():
with _profile_scope(profile):
found = _find_skill(name)
if not found:
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.")
skill_md = found["path"] / "SKILL.md"
if not skill_md.exists():
raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.")
try:
content = skill_md.read_text(encoding="utf-8")
except OSError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"name": name, "content": content, "path": str(skill_md)}
def _read():
found = _find_skill(name)
if not found:
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.")
skill_md = found["path"] / "SKILL.md"
if not skill_md.exists():
raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.")
try:
content = skill_md.read_text(encoding="utf-8")
except OSError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"name": name, "content": content, "path": str(skill_md)}
return await asyncio.to_thread(_run)
return await scoped_to_thread(profile, _read)
@router.post("/api/skills")
async def create_skill(body: SkillCreate):
"""Create a new custom skill (SKILL.md) from the dashboard editor.
Calls the same validated write path as the agent's ``skill_manage``
tool (frontmatter validation, name/category validation, size limit,
optional security scan) — but bypasses the agent write-approval gate:
a write from the authenticated dashboard IS the user acting directly.
"""
"""Create a custom skill via the same validated write path as the agent's
``skill_manage`` tool, minus the agent write-approval gate — a write from
the authenticated dashboard IS the user acting directly."""
from tools.skill_manager_tool import _create_skill
def _run():
with _profile_scope(body.profile):
return _create_skill(body.name, body.content, body.category or None)
result = await asyncio.to_thread(_run)
result = await scoped_to_thread(
body.profile, lambda: _create_skill(body.name, body.content, body.category or None)
)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill."))
_clear_skills_prompt_cache()
@@ -565,11 +464,7 @@ async def update_skill_content(body: SkillContentUpdate):
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
from tools.skill_manager_tool import _edit_skill
def _run():
with _profile_scope(body.profile):
return _edit_skill(body.name, body.content)
result = await asyncio.to_thread(_run)
result = await scoped_to_thread(body.profile, lambda: _edit_skill(body.name, body.content))
if not result.get("success"):
err = result.get("error", "Failed to update skill.")
status = 404 if "not found" in str(err).lower() else 400
+179 -329
View File
@@ -1,21 +1,15 @@
"""Toolset / terminal-backend dashboard routes (extracted verbatim from
web_server.py).
"""Toolset / terminal-backend dashboard routes.
Handler bodies are byte-identical. The toolset/terminal catalogs
(``_MODEL_CATALOG_TOOLSETS``, ``_TERMINAL_BACKENDS``,
``_TERMINAL_BACKEND_NAMES`` - some defined *after* this router's mount point
in web_server's body) and all helpers stay in web_server - reached via the
late-binding seam in :mod:`hermes_cli.web_deps` (``late`` for callables,
``LateState`` for the catalog constants) so monkeypatching on web_server
stays authoritative.
The toolset/terminal catalogs and helpers stay in web_server (some are defined
*after* this router's mount point) — reached via the late-binding seam so
monkeypatching on web_server stays authoritative.
"""
import asyncio
import logging
import sys # noqa: F401 — used by handlers
from typing import Any, Dict, List, Optional # noqa: F401
import sys
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException # noqa: F401
from fastapi import APIRouter, HTTPException
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
@@ -26,37 +20,52 @@ from hermes_cli.web_models import (
ToolsetProviderSelect,
ToolsetToggle,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
from hermes_cli.web_routers._common import (
_CONFIG_MUTATION_LOCK,
_profile_cli_args,
_profile_scope,
_spawn_hermes_action,
config_write_scope,
log as _log,
scoped_to_thread,
spawn_profile_action,
)
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_find_toolset_provider_row = late("_find_toolset_provider_row")
_probe_terminal_backend = late("_probe_terminal_backend")
_profile_cli_args = late("_profile_cli_args")
_profile_scope = late("_profile_scope")
_resolve_toolset_model_plugin = late("_resolve_toolset_model_plugin")
_spawn_hermes_action = late("_spawn_hermes_action")
_toolset_model_catalog = late("_toolset_model_catalog")
load_config = late("load_config")
save_config = late("save_config")
run_in_threadpool = late("run_in_threadpool")
# Live proxies for web_server-owned module state (mutations/monkeypatches
# on web_server remain authoritative; resolved at operation time).
_MODEL_CATALOG_TOOLSETS = LateState("_MODEL_CATALOG_TOOLSETS")
_TERMINAL_BACKENDS = LateState("_TERMINAL_BACKENDS")
_TERMINAL_BACKEND_NAMES = LateState("_TERMINAL_BACKEND_NAMES")
# Dynamic variants: built-ins + plugin-registered backends, computed per
# request so a plugin installed after server start still shows up.
# Built-ins + plugin-registered backends, computed per request so a plugin
# installed after server start still shows up.
_terminal_backend_rows = late("_terminal_backend_rows")
_terminal_backend_names = late("_terminal_backend_names")
# Config read-modify-write serialization for off-loop handlers (defined in
# web_server.py; LateState supports ``with``-blocks, so this is the live lock).
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
def _require_known_toolset(name: str) -> None:
"""400 for toolset keys outside the effective configurable set."""
from hermes_cli.tools_config import _get_effective_configurable_toolsets
if name not in {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
def _dict_section(config: dict, key: str) -> dict:
"""``config[key]`` as a dict, replacing a non-dict value in place."""
section = config.setdefault(key, {})
if not isinstance(section, dict):
section = {}
config[key] = section
return section
def _no_models(name: str) -> dict:
return {"name": name, "has_models": False, "models": [], "current": None, "default": None}
@router.get("/api/tools/toolsets")
@@ -127,68 +136,48 @@ async def get_toolsets(profile: Optional[str] = None):
@router.put("/api/tools/toolsets/{name}")
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
"""Enable/disable a configurable toolset for its configuration platform.
Most toolsets persist to ``platform_toolsets.cli``. Platform-restricted
toolsets instead target their supported platform (for example, Discord's
native toolsets persist to ``platform_toolsets.discord``). The shared
``_save_platform_tools`` helper keeps the GUI and CLI in lockstep. Scoped
to ``body.profile`` when provided. Returns 400 for unknown toolset keys.
"""
"""Enable/disable a configurable toolset for its configuration platform
(``platform_toolsets.cli`` for most; platform-restricted toolsets target
their own platform) via the same ``_save_platform_tools`` the CLI uses."""
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS,
_get_effective_configurable_toolsets,
_get_platform_tools,
_save_platform_tools,
_toolset_configuration_platform,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
_require_known_toolset(name)
target_platform = _toolset_configuration_platform(name)
def _run():
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) toggle their own config section's
# ``enabled`` flag — there is no platform_toolsets entry to write.
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
config = load_config()
section = config.setdefault(name, {})
if not isinstance(section, dict):
section = {}
config[name] = section
section["enabled"] = bool(body.enabled)
save_config(config)
return
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
config = load_config()
enabled = set(
_get_platform_tools(
config,
target_platform,
include_default_mcp_servers=False,
)
with config_write_scope(body.profile or profile):
config = load_config()
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) toggle their own section's
# ``enabled`` flag — there is no platform_toolsets entry.
_dict_section(config, name)["enabled"] = bool(body.enabled)
save_config(config)
return
enabled = set(
_get_platform_tools(
config,
target_platform,
include_default_mcp_servers=False,
)
if body.enabled:
enabled.add(name)
else:
enabled.discard(name)
_save_platform_tools(config, target_platform, enabled)
)
if body.enabled:
enabled.add(name)
else:
enabled.discard(name)
_save_platform_tools(config, target_platform, enabled)
await asyncio.to_thread(_run)
# Install-on-enable: when the newly enabled toolset's provider carries a
# post_setup hook with a registered, UNSATISFIED install-state predicate
# (cua-driver binary missing, etc. — see _POST_SETUP_INSTALLED), spawn the
# same background install `hermes tools` runs interactively. Without this,
# a dashboard/desktop toggle "saves" but the tool silently never appears
# in the schema because its check_fn can't find the binary — the exact
# dead-end that forced users to discover `hermes computer-use install`
# by hand. Best-effort: a spawn failure never fails the toggle.
# Install-on-enable: a provider with a post_setup hook whose install-state
# predicate is UNSATISFIED (cua-driver binary missing, etc.) gets the same
# background install `hermes tools` runs interactively — otherwise the
# toggle "saves" but the tool never appears because its check_fn can't
# find the binary. Best-effort: a spawn failure never fails the toggle.
post_setup_started: Optional[str] = None
if body.enabled and name not in _CONFIG_ONLY_TOOLSETS:
def _pending_install_key() -> Optional[str]:
@@ -232,17 +221,12 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str]
@router.get("/api/tools/toolsets/{name}/config")
async def get_toolset_config(name: str, profile: Optional[str] = None):
"""Return the provider matrix + key status for a toolset's config panel.
Surfaces the same provider rows the CLI ``hermes tools`` picker shows
(via ``_visible_providers``), each with its ``env_vars`` annotated with
current ``is_set`` state so the GUI can render provider selection + key
entry. Toolsets without a ``TOOL_CATEGORIES`` entry return an empty
provider list and ``has_category: false``. Returns 400 for unknown keys.
"""
"""Provider matrix + key status for a toolset's config panel: the same rows
the CLI ``hermes tools`` picker shows, each env var annotated with
``is_set``. No ``TOOL_CATEGORIES`` entry -> empty list, ``has_category:
false``."""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_is_provider_active,
_visible_providers,
provider_readiness_status,
@@ -251,9 +235,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
from hermes_cli.config import get_env_value
from hermes_cli.nous_subscription import get_nous_subscription_features
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
_require_known_toolset(name)
def _read():
with _profile_scope(profile):
@@ -264,9 +246,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
active_search_backend = None
active_extract_backend = None
if cat:
# Fetch portal/entitlement state once for the whole matrix — the
# per-provider readiness computation below reuses it instead of
# re-probing per row.
# Entitlement state fetched once for the whole matrix.
features = get_nous_subscription_features(config, force_fresh=True)
for prov in _visible_providers(cat, config, force_fresh=True):
env_vars = [
@@ -279,10 +259,8 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
}
for e in prov.get("env_vars", [])
]
# Surface the same active-provider determination the CLI picker
# uses (``_is_provider_active``) so the GUI highlights the provider
# actually written to config (e.g. web.backend), not just the first
# keyless one in the list.
# Same active-provider determination as the CLI picker, so the
# GUI highlights the provider actually written to config.
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
@@ -294,33 +272,26 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
"post_setup": prov.get("post_setup"),
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
"is_active": is_active,
# Honest server-side readiness. The GUI's old client-side
# heuristic showed "Ready" for every zero-env-var row —
# including logged-out Nous Subscription rows and never-run
# post_setup installs (see provider_readiness_status).
# Server-side readiness: zero-env-var rows are NOT
# automatically ready (logged-out Nous rows, never-run
# post_setup installs).
"status": provider_readiness_status(
prov, config, features=features, is_active=is_active
),
}
if name == "web" and prov.get("web_backend"):
# The runtime split web into two capabilities long ago
# (web.search_backend / web.extract_backend); surface each
# row's backend key and which capabilities it can serve so
# the GUI can offer per-capability selection.
# web is two capabilities (search/extract); surface each
# row's backend key + capabilities for per-capability selection.
row["web_backend"] = prov["web_backend"]
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
if name == "tts" and prov.get("tts_provider"):
# The provider key written to tts.provider on selection.
# Doubles as the config section holding the provider's
# voice/model settings (tts.<key>.*) so the GUI can render
# those fields inline in the Capabilities panel.
# Key written to tts.provider; doubles as the config section
# (tts.<key>.*) holding the provider's voice/model settings.
row["tts_provider"] = prov["tts_provider"]
providers.append(row)
if name == "web":
# Resolve the per-capability active backends exactly the way the
# web_search / web_extract dispatchers do (per-capability key →
# shared web.backend → credential auto-detect), so the GUI badges
# reflect what a tool call would actually hit right now.
# Resolve active backends exactly as the web_search/web_extract
# dispatchers do, so badges reflect what a call would hit now.
try:
from tools.web_tools import _get_extract_backend, _get_search_backend
@@ -349,17 +320,12 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
async def get_toolset_models(
name: str, provider: Optional[str] = None, profile: Optional[str] = None
):
"""Return the model catalog for a toolset backend (image/video gen).
The GUI counterpart of the model picker `hermes tools` runs after a
backend is selected — e.g. FAL's multi-model catalog (speed / strengths /
price per model). ``provider`` names a picker row; omitted, the currently
active provider is used. Toolsets without model catalogs return
``has_models: false``.
"""
"""Model catalog for a toolset backend (image/video gen) — the GUI
counterpart of the CLI model picker. ``provider`` names a picker row
(default: the active provider); no catalog -> ``has_models: false``."""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
return {"name": name, "has_models": False, "models": [], "current": None, "default": None}
return _no_models(name)
def _read():
with _profile_scope(profile):
@@ -382,13 +348,7 @@ async def get_toolset_models(
resolved = await asyncio.to_thread(_read)
if resolved is None:
return {
"name": name,
"has_models": False,
"models": [],
"current": None,
"default": None,
}
return _no_models(name)
row, plugin, catalog, default_model, current = resolved
models = [
@@ -416,12 +376,8 @@ async def get_toolset_models(
async def select_toolset_model(
name: str, body: ToolsetModelSelect, profile: Optional[str] = None
):
"""Persist a backend model selection (``image_gen.model`` / ``video_gen.model``).
Validates the model against the resolved backend's catalog — the same
write the CLI's post-selection model picker performs. Returns 400 for
toolsets without model catalogs or unknown model ids.
"""
"""Persist a backend model selection (``image_gen.model`` /
``video_gen.model``), validated against the resolved backend's catalog."""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
raise HTTPException(
@@ -433,30 +389,25 @@ async def select_toolset_model(
raise HTTPException(status_code=400, detail="model is required")
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
config = load_config()
row = _find_toolset_provider_row(name, config, body.provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
raise HTTPException(
status_code=400,
detail=f"No model-capable backend is active for {name}",
)
with config_write_scope(body.profile or profile):
config = load_config()
row = _find_toolset_provider_row(name, config, body.provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
raise HTTPException(
status_code=400,
detail=f"No model-capable backend is active for {name}",
)
catalog, _default = _toolset_model_catalog(name, plugin)
if model_id not in catalog:
raise HTTPException(
status_code=400,
detail=f"Unknown model {model_id!r} for backend {plugin!r}",
)
catalog, _default = _toolset_model_catalog(name, plugin)
if model_id not in catalog:
raise HTTPException(
status_code=400,
detail=f"Unknown model {model_id!r} for backend {plugin!r}",
)
section_cfg = config.setdefault(section, {})
if not isinstance(section_cfg, dict):
section_cfg = {}
config[section] = section_cfg
section_cfg["model"] = model_id
save_config(config)
_dict_section(config, section)["model"] = model_id
save_config(config)
return plugin
plugin = await asyncio.to_thread(_run)
@@ -467,38 +418,24 @@ async def select_toolset_model(
async def select_toolset_provider(
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None
):
"""Persist a provider selection for a toolset (no key prompting).
"""Persist a provider selection via ``apply_provider_selection`` (the
non-interactive core shared with ``hermes tools``, so both write identical
config keys). Keys and post-setup are separate endpoints.
Delegates to ``apply_provider_selection`` — the shared, non-interactive
core extracted from the CLI configurator — so the GUI and ``hermes tools``
write identical config keys (``web.backend``, ``tts.provider``, etc.).
API keys and post-setup flows are handled by separate endpoints. Returns
400 for unknown toolset or provider names.
``web`` only: ``capability`` ('search' | 'extract') scopes the write to
``web.<capability>_backend`` (the per-capability override the runtime
dispatchers resolve first); the provider must support that capability.
Omitted -> legacy whole-provider write of ``web.backend``.
For the ``web`` toolset only, an optional ``capability`` ('search' |
'extract') scopes the selection to ``web.search_backend`` /
``web.extract_backend`` — the same per-capability overrides the runtime
dispatchers (``tools.web_tools._get_search_backend`` /
``_get_extract_backend``) resolve first. The provider must actually
support the requested capability (a search-only backend can't be the
extract backend). Omitting ``capability`` keeps the legacy whole-provider
behavior (writes ``web.backend``).
Managed Nous rows (``managed_nous_feature``) additionally report the
Portal entitlement state: the CLI flow gates these selections on
``ensure_nous_portal_access`` (inline login), but the GUI has no inline
prompt, so selecting one while logged out / unentitled used to write the
config keys and then never activate (``_is_provider_active`` requires
``managed_by_nous``). The response now carries an additive
``needs_nous_auth: true`` + ``feature`` so the client can drive the
existing Nous Portal OAuth flow (``POST /api/providers/oauth/nous/start``)
and refetch.
Managed Nous rows report Portal entitlement: the GUI has no inline login,
so an unentitled selection would write config and never activate. The
response adds ``needs_nous_auth: true`` + ``feature`` so the client can
drive the Nous Portal OAuth flow and refetch.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
apply_provider_selection,
web_provider_capabilities,
_get_effective_configurable_toolsets,
_visible_providers,
)
from hermes_cli.nous_subscription import (
@@ -506,9 +443,7 @@ async def select_toolset_provider(
get_nous_subscription_features,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
_require_known_toolset(name)
if body.capability is not None:
if name != "web":
@@ -522,18 +457,20 @@ async def select_toolset_provider(
detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')",
)
def _provider_row(config):
cat = TOOL_CATEGORIES.get(name)
providers = _visible_providers(cat, config, force_fresh=True) if cat else []
return next((p for p in providers if p.get("name") == body.provider), None)
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
config = load_config()
if body.capability is not None:
# Per-capability path: resolve the picker row to its backend key
# and write web.<capability>_backend. Does NOT touch web.backend,
# so the other capability keeps resolving through the shared
# fallback chain.
cat = TOOL_CATEGORIES.get(name)
providers = _visible_providers(cat, config, force_fresh=True) if cat else []
prov = next((p for p in providers if p.get("name") == body.provider), None)
# Per-capability path writes web.<capability>_backend only —
# web.backend is untouched so the other capability keeps
# resolving through the shared fallback chain.
prov = _provider_row(config)
if prov is None:
raise HTTPException(
status_code=400,
@@ -550,11 +487,7 @@ async def select_toolset_provider(
status_code=400,
detail=f"{body.provider} does not support {body.capability}",
)
web_cfg = config.setdefault("web", {})
if not isinstance(web_cfg, dict):
web_cfg = {}
config["web"] = web_cfg
web_cfg[f"{body.capability}_backend"] = backend
_dict_section(config, "web")[f"{body.capability}_backend"] = backend
else:
try:
apply_provider_selection(name, body.provider, config)
@@ -565,23 +498,10 @@ async def select_toolset_provider(
if body.capability is not None:
response["capability"] = body.capability
# Entitlement check for managed Nous rows — mirrors the gate the CLI
# applies via ensure_nous_portal_access at selection time. This hits
# the network (Portal), so it runs AFTER releasing the mutation lock:
# holding a process-wide config-write lock across a network fetch
# would stall every other config writer behind a slow Portal call.
# Still inside the worker thread + profile scope.
cat = TOOL_CATEGORIES.get(name)
row = None
if cat:
row = next(
(
p
for p in _visible_providers(cat, config, force_fresh=True)
if p.get("name") == body.provider
),
None,
)
# Entitlement check for managed Nous rows (mirrors the CLI's
# ensure_nous_portal_access gate). Hits the Portal, so it runs AFTER
# releasing the mutation lock — still in the worker thread + scope.
row = _provider_row(config)
managed_feature = (row or {}).get("managed_nous_feature")
if managed_feature:
features = get_nous_subscription_features(config, force_fresh=True)
@@ -601,32 +521,18 @@ async def select_toolset_provider(
response["feature"] = managed_feature
return response
response = await asyncio.to_thread(_run)
return response
return await asyncio.to_thread(_run)
@router.put("/api/tools/toolsets/{name}/env")
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
"""Persist API keys for a toolset's provider env vars.
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` —
the same store ``hermes tools`` writes when it prompts for keys. Keys are
validated against the env-var allowlist for the toolset's category (the
union of every visible provider's ``env_vars``), so the GUI can't write an
arbitrary env var through this endpoint. A blank value is treated as
"leave unchanged" and skipped. Returns the saved/skipped key lists and the
refreshed ``is_set`` status. Returns 400 for unknown toolset or env keys.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_visible_providers,
)
"""Persist API keys to ``.env`` via ``save_env_value``. Keys are validated
against the union of the category's visible-provider ``env_vars`` so this
can't write arbitrary env vars; a blank value means "leave unchanged"."""
from hermes_cli.tools_config import TOOL_CATEGORIES, _visible_providers
from hermes_cli.config import get_env_value, save_env_value
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
_require_known_toolset(name)
def _run():
with _profile_scope(body.profile or profile):
@@ -668,62 +574,35 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[
async def run_toolset_post_setup(
name: str, body: ToolsetPostSetup, profile: Optional[str] = None
):
"""Spawn a provider's post-setup install hook as a background action.
Post-setup hooks (npm install for browser/Camofox, pip install for
KittenTTS/Piper/ddgs, cua-driver fetch, etc.) are long-running and
text-output, so this follows the spawn-action pattern: it launches
``hermes tools post-setup <key>`` and the frontend tails the log via
``GET /api/actions/tools-post-setup/status``. The ``key`` is validated
against the declared post-setup allowlist before spawning. Returns 400
for unknown toolset or post-setup key.
``profile`` spawns the hook as ``hermes -p <profile> tools post-setup``.
Most hooks install machine-level artifacts (repo node_modules, shared
pip packages) where the scope is inert, but hooks that read config or
write per-profile state must see the same HERMES_HOME the rest of the
drawer's writes targeted — so the scope is threaded for consistency.
"""
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets,
valid_post_setup_keys,
)
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
"""Spawn ``hermes tools post-setup <key>`` (long-running installs: npm,
pip, cua-driver fetch) as a background action the frontend tails via
``GET /api/actions/tools-post-setup/status``. The key is validated
against the declared allowlist; ``profile`` is threaded so hooks that
touch config see the same HERMES_HOME as the drawer's other writes."""
from hermes_cli.tools_config import valid_post_setup_keys
_require_known_toolset(name)
if body.key not in valid_post_setup_keys():
raise HTTPException(
status_code=400, detail=f"Unknown post-setup key: {body.key}"
)
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile or profile)
+ ["tools", "post-setup", body.key],
"tools-post-setup",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn tools post-setup")
raise HTTPException(
status_code=500, detail=f"Failed to run post-setup: {exc}"
)
return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
result = spawn_profile_action(
body.profile or profile,
["tools", "post-setup", body.key],
"tools-post-setup",
log_msg="Failed to spawn tools post-setup",
prefix="Failed to run post-setup",
)
result["key"] = body.key
return result
@router.get("/api/tools/terminal/backends")
async def get_terminal_backends(profile: Optional[str] = None):
"""Terminal execution backend rows with health probes for the picker panel.
Returns ``{active, backends: [{name, label, description, active, status,
detail}]}`` where ``status`` is ``ready`` / ``needs_setup`` /
``unavailable`` and ``detail`` carries setup guidance for non-ready rows.
Probes are fast (<~2s each) and defensive — a probe failure surfaces as a
status, never an error response.
"""
"""Terminal backend rows with health probes: ``status`` is ``ready`` /
``needs_setup`` / ``unavailable``; a probe failure is a status, never an
error response."""
def _read():
with _profile_scope(profile):
config = load_config()
@@ -755,12 +634,8 @@ async def get_terminal_backends(profile: Optional[str] = None):
async def select_terminal_backend(
body: TerminalBackendSelect, profile: Optional[str] = None
):
"""Persist ``terminal.backend`` in config.yaml.
Validates against the known backend set (the same enum the raw-config
settings row exposes). Selecting a backend that still needs setup is
allowed — the picker shows guidance instead of blocking, matching the CLI.
"""
"""Persist ``terminal.backend``. A backend that still needs setup is
allowed — the picker shows guidance instead of blocking, like the CLI."""
backend = (body.backend or "").strip().lower()
valid_names = _terminal_backend_names()
if backend not in valid_names:
@@ -771,15 +646,10 @@ async def select_terminal_backend(
)
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
config = load_config()
terminal_cfg = config.setdefault("terminal", {})
if not isinstance(terminal_cfg, dict):
terminal_cfg = {}
config["terminal"] = terminal_cfg
terminal_cfg["backend"] = backend
save_config(config)
with config_write_scope(body.profile or profile):
config = load_config()
_dict_section(config, "terminal")["backend"] = backend
save_config(config)
await asyncio.to_thread(_run)
return {"ok": True, "backend": backend}
@@ -787,47 +657,27 @@ async def select_terminal_backend(
@router.get("/api/tools/computer-use/status")
async def get_computer_use_status(profile: Optional[str] = None):
"""Cross-platform Computer Use readiness for the desktop card.
See ``tools.computer_use.permissions.computer_use_status`` for the payload
shape. Read-only and fast (shells ``cua-driver doctor`` + macOS
``permissions status``).
"""
"""Computer Use readiness for the desktop card (payload shape: see
``tools.computer_use.permissions.computer_use_status``)."""
from tools.computer_use.permissions import computer_use_status
def _read():
with _profile_scope(profile):
return computer_use_status()
return await asyncio.to_thread(_read)
return await scoped_to_thread(profile, computer_use_status)
@router.post("/api/tools/computer-use/permissions/grant")
async def grant_computer_use_permissions(profile: Optional[str] = None):
"""Spawn ``hermes computer-use permissions grant`` as a background action.
macOS-only: ``cua-driver permissions grant`` launches CuaDriver via
LaunchServices so the TCC dialog is attributed to com.trycua.driver, then
waits for approval. The frontend polls ``GET /api/actions/computer-use-
grant/status`` and re-reads ``/status`` once it exits. Windows/Linux have
no TCC toggles to grant, so this returns 400 there.
"""
"""Spawn ``hermes computer-use permissions grant`` (macOS-only: launches
CuaDriver via LaunchServices so the TCC dialog is attributed correctly).
The frontend polls ``GET /api/actions/computer-use-grant/status``."""
if sys.platform != "darwin":
raise HTTPException(
status_code=400,
detail="Computer Use permission grants are a macOS concept.",
)
try:
proc = _spawn_hermes_action(
_profile_cli_args(profile)
+ ["computer-use", "permissions", "grant"],
"computer-use-grant",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn computer-use permissions grant")
raise HTTPException(
status_code=500, detail=f"Failed to request permissions: {exc}"
)
return {"ok": True, "pid": proc.pid, "name": "computer-use-grant"}
return spawn_profile_action(
profile,
["computer-use", "permissions", "grant"],
"computer-use-grant",
log_msg="Failed to spawn computer-use permissions grant",
prefix="Failed to request permissions",
)
File diff suppressed because it is too large Load Diff