hermes: the webui was LAN-facing, not loopback — and Dankclaw's compose is gone

Two things, found while checking whether the hermes trio still needed migrating.

1. **The phone-facing webui was exposed to the LAN.** Its unit runs with
   Network=host, so there is no Podman port forwarder and the app's own bind is
   the exposure. The compose service it replaced published
   `127.0.0.1:8787:8787` (loopback); the unit bound `HERMES_WEBUI_HOST=0.0.0.0`,
   and the LAN address answered 200 — password-gated, but on every interface,
   which is not the documented "SSH tunnel is the sole ingress" posture. It now
   binds 127.0.0.1, which serves the tunnel just as well (sshd dials
   localhost:8787 from the host). The unit comment claiming "LAN-facing as
   before" was wrong about "before", and is corrected.

2. **Dankclaw's compose.yaml is retired.** Auditing ownership showed every
   running container on that host reports a PODMAN_SYSTEMD_UNIT label — the
   hermes trio's units have owned their containers since 2026-09-12 — so the
   three service blocks left in compose.yaml were dead weight that could only
   create a duplicate container fighting a unit for a name. deploy_hermes was
   already fully unit-based. The file is staged in .AGENTTRASH/; the planner
   plans nothing for it, an unknown service on that target now errors instead of
   silently no-oping through `podman-compose`, and DANKCLAW_COMPOSE_SERVICES is
   empty.

The hermes-webui seam now reads the unit — it read compose, which is exactly how
the bind drifted while the test kept passing against a file nobody ran.
This commit is contained in:
Frank committed 2026-09-16 07:22:20 -07:00
1 parent 06d3502a2b
commit aaa1f82313
5 files changed
+83 -228

No files matched your search

+51 -55
View File
@@ -2,59 +2,69 @@
# 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.
# the host .env, and share the gateway data volume.
#
# It reads the QUADLET UNIT, which is the live source of truth. It used to read
# dankclaw/compose.yaml, and that is exactly how the bind drifted: the compose
# service published 127.0.0.1:8787:8787 (loopback), the unit that replaced it
# bound 0.0.0.0 with only the password in front, and the test kept passing
# because it was checking the file nobody ran any more.
load helpers
COMPOSE="$REPO_ROOT/dankclaw/compose.yaml"
UNIT="$REPO_ROOT/dankclaw/hermes/quadlet/hermes-webui.container"
ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example"
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
DEPLOY="$REPO_ROOT/scripts/deploy.sh"
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
# Normalize the unit's key=value lines into the same shape the assertions
# below always used, so the contract reads the same as it did for compose.
[ -f "$UNIT" ] || { echo "missing $UNIT" >&2; return 1; }
grep -E '^(Image|Exec|Environment|Volume|HealthCmd|PublishPort)=' "$UNIT"
}
@test "hermes-webui runs the webui bootstrap in foreground mode" {
local y
y=$(webui_service_yaml)
grep -q 'nousresearch/hermes-agent' <<< "$y"
# The unit runs the localhost-aliased tag (this host's quadlet path cannot
# resolve registry-prefixed refs from local storage); the deployer tags it
# from the upstream name, which is what keeps the alias fresh.
grep -q 'Image=localhost/hermes-agent:latest' <<< "$y"
grep -q 'podman tag docker.io/nousresearch/hermes-agent:latest localhost/hermes-agent:latest' \
"$REPO_ROOT/scripts/deploy-remote.sh"
grep -q 'bootstrap.py' <<< "$y"
grep -q -- '--foreground' <<< "$y"
}
@test "hermes-webui is loopback-bound and password-gated" {
@test "hermes-webui binds loopback, not the LAN" {
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"
# Container-internal bind must be 0.0.0.0 or Podman's port forwarder
# (which dials the bridge IP) resets every connection.
grep -q 'HERMES_WEBUI_HOST: 0.0.0.0' <<< "$y"
grep -q 'HERMES_WEBUI_PASSWORD' <<< "$y" \
|| { echo "password gate missing"; return 1; }
# With Network=host there is no port forwarder: the app's own bind IS the
# exposure. The compose service this replaced published 127.0.0.1:8787:8787,
# and an early version of the unit bound 0.0.0.0 instead — which answered
# 200 on 192.168.0.143:8787 for anyone on the LAN (measured 2026-09-16).
grep -q 'Environment=HERMES_WEBUI_HOST=127.0.0.1' <<< "$y" \
|| { echo "webui must bind loopback (HERMES_WEBUI_HOST=127.0.0.1)"; return 1; }
! grep -q 'HERMES_WEBUI_HOST=0.0.0.0' <<< "$y" \
|| { echo "webui must not bind 0.0.0.0 on a host network"; return 1; }
}
@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"
grep -q 'Environment=HERMES_HOME=/data' <<< "$y"
grep -q 'Volume=/home/dank/Containers/hermes/data:/data' <<< "$y"
grep -q 'Volume=/home/dank/Containers/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 'HealthCmd=' <<< "$y"
grep -q '/health' <<< "$y"
}
@@ -62,43 +72,29 @@ PYEOF
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
@test "planner maps a hermes-webui unit edit to the hermes service" {
# Any file under dankclaw/hermes/** selects the `hermes` service, because
# the trio (gateway, dashboard, webui) deploys as one unit-set — see
# deploy_hermes, which installs all three units and restarts them together.
# The three names are aliases of that handler, not separate deployments.
# (This test used to assert that a *compose* edit narrowed to hermes-webui;
# Dankclaw has had no compose file since 2026-09-16.)
run python3 "$PLANNER" --target dankclaw \
--paths dankclaw/hermes/quadlet/hermes-webui.container
[ "$status" -eq 0 ]
[ "$output" = "hermes-webui" ]
[ "$output" = "hermes" ]
# And an unrelated service must not be dragged in.
! grep -qE 'caddy|prometheus|frank-large' <<< "$output"
}
@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'
# Dispatched to the unit handler, never to compose: deploy_hermes installs
# the quadlets and retires compose-era containers.
grep -q 'hermes|hermes-dashboard|hermes-webui) deploy_hermes' "$REMOTE_DEPLOY"
# deploy_hermes installs the trio with one loop over the unit names.
grep -q 'for unit in hermes hermes-dashboard hermes-webui' "$REMOTE_DEPLOY"
grep -q 'dankclaw/hermes/quadlet/$unit.container' "$REMOTE_DEPLOY"
}
@test "top-level deployer accepts hermes-webui as a Dankclaw service" {