Files
hermes-agent/hermes_cli/_startup_fast.py
ethernet c13ea774e6 refactor: make install-stamp.json the single runtime version identity
Runtime identity resolved through hermes_cli.__version__ (a static 0.0.0
on source installs, rewritten by release stamping) leaked v0.0.0 into
About, /api/health, User-Agents, and plugin compat, and source updates
showed "couldn't reach update server" because identity and channel
authority disagreed with the checkout.

Now: get_version_info() resolves install stamp -> live git -> unknown,
never pyproject metadata, never a package constant. Source checkouts
derive identity from their reachable release tag; the completion tail of
every successful install/update/historical takeover atomically rewrites
install-stamp.json with that identity; a stale source stamp whose commit
no longer matches HEAD defers to live git. ACP/TUI use derived_version
for display and base_version for protocol fields; all ~44 runtime
__version__ consumers migrated; hermes_cli.__version__ and generated
_version.py are gone; release stamping only touches the native manifests
external builders consume (nix/tauri/cargo) and passes release identity
straight into write_install_stamp.py; pyproject.toml stays inert 0.0.0.
Desktop no longer synthesizes a competing install-stamp.json: the
checkout owns its stamp, and desktop-bootstrap classification keys on
the bootstrap-complete marker. verify-bootstrap-version-stamp.py now
cross-checks the checkout's stamp (baseVersion + commit == HEAD).

Validation: 31-file focused suite green (version identity, stamping,
adoption, providers, gateway, acp/tui runtime identity, api server via
extras env, release graph); desktop tsc + 25 vitest green; real-repo
probe: base=unknown derived=git.0635606.dirty source=git on this
checkout; clean-env imports resolve entirely from this tree; windows
footgun + compat-pointer scans clean.
2026-09-23 11:41:01 -04:00

256 lines
10 KiB
Python

