Files
hermes-webui/tests/test_docker_env_readonly_vars.py
T
nesquena-hermes 57c71e89f3 fix(docker): salvage operational hardening from #1686 (env readonly + apt deps)
Three independent operational hardening fixes salvaged from PR #1686
(@binhpt310) after the parent PR was deferred over a separate sibling-repo
build-context concern unrelated to these fixes:

1. start.sh's .env loader now filters readonly bash vars (UID, GID, EUID,
   EGID, PPID) before `source`-ing.  docker-compose.yml's macOS instructions
   document `echo "UID=$(id -u)" >> .env` to set host UID/GID for bind-mount
   permission fixing — that .env was crashing start.sh with
   `UID: readonly variable` when `set -a; source ...; set +a` tried to
   assign to those names.  Replaced with
   `source <(grep -vE '^[[:space:]]*(export[[:space:]]+)?(UID|GID|EUID|EGID|PPID)=' "${REPO_ROOT}/.env")`.
   The bootstrap regression guard at tests/test_bootstrap_dotenv.py:181
   still passes — both `source` and `.env` are still on the modified line.

2. start.sh now defensively re-execs as the unprivileged hermeswebui user
   when invoked as root.  Fires only when EUID==0 AND a hermeswebui user
   actually exists AND sudo is on PATH — so it's a no-op on host machines
   without the container user setup.  The production image's entrypoint
   (docker_init.bash) already drops to hermeswebui before invoking start.sh,
   so this is a no-op on the canonical container path; it only matters for
   `sudo ./start.sh` or accidental root shells inside the container during
   interactive debugging.

3. Dockerfile installs xz-utils + git apt packages.  xz-utils is required
   to decompress .tar.xz archives (e.g. Node.js distribution tarballs);
   git is needed for `git describe` (powers WEBUI_VERSION resolution at
   api/updates.py:_detect_webui_version) and any clone-based agent install
   path.  Both are tiny apt packages on top of python:3.12-slim with no
   measurable image-size impact.

What's NOT in this commit (deferred from #1686):

- Pre-baking hermes-agent source into the image via
  `COPY hermes-agent-desktop/hermes-agent /opt/hermes/` plus a build-context
  flip to `..`.  Requires a sibling-repo layout that breaks the canonical
  `git clone hermes-webui && cd hermes-webui && docker compose build` flow.
  The right shape is a build arg gating the COPY behind
  --build-arg WITH_AGENT_SOURCE=1; left to a separate PR.
- Pre-installing Node.js 22 LTS system-wide.  Real motivation but worth
  evaluating the fix shape (full Node bake vs. opt-in vs. layer cache)
  separately from these three operational fixes.

Tests: tests/test_docker_env_readonly_vars.py — 11 tests (4 source-grep
on the start.sh filter pattern + 5 behavioral that actually run bash
against synthetic .env files containing readonly vars + 2 Dockerfile
package-presence tests).  All 11 pass.  Behavioral tests skip if bash
is not on PATH.

Full suite: 5028 → 5036 passing (+8 net new after pytest collection
counted some behavioral tests under skip), 0 regressions, 147.84s.

Closes the operational-hardening portion of #1686.

Co-authored-by: binhpt310 <binhpt310@users.noreply.github.com>
2026-05-09 19:17:34 +00:00

245 lines
10 KiB
Python

