hermes-agent/hermes_cli/managed_uv.py
teknium1 be633c1c33 fix(runtime): request minor line for SQLite runtime repair + tests
Follow-up on the #70186 salvage. The cherry-picked repair pinned the
candidate to the exact current CPython patch (e.g. 3.11.14). Verified
live with uv 0.11.19: every published python-build-standalone artifact
for 3.11.14 links vulnerable SQLite 3.50.4 — even with --reinstall — so
the exact-patch pin made the repair permanently impossible on the
installs that need it most (repair_vulnerable_runtime returned
'failed: could not provision a fixed private Python runtime').

Request the minor line (3.11) instead — the same resolution a fresh
'uv python install' would make, still inside requires-python — and
tighten the drift gate to 'same minor, no downgrade'. E2E-verified
end-to-end on a real vulnerable venv: repair_vulnerable_runtime()
provisioned 3.11.15, built + smoke-tested the sibling venv, cut over,
and reported SQLite 3.50.4 → 3.53.1 with the old venv parked for
rollback.
2026-07-24 16:00:03 -07:00

954 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Hermes-managed uv and Python runtime repair.
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
Windows). Every code path that needs uv resolves it from that single location.
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
at ``$HERMES_HOME/bin`` so the installer writes directly there — no PATH
probing, no conda guards, no multi-location resolution chains.
The Python backing the install is different: it is shared by every Hermes
profile because the checkout's ``venv`` is shared. Runtime repair therefore
uses an install-scoped store under ``<checkout>/.hermes-runtime/python``. A
vulnerable interpreter is never reinstalled in place. We provision a new
immutable Python generation, build and smoke-test a relocatable sibling venv,
then cut over with same-filesystem renames. The old venv remains available for
synchronous rollback and is parked for cleanup after the updating process
releases it.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from hermes_constants import get_hermes_home
from hermes_cli.sqlite_runtime import SQLiteRuntimeInfo, probe_sqlite_runtime
logger = logging.getLogger(__name__)
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
_RUNTIME_DIR_NAME = ".hermes-runtime"
_VENV_NAME = "venv"
_REPAIR_LOCK_NAME = "runtime-repair.lock"
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def managed_uv_path() -> Path:
"""Return the path where Hermes keeps *its* uv binary.
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
Windows. The directory may not exist yet — callers should use
``ensure_uv()`` to bootstrap it.
"""
home = get_hermes_home()
if platform.system() == "Windows":
return home / "bin" / "uv.exe"
return home / "bin" / "uv"
def resolve_uv() -> Optional[str]:
"""Return the managed uv path if it exists, else ``None``.
No side effects — pure lookup.
"""
p = managed_uv_path()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return None
def managed_python_install_dir(project_root: Path | None = None) -> Path:
"""Return the checkout-scoped Python store shared by all profiles."""
root = Path(project_root) if project_root is not None else _PROJECT_ROOT
return root / _RUNTIME_DIR_NAME / "python"
def managed_python_env(
project_root: Path | None = None,
*,
install_dir: Path | None = None,
base_env: dict[str, str] | None = None,
) -> dict[str, str]:
"""Return a sanitized environment for Hermes-private uv Python commands."""
target = (
Path(install_dir)
if install_dir is not None
else managed_python_install_dir(project_root)
)
env = dict(os.environ if base_env is None else base_env)
for key in (
"CONDA_DEFAULT_ENV",
"CONDA_PREFIX",
"UV_PROJECT_ENVIRONMENT",
"UV_NO_MANAGED_PYTHON",
"UV_PYTHON",
"UV_PYTHON_DOWNLOADS",
"UV_SYSTEM_PYTHON",
"VIRTUAL_ENV",
"PYTHONHOME",
"PYTHONPATH",
):
env.pop(key, None)
env.update({
"UV_MANAGED_PYTHON": "1",
"UV_NO_CONFIG": "1",
"UV_PYTHON_INSTALL_BIN": "0",
"UV_PYTHON_INSTALL_DIR": str(target),
"UV_PYTHON_INSTALL_REGISTRY": "0",
})
return env
@dataclass(frozen=True)
class RuntimeRepairResult:
"""Outcome of a managed-runtime repair attempt."""
status: str
detail: str = ""
sqlite_before: str = ""
sqlite_after: str = ""
backup_venv: Path | None = None
@property
def repaired(self) -> bool:
return self.status == "repaired"
@dataclass(frozen=True)
class _RepairLock:
path: Path
fd: int
def _report_runtime_repair_failure(repair: RuntimeRepairResult) -> None:
if repair.backup_venv is None:
print(
" ⚠ Managed Python runtime was not replaced; "
f"the existing venv is unchanged ({repair.detail})."
)
return
print(f" ✗ Managed Python runtime cutover needs manual recovery: {repair.detail}")
print(f" Previous venv: {repair.backup_venv}")
class _UvResult(str):
"""``ensure_uv()`` return value that survives an update boundary.
``ensure_uv()``'s arity has flipped between a single path string and a
``(path, fresh_bootstrap)`` tuple across releases. ``hermes update`` runs
the call site from the *old*, already-imported ``hermes_cli.main`` against
this *freshly pulled* module, so the two can disagree on how many values
``ensure_uv()`` returns. An install parked on a 2-tuple release runs
``uv_bin, fresh_bootstrap = ensure_uv()`` against the single-value module
and crashes the first update: the returned path is a plain ``str``, which is
itself iterable, so the 2-target unpack walks its characters and raises
``ValueError: too many values to unpack (expected 2)`` (and on the failure
path the ``None`` return raises ``TypeError: cannot unpack non-iterable
NoneType``). This wrapper answers to both conventions:
uv_bin = ensure_uv() # behaves as the path str ("" when absent)
uv_bin, fresh = ensure_uv() # unpacks as (path|None, fresh_bootstrap)
Missing uv is the empty string (falsy) instead of ``None`` so legacy
2-target call sites can still unpack a failure without raising, while
``if not uv_bin`` keeps working for single-value callers.
POSIX only. This wrapper is **never** returned on Windows — see
``ensure_uv()`` for why the ``__iter__`` override is unsafe there.
"""
fresh_bootstrap: bool
def __new__(cls, path: Optional[str], fresh: bool = False) -> "_UvResult":
self = super().__new__(cls, path or "")
self.fresh_bootstrap = fresh
return self
def __iter__(self):
# Tuple-unpacking hook for legacy ``uv_bin, fresh = ensure_uv()`` sites.
# First element mirrors the historical contract: the path string, or
# ``None`` when uv is unavailable.
return iter(((str(self) or None), self.fresh_bootstrap))
def _ensure_uv_path(
*,
repair_observer: Callable[[RuntimeRepairResult], None] | None = None,
) -> Optional[str]:
"""Resolve the managed uv path, installing it if necessary (plain ``str``/``None``)."""
existing = resolve_uv()
if existing:
return existing
target = managed_uv_path()
target.parent.mkdir(parents=True, exist_ok=True)
print(f" → Installing managed uv into {target.parent} ...")
try:
_install_uv(target)
except Exception as exc:
logger.warning("Managed uv install failed: %s", exc)
print(f" ✗ Failed to install managed uv: {exc}")
return None
# Verify
result = resolve_uv()
if result:
version = subprocess.run(
[result, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
).stdout.strip()
print(f" ✓ Managed uv installed ({version})")
# Compatibility boundary: an older, already-imported updater calls the
# freshly pulled ``ensure_uv()`` after bootstrapping uv. Repair here so
# that first update can migrate a vulnerable runtime without requiring
# a second ``hermes update``.
try:
repair = repair_vulnerable_runtime(result)
if repair_observer is not None:
repair_observer(repair)
if repair.status == "failed":
_report_runtime_repair_failure(repair)
except Exception as exc:
logger.warning("Managed Python runtime repair failed: %s", exc)
else:
print(" ✗ Managed uv install appeared to succeed but binary not found")
return result
def ensure_uv(
*,
repair_observer: Callable[[RuntimeRepairResult], None] | None = None,
):
"""Return the managed uv path, installing it first if necessary.
On **POSIX** the result is a :class:`_UvResult` (a ``str`` subclass) that is
both usable directly as the path *and* unpackable as
``(path, fresh_bootstrap)`` for older call sites parked on a 2-tuple
release — see :class:`_UvResult` for the update-boundary rationale.
On **Windows** we deliberately return a plain ``str``/``None`` instead.
``subprocess`` there serializes the argv via ``subprocess.list2cmdline``,
which iterates every entry *as a string* (``for c in arg``). The dependency
installer passes uv straight into the command list (``[uv_bin, "pip", ...]``),
so a ``_UvResult`` — whose ``__iter__`` yields ``(path, fresh_bootstrap)``
rather than characters — would inject the bool into the command line and
crash the install with ``TypeError: sequence item 1: expected str instance,
bool found``. A plain ``str`` matches the historical Windows contract and is
subprocess-safe. (A single value cannot satisfy both 2-target unpacking and
Windows char-iteration: both use the iterator protocol, with contradictory
results.)
On failure the result is falsy — never raises — so callers can fall back to
pip gracefully. ``repair_observer``, when provided, receives the runtime
repair result produced after a fresh uv bootstrap.
"""
result = _ensure_uv_path(repair_observer=repair_observer)
if platform.system() == "Windows":
# See docstring: a str subclass with an overridden __iter__ is unsafe as
# a Windows subprocess argument. Hand back the plain path (or None).
return result
return _UvResult(result)
def update_managed_uv(
*,
repair_observer: Callable[[RuntimeRepairResult], None] | None = None,
) -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
Call this during ``hermes update`` so the managed copy stays current.
Returns the managed path when uv is available and ``None`` otherwise.
A self-update failure is non-fatal because the old version still works.
``repair_observer``, when provided, receives the runtime repair result.
"""
existing = resolve_uv()
if not existing:
# Not installed yet — ensure_uv() will handle that elsewhere.
return None
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
)
if result.returncode == 0:
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True, encoding='utf-8', errors='replace',
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
else:
# Non-fatal — old uv still works fine.
logger.debug(
"uv self update failed (rc=%d): %s", result.returncode, result.stderr
)
# Keep this hook inside the long-standing API. During an update, main.py is
# already imported from the old checkout, then ``git pull`` replaces this
# module on disk before the updater imports it. Calling the repair here is
# what makes the migration happen on that first update.
try:
repair = repair_vulnerable_runtime(existing)
if repair_observer is not None:
repair_observer(repair)
if repair.status == "failed":
_report_runtime_repair_failure(repair)
except Exception as exc:
# Runtime refresh is deliberately non-fatal. The live venv was not
# touched unless a fully prepared candidate reached cutover.
logger.warning("Managed Python runtime repair failed: %s", exc)
print(f" ⚠ Managed Python runtime repair skipped: {exc}")
return existing
# ---------------------------------------------------------------------------
# Managed Python runtime repair
# ---------------------------------------------------------------------------
def _venv_python(venv_dir: Path) -> Path:
if platform.system() == "Windows":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _remove_tree(path: Path, *, boundary: Path) -> None:
"""Best-effort removal constrained to a known runtime boundary."""
try:
path.resolve().relative_to(boundary.resolve())
except (OSError, ValueError):
return
shutil.rmtree(path, ignore_errors=True)
def _make_world_traversable(path: Path) -> None:
"""Keep root/FHS-managed runtimes executable by non-root callers."""
try:
path.chmod(path.stat().st_mode | 0o755)
except OSError:
pass
def _runtime_request(info: SQLiteRuntimeInfo) -> str:
"""Pin the candidate to the current CPython minor line (e.g. ``3.11``).
Requesting the exact patch can never repair some installs: for a given
patch, python-build-standalone may have no artifact with fixed SQLite at
all (e.g. every published 3.11.14 build links SQLite 3.50.4; the fix
only exists from 3.11.15). A newer patch on the same minor is what
``uv python install`` would resolve for a fresh install, stays inside
``requires-python``, and the locked ``uv sync`` + import smoke tests gate
compatibility before any cutover.
"""
return ".".join(str(part) for part in info.python_version[:2])
def _install_safe_python_generation(
uv_bin: str,
*,
project_root: Path,
current: SQLiteRuntimeInfo,
) -> tuple[Path, Path, SQLiteRuntimeInfo] | None:
runtime_root = project_root / _RUNTIME_DIR_NAME
python_root = managed_python_install_dir(project_root)
token = f"{int(time.time())}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
generation = python_root / f"generation-{token}"
generation.mkdir(parents=True, exist_ok=False)
for path in (runtime_root, python_root, generation):
_make_world_traversable(path)
env = managed_python_env(
project_root,
install_dir=generation,
)
request = _runtime_request(current)
print(f" → Provisioning a private Python {request} runtime with fixed SQLite...")
install = subprocess.run(
[
uv_bin,
"python",
"install",
request,
"--reinstall",
"--no-bin",
"--no-registry",
"--no-config",
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
check=False,
)
if install.returncode != 0:
logger.warning(
"private Python install failed (rc=%d): %s",
install.returncode,
(install.stderr or install.stdout or "").strip(),
)
_remove_tree(generation, boundary=python_root)
return None
found = subprocess.run(
[
uv_bin,
"python",
"find",
request,
"--managed-python",
"--no-config",
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
check=False,
)
if found.returncode != 0 or not found.stdout.strip():
logger.warning(
"private Python lookup failed (rc=%d): %s",
found.returncode,
(found.stderr or "").strip(),
)
_remove_tree(generation, boundary=python_root)
return None
python = Path(found.stdout.strip().splitlines()[-1])
try:
python.resolve().relative_to(generation.resolve())
except (OSError, ValueError):
logger.warning("uv resolved Python outside the Hermes generation: %s", python)
_remove_tree(generation, boundary=python_root)
return None
candidate = probe_sqlite_runtime(python)
if candidate is None:
logger.warning("could not probe candidate Python runtime: %s", python)
_remove_tree(generation, boundary=python_root)
return None
if candidate.python_version[:2] != current.python_version[:2] or (
candidate.python_version < current.python_version
):
logger.warning(
"candidate Python drifted off the %s minor line or downgraded: %s",
".".join(str(p) for p in current.python_version[:2]),
candidate.python_version,
)
_remove_tree(generation, boundary=python_root)
return None
if candidate.wal_reset_vulnerable:
logger.warning(
"candidate Python still links vulnerable SQLite %s (%s)",
candidate.sqlite_version_string,
candidate.sqlite_source_id,
)
_remove_tree(generation, boundary=python_root)
return None
return generation, python, candidate
def _smoke_candidate_venv(venv_dir: Path) -> tuple[bool, str, SQLiteRuntimeInfo | None]:
"""Exercise the candidate interpreter and imports through its real path."""
python = _venv_python(venv_dir)
info = probe_sqlite_runtime(python)
if info is None:
return False, f"could not execute {python}", None
if info.wal_reset_vulnerable:
return (
False,
f"candidate still links vulnerable SQLite {info.sqlite_version_string}",
info,
)
check = (
"import dotenv, fastapi, openai, prompt_toolkit, pydantic, rich, uvicorn, yaml\n"
"import hermes_state\n"
)
env = dict(os.environ)
for key in (
"CONDA_DEFAULT_ENV",
"CONDA_PREFIX",
"PYTHONHOME",
"PYTHONPATH",
"UV_PROJECT_ENVIRONMENT",
"UV_PYTHON",
"VIRTUAL_ENV",
):
env.pop(key, None)
try:
result = subprocess.run(
[str(python), "-I", "-c", check],
cwd=venv_dir.parent,
env=env,
capture_output=True,
text=True,
timeout=90,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return False, str(exc), info
if result.returncode != 0:
detail = (result.stderr or result.stdout or "core import smoke failed").strip()
last_line = detail.splitlines()[-1] if detail else "core import smoke failed"
return False, last_line, info
return True, "", info
def _stage_candidate_venv(
uv_bin: str,
*,
project_root: Path,
generation: Path,
python: Path,
) -> Path | None:
runtime_root = project_root / _RUNTIME_DIR_NAME
token = f"{int(time.time())}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
candidate = runtime_root / f"venv-candidate-{token}"
env = managed_python_env(
project_root,
install_dir=generation,
)
env.update({
"UV_PROJECT_ENVIRONMENT": str(candidate),
"UV_PYTHON": str(python),
"UV_PYTHON_DOWNLOADS": "never",
"VIRTUAL_ENV": str(candidate),
})
print(" → Building a relocatable replacement environment...")
created = subprocess.run(
[
uv_bin,
"venv",
str(candidate),
"--python",
str(python),
"--managed-python",
"--no-python-downloads",
"--relocatable",
"--no-config",
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
check=False,
)
if created.returncode != 0:
logger.warning(
"candidate venv creation failed (rc=%d): %s",
created.returncode,
(created.stderr or created.stdout or "").strip(),
)
_remove_tree(candidate, boundary=runtime_root)
return None
if not (project_root / "uv.lock").is_file():
logger.warning("candidate dependency sync refused: uv.lock is missing")
_remove_tree(candidate, boundary=runtime_root)
return None
synced = subprocess.run(
[
uv_bin,
"sync",
"--extra",
"all",
"--locked",
"--python",
str(_venv_python(candidate)),
"--no-config",
],
cwd=project_root,
env=env,
check=False,
)
if synced.returncode != 0:
logger.warning("candidate dependency sync failed (rc=%d)", synced.returncode)
_remove_tree(candidate, boundary=runtime_root)
return None
healthy, detail, _ = _smoke_candidate_venv(candidate)
if not healthy:
logger.warning("candidate venv smoke failed: %s", detail)
_remove_tree(candidate, boundary=runtime_root)
return None
return candidate
def _rename_with_retry(source: Path, destination: Path) -> None:
last_error: OSError | None = None
for delay in (0.0, 0.1, 0.25, 0.5, 1.0):
if delay:
time.sleep(delay)
try:
source.rename(destination)
return
except OSError as exc:
last_error = exc
if last_error is not None:
raise last_error
def _cut_over_candidate(
candidate: Path,
*,
project_root: Path,
live: Path | None = None,
) -> tuple[bool, Path | None, SQLiteRuntimeInfo | None, str]:
live = live if live is not None else project_root / _VENV_NAME
runtime_root = project_root / _RUNTIME_DIR_NAME
token = f"{int(time.time())}-{os.getpid()}-{uuid.uuid4().hex[:8]}"
backup = live.with_name(f"{live.name}.stale.runtime-{token}")
rejected = runtime_root / f"venv-rejected-{token}"
try:
try:
_rename_with_retry(live, backup)
except OSError as exc:
return False, None, None, f"could not park the existing venv: {exc}"
try:
_rename_with_retry(candidate, live)
except OSError as promote_error:
try:
_rename_with_retry(backup, live)
except OSError as rollback_error:
return (
False,
backup,
None,
"could not promote the replacement venv "
f"({promote_error}); rollback failed ({rollback_error})",
)
return (
False,
None,
None,
f"could not promote the replacement venv: {promote_error}",
)
try:
healthy, detail, info = _smoke_candidate_venv(live)
except Exception as exc:
healthy, detail, info = False, f"candidate smoke raised: {exc}", None
if healthy:
return True, backup, info, ""
try:
_rename_with_retry(live, rejected)
_rename_with_retry(backup, live)
except OSError as exc:
return (
False,
backup,
info,
"post-cutover smoke failed "
f"({detail}); rollback failed ({exc}); rejected venv: {rejected}",
)
_remove_tree(rejected, boundary=runtime_root)
return False, None, info, f"post-cutover smoke failed: {detail}"
except BaseException:
if not live.exists() and backup.exists():
try:
_rename_with_retry(backup, live)
except OSError as exc:
logger.error(
"interrupted runtime cutover could not restore %s from %s: %s",
live,
backup,
exc,
)
raise
def _acquire_repair_lock(runtime_root: Path) -> _RepairLock | None:
"""Acquire an OS-held install lock that is released on process exit."""
runtime_root.mkdir(parents=True, exist_ok=True)
_make_world_traversable(runtime_root)
path = runtime_root / _REPAIR_LOCK_NAME
try:
fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
except OSError:
return None
try:
if os.name == "nt":
import msvcrt
if os.fstat(fd).st_size == 0:
os.write(fd, b"\0")
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except (ImportError, OSError):
os.close(fd)
return None
return _RepairLock(path=path, fd=fd)
def _release_repair_lock(lock: _RepairLock) -> None:
try:
if os.name == "nt":
import msvcrt
os.lseek(lock.fd, 0, os.SEEK_SET)
msvcrt.locking(lock.fd, msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(lock.fd, fcntl.LOCK_UN)
except (ImportError, OSError):
pass
finally:
try:
os.close(lock.fd)
except OSError:
pass
def _windows_runtime_holders() -> tuple[bool, str]:
if platform.system() != "Windows":
return False, ""
main_module = sys.modules.get("hermes_cli.main")
detector = getattr(main_module, "_detect_venv_python_processes", None)
if detector is None:
return True, "cannot verify Windows venv holders from this update context"
try:
holders = detector()
except Exception as exc:
return True, f"could not verify Windows venv holders: {exc}"
if holders:
pids = ", ".join(str(item[0]) for item in holders[:6])
return True, f"other Hermes processes still hold the venv (PID {pids})"
return False, ""
def repair_vulnerable_runtime(
uv_bin: str,
*,
project_root: Path | None = None,
venv_dir: Path | None = None,
) -> RuntimeRepairResult:
"""Replace a vulnerable install venv without mutating it in place.
Every failure before cutover leaves the live venv untouched. Rename or
post-cutover smoke failures restore the parked venv synchronously.
"""
root = Path(project_root) if project_root is not None else _PROJECT_ROOT
live = Path(venv_dir) if venv_dir is not None else root / _VENV_NAME
live_python = _venv_python(live)
if not (root / "pyproject.toml").is_file() or not live_python.is_file():
return RuntimeRepairResult("not-applicable")
current = probe_sqlite_runtime(live_python)
if current is None:
return RuntimeRepairResult(
"skipped",
f"could not probe live interpreter {live_python}",
)
if not current.wal_reset_vulnerable:
return RuntimeRepairResult(
"safe",
sqlite_before=current.sqlite_version_string,
sqlite_after=current.sqlite_version_string,
)
blocked, detail = _windows_runtime_holders()
if blocked:
print(f" ⚠ SQLite runtime repair deferred: {detail}")
return RuntimeRepairResult(
"skipped",
detail,
sqlite_before=current.sqlite_version_string,
)
runtime_root = root / _RUNTIME_DIR_NAME
lock = _acquire_repair_lock(runtime_root)
if lock is None:
detail = "another runtime repair is already in progress"
print(f" ⚠ SQLite runtime repair deferred: {detail}")
return RuntimeRepairResult(
"skipped",
detail,
sqlite_before=current.sqlite_version_string,
)
generation: Path | None = None
candidate: Path | None = None
try:
# Re-probe under the install-scoped lock: another updater may have
# completed the repair while this process was entering the path.
current = probe_sqlite_runtime(live_python)
if current is None:
return RuntimeRepairResult("skipped", "live interpreter probe failed")
if not current.wal_reset_vulnerable:
return RuntimeRepairResult(
"safe",
sqlite_before=current.sqlite_version_string,
sqlite_after=current.sqlite_version_string,
)
print(
" ⚠ Hermes venv links SQLite "
f"{current.sqlite_version_string}, which has the WAL-reset bug."
)
provisioned = _install_safe_python_generation(
uv_bin,
project_root=root,
current=current,
)
if provisioned is None:
return RuntimeRepairResult(
"failed",
"could not provision a fixed private Python runtime",
sqlite_before=current.sqlite_version_string,
)
generation, python, candidate_info = provisioned
candidate = _stage_candidate_venv(
uv_bin,
project_root=root,
generation=generation,
python=python,
)
if candidate is None:
_remove_tree(generation, boundary=managed_python_install_dir(root))
return RuntimeRepairResult(
"failed",
"replacement environment did not pass dependency and import smoke tests",
sqlite_before=current.sqlite_version_string,
sqlite_after=candidate_info.sqlite_version_string,
)
cut_over, backup, final_info, cutover_detail = _cut_over_candidate(
candidate,
project_root=root,
live=live,
)
if not cut_over:
if backup is None:
_remove_tree(candidate, boundary=runtime_root)
_remove_tree(generation, boundary=managed_python_install_dir(root))
return RuntimeRepairResult(
"failed",
cutover_detail,
sqlite_before=current.sqlite_version_string,
sqlite_after=(
final_info.sqlite_version_string if final_info is not None else ""
),
backup_venv=backup,
)
final_version = (
final_info.sqlite_version_string
if final_info is not None
else candidate_info.sqlite_version_string
)
print(
" ✓ Managed Python runtime repaired "
f"(SQLite {current.sqlite_version_string}{final_version})"
)
if backup is not None:
print(
f" Previous venv parked at {backup.name}; "
"keep it until all older Hermes processes have exited."
)
return RuntimeRepairResult(
"repaired",
sqlite_before=current.sqlite_version_string,
sqlite_after=final_version,
backup_venv=backup,
)
finally:
_release_repair_lock(lock)
# ---------------------------------------------------------------------------
# Installer internals
# ---------------------------------------------------------------------------
def _install_uv(target: Path) -> None:
"""Bootstrap uv into *target* using the official standalone installer.
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
so the astral installer writes the binary directly into
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
"""
system = platform.system()
env = {
**os.environ,
# Tell the astral installer to drop the binary in our dir, not
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
# uses UV_INSTALL_DIR.
"UV_UNMANAGED_INSTALL": str(target.parent),
"UV_INSTALL_DIR": str(target.parent),
}
if system == "Windows":
_install_uv_windows(env)
else:
_install_uv_posix(env)
def _install_uv_posix(env: dict[str, str]) -> None:
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
installer_path = f.name
try:
subprocess.run(
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
check=True,
capture_output=True,
)
subprocess.run(
["sh", installer_path],
env=env,
check=True,
capture_output=True,
)
finally:
try:
os.unlink(installer_path)
except OSError:
pass
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = "irm https://astral.sh/uv/install.ps1 | iex"
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
True # dont remove me. ask ethernet