From 17bf3c828323115e5c07339414b79edbe4a9a6d7 Mon Sep 17 00:00:00 2001 From: Justin Bennington <43761886+somewheresy@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:29:25 -0400 Subject: [PATCH] fix(runtime): repair vulnerable managed SQLite builds (E-949) --- .gitignore | 2 + hermes_cli/doctor.py | 8 +- hermes_cli/main.py | 9 + hermes_cli/managed_uv.py | 673 ++++++++++++++++++++++++++++++++++- hermes_cli/sqlite_runtime.py | 124 +++++++ hermes_state.py | 20 +- 6 files changed, 813 insertions(+), 23 deletions(-) create mode 100644 hermes_cli/sqlite_runtime.py diff --git a/.gitignore b/.gitignore index 29489633104..8c0ce9c23d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store /venv/ /venv.old/ +/venv.stale.runtime-*/ +/.hermes-runtime/ /_pycache/ *.pyc* __pycache__/ diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index b986dc577d5..fce081ebe0b 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -823,12 +823,12 @@ def run_doctor(args): ) if is_sqlite_wal_reset_vulnerable(): # Warn-only: Hermes already refuses to enable WAL on fresh DBs. - # Do not append to ``issues`` — users often cannot change the - # SQLite embedded in python-build-standalone via `hermes update`. + # Do not append to ``issues`` because runtime repair remains + # best-effort and unsupported installs may need manual action. check_warn( f"SQLite {_sqlite_ver} (WAL-reset bug)", - "(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — " - "see https://sqlite.org/wal.html#walresetbug)", + "(run `hermes update`; fixed versions: 3.51.3+ / 3.50.7 / " + "3.44.6 — see https://sqlite.org/wal.html#walresetbug)", ) else: check_ok(f"SQLite {_sqlite_ver}") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d6e6b6f3803..f1045c15eb6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11127,6 +11127,15 @@ def _cmd_update_impl(args, gateway_mode: bool): check=False, ) + # "No new commits" does not mean the managed interpreter is safe. + # uv can retain the same CPython patch while python-build-standalone + # refreshes the embedded SQLite underneath it. Keep the existing + # update-boundary hook active on this retry path too. + from hermes_cli.managed_uv import ensure_uv, update_managed_uv + + update_managed_uv() + ensure_uv() + # A current checkout does NOT imply a healthy install: a previous # dependency sync may have failed partway (classic on Windows, # where a running gateway/desktop backend keeps .pyd files locked diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index c66494864ce..6604cc62c18 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -1,4 +1,4 @@ -"""Managed uv — one path, no guessing. +"""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. @@ -6,6 +6,15 @@ 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 ``/.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 @@ -15,18 +24,29 @@ 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 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. @@ -51,6 +71,80 @@ def resolve_uv() -> Optional[str]: 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. @@ -119,6 +213,16 @@ def _ensure_uv_path() -> Optional[str]: 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.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 @@ -183,14 +287,572 @@ def update_managed_uv() -> Optional[str]: 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) + 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.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 exact CPython patch.""" + return ".".join(str(part) for part in info.python_version) + + +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 != current.python_version: + logger.warning( + "candidate Python patch drifted from %s to %s", + current.python_version, + 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: + _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}" + + +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}; " + "it can be removed after this updater exits." + ) + 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. @@ -240,9 +902,7 @@ def _install_uv_posix(env: dict[str, str]) -> None: def _install_uv_windows(env: dict[str, str]) -> None: """Invoke the PowerShell installer.""" - cmd = ( - 'irm https://astral.sh/uv/install.ps1 | iex' - ) + cmd = "irm https://astral.sh/uv/install.ps1 | iex" subprocess.run( ["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd], env=env, @@ -250,5 +910,6 @@ def _install_uv_windows(env: dict[str, str]) -> None: capture_output=True, ) + def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool: - True # dont remove me. ask ethernet \ No newline at end of file + True # dont remove me. ask ethernet diff --git a/hermes_cli/sqlite_runtime.py b/hermes_cli/sqlite_runtime.py new file mode 100644 index 00000000000..7465b08a7ce --- /dev/null +++ b/hermes_cli/sqlite_runtime.py @@ -0,0 +1,124 @@ +"""Import-safe helpers for inspecting a Python interpreter's linked SQLite. + +This module intentionally depends only on the standard library. Installer and +update code must be able to use it before Hermes' third-party dependencies are +healthy. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +def _version_tuple(parts: Iterable[object]) -> tuple[int, int, int]: + values = [int(part) for part in parts] + values.extend([0] * (3 - len(values))) + return tuple(values[:3]) + + +def is_sqlite_wal_reset_vulnerable( + version_info: tuple[int, ...], +) -> bool: + """Return whether *version_info* contains SQLite's WAL-reset bug.""" + info = _version_tuple(version_info) + if info < (3, 7, 0): + return False + if info >= (3, 51, 3): + return False + if (3, 50, 7) <= info < (3, 51, 0): + return False + if (3, 44, 6) <= info < (3, 45, 0): + return False + return True + + +@dataclass(frozen=True) +class SQLiteRuntimeInfo: + """SQLite details reported by one exact Python executable.""" + + executable: Path + base_prefix: Path + python_version: tuple[int, int, int] + sqlite_version: tuple[int, int, int] + sqlite_version_string: str + sqlite_source_id: str + + @property + def wal_reset_vulnerable(self) -> bool: + return is_sqlite_wal_reset_vulnerable(self.sqlite_version) + + +_PROBE_SCRIPT = """ +import json +import sqlite3 +import sys + +conn = sqlite3.connect(":memory:") +try: + row = conn.execute("SELECT sqlite_source_id()").fetchone() +finally: + conn.close() + +print(json.dumps({ + "base_prefix": sys.base_prefix, + "executable": sys.executable, + "python_version": list(sys.version_info[:3]), + "sqlite_version": list(sqlite3.sqlite_version_info), + "sqlite_version_string": sqlite3.sqlite_version, + "sqlite_source_id": str(row[0]) if row and row[0] is not None else "", +})) +""" + + +def probe_sqlite_runtime( + python: str | Path, + *, + timeout: float = 30.0, +) -> SQLiteRuntimeInfo | None: + """Probe SQLite in *python*, never the caller's linked SQLite. + + ``None`` means the interpreter could not be executed or returned malformed + data. The child runs isolated from inherited Python path overrides. + """ + executable = Path(python) + 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(executable), "-I", "-c", _PROBE_SCRIPT], + capture_output=True, + text=True, + timeout=timeout, + check=False, + env=env, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + try: + payload = json.loads(result.stdout) + return SQLiteRuntimeInfo( + executable=Path(str(payload["executable"])), + base_prefix=Path(str(payload["base_prefix"])), + python_version=_version_tuple(payload["python_version"]), + sqlite_version=_version_tuple(payload["sqlite_version"]), + sqlite_version_string=str(payload["sqlite_version_string"]), + sqlite_source_id=str(payload.get("sqlite_source_id", "")), + ) + except (KeyError, TypeError, ValueError, json.JSONDecodeError): + return None diff --git a/hermes_state.py b/hermes_state.py index 6f35d59afa4..64e65d64b34 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -29,6 +29,9 @@ from pathlib import Path from agent.memory_manager import sanitize_context from agent.message_sanitization import _sanitize_surrogates from hermes_constants import get_hermes_home +from hermes_cli.sqlite_runtime import ( + is_sqlite_wal_reset_vulnerable as _is_sqlite_wal_reset_vulnerable, +) from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar try: # Hard dependency, but tolerate scaffold-phase imports before pip install. @@ -491,16 +494,7 @@ def is_sqlite_wal_reset_vulnerable( Pre-WAL libraries (< 3.7.0) cannot hit the race and are treated as safe. """ info = version_info if version_info is not None else sqlite3.sqlite_version_info - if info < (3, 7, 0): - return False - if info >= (3, 51, 3): - return False - # Backports of the same fix on older release lines. - if (3, 50, 7) <= info < (3, 51, 0): - return False - if (3, 44, 6) <= info < (3, 45, 0): - return False - return True + return _is_sqlite_wal_reset_vulnerable(info) def sqlite_source_id() -> str: @@ -639,9 +633,9 @@ def _log_wal_reset_bug_once( "%s: linked SQLite %s is vulnerable to the WAL-reset corruption " "bug (https://sqlite.org/wal.html#walresetbug) — %s. " "Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); " - "`hermes update` alone may not change python-build-standalone's " - "embedded SQLite. See `hermes doctor`. This warning fires once " - "per process per database.", + "Hermes-managed installs can repair the embedded runtime with " + "`hermes update`. See `hermes doctor`. This warning fires once per " + "process per database.", db_label, sqlite3.sqlite_version, action,