"""Pre-import startup fast paths — THE canonical lightweight helpers.
This module is imported by ``hermes_cli/main.py`` BEFORE its heavy import
wall (config, argparse tree, logging, providers). Everything here must stay
**stdlib-only and cheap** (os/sys file probes; no yaml, no hermes_cli.config,
no argparse). A guard test (``test_startup_fast_import_weight``) subprocess-
imports this module and fails if any heavy module sneaks into sys.modules.
Why this module exists (the bug class it kills): version-printing kept being
reimplemented as ``*_fast()`` copies at the top of main.py, each duplicating
canonical logic — project-root resolution, container detection, profile
detection. The copies drifted: eb4040242 changed the canonical output and
referenced ``PROJECT_ROOT`` inside the fast function, which doesn't exist
yet on the fast path → the fast path NameError'd on --version and nobody
noticed. One implementation, imported by both the fast path and the module
constants, makes that drift structurally impossible; the parity guard test
would have caught eb4040242 the day it landed.
``hermes_cli/config.py``'s ``get_container_exec_info()`` reads the same
``.container-mode`` file; keep the file-format assumptions here and there in
sync (this module deliberately only PROBES existence/typos cheaply and errs
toward the slow path, which then does the authoritative parse).
"""
from __future__ import annotations
import os
import sys
__all__ = [
"project_root_str", "normalize_hermes_home_env",
"ensure_project_root_on_path",
"is_global_fast_version_argv",
"is_container_startup_environment",
"active_profile_may_override_home",
"container_mode_may_be_active",
"read_openai_version",
"read_install_method",
"print_fast_version_info",
"try_fast_version",
"is_desktop_ssh_backend_argv",
]
def _read_text(path: str) -> str | None:
"""Read a small text file, or None when it is missing/unreadable."""
try:
with open(path, encoding="utf-8-sig") as handle:
return handle.read()
except (OSError, UnicodeDecodeError):
return None
def project_root_str() -> str:
"""Repo root as a str — the single source for main.py's PROJECT_ROOT."""
return os.path.realpath(os.path.join(os.path.dirname(__file__), os.pardir))
def normalize_hermes_home_env() -> None:
"""Expand ``~``/``$VAR`` in ``HERMES_HOME`` once, at process entry, and write it back.
fish does not expand ``~`` inside ``VAR=~/...`` and every shell passes a quoted value
through verbatim, so a literal tilde reaches the process. ``Path("~/.hermes")`` is
*relative*: the many raw ``os.environ["HERMES_HOME"]`` readers (this fast path, the
active_profile probe, profile re-home, the dotenv loader) would each resolve it against
cwd and scaffold a full home under ``<cwd>/~/.hermes``. One expansion here gives every
reader the same absolute spelling; ``hermes_constants`` expands as well for non-CLI
entry points. A relative value that is not tilde/variable-shaped is left alone.
"""
raw = os.environ.get("HERMES_HOME", "")
if not raw.strip():
return
expanded = os.path.expanduser(os.path.expandvars(raw.strip()))
if expanded != raw:
os.environ["HERMES_HOME"] = expanded
def _realpath_or_self(path: str) -> str:
"""``os.path.realpath`` that survives a deleted cwd.
A relative ``sys.path`` entry is resolved through ``os.getcwd()``, which raises
``FileNotFoundError`` once the directory the process started in has been removed
(a cron delivery child spawned from a reaped scratch workspace, #102941); the CLI
then dies before it can parse argv.
"""
try:
return os.path.realpath(path)
except OSError:
return path
def ensure_project_root_on_path() -> None:
"""Put the project root at sys.path[0], deduping realpath-equivalents."""
project_root = project_root_str()
normalized_root = os.path.normcase(_realpath_or_self(project_root))
sys.path[:] = [entry for entry in sys.path
if not entry or os.path.normcase(_realpath_or_self(entry)) != normalized_root]
sys.path.insert(0, project_root)
def is_global_fast_version_argv(argv: list[str]) -> bool:
return argv in (["--version"], ["-V"])
def is_desktop_ssh_backend_argv(argv: list[str]) -> bool:
"""Is ``argv`` the Desktop client's SSH backend spawn (``serve --ssh-session-token-file``)?
That child has a fixed identity: Desktop names the remote profile explicitly (or none for
the root home) and hands its session token through a 0600 FILE, never the
``HERMES_DASHBOARD_SESSION_TOKEN`` env var the local pool spawn uses. Every reader of
"is this process Desktop's backend" needs both shapes; this is the argv half.
"""
return "--ssh-session-token-file" in argv
def is_container_startup_environment() -> bool:
"""True when we're already INSIDE a container (fast path is then safe)."""
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"):
return True
cgroup = _read_text("/proc/1/cgroup") or ""
return "docker" in cgroup or "podman" in cgroup or "/lxc/" in cgroup
def active_profile_may_override_home(hermes_root: str) -> bool:
"""Cheap probe: does an active non-default profile redirect HERMES_HOME?"""
active = (_read_text(os.path.join(hermes_root, "active_profile")) or "").strip()
return bool(active and active != "default")
def _default_home() -> str:
return os.path.join(os.path.expanduser("~"), ".hermes")
def _resolved_home() -> str:
return os.environ.get("HERMES_HOME", "").strip() or _default_home()
def container_mode_may_be_active() -> bool:
"""Conservative probe for NixOS container-mode routing.
False positives are fine (the slow path does the authoritative check). False negatives are NOT —
they'd print the host's version instead of the container's — so any profile ambiguity means "may
be active".
"""
if os.environ.get("HERMES_DEV") == "1" or is_container_startup_environment():
return False
hermes_home = os.environ.get("HERMES_HOME", "").strip()
if hermes_home:
if os.path.exists(os.path.join(hermes_home, ".container-mode")):
return True
parent_name = os.path.basename(os.path.dirname(os.path.normpath(hermes_home)))
return parent_name != "profiles" and active_profile_may_override_home(hermes_home)
default_home = _default_home()
return active_profile_may_override_home(default_home) or os.path.exists(
os.path.join(default_home, ".container-mode"))
def read_openai_version() -> str | None:
"""Read OpenAI SDK version without importing ``importlib.metadata``."""
for base in sys.path:
version_file = os.path.join(base or os.getcwd(), "openai", "_version.py")
try:
with open(version_file, encoding="utf-8-sig") as handle:
for line in handle:
stripped = line.strip()
if not stripped.startswith("__version__"):
continue
_key, _sep, value = stripped.partition("=")
value = value.split("#", 1)[0].strip().strip("\"'")
return value or None
except OSError:
continue
return None
def read_install_method() -> str | None:
"""The installer's ``.install_method`` stamp, if present.
Only the stamp (step 1 of ``config.detect_install_method``'s resolution order) — the
managed/git/pip fallbacks need heavier imports and stay on the slow path.
"""
method = _read_text(os.path.join(_resolved_home(), ".install_method"))
return (method or "").strip().lower() or None
def print_fast_version_info(*, check_updates: bool = True) -> None:
"""THE canonical ``hermes --version`` output (also used by /version).
Every lazy block degrades gracefully — a broken/heavy import can never take the basic version
output down.
"""
# Registry-owned banner label (includes "· upstream <sha>" for git installs); banner.py keeps
# rich/prompt_toolkit lazy, so this import is light.
try:
from hermes_cli.banner import format_banner_version_label
print(format_banner_version_label())
except Exception:
from hermes_cli import __release_date__
from hermes_cli.version_info import get_version_info
print(f"Hermes Agent v{get_version_info().derived_version} ({__release_date__})")
print(f"Install directory: {project_root_str()}")
# Authoritative resolver first (code-scoped stamp → managed → nix → git → pip; also self-heals
# poisoned shared-home 'docker' stamps); cheap stdlib stamp probe only if it fails.
try:
from pathlib import Path
from hermes_cli.config import detect_install_method
install_method = detect_install_method(Path(project_root_str()))
except Exception:
install_method = read_install_method()
if install_method:
print(f"Install method: {install_method}")
print(f"Python: {sys.version.split()[0]}")
openai_version = read_openai_version()
print(f"OpenAI SDK: {openai_version}" if openai_version else "OpenAI SDK: Not installed")
if not check_updates:
return
# Synchronous update status — bounded by check_for_updates' own subprocess/network timeouts
# and its 6-hour cache; any failure prints nothing.
try:
from hermes_cli.source_check import UPDATE_AVAILABLE_NO_COUNT, check_for_updates
from hermes_cli.config import recommended_update_command
behind = check_for_updates(passive=True).get("behind")
if behind == UPDATE_AVAILABLE_NO_COUNT:
print(f"Update available — run '{recommended_update_command()}'")
elif behind and behind > 0:
commits_word = "commit" if behind == 1 else "commits"
print(f"Update available: {behind} {commits_word} behind — run '{recommended_update_command()}'")
elif behind == 0:
print("Up to date")
except Exception:
pass
def try_fast_version(argv: list[str] | None = None) -> bool:
"""Handle ``hermes --version`` before the heavy import wall.
Only ``--version``/``-V`` (the ``version`` subcommand was removed —
``--version`` now carries the full output incl. update status), and
never when container mode may need to route the command into the
container.
"""
if argv is None:
argv = sys.argv[1:]
if not is_global_fast_version_argv(argv):
return False
if container_mode_may_be_active():
return False
print_fast_version_info()
return True