Files
hermes-agent/tests/hermes_cli/test_update_check.py
Teknium 689ef5e233 feat(cli): warn on unsupported pip installs + fix stale update-check cache (#34491) (#34846)
* docs(code-execution): document HERMES_* env narrowing + passthrough workaround

The execute_code sandbox-child env scrub (108397726, #27303) deliberately
dropped the broad HERMES_ prefix passthrough, keeping only an operational
4-var allowlist (HERMES_HOME/PROFILE/CONFIG/ENV). A script that relied on a
non-secret HERMES_* var (HERMES_BASE_URL, HERMES_KANBAN_DB, HERMES_*_WEBHOOK,
or a plugin-defined one) now sees it unset in the child.

Document the behavior change and the two recovery routes (terminal.env_passthrough
in config.yaml, or required_environment_variables in skill frontmatter), plus
the debug log line that surfaces the drop for diagnosis.

* feat(cli): warn on unsupported pip installs + fix stale update-check cache after pip upgrade

Banner now shows a yellow warning when detect_install_method() == 'pip':
'pip install hermes-agent' isn't the supported install path (it exists on
PyPI for internal/CI reasons), so updates and issue support don't behave
correctly. Reuses existing install-method detection; warn, never block.

Also fixes #34491: check_for_updates() keyed its 6h cache only on ts+rev.
On the pip path (no HERMES_REVISION), rev is always None, so a
'pip install --upgrade' changed VERSION but left the cache valid — the
stale 'N commits behind' count survived the upgrade. Cache now also keys
on the installed VERSION and invalidates on mismatch.
2026-05-29 13:30:28 -07:00

193 lines
7.3 KiB
Python

"""Tests for the update check mechanism in hermes_cli.banner."""
import json
import os
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
def test_version_string_no_v_prefix():
"""__version__ should be bare semver without a 'v' prefix."""
from hermes_cli import __version__
assert not __version__.startswith("v"), f"__version__ should not start with 'v', got {__version__!r}"
def test_check_for_updates_uses_cache(tmp_path, monkeypatch):
"""When cache is fresh, check_for_updates should return cached value without calling git."""
from hermes_cli.banner import check_for_updates
from hermes_cli import __version__
# Create a fake git repo and fresh cache
repo_dir = tmp_path / "hermes-agent"
repo_dir.mkdir()
(repo_dir / ".git").mkdir()
cache_file = tmp_path / ".update_check"
cache_file.write_text(json.dumps({"ts": time.time(), "behind": 3, "ver": __version__}))
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch("hermes_cli.banner.subprocess.run") as mock_run:
result = check_for_updates()
assert result == 3
mock_run.assert_not_called()
def test_check_for_updates_invalidates_on_version_change(tmp_path, monkeypatch):
"""A fresh cache from a different installed version must be re-checked, not reused.
Regression for #34491: after `pip install --upgrade`, VERSION changes but the
cache's 6h TTL hadn't expired and rev was unchanged (both None), so the stale
'behind' count survived the upgrade. The version guard forces a recheck.
"""
import hermes_cli.banner as banner
# No local git checkout -> the PyPI path is exercised (pip-install class).
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))
# Fresh (within TTL) cache that says "behind", but stamped with an OLD version.
cache_file = tmp_path / ".update_check"
cache_file.write_text(
json.dumps({"ts": time.time(), "behind": 1, "rev": None, "ver": "0.0.1-old"})
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_REVISION", raising=False)
with patch("hermes_cli.banner.subprocess.run") as mock_run, \
patch("hermes_cli.banner.check_via_pypi", return_value=0) as mock_pypi:
result = banner.check_for_updates()
# Stale-version cache rejected -> fresh check ran -> up-to-date result.
assert result == 0
mock_pypi.assert_called_once()
mock_run.assert_not_called()
# Cache rewritten with the current installed version.
written = json.loads(cache_file.read_text())
assert written["ver"] == banner.VERSION
def test_check_for_updates_expired_cache(tmp_path, monkeypatch):
"""When cache is expired, check_for_updates should call git fetch."""
from hermes_cli.banner import check_for_updates
repo_dir = tmp_path / "hermes-agent"
repo_dir.mkdir()
(repo_dir / ".git").mkdir()
# Write an expired cache (timestamp far in the past)
cache_file = tmp_path / ".update_check"
cache_file.write_text(json.dumps({"ts": 0, "behind": 1}))
mock_result = MagicMock(returncode=0, stdout="5\n")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch("hermes_cli.banner.subprocess.run", return_value=mock_result) as mock_run:
result = check_for_updates()
assert result == 5
assert mock_run.call_count == 2 # git fetch + git rev-list
def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):
"""Falls back to PyPI check when .git directory doesn't exist anywhere."""
import hermes_cli.banner as banner
# Create a fake banner.py so the fallback path also has no .git
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))
with patch("hermes_cli.banner.subprocess.run") as mock_run:
with patch("hermes_cli.banner.check_via_pypi", return_value=0):
result = banner.check_for_updates()
assert result == 0
mock_run.assert_not_called()
def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch):
"""Dev install: falls back to Path(__file__).parent.parent when HERMES_HOME has no git repo."""
import hermes_cli.banner as banner
project_root = Path(banner.__file__).parent.parent.resolve()
if not (project_root / ".git").exists():
pytest.skip("Not running from a git checkout")
# Point HERMES_HOME at a temp dir with no hermes-agent/.git
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with patch("hermes_cli.banner.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="0\n")
result = banner.check_for_updates()
# Should have fallen back to project root and run git commands
assert mock_run.call_count >= 1
def test_prefetch_non_blocking():
"""prefetch_update_check() should return immediately without blocking."""
import hermes_cli.banner as banner
# Reset module state
banner._update_result = None
banner._update_check_done = threading.Event()
with patch.object(banner, "check_for_updates", return_value=5):
start = time.monotonic()
banner.prefetch_update_check()
elapsed = time.monotonic() - start
# Should return almost immediately (well under 1 second)
assert elapsed < 1.0
# Wait for the background thread to finish
banner._update_check_done.wait(timeout=5)
assert banner._update_result == 5
def test_invalidate_update_cache_clears_all_profiles(tmp_path):
"""_invalidate_update_cache() should delete .update_check from ALL profiles."""
from hermes_cli.main import _invalidate_update_cache
# Build a fake ~/.hermes with default + two named profiles
default_home = tmp_path / ".hermes"
default_home.mkdir()
(default_home / ".update_check").write_text('{"ts":1,"behind":50}')
profiles_root = default_home / "profiles"
for name in ("ops", "dev"):
p = profiles_root / name
p.mkdir(parents=True)
(p / ".update_check").write_text('{"ts":1,"behind":50}')
with patch.object(Path, "home", return_value=tmp_path), \
patch.dict(os.environ, {"HERMES_HOME": str(default_home)}):
_invalidate_update_cache()
# All three caches should be gone
assert not (default_home / ".update_check").exists(), "default profile cache not cleared"
assert not (profiles_root / "ops" / ".update_check").exists(), "ops profile cache not cleared"
assert not (profiles_root / "dev" / ".update_check").exists(), "dev profile cache not cleared"
def test_invalidate_update_cache_no_profiles_dir(tmp_path):
"""Works fine when no profiles directory exists (single-profile setup)."""
from hermes_cli.main import _invalidate_update_cache
default_home = tmp_path / ".hermes"
default_home.mkdir()
(default_home / ".update_check").write_text('{"ts":1,"behind":5}')
with patch.object(Path, "home", return_value=tmp_path), \
patch.dict(os.environ, {"HERMES_HOME": str(default_home)}):
_invalidate_update_cache()
assert not (default_home / ".update_check").exists()