{"repo":"NousResearch/hermes-agent","public_id":"18800","display_label":"Docker UID remap chown fixes for file permissions","summary":"Multiple issues and fixes addressing file permission errors in Docker when HERMES_UID is remapped, specifically missing chown calls for directories like ui-tui, gateway, and node_modules that remain root-owned and cause EACCES errors at runtime.","subsystem":"environments","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":4,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21267},"resolution":{"state":"partially_resolved","by":"pr-21267,pr-38655"},"freshness":{"state":"verified","last_verified_at":"2026-06-04T03:34:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.17155555555555554},"fix_edges":[{"issue_number":18800,"pr_number":21267,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":20500,"pr_number":37923,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":27221,"pr_number":38556,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":27221,"pr_number":38655,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":18800,"title":"Docker: Chat tab broken — npm install fails with EACCES due to root-owned node_modules","url":null,"state":"closed"},{"kind":"issue","number":20500,"title":"Dashboard Chat tab fails with EACCES in Docker image — /opt/hermes/ui-tui is root-owned but dashboard runs as hermes","url":null,"state":"closed"},{"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":19303,"title":"fix(docker): chown runtime node_modules trees to hermes user (#18800)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20500","number":20500},{"kind":"pr","value":"#18800","number":18800}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":20509,"title":"fix(docker/tui): chown ui-tui to hermes + actionable build error (#20500)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9272e4019","number":0},{"kind":"pr","value":"#37923","number":37923}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":21267,"title":"fix(docker): chown runtime node_modules trees to hermes user (salvage #19303)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile","tests/tools/test_dockerfile_node_modules_perms.py"]},{"kind":"pr","number":37923,"title":"fix(docker): point TUI launcher at prebuilt bundle via HERMES_TUI_DIR","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["Dockerfile","tests/docker/test_tui_prebuilt_bundle.py"],"fix_hunks":[{"file":"Dockerfile","hunks":["@@ -243,6 +243,23 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r\n \n # ---------- Runtime ----------\n ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist\n+# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8:\n+# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle\n+# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the\n+# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the\n+# nix/packaged-release path the launcher was designed for.\n+#\n+# Why this is required (not just an optimization): the root package-lock.json\n+# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*),\n+# but the image only installs root/web/ui-tui (apps/* — the desktop app — is\n+# never `npm install`ed here). So the actualized node_modules permanently\n+# disagrees with the canonical lock, _tui_need_npm_install() returns True on\n+# every launch, and the runtime `npm install` it triggers (a) can never\n+# converge against the partial monorepo and (b) races itself across concurrent\n+# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a\n+# 502 / \"[session ended]\". Pointing at the prebuilt bundle sidesteps the whole\n+# check. (A separate launcher hardening is tracked independently.)\n+ENV HERMES_TUI_DIR=/opt/hermes/ui-tui\n ENV HERMES_HOME=/opt/data\n \n # `docker exec` privilege-drop shim. When operators run"]},{"file":"tests/docker/test_tui_prebuilt_bundle.py","hunks":["@@ -0,0 +1,79 @@\n+\"\"\"Harness: the image ships a prebuilt TUI bundle, not a runtime npm install.\n+\n+Regression guard for the hosted-chat failure where the embedded dashboard\n+Chat tab died with a 502 / \"[session ended]\". Root cause: the image installs\n+only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*),\n+so the actualized node_modules permanently disagrees with the canonical\n+package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls\n+through to ``_tui_need_npm_install`` (which returns True forever) and tries a\n+runtime ``npm install`` that can never converge and races itself across\n+concurrent /api/pty connections → ENOTEMPTY.\n+\n+The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which\n+makes the launcher take the prebuilt-bundle fast path (``node --expose-gc\n+.../dist/entry.js``) and skip the install check entirely. These tests assert\n+that invariant holds in the built image.\n+\"\"\"\n+from __future__ import annotations\n+\n+import json\n+import shlex\n+import subprocess\n+\n+\n+def _exec_py(image: str, py: str) -> str:\n+    \"\"\"Run a Python snippet inside the image as the hermes user, return stdout.\"\"\"\n+    inner = (\n+        \"source /opt/hermes/.venv/bin/activate && \"\n+        \"cd /opt/hermes && \"\n+        f\"python3 -c {shlex.quote(py)}\"\n+    )\n+    # Drop to the hermes user (UID 10000) so we exercise the same path the\n+    # dashboard PTY child runs as — not root.\n+    cmd = [\n+        \"docker\", \"run\", \"--rm\", \"--entrypoint\", \"su\", image,\n+        \"hermes\", \"-s\", \"/bin/bash\", \"-c\", inner,\n+    ]\n+    r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)\n+    assert r.returncode == 0, f\"in-container python failed:\\n{r.stderr[-2000:]}\"\n+    return r.stdout.strip()\n+\n+\n+def test_hermes_tui_dir_env_is_set(built_image: str) -> None:\n+    \"\"\"HERMES_TUI_DIR must point at the prebuilt bundle dir in the image.\"\"\"\n+    r = subprocess.run(\n+        [\"docker\", \"run\", \"--rm\", \"--entrypoint\", \"sh\", built_image,\n+         \"-c\", 'printf \"%s\" \"$HERMES_TUI_DIR\"'],\n+        capture_output=True, text=True, timeout=60,\n+    )\n+    assert r.returncode == 0, r.stderr[-2000:]\n+    assert r.stdout.strip() == \"/opt/hermes/ui-tui\", (\n+        f\"HERMES_TUI_DIR={r.stdout.strip()!r} (expected /opt/hermes/ui-tui)\"\n+    )\n+\n+\n+def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None:\n+    \"\"\"The launcher must (a) find the prebuilt bundle and (b) NOT want an\n+    npm install — i.e. it takes the same path as a nix/packaged release.\"\"\"\n+    py = (\n+        \"import json\\n\"\n+        \"from pathlib import Path\\n\"\n+        \"from hermes_cli.main import _tui_need_npm_install, _find_bundled_tui, _make_tui_argv\\n\"\n+        \"ui = Path('/opt/hermes/ui-tui')\\n\"\n+        \"argv, cwd = _make_tui_argv(ui, tui_dev=False)\\n\"\n+        \"out = {\\n\"\n+        \"  'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\\n\"\n+        \"  'need_npm_install': _tui_need_npm_install(ui),\\n\"\n+        \"  'argv': argv,\\n\"\n+        \"  'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\\n\"\n+        \"}\\n\"\n+        \"print(json.dumps(out))\\n\"\n+    )\n+    out = json.loads(_exec_py(built_image, py))\n+    assert out[\"dist_entry_exists\"], \"prebuilt ui-tui/dist/entry.js missing from image\"\n+    # With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE\n+    # ever reaching the install check — so the resolved argv is what matters.\n+    assert out[\"uses_prebuilt\"], f\"launcher did not take prebuilt path: argv={out['argv']!r}\"\n+    assert \"npm\" not in out[\"argv\"][0].lower(), (\n+        f\"launcher resolved to an npm invocation, not the prebuilt bundle: {out['argv']!r}\"\n+    )\n"]}]},{"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":"#38655","number":38655},{"kind":"pr","value":"#38556","number":38556},{"kind":"pr","value":"#38655","number":38655}],"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","implication":"superseded"},"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","implication":"superseded"},"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}}