fix(update-check): stop reporting phantom "N commits behind" inside Docker (#39559)

Inside the published Docker image, both the `--tui` banner and the
dashboard-embedded TUI report `1 commit behind — run docker pull
nousresearch/hermes-agent:latest to update` even though the container
has no git repo and no way to compute a commit delta.

Root cause: two independent update-detection paths, only one of which
knows it's running in Docker.

- `recommended_update_command()` → `detect_install_method()` reads the
  `.install_method` stamp that `docker/stage2-hook.sh` writes at boot →
  returns "docker", so the *command string* correctly says `docker pull`.
- `banner.check_for_updates()` (the source of the "N commits behind"
  *count*) has no notion of the docker install method. It only detects a
  build via `HERMES_REVISION` (nix-only, unset in the image) or a `.git`
  dir (excluded from the image by .dockerignore). Neither matches, so it
  silently falls through to `check_via_pypi()`, whose PyPI-version
  mismatch flag (1) is then rendered verbatim by the CLI banner
  (build_welcome_banner), the Ink TUI badge (branding.tsx), and `hermes
  version` as "1 commit behind" — a phantom count, no commit math
  involved. `hermes update` already refuses to run in-place in the
  container.

The dashboard's REST `/api/hermes/update/check` endpoint already
short-circuits docker (returns behind=None + the docker guidance). This
mirrors that guard inside `check_for_updates()` so the banner/TUI/version
surfaces agree: when `detect_install_method() == "docker"`, return None
before any git/pypi probe (and before writing a cache entry). None makes
the render guards (`typeof === 'number' && > 0`, `behind and behind > 0`)
stay false, so the badge/line disappears entirely — matching the System
page.

Fix is in one place (check_for_updates) because all three consumers route
through it via get_update_result()/_update_result.

Tests: test_check_for_updates_docker_returns_none asserts None + no
git/pypi probe + no cache write; test_check_for_updates_non_docker_still_checks
guards against over-broadening (pip still version-checks). Mutation-tested:
removing the guard fails the docker test.

Verified against a real `docker build` of the image — see PR description.
This commit is contained in:
Ben Barclay
2026-06-05 15:37:19 +10:00
committed by GitHub
parent 439f53cab8
commit b1e399de95
2 changed files with 75 additions and 0 deletions

View File

@ -131,6 +131,62 @@ def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch):
assert mock_run.call_count >= 1
def test_check_for_updates_docker_returns_none(tmp_path, monkeypatch):
"""Inside the Docker image, check_for_updates() must short-circuit to None.
Regression: the published image excludes .git (.dockerignore) and sets no
HERMES_REVISION (nix-only), so without a docker guard check_for_updates()
falls through to check_via_pypi(), whose version-mismatch flag (1) gets
rendered by both the Rich banner and the Ink TUI badge as a phantom
"1 commit behind" — despite there being no git repo or commit math in the
container, and `hermes update` correctly refusing to run there. The guard
must return None (so the > 0 render guards stay false) AND not reach the
git/pypi probes or write a cache entry.
"""
import hermes_cli.banner as banner
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
cache_file = tmp_path / ".update_check"
with patch("hermes_cli.config.detect_install_method", return_value="docker"), \
patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi") as mock_pypi:
result = banner.check_for_updates()
assert result is None
# Neither the git probe nor the PyPI probe should have run.
mock_run.assert_not_called()
mock_pypi.assert_not_called()
# And no phantom "behind" count should be cached for the next 6h.
assert not cache_file.exists()
def test_check_for_updates_non_docker_still_checks(tmp_path, monkeypatch):
"""The docker guard must NOT over-broaden: a pip install still version-checks.
Invariant guarding against the guard firing for non-docker methods — pip
installs legitimately reach check_via_pypi() and surface a real update.
"""
import hermes_cli.banner as banner
# No local git checkout -> the PyPI (pip-install) path is exercised.
fake_banner = tmp_path / "hermes_cli" / "banner.py"
fake_banner.parent.mkdir(parents=True, exist_ok=True)
fake_banner.touch()
monkeypatch.setattr(banner, "__file__", str(fake_banner))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_REVISION", raising=False)
with patch("hermes_cli.config.detect_install_method", return_value="pip"), \
patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi", return_value=1) as mock_pypi:
result = banner.check_for_updates()
assert result == 1
mock_pypi.assert_called_once()
mock_run.assert_not_called()
def test_prefetch_non_blocking():
"""prefetch_update_check() should return immediately without blocking."""
import hermes_cli.banner as banner