docs(docker): document agent-image upgrade flow + read-only WebUI source mount

The hermes-agent-src named volume in the two- and three-container compose
files is initialised from the agent image's /opt/hermes on first `up` and
Docker reuses it verbatim on every subsequent `up` — even after a fresh
`docker pull` of the agent image. This was the root cause of #1416 (the
'missing entrypoint' symptom was a stale cached volume hiding the new
image's source tree).

Changes:

- Add an 'Upgrading the agent container' section to docs/docker.md with
  the canonical `down → docker volume rm → pull → up -d` recipe, plus the
  same pointer as a comment block in both multi-container compose files
  near the volume declarations.
- Switch the WebUI's hermes-agent-src mount to `:ro` in both multi-container
  compose files. The WebUI only reads this volume to install the agent's
  Python deps at startup; mounting it read-only enforces that at the kernel
  layer and brings the actual mount mode in line with the existing
  docs/docker.md architecture diagram (which already labelled this edge as
  read-only).
- Align the workspace bind default in both multi-container compose files
  with the single-container convention — `${HERMES_WORKSPACE:-${HOME}/workspace}`
  instead of `${HERMES_WORKSPACE:-~/workspace}` — so the default resolves
  the same way across Linux, macOS, WSL2, and Docker Desktop on Windows.
- Add a 'What the multi-container setup isolates (and what it doesn't)'
  section to docs/docker.md to frame the two/three-container setups as
  process/network/resource isolation, not filesystem isolation, so users
  don't reach for multi-container expecting a trust boundary it doesn't
  provide.
- Cross-link #1416 from the Related issues section.

Adds 9 regression tests in tests/test_docker_docs_and_readonly.py covering:
- :ro on the WebUI side of hermes-agent-src in both files
- agent side stays read-write (still needs to populate /opt/hermes on first run)
- ${HOME} (not ~) in workspace bind defaults in both files
- single-container file already uses ${HOME} (pin to prevent drift)
- docs/docker.md has the 'Upgrading the agent container' section + recipe
- compose files reference docs/docker.md + show the upgrade step inline
- docs/docker.md frames the isolation model honestly

Test suite: 42 passed (33 existing Docker tests + 9 new). No behaviour
change for users who set HERMES_WORKSPACE explicitly, and no migration is
required for existing deployments — Docker rebinds the existing volume
read-only on next `up`. Users upgrading the agent image should now follow
the documented `docker volume rm hermes-agent-src` recipe.

Closes #1416 (documented upgrade procedure) and addresses the read-only
half of the multi-container coupling concern raised on #2453.
This commit is contained in:
nesquena-hermes
2026-05-17 17:18:39 +00:00
parent 522efc2921
commit 5cc8b6c654
5 changed files with 245 additions and 11 deletions

View File

