"""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" def test_multi_container_webui_points_at_gateway_service(): """REGRESSION (#4483): the multi-container WebUI service needs a compose network URL for the gateway container. Cron listing reads shared state, but scheduled ticking and the Tasks/System gateway health pill need a reachable gateway base URL from inside the WebUI container.""" import yaml for fname in ("docker-compose.two-container.yml", "docker-compose.three-container.yml"): path = REPO / fname data = yaml.safe_load(path.read_text(encoding="utf-8")) env = data["services"]["hermes-webui"]["environment"] assert "HERMES_API_URL=http://hermes-agent:8642" in env, ( f"{fname}: hermes-webui must point at hermes-agent over the compose " "network so gateway health and scheduled ticking work in multi-container setups." ) # ── 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