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:
@@ -2,6 +2,14 @@
|
||||
|
||||
## [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)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -109,13 +109,15 @@ services:
|
||||
# Same hermes home as the agent — shares config, sessions, state
|
||||
- hermes-home:/home/hermeswebui/.hermes
|
||||
# Agent source mounted where docker_init.bash expects it.
|
||||
# At startup the init script runs:
|
||||
# uv pip install /home/hermeswebui/.hermes/hermes-agent
|
||||
# which installs the agent and all its Python dependencies.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
# Mounted read-only — the WebUI only reads this volume to install
|
||||
# the agent's Python dependencies at startup (`uv pip install`).
|
||||
# Read-only enforces that defence-in-depth at the kernel layer.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# 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:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
@@ -150,5 +152,17 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
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-agent-src:
|
||||
|
||||
@@ -91,14 +91,16 @@ services:
|
||||
# Same hermes home as the agent — shares config, sessions, state
|
||||
- hermes-home:/home/hermeswebui/.hermes
|
||||
# Agent source mounted where docker_init.bash expects it.
|
||||
# At startup the init script runs:
|
||||
# uv pip install /home/hermeswebui/.hermes/hermes-agent
|
||||
# which installs the agent and all its Python dependencies.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent
|
||||
# Mounted read-only — the WebUI only reads this volume to install
|
||||
# the agent's Python dependencies at startup (`uv pip install`).
|
||||
# Read-only enforces that defence-in-depth at the kernel layer.
|
||||
- hermes-agent-src:/home/hermeswebui/.hermes/hermes-agent:ro
|
||||
# Workspace directory — browse and edit files from the WebUI.
|
||||
# Adapt the host path to your project directory.
|
||||
# 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:
|
||||
- HERMES_WEBUI_HOST=0.0.0.0
|
||||
- HERMES_WEBUI_PORT=8787
|
||||
@@ -129,5 +131,17 @@ networks:
|
||||
driver: bridge
|
||||
|
||||
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-agent-src:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -205,6 +248,7 @@ volumes:
|
||||
|
||||
## 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`)
|
||||
- #1399 — UID alignment in compose files (fixed in v0.50.260 via PR #1428 + this guide)
|
||||
- #858 — two-container `/opt/hermes` path confusion
|
||||
|
||||
154
tests/test_docker_docs_and_readonly.py
Normal file
154
tests/test_docker_docs_and_readonly.py
Normal 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."
|
||||
)
|
||||
Reference in New Issue
Block a user