mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(update): refresh stale managed-uv catalog so SQLite runtime repair can succeed
The managed uv is installed with UV_UNMANAGED_INSTALL, which disables 'uv self update' by design — the swallowed failure left its embedded python-build-standalone catalog frozen at bootstrap age forever. python-build-standalone re-releases existing patch versions with fixed SQLite (3.11.15 was re-cut with 3.53.1), so a stale catalog resolves the same version number to the OLD vulnerable build, the probe rejects it, and the patch-retry loop cannot recover because the fixed build carries no newer number to try. Result: 'hermes update' printed a guaranteed-failure provisioning warning on every run (issue #72093). - When provisioning fails, re-bootstrap the Hermes-managed uv binary via the official installer (the only supported refresh for unmanaged installs) and retry provisioning once — only when the binary version actually changed, so no wasted download cycles. - Never touch a caller-supplied uv outside the managed path. - Soften the failure report from alarming ⚠ to informational ℹ and say why it is safe to wait: the WAL gate keeps databases out of WAL on vulnerable builds, and the next update retries. Verified: 56 unit tests green; sabotage run (retry block removed) fails the 3 new retry tests; live E2E replaced a fake managed uv via the real astral installer and the refreshed binary resolved the 3.11 catalog. Fixes #72093
This commit is contained in:
parent
e2fbd0dcd7
commit
c476ad35ae
2 changed files with 225 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue