Files
hermes-agent/hermes_cli/web_server_surface.py
T
emozilla 3895357a6a fix(webapp): the Webapp's routes exist only on the surfaces that use them
The Web Access routes, the browser's file-upload staging route and the file-ticket route were
mounted on every app, so the standalone `hermes dashboard` and `hermes serve` answered them too:
any loopback dashboard tab could turn on Web Access for the gateway (writing its config and .env
and restarting it), and pastes on the dashboard met the Webapp's pre-parse body cap. Only native
Desktop's Web Access pane (on the gateway's own listener) and the Webapp's browser use them.

The surface policy gains web_access (the gateway's listener, private or Webapp) and browser_files
(the Webapp only); an app with no recorded surface reads as the gateway's listener when a gateway
runner is bound to it, else as the dashboard. Each of those routes answers 404 where its surface
does not use it, and the chat upload body cap applies only where the browser's file routes do.
Tests that reached these routes on the dashboard surface now reach them on the surface that serves
them; native Desktop's Web Access calls in tests carry a real one-use native grant per request.
2026-10-25 03:22:40 -07:00

87 lines
4.0 KiB
Python

"""What each launch surface of the one web-server stack may do.
The host serving the app records its surface once on ``app.state.ui_surface``; unset, it reads
as ``gateway`` on the gateway's own listener for native clients (the app has a
``gateway_runner``, as the lifespan choice reads) and ``dashboard`` elsewhere (the standalone
``hermes dashboard`` and ``hermes serve``). Every surface-dependent decision reads this table
instead of re-deriving it, and :func:`route_on` keeps a route off the surfaces that do not use
it. ``auth_required`` is decided at runtime by the auth gate, so the private-launch predicate is
a function over state, not a table field.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from fastapi import HTTPException, Request
@dataclass(frozen=True)
class SurfacePolicy:
#: Mounts a browser UI; ``serve`` is JSON-RPC/WS only.
serves_spa: bool
#: Public HTML may carry the session token. Where it may not, every HTML entry
#: (``/index.html`` included) gets the token-free bootstrap instead of the raw file.
html_token: bool
#: ``/api/host-terminal`` may open host shells (still gated per request).
host_terminal: bool
#: An unauthenticated start hands the operator a private launch link.
launch_link: bool
#: ``/api/webapp/socket-ticket`` mints the browser's one-use tickets for ``/api/ws`` and the voice
#: streams on this same origin (accepted by the loopback and gated upgrade gates alike).
gateway_ticket: bool
#: Behind the sign-in gate, a write that carries the session cookie (``/auth/logout`` included)
#: must come from this listener's own origin. Elsewhere the gate keeps main's rule.
own_origin_cookie_writes: bool
#: ``/api/webapp-access/*`` serves: native Desktop's Web Access pane calls the gateway's own
#: listener (the private one, or the Webapp's when it is on).
web_access: bool
#: The browser's file routes serve: ``/api/chat/file-upload`` stages a picked file,
#: ``/api/files/ticket`` mints the path-bound tickets for downloads and media links, and chat
#: upload bodies are capped before they are parsed (``web_upload_limit``).
browser_files: bool
POLICIES: dict[str, SurfacePolicy] = {
"serve": SurfacePolicy(
serves_spa=False, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
own_origin_cookie_writes=False, web_access=False, browser_files=False),
"dashboard": SurfacePolicy(
serves_spa=True, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
own_origin_cookie_writes=False, web_access=False, browser_files=False),
"gateway": SurfacePolicy(
serves_spa=True, html_token=True, host_terminal=False, launch_link=False, gateway_ticket=False,
own_origin_cookie_writes=False, web_access=True, browser_files=False),
"webapp": SurfacePolicy(
serves_spa=True, html_token=False, host_terminal=True, launch_link=True, gateway_ticket=True,
own_origin_cookie_writes=True, web_access=True, browser_files=True),
}
def surface(state: Any) -> str:
"""The surface *state*'s app serves (see the module docstring)."""
recorded = getattr(state, "ui_surface", None)
if recorded:
return recorded
return "gateway" if getattr(state, "gateway_runner", None) is not None else "dashboard"
def policy(state: Any) -> SurfacePolicy:
return POLICIES[surface(state)]
def route_on(field: str) -> Callable[[Request], None]:
"""A route dependency answering 404 where the serving surface's policy leaves *field* off, so
the route does not exist there for any caller."""
def surface_serves(request: Request) -> None:
if not getattr(policy(request.app.state), field):
raise HTTPException(status_code=404, detail="Not Found")
return surface_serves
def private_launch(state: Any) -> bool:
"""Webapp on an unauthenticated bind: the launch link is the only way in."""
return policy(state).launch_link and not getattr(state, "auth_required", False)