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
This commit is contained in:
1 parent
24c5eb4e28
commit
bb0404c310
9 files changed
+264
-2
No files matched your search
@@ -40,6 +40,11 @@ SIGNAL_ACCOUNT=+15551234567
|
|||||||
# Comma-separated E.164 numbers allowed to message the agent.
|
# Comma-separated E.164 numbers allowed to message the agent.
|
||||||
SIGNAL_ALLOWED_USERS=+15551234567
|
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
|
# Porkbun API (franksdank.cloud + franksdank.casa zones). Caddy uses these
|
||||||
# for DNS-01 Let's Encrypt wildcard cert issuance. Porkbun API keys are
|
# for DNS-01 Let's Encrypt wildcard cert issuance. Porkbun API keys are
|
||||||
# account-wide; rotate them here rather than in the dashboard.
|
# account-wide; rotate them here rather than in the dashboard.
|
||||||
|
|||||||
@@ -94,6 +94,41 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /home/dank/hermes-data:/data
|
- /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) -------------------------------------------------
|
# --- Git hosting (Gitea) -------------------------------------------------
|
||||||
# Now boot-persistent Quadlet units (private bridge network, DNS enabled),
|
# Now boot-persistent Quadlet units (private bridge network, DNS enabled),
|
||||||
# not compose services. See gitea/quadlet/ and ../docs/Dankclaw/Gitea.md.
|
# not compose services. See gitea/quadlet/ and ../docs/Dankclaw/Gitea.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.
|
- 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.
|
- 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.
|
- `--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)
|
||||||
@@ -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 `<dankclaw-public>: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]]
|
||||||
@@ -45,6 +45,8 @@ both containers. The tracked, sanitized configuration baseline is
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
- [[Hermes-Dashboard]] — the LAN browser management UI
|
||||||
|
- [[Hermes-WebUI]] — the Hermex server (phone-facing API, SSH-tunneled)
|
||||||
- [[Dankclaw Stack]]
|
- [[Dankclaw Stack]]
|
||||||
- [[OpenWebUI]] — the chat UI that pairs with Hermes
|
- [[OpenWebUI]] — the chat UI that pairs with Hermes
|
||||||
- Knowledge base: [[SearXNG]] (Hermes as the search agent)
|
- Knowledge base: [[SearXNG]] (Hermes as the search agent)
|
||||||
@@ -62,6 +62,7 @@ covers both workloads).
|
|||||||
- [[OpenWebUI]] — chat UI for local LLMs
|
- [[OpenWebUI]] — chat UI for local LLMs
|
||||||
- [[Hermes]] — the Nous Research agent
|
- [[Hermes]] — the Nous Research agent
|
||||||
- [[Hermes-Dashboard]] — the Hermes web management UI (config, keys, sessions)
|
- [[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)
|
- [[Gitea]] — Git hosting (migrated)
|
||||||
- [[Postgres]] — Gitea database backend
|
- [[Postgres]] — Gitea database backend
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ SERVICE_ORDER = (
|
|||||||
"comfyui",
|
"comfyui",
|
||||||
"hermes",
|
"hermes",
|
||||||
"hermes-dashboard",
|
"hermes-dashboard",
|
||||||
|
"hermes-webui",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"grafana",
|
"grafana",
|
||||||
"loki",
|
"loki",
|
||||||
@@ -58,6 +59,7 @@ SERVICE_ORDER = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DANKCLAW_SERVICES = {
|
DANKCLAW_SERVICES = {
|
||||||
|
"hermes-webui",
|
||||||
"gluetun",
|
"gluetun",
|
||||||
"searxng-valkey",
|
"searxng-valkey",
|
||||||
"searxng",
|
"searxng",
|
||||||
@@ -102,6 +104,7 @@ DANKCLAW_COMPOSE_SERVICES = {
|
|||||||
"comfyui",
|
"comfyui",
|
||||||
"hermes",
|
"hermes",
|
||||||
"hermes-dashboard",
|
"hermes-dashboard",
|
||||||
|
"hermes-webui",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"grafana",
|
"grafana",
|
||||||
"loki",
|
"loki",
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ service_is_allowed() {
|
|||||||
dankclaw:gluetun|dankclaw:searxng-valkey|dankclaw:searxng|dankclaw:pihole|\
|
dankclaw:gluetun|dankclaw:searxng-valkey|dankclaw:searxng|dankclaw:pihole|\
|
||||||
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
|
dankclaw:open-webui|dankclaw:gitea-postgres|dankclaw:gitea-server|\
|
||||||
dankclaw:comfyui|dankclaw:hermes|\
|
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:loki|dankclaw:alloy|dankclaw:node-exporter|\
|
||||||
dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||||
dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\
|
dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\
|
||||||
@@ -335,7 +335,7 @@ deploy_compose_service() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
case "$service" in
|
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
|
case "$service" in
|
||||||
comfyui|loki|alloy)
|
comfyui|loki|alloy)
|
||||||
build_compose_image "$service"
|
build_compose_image "$service"
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
Reference in new issue
Block a user