refactor(web): dashboard_auth/web_routers/kanban dedupe + _common helpers (resume — verified partial work)
This commit is contained in:
@@ -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: "
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+274
-628
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
+643
-1473
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user