291 lines
13 KiB
Python
291 lines
13 KiB
Python
"""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."
|
|
)
|
|
|
|
|
|
# ── 8: Docker localhost and sudo-compose troubleshooting (#3006, #3012) ────
|
|
|
|
|
|
def test_docs_docker_warns_about_sudo_compose_home_expansion():
|
|
"""REGRESSION (#3006): the Docker guide must explain that
|
|
`sudo docker compose` can expand `${HOME}` to `/root`, mounting the wrong
|
|
`.hermes` directory into the container."""
|
|
src = (REPO / "docs" / "docker.md").read_text(encoding="utf-8")
|
|
assert "sudo docker compose" in src
|
|
assert "/root/.hermes" in src
|
|
assert "sudo -E docker compose" in src
|
|
assert "docker group" in src
|
|
|
|
|
|
def test_onboarding_docs_cover_linux_host_gateway_for_container_localhost():
|
|
"""REGRESSION (#3012): local-provider onboarding docs must include the
|
|
Linux Docker host-gateway shape, not only Docker Desktop's
|
|
`host.docker.internal` shortcut."""
|
|
src = (REPO / "docs" / "onboarding.md").read_text(encoding="utf-8")
|
|
assert "host.docker.internal" in src
|
|
assert "host-gateway" in src
|
|
assert "extra_hosts" in src
|
|
assert "api.local" in src
|
|
assert "localhost" in src and "container" in src
|