# NousResearch/hermes-agent · Docker UID remap chown fixes for file permissions

## Open now

- 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.

## TL;DR

- 10 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 4 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #21267. Verify it addresses your case.
- **Recommended:** read PR #21267 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-21267,pr-38655`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #18800: Docker: Chat tab broken — npm install fails with EACCES due to root-owned node_modules
- #20500: Dashboard Chat tab fails with EACCES in Docker image — /opt/hermes/ui-tui is root-owned but dashboard runs as hermes
- #27221: \[Bug\]: entrypoint.sh misses chown for ui-tui/ and gateway/ when HERMES_UID is remapped

## Prior & competing PRs

- **#21267: fix(docker): chown runtime node_modules trees to hermes user (salvage #19303)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `Dockerfile`, `tests/tools/test_dockerfile_node_modules_perms.py`
- **#37923: fix(docker): point TUI launcher at prebuilt bundle via HERMES_TUI_DIR**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `Dockerfile`, `tests/docker/test_tui_prebuilt_bundle.py`
  - fix hunks:
  - `Dockerfile`

    ```diff
    @@ -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`

    ```diff
    @@ -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)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `docker/stage2-hook.sh`, `tests/tools/test_stage2_hook_build_tree_chown.py`
  - fix hunks:
  - `docker/stage2-hook.sh`

    ```diff
    @@ -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`

    ```diff
    @@ -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)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `Dockerfile`, `docker/stage2-hook.sh`, `tests/tools/test_dockerfile_node_modules_perms.py`, `tests/tools/test_stage2_hook_install_dir_chown.py`
- **#19303: fix(docker): chown runtime node_modules trees to hermes user (#18800)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#20500`, `#18800`
- **#20509: fix(docker/tui): chown ui-tui to hermes + actionable build error (#20500)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `9272e4019`, `#37923`
- **#37928: fix(docker): chown gateway after UID remap**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#38655`, `#27221`, `#38655`, `#38556`, `#38655`

## Fix status

- Merged to main in PR #21267 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-04`.
- Subsystem: `environments`
