# NousResearch/hermes-agent · Docker image missing built web dashboard assets

## Open now

- Nothing open on GitHub.

These issues and PRs concern the Hermes dashboard being unavailable or broken in Docker images because web UI assets are not pre-built. Proposed fixes involve building assets during the Docker image build and skipping unnecessary rebuilds when assets are already present.

## TL;DR

- 5 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #12180. Verify it addresses your case.
- **Recommended:** read PR #12180 (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-12180`. 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.34`: 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

- #12177: \[Bug\]: Docker image 'hermes dashboard' 404s because web/ assets are not built
- #12243: Dashboard fails to start in official Docker image due to read-only overlayfs layer blocking npm install

## Prior & competing PRs

- **#12180: fix(docker): build web/ dashboard assets in image**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `Dockerfile`
  - fix hunks:
  - `Dockerfile`

    ```diff
    @@ -31,6 +31,12 @@ RUN npm install --prefer-offline --no-audit && \
         npm install --prefer-offline --no-audit && \
         npm cache clean --force
     
    +# Build the web/ dashboard so FastAPI at :9119 can serve the Vite assets
    +RUN cd /opt/hermes/web && \
    +    npm install --prefer-offline --no-audit && \
    +    npm run build && \
    +    npm cache clean --force
    +
     # Hand ownership to hermes user, then install Python deps in a virtualenv
     RUN chown -R hermes:hermes /opt/hermes
     USER hermes
    ```
