#!/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, 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 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() { # 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) # 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's bind serves the address Caddy fronts it on" { local y caddy y=$(webui_service_yaml) caddy="$REPO_ROOT/dankclaw/Caddyfile" grep -q 'HERMES_WEBUI_PASSWORD' <<< "$y" \ || { echo "password gate missing"; return 1; } # This unit is host-networked, so Caddy cannot reach it by container name. # The app's bind is load-bearing for that vhost — a loopback-only bind was # tried on 2026-09-16 and broke it (502), while host networking means there # is no port forwarder to bridge the gap. # # The upstream must be netavark's HOST ADDRESS, not the host's LAN IP. # 8787 is a bare host-netns bind and netavark DNATs only *published* ports, # so from Caddy's bridge netns 192.168.0.143:8787 and 10.89.1.1:8787 are # both refused, while host.containers.internal:8787 (169.254.1.2) answers # 200 — measured 2026-09-16 from inside the Caddy container. The dashboard's # published 9119 is reachable on the LAN IP precisely because it IS # published. Pin the measured-working form so reverting to the LAN IP cannot # pass silently. local upstream upstream=$(grep -A 6 '^hermes\.franksdank\.cloud {' "$caddy" | grep -oE 'reverse_proxy [^ {]+' | head -1) [ -n "$upstream" ] || { echo "no hermes vhost upstream in the Caddyfile"; return 1; } grep -qE '^reverse_proxy (host\.containers\.internal|169\.254\.1\.2):8787$' <<< "$upstream" \ || { echo "hermes upstream is '$upstream', not netavark's host address (host.containers.internal:8787)"; return 1; } grep -q 'Environment=HERMES_WEBUI_HOST=0.0.0.0' <<< "$y" \ || { echo "webui must bind all interfaces: $upstream cannot reach loopback"; return 1; } } @test "hermes-webui shares the gateway data volume and mounts the webui checkout" { local y y=$(webui_service_yaml) 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 'HealthCmd=' <<< "$y" grep -q '/health' <<< "$y" } @test "dankclaw .env.example defines the webui password placeholder" { env_vars_defined "$ENV_EXAMPLE" | grep -qx 'HERMES_WEBUI_PASSWORD' } @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" ] # 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" # 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" { grep -q 'dankclaw:hermes-webui' "$DEPLOY" }