services: # ─── Claude Max API Proxy (fuer Hermes) ───────────────── # Gleicher Trick wie beim ARIA-Proxy (/root/ARIA-AGENT/docker-compose.yml, # Service "proxy"): das npm-Paket claude-max-api-proxy wrappt die Claude # Code CLI (angemeldet mit Claude-Max-Subscription) als OpenAI-kompatiblen # /v1/chat/completions-Endpoint. Die Sed-Patches unten sind identisch zu # ARIA — noetig damit: # - der Server auf 0.0.0.0 bindet (sonst nur localhost im Container) # - Claude ohne interaktive Permission-Prompts laeuft (--dangerously-skip-permissions, # zwingend fuer non-interactive Container-Betrieb als root) # - der System-Prompt komplett ersetzt wird (--system-prompt) statt nur # angehaengt — sonst gewinnt Claude Codes eigene "You are Claude Code"- # Identitaet bei duennem Kontext # - lange Agent-Loops nicht nach 5min timeouten (DEFAULT_TIMEOUT -> 24h) # - der Prompt via stdin statt CLI-Arg uebergeben wird (Arg-Laengenlimit/ # Escaping-Probleme bei langen Konversationen) # - Claude NICHT seine eigenen nativen Skill-/MCP-Tools nutzt (--safe-mode, # seit 20.07.2026): dieser Proxy laeuft mit einer echten Claude-Max-CLI- # Session (eigenes ./hermes-data/claude-config, aber ggf. DERSELBE # Anthropic-Account wie ARIA). Dieser Account hat eigene claude.ai- # Connectors (z.B. Gmail/Calendar/Drive) und ARIAs eigene Skills, die # OHNE --safe-mode ganz normal in JEDER CLI-Session auf diesem Account # mitgeladen werden — auch hier im Hermes-Proxy-Container. Symptom war: # Claude antwortete auf "nutze Skill spotify_albums" mit "kenne ich # nicht, hab nur Gmail/Calendar/Drive verbunden" — es hat sein EIGENES # natives Skill-/MCP-System gecheckt statt den hier per Prompt-Text # injizierten -Tools (Hermes' Spotify-Plugin etc.) zu nutzen. # --safe-mode deaktiviert alle Customizations (Skills, Plugins, MCP- # Server, CLAUDE.md, ...) fuer diese Session — eingebaute Tools # (Bash/Read/...) bleiben an, sind hier aber ungenutzt. Danach sieht # Claude NUR noch die per Prompt injizierten externen Tools. # - ECHTER ROOT CAUSE fuer "Claude kennt Hermes-Tools ueberhaupt nicht" # (gefunden 20.07.2026, npm-Paket claude-max-api-proxy@1.0.0 lokal # gezogen und Zeile fuer Zeile nachverfolgt statt weiter zu raten): # Unser eigener openai-to-cli.js-Patch baut korrekt ein Objekt # { prompt, systemPrompt, model, sessionId } (systemPrompt = der ganze # Tool-Block + System-Messages, siehe extractSystemPrompt()). Der # manager.js-Sed-Patch unten liest korrekt options.systemPrompt fuer # den --system-prompt-Flag. ABER: dazwischen liegt server/routes.js # (STOCK npm-Code, von uns bisher NICHT gepatcht!) — und genau dort # wird beim Aufruf von subprocess.start(cliInput.prompt, {...}) nur # { model: cliInput.model, sessionId: cliInput.sessionId } durchgereicht. # cliInput.systemPrompt wird schlicht NIE in dieses Options-Objekt # kopiert. Ergebnis: options.systemPrompt ist in manager.js IMMER # undefined, --system-prompt kam nie mit echtem Inhalt an, Claude lief # die ganze Zeit mit seiner EIGENEN Default-Identitaet (daher exakt die # "Web-Suche/Datei-Tools/Terminal/Kalender-Mail-Drive(Google)/Cron"- # Antwort — das sind Claude Codes eigene Bordmittel, nicht Hermes' # Tools). Der zusaetzliche sed unten patcht jetzt auch routes.js und # reicht cliInput.systemPrompt endlich durch (trifft beide Call-Sites: # Streaming- UND Non-Streaming-Handler, da beide exakt # "model: cliInput.model," enthalten). # # Adapter-Patches (proxy-patches/*.js) sind 1:1 aus dem ARIA-Proxy # uebernommen — sie sind generisch (implementieren nur die # OpenAI-tools <-> -Text-Bridge, kein ARIA-Code drin): # - openai-to-cli.js: haengt `tools`-Definitionen als Anleitung in den # System-Prompt ("gib {json} aus") # - cli-to-openai.js: parsed genau dieses Format aus Claudes Antwort # wieder raus und baut daraus OpenAI-konforme tool_calls[] # # ARIAs routes.js-Patch wurde bewusst NICHT 1:1 uebernommen (haengt # zusaetzlich an aria-bridge:8090 fuer Live-Tool-Stream + Not-Aus- # Killswitch sowie /shared/config/models.json — beides ARIA-Bridge- # spezifisch, hier nicht vorhanden). # # ABER: proxy-patches/routes.js (eigene, schlanke Variante ohne Bridge- # Extras) ist inzwischen Pflicht, kein reiner sed-Patch mehr moeglich. # Grund: die NPM-Stock-Version von handleStreamingResponse() baut SSE- # Chunks komplett inline und ruft NIE den -Parser aus # cli-to-openai.js auf (der laeuft nur im Non-Streaming-Pfad). Hermes # nutzt aber immer stream:true -> Claude gibt # {...} brav als Text aus, aber es kommt nie als echtes # OpenAI tool_calls[] an, wird nie ausgefuehrt, Claude haeuft neue # Calls an weil nie ein Ergebnis zurueckkommt ("es passiert nicht viel"). # proxy-patches/routes.js wartet deshalb im Streaming-Pfad auf das volle # "result"-Event und jagt es durch dieselbe cliResultToOpenai()-Logik # wie der Non-Streaming-Pfad (kostet den Live-Tipp-Effekt, aber Tool- # Calls funktionieren). Enthaelt den systemPrompt-Fix bereits mit. hermes-proxy: image: node:22-alpine container_name: hermes-proxy command: >- sh -c "apk add --no-cache bash curl && npm install -g @anthropic-ai/claude-code claude-max-api-proxy && DIST=$$(find /usr/local/lib -path '*/claude-max-api-proxy/dist' -type d | head -1) && sed -i 's/startServer({ port })/startServer({ port, host: process.env.HOST || \"127.0.0.1\" })/' $$DIST/server/standalone.js && sed -i 's/\"--no-session-persistence\",/\"--no-session-persistence\",\"--dangerously-skip-permissions\",\"--safe-mode\",/' $$DIST/subprocess/manager.js && sed -i 's/\"--dangerously-skip-permissions\",/\"--dangerously-skip-permissions\",\"--system-prompt\",options.systemPrompt,/' $$DIST/subprocess/manager.js && sed -i 's/const DEFAULT_TIMEOUT = 300000;/const DEFAULT_TIMEOUT = 86400000;/' $$DIST/subprocess/manager.js && sed -i '/prompt, \\/\\/ Pass prompt as argument/d' $$DIST/subprocess/manager.js && sed -i 's|this\\.process\\.stdin?\\.end();|this.process.stdin?.end(prompt);|' $$DIST/subprocess/manager.js && cp /proxy-patches/openai-to-cli.js $$DIST/adapter/openai-to-cli.js && cp /proxy-patches/cli-to-openai.js $$DIST/adapter/cli-to-openai.js && cp /proxy-patches/routes.js $$DIST/server/routes.js && claude-max-api" volumes: # WICHTIG: eigenes, von ARIA getrenntes Volume fuer die Claude-CLI-Session. # Einmalig `docker exec -it hermes-proxy claude` fuer den Browser-Login # mit der Claude-Max-Subscription (kann derselbe Account wie ARIA sein — # es ist ja dieselbe Subscription, nur eine eigene CLI-Session dafuer). - ./hermes-data/claude-config:/root/.claude # optional: nur noetig falls Hermes Dateien mit Dir austauschen soll - ./hermes-shared:/shared - ./proxy-patches:/proxy-patches:ro tmpfs: # wie bei ARIA: Claude Codes eigenes Auto-Memory nicht persistieren - /root/.claude/projects environment: - HOST=0.0.0.0 - SHELL=/bin/bash - CLAUDE_CODE_BUBBLEWRAP=1 # erlaubt --dangerously-skip-permissions als root restart: unless-stopped networks: - hermes-net # Bewusst KEIN ports:-Mapping nach aussen — genau wie bei ARIAs Proxy. # Nur Container im selben Docker-Netz (hier: hermes-gateway) sollen ihn # erreichen koennen. Das eigentliche Netzwerk-Exposing + Auth macht das # Gateway unten, damit hier niemals ein unauthentifizierter Endpoint # offen im Netz haengt. # Zum manuellen Testen direkt im Container: docker exec -it hermes-proxy curl -s localhost:3456/v1/chat/completions ... # ─── Hermes Gateway (Auth vor dem Claude-Proxy) ──────────── # Stefan hat entschieden: Hermes Agent selbst laeuft auf DERSELBEN Maschine # wie dieser Proxy-Stack (alles zu Hermes gehoerige auf eine Box). Trotzdem # bleibt dieses Gateway davor — hermes-proxy selbst hat keine Authentifizierung # (genau wie ARIAs Proxy) und ein anderer Container im selben Docker-Host # (oder spaeter doch mal ein zweiter Rechner) soll nicht ungefragt an die # Claude-Max-Subscription rankommen. Das Gateway (proxy-patches/gateway.js, # pure Node, keine Extra-Deps) prueft den Authorization: Bearer # Header, leitet nur bei Match an hermes-proxy weiter. Alles andere -> 401. # # Der Hermes-Agent-Service unten ist per ${HERMES_GATEWAY_TOKEN} + diesem # Gateway vorverdrahtet (siehe hermes-agent-config/config.yaml.example) — # kein manuelles config.yaml-Editieren mehr noetig. hermes-gateway: image: node:22-alpine container_name: hermes-gateway command: ["node", "/gateway/gateway.js"] depends_on: - hermes-proxy volumes: - ./proxy-patches:/gateway:ro environment: - TOKEN=${HERMES_GATEWAY_TOKEN:?HERMES_GATEWAY_TOKEN muss in .env gesetzt sein - siehe .env.example} - UPSTREAM_HOST=hermes-proxy - UPSTREAM_PORT=3456 - LISTEN_PORT=8080 ports: # Seit "alles auf einer Maschine": nur noch auf localhost binden, nicht # mehr auf allen Interfaces. Hermes Agent laeuft ja jetzt selbst hier # (network_mode: host, siehe unten) und erreicht localhost direkt. # Falls Du spaeter doch von einer ANDEREN Maschine ranwillst (z.B. fuer # einen mobilen Client direkt gegen Hermes' eigenen API-Server, siehe # Kommentar unten bei API_SERVER_*): "127.0.0.1:" vorne weglassen, dann # bindet's wieder auf 0.0.0.0 — aber dann auch das Sicherheitshinweis- # Kapitel im README zu TLS/VPN beachten. - "127.0.0.1:${HERMES_GATEWAY_PORT:-8447}:8080" restart: unless-stopped networks: - hermes-net # ─── Hermes Agent selbst (Nous Research) ─────────────────── # Basiert auf der offiziellen Service-Definition aus # NousResearch/hermes-agent/docker-compose.yml (umbenannt gateway->hermes-agent, # damit's nicht mit unserem eigenen "hermes-gateway" oben verwechselt wird — # das ist ein GANZ ANDERES Ding: Hermes' eigener Gateway-Begriff meint die # Messaging-Bruecke (Telegram/Discord/...) + optionalen OpenAI-API-Server, # NICHT unseren Auth-Proxy vor Claude). Seit 20.07.2026 EIN Container fuer # Gateway + Dashboard zusammen (HERMES_DASHBOARD=1, siehe Kommentar unten # direkt vorm Service) statt zwei getrennter — kein eigener # "hermes-agent-dashboard"-Service mehr. # # VORAUSSETZUNG: der echte Hermes-Agent-Sourcecode muss danebenliegen, weil # das Image nicht auf Docker Hub existiert, sondern per `build:` aus dem Repo # gebaut wird. Einmalig VOR dem ersten `docker compose up -d`: # git clone https://github.com/NousResearch/hermes-agent.git hermes-agent-src # # network_mode: host wie im Original — manche Messaging-Plattformen brauchen # echte Host-Ports, und dadurch erreicht Hermes unser hermes-gateway einfach # ueber "http://localhost:${HERMES_GATEWAY_PORT}/v1" statt ueber Docker-DNS. # Kurskorrektur 20.07.2026: KEIN separater hermes-agent-dashboard-Container # mehr. Grund (in Hermes' eigener Docs + unserem eigenen Startup-Log # nachgelesen -- Log-Zeile "dashboard supervised alongside if HERMES_DASHBOARD # is set. This is the recommended setup for the s6 container image"): das # offizielle Docker-Image kann Gateway UND Dashboard als zwei GESCHWISTER- # Prozesse in DERSELBEN s6-Supervision, DEMSELBEN Container laufen lassen, # gesteuert per Env-Var HERMES_DASHBOARD=1. Das ist explizit "the recommended # setup" -- und loest genau unser strukturelles Problem: # - Dashboard erkennt den Gateway-Status ueber LOKALE Prozess-Inspektion # (psutil/PID-Check) im eigenen Container, NICHT ueber eine Netzwerk- # Health-URL. Mit zwei getrennten Containern (unser alter Aufbau) sieht # das Dashboard den Gateway-Prozess nie -> "Gateway Status: Stopped" # obwohl der Gateway echt lief. # - Der "Restart Gateway"-Button im Dashboard ruft intern `hermes gateway # restart` auf, das ueber s6-svc gegen die Service-Verzeichnisse IM # EIGENEN Container aufgeloest wird -- im getrennten Dashboard-Container # gab's dort keinen "default"-Gateway-Service, daher der Fehler "no such # gateway 'default'". Im kombinierten Container sollte der Button den # echten main-hermes-Service finden. # Kein `command:` mehr noetig (Default-CMD des Images uebernimmt "gateway # run" + s6 kuemmert sich per HERMES_DASHBOARD=1 automatisch ums Mitstarten # des Dashboards). hermes-agent: build: ./hermes-agent-src image: hermes-agent # Erspart den unnoetigen "pull access denied"-Warning-Roundtrip von eben: # sagt Compose direkt "nie pullen, immer lokal bauen" statt erst zu # pullen und beim Scheitern auf build: zurueckzufallen. pull_policy: build container_name: hermes-agent restart: unless-stopped network_mode: host depends_on: - hermes-gateway volumes: # ~/.hermes-Aequivalent: Config, Sessions, Memory, Skills - alles persistent # hier drin. hermes-agent-config/config.yaml.example wird VOR dem ersten # Start einmalig hierher kopiert (siehe README) und dann von Hermes # NICHT mehr angefasst (nur geseedet wenn's fehlt). - ./hermes-data/agent-home:/opt/data # scripts/ (Spotify-Quick-Steuerung etc.) READ-ONLY in den Container, # damit `quick_commands` in config.yaml (siehe README, Abschnitt # "Fast-Path ohne LLM") sie unter einem festen Pfad aufrufen koennen, # ohne jedes Mal manuell `docker cp` machen zu muessen. Versioniert in # unserem eigenen Git-Repo statt im gitignorten hermes-data/. - ./scripts:/opt/data/scripts:ro environment: - HERMES_UID=${HERMES_UID:-10000} - HERMES_GID=${HERMES_GID:-10000} # Fuer die ${HERMES_GATEWAY_PORT}/${HERMES_GATEWAY_TOKEN}-Platzhalter in # hermes-data/agent-home/config.yaml (Hermes expandiert ${VAR} beim # Config-Laden aus os.environ — siehe hermes_cli/config.py _expand_env_vars). - HERMES_GATEWAY_PORT=${HERMES_GATEWAY_PORT:-8447} - HERMES_GATEWAY_TOKEN=${HERMES_GATEWAY_TOKEN:?HERMES_GATEWAY_TOKEN muss in .env gesetzt sein - siehe .env.example} # Dashboard als s6-supervisierten Geschwister-Prozess IM SELBEN Container # aktivieren (siehe Kommentar oben). HERMES_DASHBOARD_HOST=127.0.0.1 # haelt den Bind lokal (Caddy ist die einzige Auth-/TLS-Schicht nach # aussen, siehe caddy-Service unten und README) -- ohne das wuerde Hermes # per Default auf 0.0.0.0 binden und sein eigenes Auth-Gate verlangen. - HERMES_DASHBOARD=1 - HERMES_DASHBOARD_HOST=127.0.0.1 - HERMES_DASHBOARD_PORT=9119 # Optional: Hermes' EIGENER OpenAI-kompatibler API-Server, falls Du spaeter # einen mobilen Client (gpt_mobile, Maid, OpenWebUI, ...) direkt GEGEN # Hermes sprechen lassen willst (andere Richtung als unser Proxy oben! # Das hier ist Hermes ALS Server fuer einen Chat-Client, nicht Claude ALS # Backend fuer Hermes). Zum Aktivieren beide Zeilen einkommentieren und # API_SERVER_KEY in .env setzen: # - API_SERVER_HOST=0.0.0.0 # - API_SERVER_KEY=${API_SERVER_KEY} command: ["gateway", "run"] # ─── Caddy (TLS-Reverse-Proxy + Basic-Auth vor dem Dashboard) ────────── # Stefans Entscheidung 19.07.2026: Dashboard bleibt intern auf 127.0.0.1, # Caddy uebernimmt TLS-Termination auf Port 443 UND die Basic-Auth davor. # Caddy ist die EINZIGE Auth-/TLS-Schicht (aufgeraeumt 20.07.2026): Hermes' # eigenes Dashboard-Auth-Gate (dashboard.basic_auth) wurde komplett entfernt, # weil es sich laut eigener Fehlermeldung ohnehin nur bei einem 0.0.0.0-Bind # engagiert — der Dashboard-Bind (HERMES_DASHBOARD_HOST im hermes-agent- # Service) bleibt aber fest auf 127.0.0.1, das Gate haette hier nie # gegriffen. Ein zweiter, nie greifender Auth-Layer haette nur Setup- # Schritte gekostet, kein Sicherheitsgewinn. Nutzt HERMES_DASHBOARD_USER # (aus .env) als Login-Name + einen eigenen bcrypt-Hash in # CADDY_BASIC_AUTH_HASH (Caddy verlangt bcrypt, siehe .env.example fuer die # Erzeugung). # # Zertifikat: bewusst KEIN Let's Encrypt (bräuchte oeffentliche Domain + # Port-80-Challenge). Stefans Wunsch: ein selbstsigniertes Zertifikat mit # 100 Jahren Laufzeit, einmalig im Browser als Ausnahme akzeptieren, nie # wieder um Erneuerung kuemmern. Erzeugung siehe README Setup-Schritt. # network_mode: host aus demselben Grund wie bei hermes-agent: muss # 127.0.0.1:9119 (Dashboard, jetzt Teil des hermes-agent-Containers) # erreichen UND selbst auf dem Host-Port 443 lauschen. caddy: image: caddy:2-alpine container_name: hermes-caddy restart: unless-stopped network_mode: host depends_on: - hermes-agent volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro # kein :ro mehr -- der Entrypoint unten schreibt das Zertifikat selbst # ins Volume, wenn noch keins da ist. - ./hermes-data/caddy-certs:/certs - ./hermes-data/caddy-data:/data - ./hermes-data/caddy-config:/config environment: - HERMES_DASHBOARD_USER=${HERMES_DASHBOARD_USER:-admin} - CADDY_BASIC_AUTH_HASH=${CADDY_BASIC_AUTH_HASH:?CADDY_BASIC_AUTH_HASH muss in .env gesetzt sein - siehe .env.example} # Erzeugt beim allerersten Start automatisch ein selbstsigniertes # 100-Jahre-Zertifikat im /certs-Volume (persistent, ueberlebt Neustarts), # kein manueller openssl-Befehl auf dem Host mehr noetig. apk add ist # noetig, weil das caddy:2-alpine-Image openssl nicht mitbringt. entrypoint: ["/bin/sh", "-c"] command: - | set -e if [ ! -f /certs/dashboard.crt ] || [ ! -f /certs/dashboard.key ]; then echo "[caddy-init] Kein Zertifikat in /certs gefunden -- erzeuge selbstsigniertes 100-Jahre-Zertifikat..." apk add --no-cache openssl >/dev/null openssl req -x509 -nodes -newkey rsa:4096 \ -keyout /certs/dashboard.key -out /certs/dashboard.crt \ -days 36500 -subj "/CN=hermes-dashboard" echo "[caddy-init] Zertifikat erzeugt." else echo "[caddy-init] Zertifikat bereits vorhanden, ueberspringe Erzeugung." fi exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile networks: hermes-net: driver: bridge