From 7402706c5ef970a563c64d1ec4f0f2dabce934fd Mon Sep 17 00:00:00 2001 From: cornna <96944678+sweetcornna@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:38:24 +0800 Subject: [PATCH] fix(docker): accept Unraid uid mappings (#38098) Co-authored-by: Cornna <96944678+ymylive@users.noreply.github.com> --- docker/stage2-hook.sh | 11 ++- tests/tools/test_stage2_hook_unraid_uid.py | 86 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 tests/tools/test_stage2_hook_unraid_uid.py diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 20035c30d..16af485b7 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -85,11 +85,12 @@ fi # is a no-op if the dir already exists. (#18482, salvages #18488) mkdir -p "$HERMES_HOME" -# Numeric UID/GID validation: must be digits only, 1000-65534 +# Numeric UID/GID validation: must be digits only, non-root, 1-65534. +# NAS hosts such as Unraid commonly use low non-root IDs (99:100). validate_uid_gid() { case "$1" in ''|*[!0-9]*) return 1 ;; - *) [ "$1" -ge 1000 ] && [ "$1" -le 65534 ] ;; + *) [ "$1" -ge 1 ] && [ "$1" -le 65534 ] ;; esac } @@ -198,7 +199,7 @@ if [ "$needs_chown" = true ]; then # Hermes-owned subdirs: recursive chown is safe here because these are # created and managed exclusively by hermes (see the s6-setuidgid mkdir # -p block below for the canonical list). - for sub in cron sessions logs hooks memories skills skins plans workspace home profiles; do + for sub in cron sessions logs hooks memories skills skins plans workspace home profiles pairing platforms/pairing; do if [ -e "$HERMES_HOME/$sub" ]; then chown -R hermes:hermes "$HERMES_HOME/$sub" 2>/dev/null || \ echo "[stage2] Warning: chown $HERMES_HOME/$sub failed (rootless container?) — continuing" @@ -308,7 +309,9 @@ as_hermes mkdir -p \ "$HERMES_HOME/skins" \ "$HERMES_HOME/plans" \ "$HERMES_HOME/workspace" \ - "$HERMES_HOME/home" + "$HERMES_HOME/home" \ + "$HERMES_HOME/pairing" \ + "$HERMES_HOME/platforms/pairing" # --- Install-method stamp (read by detect_install_method() in hermes status) --- # Preserved from the tini-era entrypoint (PR #27843). Must be written as diff --git a/tests/tools/test_stage2_hook_unraid_uid.py b/tests/tools/test_stage2_hook_unraid_uid.py new file mode 100644 index 000000000..42ba174e7 --- /dev/null +++ b/tests/tools/test_stage2_hook_unraid_uid.py @@ -0,0 +1,86 @@ +"""Regression tests for Docker stage2 UID/GID handling on NAS hosts. + +Unraid commonly runs appdata as nobody:users (99:100). The stage2 hook must +accept those non-root numeric IDs and keep legacy/new pairing stores writable +after targeted ownership reconciliation. +""" +from __future__ import annotations + +import os +import re +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 _uid_gid_validator(text: str) -> str: + marker = "# --- UID/GID remap ---" + before_marker = text.split(marker, 1)[0] + start = before_marker.index("validate_uid_gid()") + return before_marker[start:] + + +def _validate_uid_gid(text: str, value: str) -> bool: + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not available") + script = _uid_gid_validator(text) + '\nvalidate_uid_gid "$CANDIDATE"\n' + proc = subprocess.run( + [bash, "-c", script], + env={"PATH": os.environ.get("PATH", ""), "CANDIDATE": value}, + capture_output=True, + text=True, + ) + return proc.returncode == 0 + + +@pytest.mark.parametrize("value", ["1", "99", "100", "1000", "65534"]) +def test_uid_gid_validator_accepts_non_root_nas_ids(stage2_text: str, value: str) -> None: + assert _validate_uid_gid(stage2_text, value), ( + f"stage2 hook must accept NAS UID/GID {value}; Unraid uses 99:100 (#38070)" + ) + + +@pytest.mark.parametrize("value", ["", "0", "abc", "99x", "65535"]) +def test_uid_gid_validator_rejects_root_invalid_and_out_of_range( + stage2_text: str, + value: str, +) -> None: + assert not _validate_uid_gid(stage2_text, value) + + +def _targeted_chown_subdirs(text: str) -> list[str]: + m = re.search( + r"for sub in (?P.*?); do\n\s*if \[ -e \"\$HERMES_HOME/\$sub\" \]", + text, + re.DOTALL, + ) + assert m, "stage2-hook.sh must contain the targeted subdir chown loop" + return m.group("items").split() + + +def test_targeted_chown_covers_legacy_and_new_pairing_dirs(stage2_text: str) -> None: + subdirs = _targeted_chown_subdirs(stage2_text) + assert "pairing" in subdirs + assert "platforms/pairing" in subdirs + + +def test_seeded_directory_list_covers_legacy_and_new_pairing_dirs(stage2_text: str) -> None: + seed_block = stage2_text.split("as_hermes mkdir -p \\", 1)[1].split( + "# --- Install-method stamp", + 1, + )[0] + assert '"$HERMES_HOME/pairing"' in seed_block + assert '"$HERMES_HOME/platforms/pairing"' in seed_block