diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 00000000..a2349bdf --- /dev/null +++ b/.env.docker.example @@ -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. diff --git a/.gitignore b/.gitignore index b1e74b2c..bd026e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a9dda7..085b1464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 12f5787f..bdf57283 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.three-container.yml b/docker-compose.three-container.yml index 71f8466e..6b373007 100644 --- a/docker-compose.three-container.yml +++ b/docker-compose.three-container.yml @@ -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 diff --git a/docker-compose.two-container.yml b/docker-compose.two-container.yml index bb4a02a6..5e633256 100644 --- a/docker-compose.two-container.yml +++ b/docker-compose.two-container.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index d9e71df6..3428ba4a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 - diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 00000000..04c1bc3e --- /dev/null +++ b/docs/docker.md @@ -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 diff --git a/tests/test_v050260_docker_invariants.py b/tests/test_v050260_docker_invariants.py new file mode 100644 index 00000000..f773e9d2 --- /dev/null +++ b/tests/test_v050260_docker_invariants.py @@ -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." + )