"""Regression tests for start.sh's .env parsing handling readonly bash variables.
Background: docker-compose.yml's macOS instructions document
``echo "UID=$(id -u)" >> .env`` to set host UID/GID for bind-mount permission
fixing. The repo-level .env file is then read by both:
1. ``docker-compose.yml`` itself (for ${UID}/${GID} variable substitution)
2. ``start.sh`` (which `source`s the .env to load HERMES_WEBUI_* settings)
3. ``bootstrap.py`` (via ``_load_repo_dotenv()``)
The old ``set -a; source "${REPO_ROOT}/.env"; set +a`` pattern in start.sh
crashed with ``UID: readonly variable`` when the .env carried UID/GID lines —
because bash treats UID/GID/EUID/EGID/PPID as read-only. The fix filters
those readonly vars out of the source stream while leaving them intact in the
.env file for docker-compose's substitution.
Sourced from PR #1686 (@binhpt310) — extracted to a focused follow-up after
the parent PR was deferred over an unrelated sibling-repo build-context concern.
These tests pin:
- The filter pattern is present in start.sh
- The ``source`` + ``.env`` regression guard at
test_bootstrap_dotenv.py:181 still passes (both keywords present)
- All five readonly-name forms (UID, GID, EUID, EGID, PPID) are caught
- The optional ``export`` prefix on those names is also caught
- Non-readonly KEY=value lines in .env still load
"""
import re
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
START_SH = (REPO_ROOT / "start.sh").read_text(encoding="utf-8")
class TestStartShReadonlyEnvFilter:
"""Pin start.sh's .env parser against the docker-compose macOS UID/GID flow."""
def test_start_sh_still_sources_env_regression_guard(self):
"""The bootstrap regression guard at test_bootstrap_dotenv.py:181
requires ``source`` AND ``.env`` to both appear in start.sh. After
the readonly-vars filter, both must still be present."""
assert "source" in START_SH, (
"start.sh must still call `source` to load .env "
"(regression guard, see tests/test_bootstrap_dotenv.py:181)"
)
assert ".env" in START_SH, (
"start.sh must still reference .env path "
"(regression guard, see tests/test_bootstrap_dotenv.py:181)"
)
def test_readonly_vars_filtered_before_source(self):
"""The readonly bash names (UID/GID/EUID/EGID/PPID) must be filtered
out of the .env stream before `source` reads it. The filter is a
``grep -vE`` against the .env file."""
# The filter must mention all five readonly names.
for var in ("UID", "GID", "EUID", "EGID", "PPID"):
assert var in START_SH, (
f"start.sh's .env filter must mention readonly var {var!r} "
"so that bash assignment to it does not crash with "
f"'{var}: readonly variable'"
)
def test_filter_pattern_uses_grep_or_equivalent(self):
"""Filter must use a pattern that strips readonly-var lines before
the bash `source` consumes them. `grep -vE` is the canonical form;
the assertion accepts any process-substitution-into-source shape."""
# Look for `source <(...UID...)` pattern. Note that the inner shell
# expression can contain its own parens (e.g. `(export[[:space:]]+)`),
# so we use a non-greedy `.*?` rather than `[^)]*`.
assert re.search(
r"source\s+<\(.*?UID.*?\)",
START_SH,
re.DOTALL,
), (
"start.sh's .env loader must filter readonly bash vars "
"(UID/GID/EUID/EGID/PPID) via `source <(grep -vE ...)` or "
"equivalent process-substitution form before `source`-ing "
"the .env file"
)
def test_filter_handles_optional_export_prefix(self):
"""The ``export`` prefix on env vars is optional but common. The
readonly-var filter must catch both bare and exported forms."""
assert "export" in START_SH, (
"start.sh's .env filter must account for the optional `export` "
"prefix on readonly-var assignments (e.g. `export UID=501`), "
"otherwise bash will still crash on the assignment"
)
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not available")
class TestStartShReadonlyEnvFilterBehavioral:
"""Behavioral tests — actually run bash to verify .env parsing succeeds.
These tests extract the .env loader block from start.sh and run it
against synthetic .env files. They guard against shell-quoting
regressions in the filter pattern itself (which the source-grep tests
above can't catch on their own).
"""
@staticmethod
def _extract_env_loader(start_sh: str) -> str:
"""Pull out the `if [[ -f "${REPO_ROOT}/.env" ]] ... fi` block."""
# Find the if-block with .env in it.
m = re.search(
r'(if \[\[ -f "\$\{REPO_ROOT\}/\.env" \]\]; then.*?^fi)\n',
start_sh,
re.DOTALL | re.MULTILINE,
)
assert m is not None, "could not locate .env loader block in start.sh"
return m.group(1)
def _run_loader(self, env_contents: str, tmp_path: Path) -> subprocess.CompletedProcess:
"""Write ``env_contents`` to a tmp .env and run start.sh's loader against it."""
env_file = tmp_path / ".env"
env_file.write_text(env_contents, encoding="utf-8")
loader = self._extract_env_loader(START_SH)
# Wrap loader in a tiny bash script that points REPO_ROOT at tmp_path
# and then echoes a few keys we care about.
script = textwrap.dedent(f"""\
set -euo pipefail
REPO_ROOT={str(tmp_path)!r}
{loader}
# Print loaded values (or "unset") for the test to assert against.
echo "PORT=${{HERMES_WEBUI_PORT:-unset}}"
echo "SOME=${{SOME_KEY:-unset}}"
echo "ANOTHER=${{ANOTHER:-unset}}"
echo "EXIT_OK"
""")
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
timeout=10,
)
def test_env_with_readonly_uid_gid_does_not_crash(self, tmp_path):
"""The exact macOS docker-compose pattern: UID + GID in .env."""
env_contents = textwrap.dedent("""\
UID=501
GID=20
HERMES_WEBUI_PORT=8888
SOME_KEY=normal-value
""")
result = self._run_loader(env_contents, tmp_path)
assert "EXIT_OK" in result.stdout, (
f"loader crashed on .env with readonly UID/GID. "
f"stderr: {result.stderr!r}"
)
assert "readonly variable" not in result.stderr, (
f".env loader still triggered readonly-variable crash: "
f"{result.stderr!r}"
)
# Non-readonly keys must still load.
assert "PORT=8888" in result.stdout
assert "SOME=normal-value" in result.stdout
def test_env_with_exported_readonly_does_not_crash(self, tmp_path):
"""`export UID=501` form must also be filtered."""
env_contents = textwrap.dedent("""\
export UID=501
export GID=20
HERMES_WEBUI_PORT=9000
""")
result = self._run_loader(env_contents, tmp_path)
assert "EXIT_OK" in result.stdout
assert "readonly variable" not in result.stderr
assert "PORT=9000" in result.stdout
def test_all_five_readonly_names_filtered(self, tmp_path):
"""UID, GID, EUID, EGID, PPID — all five must be filtered."""
env_contents = textwrap.dedent("""\
UID=501
GID=20
EUID=501
EGID=20
PPID=12345
HERMES_WEBUI_PORT=7777
""")
result = self._run_loader(env_contents, tmp_path)
assert "EXIT_OK" in result.stdout, (
f"loader crashed; stderr: {result.stderr!r}"
)
assert "readonly variable" not in result.stderr
assert "PORT=7777" in result.stdout
def test_normal_env_still_loads(self, tmp_path):
"""A .env without readonly vars must still load all keys."""
env_contents = textwrap.dedent("""\
HERMES_WEBUI_PORT=8787
SOME_KEY=hello
ANOTHER=world
""")
result = self._run_loader(env_contents, tmp_path)
assert "EXIT_OK" in result.stdout
assert "PORT=8787" in result.stdout
assert "SOME=hello" in result.stdout
assert "ANOTHER=world" in result.stdout
def test_export_prefix_strips_correctly(self, tmp_path):
"""`export FOO=bar` (non-readonly) loads `FOO=bar` after `set -a; source`."""
env_contents = textwrap.dedent("""\
UID=501
export ANOTHER=exported-value
HERMES_WEBUI_PORT=6543
""")
result = self._run_loader(env_contents, tmp_path)
assert "EXIT_OK" in result.stdout
assert "ANOTHER=exported-value" in result.stdout
assert "PORT=6543" in result.stdout
class TestDockerfileSystemPackages:
"""Pin Dockerfile system-package dependencies (#1686 Cluster 1)."""
def test_dockerfile_installs_xz_utils(self):
"""xz-utils is required to extract xz-compressed tarballs (e.g.
Node.js distribution archives) — without it, agent install paths
that download xz-compressed deps fail with `xz: Cannot exec`."""
dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8")
assert re.search(r"\bxz-utils\b", dockerfile), (
"Dockerfile must install xz-utils (apt package) — without it, "
"any tarball decompression of .tar.xz files fails with "
"`xz: Cannot exec: No such file or directory`"
)
def test_dockerfile_installs_git(self):
"""git is needed for any agent-install path that clones a repo, plus
for the runtime ``git describe`` that powers WEBUI_VERSION detection
in non-baked images."""
dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8")
assert re.search(r"^\s*git\s*\\?\s*$", dockerfile, re.MULTILINE), (
"Dockerfile must install git (apt package) — required for "
"version detection (`git describe`) and any agent install path "
"that clones a repo"
)