diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index ff33e7dc2148..b5bd99252d9b 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -138,9 +138,14 @@ class _RepairLock: def _report_runtime_repair_failure(repair: RuntimeRepairResult) -> None: if repair.backup_venv is None: print( - " ⚠ Managed Python runtime was not replaced; " + " ℹ Managed Python runtime was not replaced; " f"the existing venv is unchanged ({repair.detail})." ) + print( + " Sessions stay protected meanwhile: Hermes keeps databases " + "out of WAL mode on this SQLite build. The next `hermes update` " + "will retry." + ) return print(f" ✗ Managed Python runtime cutover needs manual recovery: {repair.detail}") print(f" Previous venv: {repair.backup_venv}") @@ -874,6 +879,64 @@ def _windows_runtime_holders() -> tuple[bool, str]: return False, "" +def _uv_version_string(uv_bin: str) -> str: + """Return ``uv --version`` output, or ``""`` when it cannot be read.""" + try: + result = subprocess.run( + [uv_bin, "--version"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + timeout=15, + ) + except Exception: + return "" + if result.returncode != 0: + return "" + return (result.stdout or "").strip() + + +def _refresh_managed_uv_catalog(uv_bin: str) -> bool: + """Re-bootstrap the managed uv binary to refresh its Python catalog. + + The managed uv is installed with ``UV_UNMANAGED_INSTALL``, which disables + ``uv self update`` by design — so its embedded python-build-standalone + download catalog stays frozen at bootstrap age. python-build-standalone + re-releases existing CPython patch versions with newer SQLite (e.g. the + 3.11.15 build was re-cut with SQLite 3.53.x), so a stale catalog can make + every provisioning attempt resolve to a vulnerable build even though a + fixed build of the SAME patch version exists (issue #72093). The + patch-retry loop cannot recover from that: the fixed build carries no + newer version number to retry with. + + Re-running the official installer is the only supported refresh path for + unmanaged installs. Only the Hermes-managed binary is ever refreshed; + a caller-supplied foreign uv path is left alone. + + Returns ``True`` when the binary's version actually changed — i.e. a + provisioning retry can now see a different catalog. ``False`` means a + retry would resolve identically and is not worth the download cycle. + """ + managed = managed_uv_path() + try: + if Path(uv_bin).resolve() != managed.resolve(): + return False + except OSError: + return False + before = _uv_version_string(uv_bin) + try: + _install_uv(managed) + except Exception as exc: + logger.warning("managed uv refresh failed: %s", exc) + return False + after = _uv_version_string(uv_bin) + if not after: + return False + return after != before + + def repair_vulnerable_runtime( uv_bin: str, *, @@ -948,6 +1011,19 @@ def repair_vulnerable_runtime( project_root=root, current=current, ) + if provisioned is None: + # Likely a stale managed-uv catalog: python-build-standalone + # re-releases the same patch versions with fixed SQLite, but a + # frozen catalog keeps resolving the old vulnerable build and the + # patch-retry loop has no newer number to try (issue #72093). + # Refresh the managed binary and retry once. + if _refresh_managed_uv_catalog(uv_bin): + print(" → Managed uv refreshed; retrying provisioning...") + provisioned = _install_safe_python_generation( + uv_bin, + project_root=root, + current=current, + ) if provisioned is None: return RuntimeRepairResult( "failed", diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index 167f2c6e8c5b..4ce791af6ce6 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -1067,3 +1067,151 @@ class TestListAvailablePatches: monkeypatch.setattr(managed_uv.subprocess, "run", fake_run) assert managed_uv._list_available_patches("uv", "3.11", cwd=tmp_path, env={}) == [] + + +# --------------------------------------------------------------------------- +# _refresh_managed_uv_catalog + provisioning retry (issue #72093) +# --------------------------------------------------------------------------- + +class TestRefreshManagedUvCatalog: + """The managed uv is UV_UNMANAGED_INSTALL'd, so `uv self update` is + disabled and its python-build-standalone catalog freezes at bootstrap + age. python-build-standalone re-releases the same patch versions with + fixed SQLite, so a stale catalog makes provisioning fail forever with + no newer patch number to retry (issue #72093).""" + + def test_foreign_uv_path_is_never_refreshed(self, tmp_path): + import hermes_cli.managed_uv as managed_uv + + _make_executable(tmp_path / "bin" / "uv") + foreign = tmp_path / "elsewhere" / "uv" + _make_executable(foreign) + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \ + patch("hermes_cli.managed_uv._install_uv") as mock_install: + assert managed_uv._refresh_managed_uv_catalog(str(foreign)) is False + mock_install.assert_not_called() + + def test_version_change_reports_true(self, tmp_path): + import hermes_cli.managed_uv as managed_uv + + uv_path = tmp_path / "bin" / "uv" + _make_executable(uv_path) + versions = iter(["uv 0.1.0", "uv 0.2.0"]) + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \ + patch("hermes_cli.managed_uv._install_uv"), \ + patch( + "hermes_cli.managed_uv._uv_version_string", + side_effect=lambda _uv: next(versions), + ): + assert managed_uv._refresh_managed_uv_catalog(str(uv_path)) is True + + def test_same_version_reports_false(self, tmp_path): + import hermes_cli.managed_uv as managed_uv + + uv_path = tmp_path / "bin" / "uv" + _make_executable(uv_path) + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \ + patch("hermes_cli.managed_uv._install_uv"), \ + patch( + "hermes_cli.managed_uv._uv_version_string", + return_value="uv 0.1.0", + ): + assert managed_uv._refresh_managed_uv_catalog(str(uv_path)) is False + + def test_installer_failure_reports_false(self, tmp_path): + import hermes_cli.managed_uv as managed_uv + + uv_path = tmp_path / "bin" / "uv" + _make_executable(uv_path) + with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \ + patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \ + patch( + "hermes_cli.managed_uv._install_uv", + side_effect=RuntimeError("network down"), + ): + assert managed_uv._refresh_managed_uv_catalog(str(uv_path)) is False + + +class TestRepairRetriesAfterUvRefresh: + def _run_repair(self, tmp_path, *, refresh_result, second_attempt): + """Drive repair with the first provisioning attempt failing.""" + from hermes_cli.managed_uv import repair_vulnerable_runtime + + root, live, sentinel = _make_runtime_install(tmp_path) + current = _runtime_info(live / "bin" / "python", (3, 50, 4)) + + attempts = [] + + def fake_install(uv_bin, *, project_root, current): + attempts.append(uv_bin) + if len(attempts) == 1: + return None + return second_attempt(project_root) + + with patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \ + patch( + "hermes_cli.managed_uv.probe_sqlite_runtime", + return_value=current, + ), \ + patch( + "hermes_cli.managed_uv._install_safe_python_generation", + side_effect=fake_install, + ), \ + patch( + "hermes_cli.managed_uv._refresh_managed_uv_catalog", + return_value=refresh_result, + ) as mock_refresh, \ + patch( + "hermes_cli.managed_uv._stage_candidate_venv", + return_value=None, + ): + result = repair_vulnerable_runtime("uv", project_root=root) + return result, attempts, mock_refresh, sentinel + + def test_no_retry_when_refresh_did_not_change_uv(self, tmp_path): + result, attempts, mock_refresh, sentinel = self._run_repair( + tmp_path, + refresh_result=False, + second_attempt=lambda root: None, + ) + assert result.status == "failed" + assert len(attempts) == 1 + mock_refresh.assert_called_once_with("uv") + assert sentinel.read_text(encoding="utf-8") == "live" + + def test_retries_once_after_successful_refresh(self, tmp_path): + result, attempts, mock_refresh, sentinel = self._run_repair( + tmp_path, + refresh_result=True, + second_attempt=lambda root: None, + ) + # Second attempt ran (and also failed) — exactly one retry, no loop. + assert result.status == "failed" + assert len(attempts) == 2 + mock_refresh.assert_called_once_with("uv") + assert sentinel.read_text(encoding="utf-8") == "live" + + def test_retry_success_proceeds_to_staging(self, tmp_path): + def second_attempt(root): + generation = root / ".hermes-runtime" / "python" / "generation-retry" + candidate_python = generation / "bin" / "python" + candidate_python.parent.mkdir(parents=True) + candidate_python.write_text("candidate", encoding="utf-8") + return generation, candidate_python, _runtime_info( + candidate_python, (3, 53, 1) + ) + + result, attempts, mock_refresh, sentinel = self._run_repair( + tmp_path, + refresh_result=True, + second_attempt=second_attempt, + ) + # Provisioning succeeded on retry; staging (mocked to None) is what + # failed — proving the retry result flows into the normal pipeline. + assert result.status == "failed" + assert "replacement environment" in result.detail + assert len(attempts) == 2 + assert sentinel.read_text(encoding="utf-8") == "live"