Files
hermes-webui/tests/test_docker_docs_and_readonly.py
nesquena-hermes 5cc8b6c654 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.
2026-05-17 17:18:39 +00:00

155 lines
6.9 KiB
Python

"""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."
)