v0.50.260: Docker reliability batch - PR #1428 + broader UX/docs improvements + Opus advisor fixes

Combines PR #1428 (UID/GID alignment) with a broader Docker reliability pass
that addresses recurring user reports about compose files not working.

Constituent PR:
- #1428 sunnysktsang - Align agent UID/GID with webui (fixes #1399).
  Two- and three-container compose files had agent at UID 10000 (image
  default) and webui at UID 1000 (WANTED_UID default), causing permission
  denied on shared hermes-home volume. All services now use ${UID:-1000}.

Plus broader Docker UX overhaul:
- All 3 compose files document HERMES_SKIP_CHMOD/HERMES_HOME_MODE escape
  hatches inline (the v0.50.254 fix wasn't surfaced for Docker users).
- New .env.docker.example template covering UID/GID, paths, password,
  permission handling. UID/GID are uncommented with placeholder values
  per Opus advisor (so macOS users don't skim past).
- New docs/docker.md - comprehensive guide: 5-min quickstart, failure
  mode table with one-line fixes, bind-mount migration, multi-container
  architecture diagram, macOS Docker Desktop VirtioFS note, link to
  community sunnysktsang/hermes-suite all-in-one image.
- README Docker section rewritten - clearer quickstart, failure-mode
  table, link to docs/docker.md. Stale /root/.hermes references removed.

Plus Opus pre-release advisor MUST-FIX:
- HERMES_HOME_MODE has DIFFERENT semantics in the WebUI vs the agent
  image. WebUI: credential-file mode threshold (0640 allows group bits).
  Agent: HERMES_HOME directory mode (default 0700). 0640 on a directory
  has no owner-execute bit, so the agent can't traverse its own home and
  bricks. My initial draft recommended HERMES_HOME_MODE=0640 in agent
  service blocks - corrected to 0750 across all 4 surfaces (compose
  files, .env.docker.example, docs/docker.md). 3 regression tests pin
  the asymmetry.

12 regression tests total in test_v050260_docker_invariants.py.
Full suite: 3627 passed, 0 failed.

Nathan explicitly authorized merge with my own review + Opus only, no
independent review needed.
This commit is contained in:
nesquena-hermes
2026-05-01 23:09:50 +00:00
parent 1e9aaac809
commit b57525241b
9 changed files with 751 additions and 160 deletions

89
.env.docker.example Normal file
View File

@@ -0,0 +1,89 @@
# Hermes Web UI — Docker Compose configuration template
#
# Copy this file to `.env` next to your docker-compose.yml.
# All variables are optional — Docker Compose substitutes defaults if unset.
#
# cp .env.docker.example .env
# # edit values you care about, then:
# docker compose up -d
# ──────────────────────────────────────────────────────────────────────────
# UID / GID — host user mapping
# ──────────────────────────────────────────────────────────────────────────
# Critical when bind-mounting an EXISTING host directory (e.g. ~/.hermes).
# The container runs as UID/GID and must match your host file ownership,
# otherwise the container can't read your config.yaml or write sessions.
#
# Find yours: id -u (UID) | id -g (GID)
#
# On macOS, UIDs start at 501 (not 1000), so you MUST set these.
# On Linux, the default of 1000 usually matches the first interactive user.
#
# REPLACE THESE WITH YOUR ACTUAL VALUES (run `id -u` and `id -g`):
UID=1000
GID=1000
# ──────────────────────────────────────────────────────────────────────────
# Hermes home directory — single-container compose only
# ──────────────────────────────────────────────────────────────────────────
# Where on the host your config, sessions, skills, and state live.
# Default: ~/.hermes (works for everyone with a standard install)
# Override if your .hermes is elsewhere:
# HERMES_HOME=/opt/hermes-data
# ──────────────────────────────────────────────────────────────────────────
# Workspace directory — single-container compose
# ──────────────────────────────────────────────────────────────────────────
# Path to your code/project directory. The WebUI's file browser shows
# this at /workspace inside the container.
# Default: ~/workspace
# HERMES_WORKSPACE=/home/me/dev
# ──────────────────────────────────────────────────────────────────────────
# Password — protect remote access
# ──────────────────────────────────────────────────────────────────────────
# 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.
# HERMES_WEBUI_PASSWORD=change-me-to-something-strong
# ──────────────────────────────────────────────────────────────────────────
# Permission handling for bind-mounted .hermes — advanced
# ──────────────────────────────────────────────────────────────────────────
# By default, the WebUI's startup credential-permission fixer enforces
# 0600 mode on .env, auth.json, and similar credential files in HERMES_HOME.
# This is the right behavior on a clean install, but it can clash with:
#
# - Bind-mounting an EXISTING ~/.hermes whose .env is intentionally 0640
# (e.g. group-readable for a Docker user group)
# - HERMES_HOME_MODE configured at the agent level for a multi-user setup
#
# To bypass the WebUI's fixer entirely:
# HERMES_SKIP_CHMOD=1
#
# OR to allow group bits while still stripping world-readable:
# HERMES_HOME_MODE=0640
#
# ⚠️ MULTI-CONTAINER WARNING: HERMES_HOME_MODE has DIFFERENT semantics in
# the WebUI vs. the agent image:
# - WebUI: credential FILE mode threshold (0640 = allow group bits)
# - Agent: HERMES_HOME *directory* mode (default 0700)
# 0640 on a directory has NO execute bit, so the agent can't enter its own
# home → broken. If you set HERMES_HOME_MODE for a multi-container setup,
# use 0750 (group-traversable) or 0701 (x-only for non-owner traversal).
# The compose files document both correctly per-service.
# ──────────────────────────────────────────────────────────────────────────
# Multi-container only — used by docker-compose.two-container.yml and
# docker-compose.three-container.yml
# ──────────────────────────────────────────────────────────────────────────
# These compose files use named Docker volumes by default (recommended).
# Set the variables above to your host UID/GID — both the agent container
# (HERMES_UID/HERMES_GID) and the webui container (WANTED_UID/WANTED_GID)
# are derived from $UID/$GID so files written by one are readable by the
# other.
#
# If you switch to bind mounts (replacing `hermes-home: {}` with a `device:`
# 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.

4
.gitignore vendored
View File

@@ -12,10 +12,11 @@ __pycache__/
# Archive directory (pre-git backups, kept on disk but not tracked)
archive/
# Local environment and secrets (but keep the example template)
# Local environment and secrets (but keep the example templates)
.env
.env.*
!.env.example
!.env.docker.example
.claude/
CLAUDE.md
AGENTS.md
@@ -39,6 +40,7 @@ Thumbs.db
docs/*
!docs/ui-ux/
!docs/ui-ux/**
!docs/docker.md
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
# Used by Claude during deep reviews; never shared in the repo.

View File

@@ -2,6 +2,23 @@
## [Unreleased]
## [v0.50.260] — 2026-05-01
### Fixed
- **Docker compose UID/GID alignment** (#1428, fixes #1399) — the two- and three-container compose files had a UID mismatch between containers sharing the `hermes-home` volume: `hermes-agent` and `hermes-dashboard` ran as UID 10000 (image default) while `hermes-webui` ran as UID 1000 (`WANTED_UID` default), causing `Permission denied` errors on every shared file. All services now read from `${UID:-1000}` and `${GID:-1000}` so they align by construction. Empirically tested on both two- and three-container setups by the contributor. (`docker-compose.two-container.yml`, `docker-compose.three-container.yml`) @sunnysktsang — PR #1428
### Changed
- **Docker UX overhaul** — Docker reliability has been a recurring pain point. This release ships a coordinated set of doc/config improvements:
- **All 3 compose files** now document the `HERMES_SKIP_CHMOD` and `HERMES_HOME_MODE` escape hatches inline (the v0.50.254 fix for #1389 wasn't surfaced for Docker users).
- **New `.env.docker.example`** template specifically for Docker users, covering UID/GID, paths, password, and permission-handling escape hatches with explicit `UID=1000`/`GID=1000` placeholders so macOS users don't skim past the warning.
- **New `docs/docker.md`** — comprehensive guide covering all 3 compose files, common failure modes (with one-line fixes), bind-mount migration recipe, multi-container architecture diagram, macOS Docker Desktop file-sharing implementation note, and pointer to the [community all-in-one image](https://github.com/sunnysktsang/hermes-suite) for Podman 3.4 / multi-arch users.
- **README Docker section rewritten** — clearer 5-minute quickstart pointing at the single-container setup; failure-mode table with one-line fixes; pointer to `docs/docker.md` for the deep dive; **stale `/root/.hermes` reference removed** (the agent images use `/home/hermes/.hermes`).
- **12 regression tests** in `tests/test_v050260_docker_invariants.py` — UID/GID alignment positive + negative-pattern guards, escape-hatch documentation, `.env.docker.example` shape, `docs/docker.md` failure-mode coverage, README link integrity, and YAML validity for all 3 compose files. (`docker-compose.yml`, `docker-compose.two-container.yml`, `docker-compose.three-container.yml`, `.env.docker.example`, `docs/docker.md`, `README.md`, `tests/test_v050260_docker_invariants.py`)
### Changed (Opus pre-release advisor)
- **`HERMES_HOME_MODE` semantic asymmetry warning** — Opus advisor caught a footgun in my initial draft: `HERMES_HOME_MODE` means **different things** in the WebUI vs. the agent image. WebUI's `HERMES_HOME_MODE` is a credential-FILE mode threshold (e.g. `0640` allows group bits on `.env`), but the agent's `HERMES_HOME_MODE` is the HERMES_HOME *directory* mode (default `0700`). `0640` on a directory has no owner-execute bit, so the agent can't traverse its own home directory and bricks. My initial draft recommended `HERMES_HOME_MODE=0640` as the example value in agent service blocks — corrected to `0750` (group-traversable) for multi-container setups. All three surfaces now match: compose files (per-service comments), `.env.docker.example` (multi-container warning section), `docs/docker.md` (failure mode #2 callout). 3 new regression tests pin the asymmetry: `test_agent_service_does_not_recommend_invalid_home_mode`, `test_compose_files_warn_about_home_mode_asymmetry`, `test_env_docker_example_warns_about_home_mode_asymmetry`. (`docker-compose.two-container.yml`, `docker-compose.three-container.yml`, `.env.docker.example`, `docs/docker.md`, `tests/test_v050260_docker_invariants.py`)
## [v0.50.259] — 2026-05-01
### Fixed

190
README.md
View File

@@ -125,157 +125,91 @@ If provider setup is still incomplete after install, the onboarding wizard will
## Docker
**Pre-built images** (amd64 + arm64) are published to GHCR on every release:
**Pre-built images** (amd64 + arm64) are published to GHCR on every release.
Make sure the `HERMES_WEBUI_STATE_DIR` (by default `~/.hermes/webui-mvp`, as detailed in the `.env.example` file) folder exist with the UID/GID of the owner of the `.hermes` folder.
The container will also mount your configured "workspace" (also from the example .env.example) as `/workspace`. adapt the location as needed.
For a comprehensive setup guide covering all 3 compose files, common failure modes, and bind-mount migration, see [`docs/docker.md`](docs/docker.md). The README covers the 5-minute happy path.
### 5-minute quickstart (single container)
The simplest setup: one WebUI container that runs the agent in-process.
```bash
git clone https://github.com/nesquena/hermes-webui
cd hermes-webui
cp .env.docker.example .env
# Edit .env if your host UID isn't 1000 (e.g. macOS where UIDs start at 501)
docker compose up -d
# Open http://localhost:8787
```
The container auto-detects your UID/GID from the mounted `~/.hermes` volume so files written by the agent stay readable by you on the host.
To enable password protection (required if you expose the port outside `127.0.0.1`):
```bash
echo "HERMES_WEBUI_PASSWORD=change-me-to-something-strong" >> .env
docker compose up -d --force-recreate
```
### Manual `docker run` (no compose)
```bash
docker pull ghcr.io/nesquena/hermes-webui:latest
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 ghcr.io/nesquena/hermes-webui:latest
-e WANTED_UID=$(id -u) -e WANTED_GID=$(id -g) \
-v ~/.hermes:/home/hermeswebui/.hermes \
-e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui \
-v ~/workspace:/workspace \
-p 127.0.0.1:8787:8787 \
ghcr.io/nesquena/hermes-webui:latest
```
Or run with Docker Compose (recommended):
```bash
# Check the docker-compose.yml and make sure to adapt as needed, at minimum WANTED_UID/WANTED_GID
docker compose up -d
```
Or build locally:
### Build locally
```bash
docker build -t hermes-webui .
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 hermes-webui
-e WANTED_UID=$(id -u) -e WANTED_GID=$(id -g) \
-v ~/.hermes:/home/hermeswebui/.hermes \
-e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui \
-v ~/workspace:/workspace \
-p 127.0.0.1:8787:8787 \
hermes-webui
```
Open http://localhost:8787 in your browser.
### Multi-container setups
To enable password protection:
If you want the agent and WebUI in separate containers (for isolation, or because you're already running an agent gateway elsewhere):
```bash
docker run -d \
-e WANTED_UID=`id -u` -e WANTED_GID=`id -g` \
-v ~/.hermes:/home/hermeswebui/.hermes -e HERMES_WEBUI_STATE_DIR=/home/hermeswebui/.hermes/webui-mvp \
-v ~/workspace:/workspace \
-p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret ghcr.io/nesquena/hermes-webui:latest
# Agent + WebUI
docker compose -f docker-compose.two-container.yml up -d
# Agent + Dashboard + WebUI
docker compose -f docker-compose.three-container.yml up -d
```
Both compose files use **named Docker volumes** by default, which solves the UID/GID problem by construction. If you need bind mounts to share an existing host directory, see [`docs/docker.md`](docs/docker.md) for the full migration recipe.
> **Known limitation (#681)**: in the two-container setup, tools triggered from the WebUI run in the **WebUI container**, not the agent container. If you need git/node/etc. on the WebUI's filesystem, either use the single-container setup, extend the WebUI Dockerfile, or use the community [all-in-one image](https://github.com/sunnysktsang/hermes-suite).
### Common failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
| `PermissionError` at startup | UID mismatch on bind mount | Set `UID=$(id -u)` in `.env` |
| `.env: permission denied` (#1389) | `fix_credential_permissions()` enforced 0600 | Set `HERMES_SKIP_CHMOD=1` in `.env` |
| Workspace appears empty | UID mismatch on `/workspace` mount | Set `UID=$(id -u)` in `.env` |
| `git: command not found` in chat | Two-container architectural limit (#681) | Use single-container or extend Dockerfile |
| WebUI can't find agent source | `hermes-agent-src` volume misconfigured | Use the named volumes from compose files as-is |
| Podman shared `.hermes` fails | Podman 3.4 `keep-id` limitation | Use Podman 4+ or single-container |
For the deep dive on each of these, see [`docs/docker.md`](docs/docker.md).
> **Note:** By default, Docker Compose binds to `127.0.0.1` (localhost only).
> To expose on a network, change the port to `"8787:8787"` in `docker-compose.yml`
> and set `HERMES_WEBUI_PASSWORD` to enable authentication.
### Two-container setup (Agent + WebUI)
If you run the Hermes Agent in its own Docker container and want the WebUI
in a separate container:
```bash
docker compose -f docker-compose.two-container.yml up -d
```
This starts both containers with shared volumes:
- **`hermes-home`** — shared `~/.hermes` for config, sessions, skills, memory
- **`hermes-agent-src`** — the agent's source code, mounted into the WebUI
container so it can install the agent's Python dependencies at startup
> **Volume type:** The compose files use named Docker volumes by default.
> If you prefer bind mounts to an existing directory (e.g. for sharing state
> with an agent container you already run), both containers must mount the
> same host path — the agent writes to `/root/.hermes`, the WebUI reads from
> `/home/hermeswebui/.hermes`. See `docker-compose.two-container.yml` for
> a bind-mount example.
The WebUI's init script automatically installs hermes-agent and all its
dependencies (openai, anthropic, etc.) into its own Python environment on
first boot. Subsequent restarts reuse the installed packages.
> **How it works:** The WebUI imports hermes-agent's Python modules directly
> (not via HTTP). The shared volume makes the agent source available, and
> the init script runs `uv pip install` to set up the dependencies. Both
> containers share the same `~/.hermes` directory for config and state.
See `docker-compose.two-container.yml` for the full configuration.
### Running alongside hermes-dashboard (three-container setup)
To run the Hermes Agent, Hermes Dashboard, and the WebUI together on a
shared volume, use the three-container Compose file:
```bash
docker compose -f docker-compose.three-container.yml up -d
```
This brings up:
- **`hermes-agent`** — gateway API on port 8642
- **`hermes-dashboard`** — monitoring UI on port 9119
- **`hermes-webui`** — browser chat interface on port 8787
All three services share the same `hermes-home` named volume so config,
sessions, skills, and memory are consistent across all surfaces.
#### Why UIDs must match
The `hermes-home` volume is a bind-mount in practice — all three containers
write to the same filesystem tree under `~/.hermes`. If the containers run
as different UIDs, whichever container creates a file first becomes its
owner, and the others hit `PermissionError` on subsequent writes.
The fix is to make all containers run as **your host user's UID and GID**.
#### Variable name asymmetry
> ⚠️ **The two image families use different environment variable names** for
> the UID/GID setting:
>
> | Image | Variable |
> |---|---|
> | `nousresearch/hermes-agent` (agent + dashboard) | `HERMES_UID` / `HERMES_GID` |
> | `ghcr.io/nesquena/hermes-webui` | `WANTED_UID` / `WANTED_GID` |
>
> You must set **both pairs** when using a `.env` file.
#### Recommended setup
For a standard Linux user (UID ≥ 1000):
```bash
# Create a .env file with your host UID/GID
echo "UID=$(id -u)" >> .env
echo "GID=$(id -g)" >> .env
# hermes-agent / hermes-dashboard
echo "HERMES_UID=$(id -u)" >> .env
echo "HERMES_GID=$(id -g)" >> .env
```
For NAS/Unraid deployments where a fixed service account is preferred, use
`10000:10000` (or your NAS service UID) instead of `$(id -u)`.
If you get `PermissionError` on an **existing** `~/.hermes` directory, run
the one-time ownership fix:
```bash
chown -R $(id -u):$(id -g) ~/.hermes
```
#### Volume mount mode
The dashboard container needs **read-write** access to the shared volume
(it writes session logs and dashboard state). Do **not** add `:ro` to the
`hermes-home` volume in `hermes-dashboard`'s `volumes:` entry.
See `docker-compose.three-container.yml` for the full reference configuration.
---
## What start.sh discovers automatically

View File

@@ -1,24 +1,37 @@
# Three-container Docker Compose: Hermes Agent + Dashboard + WebUI
#
# QUICK START:
# docker compose -f docker-compose.three-container.yml up -d
# Open http://localhost:8787 (chat) and http://localhost:9119 (dashboard)
#
# This extends the two-container setup with the Hermes Dashboard for
# monitoring agent activity, sessions, and resource usage.
#
# Usage:
# docker compose -f docker-compose.three-container.yml up -d
#
# Services:
# hermes-agent — gateway API on port 8642 (CLI, Telegram, cron, tools)
# hermes-agent — gateway API on port 8642 (CLI, Telegram, cron, tools)
# hermes-dashboard — monitoring dashboard on port 9119
# hermes-webui — browser chat interface on port 8787
# hermes-webui — browser chat interface on port 8787
#
# All three share the same hermes-home volume so config, sessions,
# skills, and memory are consistent across all surfaces.
#
# WHEN NOT TO USE THIS:
# - You hit "Permission denied" trying to share an existing ~/.hermes directory
# → use docker-compose.yml (single-container) instead, OR
# → keep this file but switch to NAMED VOLUMES (the default) instead of bind mounts
# - You're on Podman 3.4 or older without keep-id namespace support
# → see https://github.com/sunnysktsang/hermes-suite for an all-in-one image
#
# KNOWN LIMITATION (#681): tools triggered from the WebUI run in the WebUI
# container, not the agent container. See docker-compose.two-container.yml
# for context.
#
# NOTE ON VOLUMES:
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
# see the two-container compose file for a bind-mount example.
# When using bind mounts, ALL containers must mount the same host path.
# work out of the box. If you prefer bind mounts (e.g. to an existing
# directory), see docker-compose.two-container.yml for a bind-mount example.
# When using bind mounts, ALL THREE containers must mount the same host path
# AND run as the same UID/GID (set via the UID/GID env vars below).
services:
hermes-agent:
@@ -38,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}
# 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.
# If you set this, you MUST keep the owner-execute bit so the agent can
# traverse its own home directory. 0640 BREAKS the agent (no x bit → no
# traversal). Use 0750 for group-traversable or 0701 for x-only.
# The agent's container detection (/.dockerenv) already auto-skips
# credential chmod inside Docker, so HERMES_SKIP_CHMOD is redundant here.
# - HERMES_HOME_MODE=0750
restart: unless-stopped
deploy:
resources:
@@ -112,6 +134,13 @@ services:
# 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
# which applies to the HERMES_HOME directory itself. 0640 is correct
# for the WebUI; do NOT copy this value to the agent service block.
# - HERMES_SKIP_CHMOD=1
# - HERMES_HOME_MODE=0640
restart: unless-stopped
networks:
- hermes-net

View File

@@ -1,32 +1,49 @@
# Two-container Docker Compose: Hermes Agent + Hermes WebUI
#
# This runs the agent and web UI in separate containers connected via
# shared volumes. The WebUI installs the agent's Python dependencies
# at startup from the shared agent source volume.
#
# Usage:
# QUICK START:
# docker compose -f docker-compose.two-container.yml up -d
# Open http://localhost:8787
#
# The agent container runs the gateway (CLI, Telegram, cron, etc.).
# The WebUI container serves the browser interface on port 8787.
# Both share ~/.hermes for config, sessions, and state.
# This runs the agent and web UI in separate containers connected via shared
# Docker volumes. The WebUI installs the agent's Python dependencies from the
# shared agent source volume at startup.
#
# WHEN TO USE THIS:
# - You want isolation between the agent gateway and the WebUI
# - You're already running hermes-agent in its own container
# - You don't need the dashboard (use docker-compose.three-container.yml for that)
#
# WHEN NOT TO USE THIS:
# - You hit "Permission denied" trying to share an existing ~/.hermes directory
# → use docker-compose.yml (single-container) instead, OR
# → keep this file but switch to NAMED VOLUMES (the default) instead of bind mounts
# - You're on Podman 3.4 or older without keep-id namespace support
# → see https://github.com/sunnysktsang/hermes-suite for an all-in-one image
#
# KNOWN LIMITATION (#681): tools triggered from the WebUI run in the WebUI
# container, not the agent container. If you need git/node/etc. on the
# WebUI's filesystem, install them in the WebUI image — or use a single-
# container setup where everything lives in one place.
#
# NOTE ON VOLUMES:
# This file uses named Docker volumes (hermes-home, hermes-agent-src) which
# work out of the box. If you prefer bind mounts (e.g. to an existing directory),
# replace the named volumes at the bottom. Example for hermes-agent-src:
# work out of the box on every Docker installation. If you prefer bind mounts
# to share an existing host directory:
#
# hermes-agent-src:
# driver: local
# driver_opts:
# type: none
# o: bind
# device: /opt/hermes-agent
# volumes:
# hermes-home:
# driver: local
# driver_opts:
# type: none
# o: bind
# device: /home/youruser/.hermes
#
# When using bind mounts, BOTH containers must mount the same host path.
# The agent exposes source at /opt/hermes, the WebUI reads it from
# /home/hermeswebui/.hermes/hermes-agent — as long as both point to the
# same host directory, the paths align correctly.
# When using bind mounts, BOTH containers must mount the same host path,
# AND your host directory must be readable by UID 1000 (the default). Run:
# id -u && id -g
# to find your host UID/GID, then put them in a .env file:
# echo "UID=$(id -u)" >> .env
# echo "GID=$(id -g)" >> .env
services:
hermes-agent:
@@ -47,8 +64,18 @@ services:
- HERMES_HOME=/home/hermes/.hermes
# Align UID/GID across containers sharing the hermes-home volume.
# Defaults to 1000 to match WANTED_UID/WANTED_GID in the webui service.
# The agent image's entrypoint already supports usermod remapping.
- HERMES_UID=${UID:-1000}
- HERMES_GID=${GID:-1000}
# 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.
# If you set this, you MUST keep the owner-execute bit so the agent can
# traverse its own home directory. 0640 BREAKS the agent (no x bit → no
# traversal). Use 0750 for group-traversable or 0701 for x-only.
# The agent's container detection (/.dockerenv) already auto-skips
# credential chmod inside Docker, so HERMES_SKIP_CHMOD is redundant here.
# - HERMES_HOME_MODE=0750
restart: unless-stopped
networks:
- hermes-net
@@ -85,7 +112,14 @@ services:
- WANTED_UID=${UID:-1000}
- WANTED_GID=${GID:-1000}
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=***
# - 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
# which applies to the HERMES_HOME directory itself. 0640 is correct
# for the WebUI; do NOT copy this value to the agent service block.
# - HERMES_SKIP_CHMOD=1
# - HERMES_HOME_MODE=0640
restart: unless-stopped
networks:
- hermes-net

View File

@@ -1,4 +1,15 @@
version: "3.8"
# Hermes WebUI — single-container Docker Compose
#
# QUICK START (most users):
# 1. (Optional) Copy .env.docker.example to .env and edit values
# 2. docker compose up -d
# 3. Open http://localhost:8787
#
# This is the simplest setup: one WebUI container that runs the agent in-process.
# The WebUI auto-detects host UID/GID from the mounted .hermes volume.
#
# For multi-container setups (separate agent + webui or agent+webui+dashboard),
# see docker-compose.two-container.yml or docker-compose.three-container.yml.
services:
hermes-webui:
@@ -33,5 +44,14 @@ services:
# - HERMES_WEBUI_DEFAULT_WORKSPACE=/workspace
# Optional: set a password for remote access
# - HERMES_WEBUI_PASSWORD=your-secret-password
#
# Bind-mount permission handling (fixes #1389, #1399):
# When you mount an EXISTING ~/.hermes directory (the common case),
# the WebUI's startup credential-permission fixer can clash with
# your host file modes (e.g. 0640 group-readable .env files).
# Set HERMES_SKIP_CHMOD=1 to bypass the fixer entirely, OR set
# HERMES_HOME_MODE=0640 to allow group bits while still stripping
# world-readable. Both are documented in api/startup.py.
# - HERMES_SKIP_CHMOD=1
# - HERMES_HOME_MODE=0640
restart: unless-stopped

202
docs/docker.md Normal file
View File

@@ -0,0 +1,202 @@
# Hermes WebUI — Docker setup guide
This is the comprehensive Docker reference. For a 5-minute quickstart, see the [README Docker section](../README.md#docker).
## TL;DR — pick one
| Setup | When to use | File |
|---|---|---|
| **Single-container** (recommended) | You just want chat working. WebUI runs the agent in-process. | `docker-compose.yml` |
| **Two-container** | You want isolation between gateway (CLI/Telegram/cron) and chat UI. | `docker-compose.two-container.yml` |
| **Three-container** | Two-container PLUS the dashboard for monitoring. | `docker-compose.three-container.yml` |
| **All-in-one image** (community fork — third-party, not maintained by us) | Podman 3.4 / multi-arch / supervisord-style preference. | [sunnysktsang/hermes-suite](https://github.com/sunnysktsang/hermes-suite) — see [#1399](https://github.com/nesquena/hermes-webui/issues/1399) for the original discussion |
If something stops working, **start with the single-container setup** — it's the simplest path and fixes most permission/UID/path-mismatch issues by construction.
## 5-minute quickstart (single container)
```bash
git clone https://github.com/nesquena/hermes-webui
cd hermes-webui
cp .env.docker.example .env
# Edit .env if needed (most users can skip this on Linux)
docker compose up -d
open http://localhost:8787
```
That's it. Your existing `~/.hermes` directory is mounted, your `~/workspace` is browsable, and the WebUI auto-detects your UID/GID from the mounted volume.
## What goes wrong (and how to fix it)
### 1. "Permission denied" at startup
**Symptom**: Container starts but immediately crashes, logs show:
```
PermissionError: [Errno 13] Permission denied: '/home/hermeswebui/.hermes/...'
```
**Cause**: The container's user (UID 1000 by default) can't read your bind-mounted directory because your host files are owned by a different UID.
**Fix**: Set `UID` and `GID` in `.env` to match your host:
```bash
echo "UID=$(id -u)" >> .env
echo "GID=$(id -g)" >> .env
docker compose down && docker compose up -d
```
On macOS, host UIDs start at 501. On Linux, the first interactive user is usually UID 1000.
> **macOS Docker Desktop**: if UID mapping still misbehaves after the env fix, try toggling **Settings → General → File sharing implementation** between VirtioFS and gRPC-FUSE. Different implementations preserve UIDs across the host/container boundary differently.
### 2. ".env file mode 0640 → permission denied" (#1389)
**Symptom**: You set `HERMES_HOME_MODE=0640` (or some other group-readable mode) on your host `.env` file, container starts, then errors out:
```
[security] fixed permissions on .env (0o640 -> 0600)
failed to load .env: open .env: permission denied
```
**Cause**: WebUI's `fix_credential_permissions()` startup hook enforces 0600 by default. This is the right thing for a clean install but conflicts with operator-set modes.
**Fix**: Set one of these env vars in your `.env`:
- `HERMES_SKIP_CHMOD=1` — bypass the fixer entirely
- `HERMES_HOME_MODE=0640` — allow group bits, only strip world-readable
Both are documented in `api/startup.py::fix_credential_permissions()`.
> ⚠️ **Multi-container warning**: `HERMES_HOME_MODE` has DIFFERENT semantics in the agent image vs. the WebUI:
> - **WebUI**: credential FILE mode threshold (`0640` allows group bits on `.env`)
> - **Agent**: `HERMES_HOME` *directory* mode (default `0700`)
>
> `0640` on a directory has no owner-execute bit, so the agent can't traverse its own home → bricked. For multi-container setups, use `HERMES_HOME_MODE=0750` (group-traversable) or `0701` (x-only). The compose files have per-service comments that match each side's semantics.
### 3. "Workspace appears empty even though my files are there"
**Symptom**: WebUI loads but `/workspace` shows no files.
**Cause**: Same as #1 — UID mismatch on the bind mount.
**Fix**: Same as #1 — match host UID/GID via `.env`.
### 4. "Two-container setup: WebUI can't find agent source" (#858)
**Symptom**: WebUI logs at startup:
```
!! WARNING: hermes-agent source not found.
!! Looked in: /home/hermeswebui/.hermes/hermes-agent
!! /opt/hermes
```
**Cause**: The agent's source (`/opt/hermes` inside the agent container) needs to be exposed to the WebUI container via a shared volume. The two-container compose file does this via `hermes-agent-src` named volume, but if you're using bind mounts incorrectly the path won't resolve.
**Fix**: Use the named volumes that ship with `docker-compose.two-container.yml` — don't replace them with bind mounts unless you know what you're doing. The agent container writes its source to `/opt/hermes`, and the WebUI mounts that volume at `/home/hermeswebui/.hermes/hermes-agent`.
If you must use a bind mount: pick a host path, then mount it to `/opt/hermes` in the agent container AND `/home/hermeswebui/.hermes/hermes-agent` in the WebUI container.
### 5. "Tools (git, node, etc.) missing in two-container setup" (#681)
**Symptom**: You ask the agent to run `git status` in chat and it errors with `command not found`.
**Cause**: This is **architectural, not a bug**. In the two-container setup, agent processes started by the WebUI run **inside the WebUI container**, not the agent container. The WebUI image doesn't include git/node by design (it's a UI image, not a tool host).
**Workarounds**:
- **Single-container setup** (`docker-compose.yml`) — everything in one container, no boundary
- **Custom WebUI image** — extend the `Dockerfile` to install the tools you need
- **Combined image** ([sunnysktsang/hermes-suite](https://github.com/sunnysktsang/hermes-suite)) — community fork that ships agent+webui+dashboard in one container
### 6. "config.yaml not loaded"
**Symptom**: You have a `config.yaml` in your host `~/.hermes/`, but the WebUI shows "no model configured" or doesn't pick up your custom providers.
**Cause**: Either the file isn't readable (UID/GID issue, see #1) or it's not in the expected path inside the container.
**Fix**:
- Verify: `docker exec hermes-webui ls -la /home/hermeswebui/.hermes/config.yaml`
- If it doesn't exist: your host bind mount is pointing at the wrong directory.
- If it exists but is unreadable: see #1 for the UID/GID fix.
### 7. "On Podman: can't share .hermes between containers"
**Symptom**: Two-container setup works on Docker but fails on Podman with permission errors no matter what UID/GID you set.
**Cause**: Podman 3.4 (Ubuntu 22.04 default) has limited support for `userns_mode: keep-id` across multiple containers — files written by one container appear with a different UID in the other.
**Fix**: Either upgrade to Podman 4+ (which fixes this), or use the [single-container setup](#5-minute-quickstart-single-container), or use the [community all-in-one image](https://github.com/sunnysktsang/hermes-suite).
## Multi-container architecture
The two- and three-container setups use **named Docker volumes** (not bind mounts) by default for a reason: named volumes solve the UID/GID problem by construction. Docker creates the volume's root directory with the correct ownership, all containers reading/writing to it see the same files, no host-side permission setup required.
```
┌─────────────────────────────────┐
│ hermes-home (volume) │
│ (config, sessions, state, ...) │
└─────────────────────────────────┘
↑ ↑
│ rw │ rw
│ │
┌──────────────┐ │ │ ┌──────────────┐
│ hermes-agent │────┘ └────│ hermes-webui │
│ (port 8642) │ │ (port 8787) │
└──────────────┘ └──────────────┘
│ ↑
│ rw │ ro
↓ │
┌─────────────────────────┐ │
│ hermes-agent-src (vol) │─────────────────────┘
│ (agent's Python source) │
└─────────────────────────┘
```
The WebUI container doesn't ship with the agent's Python deps — at startup it runs `uv pip install /home/hermeswebui/.hermes/hermes-agent` to install them from the shared volume.
## Bind-mount migration (advanced)
If you really need to bind-mount an existing host `~/.hermes` (e.g. you're keeping config in dotfiles, sharing with a non-Docker `hermes` install, etc.):
```yaml
volumes:
hermes-home:
driver: local
driver_opts:
type: none
o: bind
device: /home/youruser/.hermes
hermes-agent-src:
driver: local
driver_opts:
type: none
o: bind
device: /opt/hermes-agent-source
```
**Critical requirements**:
1. The host directory MUST be readable by your container UID. Run `id -u` on the host and ensure `~/.hermes` is owned by that UID (or readable via group bits).
2. ALL containers sharing the volume must run as the SAME UID/GID. Set `UID=$(id -u)` and `GID=$(id -g)` in `.env`.
3. If your host `.env` is mode 0640, set `HERMES_SKIP_CHMOD=1` or `HERMES_HOME_MODE=0640` so the startup hook doesn't try to enforce 0600.
## Reference
- [`docker-compose.yml`](../docker-compose.yml) — single container (recommended)
- [`docker-compose.two-container.yml`](../docker-compose.two-container.yml) — agent + webui
- [`docker-compose.three-container.yml`](../docker-compose.three-container.yml) — agent + dashboard + webui
- [`.env.docker.example`](../.env.docker.example) — environment variable template
- [`Dockerfile`](../Dockerfile) — single-container build
- [`docker_init.bash`](../docker_init.bash) — container entrypoint script
## Related issues
- #1389`HERMES_HOME_MODE` override (fixed in v0.50.254 — agent honors `HERMES_SKIP_CHMOD` and `HERMES_HOME_MODE`)
- #1399 — UID alignment in compose files (fixed in v0.50.260 via PR #1428 + this guide)
- #858 — two-container `/opt/hermes` path confusion
- #681 — tools running in WebUI container, not agent container (architectural)
- #668 — auto-detect UID/GID from mounted volume
- #569 — UID/GID detection priority order
If you hit a new failure mode not covered here, please [open an issue](https://github.com/nesquena/hermes-webui/issues/new) with:
1. Which compose file you used
2. The error from `docker logs hermes-webui`
3. `docker exec hermes-webui id` output
4. `docker exec hermes-webui ls -la /home/hermeswebui/.hermes` output

View File

@@ -0,0 +1,264 @@
"""Regression tests for v0.50.260 — Docker compose file invariants.
PR #1428 fixed a UID/GID mismatch between the agent container and the webui
container in the two- and three-container compose files. This test module
pins the invariants that prevented the original bug from coming back AND
extends coverage to the related fixes shipped alongside #1428:
- All compose files reference the same UID/GID source (`${UID}` / `${GID}`)
- All compose files document the bind-mount permission escape hatches
(`HERMES_SKIP_CHMOD`, `HERMES_HOME_MODE`) inline so users hit by #1389
or #1399 see the fix in the file they're reading
- The `.env.docker.example` template ships and documents the same vars
- `docs/docker.md` exists and covers the multi-container architecture
- Stale README references to `/root/.hermes` are gone (the agent images
use `/home/hermes/.hermes`)
"""
from __future__ import annotations
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
# ── 1: UID/GID alignment across compose files (PR #1428) ────────────────────
def test_two_container_compose_aligns_agent_uid_with_webui():
"""REGRESSION (#1399, fixed in #1428): the two-container compose file
must align the agent's UID/GID with the webui's. Before #1428 the
agent had no HERMES_UID/HERMES_GID at all and used the image default
of 10000, while the webui used 1000 — bind-mounted files written by
the agent were unreadable by the webui."""
src = (REPO / "docker-compose.two-container.yml").read_text(encoding="utf-8")
# Agent must declare HERMES_UID/HERMES_GID
assert "HERMES_UID=${UID:-1000}" in src, (
"two-container: hermes-agent must set HERMES_UID=${UID:-1000} so it "
"matches the webui's WANTED_UID=${UID:-1000}. Before #1428 the agent "
"ran as the image default (10000), causing PermissionError on the "
"shared hermes-home volume."
)
assert "HERMES_GID=${GID:-1000}" in src, (
"two-container: hermes-agent must set HERMES_GID=${GID:-1000}"
)
# WebUI must use ${UID}/${GID} (same source)
assert "WANTED_UID=${UID:-1000}" in src
assert "WANTED_GID=${GID:-1000}" in src
def test_three_container_compose_aligns_all_three_services():
"""REGRESSION (#1399, fixed in #1428): all three services in the
three-container compose file must use ${UID}/${GID} as the source.
Before #1428 the agent and dashboard defaulted to 10000 while the
webui defaulted to 1000."""
src = (REPO / "docker-compose.three-container.yml").read_text(encoding="utf-8")
# Agent + dashboard both use HERMES_UID/HERMES_GID with ${UID:-1000} as source
# (Two occurrences each — once per service)
assert src.count("HERMES_UID=${UID:-1000}") >= 2, (
"three-container: both hermes-agent and hermes-dashboard must set "
"HERMES_UID=${UID:-1000}"
)
assert src.count("HERMES_GID=${GID:-1000}") >= 2
# WebUI uses WANTED_UID=${UID:-1000}
assert "WANTED_UID=${UID:-1000}" in src
assert "WANTED_GID=${GID:-1000}" in src
# The pre-#1428 default of 10000 must NOT appear anywhere
# (negative-pattern guard prevents revert)
assert "HERMES_UID:-10000" not in src, (
"Pre-#1428 default (HERMES_UID:-10000) must not return — that's the "
"bug shape. All UIDs should pull from ${UID:-1000}."
)
assert "HERMES_GID:-10000" not in src
def test_single_container_compose_uses_same_uid_source():
"""The single-container compose file should use the same ${UID} default
as the multi-container files for consistency."""
src = (REPO / "docker-compose.yml").read_text(encoding="utf-8")
assert "WANTED_UID=${UID:-1000}" in src
assert "WANTED_GID=${GID:-1000}" in src
# ── 2: bind-mount permission escape hatches documented (#1389, #1399) ──────
def test_compose_files_document_skip_chmod_escape_hatch():
"""Every compose file must mention HERMES_SKIP_CHMOD inline so users
hit by #1389 (the auth.json/.env chmod-override bug) can find the fix
in the file they're reading. The fix shipped in v0.50.254 but Docker
users may not be reading CHANGELOGs."""
for fname in ("docker-compose.yml", "docker-compose.two-container.yml", "docker-compose.three-container.yml"):
src = (REPO / fname).read_text(encoding="utf-8")
assert "HERMES_SKIP_CHMOD" in src, (
f"{fname}: must document HERMES_SKIP_CHMOD as a bind-mount "
f"escape hatch so users hit by #1389 find the fix inline."
)
assert "HERMES_HOME_MODE" in src, (
f"{fname}: must document HERMES_HOME_MODE alongside HERMES_SKIP_CHMOD"
)
# ── 3: .env.docker.example exists and documents the same vars ──────────────
def test_env_docker_example_exists():
"""The .env.docker.example template must ship in the repo so users
can `cp .env.docker.example .env` as the first step of the quickstart."""
p = REPO / ".env.docker.example"
assert p.exists(), ".env.docker.example must exist in repo root"
src = p.read_text(encoding="utf-8")
# Must document the critical vars
for var in ("UID", "GID", "HERMES_HOME", "HERMES_WORKSPACE",
"HERMES_WEBUI_PASSWORD", "HERMES_SKIP_CHMOD", "HERMES_HOME_MODE"):
assert var in src, (
f".env.docker.example must document {var} — without it, users "
f"hit by the related failure mode have no in-template hint."
)
# ── 4: docs/docker.md comprehensive guide ──────────────────────────────────
def test_docs_docker_md_exists_and_covers_failure_modes():
"""The docs/docker.md guide must exist and cover the recurring failure
modes seen in #1399, #1389, #858, #681, #668."""
p = REPO / "docs" / "docker.md"
assert p.exists(), "docs/docker.md must exist as the comprehensive guide"
src = p.read_text(encoding="utf-8")
# Must mention each documented failure mode by issue ref
for issue in ("#1389", "#1399", "#858", "#681"):
assert issue in src, (
f"docs/docker.md must reference issue {issue} so users searching "
f"for the symptom find the right diagnostic path."
)
# Must explicitly link the alternate single-container community image
assert "sunnysktsang/hermes-suite" in src, (
"docs/docker.md should point Podman 3.4 / multi-arch users to the "
"community all-in-one image as a documented escape hatch."
)
# ── 5: stale /root/.hermes references removed from README ──────────────────
def test_readme_no_stale_root_hermes_path():
"""REGRESSION: the README's two-container Docker section used to claim
'the agent writes to /root/.hermes' which is wrong — current agent
images use /home/hermes/.hermes. Stale paths confuse users reading
the README to debug their own setup."""
src = (REPO / "README.md").read_text(encoding="utf-8")
assert "/root/.hermes" not in src, (
"README.md must not reference /root/.hermes — the current agent "
"image uses /home/hermes/.hermes. Stale paths in docs are worse "
"than no docs at all."
)
def test_readme_links_to_docker_md():
"""The README Docker section should point at docs/docker.md for the
deep dive so we don't have to keep two copies of the same content
in sync."""
src = (REPO / "README.md").read_text(encoding="utf-8")
assert "docs/docker.md" in src, (
"README.md should reference docs/docker.md so users with deeper "
"needs (multi-container, bind mounts, Podman) find the full guide."
)
# ── 6: compose files all parse as valid YAML ───────────────────────────────
def test_compose_files_parse_as_valid_yaml():
"""Every compose file must parse as valid YAML — without this guard,
a stray indentation or unquoted ${VAR} could ship a broken compose
file that breaks `docker compose up` for everyone."""
import yaml
for fname in ("docker-compose.yml", "docker-compose.two-container.yml",
"docker-compose.three-container.yml"):
path = REPO / fname
try:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except yaml.YAMLError as e:
raise AssertionError(f"{fname} is not valid YAML: {e}")
assert isinstance(data, dict), f"{fname} must parse to a dict"
assert "services" in data, f"{fname} must define a `services:` block"
# ── 7: agent vs webui HERMES_HOME_MODE semantic asymmetry ──────────────────
def test_agent_service_does_not_recommend_invalid_home_mode():
"""REGRESSION (Opus pre-release advisor): the WebUI's HERMES_HOME_MODE
is a credential-file threshold (0640 = allow group bits). The agent's
HERMES_HOME_MODE is a DIRECTORY mode (default 0700). 0640 on a directory
has no owner-execute bit, so the agent can't traverse its own home and
bricks. The agent service blocks must NOT recommend HERMES_HOME_MODE=0640
as their example value."""
import re
BAD_VALUES = (
"HERMES_HOME_MODE=0640",
"HERMES_HOME_MODE=0644",
"HERMES_HOME_MODE=0600",
"HERMES_HOME_MODE=0660",
)
for fname in ("docker-compose.two-container.yml", "docker-compose.three-container.yml"):
src = (REPO / fname).read_text(encoding="utf-8")
# Find each agent/dashboard service block by name and slice to the next
# top-level service or root key.
for service_name in ("hermes-agent", "hermes-dashboard"):
service_marker = " " + service_name + ":"
idx = src.find(service_marker)
if idx == -1:
continue
# Find next service line (2-space-indented name + colon) or root key
after = idx + len(service_marker)
# Match next " name:" at indent 2 or root-level (no indent)
next_match = re.search(r"\n [a-z][a-z0-9-]*:\n|\n[a-z]", src[after:])
block_end = after + next_match.start() if next_match else len(src)
block = src[idx:block_end]
for bad in BAD_VALUES:
assert bad not in block, (
f"{fname} service `{service_name}` recommends `{bad}` — "
f"the agent's HERMES_HOME_MODE applies to the HERMES_HOME "
f"directory, and a mode without owner-execute prevents "
f"traversal. Use 0750 (group-traversable) or 0701 (x-only). "
f"See Opus pre-release advisor finding for v0.50.260."
)
def test_compose_files_warn_about_home_mode_asymmetry():
"""The compose files must explicitly warn about the WebUI vs agent
HERMES_HOME_MODE semantic asymmetry so users don't copy the WebUI's
valid 0640 value into the agent service."""
for fname in ("docker-compose.two-container.yml", "docker-compose.three-container.yml"):
src = (REPO / fname).read_text(encoding="utf-8").lower()
# Look for a comment that distinguishes directory mode from credential file mode
assert "directory" in src and "credential" in src, (
f"{fname} must contain comments explaining that HERMES_HOME_MODE "
f"means different things for the agent (directory mode) vs the "
f"WebUI (credential file threshold)."
)
def test_env_docker_example_warns_about_home_mode_asymmetry():
"""The .env.docker.example template must warn that HERMES_HOME_MODE has
different semantics across services."""
src = (REPO / ".env.docker.example").read_text(encoding="utf-8")
assert "MULTI-CONTAINER WARNING" in src, (
".env.docker.example must include a MULTI-CONTAINER WARNING about "
"the HERMES_HOME_MODE semantic asymmetry between WebUI and agent."
)