From 53cbfe23c65beaae477d0911a19c34c0129e2b7a Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Sat, 4 Jul 2026 15:08:31 +0800 Subject: [PATCH 1/4] fix(webui): resolve git.exe via Windows registry/known paths so WEBUI_VERSION doesn't degrade to 'unknown' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the WebUI server is launched from a venv python whose PATH does not include git (e.g. hermes-agent venv on Windows), shutil.which('git') returns None and WEBUI_VERSION falls back to 'unknown'. That freezes the ?v= static-asset cache key, so browsers keep serving stale cached ui.js/messages.js even after the server is restarted with fixed code — frontend fixes silently never reach clients (recurring scroll jump-back). Add a Windows fallback to _resolve_git_executable(): read the Git-for-Windows registry InstallPath (HKLM + WOW6432Node + HKCU), then probe common install dirs. Mirrors the existing darwin /usr/bin/git fallback. No behavior change on posix or when git is already on PATH. --- api/updates.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/api/updates.py b/api/updates.py index a44bed59..d9c91d69 100644 --- a/api/updates.py +++ b/api/updates.py @@ -214,12 +214,60 @@ def _run_git(args, cwd, timeout=10): return f'git failed to start: {exc}', False +def _windows_git_from_registry(): + """Best-effort resolve git.exe from the Git-for-Windows registry key. + + Git for Windows records its install root at + ``HKLM\\SOFTWARE\\GitForWindows\\InstallPath`` (and the WOW6432Node mirror + for a 32-bit install on 64-bit Windows). ``git.exe`` lives under + ``\\cmd\\git.exe``. This is the reliable way to find git when + it is installed but NOT on the launching process's PATH — e.g. the WebUI + server started from a venv python whose environment does not inherit the + interactive shell PATH, which otherwise degrades WEBUI_VERSION to + ``'unknown'`` and freezes the ``?v=`` static-asset cache-busting stamp. + """ + try: + import winreg + except ImportError: + return None + for hive, flag in ( + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY), + (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY), + (winreg.HKEY_CURRENT_USER, 0), + ): + try: + with winreg.OpenKey( + hive, r'SOFTWARE\GitForWindows', 0, + winreg.KEY_READ | flag, + ) as key: + install_path, _ = winreg.QueryValueEx(key, 'InstallPath') + except OSError: + continue + if not install_path: + continue + candidate = os.path.join(install_path, 'cmd', 'git.exe') + if os.path.exists(candidate): + return candidate + return None + + 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' + if sys.platform == 'win32': + from_registry = _windows_git_from_registry() + if from_registry: + return from_registry + for candidate in ( + os.path.expandvars(r'%ProgramFiles%\Git\cmd\git.exe'), + os.path.expandvars(r'%ProgramFiles(x86)%\Git\cmd\git.exe'), + os.path.expandvars(r'%LocalAppData%\Programs\Git\cmd\git.exe'), + ): + if candidate and os.path.exists(candidate): + return candidate return None From 861cc9da772b939bfc8020f5b1e1ef37e7b400b9 Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Sat, 4 Jul 2026 15:39:39 +0800 Subject: [PATCH 2/4] test(webui): cover Windows git.exe registry/known-path fallback Parallels tests/test_issue5175_macos_launchd_git.py for the Windows case: verifies _resolve_git_executable() resolves git via the Git-for-Windows registry InstallPath and known install dirs when it is off PATH, that _detect_webui_version() recovers a real stamp instead of 'unknown', and falsifiable guards that the registry path is Windows-only and returns None when git is genuinely absent. --- ...est_issue_windows_git_version_detection.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/test_issue_windows_git_version_detection.py diff --git a/tests/test_issue_windows_git_version_detection.py b/tests/test_issue_windows_git_version_detection.py new file mode 100644 index 00000000..a8b3bc62 --- /dev/null +++ b/tests/test_issue_windows_git_version_detection.py @@ -0,0 +1,128 @@ +"""Windows parallel to issue #5175 (macOS launchd git-on-PATH miss). + +When the source WebUI server is launched from a Python environment whose PATH +does not include git (e.g. a venv on Windows, mirroring the macOS launchd case), +``shutil.which('git')`` returns None. Before this fix ``_resolve_git_executable`` +had only a darwin fallback, so on Windows it returned None -> ``git describe`` +never ran -> ``WEBUI_VERSION`` degraded to ``'unknown'`` -> the ``?v=`` +static-asset cache key froze, so browsers kept serving stale cached JS/CSS even +after the server restarted with fixed code. + +The fix adds a Windows fallback that resolves git.exe from the Git-for-Windows +registry ``InstallPath`` (and common install dirs) without relying on PATH. +""" +import os +import sys +from unittest.mock import MagicMock, patch + +import api.updates as updates + + +def _fake_winreg(): + """Minimal winreg stub whose OpenKey/QueryValueEx yield a Git InstallPath.""" + mod = MagicMock() + mod.HKEY_LOCAL_MACHINE = 0 + mod.HKEY_CURRENT_USER = 1 + mod.KEY_READ = 0x20019 + mod.KEY_WOW64_64KEY = 0x0100 + mod.KEY_WOW64_32KEY = 0x0200 + + class _Key: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + mod.OpenKey.return_value = _Key() + mod.QueryValueEx.return_value = (r'C:\Program Files\Git', 1) + return mod + + +def test_resolve_git_executable_uses_registry_on_windows(): + winreg = _fake_winreg() + expected = os.path.join(r'C:\Program Files\Git', 'cmd', 'git.exe') + + def fake_exists(p): + return p == expected + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'win32'), \ + patch.dict('sys.modules', {'winreg': winreg}), \ + patch.object(updates.os.path, 'exists', side_effect=fake_exists): + resolved = updates._resolve_git_executable() + + assert resolved == expected + + +def test_resolve_git_executable_probes_known_dirs_when_registry_absent(): + winreg = _fake_winreg() + # Registry has no value -> QueryValueEx raises -> fall through to dir probe. + winreg.QueryValueEx.side_effect = OSError('no InstallPath') + program_files = os.environ.get('ProgramFiles', r'C:\Program Files') + expected = os.path.join(program_files, 'Git', 'cmd', 'git.exe') + + def fake_exists(p): + return p == expected + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'win32'), \ + patch.dict('sys.modules', {'winreg': winreg}), \ + patch.object(updates.os.path, 'exists', side_effect=fake_exists): + resolved = updates._resolve_git_executable() + + assert resolved == expected + + +def test_resolve_git_executable_returns_none_on_windows_when_git_truly_absent(): + winreg = _fake_winreg() + winreg.QueryValueEx.side_effect = OSError('no InstallPath') + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'win32'), \ + patch.dict('sys.modules', {'winreg': winreg}), \ + patch.object(updates.os.path, 'exists', return_value=False): + resolved = updates._resolve_git_executable() + + assert resolved is None + + +def test_non_windows_never_touches_registry_fallback(): + """Falsifiable guard: the registry path must be Windows-only.""" + winreg = _fake_winreg() + + with patch.object(updates.shutil, 'which', return_value=None), \ + patch.object(sys, 'platform', 'linux'), \ + patch.dict('sys.modules', {'winreg': winreg}), \ + patch.object(updates.os.path, 'exists', return_value=True): + resolved = updates._resolve_git_executable() + + assert resolved is None + winreg.OpenKey.assert_not_called() + + +def test_detect_webui_version_recovers_via_windows_registry_fallback(tmp_path): + winreg = _fake_winreg() + expected_git = os.path.join(r'C:\Program Files\Git', 'cmd', 'git.exe') + + def fake_exists(p): + # git.exe resolves; no api/_version.py fallback file present. + return p == expected_git + + def fake_run(cmd, **kwargs): + assert cmd[0] == expected_git + 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', 'win32'), \ + patch.dict('sys.modules', {'winreg': winreg}), \ + patch.object(updates.os.path, 'exists', side_effect=fake_exists), \ + 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' From 2c03277a9738752b70fd1d2871440968f08a604c Mon Sep 17 00:00:00 2001 From: allenliang2022 Date: Sat, 4 Jul 2026 15:50:05 +0800 Subject: [PATCH 3/4] test(webui): make Windows git-fallback tests platform-independent The known-dirs-probe test built its expected path from os.environ['ProgramFiles'] + os.path.join, but the production code uses os.path.expandvars('%ProgramFiles%..'). On the Linux CI runners expandvars does not expand %VAR% syntax and os.path.join uses '/', so the expected path diverged from what the code produced and the test failed (assert None == 'C:\Program Files/Git/cmd/git.exe'). Mock the OS-specific surfaces (winreg, os.path.exists/expandvars/join) so the Windows code path is exercised deterministically on any host. Verified the two previously-failing assertions now pass under posix os.path semantics, and a mutation check (reverting the fix) still makes the three behavior tests fail. --- ...est_issue_windows_git_version_detection.py | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/tests/test_issue_windows_git_version_detection.py b/tests/test_issue_windows_git_version_detection.py index a8b3bc62..5812d633 100644 --- a/tests/test_issue_windows_git_version_detection.py +++ b/tests/test_issue_windows_git_version_detection.py @@ -10,15 +10,26 @@ after the server restarted with fixed code. The fix adds a Windows fallback that resolves git.exe from the Git-for-Windows registry ``InstallPath`` (and common install dirs) without relying on PATH. + +These tests mock the OS-specific surfaces (winreg, os.path.exists, +os.path.expandvars) so they exercise the Windows code path deterministically on +ANY host — the CI runners are Linux, where %VAR% expansion and native path +separators differ, so nothing here may depend on the running platform's real +environment or filesystem. """ -import os import sys from unittest.mock import MagicMock, patch import api.updates as updates +# Deterministic install root used by the winreg stub. Kept as a forward-slash +# string so the expected git path is identical regardless of the host's +# os.path.join separator (CI is Linux; dev is Windows). +FAKE_GIT_ROOT = 'C:/Program Files/Git' +FAKE_GIT_EXE = FAKE_GIT_ROOT + '/cmd/git.exe' -def _fake_winreg(): + +def _fake_winreg(install_path=FAKE_GIT_ROOT): """Minimal winreg stub whose OpenKey/QueryValueEx yield a Git InstallPath.""" mod = MagicMock() mod.HKEY_LOCAL_MACHINE = 0 @@ -35,43 +46,58 @@ def _fake_winreg(): return False mod.OpenKey.return_value = _Key() - mod.QueryValueEx.return_value = (r'C:\Program Files\Git', 1) + mod.QueryValueEx.return_value = (install_path, 1) return mod +def _patch_join_forward_slash(): + """Make os.path.join deterministic (forward slash) regardless of host OS. + + The production code builds candidates with os.path.join; on Linux CI that + uses '/', on Windows '\\'. Pin it so expected paths match on both. + """ + def fake_join(*parts): + return '/'.join(p.rstrip('/\\') for p in parts) + return patch.object(updates.os.path, 'join', side_effect=fake_join) + + def test_resolve_git_executable_uses_registry_on_windows(): winreg = _fake_winreg() - expected = os.path.join(r'C:\Program Files\Git', 'cmd', 'git.exe') - - def fake_exists(p): - return p == expected with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ - patch.object(updates.os.path, 'exists', side_effect=fake_exists): + _patch_join_forward_slash(), \ + patch.object(updates.os.path, 'exists', side_effect=lambda p: p == FAKE_GIT_EXE): resolved = updates._resolve_git_executable() - assert resolved == expected + assert resolved == FAKE_GIT_EXE def test_resolve_git_executable_probes_known_dirs_when_registry_absent(): - winreg = _fake_winreg() # Registry has no value -> QueryValueEx raises -> fall through to dir probe. + winreg = _fake_winreg() winreg.QueryValueEx.side_effect = OSError('no InstallPath') - program_files = os.environ.get('ProgramFiles', r'C:\Program Files') - expected = os.path.join(program_files, 'Git', 'cmd', 'git.exe') - def fake_exists(p): - return p == expected + # The code probes os.path.expandvars('%ProgramFiles%\\Git\\cmd\\git.exe'); + # mock expandvars deterministically so the test does not depend on the + # host's %VAR% expansion (Linux CI does not expand %ProgramFiles%). + probed = 'C:/Program Files/Git/cmd/git.exe' + + def fake_expandvars(s): + if 'ProgramFiles%' in s and '(x86)' not in s: + return probed + # Other candidates resolve to a sentinel that never "exists". + return '/nonexistent/' + s with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ - patch.object(updates.os.path, 'exists', side_effect=fake_exists): + patch.object(updates.os.path, 'expandvars', side_effect=fake_expandvars), \ + patch.object(updates.os.path, 'exists', side_effect=lambda p: p == probed): resolved = updates._resolve_git_executable() - assert resolved == expected + assert resolved == probed def test_resolve_git_executable_returns_none_on_windows_when_git_truly_absent(): @@ -81,6 +107,7 @@ def test_resolve_git_executable_returns_none_on_windows_when_git_truly_absent(): with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ + patch.object(updates.os.path, 'expandvars', side_effect=lambda s: s), \ patch.object(updates.os.path, 'exists', return_value=False): resolved = updates._resolve_git_executable() @@ -103,14 +130,13 @@ def test_non_windows_never_touches_registry_fallback(): def test_detect_webui_version_recovers_via_windows_registry_fallback(tmp_path): winreg = _fake_winreg() - expected_git = os.path.join(r'C:\Program Files\Git', 'cmd', 'git.exe') def fake_exists(p): # git.exe resolves; no api/_version.py fallback file present. - return p == expected_git + return p == FAKE_GIT_EXE def fake_run(cmd, **kwargs): - assert cmd[0] == expected_git + assert cmd[0] == FAKE_GIT_EXE if cmd[1:] == ['describe', '--tags', '--always']: return MagicMock(returncode=0, stdout='v0.51.999\n', stderr='') if cmd[1:] == ['diff-index', '--quiet', 'HEAD', '--']: @@ -120,6 +146,7 @@ def test_detect_webui_version_recovers_via_windows_registry_fallback(tmp_path): with patch.object(updates.shutil, 'which', return_value=None), \ patch.object(sys, 'platform', 'win32'), \ patch.dict('sys.modules', {'winreg': winreg}), \ + _patch_join_forward_slash(), \ patch.object(updates.os.path, 'exists', side_effect=fake_exists), \ patch.object(updates, 'REPO_ROOT', tmp_path), \ patch.object(updates.subprocess, 'run', side_effect=fake_run): From 658624af7f3ce7b9c0fe3e98183ec377ca1eb1cb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 4 Jul 2026 08:07:29 +0000 Subject: [PATCH 4/4] release #5522: Windows git.exe resolution for version/cache-bust + CHANGELOG allenliang2022's fix: WEBUI_VERSION degraded to 'unknown' on Windows when git wasn't on the launching process PATH (venv/service/non-interactive launcher), freezing the ?v= static-asset cache stamp so browsers served stale JS/CSS after upgrade. Extends the pre-existing _resolve_git_executable() with a win32-only Git-for-Windows registry lookup (WOW64 64/32 + HKCU) + common install-path fallbacks, only when shutil.which('git') fails. Non-win32 paths untouched. 5 tests incl non-windows-never-touches-registry guard + e2e version recovery. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fdf8d7e..63803598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed +- **Windows: after an upgrade, browsers no longer keep serving stale JS/CSS when git isn't on the server's PATH.** When the WebUI server was launched on Windows from an environment without git on `PATH` (a venv, a service wrapper, a non-interactive launcher), version detection silently degraded to `unknown`, which froze the `?v=` static-asset cache key — so browsers kept serving cached `ui.js` / `messages.js` / CSS even after the server restarted with fixed code, and the only workaround was a manual hard-refresh on every device. Version detection now resolves `git.exe` from the Git-for-Windows registry key (and common install paths) when it isn't on `PATH`, so the cache stamp updates and clients pick up frontend fixes automatically. Non-Windows behavior is unchanged. Thanks @allenliang2022. (#5522) - **Transparent Stream is smoother during streaming — less row churn and no thinking-block scrollbar flicker.** Building on the identity-preserving live-row reconcile, the renderer now skips redundant work while a response streams: a preserved row's markup isn't rewritten when the incoming HTML is unchanged, a row isn't reinserted when it's already in the correct position, and long thinking blocks no longer flicker an internal scrollbar as their scroll container appears. Purely a reduction of no-op DOM work — what renders is unchanged, and matching rows still preserve their DOM node, copy button, tool-call data, and expanded/collapsed state. Thanks @Stacey2911. (#5456, #5367) - **Models from a user-defined provider in `config.yaml` now route correctly.** If you defined a custom provider under the `providers:` block with an explicit `models:` allowlist, a bare model id from that list wasn't matched by request-time routing — it silently fell back to the default provider and often 404'd. Resolution now scans your `providers:` allowlists (exact match, same as `custom_providers:`), so those models reach the right endpoint. Copilot is deliberately excluded from this scan because `providers.copilot.models` is a per-model settings map (reasoning effort, limits), not a routing allowlist. Thanks @akay64. (#5511) - **The transcript no longer scrolls up when the composer grows.** When you were pinned to the bottom of a conversation and the composer got taller — multi-line typing, `Shift+Enter`, or a multi-line / dictation paste — the transcript viewport shrank by the same amount and left you stranded a row or two above the bottom (it read as the chat "randomly scrolling up" during normal use). The transcript now re-pins to the bottom whenever the composer grows, but only when you were genuinely still pinned there — if you'd scrolled up to read history, nothing yanks you back down. (#5516, #5514, #5515)