{"repo":"NousResearch/hermes-agent","public_id":"27221","display_label":"Docker entrypoint chown of build trees after HERMES_UID remap","summary":"These items all concern the Docker entrypoint's handling of file ownership for directories such as gateway/ and ui-tui/ when HERMES_UID is remapped. One is a bug report and the others are fixes or salvage attempts touching the same ownership behavior; one also references a possible regression related to #35027.","subsystem":"environments","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":38655,"merged_at":"2026-06-04T03:34:24+00:00"},"resolution":{"state":"resolved","by":"pr-38655"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T03:34:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":27221,"pr_number":38556,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-04T00:17:56+00:00"},{"issue_number":27221,"pr_number":38655,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-04T03:34:24+00:00"}],"members":[{"kind":"issue","number":27221,"title":"[Bug]: entrypoint.sh misses chown for ui-tui/ and gateway/ when HERMES_UID is remapped","url":null,"state":"closed"},{"kind":"pr","number":37928,"title":"fix(docker): chown gateway after UID remap","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#38655","number":38655},{"kind":"pr","value":"#27221","number":27221},{"kind":"pr","value":"#38556","number":38556}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38556,"title":"fix(docker): chown build trees on UID remap independently of $HERMES_HOME (#35027 regression)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["docker/stage2-hook.sh","tests/tools/test_stage2_hook_build_tree_chown.py"],"fix_hunks":[{"file":"docker/stage2-hook.sh","hunks":["@@ -154,20 +154,39 @@ if [ \"$needs_chown\" = true ]; then\n                 echo \"[stage2] Warning: chown $HERMES_HOME/$sub failed (rootless container?) — continuing\"\n         fi\n     done\n-    # Hermes-owned trees under $INSTALL_DIR must be re-chowned when the UID\n-    # is remapped — otherwise:\n-    #   - .venv: lazy_deps.py cannot install platform packages (discord.py,\n-    #     telegram, slack, etc.) with EACCES (#15012, #21100)\n-    #   - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when\n-    #     the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD\n-    #     is set) and writes to ui-tui/dist/. Without this chown the new\n-    #     hermes UID can't write the build output (#28851).\n-    #   - node_modules: root-level dependencies (puppeteer, web tooling)\n-    #     that runtime code may walk/update.\n-    # The set mirrors the build-time `chown -R hermes:hermes` line in the\n-    # Dockerfile — keep them in sync if the Dockerfile chown set changes.\n-    # These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount\n-    # concern doesn't apply — recursive is fine.\n+fi\n+\n+# --- Fix ownership of build trees under $INSTALL_DIR ---\n+# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the\n+# runtime hermes UID no longer owns them — otherwise:\n+#   - .venv: lazy_deps.py cannot install platform packages (discord.py,\n+#     telegram, slack, etc.) with EACCES (#15012, #21100)\n+#   - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when\n+#     the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD\n+#     is set) and writes to ui-tui/dist/. Without this chown the new\n+#     hermes UID can't write the build output (#28851).\n+#   - node_modules: root-level dependencies (puppeteer, web tooling)\n+#     that runtime code may walk/update.\n+# The set mirrors the build-time `chown -R hermes:hermes` line in the\n+# Dockerfile — keep them in sync if the Dockerfile chown set changes.\n+# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount\n+# concern doesn't apply — recursive is fine.\n+#\n+# This MUST be gated independently of the $HERMES_HOME ownership check\n+# above. `usermod -u <new> hermes` re-chowns the hermes home dir\n+# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a\n+# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new\n+# UID and `needs_chown` is false — but the build trees under /opt/hermes\n+# are NOT touched by usermod and remain owned by the build-time UID\n+# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently\n+# skipped this chown on the common PUID/NAS path, regressing lazy installs\n+# and TUI rebuilds. Probe the build trees directly instead: chown only\n+# when the venv is not already owned by the runtime hermes UID. Idempotent\n+# and skips the expensive recursive chown on every restart once ownership\n+# is settled.\n+venv_owner=$(stat -c %u \"$INSTALL_DIR/.venv\" 2>/dev/null || echo \"\")\n+if [ -n \"$venv_owner\" ] && [ \"$venv_owner\" != \"$actual_hermes_uid\" ]; then\n+    echo \"[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)\"\n     chown -R hermes:hermes \\\n         \"$INSTALL_DIR/.venv\" \\\n         \"$INSTALL_DIR/ui-tui\" \\"]},{"file":"tests/tools/test_stage2_hook_build_tree_chown.py","hunks":["@@ -0,0 +1,122 @@\n+\"\"\"Contract test: the s6-overlay stage2 hook re-chowns the build trees under\n+$INSTALL_DIR (/opt/hermes/.venv, ui-tui, node_modules) to the runtime hermes\n+UID whenever they are not already hermes-owned — INDEPENDENTLY of whether\n+$HERMES_HOME ownership already matches.\n+\n+Regression guard for the HERMES_UID/PUID remap path broken by #35027.\n+\n+`usermod -u <new> hermes` re-chowns the hermes home dir ($HERMES_HOME ==\n+/opt/data) to the new UID as a side effect. #35027 gated the build-tree chown\n+behind `stat $HERMES_HOME != hermes_uid`, so after any remap that stat is\n+already satisfied and the build-tree chown was silently skipped — leaving\n+.venv owned by the build-time UID (10000) and breaking:\n+  - lazy_deps.py `uv pip install` of platform extras (#15012, #21100)\n+  - the TUI esbuild rebuild into ui-tui/dist (#28851)\n+\n+The fix probes the build trees directly (stat .venv) rather than $HERMES_HOME.\n+\n+The extraction + stubbed-shell-run approach mirrors\n+tests/tools/test_stage2_hook_toplevel_chown.py.\n+\"\"\"\n+from __future__ import annotations\n+\n+import re\n+import shutil\n+import subprocess\n+import tempfile\n+from pathlib import Path\n+\n+import pytest\n+\n+REPO_ROOT = Path(__file__).resolve().parents[2]\n+STAGE2_HOOK = REPO_ROOT / \"docker\" / \"stage2-hook.sh\"\n+\n+\n[redacted:email](scope=\"module\")\n+def stage2_text() -> str:\n+    if not STAGE2_HOOK.exists():\n+        pytest.skip(\"docker/stage2-hook.sh not present in this checkout\")\n+    return STAGE2_HOOK.read_text()\n+\n+\n+def _build_tree_block(text: str) -> str:\n+    \"\"\"Extract the build-tree chown block: from the `venv_owner=` probe\n+    through the closing `fi` of the chown.\"\"\"\n+    m = re.search(\n+        r\"(venv_owner=\\$\\(stat[^\\n]*\\n(?:.*\\n)*?fi)\",\n+        text,\n+    )\n+    assert m, \"stage2-hook.sh must contain the venv_owner-gated build-tree chown block\"\n+    return m.group(1)\n+\n+\n+def test_build_tree_chown_not_gated_on_hermes_home(stage2_text: str) -> None:\n+    \"\"\"The build-tree chown must NOT live inside the `if [ \"$needs_chown\" = true ]`\n+    block keyed on $HERMES_HOME ownership — that is exactly the #35027 bug.\"\"\"\n+    block = _build_tree_block(stage2_text)\n+    # The block probes the venv owner, not $HERMES_HOME.\n+    assert \"venv_owner\" in block\n+    assert \"$INSTALL_DIR/.venv\" in block\n+    # All three build trees are covered.\n+    for tree in (\"$INSTALL_DIR/.venv\", \"$INSTALL_DIR/ui-tui\", \"$INSTALL_DIR/node_modules\"):\n+        assert tree in block, f\"build-tree chown must cover {tree}\"\n+\n+\n+def _run_build_tree_block(\n+    text: str, *, venv_owner: int, hermes_uid: int\n+) -> bool:\n+    \"\"\"Run the extracted build-tree block with `stat`, `id`, and `chown`\n+    stubbed. Returns True iff the block attempted the recursive chown.\"\"\"\n+    bash = shutil.which(\"bash\")\n+    if bash is None:\n+        pytest.skip(\"bash not available\")\n+    block = _build_tree_block(text)\n+\n+    with tempfile.TemporaryDirectory() as d:\n+        dpath = Path(d)\n+        log = dpath / \"chown.log\"\n+        # Stubs:\n+        #   stat -c %u <path>  -> echo the simulated venv owner\n+        #   id -u hermes       -> handled via actual_hermes_uid var below\n+        #   chown ...          -> record that it fired\n+        script = (\n+            \"set -eu\\n\"\n+            f'INSTALL_DIR=\"/opt/hermes\"\\n'\n+            f'actual_hermes_uid={hermes_uid}\\n'\n+            f'stat() {{ echo {venv_owner}; }}\\n'\n+            f'chown() {{ echo fired >> \"{log}\"; }}\\n'\n+            + block\n+        )\n+        script_path = dpath / \"harness.sh\"\n+        script_path.write_text(script)\n+        proc = subprocess.run([bash, str(script_path)], capture_output=True, text=True)\n+        assert proc.returncode == 0, proc.stderr\n+        return log.exists() and \"fired\" in log.read_text()\n+\n+\n+def test_chown_fires_when_venv_owner_differs(stage2_text: str) -> None:\n+    \"\"\"The #35027 regression scenario: after a remap $HERMES_HOME already\n+    matches the new UID, but the venv is still owned by the build-time UID\n+    (10000). The build-tree chown MUST still fire.\"\"\"\n+    fired = _run_build_tree_block(stage2_text, venv_owner=10000, hermes_uid=4242)\n+    assert fired, (\n+        \"build-tree chown must fire when the venv is not owned by the runtime \"\n+        \"hermes UID, regardless of $HERMES_HOME ownership (#35027 regression)\"\n+    )\n+\n+\n+def test_chown_skipped_when_venv_already_owned(stage2_text: str) -> None:\n+    \"\"\"Idempotency: once the venv is hermes-owned, the recursive chown is\n+    skipped on subsequent boots.\"\"\"\n+    fired = _run_build_tree_block(stage2_text, venv_owner=4242, hermes_uid=4242)\n+    assert not fired, (\n+        \"build-tree chown must be skipped when the venv already matches the \"\n+        \"runtime hermes UID (avoid expensive recursive chown on every restart)\"\n+    )\n+\n+\n+def test_chown_skipped_for_default_uid(stage2_text: str) -> None:\n+    \"\"\"No remap: venv owned by the default build UID (10000) and hermes is\n+    still 10000 — nothing to do.\"\"\"\n+    fired = _run_build_tree_block(stage2_text, venv_owner=10000, hermes_uid=10000)\n+    assert not fired\n"]}]},{"kind":"pr","number":38655,"title":"fix(docker): chown gateway install tree on UID remap (salvage #37928)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile","docker/stage2-hook.sh","tests/tools/test_dockerfile_node_modules_perms.py","tests/tools/test_stage2_hook_install_dir_chown.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}