mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The tickets a Webapp tab mints to open another window, and the one-use links hermes webapp opens, were stored with the browser credential itself next to them, and redeeming one compared that copy with the credential in use. Anything that could read the gateway's app state while a link was pending found the credential there, and the check was a fourth way of asking whether a grant outlived a sign-out. The store now records the credential's generation from hermes_cli.browser_credential. Redeeming a link hands over the credential through browser_credential.current_if(), which reads it once, so a sign-out landing between the check and the answer cannot pass on the new credential to a link minted under the old one. The sign-in binding's renewal uses the same call for the same reason.
402 lines
19 KiB
Python
402 lines
19 KiB
Python
"""Webapp-only routes: the host terminal (loopback or authenticated binds), the
|
|
one-use ticket each browser socket dial mints (``/api/webapp/gateway-ticket`` for
|
|
``/api/ws`` on this same origin, ``/api/webapp/terminal-ticket`` for the host terminal,
|
|
``/api/webapp/audio-ticket`` for each voice stream), and the one-use handoffs (child window,
|
|
auto-opened launch tab) for the private, loopback Webapp session."""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
|
from hermes_cli.dashboard_auth.request_utils import _http_origin
|
|
from hermes_cli.web_server_surface import policy, private_launch
|
|
from hermes_cli.web_session_sockets import AUDIO_SOCKETS, GATEWAY_SOCKET, HOST_TERMINAL_SOCKET
|
|
|
|
_log = logging.getLogger("hermes_cli.web_server")
|
|
router = APIRouter()
|
|
WINDOW_TICKET_TTL_SECONDS = 30
|
|
#: A sandboxed (snap/Flatpak) browser's cold start can take several seconds to reach the page.
|
|
LAUNCH_TICKET_TTL_SECONDS = 60
|
|
_MAX_WINDOW_TICKETS = 128
|
|
_ticket_lock = threading.Lock()
|
|
#: Who a socket opened with the loopback session token is (``chat_ws.gateway_ws`` gives a
|
|
#: ``?token=`` socket the same identity, and REST mutations are forwarded as it).
|
|
SESSION_TOKEN_OWNER = {"user_id": "legacy-token-owner", "provider": "session-token", "issuer": ""}
|
|
|
|
|
|
def _require_private_webapp(request: Request) -> None:
|
|
if not private_launch(request.app.state):
|
|
raise HTTPException(404, "Private Webapp window handoff is unavailable")
|
|
|
|
|
|
@router.websocket("/api/host-terminal")
|
|
async def host_terminal_ws(ws: WebSocket) -> None:
|
|
from hermes_cli import web_host_terminal
|
|
from hermes_cli.web_host_terminal_sessions import host_terminal
|
|
from hermes_cli.web_server_chat import _PTY_BRIDGE_AVAILABLE, PtyUnavailableError, _pty_fail, _ws_gate
|
|
|
|
gate = await _ws_gate(ws, HOST_TERMINAL_SOCKET)
|
|
if gate is None:
|
|
return
|
|
peer, mode, cred = gate
|
|
if not web_host_terminal.request_allowed():
|
|
await ws.close(code=4403, reason="host terminal requires authenticated Webapp")
|
|
return
|
|
await ws.accept()
|
|
_log.info("host terminal accepted peer=%s mode=%s cred=%s", peer, mode, cred)
|
|
if not _PTY_BRIDGE_AVAILABLE:
|
|
await _pty_fail(
|
|
ws, PtyUnavailableError("Pseudo-terminal support is not installed on this host."), surface="Terminal")
|
|
return
|
|
await host_terminal(ws)
|
|
|
|
|
|
def _mint_handoff_ticket(state, ttl: float, *, launch: bool) -> str:
|
|
"""One-use ticket that ``/webapp/window-session`` exchanges for this start's session token. The
|
|
store keeps ``(expiry, browser credential generation, launch)``, never the credential itself."""
|
|
from hermes_cli import browser_credential
|
|
|
|
now = time.monotonic()
|
|
with _ticket_lock:
|
|
tickets = getattr(state, "webapp_window_tickets", {})
|
|
tickets = {key: value for key, value in tickets.items()
|
|
if value[0] > now and browser_credential.is_current(value[1])}
|
|
state.webapp_window_tickets = tickets
|
|
if len(tickets) >= _MAX_WINDOW_TICKETS:
|
|
raise HTTPException(429, "Too many pending Webapp windows; try again shortly")
|
|
ticket = secrets.token_urlsafe(32)
|
|
tickets[ticket] = (now + ttl, browser_credential.generation(), launch)
|
|
return ticket
|
|
|
|
|
|
def mint_launch_ticket(state) -> str:
|
|
"""Ticket for the browser the server opens itself: a launcher's argv carries this, not the token."""
|
|
return _mint_handoff_ticket(state, LAUNCH_TICKET_TTL_SECONDS, launch=True)
|
|
|
|
|
|
def mint_printed_launch_ticket(state) -> str:
|
|
"""Ticket for a launch link the user opens by hand where nothing identifies the redeeming
|
|
connection's owner (macOS, the BSDs): it spends without that check, as a window ticket does, so
|
|
``hermes webapp`` prints it and never passes it to a browser launcher."""
|
|
return _mint_handoff_ticket(state, LAUNCH_TICKET_TTL_SECONDS, launch=False)
|
|
|
|
|
|
def _loopback_peer_uid(client: tuple[str, int], server: tuple[str, int]) -> int | None:
|
|
"""Owner UID of the client end of a local TCP connection, from Linux ``/proc/net/tcp*``.
|
|
|
|
Both ends of a loopback connection are listed with their socket owner; the client's is
|
|
the row whose local address is *client* and remote address is *server*. None when absent
|
|
(another network namespace, a closed connection) or unreadable.
|
|
"""
|
|
def _normal(host: str | bytes, port: int):
|
|
address = ipaddress.ip_address(host)
|
|
return getattr(address, "ipv4_mapped", None) or address, port
|
|
|
|
def _row_address(field: str):
|
|
host_hex, port_hex = field.split(":")
|
|
raw = bytes.fromhex(host_hex)
|
|
# The kernel prints each 32-bit word of the address in host byte order.
|
|
packed = b"".join(int.from_bytes(raw[i:i + 4], "big").to_bytes(4, sys.byteorder)
|
|
for i in range(0, len(raw), 4))
|
|
return _normal(packed, int(port_hex, 16))
|
|
|
|
try:
|
|
wanted = (_normal(*client), _normal(*server))
|
|
except ValueError:
|
|
return None
|
|
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
try:
|
|
with open(table, encoding="ascii") as stream:
|
|
rows = stream.read().splitlines()[1:]
|
|
except OSError:
|
|
continue
|
|
for row in rows:
|
|
fields = row.split()
|
|
try:
|
|
if (_row_address(fields[1]), _row_address(fields[2])) == wanted:
|
|
return int(fields[7])
|
|
except (IndexError, ValueError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def _peer_is_server_user(request: Request, platform: str = sys.platform) -> bool:
|
|
"""Whether this request's connection belongs to the OS user running the server.
|
|
|
|
Linux authenticates the connection owner. Windows needs no check: a process's command line,
|
|
where the launch ticket travels, is readable only by its own user, SYSTEM and elevated
|
|
administrators. Elsewhere (macOS, the BSDs) every local user can read the launcher's argv
|
|
and nothing here identifies the connection's owner, so an argv-carried launch ticket cannot
|
|
be redeemed. Authenticated child-window tickets and the launch tickets ``hermes webapp`` prints for
|
|
the user to open by hand (``mint_printed_launch_ticket``) do not use this launcher channel.
|
|
"""
|
|
if platform == "win32":
|
|
return True
|
|
if platform != "linux":
|
|
return False
|
|
client, server = request.client, request.scope.get("server")
|
|
if client is None or not server:
|
|
return False
|
|
return _loopback_peer_uid((client.host, client.port), (server[0], server[1])) == os.getuid() # windows-footgun: ok — Linux-only branch
|
|
|
|
|
|
@router.post("/api/webapp/window-ticket")
|
|
async def issue_window_ticket(request: Request):
|
|
from hermes_cli.web_server import _require_token
|
|
|
|
_require_private_webapp(request)
|
|
_require_token(request)
|
|
ticket = _mint_handoff_ticket(request.app.state, WINDOW_TICKET_TTL_SECONDS, launch=False)
|
|
return JSONResponse({"ticket": ticket}, headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
def _ticket_principal(request: Request) -> tuple[dict, str | None]:
|
|
"""``({user_id, provider, issuer}, session_token)`` of the caller: its verified login (no
|
|
token), or on an unauthenticated loopback bind ``SESSION_TOKEN_OWNER`` and the session token
|
|
it presented."""
|
|
session = getattr(request.state, "session", None)
|
|
if session is not None:
|
|
return {"user_id": session.user_id, "provider": session.provider,
|
|
"issuer": getattr(session, "issuer", "") or ""}, None
|
|
if not getattr(request.app.state, "auth_required", False):
|
|
from hermes_cli.web_server import _SESSION_HEADER_NAME, _has_valid_session_token
|
|
presented = (request.headers.get(_SESSION_HEADER_NAME)
|
|
or request.headers.get("authorization", "").removeprefix("Bearer "))
|
|
if _has_valid_session_token(request):
|
|
return dict(SESSION_TOKEN_OWNER), presented
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
|
def mint_socket_ticket(identity: dict, socket: str, *, session_token: str | None = None,
|
|
sign_in: str | None = None, profile_home: str | None = None) -> str:
|
|
"""A one-use ticket that opens only *socket* (``web_session_sockets.GATEWAY_SOCKET``,
|
|
``HOST_TERMINAL_SOCKET`` or one of ``AUDIO_SOCKETS``) as *identity*. Minted with
|
|
*session_token*, or by a login whose sign-in binding was checked against the browser credential
|
|
of generation *sign_in* (``hermes_cli.webapp_sign_in``), it records that credential's generation
|
|
(``hermes_cli.browser_credential``): it opens nothing once that credential is replaced, and the
|
|
socket it opens closes when the browser signs out. With *profile_home* the socket it opens answers
|
|
for that profile (``chat_ws.gateway_ws``)."""
|
|
from hermes_cli.browser_credential import generation_of
|
|
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
|
|
|
|
extra = {"socket": socket}
|
|
if session_token is not None:
|
|
extra["session_token"] = generation_of(session_token)
|
|
if sign_in is not None:
|
|
extra["sign_in"] = sign_in
|
|
if profile_home is not None:
|
|
extra["profile_home"] = profile_home
|
|
return mint_ticket(user_id=identity["user_id"], provider=identity["provider"],
|
|
issuer=identity.get("issuer", ""), extra=extra)
|
|
|
|
|
|
def _socket_ticket_response(request: Request, identity: dict, socket: str, session_token: str | None,
|
|
profile_home: str | None = None) -> JSONResponse:
|
|
from hermes_cli.dashboard_auth.ws_tickets import TTL_SECONDS
|
|
from hermes_cli.webapp_sign_in import sign_in_of
|
|
|
|
ticket = mint_socket_ticket(identity, socket, session_token=session_token, sign_in=sign_in_of(request),
|
|
profile_home=profile_home)
|
|
return JSONResponse({"ticket": ticket, "ttl_seconds": TTL_SECONDS}, headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
@router.post("/api/webapp/gateway-ticket")
|
|
async def issue_gateway_ticket(request: Request, profile: str | None = None):
|
|
"""The browser's one-use ticket for its live socket: ``/api/ws`` on this same origin, offered
|
|
as the ``hermes-gateway-ticket.`` subprotocol and redeemed by the upgrade gate
|
|
(``web_server_chat._consume_ws_ticket``) as this caller's login. It opens no other socket.
|
|
Minted locally: the server that serves this page runs the socket's sessions. *profile* is
|
|
validated (400/404) so a stale tab learns its profile is gone here rather than from a socket
|
|
that keeps closing, and the ticket names it: the socket answers a request that names no profile
|
|
for the tab's profile, as a native ticket does, never for the gateway's launch profile."""
|
|
from hermes_cli.web_server_cron import _cron_profile_home
|
|
|
|
if not policy(request.app.state).gateway_ticket:
|
|
raise HTTPException(404, "Webapp gateway tickets are unavailable on this surface")
|
|
identity, session_token = _ticket_principal(request)
|
|
_name, home = _cron_profile_home(profile)
|
|
return _socket_ticket_response(request, identity, GATEWAY_SOCKET, session_token,
|
|
profile_home=str(home) if profile else None)
|
|
|
|
|
|
@router.post("/api/webapp/terminal-ticket")
|
|
async def issue_terminal_ticket(request: Request):
|
|
"""The browser's one-use ticket for one host-terminal dial (``/api/host-terminal?ticket=``).
|
|
The host terminal opens with nothing else, and this ticket opens nothing else."""
|
|
if not policy(request.app.state).host_terminal:
|
|
raise HTTPException(404, "The host terminal is unavailable on this surface")
|
|
identity, session_token = _ticket_principal(request)
|
|
return _socket_ticket_response(request, identity, HOST_TERMINAL_SOCKET, session_token)
|
|
|
|
|
|
@router.post("/api/webapp/audio-ticket")
|
|
async def issue_audio_ticket(request: Request, socket: str = ""):
|
|
"""The browser's one-use ticket for one voice stream dial, named by *socket*:
|
|
``speak-stream`` for ``/api/audio/speak-stream``, ``transcribe-stream`` for
|
|
``/api/audio/transcribe-stream``. It opens that stream and nothing else, and the gateway ticket
|
|
opens neither stream, so the browser mints one per dial beside the gateway's."""
|
|
if not policy(request.app.state).gateway_ticket:
|
|
raise HTTPException(404, "Webapp voice tickets are unavailable on this surface")
|
|
identity, session_token = _ticket_principal(request)
|
|
if socket not in AUDIO_SOCKETS:
|
|
raise HTTPException(400, "Unknown voice stream")
|
|
return _socket_ticket_response(request, identity, socket, session_token)
|
|
|
|
|
|
@router.post("/webapp/window-session")
|
|
async def redeem_window_ticket(request: Request):
|
|
from hermes_cli import browser_credential
|
|
|
|
_require_private_webapp(request)
|
|
# This endpoint deliberately sits outside /api: the child window or launch
|
|
# tab has no session yet. Only a purpose-specific ticket can exchange for
|
|
# the existing one.
|
|
# Private links use the actual bind origin, which may differ from a
|
|
# configured loopback public_url. Host validation remains in middleware;
|
|
# proxy-normalized ASGI scheme/Host (not raw forwarded headers) are trusted.
|
|
origins = request.headers.getlist("origin")
|
|
origin = _http_origin(origins[0]) if len(origins) == 1 else None
|
|
target_origin = _http_origin(f"{request.url.scheme}://{request.url.netloc}")
|
|
if origin is None or origin != target_origin:
|
|
raise HTTPException(403, "Webapp window handoff requires a same-origin request")
|
|
# Keep the unauthenticated exchange body-free; never parse an arbitrary
|
|
# JSON/multipart payload just to extract a fixed-size capability.
|
|
ticket = request.headers.get("X-Hermes-Window-Ticket", "")
|
|
with _ticket_lock:
|
|
tickets = getattr(request.app.state, "webapp_window_tickets", {})
|
|
entry = tickets.get(ticket)
|
|
# A launch ticket crossed a browser launcher's argv: only a connection owned by this
|
|
# server's user may spend it, and a refused attempt must not burn it for the real browser.
|
|
if entry is not None and entry[2] and not _peer_is_server_user(request):
|
|
entry = None
|
|
elif entry is not None:
|
|
del tickets[ticket]
|
|
token = browser_credential.current_if(entry[1]) if entry is not None and entry[0] > time.monotonic() else None
|
|
if token is None:
|
|
raise HTTPException(403, "Webapp window link expired or was already used; open a new window")
|
|
return JSONResponse({"token": token}, headers={"Cache-Control": "no-store"})
|
|
|
|
|
|
_LAUNCH_SCRIPT = r"""
|
|
const basePath = __BASE_PATH__;
|
|
const params = new URLSearchParams(location.hash.slice(1));
|
|
history.replaceState(null, '', location.pathname);
|
|
const ticket = params.get('ticket') || '';
|
|
const fail = () => {
|
|
document.getElementById('status').textContent = 'This launch link was already used or has expired. '
|
|
+ 'Run hermes webapp again for a new one.';
|
|
};
|
|
if (!/^[A-Za-z0-9_-]{43}$/.test(ticket)) {
|
|
fail();
|
|
} else {
|
|
fetch(`${basePath}/webapp/window-session`, {
|
|
method: 'POST', credentials: 'same-origin', headers: {'X-Hermes-Window-Ticket': ticket}
|
|
}).then(response => response.ok ? response.json() : Promise.reject()).then(({token}) => {
|
|
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new Error();
|
|
// The SPA consumes and strips this fragment exactly as it does for the printed link.
|
|
const target = new URL(`${basePath}/`, location.origin);
|
|
target.search = params.get('query') || '';
|
|
target.hash = `hermes-session=${token}`;
|
|
location.replace(target.href);
|
|
}).catch(fail);
|
|
}
|
|
"""
|
|
|
|
|
|
def _handoff_page(request: Request, title: str, status: str, script: str) -> HTMLResponse:
|
|
"""Credential-free, uncached page whose only script is *script* (``__BASE_PATH__`` bound)."""
|
|
from hermes_cli.web_server_dashboard import _normalise_prefix
|
|
|
|
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
|
nonce = secrets.token_urlsafe(16)
|
|
script = script.replace("__BASE_PATH__", json.dumps(prefix).replace("</", "<\\/"))
|
|
return HTMLResponse(
|
|
'<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer">'
|
|
f'<title>{title}</title><p id="status">{status}</p>'
|
|
f'<script nonce="{nonce}">{script}</script>',
|
|
headers={
|
|
"Cache-Control": "no-store",
|
|
"Referrer-Policy": "no-referrer",
|
|
"Content-Security-Policy": f"default-src 'none'; script-src 'nonce-{nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/webapp/launch")
|
|
async def launch_page(request: Request):
|
|
"""Where the server's own browser-open lands: spends the one-use launch ticket from the
|
|
fragment (never sent to the server or kept in history) and enters the Webapp."""
|
|
_require_private_webapp(request)
|
|
return _handoff_page(request, "Opening Hermes", "Opening Hermes Webapp…", _LAUNCH_SCRIPT)
|
|
|
|
|
|
# No credential appears in this public page or in its URL. The fragment
|
|
# names a transient channel; the authorized parent mints a one-use ticket.
|
|
_WINDOW_SCRIPT = r"""
|
|
const basePath = __BASE_PATH__;
|
|
const params = new URLSearchParams(location.hash.slice(1));
|
|
history.replaceState(null, '', location.pathname);
|
|
const id = params.get('channel') || '';
|
|
const status = document.getElementById('status');
|
|
if (!/^[a-f0-9-]{36}$/.test(id)) {
|
|
status.textContent = 'Open this window from an authorized Hermes Webapp tab.';
|
|
} else {
|
|
const channel = new BroadcastChannel(`hermes.webapp.window:${id}`);
|
|
let receiving = false;
|
|
let finished = false;
|
|
const controller = new AbortController();
|
|
const fail = message => {
|
|
if (finished) return;
|
|
finished = true;
|
|
controller.abort();
|
|
status.textContent = message;
|
|
channel.postMessage({type: 'error'});
|
|
channel.close();
|
|
clearTimeout(timeout);
|
|
};
|
|
const timeout = setTimeout(() => fail('This window link expired. Open a new window from the original tab.'), 30000);
|
|
channel.onmessage = async event => {
|
|
if (receiving) return;
|
|
if (event.data?.error) { fail(event.data.error); return; }
|
|
const ticket = event.data?.ticket;
|
|
if (typeof ticket !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(ticket)) return;
|
|
receiving = true;
|
|
try {
|
|
const response = await fetch(`${basePath}/webapp/window-session`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: {'X-Hermes-Window-Ticket': ticket}, signal: controller.signal
|
|
});
|
|
if (!response.ok) throw new Error('Window authorization expired. Open a new window from the original tab.');
|
|
const {token} = await response.json();
|
|
if (finished) return;
|
|
if (typeof token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(token)) throw new Error('Invalid window authorization.');
|
|
sessionStorage.setItem(`hermes.webapp.session.v1:${JSON.stringify([location.origin, basePath])}`, token);
|
|
const target = new URL(`${basePath}/`, location.origin);
|
|
target.search = params.get('query') || '';
|
|
target.hash = params.get('route') || '/';
|
|
channel.postMessage({type: 'done'});
|
|
finished = true;
|
|
clearTimeout(timeout);
|
|
channel.close();
|
|
location.replace(target.href);
|
|
} catch (error) { fail(error.message || 'Could not authorize this window. Open it again from the original tab.'); }
|
|
};
|
|
channel.postMessage({type: 'ready'});
|
|
}
|
|
"""
|
|
|
|
|
|
@router.get("/webapp/window")
|
|
async def child_window_page(request: Request):
|
|
_require_private_webapp(request)
|
|
return _handoff_page(request, "Opening Hermes", "Authorizing this Hermes window…", _WINDOW_SCRIPT)
|