Conflicts: - hermes_bootstrap.py: main calls install_never_free_environ() in the apply-on-import block right after the console fixes, where pm-clean runs its PM block (activation, relaunch, environ writes) instead of activate_durable_lazy_target(). It now runs ahead of the whole PM block, main's order, so the glibc < 2.41 guard is in before anything writes os.environ. - tests/test_hermes_bootstrap.py: pm-clean dissolved the entry-point class and dropped its source-reading test; main's new test_library_imports_of_dual_use_entry_modules_stay_side_effect_free lands as a module-level function.
519 lines
24 KiB
Python
519 lines
24 KiB
Python
"""Process bootstrap for Hermes entry points: Windows UTF-8 stdio, import-path
|
|
hardening, durable lazy-install target, and dual-stack (Happy Eyeballs) connects.
|
|
|
|
Windows binds stdio to the console code page (cp1252), so ``print("café")`` raises
|
|
``UnicodeEncodeError``, and Python children inherit the same default unless
|
|
``PYTHONUTF8``/``PYTHONIOENCODING`` are set. Import this module first in every entry
|
|
point (``hermes``, ``hermes-agent``, ``hermes-acp``, ``gateway.run``, ``batch_runner``,
|
|
``cron/scheduler``). It does NOT re-exec with ``-X utf8``: ``open()`` in the current
|
|
process still needs an explicit ``encoding="utf-8"`` (ruff ``PLW1514``). POSIX is left
|
|
alone deliberately — users' ``LANG``/``LC_*`` choices are respected.
|
|
|
|
Stdlib only: entry points import this before ``harden_import_path()`` runs, so nothing
|
|
here may pull in a Hermes package that a project-local directory could shadow.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import importlib.abc
|
|
import importlib.util
|
|
import os
|
|
import selectors
|
|
import socket
|
|
import sys
|
|
import time
|
|
|
|
_IS_WINDOWS = sys.platform == "win32"
|
|
_bootstrap_applied = False
|
|
_HAPPY_EYEBALLS_DELAY_SECONDS = 0.25
|
|
_URLLIB3_CONNECTION_MODULE = "urllib3.util.connection"
|
|
|
|
|
|
def _interleave_addrinfos(addrinfos: list[tuple]) -> list[tuple]:
|
|
"""Round-robin the resolved address families (deduped), preserving resolver order within each."""
|
|
queues: dict[int, list[tuple]] = {}
|
|
seen: set[tuple] = set()
|
|
for addrinfo in addrinfos:
|
|
family, socktype, proto, _canonname, sockaddr = addrinfo
|
|
if (family, socktype, proto, sockaddr) not in seen:
|
|
seen.add((family, socktype, proto, sockaddr))
|
|
queues.setdefault(family, []).append(addrinfo)
|
|
interleaved: list[tuple] = []
|
|
while any(queues.values()):
|
|
interleaved.extend(queue.pop(0) for queue in queues.values() if queue)
|
|
return interleaved
|
|
|
|
|
|
def _quiet_unregister(selector, sock) -> None:
|
|
try:
|
|
selector.unregister(sock)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _happy_eyeballs_create_connection(address: tuple[str, int], timeout: float | None,
|
|
source_address: tuple[str, int] | None = None, socket_options=()):
|
|
"""RFC 8305-style connect: staggered non-blocking attempts across families.
|
|
|
|
``socket.create_connection`` tries addresses serially, so broken-but-
|
|
advertised IPv6 can burn the whole timeout per AAAA record before IPv4.
|
|
"""
|
|
host, port = address
|
|
addrinfos = _interleave_addrinfos(socket.getaddrinfo(host, port, type=socket.SOCK_STREAM))
|
|
if not addrinfos:
|
|
raise OSError(f"getaddrinfo returned no addresses for {host}")
|
|
|
|
selector = selectors.DefaultSelector()
|
|
active: set[socket.socket] = set()
|
|
winner = None
|
|
last_error: OSError | None = None
|
|
deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0)
|
|
next_launch = time.monotonic()
|
|
pending = list(addrinfos)
|
|
in_progress = {0, errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EALREADY, errno.EINTR, getattr(errno, "WSAEWOULDBLOCK", 10035)}
|
|
|
|
def start_attempt(addrinfo):
|
|
family, socktype, proto, _canonname, sockaddr = addrinfo
|
|
candidate = socket.socket(family, socktype, proto)
|
|
try:
|
|
if source_address is not None:
|
|
local_infos = socket.getaddrinfo(source_address[0], source_address[1], family=family, type=socktype)
|
|
if not local_infos:
|
|
raise OSError(f"getaddrinfo returned no local {family} address for {source_address[0]}")
|
|
candidate.bind(local_infos[0][4])
|
|
candidate.setblocking(False)
|
|
result = candidate.connect_ex(sockaddr)
|
|
if result in (0, errno.EISCONN):
|
|
return candidate
|
|
if result not in in_progress:
|
|
raise OSError(result, os.strerror(result))
|
|
selector.register(candidate, selectors.EVENT_WRITE)
|
|
active.add(candidate)
|
|
return None
|
|
except Exception:
|
|
candidate.close()
|
|
raise
|
|
|
|
try:
|
|
while pending or active:
|
|
now = time.monotonic()
|
|
if deadline is not None and now >= deadline:
|
|
raise socket.timeout("timed out")
|
|
if pending and now >= next_launch:
|
|
try:
|
|
winner = start_attempt(pending.pop(0))
|
|
except OSError as exc:
|
|
last_error = exc
|
|
if not active:
|
|
next_launch = now
|
|
continue
|
|
if winner is not None:
|
|
break
|
|
next_launch = now + _HAPPY_EYEBALLS_DELAY_SECONDS
|
|
wait_timeout = None if deadline is None else max(0.0, deadline - now)
|
|
if pending:
|
|
until_launch = max(0.0, next_launch - now)
|
|
wait_timeout = until_launch if wait_timeout is None else min(wait_timeout, until_launch)
|
|
for key, _mask in selector.select(wait_timeout):
|
|
candidate = key.fileobj
|
|
error_code = candidate.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
|
selector.unregister(candidate)
|
|
active.discard(candidate)
|
|
if error_code == 0:
|
|
winner = candidate
|
|
break
|
|
candidate.close()
|
|
last_error = OSError(error_code, os.strerror(error_code))
|
|
if winner is not None:
|
|
break
|
|
if not active and pending:
|
|
next_launch = time.monotonic()
|
|
|
|
if winner is None:
|
|
raise last_error if last_error is not None else OSError(f"Could not connect to {host}:{port}")
|
|
_quiet_unregister(selector, winner)
|
|
active.discard(winner)
|
|
winner.settimeout(timeout)
|
|
for option in socket_options or ():
|
|
winner.setsockopt(*option)
|
|
winner.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
return winner
|
|
finally:
|
|
for candidate in active:
|
|
_quiet_unregister(selector, candidate)
|
|
candidate.close()
|
|
selector.close()
|
|
|
|
|
|
def _patch_urllib3_create_connection(module) -> None:
|
|
"""Point ``urllib3.util.connection.create_connection`` (its own serial walker) at the racer."""
|
|
if getattr(module.create_connection, "_hermes_happy_eyeballs", False):
|
|
return
|
|
urllib3_sentinel = module._DEFAULT_TIMEOUT
|
|
|
|
def _urllib3_racer(address, timeout=urllib3_sentinel, source_address=None, socket_options=None):
|
|
effective = socket.getdefaulttimeout() if timeout is urllib3_sentinel else timeout
|
|
# OSError = every candidate failed (identical to the serial original); anything else is a
|
|
# racer bug and must surface rather than silently fall back to the serial stall.
|
|
return _happy_eyeballs_create_connection(
|
|
address, effective, source_address=source_address, socket_options=tuple(socket_options or ()))
|
|
|
|
_urllib3_racer._hermes_happy_eyeballs = True # type: ignore[attr-defined]
|
|
module.create_connection = _urllib3_racer
|
|
|
|
|
|
class _Urllib3ConnectionPatcher(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
|
"""One-shot import hook: patch urllib3's connect walker the moment the module loads.
|
|
|
|
Importing urllib3 eagerly costs ~50 ms on every CLI start, and ``hermes`` / the TUI
|
|
gateway never load it unless something actually calls ``requests``.
|
|
"""
|
|
|
|
def find_spec(self, fullname, path, target=None):
|
|
if fullname != _URLLIB3_CONNECTION_MODULE:
|
|
return None
|
|
if self in sys.meta_path:
|
|
sys.meta_path.remove(self)
|
|
spec = importlib.util.find_spec(fullname)
|
|
if spec is None or spec.loader is None:
|
|
return None
|
|
self._inner = spec.loader
|
|
spec.loader = self
|
|
return spec
|
|
|
|
def create_module(self, spec):
|
|
return self._inner.create_module(spec)
|
|
|
|
def exec_module(self, module):
|
|
self._inner.exec_module(module)
|
|
_patch_urllib3_create_connection(module)
|
|
|
|
|
|
def install_happy_eyeballs_socket_connect() -> None:
|
|
"""Race IPv6/IPv4 for every sync TCP connect in the process (RFC 8305, #114265).
|
|
|
|
The startup path does not build its HTTP clients in one place: the model catalog
|
|
fetch goes through ``requests``/``urllib3``, sync LLM and OAuth clients through
|
|
httpcore, plugins through ``urllib``/``http.client``. All of them funnel their TCP
|
|
connect into ``socket.create_connection`` (``http.client`` re-reads it per connection;
|
|
httpcore looks it up at call time) or into urllib3's own serial copy in
|
|
``urllib3.util.connection``. The stock implementations walk the ``getaddrinfo``
|
|
results serially — on a network whose advertised IPv6 route is blackholed, each AAAA
|
|
record burns the full connect timeout before IPv4 answers. Idempotent, best-effort.
|
|
"""
|
|
if getattr(socket.create_connection, "_hermes_happy_eyeballs", False):
|
|
return
|
|
|
|
def _socket_racer(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None, *, all_errors=False):
|
|
# Stock create_connection leaves the sentinel alone, so the socket keeps the
|
|
# process default from socket.setdefaulttimeout(); the racer re-applies the
|
|
# timeout on the winner, so it must resolve the sentinel the same way.
|
|
effective = socket.getdefaulttimeout() if timeout is socket._GLOBAL_DEFAULT_TIMEOUT else timeout
|
|
# OSError = every candidate failed (identical to the serial original); anything else is a
|
|
# racer bug and must surface rather than silently fall back to the serial stall.
|
|
return _happy_eyeballs_create_connection(address, effective, source_address=source_address)
|
|
|
|
_socket_racer._hermes_happy_eyeballs = True # type: ignore[attr-defined]
|
|
socket.create_connection = _socket_racer
|
|
|
|
urllib3_connection = sys.modules.get(_URLLIB3_CONNECTION_MODULE)
|
|
if urllib3_connection is not None:
|
|
_patch_urllib3_create_connection(urllib3_connection)
|
|
elif not any(isinstance(finder, _Urllib3ConnectionPatcher) for finder in sys.meta_path):
|
|
sys.meta_path.insert(0, _Urllib3ConnectionPatcher())
|
|
|
|
|
|
def apply_windows_utf8_bootstrap() -> bool:
|
|
"""Apply the Windows UTF-8 bootstrap once; True only when it was applied this call."""
|
|
global _bootstrap_applied
|
|
|
|
if not _IS_WINDOWS or _bootstrap_applied:
|
|
return False
|
|
|
|
# setdefault() so a user can opt out with PYTHONUTF8=0 / PYTHONIOENCODING=...
|
|
os.environ.setdefault("PYTHONUTF8", "1")
|
|
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
|
|
|
# os.environ changes don't rebind streams bound at interpreter startup, so
|
|
# reconfigure them in-process. errors="replace" keeps a non-UTF-8 legacy
|
|
# pipe on stdin from crashing us (U+FFFD instead of an exception).
|
|
# Non-TextIOWrapper streams (BytesIO in tests, embedded hosts) have no
|
|
# reconfigure(): skip — the env-var fix for children is the bigger win.
|
|
for stream_name in ("stdout", "stderr", "stdin"):
|
|
reconfigure = getattr(getattr(sys, stream_name, None), "reconfigure", None)
|
|
if reconfigure is None:
|
|
continue
|
|
try:
|
|
reconfigure(encoding="utf-8", errors="replace")
|
|
except (OSError, ValueError):
|
|
pass # closed, or replaced with something non-reconfigurable
|
|
|
|
_bootstrap_applied = True
|
|
return True
|
|
|
|
|
|
def suppress_platform_ver_console() -> None:
|
|
"""Stub ``platform._syscmd_ver`` on Windows — decode-crash + console-flash guard.
|
|
|
|
``platform.win32_ver()`` (reached via ``platform.platform()``, which the OpenAI SDK
|
|
calls) shells out ``cmd /c ver`` with ``shell=True`` and no ``CREATE_NO_WINDOW``: a
|
|
windowless parent (pythonw gateway, slash/kanban workers) flashes a console per call,
|
|
and Python 3.11.0/3.11.1 (no ``encoding="locale"`` fix) strict-utf-8-decodes the OEM
|
|
code page output under PEP 540 mode and raises (#69413). Returning the inputs makes
|
|
``win32_ver()`` fall back to ``sys.getwindowsversion()`` — same data, no subprocess.
|
|
Mirrors ``hermes_cli._subprocess_compat.suppress_platform_ver_console`` for callers
|
|
that never import ``hermes_cli.main``; double application is harmless.
|
|
"""
|
|
if not _IS_WINDOWS:
|
|
return
|
|
try:
|
|
import platform
|
|
|
|
if hasattr(platform, "_syscmd_ver"):
|
|
def _quiet_syscmd_ver(system="", release="", version="",
|
|
supported_platforms=("win32", "win16", "dos")):
|
|
return system, release, version
|
|
|
|
platform._syscmd_ver = _quiet_syscmd_ver
|
|
except Exception:
|
|
pass # hardening only — never break an entry point
|
|
|
|
|
|
def _glibc_frees_environ() -> bool:
|
|
"""True on glibc < 2.41, whose ``setenv`` of a NEW name reallocs the ``environ`` array
|
|
and frees the old one (2.41+ never frees it, so a concurrent ``getenv`` stays safe)."""
|
|
if not sys.platform.startswith("linux"):
|
|
return False
|
|
try:
|
|
libc, _, version = (os.confstr("CS_GNU_LIBC_VERSION") or "").partition(" ")
|
|
return libc == "glibc" and tuple(int(p) for p in version.split(".")[:2]) < (2, 41)
|
|
except (AttributeError, OSError, TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def install_never_free_environ() -> None:
|
|
"""Make ``os.environ`` writes safe against native ``getenv`` in other threads.
|
|
|
|
On glibc < 2.41 adding a name reallocs ``environ`` and frees the old array while a
|
|
thread that dropped the GIL (``getaddrinfo``, OpenSSL's ``SSL_CERT_FILE`` lookup) may
|
|
still be walking it; the freed slots hold tcache pointers, so the walk segfaults the
|
|
whole process. Hermes writes new names at runtime from many places (``session.create``
|
|
turns on gateway prompts, the agent build sets ``HERMES_SESSION_ID``) while background
|
|
threads fetch catalogs, so the tui_gateway died with SIGSEGV. This is glibc 2.41's own
|
|
fix: entry strings are cached per ``NAME=value`` and never freed; a new name is
|
|
appended in place to an array with spare room, and only a full array is replaced by
|
|
a bigger one, the old one kept forever. Set/del churn of the same names therefore
|
|
allocates nothing after the first cycle.
|
|
|
|
Residual: a NEW-name ``setenv`` from native code (a C or Rust extension, not
|
|
``os.environ``) bypasses the lock and still reallocs glibc's own last array, so a
|
|
``getenv`` that started walking that array before our swap can still fault. None of
|
|
the gateway's crash paths do this.
|
|
"""
|
|
if getattr(os.putenv, "_hermes_never_free_environ", False) or not _glibc_frees_environ():
|
|
return
|
|
import _thread
|
|
import ctypes
|
|
|
|
try:
|
|
libc = ctypes.CDLL(None)
|
|
environ = ctypes.c_void_p.in_dll(libc, "environ")
|
|
getenv = libc.getenv
|
|
except (AttributeError, OSError, ValueError):
|
|
return
|
|
getenv.restype, getenv.argtypes = ctypes.c_void_p, [ctypes.c_char_p]
|
|
real_putenv, real_unsetenv = os.putenv, os.unsetenv
|
|
# Two unserialized writers both copy the live array and the later publish drops the
|
|
# other's new name or undoes its replacement. Reentrant: audit hooks run inside it.
|
|
lock = _thread.RLock()
|
|
# A fork while another thread holds the lock would leave it held forever in the child.
|
|
os.register_at_fork(before=lock.acquire, after_in_parent=lock.release, after_in_child=lock.release)
|
|
lines: dict[bytes, ctypes.Array] = {} # b"NAME=value" -> its C string (glibc's known_values)
|
|
arrays: list[tuple[ctypes.Array, int]] = [] # every array we published + its address; only the last grows
|
|
gen = [0] # bumped by every publish, so a nested write inside an audited call forces a redo
|
|
|
|
def _putenv(key, value) -> None:
|
|
name, val = os.fsencode(key), os.fsencode(value)
|
|
if not name or b"=" in name or b"\0" in name + val:
|
|
real_putenv(key, value) # the usual OSError/ValueError
|
|
return
|
|
sys.audit("os.putenv", name, val)
|
|
prefix = name + b"="
|
|
with lock:
|
|
if (line := lines.get(prefix + val)) is None:
|
|
line = lines[prefix + val] = ctypes.create_string_buffer(prefix + val)
|
|
entry = ctypes.addressof(line)
|
|
# create_string_buffer/addressof above are audited and a hook may write os.environ
|
|
# re-entrantly (RLock): redo the read if any write was published before ours. The loop
|
|
# itself makes no audited call, so a hook that writes on every event cannot spin it.
|
|
while True:
|
|
start = gen[0]
|
|
# getenv returns a pointer just past "NAME=" inside the matching entry, so the
|
|
# walk compares pointers instead of reading every string.
|
|
found = getenv(name)
|
|
target = found - len(prefix) if found else None
|
|
live = ctypes.cast(environ.value, ctypes.POINTER(ctypes.c_void_p)) if environ.value else None
|
|
n, hit = 0, False
|
|
while live and (current := live[n]):
|
|
if current == target:
|
|
hit = True
|
|
break
|
|
n += 1
|
|
own, own_addr = arrays[-1] if arrays else (None, None)
|
|
grow = not hit and not (own is not None and environ.value == own_addr and n + 2 <= len(own))
|
|
if grow:
|
|
fresh = (ctypes.c_void_p * max(2 * (n + 2), 64))(*(live[:n] if live else ()), entry)
|
|
fresh_addr = ctypes.cast(fresh, ctypes.c_void_p).value # unaudited, unlike addressof
|
|
if gen[0] == start:
|
|
break
|
|
# No audited call from here on. Plain aligned stores: a concurrent walker sees them in
|
|
# order on x86-64 (TSO). aarch64 may reorder them, which is theoretical there and
|
|
# matches glibc < 2.41's own plain-store publish; Python has no cheap portable fence.
|
|
gen[0] += 1
|
|
if hit:
|
|
live[n] = entry # replace in place, as glibc does
|
|
elif not grow:
|
|
own[n + 1] = None # terminator first, so a walker never runs past the new entry
|
|
own[n] = entry
|
|
else:
|
|
arrays.append((fresh, fresh_addr))
|
|
environ.value = fresh_addr
|
|
|
|
def _unsetenv(key) -> None:
|
|
with lock: # glibc shifts the entries of the live array (ours included) in place
|
|
real_unsetenv(key)
|
|
gen[0] += 1
|
|
|
|
_putenv._hermes_never_free_environ = True # type: ignore[attr-defined]
|
|
_unsetenv._hermes_never_free_environ = True # type: ignore[attr-defined]
|
|
os.putenv, os.unsetenv = _putenv, _unsetenv
|
|
|
|
|
|
def harden_import_path(src_root: str | None = None) -> None:
|
|
"""Stop a package in the current directory from shadowing Hermes modules.
|
|
|
|
Hermes ships top-level modules with common names (``utils``, ``proxy``, ``ui``); a
|
|
project with its own ``utils/`` launched from its directory would win the import.
|
|
The cwd reaches ``sys.path`` as ``""``/``"."`` (script/``-m`` launches) AND as an
|
|
absolute path (venv activation, PYTHONPATH), so both are handled: relative forms are
|
|
dropped and the Hermes root is *relocated* to the front, not merely inserted when
|
|
absent. ``src_root`` defaults to this module's directory (the repo root for every
|
|
shipped entry point), so no spawner env var is required.
|
|
"""
|
|
root = src_root or os.environ.get("HERMES_PYTHON_SRC_ROOT") or os.path.dirname(
|
|
os.path.abspath(__file__)
|
|
)
|
|
|
|
sys.path[:] = [p for p in sys.path if p not in ("", ".")]
|
|
|
|
root_abs = os.path.abspath(root)
|
|
sys.path[:] = [p for p in sys.path if os.path.abspath(p) != root_abs]
|
|
sys.path.insert(0, root)
|
|
|
|
|
|
def export_scratch_tmp_env() -> None:
|
|
"""Point ``TMPDIR``/``TMP``/``TEMP`` at ``HERMES_HOME/cache/scratch`` unless the user set them.
|
|
|
|
System temp is tmpfs on most Linux hosts and containers; Hermes' browser profiles, PTY
|
|
probes and every ``tempfile`` default a child script makes would eat RAM there. Runs at
|
|
import so every entry point and every child they spawn inherits it; ``hermes_cli.main``
|
|
re-runs it after ``--profile`` re-homes the process. Never raises.
|
|
"""
|
|
try:
|
|
from hermes_constants import export_scratch_tmp_env as _export
|
|
_export()
|
|
except Exception:
|
|
pass # a missing/unwritable home just leaves the system temp dir in place
|
|
|
|
|
|
# Apply on import — entry points just need ``import hermes_bootstrap``
|
|
# (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at
|
|
# the very top of their module, before importing anything else. The
|
|
# import side effect does the right thing.
|
|
apply_windows_utf8_bootstrap()
|
|
suppress_platform_ver_console()
|
|
install_never_free_environ()
|
|
|
|
# Every entry point imports this module before its dependency graph.
|
|
from pathlib import Path
|
|
|
|
_root = Path(__file__).resolve().parent
|
|
try:
|
|
os.getcwd()
|
|
except FileNotFoundError:
|
|
# Reaped workspaces leave children in a deleted cwd. PM resolves relative
|
|
# import paths before the CLI's guards, so recover before any PM work.
|
|
os.chdir(_root)
|
|
|
|
|
|
def _legacy_post_swap_invocation(argv: list[str]) -> tuple[Path, list[str]] | None:
|
|
"""Recognize the exact fresh-checkout command emitted by shipped updaters."""
|
|
if not argv or argv[0] != "update":
|
|
return None
|
|
try:
|
|
marker = argv.index("--post-swap", 1)
|
|
except ValueError:
|
|
return None
|
|
if marker + 2 != len(argv):
|
|
return None
|
|
return Path(argv[marker + 1]), argv[1:marker]
|
|
|
|
|
|
# Everything below imports Hermes packages, so the root goes on sys.path first. A venv
|
|
# editable-installed from a pre-PM tree maps only the top-level packages it knew then:
|
|
# without this, ``pm`` is unimportable and the launch silently skips PM adoption.
|
|
harden_import_path(str(_root))
|
|
|
|
_legacy_post_swap = _legacy_post_swap_invocation(sys.argv[1:])
|
|
if _legacy_post_swap is not None:
|
|
# This continuation exists precisely because the replacement tree may not
|
|
# run under the old release's dependency graph. Take it over before PM
|
|
# activation, launch preparation, or argparse imports any of that graph.
|
|
from hermes_cli.update_handoff import _continue_legacy_post_swap
|
|
|
|
_handoff_path, _argv_tail = _legacy_post_swap
|
|
raise SystemExit(_continue_legacy_post_swap(_handoff_path, argv_tail=_argv_tail))
|
|
|
|
|
|
from pm.environments import activate_dependencies
|
|
from hermes_cli._early_recovery import recover_if_needed
|
|
|
|
from hermes_cli._parser import command_argv
|
|
|
|
# Repair needs only stdlib. Do not activate the damaged tree to reach it.
|
|
_pm_repair = command_argv(sys.argv[1:])[:2] == ["pm", "repair"]
|
|
if not _pm_repair:
|
|
from hermes_cli.venv_sync import prepare_launch, relaunch_command
|
|
|
|
try:
|
|
_launch_python = prepare_launch(_root, sys.argv[1:])
|
|
if _launch_python is not None:
|
|
_main_spec = getattr(sys.modules.get("__main__"), "__spec__", None)
|
|
_command = relaunch_command(
|
|
_launch_python, _root, sys.argv, sys.orig_argv,
|
|
getattr(_main_spec, "name", None),
|
|
)
|
|
if os.name == "nt":
|
|
import subprocess
|
|
|
|
raise SystemExit(subprocess.call(_command))
|
|
os.execv(str(_launch_python), _command)
|
|
except Exception as exc:
|
|
# Degrade, never brick the CLI: the previous dependency generation is still selected
|
|
# (a failed sync commits nothing), so an offline or half-finished update leaves a
|
|
# usable Hermes plus a warning. Activation below is the real gate — a tree whose
|
|
# dependencies cannot load still exits with the repair remedy.
|
|
print(f"hermes: source-update completion failed: {exc}; "
|
|
"running with the previous dependencies — run `hermes update` to finish it",
|
|
file=sys.stderr)
|
|
recover_if_needed(_root)
|
|
try:
|
|
activate_dependencies(_root)
|
|
except (RuntimeError, OSError) as exc:
|
|
if command_argv(sys.argv[1:])[:1] != ["pm"]:
|
|
print(f"hermes: {exc}; run `hermes pm repair`", file=sys.stderr)
|
|
raise SystemExit(1) from None
|
|
install_happy_eyeballs_socket_connect()
|
|
export_scratch_tmp_env()
|