From 5cc8b6c654e31cfbd0a8aaee16a9fe6149d11c93 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 17 May 2026 17:18:39 +0000 Subject: [PATCH] docs(docker): document agent-image upgrade flow + read-only WebUI source mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 8 ++ docker-compose.three-container.yml | 24 +++- docker-compose.two-container.yml | 24 +++- docs/docker.md | 46 +++++++- tests/test_docker_docs_and_readonly.py | 154 +++++++++++++++++++++++++ 5 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 tests/test_docker_docs_and_readonly.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe6646c..29f6fd15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docker-compose.three-container.yml b/docker-compose.three-container.yml index 6b373007..4f9ac75d 100644 --- a/docker-compose.three-container.yml +++ b/docker-compose.three-container.yml @@ -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 _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: diff --git a/docker-compose.two-container.yml b/docker-compose.two-container.yml index 5e633256..f5237427 100644 --- a/docker-compose.two-container.yml +++ b/docker-compose.two-container.yml @@ -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 _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: diff --git a/docs/docker.md b/docs/docker.md index ada305b3..4d1a5944 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 _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 _hermes-agent-src +docker compose -f docker-compose.three-container.yml pull +docker compose -f docker-compose.three-container.yml up -d +``` + +Replace `` 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 diff --git a/tests/test_docker_docs_and_readonly.py b/tests/test_docker_docs_and_readonly.py new file mode 100644 index 00000000..87c2dbf6 --- /dev/null +++ b/tests/test_docker_docs_and_readonly.py @@ -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." + )