diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 1e8af197de9..60b4bb30c18 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -33,6 +33,15 @@ INSTALL_DIR="/opt/hermes" mkdir -p "$HERMES_HOME" # --- UID/GID remap --- +# Accept PUID/PGID as aliases for HERMES_UID/HERMES_GID. NAS users (UGOS, +# Synology, unRAID) expect the LinuxServer.io PUID/PGID convention and +# bind-mount /opt/data from a host directory owned by their own UID; without +# this alias those vars are silently ignored and the s6-setuidgid drop to +# UID 10000 leaves the runtime unable to read the volume. HERMES_UID/ +# HERMES_GID still win when both are set. See #15290, salvages #25872. +HERMES_UID="${HERMES_UID:-${PUID:-}}" +HERMES_GID="${HERMES_GID:-${PGID:-}}" + if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then echo "[stage2] Changing hermes UID to $HERMES_UID" usermod -u "$HERMES_UID" hermes diff --git a/tests/tools/test_stage2_hook_puid_pgid.py b/tests/tools/test_stage2_hook_puid_pgid.py new file mode 100644 index 00000000000..ee45ebfba89 --- /dev/null +++ b/tests/tools/test_stage2_hook_puid_pgid.py @@ -0,0 +1,86 @@ +"""Contract test: the s6-overlay stage2 hook accepts PUID/PGID as aliases for +HERMES_UID/HERMES_GID. + +Regression guard for #15290. NAS platforms (UGOS, Synology, unRAID) bind-mount +/opt/data from a host directory owned by the user's own UID and expect the +LinuxServer.io PUID/PGID convention. Without the alias those vars are silently +ignored, the s6-setuidgid drop lands on UID 10000, and the runtime cannot read +the volume. HERMES_UID/HERMES_GID must still take precedence when both are +set. + +The s6-overlay rework moved bootstrap from docker/entrypoint.sh (now a shim) +to docker/stage2-hook.sh, which is installed as /etc/cont-init.d/01-hermes-setup +by the Dockerfile. This test targets the post-rework location. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh" + + +@pytest.fixture(scope="module") +def stage2_text() -> str: + if not STAGE2_HOOK.exists(): + pytest.skip("docker/stage2-hook.sh not present in this checkout") + return STAGE2_HOOK.read_text() + + +def _alias_lines(text: str) -> list[str]: + """The stage2 hook lines that resolve HERMES_UID/HERMES_GID from aliases.""" + return [ + line.strip() + for line in text.splitlines() + if line.strip().startswith(("HERMES_UID=", "HERMES_GID=")) + ] + + +def test_stage2_hook_resolves_puid_pgid_aliases(stage2_text: str) -> None: + alias_lines = _alias_lines(stage2_text) + assert any("PUID" in line for line in alias_lines), ( + "docker/stage2-hook.sh must resolve HERMES_UID from a PUID alias; see #15290" + ) + assert any("PGID" in line for line in alias_lines), ( + "docker/stage2-hook.sh must resolve HERMES_GID from a PGID alias; see #15290" + ) + + +def _resolve(stage2_text: str, env: dict[str, str]) -> str: + """Run the stage2 hook's alias-resolution lines in isolation and report the + resolved ``HERMES_UID:HERMES_GID`` pair.""" + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not available") + script = "\n".join(_alias_lines(stage2_text)) + script += '\necho "${HERMES_UID:-}:${HERMES_GID:-}"\n' + proc = subprocess.run( + [bash, "-ec", script], + env={"PATH": os.environ.get("PATH", "")} | env, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout.strip() + + +def test_puid_pgid_populate_hermes_uid_gid(stage2_text: str) -> None: + assert _resolve(stage2_text, {"PUID": "1000", "PGID": "10"}) == "1000:10" + + +def test_hermes_uid_gid_take_precedence_over_aliases(stage2_text: str) -> None: + resolved = _resolve( + stage2_text, + {"HERMES_UID": "2000", "HERMES_GID": "2001", "PUID": "1000", "PGID": "10"}, + ) + assert resolved == "2000:2001" + + +def test_no_uid_vars_leaves_values_empty(stage2_text: str) -> None: + # An empty resolution means the stage2 hook keeps the default hermes user. + assert _resolve(stage2_text, {}) == ":" diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index 3a660fbf5ef..9168d39ad70 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -710,12 +710,22 @@ Check logs: `docker logs hermes`. Common causes: ### "Permission denied" errors -The container's stage2 hook drops privileges to the non-root `hermes` user (UID 10000) via `s6-setuidgid` inside each supervised service. If your host `~/.hermes/` is owned by a different UID, set `HERMES_UID`/`HERMES_GID` to match your host user, or ensure the data directory is writable: +The container's stage2 hook drops privileges to the non-root `hermes` user (UID 10000) via `s6-setuidgid` inside each supervised service. If your host `~/.hermes/` is owned by a different UID, set `HERMES_UID`/`HERMES_GID` — or their `PUID`/`PGID` aliases, for parity with LinuxServer.io and NAS images — to match your host user, or ensure the data directory is writable: ```sh chmod -R 755 ~/.hermes ``` +On a NAS (UGOS, Synology, unRAID) the data directory is typically a **bind mount** owned by a host UID the container cannot `chown`. Set `PUID`/`PGID` (or `HERMES_UID`/`HERMES_GID`) to that host user so the runtime runs as the owner of the mount rather than UID 10000: + +```sh +docker run -d \ + --name hermes \ + -e PUID=1000 -e PGID=10 \ + -v /volume1/docker/hermes:/opt/data \ + nousresearch/hermes-agent gateway run +``` + `docker exec hermes ` automatically drops to UID 10000 too — see [`docker exec` automatically drops to the `hermes` user](#docker-exec-automatically-drops-to-the-hermes-user) for details and the per-invocation opt-out. ### Browser tools not working