From bb0404c310b3fba216469e38057255fdd5a92f1b Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 26 Aug 2026 08:17:27 -0700 Subject: [PATCH] hermes-webui: add Hermex server as loopback compose service on Dankclaw - compose: hermes-webui runs bootstrap.py --foreground in the agent image, shares /home/dank/hermes-data (same uid mapping as the gateway), publishes 127.0.0.1:8787 only; password from host .env - planner/remote deployer: hermes-webui is an isolated Dankclaw service - tests: deployment integrity seam (loopback, auth, volume, healthcheck, planner narrowing, deployer dispatch) - docs: Hermes-WebUI page with the iOS SSH-tunnel access pattern --- dankclaw/.env.example | 5 ++ dankclaw/compose.yaml | 35 +++++++++ docs/Dankclaw/Hermes-Dashboard.md | 4 ++ docs/Dankclaw/Hermes-WebUI.md | 114 ++++++++++++++++++++++++++++++ docs/Dankclaw/Hermes.md | 2 + docs/index.md | 1 + scripts/deploy-plan.py | 3 + scripts/deploy-remote.sh | 4 +- tests/hermes_webui.bats | 98 +++++++++++++++++++++++++ 9 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 docs/Dankclaw/Hermes-WebUI.md create mode 100644 tests/hermes_webui.bats diff --git a/dankclaw/.env.example b/dankclaw/.env.example index 32b6194..e7e2566 100644 --- a/dankclaw/.env.example +++ b/dankclaw/.env.example @@ -40,6 +40,11 @@ SIGNAL_ACCOUNT=+15551234567 # Comma-separated E.164 numbers allowed to message the agent. SIGNAL_ALLOWED_USERS=+15551234567 +# Hermes web UI (Hermex server) login password. Required: the webui binds to +# loopback and is reached only through the SSH tunnel, but Hermex still signs +# in with this password. Set a strong value in the live .env. +HERMES_WEBUI_PASSWORD=changeme + # Porkbun API (franksdank.cloud + franksdank.casa zones). Caddy uses these # for DNS-01 Let's Encrypt wildcard cert issuance. Porkbun API keys are # account-wide; rotate them here rather than in the dashboard. diff --git a/dankclaw/compose.yaml b/dankclaw/compose.yaml index 5653f3f..4140f32 100644 --- a/dankclaw/compose.yaml +++ b/dankclaw/compose.yaml @@ -94,6 +94,41 @@ services: volumes: - /home/dank/hermes-data:/data + # --- Hermes web UI (Hermex server; phone-facing API for the agent) -------- + # Runs the open-source hermes-webui (github.com/nesquena/hermes-webui) in + # foreground mode: bootstrap.py creates .venv inside the mounted checkout, + # installs WebUI + agent deps, then execs server.py as a single foreground + # process. It reads HERMES_HOME=/data, so it drives the SAME agent state + # (memory, skills, sessions) as the gateway container. + # Loopback-only publish: the SSH tunnel (phone -> Dankclaw:22) is the sole + # ingress. The password gates Hermex logins and lives in the host .env. + hermes-webui: + image: docker.io/nousresearch/hermes-agent:latest + container_name: hermes-webui + restart: unless-stopped + entrypoint: ["bash", "-c", "python3 /webui/bootstrap.py --foreground"] + environment: + HERMES_HOME: /data + HERMES_WEBUI_HOST: 127.0.0.1 + HERMES_WEBUI_PORT: "8787" + HERMES_WEBUI_PASSWORD: "${HERMES_WEBUI_PASSWORD}" + # Headless host: never show the first-run wizard (a profile already + # exists in /data from the gateway). + HERMES_WEBUI_SKIP_ONBOARDING: "1" + ports: + - "127.0.0.1:8787:8787" + volumes: + - /home/dank/hermes-data:/data + # rw on purpose: bootstrap.py creates /webui/.venv in the repo root. + - /home/dank/hermes-webui:/webui + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health')"] + interval: 30s + timeout: 10s + retries: 5 + # First boot pip-installs WebUI + agent deps into a fresh venv. + start_period: 600s + # --- Git hosting (Gitea) ------------------------------------------------- # Now boot-persistent Quadlet units (private bridge network, DNS enabled), # not compose services. See gitea/quadlet/ and ../docs/Dankclaw/Gitea.md. diff --git a/docs/Dankclaw/Hermes-Dashboard.md b/docs/Dankclaw/Hermes-Dashboard.md index 438ba1d..b24e0c8 100644 --- a/docs/Dankclaw/Hermes-Dashboard.md +++ b/docs/Dankclaw/Hermes-Dashboard.md @@ -35,3 +35,7 @@ Open `http://192.168.0.143:9119` in a browser on the LAN. Log in with the config - The dashboard and the gateway share one data volume. A config change made in the dashboard takes effect when the gateway restarts. - If the shared password is reused elsewhere, treat it as exposed. Rotate it and regenerate the hash. - `--skip-build` uses the prebuilt web assets in the image. Do not remove it unless the assets are rebuilt. + +## See Also + +- [[Hermes-WebUI]] — the phone-facing API server (Hermex) diff --git a/docs/Dankclaw/Hermes-WebUI.md b/docs/Dankclaw/Hermes-WebUI.md new file mode 100644 index 0000000..c623310 --- /dev/null +++ b/docs/Dankclaw/Hermes-WebUI.md @@ -0,0 +1,114 @@ +--- +type: configuration +title: Hermes-WebUI (Hermex server) +description: The hermes-webui compose service on Dankclaw — the loopback-only API server that the Hermex iPhone app talks to over an SSH tunnel. +tags: + - hermes + - hermes-webui + - hermex + - dankclaw + - ssh-tunnel +timestamp: 2026-09-14 +--- + +# Hermes-WebUI (Hermex server) + +`hermes-webui` is the [hermes-webui](https://github.com/nesquena/hermes-webui) +server (MIT, third-party) that the **Hermex** iPhone app connects to. It runs +the Hermes agent in-process from the shared `HERMES_HOME`, so it is the *same* +agent — same memory, skills, and sessions — as the [[Hermes]] gateway and the +[[Hermes-Dashboard]]. + +**Host:** Dankclaw — **Port:** `127.0.0.1:8787` (loopback only) — **Ingress:** SSH tunnel + +## Facts + +| Item | Value | +|------|-------| +| Image | `docker.io/nousresearch/hermes-agent:latest` (same as the gateway) | +| Command | `python3 /webui/bootstrap.py --foreground` | +| Source checkout | `/home/dank/hermes-webui` on Dankclaw (mounted at `/webui`) | +| Port | `8787`, published to `127.0.0.1` only | +| Auth | `HERMES_WEBUI_PASSWORD` (host `.env`; Hermex signs in with it) | +| Data | shares `/home/dank/hermes-data` with the gateway and dashboard | +| Health | `GET /health` (compose healthcheck + deployer) | + +## Why a container, not a host process + +The gateway's state files are owned by the container-mapped uid (`109999`, +from rootless Podman's `1 → 100000/65536` subuid range). A fresh rootless +namespace on Dankclaw maps identically (verified with `podman unshare +cat /proc/self/uid_map`), so a second container using the same image reads +and writes the shared volume with exactly the ownership the gateway already +uses. A host-side process running as `dank` (uid 1000) could not read the +gateway's `0600` state files. + +## How it works + +- `bootstrap.py` creates `/webui/.venv` on first boot, pip-installs the WebUI + and agent requirements, then **execs** `server.py` as one foreground process + (`--foreground`). systemd-free: Podman supervises it via + `restart: unless-stopped`. +- `HERMES_WEBUI_SKIP_ONBOARDING=1` keeps the first-run wizard off; a profile + already exists in `/data` from the gateway. +- The healthcheck `start_period` is 600 s because the very first boot spends + minutes pip-installing into a fresh venv. + +## Remote access (Hermex on iOS, from anywhere) + +Only port `22` is forwarded to Dankclaw at the router, so the webui is +reached through an SSH local forward — no extra router ports, no third-party +relay (no Tailscale, no Cloudflare): + +1. On the iPhone, open **Termius** (or any SSH client with local port + forwarding) and connect to `:22` with the usual key. +2. On that connection, add a **local forward**: `127.0.0.1:8787` → + `127.0.0.1:8787`. The forward runs inside the encrypted SSH session; + nothing else on the phone routes through it. +3. In Hermex, set the server URL to `http://localhost:8787` and sign in with + the webui password. + +The tunnel lives as long as the SSH session on the phone — iOS does not allow +a true background daemon, so connect before you use Hermex. + +```mermaid +graph LR + A[Hermex app] -->|http://localhost:8787| B[SSH local forward on iPhone] + B ==>|encrypted SSH to public IP:22| C[Dankclaw sshd] + C --> D["hermes-webui 127.0.0.1:8787"] + D --> E["/home/dank/hermes-data (shared agent state)"] +``` + +## Deployment + +``` +./scripts/deploy.sh dankclaw --service hermes-webui +``` + +The planner maps changes to the `hermes-webui` compose block (and only that +block) to this service, so gateway and inference restarts stay isolated. +Before first deploy, set a strong `HERMES_WEBUI_PASSWORD` in the live +`dankclaw/.env`. + +## Gotchas + +- The `/webui` mount is **rw on purpose**: `bootstrap.py` creates `.venv` in + the repo root. Do not flip it to `:ro`. +- Never publish `8787` beyond loopback without keeping the password set — + the webui can run agent commands. +- The webui and the gateway share one data volume. A config change made on one + side takes effect when the other restarts (same caveat as the dashboard). +- `bootstrap.py` auto-installs a Hermes Agent if it cannot find one; in this + image the agent is already present, so that path should never fire. If the + log shows an install attempt, the image changed — re-verify the entrypoint. + +# Citations + +- hermes-webui README (env vars, `--foreground`, `/health`): https://github.com/nesquena/hermes-webui +- Hermex setup model (server URL + password; Cloudflare/Tailscale are optional, not required): https://hermexapp.com/ + +## See Also + +- [[Hermes]] — the agent and its Signal gateway +- [[Hermes-Dashboard]] — the LAN browser management UI +- [[Dankclaw Stack]] diff --git a/docs/Dankclaw/Hermes.md b/docs/Dankclaw/Hermes.md index 833f657..aeb3c47 100644 --- a/docs/Dankclaw/Hermes.md +++ b/docs/Dankclaw/Hermes.md @@ -45,6 +45,8 @@ both containers. The tracked, sanitized configuration baseline is ## See Also +- [[Hermes-Dashboard]] — the LAN browser management UI +- [[Hermes-WebUI]] — the Hermex server (phone-facing API, SSH-tunneled) - [[Dankclaw Stack]] - [[OpenWebUI]] — the chat UI that pairs with Hermes - Knowledge base: [[SearXNG]] (Hermes as the search agent) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 43eeb81..e62de6b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,7 @@ covers both workloads). - [[OpenWebUI]] — chat UI for local LLMs - [[Hermes]] — the Nous Research agent - [[Hermes-Dashboard]] — the Hermes web management UI (config, keys, sessions) +- [[Hermes-WebUI]] — the Hermex server (loopback API for the iPhone app, SSH-tunneled) - [[Gitea]] — Git hosting (migrated) - [[Postgres]] — Gitea database backend diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 2658f50..68d1c77 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -30,6 +30,7 @@ SERVICE_ORDER = ( "comfyui", "hermes", "hermes-dashboard", + "hermes-webui", "prometheus", "grafana", "loki", @@ -58,6 +59,7 @@ SERVICE_ORDER = ( ) DANKCLAW_SERVICES = { + "hermes-webui", "gluetun", "searxng-valkey", "searxng", @@ -102,6 +104,7 @@ DANKCLAW_COMPOSE_SERVICES = { "comfyui", "hermes", "hermes-dashboard", + "hermes-webui", "prometheus", "grafana", "loki", diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index da8b069..c115669 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -85,7 +85,7 @@ service_is_allowed() { dankclaw:gluetun|dankclaw:searxng-valkey|dankclaw:searxng|dankclaw:pihole|\ dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\ dankclaw:comfyui|dankclaw:hermes|\ - dankclaw:hermes-dashboard|dankclaw:prometheus|dankclaw:grafana|\ + dankclaw:hermes-dashboard|dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|\ dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|\ dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\ @@ -335,7 +335,7 @@ deploy_compose_service() { return 0 fi case "$service" in - comfyui|hermes|hermes-dashboard|prometheus|grafana|loki|alloy|node-exporter|authentik-postgres|authentik-server|authentik-worker) + comfyui|hermes|hermes-dashboard|hermes-webui|prometheus|grafana|loki|alloy|node-exporter|authentik-postgres|authentik-server|authentik-worker) case "$service" in comfyui|loki|alloy) build_compose_image "$service" diff --git a/tests/hermes_webui.bats b/tests/hermes_webui.bats new file mode 100644 index 0000000..55c88ab --- /dev/null +++ b/tests/hermes_webui.bats @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Seam: hermes-webui (the Hermex server) deployment integrity. +# The webui is the phone-facing API for the Hermes agent. It must stay +# loopback-bound so the SSH tunnel is its only ingress, keep its password in +# the host .env, share the gateway data volume, and deploy as an isolated +# compose service that cannot drag the gateway or inference along with it. + +load helpers + +COMPOSE="$REPO_ROOT/dankclaw/compose.yaml" +ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example" +PLANNER="$REPO_ROOT/scripts/deploy-plan.py" +REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" + +webui_service_yaml() { + python3 - "$COMPOSE" <<'PYEOF' +import sys, yaml +with open(sys.argv[1]) as fh: + data = yaml.safe_load(fh) +svc = (data.get("services") or {}).get("hermes-webui") +assert svc, "dankclaw/compose.yaml: no hermes-webui service" +print(yaml.safe_dump(svc)) +PYEOF +} + +@test "hermes-webui runs the webui bootstrap in foreground mode" { + local y + y=$(webui_service_yaml) + grep -q 'nousresearch/hermes-agent' <<< "$y" + grep -q 'bootstrap.py' <<< "$y" + grep -q -- '--foreground' <<< "$y" +} + +@test "hermes-webui is loopback-bound and password-gated" { + local y + y=$(webui_service_yaml) + # Host-side publish stays on loopback: the SSH tunnel is the only ingress. + grep -q '127.0.0.1:8787' <<< "$y" + grep -q 'HERMES_WEBUI_PASSWORD' <<< "$y" +} + +@test "hermes-webui shares the gateway data volume and mounts the webui checkout" { + local y + y=$(webui_service_yaml) + grep -q 'HERMES_HOME: /data' <<< "$y" + grep -q '/home/dank/hermes-data:/data' <<< "$y" + grep -q '/home/dank/hermes-webui:/webui' <<< "$y" +} + +@test "hermes-webui has a healthcheck that probes /health" { + local y + y=$(webui_service_yaml) + grep -q 'healthcheck' <<< "$y" + grep -q '/health' <<< "$y" +} + +@test "dankclaw .env.example defines the webui password placeholder" { + env_vars_defined "$ENV_EXAMPLE" | grep -qx 'HERMES_WEBUI_PASSWORD' +} + +# Revision-aware planner: a change to only the hermes-webui service block must +# plan hermes-webui and nothing else (the gateway is unchanged). +webui_fixture_repo() { + local dir="$1" + git init -q "$dir" + git -C "$dir" config user.email t@t && git -C "$dir" config user.name t + mkdir -p "$dir/dankclaw" + cat > "$dir/dankclaw/compose.yaml" <<'EOF' +services: + hermes: + image: docker.io/nousresearch/hermes-agent:latest + hermes-webui: + image: docker.io/nousresearch/hermes-agent:latest + ports: + - "127.0.0.1:8787:8787" +EOF + git -C "$dir" add -A && git -C "$dir" commit -qm base + # Touch only the webui service block (its port line is unique in the file). + sed -i 's|127.0.0.1:8787:8787|127.0.0.1:8788:8787|' "$dir/dankclaw/compose.yaml" + git -C "$dir" commit -qam head +} + +@test "planner narrows a hermes-webui compose edit to hermes-webui only" { + local dir base head + dir=$(mktemp -d) + webui_fixture_repo "$dir" + base=$(git -C "$dir" rev-parse HEAD~1) + head=$(git -C "$dir" rev-parse HEAD) + run python3 "$PLANNER" --target dankclaw --repo "$dir" \ + --base "$base" --head "$head" --paths dankclaw/compose.yaml + [ "$status" -eq 0 ] + [ "$output" = "hermes-webui" ] +} + +@test "remote deployer accepts and dispatches hermes-webui" { + grep -q 'dankclaw:hermes-webui' "$REMOTE_DEPLOY" + awk '/^deploy_compose_service\(\)/,/^}/' "$REMOTE_DEPLOY" | grep -q 'hermes-webui' +}