Docker UID remap chown fixes for file permissions
NousResearch/hermes-agent · cluster 18800 · environments
Data as of
Nothing open on GitHub. Fix merged in #21267. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #21267. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #21267, #38655. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#21267 fix(docker): chown runtime node_modules trees to hermes user (salvage #19303)
files:
Dockerfile,tests/tools/test_dockerfile_node_modules_perms.py -
#37923 fix(docker): point TUI launcher at prebuilt bundle via HERMES_TUI_DIR
files:
Dockerfile,tests/docker/test_tui_prebuilt_bundle.pyShow the diff
-
Dockerfile@@ -243,6 +243,23 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r # ---------- Runtime ---------- ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist +# Point the TUI launcher at the prebuilt bundle baked at build time (Layer 8: +# `ui-tui && npm run build`). This makes _make_tui_argv take the prebuilt-bundle +# fast path (`node --expose-gc /opt/hermes/ui-tui/dist/entry.js`) and skip the +# _tui_need_npm_install / runtime `npm install` branch entirely — exactly the +# nix/packaged-release path the launcher was designed for. +# +# Why this is required (not just an optimization): the root package-lock.json +# describes the WHOLE monorepo workspace set (root + web + ui-tui + apps/*), +# but the image only installs root/web/ui-tui (apps/* — the desktop app — is +# never `npm install`ed here). So the actualized node_modules permanently +# disagrees with the canonical lock, _tui_need_npm_install() returns True on +# every launch, and the runtime `npm install` it triggers (a) can never +# converge against the partial monorepo and (b) races itself across concurrent +# embedded-chat (/api/pty) connections → ENOTEMPTY → the chat tab dies with a +# 502 / "[session ended]". Pointing at the prebuilt bundle sidesteps the whole +# check. (A separate launcher hardening is tracked independently.) +ENV HERMES_TUI_DIR=/opt/hermes/ui-tui ENV HERMES_HOME=/opt/data # `docker exec` privilege-drop shim. When operators run -
tests/docker/test_tui_prebuilt_bundle.py@@ -0,0 +1,79 @@ +"""Harness: the image ships a prebuilt TUI bundle, not a runtime npm install. + +Regression guard for the hosted-chat failure where the embedded dashboard +Chat tab died with a 502 / "[session ended]". Root cause: the image installs +only a subset of the npm monorepo workspaces (root/web/ui-tui, never apps/*), +so the actualized node_modules permanently disagrees with the canonical +package-lock.json. Without HERMES_TUI_DIR set, ``_make_tui_argv`` falls +through to ``_tui_need_npm_install`` (which returns True forever) and tries a +runtime ``npm install`` that can never converge and races itself across +concurrent /api/pty connections → ENOTEMPTY. + +The fix is ``ENV HERMES_TUI_DIR=/opt/hermes/ui-tui`` in the Dockerfile, which +makes the launcher take the prebuilt-bundle fast path (``node --expose-gc +.../dist/entry.js``) and skip the install check entirely. These tests assert +that invariant holds in the built image. +""" +from __future__ import annotations + +import json +import shlex +import subprocess + + +def _exec_py(image: str, py: str) -> str: + """Run a Python snippet inside the image as the hermes user, return stdout.""" + inner = ( + "source /opt/hermes/.venv/bin/activate && " + "cd /opt/hermes && " + f"python3 -c {shlex.quote(py)}" + ) + # Drop to the hermes user (UID 10000) so we exercise the same path the + # dashboard PTY child runs as — not root. + cmd = [ + "docker", "run", "--rm", "--entrypoint", "su", image, + "hermes", "-s", "/bin/bash", "-c", inner, + ] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + assert r.returncode == 0, f"in-container python failed:\n{r.stderr[-2000:]}" + return r.stdout.strip() + + +def test_hermes_tui_dir_env_is_set(built_image: str) -> None: + """HERMES_TUI_DIR must point at the prebuilt bundle dir in the image.""" + r = subprocess.run( + ["docker", "run", "--rm", "--entrypoint", "sh", built_image, + "-c", 'printf "%s" "$HERMES_TUI_DIR"'], + capture_output=True, text=True, timeout=60, + ) + assert r.returncode == 0, r.stderr[-2000:] + assert r.stdout.strip() == "/opt/hermes/ui-tui", ( + f"HERMES_TUI_DIR={r.stdout.strip()!r} (expected /opt/hermes/ui-tui)" + ) + + +def test_prebuilt_bundle_present_and_no_runtime_install(built_image: str) -> None: + """The launcher must (a) find the prebuilt bundle and (b) NOT want an + npm install — i.e. it takes the same path as a nix/packaged release.""" + py = ( + "import json\n" + "from pathlib import Path\n" + "from hermes_cli.main import _tui_need_npm_install, _find_bundled_tui, _make_tui_argv\n" + "ui = Path('/opt/hermes/ui-tui')\n" + "argv, cwd = _make_tui_argv(ui, tui_dev=False)\n" + "out = {\n" + " 'dist_entry_exists': (ui / 'dist' / 'entry.js').is_file(),\n" + " 'need_npm_install': _tui_need_npm_install(ui),\n" + " 'argv': argv,\n" + " 'uses_prebuilt': ('dist/entry.js' in ' '.join(argv)) and ('npm' not in argv[0].lower()),\n" + "}\n" + "print(json.dumps(out))\n" + ) + out = json.loads(_exec_py(built_image, py)) + assert out["dist_entry_exists"], "prebuilt ui-tui/dist/entry.js missing from image" + # With HERMES_TUI_DIR set, _make_tui_argv returns the prebuilt path BEFORE + # ever reaching the install check — so the resolved argv is what matters. + assert out["uses_prebuilt"], f"launcher did not take prebuilt path: argv={out['argv']!r}" + assert "npm" not in out["argv"][0].lower(), ( + f"launcher resolved to an npm invocation, not the prebuilt bundle: {out['argv']!r}" + )
-
-
#38556 fix(docker): chown build trees on UID remap independently of $HERMES_HOME (#35027 regression)
files:
docker/stage2-hook.sh,tests/tools/test_stage2_hook_build_tree_chown.pyShow the diff
-
docker/stage2-hook.sh@@ -154,20 +154,39 @@ if [ "$needs_chown" = true ]; then echo "[stage2] Warning: chown $HERMES_HOME/$sub failed (rootless container?) — continuing" fi done - # Hermes-owned trees under $INSTALL_DIR must be re-chowned when the UID - # is remapped — otherwise: - # - .venv: lazy_deps.py cannot install platform packages (discord.py, - # telegram, slack, etc.) with EACCES (#15012, #21100) - # - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when - # the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD - # is set) and writes to ui-tui/dist/. Without this chown the new - # hermes UID can't write the build output (#28851). - # - node_modules: root-level dependencies (puppeteer, web tooling) - # that runtime code may walk/update. - # The set mirrors the build-time `chown -R hermes:hermes` line in the - # Dockerfile — keep them in sync if the Dockerfile chown set changes. - # These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount - # concern doesn't apply — recursive is fine. +fi + +# --- Fix ownership of build trees under $INSTALL_DIR --- +# Hermes-owned trees under $INSTALL_DIR must be re-chowned whenever the +# runtime hermes UID no longer owns them — otherwise: +# - .venv: lazy_deps.py cannot install platform packages (discord.py, +# telegram, slack, etc.) with EACCES (#15012, #21100) +# - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when +# the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD +# is set) and writes to ui-tui/dist/. Without this chown the new +# hermes UID can't write the build output (#28851). +# - node_modules: root-level dependencies (puppeteer, web tooling) +# that runtime code may walk/update. +# The set mirrors the build-time `chown -R hermes:hermes` line in the +# Dockerfile — keep them in sync if the Dockerfile chown set changes. +# These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount +# concern doesn't apply — recursive is fine. +# +# This MUST be gated independently of the $HERMES_HOME ownership check +# above. `usermod -u <new> hermes` re-chowns the hermes home dir +# ($HERMES_HOME == /opt/data) to the new UID as a side effect, so after a +# HERMES_UID/PUID remap `stat $HERMES_HOME` always already matches the new +# UID and `needs_chown` is false — but the build trees under /opt/hermes +# are NOT touched by usermod and remain owned by the build-time UID +# (10000). Gating them on $HERMES_HOME ownership (as #35027 did) silently +# skipped this chown on the common PUID/NAS path, regressing lazy installs +# and TUI rebuilds. Probe the build trees directly instead: chown only +# when the venv is not already owned by the runtime hermes UID. Idempotent +# and skips the expensive recursive chown on every restart once ownership +# is settled. +venv_owner=$(stat -c %u "$INSTALL_DIR/.venv" 2>/dev/null || echo "") +if [ -n "$venv_owner" ] && [ "$venv_owner" != "$actual_hermes_uid" ]; then + echo "[stage2] Fixing ownership of build trees under $INSTALL_DIR to hermes ($actual_hermes_uid)" chown -R hermes:hermes \ "$INSTALL_DIR/.venv" \ "$INSTALL_DIR/ui-tui" \ -
tests/tools/test_stage2_hook_build_tree_chown.py@@ -0,0 +1,122 @@ +"""Contract test: the s6-overlay stage2 hook re-chowns the build trees under +$INSTALL_DIR (/opt/hermes/.venv, ui-tui, node_modules) to the runtime hermes +UID whenever they are not already hermes-owned — INDEPENDENTLY of whether +$HERMES_HOME ownership already matches. + +Regression guard for the HERMES_UID/PUID remap path broken by #35027. + +`usermod -u <new> hermes` re-chowns the hermes home dir ($HERMES_HOME == +/opt/data) to the new UID as a side effect. #35027 gated the build-tree chown +behind `stat $HERMES_HOME != hermes_uid`, so after any remap that stat is +already satisfied and the build-tree chown was silently skipped — leaving +.venv owned by the build-time UID (10000) and breaking: + - lazy_deps.py `uv pip install` of platform extras (#15012, #21100) + - the TUI esbuild rebuild into ui-tui/dist (#28851) + +The fix probes the build trees directly (stat .venv) rather than $HERMES_HOME. + +The extraction + stubbed-shell-run approach mirrors +tests/tools/test_stage2_hook_toplevel_chown.py. +""" +from __future__ import annotations + +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh" + + [redacted:email](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 _build_tree_block(text: str) -> str: + """Extract the build-tree chown block: from the `venv_owner=` probe + through the closing `fi` of the chown.""" + m = re.search( + r"(venv_owner=\$\(stat[^\n]*\n(?:.*\n)*?fi)", + text, + ) + assert m, "stage2-hook.sh must contain the venv_owner-gated build-tree chown block" + return m.group(1) + + +def test_build_tree_chown_not_gated_on_hermes_home(stage2_text: str) -> None: + """The build-tree chown must NOT live inside the `if [ "$needs_chown" = true ]` + block keyed on $HERMES_HOME ownership — that is exactly the #35027 bug.""" + block = _build_tree_block(stage2_text) + # The block probes the venv owner, not $HERMES_HOME. + assert "venv_owner" in block + assert "$INSTALL_DIR/.venv" in block + # All three build trees are covered. + for tree in ("$INSTALL_DIR/.venv", "$INSTALL_DIR/ui-tui", "$INSTALL_DIR/node_modules"): + assert tree in block, f"build-tree chown must cover {tree}" + + +def _run_build_tree_block( + text: str, *, venv_owner: int, hermes_uid: int +) -> bool: + """Run the extracted build-tree block with `stat`, `id`, and `chown` + stubbed. Returns True iff the block attempted the recursive chown.""" + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not available") + block = _build_tree_block(text) + + with tempfile.TemporaryDirectory() as d: + dpath = Path(d) + log = dpath / "chown.log" + # Stubs: + # stat -c %u <path> -> echo the simulated venv owner + # id -u hermes -> handled via actual_hermes_uid var below + # chown ... -> record that it fired + script = ( + "set -eu\n" + f'INSTALL_DIR="/opt/hermes"\n' + f'actual_hermes_uid={hermes_uid}\n' + f'stat() {{ echo {venv_owner}; }}\n' + f'chown() {{ echo fired >> "{log}"; }}\n' + + block + ) + script_path = dpath / "harness.sh" + script_path.write_text(script) + proc = subprocess.run([bash, str(script_path)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return log.exists() and "fired" in log.read_text() + + +def test_chown_fires_when_venv_owner_differs(stage2_text: str) -> None: + """The #35027 regression scenario: after a remap $HERMES_HOME already + matches the new UID, but the venv is still owned by the build-time UID + (10000). The build-tree chown MUST still fire.""" + fired = _run_build_tree_block(stage2_text, venv_owner=10000, hermes_uid=4242) + assert fired, ( + "build-tree chown must fire when the venv is not owned by the runtime " + "hermes UID, regardless of $HERMES_HOME ownership (#35027 regression)" + ) + + +def test_chown_skipped_when_venv_already_owned(stage2_text: str) -> None: + """Idempotency: once the venv is hermes-owned, the recursive chown is + skipped on subsequent boots.""" + fired = _run_build_tree_block(stage2_text, venv_owner=4242, hermes_uid=4242) + assert not fired, ( + "build-tree chown must be skipped when the venv already matches the " + "runtime hermes UID (avoid expensive recursive chown on every restart)" + ) + + +def test_chown_skipped_for_default_uid(stage2_text: str) -> None: + """No remap: venv owned by the default build UID (10000) and hermes is + still 10000 — nothing to do.""" + fired = _run_build_tree_block(stage2_text, venv_owner=10000, hermes_uid=10000) + assert not fired
-
-
#38655 fix(docker): chown gateway install tree on UID remap (salvage #37928)
files:
Dockerfile,docker/stage2-hook.sh,tests/tools/test_dockerfile_node_modules_perms.py,tests/tools/test_stage2_hook_install_dir_chown.py
3 PRs closed without merging
From the close comments: done elsewhere 2 · duplicate 1
-
#19303 fix(docker): chown runtime node_modules trees to hermes user (#18800) · duplicate →
#20500,#18800 -
#20509 fix(docker/tui): chown ui-tui to hermes + actionable build error (#20500) · done elsewhere →
9272e40,#37923 -
#37928 fix(docker): chown gateway after UID remap · done elsewhere →
#38655,#27221,#38556
Closed issues · 3
- Closed#18800Docker: Chat tab broken — npm install fails with EACCES due to root-owned node_modules
- Closed#20500Dashboard Chat tab fails with EACCES in Docker image — /opt/hermes/ui-tui is root-owned but dashboard runs as hermes
- Closed#27221[Bug]: entrypoint.sh misses chown for ui-tui/ and gateway/ when HERMES_UID is remapped
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.17: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab