Files
hermes-agent/README.md
T
ARIA 7b8bcb0b04 Fix: WS-Chat/Events-Feed brach durch Origin-Header-Mismatch ab (Caddyfile)
Root Cause im echten Hermes-Sourcecode (hermes_cli/web_server.py,
_ws_host_origin_reason) verifiziert, nicht geraten: WebSocket-Upgrades
(/api/ws, /api/pub, /api/events) haben einen zweiten Guard neben dem
Host-Header, der zusaetzlich den Origin-Header gegen den Bind (127.0.0.1)
prueft. Browser schickt die echte Server-Adresse als Origin -> origin_mismatch
-> WS wird mit 4403 (Browser: 1006) geschlossen, komplett ohne Logging
(anders als /api/console + /api/pty). Fix: Origin-Header in Caddy entfernen,
dann ueberspringt Hermes den Check ganz.
2026-07-19 23:10:29 +00:00

355 lines
18 KiB
Markdown

# 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](https://github.com/NousResearch/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 (EIN Container, s6 supervisiert Gateway + Dashboard als
Geschwister-Prozesse via HERMES_DASHBOARD=1):
- Gateway-Prozess: Messaging-Bruecke, redet mit Claude wie oben
- Dashboard-Prozess: 127.0.0.1:9119, Hermes' eigenes Web-UI, nur intern
erreichbar
^
| reverse_proxy (Dashboard-Port)
v
caddy (0.0.0.0:443, TLS + Basic-Auth — einzige Auth-/TLS-Schicht,
siehe Sicherheitshinweis)
```
**Wichtig, Kurskorrektur 20.07.2026:** Gateway und Dashboard liefen anfangs
in ZWEI getrennten Containern (`hermes-agent` + `hermes-agent-dashboard`,
jeweils dasselbe Image mit anderem `command:`). Das verursachte zwei reale
Bugs: Dashboard zeigte den Gateway-Status immer als "Stopped" (die Status-
Pruefung ist eine LOKALE Prozess-Inspektion im eigenen Container, nicht per
Netzwerk-Call — im getrennten Container sah sie den Gateway-Prozess nie),
und der "Restart Gateway"-Button im Dashboard scheiterte mit `no such
gateway 'default'` (der Button loest s6-Services im EIGENEN Container auf,
im Dashboard-Container gab's aber keinen). Der Startup-Log hatte den
Hinweis die ganze Zeit schon dabei: *"dashboard supervised alongside if
HERMES_DASHBOARD is set. This is the recommended setup for the s6 container
image."* Jetzt EIN `hermes-agent`-Container mit `HERMES_DASHBOARD=1` +
`HERMES_DASHBOARD_HOST=127.0.0.1` — kein separater Dashboard-Service mehr.
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):
```bash
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:**
```bash
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):
```bash
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 das Dashboard bindet fest auf `127.0.0.1`
(`HERMES_DASHBOARD_HOST` im `hermes-agent`-Service, siehe
`docker-compose.yml`). 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:
```bash
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):
```bash
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:
```bash
sed -i '/^CADDY_BASIC_AUTH_HASH=/ s/\$/$$/g' .env
```
`HERMES_DASHBOARD_USER` aus Schritt 2 (`.env`) wird als Login-Name
wiederverwendet.
5. **Stack starten:**
```bash
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` (EIN Container, s6 supervisiert darin Gateway UND
Dashboard zusammen, siehe Architektur oben) 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):
```bash
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:**
```bash
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:**
```bash
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).
Das Dashboard (s6-supervisierter Geschwister-Prozess im `hermes-agent`-
Container, `HERMES_DASHBOARD_HOST=127.0.0.1`) 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, das Dashboard 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>'`, und/oder das
Dashboard zeigt "Gateway Status: Stopped" obwohl der Gateway laeuft:**
Root Cause war eine Architektur-Inkompatibilitaet: Gateway und Dashboard
liefen anfangs in ZWEI GETRENNTEN Containern (`hermes-agent` mit
`command: ["gateway", "run"]` + ein separater `hermes-agent-dashboard` mit
`command: ["dashboard", ...]`, beide aus demselben Image). Zwei Symptome,
eine Ursache:
- Der Gateway-Status im Dashboard kommt aus einer **lokalen Prozess-
Inspektion** (PID-Check) im eigenen Container, NICHT aus einem Netzwerk-
Call — im getrennten Dashboard-Container sah dieser Check den
Gateway-Prozess nie, daher immer "Stopped".
- Der Restart-Button ruft `hermes gateway restart` auf, das s6-Services
**im eigenen Container** aufloest. Im Dashboard-Container gab's dafuer
keinen registrierten Service — daher `no such gateway 'default'`. Der
echte Gateway-Prozess lief davon unbeeindruckt weiter.
**Fix (seit 20.07.2026, `docker-compose.yml`):** Gateway und Dashboard laufen
jetzt als s6-supervisierte Geschwister-Prozesse in EINEM `hermes-agent`-
Container (`HERMES_DASHBOARD=1` + `HERMES_DASHBOARD_HOST=127.0.0.1` im
`environment:`-Block) — genau der vom Image selbst empfohlene Weg, siehe
Startup-Log: *"dashboard supervised alongside if HERMES_DASHBOARD is set.
This is the recommended setup for the s6 container image."* Kein separater
`hermes-agent-dashboard`-Service mehr, kein `hermes profile create default`
noetig. Nach `git pull` + `docker-compose up -d --build` sollten Gateway-
Status UND Restart-Button im Dashboard den echten Zustand zeigen — bitte
nach dem Redeploy einmal ausprobieren.
Sollte der Restart-Button trotzdem noch spinnen: als Fallback von aussen
neu starten (bei s6-Auto-Restart selten noetig):
```bash
docker-compose restart hermes-agent
```
**Chat im Dashboard bricht dauerhaft mit "Chat connection interrupted (code
1006). Reconnecting..." + "events feed disconnected — tool calls may not
appear" ab, obwohl die Seite selbst (Login, Session-History) normal laedt
und `docker logs hermes-agent` waehrend des Reconnects RUHIG bleibt (kein
Guard-Token, kein Fehler, gar nichts):**
Root Cause im echten Hermes-Sourcecode gefunden (`hermes_cli/web_server.py`,
Funktion `_ws_host_origin_reason`), nicht geraten: der Host-Header-Fix von
weiter oben loest nur die **HTTP**-Seite. WebSocket-Upgrades
(`/api/ws`, `/api/pub`, `/api/events` — genau die Endpunkte hinter Chat +
Events-Feed) haben einen ZWEITEN, unabhaengigen Guard, der zusaetzlich den
`Origin`-Header prueft. Der Browser schickt als Origin immer die echte
aufgerufene Adresse (Server-IP/Domain), Hermes ist aber auf `127.0.0.1`
gebunden und lehnt jeden Origin ab, der nicht `127.0.0.1`/`localhost`/`::1`
ist -> `origin_mismatch` -> WS wird sofort mit Code 4403 geschlossen
(Browser zeigt das oft generisch als 1006).
Der Grund warum die Log-Suche vorher ins Leere lief: anders als
`/api/console` und `/api/pty` loggen `/api/ws`, `/api/pub` und `/api/events`
diese Ablehnung **nicht** — sie schliessen still. Gleicher Bug-Mechanismus
wie der Host-Header-Fehler, nur eine Ebene tiefer und ohne jede
Fehlermeldung.
**Fix (bereits im `Caddyfile`):** Origin-Header komplett entfernen statt ihn
umzuschreiben — fehlt der Header, ueberspringt Hermes den Origin-Check
komplett:
```
reverse_proxy 127.0.0.1:9119 {
header_up Host 127.0.0.1:9119
header_up -Origin
}
```
Nach `git pull`: `docker-compose up -d --build caddy` (Config-Reload reicht,
kein Neubuild von hermes-agent noetig).
## 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`.