From 07118df53c8db0c7fc31e8d244367871526c6347 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 13 Aug 2026 11:21:14 -0700 Subject: [PATCH] Release: multi-container Docker gateway API forwarding (#6917) (#6995) * feat(docker): enable gateway API and password forwarding in compose files - Added API_SERVER_KEY forwarding to enable the gateway API listener in multi-container setups. - Configured HERMES_WEBUI_PASSWORD to ensure password authentication works as expected. - Updated documentation to clarify API_SERVER_KEY requirements and forwarding behavior. - Added regression tests to cover the new functionality and ensure proper configuration. * build(docker): force LF line endings for shell scripts via .gitattributes A CRLF shebang (#!/bin/bash\r) breaks locally-built Docker images: the kernel can't resolve the interpreter /bin/bash\r, so 'docker compose up -d --build' on Windows with core.autocrlf=true fails with 'exec /hermeswebui_init.bash: no such file or directory'. The repo blobs and CI/GHCR builds are LF and unaffected; only Windows local builds baked the CRLF checkout into the image. * fixup(docker): dedup API_SERVER_HOST after rebase onto #6968 (which already added it) * docs(changelog): stamp #6917 (multi-container gateway API forwarding) --------- Co-authored-by: Yang Co-authored-by: nesquena-hermes --- .env.docker.example | 15 +++ .gitattributes | 10 ++ CHANGELOG.md | 2 + docker-compose.three-container.yml | 21 +++-- docker-compose.two-container.yml | 21 +++-- docker-compose.yml | 5 +- docs/docker.md | 16 ++++ ...compose_gateway_and_password_forwarding.py | 92 +++++++++++++++++++ 8 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 .gitattributes create mode 100644 tests/test_docker_compose_gateway_and_password_forwarding.py diff --git a/.env.docker.example b/.env.docker.example index d0db0ecc..d6d6c001 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -45,6 +45,9 @@ GID=1000 # REQUIRED if you expose the container on anything other than 127.0.0.1. # Without a password, anyone who can reach the port can run commands as # the agent. +# +# All three compose files forward this into the WebUI container, so +# uncommenting it here is sufficient — no compose edit needed: # HERMES_WEBUI_PASSWORD=change-me-to-something-strong # ────────────────────────────────────────────────────────────────────────── @@ -118,3 +121,15 @@ GID=1000 # bind), ALL THREE containers must mount the SAME host path and run as the # SAME UID/GID. Mismatched UIDs → "Permission denied" → the WebUI crashes # on every HTTP request because it can't read its own auth signing key. + +# ────────────────────────────────────────────────────────────────────────── +# Gateway API server — multi-container only +# ────────────────────────────────────────────────────────────────────────── +# The two/three-container compose files forward this into the hermes-agent +# container. The agent only starts the gateway API listener (port 8642) when +# a usable API_SERVER_KEY is present (>=16 chars) — API_SERVER_ENABLED alone +# does nothing. Without a key, the gateway daemon still runs (messaging + +# cron) but the WebUI's Tasks/System pill shows "Gateway endpoint not +# reachable". Set a long random string here to enable it; the same value is +# forwarded to the WebUI as HERMES_WEBUI_GATEWAY_API_KEY automatically: +# API_SERVER_KEY=replace-with-a-long-random-string diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..3906b5c4 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalize line endings to LF in the repository. +* text=auto + +# Shell scripts must stay LF on checkout too. A CRLF shebang (`#!/bin/bash\r`) +# breaks the Docker image: the kernel can't find interpreter `/bin/bash\r`, so +# local `docker compose up -d --build` on Windows machines with +# `core.autocrlf=true` fails with `exec /hermeswebui_init.bash: no such file +# or directory`. +*.sh text eol=lf +*.bash text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ad7bf8..31244288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Multi-container Docker: the WebUI can now reach the agent gateway API without hand-editing compose files.** The two- and three-container Compose topologies now forward the gateway API server settings end to end — `API_SERVER_ENABLED`/`API_SERVER_HOST`/`API_SERVER_KEY` into the agent container and the matching `HERMES_WEBUI_GATEWAY_API_KEY` into the WebUI container — so setting `API_SERVER_KEY` in `.env` is enough to enable Tasks/cron and gateway-backed features (the agent only binds port 8642 when the key is a usable value). Password vars are now `${HERMES_WEBUI_PASSWORD:-}` substitutions (set once in `.env`, no compose edit), and a new `.gitattributes` forces LF on shell scripts so a Windows `core.autocrlf` checkout no longer breaks the image build (`exec …init.bash: no such file or directory`). Thanks @Yang-qwq. (#6917) + - **The chat view no longer bounces vertically while a reply streams in.** As each word faded in during streaming, the browser replaced the finished word's DOM node with a fresh text node — and every replacement made the browser's native scroll anchoring re-pick an anchor while the transcript was still growing, producing a visible vertical bounce. The two fade-cleanup handlers now keep each faded-in word's node stable (they just clear the transient animation class and timing variable) instead of replacing it, so the scroll position stays put. The settled reply is still rebuilt as plain text exactly as before, and text selection during streaming is steadier as a bonus. Thanks @ruizanthony. (#6257) - **The model selector no longer lists providers whose API key you've removed.** A provider that stayed in `config.yaml` with only metadata (e.g. `openai-api: {name: "OpenAI API"}`) kept appearing in the chat model dropdown after its credential was removed, so picking it failed. A config-listed provider now appears only when it was actually detected from a credential source (env var, hermes auth, or the credential pool), with provider aliases resolved on both sides (so `x-ai`/`xai`, `google`/`gemini` match correctly). Route-configured and active-model providers are unaffected. Thanks @webtecnica. (#6338) diff --git a/docker-compose.three-container.yml b/docker-compose.three-container.yml index f8602d94..3dff3a1e 100644 --- a/docker-compose.three-container.yml +++ b/docker-compose.three-container.yml @@ -51,6 +51,15 @@ services: # Defaults to 1000 to match WANTED_UID/WANTED_GID in the webui service. - HERMES_UID=${UID:-1000} - HERMES_GID=${GID:-1000} + # Gateway API server (port 8642). The WebUI's Tasks/cron health probe + # reaches this listener via HERMES_API_URL below. The agent only starts + # the listener when API_SERVER_KEY is a usable value (>=16 chars); set + # it in .env to enable the gateway API. Without a key the gateway daemon + # still runs (messaging + cron) but port 8642 stays unbound and the + # WebUI reports "Gateway endpoint not reachable". + - API_SERVER_ENABLED=true + - API_SERVER_HOST=0.0.0.0 + - API_SERVER_KEY=${API_SERVER_KEY:-} # Bind-mount permission handling for the agent — narrow set of overrides. # NOTE: The agent's HERMES_HOME_MODE applies to the HERMES_HOME *directory* # mode (default 0700) — NOT to credential files like the WebUI's variant. @@ -60,10 +69,6 @@ services: # The agent's container detection (/.dockerenv) already auto-skips # credential chmod inside Docker, so HERMES_SKIP_CHMOD is redundant here. # - HERMES_HOME_MODE=0750 - # Bind to 0.0.0.0 so that other services can reach the agent's api. - # We map the service to 127.0.0.1 with docker, so it won't be reachable - # outside the host - - API_SERVER_HOST=0.0.0.0 restart: unless-stopped deploy: resources: @@ -136,14 +141,18 @@ services: # echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env - WANTED_UID=${UID:-1000} - WANTED_GID=${GID:-1000} + # Forward the API server key so the WebUI's gateway health probe can + # authenticate (must match the agent's API_SERVER_KEY above). + - HERMES_WEBUI_GATEWAY_API_KEY=${API_SERVER_KEY:-} + # Optional: enable password auth. Set HERMES_WEBUI_PASSWORD in .env + # (required if you expose the port beyond 127.0.0.1). + - HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-} # NOTE: When using bind-mount volumes shared across containers, ALL containers # that write to the same host directory must run as the same UID/GID. # If hermes-agent initialises the state dir as root (UID 0), hermes-webui # will get a PermissionError accessing those paths — including a crash on every # HTTP request if the auth signing-key file is unreadable. Either set WANTED_UID # to match the agent container's UID, or use a named Docker volume (preferred). - # Optional: set a password for remote access - # - HERMES_WEBUI_PASSWORD=your-secret-password # Bind-mount permission handling for the WebUI (fixes #1389, #1399). # NOTE: WebUI's HERMES_HOME_MODE is a credential-file threshold (allow # group bits on .env/.signing_key/etc.), DIFFERENT from the agent's diff --git a/docker-compose.two-container.yml b/docker-compose.two-container.yml index 3fdba336..f99d61ca 100644 --- a/docker-compose.two-container.yml +++ b/docker-compose.two-container.yml @@ -67,6 +67,15 @@ services: # The agent image's entrypoint already supports usermod remapping. - HERMES_UID=${UID:-1000} - HERMES_GID=${GID:-1000} + # Gateway API server (port 8642). The WebUI's Tasks/cron health probe + # reaches this listener via HERMES_API_URL below. The agent only starts + # the listener when API_SERVER_KEY is a usable value (>=16 chars); set + # it in .env to enable the gateway API. Without a key the gateway daemon + # still runs (messaging + cron) but port 8642 stays unbound and the + # WebUI reports "Gateway endpoint not reachable". + - API_SERVER_ENABLED=true + - API_SERVER_HOST=0.0.0.0 + - API_SERVER_KEY=${API_SERVER_KEY:-} # Bind-mount permission handling for the agent — narrow set of overrides. # NOTE: The agent's HERMES_HOME_MODE applies to the HERMES_HOME *directory* # mode (default 0700) — NOT to credential files like the WebUI's variant. @@ -76,10 +85,6 @@ services: # The agent's container detection (/.dockerenv) already auto-skips # credential chmod inside Docker, so HERMES_SKIP_CHMOD is redundant here. # - HERMES_HOME_MODE=0750 - # Bind to 0.0.0.0 so that other services can reach the agent's api. - # We map the service to 127.0.0.1 with docker, so it won't be reachable - # outside the host - - API_SERVER_HOST=0.0.0.0 restart: unless-stopped networks: - hermes-net @@ -121,8 +126,12 @@ services: # echo "UID=$(id -u)" >> .env && echo "GID=$(id -g)" >> .env - WANTED_UID=${UID:-1000} - WANTED_GID=${GID:-1000} - # Optional: set a password for remote access - # - HERMES_WEBUI_PASSWORD=your-secret-password + # Forward the API server key so the WebUI's gateway health probe can + # authenticate (must match the agent's API_SERVER_KEY above). + - HERMES_WEBUI_GATEWAY_API_KEY=${API_SERVER_KEY:-} + # Optional: enable password auth. Set HERMES_WEBUI_PASSWORD in .env + # (required if you expose the port beyond 127.0.0.1). + - HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-} # Bind-mount permission handling for the WebUI (fixes #1389, #1399). # NOTE: WebUI's HERMES_HOME_MODE is a credential-file threshold (allow # group bits on .env/.signing_key/etc.), DIFFERENT from the agent's diff --git a/docker-compose.yml b/docker-compose.yml index 3428ba4a..d84161e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,8 +42,9 @@ services: - HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui # Default workspace directory shown on first launch # - HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace - # Optional: set a password for remote access - # - HERMES_WEBUI_PASSWORD=your-secret-password + # Optional: enable password auth. Set HERMES_WEBUI_PASSWORD in .env + # (required if you expose the port beyond 127.0.0.1). + - HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-} # # Bind-mount permission handling (fixes #1389, #1399): # When you mount an EXISTING ~/.hermes directory (the common case), diff --git a/docs/docker.md b/docs/docker.md index e4a8bdd0..a5a5d004 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -193,6 +193,22 @@ cp .env.docker.example .env docker compose -f docker-compose.two-container.yml up -d ``` +The compose files forward `API_SERVER_KEY` from `.env` into the `hermes-agent` +container. The agent only starts the gateway API listener (port 8642) when +`API_SERVER_KEY` is a usable value (>=16 chars) — `API_SERVER_ENABLED` alone +does nothing. Without a key, the gateway daemon still runs but port 8642 stays +unbound and the WebUI keeps showing **"Gateway endpoint not reachable"**. To +enable scheduled ticking and the green gateway pill, set a long random string +in `.env`: + +```bash +echo "API_SERVER_KEY=$(openssl rand -hex 24)" >> .env +docker compose -f docker-compose.two-container.yml up -d --force-recreate +``` + +The compose file forwards the same value to the WebUI as +`HERMES_WEBUI_GATEWAY_API_KEY`, so the health probe authenticates automatically. + The three-container layout adds the dashboard but is otherwise the same shape. If you must stay single-container, you can run `hermes gateway` inside the container as a long-lived background process, but the compose split is sturdier. If you maintain a custom compose file, make sure the **WebUI service** points at diff --git a/tests/test_docker_compose_gateway_and_password_forwarding.py b/tests/test_docker_compose_gateway_and_password_forwarding.py new file mode 100644 index 00000000..00a7eadb --- /dev/null +++ b/tests/test_docker_compose_gateway_and_password_forwarding.py @@ -0,0 +1,92 @@ +"""Regression coverage for two Docker-compose gaps found in the field. + +1. Gateway API not reachable out of the box. The two/three-container compose + files set ``HERMES_API_URL=http://hermes-agent:8642`` on the WebUI but + never configured the agent to listen on 8642. The agent image only starts + its API-server listener when ``API_SERVER_KEY`` is a usable value (>=16 + chars); ``API_SERVER_ENABLED`` alone does nothing. The compose files now + forward ``API_SERVER_KEY`` from ``.env`` and bind the listener on + 0.0.0.0, and the WebUI receives the matching + ``HERMES_WEBUI_GATEWAY_API_KEY`` so its health probe authenticates. + +2. ``HERMES_WEBUI_PASSWORD`` set in ``.env`` had no effect. Docker Compose + uses ``.env`` only for variable interpolation — a value is not injected + into a container unless the compose file references it. All three compose + files now forward ``HERMES_WEBUI_PASSWORD`` into the WebUI service. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + +MULTI = ("docker-compose.two-container.yml", "docker-compose.three-container.yml") +ALL = ("docker-compose.yml",) + MULTI + + +# ── 1: gateway API server forwarding (multi-container only) ──────────────── + + +def test_agent_service_enables_api_server(): + """The agent service must carry the API-server env block so a user who + sets API_SERVER_KEY in .env gets a listening 8642 without editing the + compose file.""" + for fn in MULTI: + src = (REPO / fn).read_text(encoding="utf-8") + assert "- API_SERVER_ENABLED=true" in src, ( + f"{fn}: agent must declare API_SERVER_ENABLED." + ) + assert "- API_SERVER_HOST=0.0.0.0" in src, ( + f"{fn}: API server must bind 0.0.0.0 — the default 127.0.0.1 is " + "unreachable from the WebUI container over the compose network." + ) + assert "- API_SERVER_KEY=${API_SERVER_KEY:-}" in src, ( + f"{fn}: agent must forward API_SERVER_KEY from .env so the gateway " + "API listener can start (the agent requires a usable key, >=16 chars)." + ) + + +def test_webui_service_forwards_gateway_api_key(): + """The WebUI must receive the same API_SERVER_KEY so its /health/detailed + probe authenticates instead of 401ing into 'Gateway endpoint not + reachable'.""" + for fn in MULTI: + src = (REPO / fn).read_text(encoding="utf-8") + assert "- HERMES_WEBUI_GATEWAY_API_KEY=${API_SERVER_KEY:-}" in src, ( + f"{fn}: WebUI must forward HERMES_WEBUI_GATEWAY_API_KEY from the " + "same API_SERVER_KEY so the gateway health probe authenticates." + ) + + +# ── 2: HERMES_WEBUI_PASSWORD forwarding (all compose files) ──────────────── + + +def test_webui_service_forwards_password(): + """HERMES_WEBUI_PASSWORD set in .env must reach the WebUI container. + .env is only for compose interpolation; without this forwarding line the + password silently does nothing.""" + for fn in ALL: + src = (REPO / fn).read_text(encoding="utf-8") + assert "- HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD:-}" in src, ( + f"{fn}: WebUI must forward HERMES_WEBUI_PASSWORD from .env — " + "setting it only in .env does nothing unless the compose file " + "references it." + ) + + +def test_env_example_documents_api_server_key(): + """.env.docker.example must tell users the API server needs a usable + API_SERVER_KEY (>=16 chars) and that it is forwarded automatically.""" + example = (REPO / ".env.docker.example").read_text(encoding="utf-8") + assert "API_SERVER_KEY" in example, ( + ".env.docker.example must document API_SERVER_KEY." + ) + assert ">=16" in example, ( + ".env.docker.example must state the API_SERVER_KEY length floor " + "(>=16 chars) — shorter keys are silently ignored by the agent." + ) + assert "HERMES_WEBUI_GATEWAY_API_KEY" in example, ( + ".env.docker.example must mention that the WebUI key is forwarded " + "automatically from the same API_SERVER_KEY." + )