Files
unsupportedpastels 3f2b1722b1 feat(nix): ship the Webapp in the Nix package
The Nix package had no Webapp bundle, and `hermes webapp` could not build
one in the read-only store. nix/webapp.nix builds it in the npm workspace
the hook prepares (no native rebuild, no Electron), exposed as
`.#webapp`. The agent assembler takes it as a `webapp` frontend: the
wrapper sets HERMES_WEBAPP_DIST to it, as HERMES_WEB_DIST is set for the
dashboard.

hermes_cli/webapp.py serves a HERMES_WEBAPP_DIST bundle as shipped: no
freshness check, no build lock, never rebuilt or marked stale, and
`--force-build` says it cannot rebuild it.
2026-10-27 09:42:22 -04:00

418 lines
20 KiB
Python

"""``hermes webapp``: open the browser-hosted Desktop that the gateway serves.
The gateway serves the Webapp on its own listener when its home's config sets ``webapp.enabled``
(``gateway.webapp_listener``). This command builds the bundle when it is stale, starts the gateway that
serves the current profile, asks it for a one-use launch link over the control socket (same OS user
only) and opens that link, so the credential appears in no argv, output or link. On Linux the gateway
checks that the browser's connection is the user's; on Windows nothing checks it, and the link travels
only in the browser launcher's command line, which other standard users cannot read. On macOS, the BSDs
and illumos neither holds, so it writes the link into a page only the user can read and opens that page:
the launcher's command line carries only the page's path. Where it cannot open a browser, and on WSL
(whose Windows browser reaches the gateway through a relay any Windows account can use), it prints the
link for the user to open instead.
``--status`` and ``--sign-out`` talk to a running gateway and never start one.
"""
from __future__ import annotations
import html
import ipaddress
import json
import os
import secrets
import sys
import time
from pathlib import Path
from urllib.parse import urlsplit
RESTART_COMMAND = "hermes gateway restart"
# The gateway reads the Webapp's settings and sign-in providers only when it starts.
RETRY_AFTER_FIX = f"The gateway reads these settings when it starts: after fixing them, run {RESTART_COMMAND}."
STARTED_BEFORE_ON = f"The gateway started before the Webapp was turned on. Restart it to serve the Webapp: {RESTART_COMMAND}"
# A gateway answers a verb it does not know this way; the Webapp's are registered before its control
# socket answers, so the gateway running predates them (an upgrade it has not restarted onto).
OLDER_GATEWAY = ("The running gateway is older than the Webapp and does not know its commands. Restart it "
f"onto this version of Hermes: {RESTART_COMMAND}")
# Browsers keep secure-context APIs (crypto.subtle, mediaDevices, navigator.clipboard, Notification) from a
# plain-HTTP origin that is not loopback.
PLAIN_HTTP_NOTE = ("This address is plain HTTP, so the browser turns off what needs a secure connection: the bot "
"screen, the microphone (dictation, live voice and the remote wake word), reading the clipboard "
"(terminal paste, pasting an image) and system notifications. Open the Webapp over HTTPS "
"(webapp.public_url) or through an SSH tunnel to 127.0.0.1 to use them.")
# How long --sign-out waits for a gateway that is still starting to answer (30 s).
_START_WAIT_ATTEMPTS, _START_WAIT_INTERVAL = 60, 0.5
class GatewayUnreachable(Exception):
"""The gateway for that home did not start or does not answer on its control socket."""
def _gateway_home(home: Path) -> Path:
"""The home whose gateway serves *home*: the default gateway for a named profile it serves (the
route native Desktop takes), else the profile's own."""
from hermes_cli.gateway_runtime_multiplex import implied_host_root
return implied_host_root(home) or home
def _deleted_profile_refusal(home: Path) -> str | None:
"""What ``-p <name>`` says when *home* is a named profile that was deleted or never created, else
None. ``HERMES_HOME`` set to such a home skips that check, and its gateway would be the default
one, so the command refuses it the same way rather than act on the default profile."""
from hermes_cli.profiles import _missing_profile_error
from hermes_constants import named_profile_home, named_profile_is_live
profile = named_profile_home(home)
if profile is None or named_profile_is_live(profile):
return None
return str(_missing_profile_error(profile.name))
def _build(args):
from hermes_cli.config import get_project_root
from hermes_cli.webapp import prepare_webapp_renderer
return prepare_webapp_renderer(
get_project_root(), force=args.force_build, skip_build=args.skip_build,
explicit=args.force_build or args.build_only)
def _ensure(home) -> None:
from hermes_cli.gateway_runtime import attachable, ensure_gateway_runtime
result = ensure_gateway_runtime(home, idle_exit=False)
if not attachable(result):
cause = ": ".join(part for part in (result.state, result.detail or result.reason_code) if part)
raise GatewayUnreachable(f"The gateway did not start ({cause}).")
def _control(home, verb: str, params: dict | None = None) -> dict:
from hermes_cli.gateway_client import private_control_query
request = json.dumps({"protocol": 1, "id": 1, "verb": verb, "params": params or {}}).encode() + b"\n"
try:
data = private_control_query(Path(home), request)
return json.loads(data.split(b"\n", 1)[0])
except Exception as exc: # no socket, a refused peer, a timeout or a garbled answer
raise GatewayUnreachable(str(exc)) from exc
def _open(url: str) -> bool:
import webbrowser
from hermes_cli.auth_device_flow import _can_open_graphical_browser
if not _can_open_graphical_browser():
return False
try:
return bool(webbrowser.open(url))
except (webbrowser.Error, OSError):
return False
# The page a private launch link reaches the browser in: it moves on to the link at once and names nothing
# else, so the referrer and any script stay out of it.
_LAUNCH_PAGE = ('<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer">'
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'">'
'<meta http-equiv="refresh" content="0;url={url}"><title>Opening Hermes</title>'
'<p><a href="{url}">Open Hermes Webapp</a></p>\n')
def _open_privately(target: Path, url: str) -> bool:
"""Open *url*, a launch link nobody checks the redeemer of, in a browser without handing it to the
browser launcher, whose command line other local users can read: write it into a page in *target*'s
user-only ``webapp`` folder, readable by this OS user alone, and open that page. Pages of earlier
launches go first, so at most one is left, and its link stops working within the link's lifetime.
False, leaving no page, on WSL (the Windows browser cannot read the page as this user only), when the
folder or page cannot be kept user-only, or when no browser opens it. On macOS ``webbrowser`` hands the
URL to ``osascript`` on stdin, never in argv, so the page mainly protects the BSDs and illumos, where
``xdg-open`` or ``firefox`` gets the URL in argv."""
from gateway.webapp_listener import TOKEN_PATH, _close_to_other_users, _readable_by_other_users
from hermes_constants import is_wsl
if os.name == "nt" or is_wsl():
return False
folder = Path(target).absolute() / TOKEN_PATH.parent # HERMES_HOME may be relative; a file: URI is not
try:
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
_close_to_other_users(folder)
if _readable_by_other_users(folder):
return False
for earlier in folder.glob("launch-*.html"):
earlier.unlink(missing_ok=True)
page = folder / f"launch-{secrets.token_hex(8)}.html"
fd = os.open(page, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(_LAUNCH_PAGE.format(url=html.escape(url, quote=True)))
except OSError:
return False
if _open(page.as_uri()):
return True
page.unlink(missing_ok=True)
return False
def _profile_choice(home: Path, target: Path) -> dict:
"""``{"profile": name}`` when the gateway serves *home* as one of its named profiles, so the
Webapp opens on the profile this command ran for."""
from hermes_constants import profile_name_for_home
if Path(home).resolve() == Path(target).resolve():
return {}
name = profile_name_for_home(home)
return {"profile": name} if name and name != "default" else {}
def _rerun_command(*flags: str) -> str:
"""``hermes webapp *flags*`` for the profile this run used: ``-p`` re-homed only this process, so a
bare rerun gets the sticky profile, and the command keeps ``-p <name>`` when that names another one."""
import shlex
from hermes_cli.main import explicit_cli_profile
from hermes_cli.profiles import get_active_profile
name = explicit_cli_profile()
profile = ["-p", shlex.quote(name)] if name and name != get_active_profile() else []
return " ".join(["hermes", *profile, "webapp", *flags])
def older_gateway(response: dict) -> bool:
"""Whether *response* is a gateway's answer to a verb it does not know: it predates the Webapp."""
return not response.get("ok") and str(response.get("error") or "").startswith("unknown verb:")
def _refusal(response: dict) -> str:
if older_gateway(response):
return OLDER_GATEWAY
error = str(response.get("error") or "The gateway refused the request.")
kind, _, message = error.partition(": ")
return message if kind.endswith("Error") and message else error
def _off_message(target: Path, then: str) -> str:
"""Why *target*'s config keeps the Webapp off and what to do about it, then *then*: the
``webapp:`` block's own problem when it asks for the Webapp but cannot be used."""
from gateway.webapp_listener import webapp_settings
settings = webapp_settings(target)
if settings.reason:
return f"The Webapp is off: {settings.reason}. Fix it, then {then}."
if settings.enabled:
return STARTED_BEFORE_ON
return f"The Webapp is off. Set webapp.enabled: true in {target / 'config.yaml'}, then {then}."
def _plain_http_note(url: str) -> str | None:
""":data:`PLAIN_HTTP_NOTE` when *url* is plain HTTP on a host other than loopback, else None.
Browsers count ``localhost``, ``*.localhost``, 127.0.0.0/8 and ``::1`` as secure contexts."""
parts = urlsplit(url)
if parts.scheme != "http":
return None
host = (parts.hostname or "").rstrip(".")
if host == "localhost" or host.endswith(".localhost"):
return None
try:
return None if ipaddress.ip_address(host).is_loopback else PLAIN_HTTP_NOTE
except ValueError:
return PLAIN_HTTP_NOTE
def _browser_address(target: Path, settings, served_url: str) -> str:
"""Where a browser opens the Webapp the gateway serves at *served_url* (its loopback dial origin), as
``webapp-launch`` works it out: ``webapp.public_url`` when set, else behind sign-in the LAN address
Web Access registered Nous sign-in for, else *served_url* (a private Webapp's launch link). Read from
*target*'s config now, not from when the gateway started."""
from hermes_cli.web_server_boundary import public_hosts_engage_gate, public_url_hosts, should_require_auth
from hermes_cli.webapp_access import registered_lan_origin
# The sign-in gate gateway.run_api.start_gateway_api engages on the Webapp's listener; without it
# webapp-launch hands out the loopback launch link and never reads the registration, which outlives
# a move back to a loopback bind.
gated = should_require_auth(settings.host) or public_hosts_engage_gate(public_url_hosts(settings.public_url))
lan = registered_lan_origin(target, urlsplit(served_url).port) if gated else None
return settings.public_url or lan or served_url
def _status(target: Path) -> int:
try:
response = _control(target, "webapp-status")
except GatewayUnreachable:
from gateway.webapp_listener import webapp_settings
if webapp_settings(target).enabled:
print("The gateway is not running. Run hermes webapp to start it.")
else:
print(_off_message(target, "run hermes webapp"))
return 1
if not response.get("ok"):
print(_refusal(response))
return 1
status = response["result"]
if status["state"] == "serving":
print(f"Serving at {status['url']}")
from gateway.webapp_listener import webapp_settings
settings = webapp_settings(target)
if note := _plain_http_note(_browser_address(target, settings, status["url"])):
print(note)
if not settings.enabled:
print(f"{target / 'config.yaml'} no longer turns the Webapp on: it stops at the next gateway restart "
f"({RESTART_COMMAND}).")
elif status["state"] == "starting":
print("The gateway is still starting the Webapp. Run hermes webapp --status again in a moment.")
elif status["state"] == "off":
print(_off_message(target, f"run {RESTART_COMMAND}"))
else:
print(f"Unavailable: {status['reason']}")
if _restart_applies(status["reason"]):
print(RETRY_AFTER_FIX)
return 0
def _restart_applies(reason) -> bool:
"""Whether fixing *reason* (why the gateway does not serve the Webapp) takes a gateway restart. A
missing or stale bundle does not: the gateway serves one as soon as it is built, and a restart
keeps the stale mark."""
from gateway.webapp_listener import NOT_BUILT
return not str(reason).startswith(NOT_BUILT)
def _still_served(target: Path) -> str | None:
"""What to say when *target*'s config no longer turns the Webapp on but its running gateway, which
reads the config only when it starts, still serves it; None otherwise. Never starts a gateway."""
try:
response = _control(target, "webapp-status")
except GatewayUnreachable:
return None
status = (response.get("result") or {}) if response.get("ok") else {}
if status.get("state") != "serving":
return None
return (f"{target / 'config.yaml'} no longer turns the Webapp on, but the running gateway still serves it at "
f"{status['url']} until it restarts. Run {RESTART_COMMAND} to apply the config.")
def _signed_out_summary(result: dict) -> str:
"""What a gateway's ``webapp-sign-out`` answer says it did, for the user."""
if not result.get("serving", True):
return ("The gateway is not serving the Webapp, so no browser is connected to it. Replaced its stored "
"browser credential: every browser must sign in again once it serves the Webapp.")
if "connections_closed" not in result:
# A gateway started before this Hermes: it replaced the credential and closed the sockets that
# credential opened, but logins behind the sign-in gate outlive it.
return (f"Replaced the Webapp's browser credential. Restart the gateway ({RESTART_COMMAND}) to sign "
"out browsers that signed in with a password or an account too.")
closed = result["connections_closed"]
connections = f"closed {closed} browser connection{'' if closed == 1 else 's'}"
if result.get("sign_in_gate"):
return (f"Signed out every browser: {connections}. Every browser login behind sign-in has ended, "
"and the account tokens it held no longer work here: browsers must sign in again. Native apps "
"signed in to this gateway with their own sign-in stay signed in.")
return f"Signed out of every Webapp tab: {connections}. Run hermes webapp to open it again."
def _sign_out(target: Path) -> int:
from gateway.webapp_listener import BrowserCredentialError, browser_session_token
for attempt in range(_START_WAIT_ATTEMPTS + 1):
try:
response = _control(target, "webapp-sign-out")
except GatewayUnreachable:
response = None
if response is not None and response.get("ok"):
print(_signed_out_summary(response.get("result") or {}))
return 0
state = _gateway_state(target)
if response is None and state == "absent":
# No gateway is running: replace the stored credential so no browser can use the old one
# when one starts. Behind the sign-in gate, logins bound to it end too.
try:
browser_session_token(target, rotate=True)
except BrowserCredentialError as exc:
print(f"Nobody was signed out: {exc}")
return 1
print("No gateway is running for this profile. Replaced the stored Webapp credential: every browser "
"must sign in again when the gateway next serves the Webapp.")
return 0
if state != "starting":
reason = _refusal(response) if response is not None else f"the gateway did not answer (state: {state})"
print(f"Nobody was signed out: {reason}")
return 1
# A starting gateway may already hold the stored credential in memory, where rewriting the file
# never reaches it: wait until it answers the sign-out itself.
if attempt < _START_WAIT_ATTEMPTS:
time.sleep(_START_WAIT_INTERVAL)
print(f"The gateway is still starting, so nobody was signed out. Run hermes webapp --sign-out again once "
f"it is up ({RESTART_COMMAND} if it never comes up).")
return 1
def _gateway_state(home: Path) -> str:
"""What *home*'s gateway lock and control socket say: ``absent`` (none running), ``starting``,
``ready``, or why it cannot be reached."""
from hermes_cli.gateway_runtime import discover_gateway_endpoint
return discover_gateway_endpoint(home).state
def _explain_refused_launch(target: Path, response: dict) -> int:
try:
status = _control(target, "webapp-status").get("result") or {}
except GatewayUnreachable:
status = {}
if status.get("state") == "off":
print(STARTED_BEFORE_ON)
else:
print(_refusal(response))
if status.get("state") == "unavailable" and _restart_applies(status.get("reason")):
print(RETRY_AFTER_FIX)
return 1
def cmd_webapp(args) -> int:
from gateway.webapp_listener import webapp_settings
from hermes_cli.webapp import WebappBuildError, packaged_webapp_dist
from hermes_constants import get_hermes_home
home = get_hermes_home()
if (refusal := _deleted_profile_refusal(home)) is not None:
print(f"Error: {refusal}", file=sys.stderr)
return 1
target = _gateway_home(home)
if args.status:
return _status(target)
if args.sign_out:
return _sign_out(target)
if not args.build_only and not webapp_settings(target).enabled:
print(_still_served(target) or _off_message(target, "run hermes webapp again"))
return 1
try:
_build(args)
except WebappBuildError as exc:
print(f"✗ {exc}")
# A packaged bundle (HERMES_WEBAPP_DIST) is never built here, so no build can fix it.
if packaged_webapp_dist() is None:
print(" Build it explicitly:")
print(" hermes webapp --build-only")
return 1
if args.build_only:
print("Webapp bundle ready.")
return 0
try:
_ensure(target)
response = _control(target, "webapp-launch", _profile_choice(home, target))
except GatewayUnreachable as exc:
print(str(exc))
return 1
if not response.get("ok"):
return _explain_refused_launch(target, response)
link = response["result"]
if note := _plain_http_note(link["url"]):
print(note)
if link.get("sign_in"):
# The Webapp is behind the dashboard's sign-in: the page asks for it, so its address is no secret.
if not args.no_open and _open(link["url"]):
print("Opened the Webapp in your browser. Sign in with your dashboard account.")
else:
print(f"Open the Webapp and sign in with your dashboard account: {link['url']}")
return 0
if link.get("private"):
# macOS, the BSDs, illumos, WSL: nothing checks who redeems this one-use link, so it never goes to a
# browser launcher, whose arguments other users' processes can read. A page only this user can read
# carries it to the browser; failing that, the user opens it by hand.
if not args.no_open and _open_privately(target, link["url"]):
print("Opened the Webapp in your browser.")
print(f"If no browser opened, run `{_rerun_command('--no-open')}` for a link.")
return 0
print(f"Open this link in your browser within {link['expires_in_seconds']} seconds. It works "
"once and signs the browser in as you, so keep it private:")
print(f" {link['url']}")
return 0
if not args.no_open and _open(link["url"]):
print("Opened the Webapp in your browser.")
return 0
print(f"Open the Webapp with this one-use link (valid for {link['expires_in_seconds']} seconds):")
print(f" {link['url']}")
return 0