Hermes Agent — Claude-Max-Proxy + Hermes selbst, alles auf einer Maschine

Repliziert den Proxy-Trick, den wir fuer ARIA nutzen: claude-max-api-proxy (npm) wrappt die Claude Code CLI — angemeldet mit deiner Claude-Max- Subscription — als OpenAI-kompatiblen /v1/chat/completions-Endpoint. Hermes Agent (Nous Research, CLI/Desktop-Agent) redet darueber mit Claude, ohne API-Kosten — laeuft ueber die Subscription, nicht ueber Anthropic-API-Billing.

Entscheidung (Stefan): Hermes Agent selbst laeuft auf DERSELBEN Maschine wie dieser Proxy-Stack — alles was zu Hermes gehoert auf eine Box, minimale Latenz, nur ein Rechner zum Pflegen. ARIA bleibt komplett unangetastet (eigenes Docker-Netz hermes-net, eigenes Claude-CLI-Login-Volume, kein gemeinsamer Container, laeuft ja ohnehin auf einer anderen Maschine als Hermes).

Architektur

hermes-agent (Nous Research, network_mode: host)
        |  HTTP, Authorization: Bearer <TOKEN>, http://localhost:8447/v1
        v
  hermes-gateway  (Port 8447, NUR auf 127.0.0.1 gebunden)
        |  prueft Bearer-Token, sonst 401
        v
  hermes-proxy    (Port 3456, NUR docker-intern im hermes-net erreichbar)
        |  claude-max-api-proxy -> Claude Code CLI (Claude-Max-Login)
        v
     Anthropic (ueber deine Claude-Max-Subscription)

hermes-agent-dashboard (127.0.0.1:9119, Hermes' eigenes Web-UI/Setup, nur
                         intern erreichbar)
        ^
        |  reverse_proxy
        v
     caddy (0.0.0.0:443, TLS + Basic-Auth — einzige Auth-/TLS-Schicht,
            siehe Sicherheitshinweis)

Warum das Gateway trotzdem noch da ist, obwohl alles auf einer Maschine laeuft: hermes-proxy selbst hat keine Authentifizierung (genau wie ARIAs Proxy) — jeder andere Container im selben Docker-Host koennte sonst unbemerkt Deine Claude-Max-Subscription mitbenutzen. Das Gateway (proxy-patches/gateway.js, pure Node, keine Extra-Deps) prueft den Authorization: Bearer <TOKEN>-Header gegen ein Shared Secret, bevor irgendwas an hermes-proxy weitergeleitet wird. Seit "alles auf einer Maschine" bindet das Gateway nur noch auf 127.0.0.1, nicht mehr nach aussen — kein anderer Rechner kommt mehr direkt ran.

Was uebernommen wurde (1:1 aus ARIAs Proxy-Setup)

  • Sed-Patches an der npm-CLI-Bridge (host-binding, --dangerously-skip-permissions, echter --system-prompt-Kanal, 24h-Timeout, Prompt via stdin) — generische Infra-Fixes, nicht ARIA-spezifisch.
  • Adapter-Patches (proxy-patches/openai-to-cli.js, cli-to-openai.js) — die OpenAI-tools <-> <tool_call>-Text-Bruecke. Generisch, 1:1 uebernommen.
  • NICHT uebernommen: ARIAs routes.js-Patch (Live-Tool-Stream/Not-Aus fuers Diagnostic-UI, ARIA-Bridge-spezifisch). Stattdessen gibt's hier das eigene gateway.js fuer die Auth, die ARIA in der Form nicht braucht.

