mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
57c71e89f3
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>
245 lines
10 KiB
Python
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"
|
|
)
|