- **#16292: fix(cli): skip web UI rebuild when dist is fresh (salvage #15863, closes #14898)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/main.py`, `scripts/release.py`, `tests/hermes_cli/test_web_ui_build.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -4984,6 +4984,43 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0)
         return default
     
     
    +def _web_ui_build_needed(web_dir: Path) -> bool:
    +    """Return True if the web UI dist is missing or stale.
    +
    +    Mirrors the staleness logic used by ``_tui_build_needed()`` for the TUI.
    +    The Vite build outputs to ``hermes_cli/web_dist/`` (per vite.config.ts
    +    outDir: "../hermes_cli/web_dist"), NOT to ``web/dist/``.  Uses the Vite
    +    manifest as the sentinel because it is written last and therefore has the
    +    newest mtime of any build output.
    +    """
    +    dist_dir = web_dir.parent / "hermes_cli" / "web_dist"
    +    sentinel = dist_dir / ".vite" / "manifest.json"
    +    if not sentinel.exists():
    +        sentinel = dist_dir / "index.html"
    +    if not sentinel.exists():
    +        return True
    +    dist_mtime = sentinel.stat().st_mtime
    +    skip = frozenset({"node_modules", "dist"})
    +    for dirpath, dirnames, filenames in os.walk(web_dir, topdown=True):
    +        dirnames[:] = [d for d in dirnames if d not in skip]
    +        for fn in filenames:
    +            if fn.endswith((".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".vue")):
    +                if os.path.getmtime(os.path.join(dirpath, fn)) > dist_mtime:
    +                    return True
    +    for meta in (
    +        "package.json",
    +        "package-lock.json",
    +        "yarn.lock",
    +        "pnpm-lock.yaml",
    +        "vite.config.ts",
    +        "vite.config.js",
    +    ):
    +        mp = web_dir / meta
    +        if mp.exists() and mp.stat().st_mtime > dist_mtime:
    +            return True
    +    return False
    +
    +
     def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
         """Build the web UI frontend if npm is available.
     
    ```

    ```diff
    @@ -4997,6 +5034,9 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
         if not (web_dir / "package.json").exists():
             return True
     
    +    if not _web_ui_build_needed(web_dir):
    +        return True
    +
         npm = shutil.which("npm")
         if not npm:
             if fatal:
    ```
  - `scripts/release.py`

    ```diff
    @@ -54,6 +54,7 @@
         "[redacted:email]": "benjaminsehl",
         "[redacted:email]": "Nerijusas",
         "[redacted:email]": "Ito-69",
    +    "[redacted:email]": "georgeglessner",
         "[redacted:email]": "maxims-oss",
         # contributors (from noreply pattern)
         "[redacted:email]": "davidvv",
    ```
  - `tests/hermes_cli/test_web_ui_build.py`

    ```diff
    @@ -0,0 +1,121 @@
    +"""Tests for _web_ui_build_needed — staleness check for the web UI dist.
    +
    +Critical invariant: the Vite build outputs to hermes_cli/web_dist/
    +(vite.config.ts: outDir: "../hermes_cli/web_dist"), NOT web/dist/.
    +The sentinel must be checked in the correct output directory or the
    +freshness check is a no-op and the OOM rebuild always runs.
    +"""
    +
    +import os
    +import time
    +from pathlib import Path
    +from unittest.mock import patch
    +
    +import pytest
    +
    +from hermes_cli.main import _web_ui_build_needed, _build_web_ui
    +
    +
    +def _touch(path: Path, offset: float = 0.0) -> None:
    +    path.parent.mkdir(parents=True, exist_ok=True)
    +    path.touch()
    +    if offset:
    +        t = time.time() + offset
    +        os.utime(path, (t, t))
    +
    +
    +def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]:
    +    """Return (web_dir, dist_dir) matching real repo layout."""
    +    web_dir = tmp_path / "web"
    +    web_dir.mkdir()
    +    (web_dir / "package.json").touch()
    +    dist_dir = tmp_path / "hermes_cli" / "web_dist"
    +    return web_dir, dist_dir
    +
    +
    +class TestWebUIBuildNeeded:
    +
    +    def test_returns_true_when_dist_missing(self, tmp_path):
    +        web_dir, _ = _make_web_dir(tmp_path)
    +        assert _web_ui_build_needed(web_dir) is True
    +
    +    def test_returns_false_when_vite_manifest_fresh(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        _touch(web_dir / "src" / "App.tsx", offset=-10)
    +        _touch(dist_dir / ".vite" / "manifest.json")
    +        assert _web_ui_build_needed(web_dir) is False
    +
    +    def test_returns_true_when_source_newer_than_manifest(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
    +        _touch(web_dir / "src" / "App.tsx")
    +        assert _web_ui_build_needed(web_dir) is True
    +
    +    def test_falls_back_to_index_html_when_manifest_missing(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        _touch(web_dir / "src" / "main.ts", offset=-10)
    +        _touch(dist_dir / "index.html")
    +        assert _web_ui_build_needed(web_dir) is False
    +
    +    def test_web_dist_dir_not_web_dist_subdir(self, tmp_path):
    +        """Regression: sentinel must be in hermes_cli/web_dist/, NOT web/dist/."""
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        _touch(web_dir / "src" / "App.tsx", offset=-10)
    +        # Place manifest in wrong location (web/dist/) — should NOT count as fresh
    +        wrong_dist = web_dir / "dist" / ".vite" / "manifest.json"
    +        _touch(wrong_dist)
    +        # Correct location is empty → still needs build
    +        assert _web_ui_build_needed(web_dir) is True
    +
    +    def test_returns_true_when_package_lock_newer_than_dist(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
    +        _touch(web_dir / "package-lock.json")
    +        assert _web_ui_build_needed(web_dir) is True
    +
    +    def test_returns_true_when_vite_config_newer_than_dist(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
    +        _touch(web_dir / "vite.config.ts")
    +        assert _web_ui_build_needed(web_dir) is True
    +
    +    def test_ignores_node_modules(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        # package.json older than manifest; only node_modules file is newer
    +        _touch(web_dir / "package.json", offset=-20)
    +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
    +        _touch(web_dir / "node_modules" / "react" / "index.js")
    +        assert _web_ui_build_needed(web_dir) is False
    +
    +    def test_ignores_dist_subdir_under_web(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        # package.json older than manifest; only web/dist file is newer
    +        _touch(web_dir / "package.json", offset=-20)
    +        _touch(dist_dir / ".vite" / "manifest.json", offset=-10)
    +        _touch(web_dir / "dist" / "assets" / "index.js")
    +        assert _web_ui_build_needed(web_dir) is False
    +
    +
    +class TestBuildWebUISkipsWhenFresh:
    +
    +    def test_skips_npm_when_dist_is_fresh(self, tmp_path):
    +        web_dir, dist_dir = _make_web_dir(tmp_path)
    +        _touch(dist_dir / ".vite" / "manifest.json")
    +
    +        with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
    +             patch("hermes_cli.main.subprocess.run") as mock_run:
    +            result = _build_web_ui(web_dir)
    +
    +        assert result is True
    +        mock_run.assert_not_called()
    +
    +    def test_runs_npm_when_dist_missing(self, tmp_path):
    +        web_dir, _ = _make_web_dir(tmp_path)
    +
    +        mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout=b"", stderr=b"")
    +        with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
    +             patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
    +            result = _build_web_ui(web_dir)
    +
    +        assert result is True
    +        assert mock_run.call_count == 2  # npm install + npm run build
    ```
- **#14914: fix(cli): skip web UI rebuild when dist/ is present and sources are unchanged**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#16292`, `#15863`, `#15863`

## Fix status

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