Setup

  1. Hermes-Agent-Sourcecode daneben klonen (das offizielle Docker-Image liegt nicht auf Docker Hub, sondern wird per build: aus dem Repo gebaut):

    cd /pfad/zum/deploy-ziel
    git clone https://github.com/NousResearch/hermes-agent.git hermes-agent-src
    

    (dieser Clone ist in .gitignore — bleibt lokal, wandert nicht in unser Repo)

  2. .env anlegen:

    cp .env.example .env
    openssl rand -hex 32   # Ausgabe in .env bei HERMES_GATEWAY_TOKEN eintragen
    
  3. Model-Config fuer Hermes seeden (einmalig, VOR dem ersten Start — danach fasst Hermes die Datei nicht mehr an):

    mkdir -p hermes-data/agent-home
    cp hermes-agent-config/config.yaml.example hermes-data/agent-home/config.yaml
    

    Die Datei zeigt Hermes per provider: custom auf unseren hermes-gateway (http://localhost:${HERMES_GATEWAY_PORT}/v1) — ${HERMES_GATEWAY_PORT}/${HERMES_GATEWAY_TOKEN} werden von Hermes selbst beim Config-Laden aus den Container-Env-Vars expandiert, die kommen aus Deiner .env. Kein manuelles Ausfuellen noetig.

  4. TLS-Reverse-Proxy (Caddy) einrichten — macht Port 443 von aussen erreichbar, Dashboard bleibt intern auf 127.0.0.1:

    Hermes' eigenes Dashboard-Auth-Gate (dashboard.basic_auth in config.yaml + HERMES_DASHBOARD_PASSWORD_HASH) ist bewusst NICHT mehr Teil des Setups (aufgeraeumt am 19.07.2026, spaeter Abend/20.07.2026) — das Gate engagiert sich laut eigener Fehlermeldung ohnehin nur bei einem 0.0.0.0-Bind, und der Dashboard-Container bindet fest auf 127.0.0.1 (siehe command: im hermes-agent-dashboard-Service). Die komplette Absicherung nach aussen laeuft ausschliesslich ueber Caddy unten — ein Auth-Layer statt zwei, weniger Setup-Schritte, weniger Verwirrung.

    a. Selbstsigniertes Zertifikat, 100 Jahre gueltig (bewusst kein Let's Encrypt — kein Domain-/Port-80-Zwang, kein Renewal je wieder noetig). Erzeugt sich automatisch — kein manueller Schritt mehr noetig: der caddy-Container prueft beim Start, ob in hermes-data/caddy-certs/ schon ein Zertifikat liegt, und wenn nicht, installiert er sich kurz openssl (per apk) und erzeugt es selbst (siehe command:-Block im caddy-Service in docker-compose.yml). Einmalig generiert, danach persistent im Volume — ueberlebt docker compose up/Neustarts, wird nicht bei jedem Start neu gebaut.

    Falls Du's doch manuell/mit eigenem /CN (z.B. Server-IP) erzeugen willst, geht das weiterhin von Hand — dann einfach VOR dem ersten Hochziehen selbst nach hermes-data/caddy-certs/dashboard.{crt,key} legen, der Auto-Generate-Check ueberspringt die Erzeugung dann:

    mkdir -p hermes-data/caddy-certs
    openssl req -x509 -nodes -newkey rsa:4096 \
      -keyout hermes-data/caddy-certs/dashboard.key \
      -out hermes-data/caddy-certs/dashboard.crt \
      -days 36500 \
      -subj "/CN=hermes-dashboard"
    

    b. Caddy-Basic-Auth-Hash erzeugen (bcrypt, das einzige Auth-Secret das es jetzt noch braucht):

    docker run --rm caddy:2-alpine caddy hash-password --plaintext 'DEIN-PASSWORT'
    

    Output in .env bei CADDY_BASIC_AUTH_HASH eintragen, danach — Dollarzeichen-Falle (bcrypt-Hashes haben rohe $-Zeichen drin, Docker Compose interpretiert $xyz in .env-Werten sonst als eigene Variablen-Referenz und ersetzt still durch Leerstring) — escapen:

    sed -i '/^CADDY_BASIC_AUTH_HASH=/ s/\$/$$/g' .env
    

    HERMES_DASHBOARD_USER aus Schritt 2 (.env) wird als Login-Name wiederverwendet.

  5. Stack starten:

    docker compose up -d
    

    Baut beim ersten Mal das Hermes-Image aus hermes-agent-src (dauert ein paar Minuten), startet dann hermes-proxy, hermes-gateway, hermes-agent (Gateway/Messaging-Prozess), hermes-agent-dashboard und caddy. Falls CADDY_BASIC_AUTH_HASH noch leer ist: caddy bricht beim Start ab, die anderen laufen trotzdem — Wert nachtragen (Schritt 4b), dann docker compose up -d caddy.

  6. Einmalig Claude-CLI-Login (oeffnet Browser-OAuth mit deiner Claude-Max-Subscription):

    docker exec -it hermes-proxy claude
    

    Danach liegen die Credentials persistent in hermes-data/claude-config/ — ueberlebt Container-Restarts.

  7. Testen, dass der Proxy laeuft:

    curl -s http://127.0.0.1:${HERMES_GATEWAY_PORT:-8447}/v1/chat/completions \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer <HERMES_GATEWAY_TOKEN aus .env>" \
      -d '{"model":"sonnet","messages":[{"role":"user","content":"Sag Hallo"}]}'
    

    Ohne oder mit falschem Bearer-Token gibt's 401 unauthorized.

  8. Mit Hermes chatten:

    docker exec -it hermes-agent hermes chat
    

    oder das Dashboard im Browser oeffnen: https://<diese-maschine> (Port 443, kein :9119 mehr — der ist jetzt nur noch intern). Browser meldet beim ersten Aufruf "Zertifikat nicht vertrauenswuerdig" (weil selbstsigniert, nicht von einer oeffentlichen CA) — einmalig als Ausnahme akzeptieren ("Trotzdem fortfahren" o.ae.), danach fragt Caddy den Basic-Auth-Login aus Schritt 4b ab. Traffic ist ab hier durchgehend TLS-verschluesselt, kein SSH-Tunnel mehr noetig.

Mobiler Client (Android)?

Zwei GRUNDVERSCHIEDENE Richtungen, nicht verwechseln:

  • Unser Proxy oben (hermes-gateway -> hermes-proxy): Claude ALS Backend FUER Hermes. Kein Client redet direkt damit ausser Hermes selbst.
  • Hermes' EIGENER OpenAI-kompatibler API-Server (API_SERVER_HOST / API_SERVER_KEY, auskommentiert im hermes-agent-Service): Hermes ALS Server FUER einen Chat-Client. Aktivieren + API_SERVER_KEY in .env setzen, dann kann ein Client wie gpt_mobile oder Maid (Open Source, Android, freier base_url+Key, kein Messenger — siehe Chat-Verlauf) sich direkt gegen Hermes verbinden. Fuer sowas wie die ARIA-App (Wake-Word, Sprachsteuerung, TTS) braeuchte man dagegen einen eigenen App-Build — kein fertiges Produkt zum Runterladen.

Sicherheitshinweis

hermes-gateway bindet nur auf 127.0.0.1 — von aussen kommt niemand mehr ran, solange Du nicht selbst was aendertst (z.B. fuer den mobilen API-Server oben, oder falls Hermes doch mal auf eine zweite Maschine zieht).

hermes-agent-dashboard bindet fest auf 127.0.0.1 — von aussen nicht mehr direkt erreichbar. Davor steht caddy (Service in docker-compose.yml, Config in Caddyfile): terminiert TLS auf Port 443 mit einem selbstsignierten 100-Jahre-Zertifikat (Setup-Schritt 4a) und erzwingt eine Basic-Auth-Schicht (Setup-Schritt 4b, bcrypt-Hash in CADDY_BASIC_AUTH_HASH). Caddy ist die EINZIGE Auth-/TLS-Schicht — Hermes' eigenes Dashboard-Auth-Gate (dashboard.basic_auth/HERMES_DASHBOARD_PASSWORD_HASH) wurde bewusst komplett rausgenommen (aufgeraeumt 20.07.2026): es engagiert sich laut eigener Fehlermeldung ohnehin nur bei einem 0.0.0.0-Bind, der Dashboard-Container bindet aber fest auf 127.0.0.1 — ein zweiter, nie greifender Auth-Layer haette nur Setup-Schritte und Verwirrung gekostet, ohne echten Sicherheitsgewinn.

Das war vorher (kurzzeitig, testweise) 0.0.0.0 OHNE TLS — Passwort ging als Basic-Auth im Klartext raus. Ist jetzt behoben: einziger von aussen offene Port ist 443, durchgehend verschluesselt.

Das Zertifikat ist selbstsigniert (keine oeffentliche CA dahinter) — Browser zeigen beim ersten Aufruf eine Warnung, die Du einmalig als Ausnahme akzeptierst (Details siehe Setup-Schritt 8). Wer eine "echte", warnungsfreie CA-Kette will, braucht dafuer eine oeffentliche Domain + Let's Encrypt — war hier bewusst nicht der Plan.

Generell gilt: wenn Du HERMES_GATEWAY_PORT, API_SERVER_HOST oder den Dashboard-Port (9119) doch mal direkt nach aussen exposest (statt ueber Caddy), ist der Traffic unverschluesseltes HTTP, kein TLS. Nicht direkt ins offene Internet haengen — SSH-Tunnel, WireGuard/Tailscale oder eben den Caddy-Reverse-Proxy davorschalten.

Troubleshooting

{"detail":"Invalid Host header. Dashboard requests must use the hostname the server was bound to."} beim Aufruf ueber Caddy: Hermes' Dashboard- Backend prueft den eingehenden Host-Header gegen den Host, an den es gebunden ist (127.0.0.1:9119) — Schutz gegen DNS-Rebinding. Caddy reicht per Default den ORIGINALEN Host-Header vom Browser durch (Deine Server-IP/Domain), den lehnt Hermes dann ab. Fix ist im Caddyfile bereits drin: der reverse_proxy-Block ueberschreibt den Host-Header explizit (header_up Host 127.0.0.1:9119), bevor die Anfrage ans Dashboard geht. Falls der Fehler trotzdem auftritt: pruefen ob Dein Caddyfile den header_up-Block im reverse_proxy 127.0.0.1:9119 { ... } wirklich enthaelt (git pull + docker-compose up -d --build caddy, kein Neubuild noetig, Caddy liest die Config beim Start neu ein).

"Restart Gateway"-Button im Dashboard zeigt ✗ no such gateway 'default': register it with 'hermes profile create default' first, or pass an existing profile name via '-p <name>': Kein Bug bei uns, sondern eine Architektur-Inkompatibilitaet zwischen unserem Deploy-Stil und dem Dashboard-Button. Hintergrund (im Hermes-Sourcecode nachgesehen, hermes_cli/service_manager.py + hermes_cli/container_boot.py):

  • Unser hermes-agent-Service startet mit command: ["gateway", "run"] direkt als Container-Kommando. Das Image erkennt dieses Muster als "Legacy-Start" und supervised den Prozess ueber den statischen s6-Service main-hermes (fest im Image, siehe /etc/s6-overlay/s6-rc.d/).
  • Der Dashboard-Restart-Button ruft dagegen immer hermes gateway restart auf (ohne -p = Profil default). Dieser Befehl sucht einen dynamisch registrierten s6-Service gateway-default unter /run/service/ — der entsteht aber nur durch hermes profile create <name> (Per-Profile- Gateway-System). Bei uns wurde das nie ausgefuehrt, main-hermes und gateway-default sind zwei komplett getrennte Dinge im selben Image.
  • Ergebnis: der Button findet "sein" gateway-default nicht und wirft genau diesen Fehler — der eigentliche Gateway-Prozess (main-hermes) laeuft davon voellig unbeeindruckt normal weiter.

Fix / Workaround: Den Dashboard-Restart-Button bei uns einfach nicht benutzen, sondern von aussen den Container neu starten:

docker-compose restart hermes-agent

Das genuegt fast nie: main-hermes laeuft unter s6-Supervision mit Auto-Restart bei Crash (siehe Startup-Log "gateway is now running under s6 supervision (auto-restart on crash...)"), ein manueller Restart ist im Normalbetrieb also selten noetig. hermes profile create default NICHT ausfuehren, um den Button zu "reparieren" — das wuerde einen zweiten, parallelen Gateway-Mechanismus fuer denselben Port aufmachen und ist nicht das, wofuer unser Single-Profile-Setup gebaut ist.

Verzeichnisse (nicht committet, siehe .gitignore)

  • hermes-agent-src/ — geklonter Hermes-Agent-Sourcecode (Docker-Build-Context)
  • hermes-data/claude-config/ — Claude-CLI-Login (Browser-OAuth-Session)
  • hermes-data/agent-home/ — Hermes' ~/.hermes-Aequivalent: Config (config.yaml, aus hermes-agent-config/config.yaml.example geseedet), Sessions, Memory, Skills, .env
  • hermes-data/caddy-certs/ — selbstsigniertes 100-Jahre-Zertifikat (dashboard.crt/dashboard.key, Setup-Schritt 4a) — Private Key, niemals committen
  • hermes-data/caddy-data/, hermes-data/caddy-config/ — Caddys eigener Runtime-State (nicht von Dir angefasst)
  • .env — Secrets (Token, ggf. API_SERVER_KEY, Basic-Auth-Hashes)

Wo deployen?

Diese Compose-Datei ist ortsunabhaengig. Stefan deployt sie selbst auf der Ziel-Maschine (nicht aria-wohnung — komplett getrennt von ARIA). Repo klonen, die drei Setup-Schritte oben (Hermes-Source klonen, .env, Config seeden), dann docker compose up -d.

S
Description
No description provided
Readme
130 KiB
Languages
JavaScript 54.7%
Python 45.3%