mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(runtime): repair vulnerable managed SQLite builds (E-949)
This commit is contained in:
parent
a5f9ea2741
commit
17bf3c8283
6 changed files with 813 additions and 23 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
|||
.DS_Store
|
||||
/venv/
|
||||
/venv.old/
|
||||
/venv.stale.runtime-*/
|
||||
/.hermes-runtime/
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
124
hermes_cli/sqlite_runtime.py
Normal file
124
hermes_cli/sqlite_runtime.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue