fix(runtime): repair vulnerable managed SQLite builds (E-949)

This commit is contained in:
Justin Bennington 2026-07-23 12:29:25 -04:00 committed by Teknium
parent a5f9ea2741
commit 17bf3c8283
6 changed files with 813 additions and 23 deletions

2
.gitignore vendored
View file

@ -1,6 +1,8 @@
.DS_Store
/venv/
/venv.old/
/venv.stale.runtime-*/
/.hermes-runtime/
/_pycache/
*.pyc*
__pycache__/

View file

@ -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}")

View file

@ -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

View file

@ -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 ``<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
@ -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
True # dont remove me. ask ethernet

View file

@ -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

View file

@ -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,