@@ -2,6 +2,14 @@
## [Unreleased] ## [Unreleased]
### Changed
- **PR (TBD)** — Multi-container Docker hygiene pass. The `hermes-agent-src` named volume in `docker-compose.two-container.yml` and `docker-compose.three-container.yml` is now mounted **read-only** on the WebUI service (the WebUI only reads it to install the agent's Python dependencies at startup), bringing the actual mount mode in line with the existing `docs/docker.md` architecture diagram. The default `${HERMES_WORKSPACE:-~/workspace}` workspace bind is changed to `${HERMES_WORKSPACE:-${HOME}/workspace}` so the path resolves consistently across Linux, macOS, WSL2, and Docker Desktop on Windows (matching the single-container `docker-compose.yml` convention). No behaviour change for users who set `HERMES_WORKSPACE` explicitly.
### Documentation
- **PR (TBD)** — `docs/docker.md` gains an **"Upgrading the agent container"** section documenting the root cause of [#1416](https://github.com/nesquena/hermes-webui/issues/1416): the `hermes-agent-src` named volume caches the agent's `/opt/hermes` source tree on first run, and Docker reuses the cached volume on every subsequent `compose up` — even after `docker pull` of a newer agent image. The new section gives the canonical `down → docker volume rm → pull → up -d` recipe and the same upgrade pointer is mirrored as a comment block in both multi-container compose files. A new **"What the multi-container setup isolates (and what it doesn't)"** section explicitly frames the two/three-container setups as **process, network, and resource isolation, not filesystem isolation** — calibrating expectations for users who reach for multi-container expecting a trust boundary between the chat UI and the agent.
## [v0.51.83] — 2026-05-17 — Release BG (stage-376 — 12-PR contributor batch — chat-start adapter parity + populated-core journal recovery + thinking card dedup + context metadata refresh + model cache fingerprint + stream fade cap + manual cron delivery + active-session spinner + email gateway label + thinking copy button + /theme i18n + compact activity semantics) ## [v0.51.83] — 2026-05-17 — Release BG (stage-376 — 12-PR contributor batch — chat-start adapter parity + populated-core journal recovery + thinking card dedup + context metadata refresh + model cache fingerprint + stream fade cap + manual cron delivery + active-session spinner + email gateway label + thinking copy button + /theme i18n + compact activity semantics)
### Added ### Added

View File

@@ -109,13 +109,15 @@ services:
# Same hermes home as the agent — shares config, sessions, state # Same hermes home as the agent — shares config, sessions, state
- hermes-home:/home/hermeswebui/.hermes - hermes-home:/home/hermeswebui/.hermes
# Agent source mounted where docker_init.bash expects it. # Agent source mounted where docker_init.bash expects it.
# At startup the init script runs: # Mounted read-only — the WebUI only reads this volume to install
# uv pip install /home/hermeswebui/.hermes/hermes-agent # the agent's Python dependencies at startup (`uv pip install`).
# which installs the agent and all its Python dependencies. # Read-only enforces that defence-in-depth at the kernel layer.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
# Workspace directory — browse and edit files from the WebUI. # Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory. # Adapt the host path to your project directory.
- ${HERMES_WORKSPACE:-~/workspace}:/workspace # ${HOME} is used rather than `~` so the default resolves the same way
# across Linux, macOS, WSL2, and Docker Desktop on Windows.
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
environment: environment:
- HERMES_WEBUI_HOST=0.0.0.0 - HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787 - HERMES_WEBUI_PORT=8787
@@ -150,5 +152,17 @@ networks:
driver: bridge driver: bridge
volumes: volumes:
# IMPORTANT — upgrading the agent image:
# The `hermes-agent-src` volume is initialised from the agent image's
# `/opt/hermes` on first `up`, and Docker reuses the volume verbatim on
# later runs — even after `docker pull` of a newer agent image. After
# upgrading the agent image, run:
#
# docker compose -f docker-compose.three-container.yml down
# docker volume rm <project>_hermes-agent-src
# docker compose -f docker-compose.three-container.yml pull
# docker compose -f docker-compose.three-container.yml up -d
#
# The full procedure (and why) is documented in docs/docker.md.
hermes-home: hermes-home:
hermes-agent-src: hermes-agent-src:

View File

@@ -91,14 +91,16 @@ services:
# Same hermes home as the agent — shares config, sessions, state # Same hermes home as the agent — shares config, sessions, state
- hermes-home:/home/hermeswebui/.hermes - hermes-home:/home/hermeswebui/.hermes
# Agent source mounted where docker_init.bash expects it. # Agent source mounted where docker_init.bash expects it.
# At startup the init script runs: # Mounted read-only — the WebUI only reads this volume to install
# uv pip install /home/hermeswebui/.hermes/hermes-agent # the agent's Python dependencies at startup (`uv pip install`).
# which installs the agent and all its Python dependencies. # Read-only enforces that defence-in-depth at the kernel layer.
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent - hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
# Workspace directory — browse and edit files from the WebUI. # Workspace directory — browse and edit files from the WebUI.
# Adapt the host path to your project directory. # Adapt the host path to your project directory.
# Override with: HERMES_WORKSPACE=/your/path docker compose up # Override with: HERMES_WORKSPACE=/your/path docker compose up
- ${HERMES_WORKSPACE:-~/workspace}:/workspace # ${HOME} is used rather than `~` so the default resolves the same way
# across Linux, macOS, WSL2, and Docker Desktop on Windows.
- ${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace
environment: environment:
- HERMES_WEBUI_HOST=0.0.0.0 - HERMES_WEBUI_HOST=0.0.0.0
- HERMES_WEBUI_PORT=8787 - HERMES_WEBUI_PORT=8787
@@ -129,5 +131,17 @@ networks:
driver: bridge driver: bridge
volumes: volumes:
# IMPORTANT — upgrading the agent image:
# The `hermes-agent-src` volume is initialised from the agent image's
# `/opt/hermes` on first `up`, and Docker reuses the volume verbatim on
# later runs — even after `docker pull` of a newer agent image. After
# upgrading the agent image, run:
#
# docker compose down
# docker volume rm <project>_hermes-agent-src
# docker compose pull
# docker compose up -d
#
# The full procedure (and why) is documented in docs/docker.md.
hermes-home: hermes-home:
hermes-agent-src: hermes-agent-src:

View File

@@ -166,7 +166,50 @@ The two- and three-container setups use **named Docker volumes** (not bind mount
└─────────────────────────┘ └─────────────────────────┘
``` ```
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. 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. The WebUI mount is read-only; the agent container is the only writer.
## Upgrading the agent container
The `hermes-agent-src` named volume is initialised from the agent image's `/opt/hermes` on first `up`. Docker reuses the volume verbatim on every subsequent `up`**even after `docker pull` of a newer agent image**. The cached volume content masks the new image's source tree, so a fresh `docker pull` of `nousresearch/hermes-agent:latest` does not by itself give you the new agent code, dependencies, or entrypoint.
This is the root cause of [#1416](https://github.com/nesquena/hermes-webui/issues/1416): the symptom looked like a missing entrypoint, but the entrypoint was actually present in the new image and hidden behind the stale named volume.
To upgrade the agent image cleanly, drop the source volume before recreating:
```bash
# Two-container setup
docker compose -f docker-compose.two-container.yml down
docker volume rm <project>_hermes-agent-src
docker compose -f docker-compose.two-container.yml pull
docker compose -f docker-compose.two-container.yml up -d
# Three-container setup
docker compose -f docker-compose.three-container.yml down
docker volume rm <project>_hermes-agent-src
docker compose -f docker-compose.three-container.yml pull
docker compose -f docker-compose.three-container.yml up -d
```
Replace `<project>` with your Compose project name (the parent directory by default; check with `docker volume ls`). The `hermes-home` volume (config, sessions, state) is left untouched — only `hermes-agent-src` (the agent's installed Python source) is recreated.
> The single-container setup (`docker-compose.yml`) does not use `hermes-agent-src` and is not affected by this upgrade pattern — pulling a newer WebUI image and `docker compose up -d --force-recreate` is sufficient.
## What the multi-container setup isolates (and what it doesn't)
The two- and three-container setups give you **process, network, and resource isolation** between the gateway and the chat UI:
- Each service has its own PID namespace and lifecycle — the agent process can crash without taking down the chat UI and vice versa.
- The gateway API (port 8642) is bound by the agent service only; the WebUI cannot bind it. Other containers reach the gateway via the `hermes-net` Docker network.
- Resource limits (`deploy.resources.limits` in `docker-compose.three-container.yml`) apply per service, so you can cap the agent independently of the dashboard.
- Restart policies, log streams, and container health checks are scoped per service.
What multi-container does **not** isolate:
- **Filesystem boundary.** Both services share `hermes-home` (config, sessions, state), and the WebUI mounts the agent's installed source from `hermes-agent-src`. The WebUI mount is read-only (since v0.51.84), but the agent service still has write access, and both services share the home volume.
- **UID/GID boundary.** Both services default to `${UID:-1000}` so files written by one are readable by the other. If you align them to different UIDs you'll get permission errors on the shared volume.
- **Trust boundary on the agent source.** The WebUI installs Python dependencies from the shared `hermes-agent-src` volume at startup. The read-only mount means a compromised WebUI cannot rewrite the agent source, but it does run code from that volume.
If you need **filesystem isolation** between the chat UI and the agent (e.g. you don't trust the WebUI to read agent state), the multi-container setup is not enough — run the agent on a separate host and connect the WebUI to it via the gateway HTTP API. If you don't need any boundary, the single-container setup is simpler.
## Bind-mount migration (advanced) ## Bind-mount migration (advanced)
@@ -205,6 +248,7 @@ volumes:
## Related issues ## Related issues
- #1416 — agent-image upgrade requires removing `hermes-agent-src` named volume (see [Upgrading the agent container](#upgrading-the-agent-container))
- #1389`HERMES_HOME_MODE` override (fixed in v0.50.254 — agent honors `HERMES_SKIP_CHMOD` and `HERMES_HOME_MODE`) - #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) - #1399 — UID alignment in compose files (fixed in v0.50.260 via PR #1428 + this guide)
- #858 — two-container `/opt/hermes` path confusion - #858 — two-container `/opt/hermes` path confusion

View File

@@ -0,0 +1,154 @@
"""Regression tests for the Docker docs+readonly hygiene PR (post v0.51.83).
Pins three invariants:
1. The `hermes-agent-src` named volume is mounted READ-ONLY on the WebUI
service in both multi-container compose files. The WebUI only reads it to
install agent Python deps at startup; this is defence-in-depth against a
compromised WebUI writing into the agent's source tree (Concern raised by
RustyLopez on #2453 and #1416).
2. The workspace bind-mount default uses `${HOME}/workspace` (not `~/workspace`)
in both multi-container compose files, matching the single-container
convention so `~`/`${HOME}` doesn't disagree across Linux, macOS, WSL2, and
Docker Desktop on Windows.
3. `docs/docker.md` documents the agent-image upgrade procedure (`docker volume
rm hermes-agent-src`) — the root cause of #1416.
"""
from __future__ import annotations
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
# ── 1: hermes-agent-src must be read-only on the WebUI mount ────────────────
def test_two_container_webui_mounts_agent_src_readonly():
"""The WebUI only reads the agent source to install Python deps. Mounting
read-only enforces that at the kernel layer — a compromised WebUI process
cannot rewrite the agent source it then imports."""
src = (REPO / "docker-compose.two-container.yml").read_text(encoding="utf-8")
assert (
"hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro" in src
), (
"two-container: the WebUI must mount hermes-agent-src with :ro. "
"Without :ro, a compromised WebUI process can rewrite the agent's "
"Python source tree."
)
def test_three_container_webui_mounts_agent_src_readonly():
src = (REPO / "docker-compose.three-container.yml").read_text(encoding="utf-8")
assert (
"hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro" in src
), (
"three-container: the WebUI must mount hermes-agent-src with :ro."
)
def test_agent_service_keeps_writable_agent_src_mount():
"""The agent SERVICE writes the source tree to the volume on first up.
It must stay read-write — only the WebUI side is read-only."""
for fn in ("docker-compose.two-container.yml", "docker-compose.three-container.yml"):
src = (REPO / fn).read_text(encoding="utf-8")
# The agent's mount is `hermes-agent-src:/opt/hermes` (no :ro suffix).
# Look for the line that has /opt/hermes without :ro.
agent_lines = [
line for line in src.splitlines()
if "hermes-agent-src:/opt/hermes" in line
]
assert agent_lines, f"{fn}: agent must mount hermes-agent-src at /opt/hermes"
for line in agent_lines:
assert not line.rstrip().endswith(":ro"), (
f"{fn}: agent's hermes-agent-src mount must be writable "
f"(it populates /opt/hermes on first run): {line!r}"
)
# ── 2: ${HOME} (not ~) in workspace bind defaults ───────────────────────────
def test_two_container_workspace_uses_home_env_var():
"""Compose v2 expands `~` differently than `${HOME}` under sudo, on Docker
Desktop on Windows, and on some NAS appliances. Use `${HOME}` to match the
single-container `docker-compose.yml` and avoid platform drift."""
src = (REPO / "docker-compose.two-container.yml").read_text(encoding="utf-8")
assert "${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace" in src, (
"two-container: workspace default must use ${HOME}/workspace, not ~/workspace, "
"to match docker-compose.yml's single-container convention."
)
assert "${HERMES_WORKSPACE:-~/workspace}" not in src, (
"two-container: tilde-form workspace default still present — change to ${HOME}/workspace."
)
def test_three_container_workspace_uses_home_env_var():
src = (REPO / "docker-compose.three-container.yml").read_text(encoding="utf-8")
assert "${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace" in src, (
"three-container: workspace default must use ${HOME}/workspace, not ~/workspace."
)
assert "${HERMES_WORKSPACE:-~/workspace}" not in src
def test_single_container_workspace_already_uses_home_env_var():
"""Sanity: the single-container file has used ${HOME} all along; pin it
so it doesn't drift back."""
src = (REPO / "docker-compose.yml").read_text(encoding="utf-8")
assert "${HERMES_WORKSPACE:-${HOME}/workspace}:/workspace" in src
# ── 3: docs/docker.md documents the agent-image upgrade procedure ──────────
def test_docker_md_documents_agent_image_upgrade():
"""The `hermes-agent-src` named volume caches the agent source on first
`up` and is reused verbatim on every subsequent `up`, even after a fresh
`docker pull` of the agent image. This is the root cause of #1416. The
docs must give users the explicit `docker volume rm` recipe so they don't
misdiagnose 'missing entrypoint' errors."""
docs = (REPO / "docs" / "docker.md").read_text(encoding="utf-8")
assert "Upgrading the agent container" in docs, (
"docs/docker.md must have an 'Upgrading the agent container' section."
)
assert "docker volume rm" in docs, (
"docs/docker.md must show the `docker volume rm` step in the upgrade recipe."
)
assert "hermes-agent-src" in docs
# Cross-reference to the original issue so users searching for the
# symptom land in the right place
assert "#1416" in docs
def test_compose_files_point_to_docker_md_for_upgrades():
"""Both multi-container compose files should reference docs/docker.md
near the named-volumes block so anyone reading the compose file directly
finds the upgrade procedure."""
for fn in ("docker-compose.two-container.yml", "docker-compose.three-container.yml"):
src = (REPO / fn).read_text(encoding="utf-8")
assert "docs/docker.md" in src, (
f"{fn}: must reference docs/docker.md so users reading the compose "
f"file see the agent upgrade pointer."
)
assert "docker volume rm" in src, (
f"{fn}: must show the `docker volume rm` upgrade step inline."
)
# ── 4: docs/docker.md frames the isolation model honestly ──────────────────
def test_docker_md_documents_isolation_model():
"""The multi-container setups give process + network + resource isolation
but NOT filesystem isolation. Document that explicitly so users don't
reach for multi-container expecting a trust boundary it doesn't provide
(RustyLopez's concern on #2453)."""
docs = (REPO / "docs" / "docker.md").read_text(encoding="utf-8")
assert "What the multi-container setup isolates" in docs, (
"docs/docker.md must have a section calibrating multi-container "
"isolation expectations — process/network/resource isolation, NOT "
"filesystem isolation."
)