Files
hermes-agent/docker-compose.yml
T
ARIAandClaude Sonnet 5 4710e4071b fix(proxy): Streaming-Pfad parst <tool_call>-Tags nie -> Tool-Calls wirkungslos
Root Cause (verifiziert am echten npm-Paket claude-max-api-proxy@dist):
handleStreamingResponse() in server/routes.js baut SSE-Chunks komplett
inline aus den rohen content_delta-Tokens und ruft dabei NIE den
<tool_call name="X">{json}</tool_call>-Parser aus adapter/cli-to-openai.js
auf (_parseToolCalls / cliResultToOpenai) -- der laeuft bisher nur im
Non-Streaming-Pfad (handleNonStreamingResponse). Hermes fragt aber immer
mit stream:true an. Folge: Claude gibt die Tool-Call-Tags korrekt als Text
aus (das hatten wir zuletzt gefixt), aber sie kommen nie als echtes OpenAI
tool_calls[]-Array bei Hermes an -- kein Tool wird ausgefuehrt, kein
Ergebnis geht zurueck in die Session, Claude haeuft neue <tool_call>-
Bloecke an weil nie eine Antwort kommt ("es passiert nicht viel", vier
Tags in einer Antwort).

Bisher gab's fuer routes.js nur einen minimalen sed-Patch (systemPrompt
durchreichen), bewusst ohne ARIAs vollen routes.js-Patch (der haengt an
ARIA-Bridge-spezifischen Extras wie Killswitch/Live-Stream). Der Fehler
sitzt aber strukturell in handleStreamingResponse selbst, sed reicht nicht
mehr.

Fix: proxy-patches/routes.js -- eigene schlanke Variante ohne Bridge-
Extras, komplett per cp ersetzt statt sed. handleStreamingResponse
streamt keine rohen Tokens mehr live, sondern wartet auf das volle
"result"-Event und baut daraus per cliResultToOpenai() (dieselbe Logik
wie Non-Streaming) den finalen Chunk inkl. tool_calls[]/finish_reason.
Kostet den Live-Tipp-Effekt, aber Tool-Calls funktionieren jetzt
ueberhaupt erst. systemPrompt-Fix ist in der neuen Datei bereits
enthalten (ersetzt den alten sed dafuer).

Lokal verifiziert: node --check auf der neuen Datei, und
cliResultToOpenai() gegen Stefans exakten Beispiel-Text (vier
zusammenhaengende <tool_call>-Tags) getestet -- liefert korrekt 4
tool_calls + finish_reason=tool_calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 07:55:55 +00:00

310 lines
18 KiB
YAML

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 <tool_call>-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 <-> <tool_call>-Text-Bridge, kein ARIA-Code drin):
# - openai-to-cli.js: haengt `tools`-Definitionen als Anleitung in den
# System-Prompt ("gib <tool_call name=X>{json}</tool_call> 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 <tool_call>-Parser aus
# cli-to-openai.js auf (der laeuft nur im Non-Streaming-Pfad). Hermes
# nutzt aber immer stream:true -> Claude gibt <tool_call name="...">
# {...}</tool_call> 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 <TOKEN>
# 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
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