From a052f390f86589bb213c507a29ec042cb1c80396 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 22:08:59 -0400 Subject: [PATCH 1/3] fix(updates): resolve git under macOS launchd (#5175) --- api/updates.py | 15 ++- tests/test_issue5175_macos_launchd_git.py | 134 ++++++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue5175_macos_launchd_git.py diff --git a/api/updates.py b/api/updates.py index 9d9bde4ed..a44bed599 100644 --- a/api/updates.py +++ b/api/updates.py @@ -15,6 +15,7 @@ import os import re import shutil import subprocess +import sys import threading import time import urllib.error @@ -187,9 +188,12 @@ def _run_git(args, cwd, timeout=10): On failure, returns stderr (or stdout as fallback) so callers can surface actionable git error messages instead of empty strings. """ + git_executable = _resolve_git_executable() + if not git_executable: + return 'git executable not found', False try: r = subprocess.run( - ['git'] + args, cwd=str(cwd), capture_output=True, + [git_executable] + args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout, encoding='utf-8', errors='replace', ) @@ -210,6 +214,15 @@ def _run_git(args, cwd, timeout=10): return f'git failed to start: {exc}', False +def _resolve_git_executable(): + git_executable = shutil.which('git') + if git_executable: + return git_executable + if sys.platform == 'darwin' and os.path.exists('/usr/bin/git'): + return '/usr/bin/git' + return None + + def _dirty_suffix(path: Path, timeout=1) -> str: """Return a best-effort ``-dirty`` suffix without blocking version display.""" out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout) diff --git a/tests/test_issue5175_macos_launchd_git.py b/tests/test_issue5175_macos_launchd_git.py new file mode 100644 index 000000000..4992ece7b --- /dev/null +++ b/tests/test_issue5175_macos_launchd_git.py @@ -0,0 +1,134 @@ +import sys +from unittest.mock import MagicMock, patch + +import api.updates as updates + + +def test_run_git_uses_which_result_when_available(tmp_path): + with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \ + patch.object(updates.subprocess, 'run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout='v0.51.999\n', stderr='') + + out, ok = updates._run_git(['describe', '--tags'], tmp_path) + + assert ok is True + assert out == 'v0.51.999' + assert mock_run.call_args.args[0][0] == 'C:/Tools/git.exe' + + +def test_run_git_falls_back_to_usr_bin_git_on_darwin(tmp_path): + def fake_run(cmd, **kwargs): + if cmd[0] == 'git': + raise FileNotFoundError + if cmd[0] != '/usr/bin/git': + raise AssertionError(f'unexpected executable: {cmd[0]!r}') + return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='') + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'darwin'), \ + patch.object(updates.os.path, 'exists', return_value=True), \ + patch.object(updates.subprocess, 'run', side_effect=fake_run): + out, ok = updates._run_git(['describe', '--tags'], tmp_path) + + assert ok is True + assert out == 'v0.51.999' + + +def test_run_git_returns_not_found_when_no_executable(tmp_path): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd[0]) + if cmd[0] == '/usr/bin/git': + raise AssertionError('non-macOS path miss must not use /usr/bin/git') + raise FileNotFoundError + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'linux'), \ + patch.object(updates.os.path, 'exists', return_value=True), \ + patch.object(updates.subprocess, 'run', side_effect=fake_run): + out, ok = updates._run_git(['status'], tmp_path) + + assert ok is False + assert out == 'git executable not found' + assert '/usr/bin/git' not in calls + + +def test_run_git_returns_not_found_when_usr_bin_git_absent_on_darwin(tmp_path): + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd[0]) + raise FileNotFoundError + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'darwin'), \ + patch.object(updates.os.path, 'exists', return_value=False), \ + patch.object(updates.subprocess, 'run', side_effect=fake_run): + out, ok = updates._run_git(['status'], tmp_path) + + assert ok is False + assert out == 'git executable not found' + assert '/usr/bin/git' not in calls + + +def test_detect_webui_version_recovers_via_launchd_fallback(tmp_path): + def fake_run(cmd, **kwargs): + if cmd[0] == 'git': + raise FileNotFoundError + if cmd[1:] == ['describe', '--tags', '--always']: + return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='') + if cmd[1:] == ['diff-index', '--quiet', 'HEAD', '--']: + return MagicMock(returncode=0, stdout='', stderr='') + raise AssertionError(f'unexpected git args: {cmd[1:]!r}') + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'darwin'), \ + patch.object(updates.os.path, 'exists', return_value=True), \ + patch.object(updates, 'REPO_ROOT', tmp_path), \ + patch.object(updates.subprocess, 'run', side_effect=fake_run): + version = updates._detect_webui_version() + + assert version == 'v0.51.999' + + +def test_check_repo_does_not_report_git_not_found_via_launchd_fallback(tmp_path): + (tmp_path / '.git').mkdir() + + def fake_run(cmd, **kwargs): + if cmd[0] == 'git': + raise FileNotFoundError + git_args = cmd[1:] + if git_args == ['fetch', 'origin', '--tags', '--force']: + return MagicMock(returncode=0, stdout='', stderr='') + if git_args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return MagicMock(returncode=0, stdout='', stderr='') + if git_args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return MagicMock(returncode=0, stdout='origin/master\n', stderr='') + if git_args == ['rev-list', '--count', 'HEAD..origin/master']: + return MagicMock(returncode=0, stdout='0\n', stderr='') + if git_args == ['merge-base', 'HEAD', 'origin/master']: + return MagicMock(returncode=0, stdout='abcdef1234567890\n', stderr='') + if git_args == ['rev-parse', '--short', 'abcdef1234567890']: + return MagicMock(returncode=0, stdout='abcdef1\n', stderr='') + if git_args == ['rev-parse', '--short', 'origin/master']: + return MagicMock(returncode=0, stdout='fedcba9\n', stderr='') + if git_args == ['remote', 'get-url', 'origin']: + return MagicMock( + returncode=0, + stdout='https://github.com/nesquena/hermes-webui.git\n', + stderr='', + ) + if git_args == ['diff-index', '--quiet', 'HEAD', '--']: + return MagicMock(returncode=0, stdout='', stderr='') + raise AssertionError(f'unexpected git args: {git_args!r}') + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'darwin'), \ + patch.object(updates.os.path, 'exists', return_value=True), \ + patch.object(updates.subprocess, 'run', side_effect=fake_run): + info = updates._check_repo(tmp_path, 'webui') + + assert info['behind'] == 0 + assert info['dirty'] is False + assert 'error' not in info From a98b0551c9c49c121bc0a01eb1f7701b048d43a1 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sun, 28 Jun 2026 22:20:18 -0400 Subject: [PATCH 2/3] test(updates): keep launchd regression proofs faithful (#5175) --- tests/test_issue5175_macos_launchd_git.py | 11 +++-------- tests/test_updates.py | 15 ++++++++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_issue5175_macos_launchd_git.py b/tests/test_issue5175_macos_launchd_git.py index 4992ece7b..d0d8d3450 100644 --- a/tests/test_issue5175_macos_launchd_git.py +++ b/tests/test_issue5175_macos_launchd_git.py @@ -18,10 +18,7 @@ def test_run_git_uses_which_result_when_available(tmp_path): def test_run_git_falls_back_to_usr_bin_git_on_darwin(tmp_path): def fake_run(cmd, **kwargs): - if cmd[0] == 'git': - raise FileNotFoundError - if cmd[0] != '/usr/bin/git': - raise AssertionError(f'unexpected executable: {cmd[0]!r}') + assert cmd[0] == '/usr/bin/git' return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='') with patch.object(updates.shutil, 'which', return_value=None), \ @@ -74,8 +71,7 @@ def test_run_git_returns_not_found_when_usr_bin_git_absent_on_darwin(tmp_path): def test_detect_webui_version_recovers_via_launchd_fallback(tmp_path): def fake_run(cmd, **kwargs): - if cmd[0] == 'git': - raise FileNotFoundError + assert cmd[0] == '/usr/bin/git' if cmd[1:] == ['describe', '--tags', '--always']: return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='') if cmd[1:] == ['diff-index', '--quiet', 'HEAD', '--']: @@ -96,8 +92,7 @@ def test_check_repo_does_not_report_git_not_found_via_launchd_fallback(tmp_path) (tmp_path / '.git').mkdir() def fake_run(cmd, **kwargs): - if cmd[0] == 'git': - raise FileNotFoundError + assert cmd[0] == '/usr/bin/git' git_args = cmd[1:] if git_args == ['fetch', 'origin', '--tags', '--force']: return MagicMock(returncode=0, stdout='', stderr='') diff --git a/tests/test_updates.py b/tests/test_updates.py index 7f3526ca1..083ef23e8 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -288,7 +288,8 @@ def test_update_cache_is_scoped_by_agent_inclusion(tmp_path): def test_run_git_returns_stderr_on_failure(tmp_path): """When a git command fails, _run_git should return stderr (not empty string).""" - with patch('subprocess.run') as mock_run: + with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \ + patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock( returncode=1, stdout='', @@ -302,7 +303,8 @@ def test_run_git_returns_stderr_on_failure(tmp_path): def test_run_git_returns_stdout_when_no_stderr(tmp_path): """If stderr is empty on failure, fall back to stdout.""" - with patch('subprocess.run') as mock_run: + with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \ + patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock( returncode=128, stdout='Already up to date.', @@ -316,7 +318,8 @@ def test_run_git_returns_stdout_when_no_stderr(tmp_path): def test_run_git_returns_exit_code_when_no_output(tmp_path): """If both stdout and stderr are empty, report the exit code.""" - with patch('subprocess.run') as mock_run: + with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \ + patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock( returncode=1, stdout='', @@ -330,7 +333,8 @@ def test_run_git_returns_exit_code_when_no_output(tmp_path): def test_run_git_uses_utf8_replacement_for_windows_console_output(tmp_path): """Git output can contain Unicode even when Windows' active code page cannot.""" - with patch('subprocess.run') as mock_run: + with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \ + patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout='v0.51.184\n', stderr=None) out, ok = updates._run_git(['describe', '--tags'], tmp_path) @@ -344,7 +348,8 @@ def test_run_git_uses_utf8_replacement_for_windows_console_output(tmp_path): def test_run_git_handles_missing_stdout_after_decode_thread_failure(tmp_path): """A subprocess reader failure must not make version detection crash on import.""" - with patch('subprocess.run') as mock_run: + with patch.object(updates.shutil, 'which', return_value='C:/Tools/git.exe'), \ + patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout=None, stderr=None) out, ok = updates._run_git(['diff', '--binary', 'HEAD', '--'], tmp_path) From c59be15206147567115e28e2df663f22e405389b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 29 Jun 2026 04:36:10 +0000 Subject: [PATCH 3/3] docs(changelog): resolve git under macOS launchd (#5184, #5175) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02f923ca9..25b63ed06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ ### Fixed +- **The version badge and in-app updater now work on macOS when the WebUI is launched by launchd.** A launchd-started process inherits a minimal `PATH` that doesn't include git's location (e.g. Homebrew), so `api/updates.py` running a bare `git` failed — breaking both the displayed version and the update checker on those installs. Git is now resolved once in `_run_git()`: normal `PATH` lookup first (byte-identical on Linux/CI and every non-miss), falling back to `/usr/bin/git` only on macOS when `PATH` lookup misses; a `.git`-absent install keeps its existing `no_git` path and the missing-git diagnostic is preserved when no executable exists. Thanks @rodboev. (#5184, fixes #5175) - **Settings search now finds the busy-input-mode setting by its option text and description, not just its label.** The Settings search index was built from each field's *label* only, so searching for natural terms like "queue", "interrupt", "steer", "message", or "default" didn't surface the busy-input-mode control even though those words describe exactly what it does. The index now also collects each field's option text, description text, and optional per-field supplemental search terms (kept a strict superset of the old label-only match, so existing results are unaffected), and the busy-input field carries supplemental terms so it's discoverable by those words. Thanks @rodboev. (#5163, fixes #5148) - **`scripts/test.sh` now works on Windows Git Bash by accepting the Windows `.venv` layout.** The documented local test entrypoint created a valid `.venv` on Windows but then threw it away because it hardcoded the POSIX interpreter path (`.venv/bin/python`); it now resolves both the POSIX path and the Windows `.venv/Scripts/python.exe` in the one place used for venv create/reuse, dependency install, and the final pytest run (byte-identical behavior on POSIX/CI), unblocking Windows contributors running the documented flow. Thanks @rodboev. (#5166, fixes #5152) - **Updating the Agent from the WebUI now restarts the gateway, so model switching works immediately afterward instead of bricking on a stale-module guard.** The in-app updater ("Update Now" → agent target) pulled new Agent code to disk and purged `__pycache__` but only re-exec'd the WebUI process — the Agent gateway kept running the old in-memory code, so the next model switch tripped the stale-module guard (*"This gateway is running code from … restart the gateway: `hermes gateway restart`"*) and the user had to restart it by hand. A successful agent update now invokes a shared `restart_active_profile_gateway()` helper (extracted from the existing health-restart path, same `hermes gateway restart` CLI delegation with its in-flight-run drain) — but only after all git operations succeed, and only for the `agent` target; a `busy`/`failed` update never restarts, and a `webui`-only update is unchanged. Thanks @rodboev. (#5181, fixes #5156)