* 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.
193 lines
7.3 KiB
Python
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()
|