refactor(hermes_cli): compact docstrings/comments in desktop/dashboard/web_build/tui_launch (WHY kept)

This commit is contained in:
Teknium
2026-09-02 21:56:57 -07:00
parent 7e270c0d9e
commit 616d7c3c2a
4 changed files with 188 additions and 469 deletions

View File

@@ -77,10 +77,9 @@ def _run_probe(cmd: list[str], *, timeout: int) -> subprocess.CompletedProcess:
def _restart_managed_dashboard_service(reason: str, unit: str = _DASHBOARD_SYSTEMD_UNIT) -> bool:
"""Restart a systemd-managed dashboard instead of raw-killing its PID.
Returns True when a dashboard unit was found and handled (successfully or
with a printed actionable failure). Returning True deliberately prevents the
caller from falling back to ``os.kill``: systemd treats a direct SIGTERM of
the main PID as a clean stop, so ``Restart=on-failure`` won't bring it back.
True when a unit was found and handled (success or printed failure) — which
deliberately stops the caller's ``os.kill`` fallback: systemd treats a direct
SIGTERM as a clean stop, so ``Restart=on-failure`` won't bring it back.
"""
if sys.platform == "win32":
return False
@@ -216,12 +215,8 @@ def _try_restart_systemd_service(svc_name: str, cgroup_path: str | None = None)
def _dashboard_cmdline_for_pid(pid: int) -> list[str] | None:
"""The exact argv of a running process, when recoverable.
Linux: ``/proc/<pid>/cmdline`` (lossless). macOS: ``ps -o command=`` + shlex
(best effort). Windows: None — taskkill /F gives no graceful window and the
desktop app manages its own backend there.
"""
"""Exact argv of a running process: ``/proc/<pid>/cmdline`` (Linux), ``ps -o command=`` + shlex
(macOS), None on Windows (no graceful taskkill window; Desktop manages its backend)."""
if sys.platform == "win32":
return None
try:
@@ -247,16 +242,10 @@ def _dashboard_cmdline_for_pid(pid: int) -> list[str] | None:
def _respawn_dashboard_processes(commands: list[list[str]]) -> list[list[str]]:
"""Best-effort respawn of manually-started dashboards after ``hermes update``.
Spawns each argv detached (new session, output appended to the profile's
``logs/dashboard-restart.log``). Returns the commands that failed to spawn so
the caller can print the manual hint. Callers must pre-filter via
``_filter_dashboard_respawn_candidates`` (no Desktop ``--port 0`` backends,
duplicates capped per profile).
"""
"""Respawn manually-started dashboards after ``hermes update``, detached, logging to
``logs/dashboard-restart.log``; returns the argvs that failed to spawn. Callers pre-filter via
``_filter_dashboard_respawn_candidates`` (no Desktop ``--port 0`` backends, capped per profile)."""
from hermes_constants import get_hermes_home
respawned: list[list[str]] = []
failed: list[tuple[list[str], str]] = []
log_path = get_hermes_home() / "logs" / "dashboard-restart.log"
@@ -288,14 +277,8 @@ def _respawn_dashboard_processes(commands: list[list[str]]) -> list[list[str]]:
class _UpdateOutputStream:
"""Stream wrapper used during ``hermes update`` to survive terminal loss.
Every write is mirrored to an append-only log (``~/.hermes/logs/update.log``)
and writes to the original stream that fail with ``BrokenPipeError`` /
``OSError`` / ``ValueError`` stop the on-screen output instead of the update.
With ``SIGHUP -> SIG_IGN`` from ``_install_hangup_protection`` this makes
``hermes update`` safe in an SSH session that disconnects mid-install.
"""
"""stdout/stderr wrapper for ``hermes update``: mirrors to ``logs/update.log`` and, once the
terminal vanishes (BrokenPipe/OSError/ValueError), drops screen output instead of the update."""
_BROKEN = (BrokenPipeError, OSError, ValueError)
@@ -348,17 +331,9 @@ class _UpdateOutputStream:
def _install_hangup_protection(gateway_mode: bool = False):
"""Protect ``cmd_update`` from SIGHUP and broken terminal pipes.
1. ``SIGHUP`` → ``SIG_IGN`` (preserved across exec, so pip/git children
survive hangup too). ``SIGINT``/``SIGTERM`` are intentionally left alone —
those are legitimate cancellation signals.
2. ``sys.stdout``/``sys.stderr`` are wrapped to mirror to
``~/.hermes/logs/update.log`` and absorb ``BrokenPipeError``.
No-op in gateway mode (already detached). Returns the state dict that
``_finalize_update_output`` consumes on exit.
"""
"""Protect ``cmd_update`` from SIGHUP (→ SIG_IGN, inherited by pip/git children) and broken pipes
(stdio wrapped in ``_UpdateOutputStream``). SIGINT/SIGTERM are left alone — legitimate cancels.
No-op in gateway mode (already detached). Returns state for ``_finalize_update_output``."""
state = {
"prev_stdout": sys.stdout, "prev_stderr": sys.stderr, "log_file": None, "installed": False
}
@@ -379,7 +354,6 @@ def _install_hangup_protection(gateway_mode: bool = False):
# Late-bound import so tests can monkeypatch
# hermes_cli.config.get_hermes_home to simulate setup failure.
from hermes_cli.config import get_hermes_home as _get_hermes_home
logs_dir = _get_hermes_home() / "logs"
logs_dir.mkdir(parents=True, exist_ok=True)
log_file = open(logs_dir / "update.log", "a", buffering=1, encoding="utf-8")
@@ -428,7 +402,6 @@ def _report_dashboard_status() -> int:
"""
from hermes_cli.main import _dashboard_listening, _self
from gateway.status import _pid_exists
live: list[tuple[int, str, str]] = []
for pid, command in _self()._scan_dashboard_processes():
runtime = _parse_dashboard_runtime(command)
@@ -452,7 +425,6 @@ def _report_dashboard_status() -> int:
def _dashboard_listening(host: str, port: int) -> bool:
"""True when something accepts TCP connections at host:port (even a 401 proves a dashboard is up)."""
import socket
try:
with socket.create_connection((_dashboard_probe_host(host), port), timeout=1.5):
return True
@@ -466,14 +438,12 @@ def _cancel(message: str = " Cancelled.") -> NoReturn:
def _maybe_setup_dashboard_auth_interactively(args) -> None:
"""Offer to configure dashboard auth when the gate engages and none exists.
"""Offer to configure dashboard auth when the gate engages and no provider exists.
Called from ``cmd_dashboard`` just before ``start_server``, which fails closed
when no ``DashboardAuthProvider`` is registered for a non-loopback bind or a
non-loopback ``dashboard.public_url``. Prompt an interactive operator to set
up the bundled password provider (or point at ``hermes dashboard register``
for OAuth). No-op — leaving the fail-closed ``SystemExit`` as backstop — when
the gate doesn't engage, a provider exists, or stdin/stdout isn't a TTY.
``start_server`` fails closed for a non-loopback bind / ``dashboard.public_url``
without a ``DashboardAuthProvider``; prompt an interactive operator first.
No-op (fail-closed backstop stays) when the gate doesn't engage, a provider
exists, or stdin/stdout isn't a TTY.
"""
host = getattr(args, "host", "127.0.0.1") or "127.0.0.1"
@@ -530,7 +500,6 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
import getpass
import secrets
print()
try:
username = line_input(" Username [admin]: ").strip() or "admin"
@@ -556,7 +525,6 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
try:
from hermes_cli.config import load_config, save_config
from hermes_cli.plugins_cmd import ensure_basic_auth_plugin_enabled_in_config
cfg = load_config()
basic = cfg.setdefault("dashboard", {}).setdefault("basic_auth", {})
basic["username"] = username
@@ -576,7 +544,6 @@ def _maybe_setup_dashboard_auth_interactively(args) -> None:
# Re-run plugin discovery so the provider registers before start_server's gate.
try:
from hermes_cli.plugins import discover_plugins
discover_plugins(force=True)
except Exception as exc:
print(f" ⚠ Plugin re-discovery failed ({exc}); the gate may still "
@@ -686,15 +653,13 @@ def _is_electron_packaged_web_dist(path: str) -> bool:
def _route_named_profile_dashboard(args, _headless_backend: bool, _ssh_owner_nonce: str, _token_file: str) -> None:
"""Named-profile launches route to the single MACHINE dashboard.
"""Route a named-profile launch to the single MACHINE dashboard (per-request ``?profile=`` scoping
makes one server per profile pure fragmentation).
The dashboard manages every profile via per-request ``?profile=`` scoping, so
one server per profile only fragments it. If the machine dashboard is already
listening, open the browser at ``?profile=<name>`` and exit; otherwise re-exec
as the machine dashboard pinned to ``-p default`` (so ``_apply_profile_override``
can't re-route through the sticky active_profile file) with this profile
preselected. ``--isolated`` opts out; Desktop pool backends (HERMES_DESKTOP=1)
stay per-profile. Returns normally when no routing applies.
Already listening → open ``?profile=<name>`` and exit; else re-exec pinned to
``-p default`` (so ``_apply_profile_override`` can't re-route via the sticky
active_profile file). ``--isolated`` opts out; Desktop pool backends
(HERMES_DESKTOP=1) stay per-profile. Returns normally when no routing applies.
"""
from hermes_cli.main import _dashboard_listening
try:
@@ -773,12 +738,10 @@ def _route_named_profile_dashboard(args, _headless_backend: bool, _ssh_owner_non
def _resolve_dashboard_web_dist(args, _headless_backend: bool) -> None:
"""Build or validate the web UI dist before the server imports.
``serve`` sets HERMES_SERVE_HEADLESS so mount_spa() stays off even if a stray
dist exists. Otherwise build unless HERMES_WEB_DIST or --skip-build says a
dist is pre-built — then verify index.html exists (an unverified promise
means the server starts and serves 404s). --skip-build on the default dist
location gets ONE recovery build; a caller-managed HERMES_WEB_DIST cannot be
populated and is written back expanded because web_server reads it raw.
``serve`` sets HERMES_SERVE_HEADLESS so mount_spa() stays off. Otherwise build
unless HERMES_WEB_DIST / --skip-build promise a dist — then verify index.html
(else the server serves 404s). --skip-build on the default location gets ONE
recovery build; a caller-managed HERMES_WEB_DIST can't be populated.
"""
from hermes_cli.main import PROJECT_ROOT, _build_web_ui
skip_build = getattr(args, "skip_build", False)

View File

@@ -47,13 +47,8 @@ def _desktop_stamp_path() -> Path:
def _renderer_bundle_dir(desktop_dir: Path, *, source_mode: bool) -> Optional[Path]:
"""The renderer ``dist`` directory a launch loads, when it is inspectable.
Source mode builds to ``apps/desktop/dist``. A packaged app ships the bundle
inside ``app.asar`` and (``asarUnpack: dist/**``) beside it in
``app.asar.unpacked``; only the unpacked copy is a real directory, and it is
the one an interrupted replace tears.
"""
"""The renderer ``dist`` a launch loads: ``apps/desktop/dist`` in source mode, else the
``app.asar.unpacked/dist`` copy (the only real directory, and the one an interrupted replace tears)."""
from hermes_cli.main import _desktop_packaged_executable
if source_mode:
return desktop_dir / "dist"
@@ -77,14 +72,12 @@ _MODULE_TAG = re.compile(r"""\btype=["']module["']|\brel=["']modulepreload["']""
def _renderer_bundle_torn(dist_dir: Path) -> bool:
"""True when ``index.html`` names hashed module files that aren't there.
"""True when ``index.html`` names hashed module chunks that aren't there.
``index.html`` and the hashed chunks under ``assets/`` are ONE generation. An
update that replaces the app while its files are locked can leave them from
different generations; the app then dies on its first lazy import, and
because the content stamp still matches the intact SOURCE tree the rebuild
that would fix it is skipped. Conservative: an unreadable index, or one naming
nothing checkable, is NOT torn — the missing-bundle guards own those cases.
A replace interrupted by locked files leaves index and ``assets/`` from
different generations; the app dies on its first lazy import while the
SOURCE-tree stamp still matches, so no rebuild fixes it. Conservative: an
unreadable index or one naming nothing checkable is NOT torn.
"""
try:
html = (dist_dir / "index.html").read_text(encoding="utf-8", errors="replace")
@@ -171,12 +164,8 @@ _DESKTOP_PREVIOUS_SUFFIX = ".previous"
def _desktop_staging_dir(desktop_dir: Path) -> Path:
"""Fresh, unique staging output dir: ``apps/desktop/.staging-<pid>-<ts>``.
A sibling of ``release/`` (same filesystem → the swap is a rename) but NOT
inside it, so nothing globbing ``release/*-unpacked`` can mistake the
half-built tree for the live app. Stale leftovers are swept first.
"""
"""Fresh staging dir ``apps/desktop/.staging-<pid>-<ts>``: a sibling of ``release/`` (same fs → the
swap is a rename) but not inside it, so ``release/*-unpacked`` globs never see it. Sweeps leftovers."""
for stale in desktop_dir.glob(f"{_DESKTOP_STAGING_PREFIX}*"):
shutil.rmtree(stale, ignore_errors=True)
return desktop_dir / f"{_DESKTOP_STAGING_PREFIX}{os.getpid()}-{int(_time_mod.time())}"
@@ -193,13 +182,8 @@ def _desktop_unpacked_root(exe: Path, release_dir: Path) -> Path:
def _swap_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Optional[Path]:
"""Promote a VERIFIED staged pack over the live ``release/`` app.
``release/<unpacked>`` → ``.previous``, ``<staging>/<unpacked>`` →
``release/<unpacked>``, then drop ``.previous``. The only window with no live
app is between the two renames, and a failure there rolls back. Returns the
live executable, or None (live app untouched or restored). Never raises.
"""
"""Promote a VERIFIED staged pack over ``release/`` by two renames (live → ``.previous``, staged →
live); a failure between them rolls back. Returns the live exe or None (live app kept). Never raises."""
staged_exe = _desktop_packaged_executable_in(staging_dir)
if staged_exe is None:
shutil.rmtree(staging_dir, ignore_errors=True)
@@ -250,15 +234,10 @@ _MACHINE_ATTRIBUTE_USER_ENABLED = 0x00000001
def _windows_native_machine_from_iswow64() -> Optional[str]:
"""Ask IsWow64Process2 for the OS-native machine (None if unavailable/fail).
``restype``/``argtypes`` are bound to ``wintypes.HANDLE``: ctypes' default
``c_int`` truncates the ``(HANDLE)-1`` pseudo-handle to ``0xFFFFFFFF`` and
``IsWow64Process2`` then fails with ``ERROR_INVALID_HANDLE`` on Win64.
"""
"""IsWow64Process2's OS-native machine, or None. HANDLE types are bound explicitly: ctypes'
default ``c_int`` truncates the ``(HANDLE)-1`` pseudo-handle → ``ERROR_INVALID_HANDLE`` on Win64."""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
kernel32.GetCurrentProcess.argtypes = []
@@ -277,15 +256,10 @@ def _windows_native_machine_from_iswow64() -> Optional[str]:
def _windows_user_runnable_pe_machines() -> Optional[set]:
"""PE machines this host can run in user mode, via GetMachineTypeAttributes.
The only documented API that reports AMD64-on-ARM64 emulation support. None
when unavailable (pre-Windows-11 build 22000) or nothing runnable, so callers
fall back to name-based detection.
"""
"""PE machines this host runs in user mode via GetMachineTypeAttributes (the only API reporting
AMD64-on-ARM64 emulation); None when unavailable (pre-Win11 22000) so callers fall back."""
import ctypes
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.GetMachineTypeAttributes.argtypes = [wintypes.USHORT, ctypes.POINTER(ctypes.c_int)]
kernel32.GetMachineTypeAttributes.restype = ctypes.c_long
@@ -302,15 +276,10 @@ def _windows_user_runnable_pe_machines() -> Optional[set]:
def _windows_native_machine() -> str:
"""The Windows host OS's NATIVE machine architecture, normalized upper.
``platform.machine()`` reports the PROCESS architecture, which lies under
emulation (x64 Python on Windows-on-ARM reports AMD64). Probe order:
``IsWow64Process2`` (the only API that tells the truth from an emulated
process), ``PROCESSOR_ARCHITEW6432`` / ``PROCESSOR_ARCHITECTURE``, then
``platform.machine()``. ``GetNativeSystemInfo`` is deliberately NOT used: it
also returns emulated details under emulation.
"""
"""The Windows host's NATIVE machine, upper-cased: ``IsWow64Process2`` (the only API that tells
the truth from an emulated x64 process on ARM64), then ``PROCESSOR_ARCHITEW6432`` /
``PROCESSOR_ARCHITECTURE``, then ``platform.machine()`` (which lies under emulation).
``GetNativeSystemInfo`` is NOT used: it also returns emulated details."""
if sys.platform == "win32":
try:
name = _windows_native_machine_from_iswow64()
@@ -327,12 +296,8 @@ def _windows_native_machine() -> str:
def _expected_windows_pe_machines() -> set:
"""PE machine values the current Windows host can natively load.
``GetMachineTypeAttributes`` first; else name-based: AMD64 runs x64 + x86
(WOW64), ARM64 runs ARM64 + x64 (emulation), x86 runs only x86. Unknown
machines return the permissive full set so the gate can never brick launch.
"""
"""PE machines this Windows host can load: ``GetMachineTypeAttributes``, else by name (AMD64 → x64+x86,
ARM64 → ARM64+x64, x86 → x86). Unknown hosts get the full set so the gate can never brick launch."""
from hermes_cli.main import _windows_native_machine
if sys.platform == "win32":
try:
@@ -352,14 +317,9 @@ def _expected_windows_pe_machines() -> set:
def _parse_pe_machine(path: Path) -> int:
"""Parse ``path`` as a PE executable and return its COFF machine field.
Raises ``ValueError`` with a human-readable reason when the file is not a
structurally complete PE (missing MZ/PE magic, truncated header, or section
data past EOF — the truncated-download shape). Header walk only; cheap.
"""
"""COFF machine field of the PE at ``path``; ``ValueError`` with a readable reason when it is not a
structurally complete PE (bad magic, truncated header, section data past EOF). Header walk only."""
import struct
try:
file_size = path.stat().st_size
except OSError as exc:
@@ -451,14 +411,9 @@ def _rollback_desktop_from_backup(packaged_executable: Path) -> Optional[Path]:
def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optional[Path]) -> tuple:
"""Windows post-build integrity gate for the self-update rebuild.
Returns ``(verified_exe_or_None, rolled_back)``: probe passed → ``(exe, False)``;
corrupt/wrong-arch with backup restored → ``(old_exe, True)``; nothing
restorable → ``(None, False)``. On failure the cached Electron zip is purged
and the stamp invalidated so the retry-once rebuild pulls a fresh,
SHASUM-verified download. No-op off Windows / with no executable.
"""
"""Windows post-build integrity gate → ``(verified_exe_or_None, rolled_back)``: pass →
``(exe, False)``; corrupt with backup restored → ``(old_exe, True)``; nothing restorable →
``(None, False)``. Failure purges the cached zip + stamp so the retry re-downloads."""
from hermes_cli.main import _desktop_stamp_path, _purge_electron_build_cache
if packaged_executable is None or sys.platform != "win32":
return packaged_executable, False
@@ -492,13 +447,9 @@ def _ensure_desktop_exe_launchable(desktop_dir: Path, packaged_executable: Optio
def _electron_download_cache_dirs() -> list[Path]:
"""Per-user Electron download cache directories for this OS (deduped, in priority order).
electron-builder's ``app-builder unpack-electron`` extracts Electron from a
zip stored here (NOT from node_modules), so a corrupt zip here poisons the
build. Honors the ``electron_config_cache`` / ``ELECTRON_CACHE`` overrides
``@electron/get`` respects.
"""
"""Per-user Electron download caches (``electron_config_cache`` / ``ELECTRON_CACHE`` overrides
first): ``unpack-electron`` extracts from a zip here, NOT node_modules, so a corrupt zip poisons
the build."""
home = Path.home()
candidates: list[Path] = []
override = os.environ.get("electron_config_cache") or os.environ.get("ELECTRON_CACHE")
@@ -521,20 +472,15 @@ def _electron_download_cache_dirs() -> list[Path]:
def _purge_electron_build_cache(desktop_dir: Path, release_dir: Optional[Path] = None) -> list[Path]:
"""Clear the cached Electron download + half-written unpacked dir so the next pack restarts from scratch.
"""Purge the cached Electron zips + half-written unpacked dir so the next pack restarts from scratch.
Root cause of ``ENOENT … rename 'linux-unpacked/electron'``: a corrupt zip in
the per-user cache (resumed partial download with prepended junk, or a
truncated write) unpacks to a tree MISSING the ``electron`` binary, and
re-running repeats the same extraction forever. We deliberately do NOT
validate the zip ourselves: stdlib ``zipfile`` tolerates exactly the
concat-junk that ``@electron/get`` rejects, so a gate would never self-heal.
Instead purge unconditionally and let the caller retry once; ``@electron/get``
re-downloads with SHASUM verification (the real source of truth).
``release_dir`` lets a stage-and-swap caller point at its STAGING output so
the purge never touches the live app. Never raises; returns removed paths
(empty ⇒ nothing to clear, so no point retrying).
A corrupt cached zip unpacks to a tree MISSING the ``electron`` binary
(``ENOENT … rename``) and every rerun repeats it. Deliberately no self-rolled
zip validation: stdlib ``zipfile`` tolerates exactly the concat-junk
``@electron/get`` rejects, so a gate would never self-heal — purge
unconditionally and let ``@electron/get``'s SHASUM check be the truth.
``release_dir`` points a stage-and-swap caller at its STAGING output so the
live app is never touched. Never raises; empty result ⇒ nothing to retry.
"""
from hermes_cli.main import _electron_download_cache_dirs
removed: list[Path] = []
@@ -568,12 +514,8 @@ _ELECTRON_FALLBACK_MIRROR = "https://npmmirror.com/mirrors/electron/"
def _electron_dir(project_root: Path) -> Path:
"""The Electron package directory the desktop workspace installs.
npm may keep workspace-only dev deps under ``apps/desktop/node_modules`` or
hoist them to the root depending on version; ``apps/desktop/package.json``
points ``electronDist`` at the workspace-local path, so prefer that.
"""
"""The installed Electron package dir: workspace-local ``apps/desktop/node_modules/electron`` (where
``electronDist`` points) when present, else the root hoist npm sometimes uses instead."""
desktop_local = project_root / "apps" / "desktop" / "node_modules" / "electron"
if desktop_local.exists():
return desktop_local
@@ -620,7 +562,6 @@ def _redownload_electron_dist(project_root: Path, env: dict, *, mirror: Optional
if not installer.is_file():
return False
from hermes_constants import find_node_executable, with_hermes_node_path
node = find_node_executable("node")
if not node:
return False
@@ -652,14 +593,9 @@ def _try_redownload_electron_dist(project_root: Path, env: dict) -> bool:
def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
"""Terminate a running desktop app executing from this build's ``release`` dir (Windows only).
A running ``Hermes.exe`` holds an exclusive lock, so electron-builder's pack
dies with ``Access is denied`` and the retry repeats it. POSIX lets you
unlink a running binary, so this is a no-op off Windows. Only processes whose
exe lives INSIDE this desktop's ``release`` tree are stopped. Never raises;
returns the PIDs asked to stop.
"""
"""Terminate a running desktop app whose exe lives INSIDE this build's ``release`` tree (Windows
only — its lock makes the pack die with ``Access is denied``; POSIX can unlink a running
binary). Never raises; returns the PIDs asked to stop."""
if sys.platform != "win32":
return []
try:
@@ -713,7 +649,6 @@ def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]:
def _desktop_macos_bundle_id(bundle: Path) -> Optional[str]:
"""Return a bundle/framework CFBundleIdentifier for local macOS signing."""
import plistlib
info = bundle / "Contents" / "Info.plist"
if not info.exists() and bundle.suffix == ".framework":
candidates = list(bundle.glob("Versions/*/Resources/Info.plist")) + list(
@@ -732,18 +667,12 @@ def _desktop_macos_bundle_id(bundle: Path) -> Optional[str]:
def _desktop_macos_local_signing_identity() -> Optional[str]:
"""The opt-in keychain identity for local macOS desktop signing (``desktop.macos_signing_identity``).
A persistent code-signing cert (a self-signed one from Keychain Access is
enough) gives the app a certificate-anchored Designated Requirement — the
strongest way to keep TCC grants stable across rebuilds. Empty/unset keeps
identifier-pinned ad-hoc signing.
"""
"""``desktop.macos_signing_identity`` — a persistent (even self-signed) code-signing cert anchors
the Designated Requirement and keeps TCC grants stable across rebuilds. Unset → ad-hoc."""
if sys.platform != "darwin":
return None
try:
from hermes_cli.config import load_config
desktop = load_config().get("desktop", {})
if not isinstance(desktop, dict):
return None
@@ -766,13 +695,8 @@ def _codesign_verify(codesign: str, app: Path, **kwargs) -> subprocess.Completed
def _desktop_macos_has_valid_real_signature(app: Path) -> bool:
"""True when the bundle carries an intact non-ad-hoc (Team ID) signature.
Makes the relaunch fixup a no-op on properly signed/notarized builds even
without CSC_LINK / APPLE_SIGNING_IDENTITY in the environment — clobbering a
Developer ID signature with an ad-hoc one resets TCC grants. A STALE real
signature fails --verify and returns False so the fixup can repair it.
"""
"""True when the bundle has an intact Team-ID signature, so the fixup never clobbers a notarized
build with ad-hoc (resets TCC). A STALE real signature fails --verify → False → repairable."""
codesign = shutil.which("codesign")
if not codesign:
return False
@@ -789,16 +713,10 @@ def _desktop_macos_has_valid_real_signature(app: Path) -> bool:
def _desktop_macos_local_codesign(app: Path, *, desktop_dir: Path, identity: str = "-") -> bool:
"""Re-sign a local Desktop build so macOS TCC grants survive rebuilds.
A plain ``codesign --deep --sign -`` leaves a cdhash-only Designated
Requirement (changes every rebuild → TCC re-prompts everything) and strips
electron-builder's entitlements (breaks microphone/JIT under the hardened
runtime). Instead sign inside-out (standalone Mach-O, nested frameworks/
helpers, main bundle), preserving the repo's entitlement plists, and pin an
identifier-based DR when ad-hoc. Raises on signing failure; True after
strict verification passes.
"""
"""Sign a local build inside-out (Mach-O files, nested frameworks/helpers, main bundle) with the
repo's entitlements and an identifier-pinned DR when ad-hoc — a plain ``--deep --sign -`` gives
a cdhash-only DR (TCC re-prompts every rebuild) and strips the JIT/mic entitlements.
Raises on signing failure; True after strict verification."""
codesign = shutil.which("codesign")
if not codesign:
return False
@@ -862,13 +780,9 @@ def _desktop_macos_local_codesign(app: Path, *, desktop_dir: Path, identity: str
def _macos_legacy_adhoc_resign(codesign: str, app: Path) -> bool:
"""Legacy deep ad-hoc re-sign fallback; NEVER deletes the safeStorage keychain item.
Deleting it would permanently orphan every credential encrypted under it,
and this path is reached exactly when the entitlement-preserving signer
failed, so there is no verified successor identity. The keychain prompt
macOS shows instead is recoverable ("Always Allow"); deletion is not.
"""
"""Legacy deep ad-hoc re-sign; NEVER deletes the safeStorage keychain item (that would orphan every
credential under it, and there is no verified successor identity here — the "Always Allow"
prompt is recoverable, deletion is not)."""
try:
result = subprocess.run(
[codesign, "--force", "--deep", "--sign", "-", str(app)], check=False, capture_output=True, text=True
@@ -896,17 +810,15 @@ def _desktop_macos_relaunchable_fixup(
desktop_dir: Path, *, publisher_signing_configured: Optional[bool] = None,
release_dir: Optional[Path] = None,
) -> bool:
"""Make a locally-built macOS app survive in-place self-update without resetting TCC grants.
"""Re-sign a locally-built macOS app so in-place self-update doesn't reset TCC grants.
An ad-hoc-signed .app has no stable Designated Requirement, so a rebuilt
bundle (new cdhash) reports "Hermes is damaged" and loses every TCC grant.
Clear quarantine xattrs, then re-sign with ``desktop.macos_signing_identity``
when configured, else identifier-pinned ad-hoc, preserving entitlements. No-op
when a publisher identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY —
callers may pass the decision so a later dotenv load can't reverse it) or the
bundle already carries an intact Developer ID signature. ``release_dir``
signs the STAGED bundle before promotion. Falls back to the legacy deep
ad-hoc sign. Never raises; True when no work was needed or signing verified.
A rebuilt ad-hoc bundle (new cdhash, no stable Designated Requirement) reports
"Hermes is damaged" and loses every grant. Clear quarantine xattrs, then sign
with ``desktop.macos_signing_identity`` or identifier-pinned ad-hoc, keeping
entitlements; legacy deep ad-hoc as fallback. No-op with a publisher identity
(CSC_LINK / APPLE_SIGNING_IDENTITY; callers may pass the decision so a later
dotenv load can't reverse it) or an intact Developer ID signature.
``release_dir`` signs the STAGED bundle before promotion. Never raises.
"""
from hermes_cli.main import _desktop_macos_has_valid_real_signature, _desktop_macos_local_codesign, _desktop_macos_local_signing_identity
if sys.platform != "darwin":
@@ -947,13 +859,8 @@ def _desktop_macos_relaunchable_fixup(
def _macos_codesigning_identity_valid(security: str, identity: str) -> bool:
"""True when `identity` appears among VALID code-signing identities.
``find-identity`` without ``-v`` also lists certs macOS refuses to sign with
(imported but never trusted for codeSign); only the ``-v`` listing proves
codesign can use it. Both the idempotency probe and the success
postcondition for ``--setup-tcc-identity``. Never raises.
"""
"""True when `identity` is among VALID (``-v``) code-signing identities — the plain listing also
shows untrusted certs codesign refuses. Idempotency probe + postcondition. Never raises."""
try:
result = subprocess.run(
[security, "find-identity", "-v", "-p", "codesigning"], capture_output=True, text=True, check=False,
@@ -1046,15 +953,10 @@ def _macos_create_signing_identity(
def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") -> bool:
"""Create/import a self-signed code-signing cert and configure Hermes to use it.
One-shot setup for ``hermes desktop --setup-tcc-identity``: creates the cert
in the login keychain, grants ``codesign`` access, writes
``desktop.macos_signing_identity`` to config.yaml, and re-signs the packaged
app. TCC grants persist against the code-signing identity, not the path; a
certificate-anchored identity is stable across rebuilds (the yabai/skhd
mechanism). Idempotent; True on success or already configured. Never raises.
"""
"""``--setup-tcc-identity``: create/import a self-signed code-signing cert, point
``desktop.macos_signing_identity`` at it and re-sign the packaged app. TCC grants follow the
signing identity, so a certificate-anchored one is stable across rebuilds (the yabai/skhd
mechanism). Idempotent; never raises."""
from hermes_cli.main import PROJECT_ROOT, _desktop_macos_relaunchable_fixup, _desktop_packaged_executable
if sys.platform != "darwin":
print(" (--setup-tcc-identity is macOS-only; skipping)")
@@ -1091,7 +993,6 @@ def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") ->
# config.yaml, not .env — it's not a secret.
try:
from hermes_cli.config import set_config_value
set_config_value("desktop.macos_signing_identity", identity)
print(f" → set desktop.macos_signing_identity = {identity!r}")
except Exception as exc:
@@ -1118,15 +1019,9 @@ def _desktop_macos_setup_tcc_identity(identity: str = "Hermes Local Signing") ->
def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool:
"""Stop electron-builder grabbing a random keychain identity on self-update.
The self-updater re-signs the .app on the end user's machine; with
``CSC_IDENTITY_AUTO_DISCOVERY`` on, electron-builder signs the hardened-
runtime bundle with whatever personal cert it finds, which stalls the sign
step or clobbers a real notarized signature. Force ad-hoc for the local
packaged rebuild instead. No-op for source runs, off-macOS, with a real
identity configured, or when the caller pinned the flag. Mutates ``env``.
"""
"""Force ad-hoc signing for the local packaged rebuild: with ``CSC_IDENTITY_AUTO_DISCOVERY`` on,
electron-builder grabs any personal keychain cert and stalls the sign step or clobbers a
notarized signature. No-op for source runs, off-macOS, with a real identity, or when pinned."""
if sys.platform != "darwin" or source_mode:
return False
if env.get("CSC_LINK") or env.get("APPLE_SIGNING_IDENTITY") or "CSC_IDENTITY_AUTO_DISCOVERY" in env:
@@ -1136,14 +1031,9 @@ def _force_adhoc_macos_signing(env: dict, *, source_mode: bool) -> bool:
def _desktop_linux_needs_no_sandbox() -> bool:
"""True when Chromium/Electron should bypass the Linux sandbox.
Ubuntu 23.10+ ``apparmor_restrict_unprivileged_userns`` breaks the userns
sandbox unless the app ships a root-owned 4755 ``chrome-sandbox``; when we
can't ``sudo chown/chmod`` it, fall back to ``--no-sandbox`` rather than
hard-failing. Deliberately NOT True for root: Electron as root without a
sandbox is a qualitatively riskier path and must stay an explicit choice.
"""
"""True when Electron should run ``--no-sandbox``: Ubuntu 23.10+ ``apparmor_restrict_unprivileged_userns``
breaks the userns sandbox without a root-owned 4755 helper. Deliberately NOT True for root —
Electron as root without a sandbox must stay an explicit choice."""
if os.environ.get("ELECTRON_DISABLE_SANDBOX", 0) == "1":
return True
@@ -1159,13 +1049,8 @@ def _desktop_linux_needs_no_sandbox() -> bool:
def _desktop_linux_userns_sandbox_available() -> bool:
"""True when Chromium's unprivileged user-namespace sandbox works.
Then Chromium never consults the setuid ``chrome-sandbox`` helper, so
requiring it root-owned 4755 (and prompting for sudo) is unnecessary. Probe
the real capability with ``unshare``; fails closed on hosts where user
namespaces are disabled or AppArmor-restricted.
"""
"""True when the unprivileged userns sandbox works (probed with ``unshare``, fails closed) — then
the setuid ``chrome-sandbox`` helper is never consulted and no sudo prompt is needed."""
if sys.platform != "linux":
return False
unshare = shutil.which("unshare")
@@ -1243,12 +1128,8 @@ def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool:
def _desktop_linux_needs_disable_setuid_sandbox(packaged_executable: Path) -> bool:
"""True when Chromium should skip the present-but-non-setuid helper.
A user-owned ``chrome-sandbox`` still makes Chromium abort with
``setuid_sandbox_host`` even when the namespace sandbox works. Call only
after ``_desktop_linux_sandbox_fixup`` succeeded via the userns path.
"""
"""True when a present, non-setuid ``chrome-sandbox`` would make Chromium abort with
``setuid_sandbox_host`` despite a working userns sandbox (call after the fixup's userns path)."""
if sys.platform != "linux":
return False
_sandbox, st = _sandbox_helper_lstat(packaged_executable)
@@ -1259,15 +1140,9 @@ _LINUX_PASSWORD_STORES = frozenset({"gnome-libsecret", "kwallet", "kwallet5", "k
def _detect_linux_password_store() -> str | None:
"""Detect the Chromium password-store backend for the current Linux session.
safeStorage only reports encryption available when Chromium selects the right
keychain backend, and its own detection routinely fails under ``hermes
desktop`` (launcher env doesn't look like a desktop session). Probe order:
KDE session env, GNOME Keyring control socket, D-Bus ping of
org.freedesktop.secrets (any Secret Service, e.g. KeePassXC). None when no
keychain daemon is reachable.
"""
"""Chromium password-store backend for this Linux session (KDE env → GNOME Keyring socket → D-Bus
ping of org.freedesktop.secrets), or None. Chromium's own detection fails under the launcher
env, and safeStorage then reports encryption unavailable."""
kde_version = os.environ.get("KDE_SESSION_VERSION", "").strip()
if kde_version:
return {"6": "kwallet6", "5": "kwallet5"}.get(kde_version, "kwallet")
@@ -1294,20 +1169,13 @@ def _detect_linux_password_store() -> str | None:
def _desktop_launch_options() -> tuple[list[str], str, str, str]:
"""Read `desktop.*` launch options from config.yaml.
Returns ``(electron_flags, disable_gpu, password_store, ozone_hint)``:
``disable_gpu`` is "auto"/"1"/"0" (for HERMES_DESKTOP_DISABLE_GPU),
``password_store`` is "auto" or a Chromium backend (unknown → "auto"),
``ozone_hint`` is "auto"/"x11"/"wayland" (for ELECTRON_OZONE_PLATFORM_HINT).
Any config error yields ``([], "auto", "auto", "auto")`` so a malformed
config never blocks the launch.
"""
"""``desktop.*`` launch options: ``(electron_flags, disable_gpu "auto"/"1"/"0", password_store,
ozone_hint "auto"/"x11"/"wayland")``; unknown values and config errors yield "auto"/[] so a
malformed config never blocks the launch."""
flags: list[str] = []
disable_gpu = password_store = ozone_hint = "auto"
try:
from hermes_cli.config import load_config
desktop_cfg = (load_config() or {}).get("desktop") or {}
except Exception:
return flags, disable_gpu, password_store, ozone_hint
@@ -1347,7 +1215,6 @@ def _register_linux_desktop_entry() -> None:
from hermes_cli.main import PROJECT_ROOT
try:
from hermes_cli.linux_desktop_entry import install_desktop_entry, is_supported
if not is_supported():
return
entry = install_desktop_entry(PROJECT_ROOT)
@@ -1361,7 +1228,6 @@ def _install_desktop_workspace_deps(npm: str, env: dict) -> None:
"""npm-install the desktop workspace; exits on a failure that isn't a repairable missing Electron dist."""
from hermes_cli.main import PROJECT_ROOT, _run_npm_install_deterministic
from hermes_constants import with_hermes_node_path
print("→ Installing desktop workspace dependencies...")
# Managed Node on PATH so npm's child scripts that shell out to bare `node`
# (e.g. electron-winstaller's select-7z-arch.js) resolve it even when the
@@ -1389,14 +1255,11 @@ def _run_desktop_pack_with_recovery(
) -> subprocess.CompletedProcess:
"""Run the desktop build; a packaged build with NO staged exe retries after an Electron re-download, then via mirror.
Gate on a MISSING packaged executable: that is the signature of the
corrupt-download class (corrupt cached zip → partial unpack → ENOENT on
rename). A late failure such as macOS code signing leaves the executable in
place — redownloading can't repair it, so the retry would only add a slow,
identical failure.
A MISSING exe is the signature of the corrupt-download class; a late failure
(e.g. macOS signing) leaves it in place and a redownload retry would only
repeat the same slow failure.
"""
from hermes_cli.main import PROJECT_ROOT, _electron_dist_ok, _purge_electron_build_cache, _redownload_electron_dist, _stop_desktop_processes_locking_build
def _staged_exe() -> Optional[Path]:
return _desktop_packaged_executable_in(staging_dir) if staging_dir else None
@@ -1463,14 +1326,9 @@ def _promote_staged_desktop_app(desktop_dir: Path, staging_dir: Path) -> Path:
def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: dict) -> Optional[Path]:
"""npm-install + build the desktop app; stage-and-swap the packaged tree.
Returns the freshly installed packaged executable (non-source mode) or None
(source mode builds ``dist/`` in place). Exits on any unrecoverable build
failure, leaving the previous packaged app untouched.
"""
"""npm-install + build the desktop app, stage-and-swapping the packaged tree. Returns the new
packaged exe (None in source mode). Exits on unrecoverable failure with the previous app kept."""
from hermes_cli.main import PROJECT_ROOT, _desktop_packaged_executable, _stop_desktop_processes_locking_build, _write_desktop_build_stamp
_install_desktop_workspace_deps(npm, env)
build_label = "source build" if source_mode else "packaged app"
@@ -1520,15 +1378,10 @@ def _build_desktop_app(desktop_dir: Path, *, source_mode: bool, npm: str, env: d
def _desktop_launch_env(args: argparse.Namespace) -> tuple[dict, list[str]]:
"""Child env for the Electron process plus the config-supplied extra Electron flags.
Config (``desktop.*``) is bridged to env vars the Electron/Chromium process
already reads; an explicit env var still wins over config. Linux keychain
backend for safeStorage: config wins over detection, env wins over both.
"""
"""Electron child env + config-supplied extra flags. ``desktop.*`` config is bridged to env vars
Electron already reads; an explicit env var wins over config (and over keychain detection)."""
from hermes_cli.main import _desktop_launch_options, _detect_linux_password_store
from hermes_constants import with_hermes_node_path
# with_hermes_node_path() copies os.environ when called with no arg.
env = with_hermes_node_path()
if getattr(args, "fake_boot", False):

View File

@@ -43,7 +43,6 @@ def _print_tui_exit_summary(session_id: Optional[str], active_session_file: Opti
db = None
try:
from hermes_state import SessionDB
db = SessionDB()
session = db.get_session(target)
if not session:
@@ -94,14 +93,9 @@ intersection comparison in :func:`_tui_need_npm_install` always catches.
def _workspace_root(dir: Path) -> Path:
"""The npm workspace root for *dir*.
If *dir* has a ``package.json`` but no ``package-lock.json`` and its parent has
one, the parent is the workspace root (single lockfile + hoisted node_modules).
Otherwise *dir* itself (standalone project or prebuilt-bundle layout). Shared
by the install-need check, the TUI launcher and the web build so lockfile /
node_modules resolution and ``npm install`` cwd can't diverge.
"""
"""The npm workspace root for *dir*: its parent when *dir* has ``package.json`` but the
lockfile lives one level up (hoisted node_modules), else *dir* (standalone / prebuilt).
Shared by the install check, TUI launcher and web build so their cwd can't diverge."""
if (
(dir / "package.json").is_file()
and not (dir / "package-lock.json").is_file()
@@ -141,19 +135,14 @@ def _termux_workspace_install_context(dir: Path, *, include_child_workspaces: bo
def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]:
"""Package-map keys reachable from the selected workspaces via npm resolution.
"""Package-map keys reachable from the selected workspaces (*starts*: set or str) via npm resolution.
*starts* is the set of workspace keys the launch install scopes to (a str is
accepted). ``devDependencies`` are followed for each of those (npm installs
the dev toolchain of every workspace it selects) but not for transitive deps.
Returns ``None`` when none of *starts* are in *packages* so callers fall back
to the full-lockfile comparison. The shared root lock also lists every OTHER
workspace's deps (``apps/desktop``, ``web``); comparing in full reported them
as "missing" and reinstalled on every launch.
Keys follow npm's v3 ``packages`` map; dependency names resolve by walking up
``node_modules`` ancestors (node resolution), and workspace symlinks
(``link: true``) are followed to their real entry.
``devDependencies`` are followed for each start (npm installs every selected
workspace's dev toolchain) but not for transitive deps. None when no start is
in *packages* so callers fall back to the full comparison — which would report
every OTHER workspace's deps (``apps/desktop``, ``web``) as missing and
reinstall on every launch. Names resolve by walking up ``node_modules``
ancestors; ``link: true`` entries are followed to their real package.
"""
start_set = {starts} if isinstance(starts, str) else {s for s in starts if s}
present = [s for s in start_set if s in packages]
@@ -198,12 +187,8 @@ def _npm_lock_workspace_closure(packages: dict, starts) -> Optional[set]:
def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set:
"""Lock-map keys for the workspaces the launch install scopes to.
Mirrors ``_make_tui_argv``: the ui-tui workspace, plus its child ``packages/*``
on Termux. Each is a dev-included closure root (npm installs devDependencies
of every selected workspace). Empty set when ui-tui isn't under *ws_root*.
"""
"""Lock-map keys the launch install scopes to: ui-tui, plus its child ``packages/*`` on Termux
(each a dev-included closure root). Empty when ui-tui isn't under *ws_root*."""
from hermes_cli.main import _is_termux_startup_environment
try:
keys = {tui_dir.relative_to(ws_root).as_posix()}
@@ -221,16 +206,13 @@ def _tui_selected_workspace_keys(tui_dir: Path, ws_root: Path) -> set:
def _tui_need_npm_install(root: Path) -> bool:
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
Prebuilt bundle (``dist/entry.js`` with no lockfile): nothing to install.
Lockfile / ink / marker checks use the workspace root. The root lock is
compared against npm's hidden ``node_modules/.package-lock.json`` by CONTENT
(git checkouts bump mtimes without changing deps): an entry missing from the
hidden lock → reinstall unless ``optional``/``peer``/``link`` or not under
``node_modules/``; present in both → compare only the intersection of
non-null fields after stripping ``_NPM_LOCK_RUNTIME_KEYS`` (the hidden lock
omits or nulls many metadata fields; ``resolved``/``integrity`` are always in
both). Extra hidden-only entries are ignored. Falls back to mtime when either
lockfile is unparseable.
Prebuilt bundle (``dist/entry.js``, no lockfile): nothing to install. The root
lock is compared to npm's hidden ``node_modules/.package-lock.json`` by CONTENT
(git bumps mtimes without changing deps): missing from hidden → reinstall
unless ``optional``/``peer``/``link`` or outside ``node_modules/``; present in
both → compare the intersection of non-null fields minus
``_NPM_LOCK_RUNTIME_KEYS`` (``resolved``/``integrity`` are always in both).
Hidden-only entries are ignored; unparseable lockfiles fall back to mtime.
"""
entry = root / "dist" / "entry.js"
ws_root = _workspace_root(root)
@@ -317,11 +299,8 @@ def _iter_tui_build_inputs(root: Path):
def _tui_need_rebuild(root: Path) -> bool:
"""True when ``dist/entry.js`` is missing or older than TUI inputs.
Rebuilding on every launch is a visible cold-start tax on slow Termux CPUs;
``HERMES_TUI_FORCE_BUILD=1`` forces the old always-rebuild behaviour.
"""
"""True when ``dist/entry.js`` is missing or older than TUI inputs (Termux cold-start saver);
``HERMES_TUI_FORCE_BUILD=1`` forces a rebuild."""
force = (os.environ.get("HERMES_TUI_FORCE_BUILD") or "").strip().lower()
if force in {"1", "true", "yes", "on"}:
return True
@@ -341,13 +320,8 @@ def _tui_need_rebuild(root: Path) -> bool:
def _ensure_tui_node() -> None:
"""Make sure `node` + `npm` are on PATH for the TUI.
If either is missing, source scripts/lib/node-bootstrap.sh and call
`ensure_node` (fnm/nvm/proto/brew/bundled cascade), then prepend the resolved
node's directory to PATH so shutil.which finds it in this process. No-op when
both exist; ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install.
"""
"""Ensure `node` + `npm` are on PATH: else run node-bootstrap.sh `ensure_node` and prepend
the resolved node dir to PATH. ``HERMES_SKIP_NODE_BOOTSTRAP=1`` disables auto-install."""
from hermes_cli.main import PROJECT_ROOT
if shutil.which("node") and shutil.which("npm"):
return
@@ -359,7 +333,6 @@ def _ensure_tui_node() -> None:
return
from hermes_constants import get_hermes_home
hermes_home = str(get_hermes_home())
try:
# Helper logs to stderr; stdout carries `command -v node` — subshell PATH
@@ -397,13 +370,8 @@ def _find_bundled_tui(hermes_cli_dir: Path | None = None) -> Path | None:
def _restore_tui_workspace(tui_dir: Path) -> bool:
"""Try to restore a missing ``ui-tui/`` from git, returning True on success.
On Windows an antivirus / NTFS filter driver can leave tracked ``ui-tui/``
files deleted after ``hermes update``; ``git restore`` puts them back.
Best-effort: False when git is unavailable, this isn't a checkout, or the
directory is still missing afterwards.
"""
"""Best-effort ``git restore`` of a missing ``ui-tui/`` (Windows AV/NTFS filters can delete
tracked files after ``hermes update``); True when the directory exists afterwards."""
git = shutil.which("git")
if not git or not (tui_dir.parent / ".git").exists():
return False
@@ -418,12 +386,8 @@ def _restore_tui_workspace(tui_dir: Path) -> bool:
def _ensure_tui_workspace(tui_dir: Path) -> None:
"""Ensure ``ui-tui/`` exists before any npm/node subprocess uses it as cwd.
Otherwise ``subprocess.run(cwd=<missing>)`` crashes with ``NotADirectoryError``
(``WinError 267``) instead of a usable message. Self-heal via ``git restore``
first; abort with manual recovery steps only if that fails.
"""
"""Ensure ``ui-tui/`` exists before it is used as a subprocess cwd (else ``NotADirectoryError``
/ ``WinError 267`` with no usable message): git-restore first, then abort with recovery steps."""
if tui_dir.is_dir():
return
@@ -456,18 +420,13 @@ def _npm_lifecycle_env(env: dict[str, str] | None = None) -> dict[str, str]:
def _tui_node_bin(bin: str) -> str:
"""Resolve ``node``/``npm`` for the TUI launch, or exit with a hint.
``HERMES_NODE`` wins for node. ``find_node_executable()`` prefers the managed
``$HERMES_HOME/node`` tree, which is not on PATH — a bare which() would say
"node not found" on an install whose only Node is the one Hermes installed.
"""
"""Resolve ``node``/``npm`` for the TUI launch, or exit with a hint. ``HERMES_NODE`` wins for node;
``find_node_executable()`` sees the managed ``$HERMES_HOME/node`` tree a bare which() misses."""
if bin == "node":
env_node = os.environ.get("HERMES_NODE")
if env_node and os.path.isfile(env_node) and os.access(env_node, os.X_OK):
return env_node
from hermes_constants import find_node_executable
path = find_node_executable(bin)
if not path and bin == "node":
try:
@@ -506,11 +465,10 @@ def _run_tui_npm_build(npm: str, cwd: Path, failure_message: str) -> None:
def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
"""``npm install`` for the TUI workspace, with one EBADENGINE repair retry. Exits on failure.
``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty);
omitted when ui-tui/ has its own lockfile (npm can't find a workspace named
"ui-tui" inside ui-tui/). Termux scopes the install to ui-tui + its child
packages. ``--include=dev``: the build toolchain lives in devDependencies and
an inherited ``NODE_ENV=production`` / ``omit=dev`` would silently skip it.
``--workspace ui-tui`` avoids resolving apps/desktop (Electron + node-pty) and
is omitted when ui-tui/ has its own lockfile. ``--include=dev``: the build
toolchain is in devDependencies and an inherited ``NODE_ENV=production`` /
``omit=dev`` would silently skip it.
"""
npm = _tui_node_bin("npm")
if not os.environ.get("HERMES_QUIET"):
@@ -526,7 +484,6 @@ def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
def _run_tui_install() -> subprocess.CompletedProcess:
from hermes_constants import with_hermes_node_path
# Managed tree first on PATH: if the EBADENGINE repair provisioned a
# managed Node, npm's shebang/lifecycle scripts must resolve that node.
return subprocess.run(
@@ -541,7 +498,6 @@ def _install_tui_dependencies(tui_dir: Path, *, termux_startup: bool) -> None:
# repair once (upgrade a managed npm in place, or provision a managed
# runtime) and retry rather than dumping EBADENGINE at the user.
from hermes_cli.npm_engine import maybe_repair_npm_engine
repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
if repaired_npm:
npm_install_cmd[0] = repaired_npm
@@ -629,20 +585,16 @@ def _normalize_tui_toolsets(toolsets: object) -> list[str]:
"""Normalize argparse/Fire-style toolset input for the TUI subprocess."""
try:
from hermes_cli.oneshot import _normalize_toolsets
return _normalize_toolsets(toolsets) or []
except (AttributeError, ImportError):
return _split_comma_items(toolsets, split_non_str=False) if toolsets else []
def _read_cgroup_memory_limit() -> Optional[int]:
"""Container memory limit in bytes, or None if unconstrained.
"""Container memory limit in bytes, or None if unconstrained (v2 ``memory.max``, then v1).
V8 is NOT cgroup-aware: a flat ``--max-old-space-size=8192`` grows past a
smaller container limit and the cgroup OOM-killer SIGKILLs Node (no JS
handler, no breadcrumb — the user sees a bare ``stdin EOF``). Checks cgroup
v2 ``memory.max`` then v1 ``memory.limit_in_bytes``; ``max`` or the v1
near-INT64 "unlimited" sentinel means no limit.
V8 is NOT cgroup-aware: a flat 8GB heap grows past a smaller container limit
and the OOM-killer SIGKILLs Node with no breadcrumb (bare ``stdin EOF``).
"""
candidates = (
"/sys/fs/cgroup/memory.max", # cgroup v2
@@ -671,13 +623,9 @@ def _read_cgroup_memory_limit() -> Optional[int]:
def _resolve_tui_heap_mb(default_mb: int = 8192) -> int:
"""Pick a V8 ``--max-old-space-size`` (MB) that fits the container.
``default_mb`` when unconstrained or the box is large enough; otherwise ~75% of
the cgroup limit (headroom for non-heap RSS and the Python gateway child in
the same cgroup), floored at 1536MB when the container is > 2GB (below that
V8 GC-thrashes). Never exceeds ``default_mb``.
"""
"""V8 ``--max-old-space-size`` (MB) that fits the container: ``default_mb`` when unconstrained,
else 75% of the cgroup limit (headroom for non-heap RSS + the gateway child), floored at
1536MB when the container is > 2GB (below that V8 GC-thrashes)."""
from hermes_cli.main import _read_cgroup_memory_limit
limit = _read_cgroup_memory_limit()
if not limit:
@@ -731,7 +679,6 @@ def _setup_tui_worktree() -> dict:
wt_info = None
try:
from cli import _git_repo_root, _maintain_pack_health, _prune_stale_worktrees, _setup_worktree
repo = _git_repo_root()
if repo:
_prune_stale_worktrees(repo)
@@ -760,7 +707,6 @@ def _launch_tui(
tui_dir = PROJECT_ROOT / "ui-tui"
import tempfile
# TUI child is a hermes process: propagate the profile-home contract via
# the single factory; keep secrets (the TUI/agent needs provider creds).
from tools.environments.local import build_subprocess_env
@@ -849,7 +795,6 @@ def _launch_tui(
# preserve_inherited=False keeps --tui and other flags out of the subcommand.
if code == 42:
from hermes_cli.relaunch import relaunch
print()
print("⚕ Launching update...")
print()
@@ -859,47 +804,34 @@ def _launch_tui(
def _pin_kanban_board_env() -> None:
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` for the chat session.
Otherwise in-process ``kanban_*`` tools and shelled-out ``hermes kanban`` calls
resolve the board on different paths (env pin vs the global ``kanban/current``
file), and a concurrent ``boards switch`` can flip the file mid-turn.
"""
"""Pin the active kanban board into ``HERMES_KANBAN_BOARD`` so in-process tools and shelled-out
``hermes kanban`` calls agree even if a concurrent ``boards switch`` flips the file mid-turn."""
if os.environ.get("HERMES_KANBAN_BOARD"):
return
try:
from hermes_cli.kanban_db import get_current_board
os.environ["HERMES_KANBAN_BOARD"] = get_current_board()
except Exception:
pass
def _sync_bundled_skills_quietly() -> None:
"""Seed ``~/.hermes/skills/`` with the bundled skill library on first launch.
Manifest-based and idempotent (skipped skills cost milliseconds), so every
first-interaction entrypoint may call it. Failures are swallowed: skills are
an enhancement, not a hard dependency.
"""
"""Seed ``~/.hermes/skills/`` with the bundled library (idempotent, milliseconds when synced).
Failures are swallowed: skills are an enhancement, not a hard dependency."""
try:
from tools.skills_sync import sync_skills
sync_skills(quiet=True)
except Exception:
pass
def _resolve_use_tui(args) -> bool:
"""Decide whether to launch the TUI for a chat/bare invocation.
Precedence: ``--cli`` → classic; ``--tui`` → TUI; no TTY → classic;
"""Decide whether to launch the TUI: ``--cli`` → classic; ``--tui`` → TUI; no TTY → classic;
``HERMES_TUI=1`` → TUI; ``display.interface`` config; default classic.
The TTY gate is load-bearing: ambient TUI preferences must never hijack a
non-interactive invocation (kanban workers, cron, pipelines run
``hermes chat -q`` on a pipe; the Ink no-TTY bail-out exits 0 and a kanban
worker then dies with a protocol violation). An explicit ``--tui`` still gets
the informative bail-out.
The TTY gate is load-bearing: ambient preferences must never hijack a piped
``hermes chat -q`` (kanban workers, cron) — the Ink no-TTY bail-out exits 0 and
the worker dies with a protocol violation. Explicit ``--tui`` still bails out.
"""
if getattr(args, "cli", False):
return False
@@ -914,7 +846,6 @@ def _resolve_use_tui(args) -> bool:
return True
try:
from hermes_cli.config import load_config
iface = (load_config().get("display", {}) or {}).get("interface", "cli")
return isinstance(iface, str) and iface.strip().lower() == "tui"
except Exception:

View File

@@ -48,15 +48,11 @@ def _record_bytecode_fingerprint() -> None:
def _sweep_stale_bytecode_if_checkout_changed() -> None:
"""Clear ``__pycache__`` at launch when the checkout changed underneath us.
"""Clear ``__pycache__`` at launch when the checkout fingerprint changed since the last sweep.
Stale-bytecode bug class: the checkout's ``.py`` files change (git pull inside
``hermes update``, a manual pull, a ZIP update, a file-sync restore) while
``__pycache__`` keeps bytecode from the previous revision. Update-time clears
can't close it — ``hermes update`` runs the PRE-pull updater code, and manual
pulls never run it — so every entry point compares the checkout fingerprint
(cheap file reads, no git subprocess) against the last-validated stamp and
sweeps once when they diverge. Never raises.
Update-time clears can't close the stale-bytecode class: ``hermes update`` runs
the PRE-pull updater code and manual pulls never run it. Cheap file reads, no
git subprocess. Never raises.
"""
from hermes_cli.main import PROJECT_ROOT, _clear_bytecode_cache, _read_git_revision_fingerprint, _record_bytecode_fingerprint
try:
@@ -112,7 +108,6 @@ def _hash_source_tree(project_root: Path, tree_dir: Path) -> str:
h.update(b"\0")
from pathspec import PathSpec
gitignore = project_root / ".gitignore"
lines = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.is_file() else []
spec = PathSpec.from_lines("gitignore", lines)
@@ -218,13 +213,9 @@ def _run_with_idle_timeout(
cmd: list[str], cwd: Path, *, idle_timeout_seconds: int = 180, indent: str = " ",
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess:
"""Run a subprocess that streams output, killing it after *idle_timeout_seconds* of silence.
A silent, captured ``npm run build`` on a low-memory host looks like a hang and
users reboot mid-install. Stdout is streamed, and idle output terminates the
process with a non-zero returncode (124 if terminate raced a clean exit).
Returns merged stdout (text) and empty stderr; never raises on idle timeout.
"""
"""Stream a subprocess, killing it after *idle_timeout_seconds* of silence (a silent captured
Vite build on a low-memory host looks like a hang and users reboot mid-install). Returns merged
stdout, empty stderr, rc 124 if terminate raced a clean exit; never raises on idle timeout."""
merged_chunks: list[str] = []
last_output_ts = _time.monotonic()
lock = threading.Lock()
@@ -285,15 +276,10 @@ def _run_with_idle_timeout(
def _nixos_build_env() -> dict[str, str] | None:
"""Extra env for native module builds on NixOS, or None when not needed.
node-gyp's ``find-python.js`` does a bare PATH lookup for python3, which fails
on NixOS outside a nix-shell. Tier 1: the hermes venv python3; tier 2: resolve
via ``nix-shell`` (a self-contained Nix store binary, valid after the shell exits).
"""
"""``PYTHON=`` env for node-gyp on NixOS (bare PATH lookup fails outside nix-shell): the hermes
venv python3, else a ``nix-shell``-resolved store path. None off NixOS / python3 on PATH."""
from hermes_cli.main import PROJECT_ROOT
import re
try:
os_release = Path("/etc/os-release").read_text(encoding="utf-8")
except OSError:
@@ -327,14 +313,11 @@ def _run_npm_install_deterministic(
) -> subprocess.CompletedProcess:
"""Deterministic npm install that never mutates ``package-lock.json``.
``npm ci`` when a lockfile is present, else/on failure ``npm install --no-save``
(lockfile may be out of sync on a WIP checkout; ``--no-save`` keeps the
contract — a rewritten lockfile makes every future ``npm ci`` fail).
``--include=dev`` is forced: callers are frontend builds whose toolchain is in
devDependencies, and an inherited ``NODE_ENV=production`` / ``omit=dev`` would
silently skip them (exit 0) and the build then dies with ``tsc: not found``.
An npm outside the root ``engines.npm`` range fails every command identically,
so that failure gets exactly one ``maybe_repair_npm_engine`` retry.
``npm ci`` when a lockfile exists, else/on failure ``npm install --no-save``
(a rewritten lockfile makes every future ``npm ci`` fail). ``--include=dev``
is forced: an inherited ``NODE_ENV=production`` / ``omit=dev`` silently skips
the build toolchain and the build dies with ``tsc: not found``. An npm outside
``engines.npm`` fails every command, so it gets one engine-repair retry.
"""
# CI=1 no-ops unicode-animations' postinstall that animates to /dev/tty.
run_env = _npm_lifecycle_env(env)
@@ -355,14 +338,12 @@ def _run_npm_install_deterministic(
return result
from hermes_cli.npm_engine import maybe_repair_npm_engine
repaired_npm = maybe_repair_npm_engine(npm, f"{result.stdout or ''}\n{result.stderr or ''}")
if not repaired_npm:
return result
# A freshly provisioned managed npm resolves `node` from PATH — put the
# managed tree first so it finds the managed Node, not a mismatched system one.
from hermes_constants import with_hermes_node_path
run_env["PATH"] = with_hermes_node_path(run_env)["PATH"]
return _attempt(repaired_npm)
@@ -404,14 +385,9 @@ def _missing_web_build_tool(output: str) -> str | None:
def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
"""Build the web UI frontend if npm is available, serializing across processes.
Concurrent dashboard boots used to each spawn ``npm install`` + ``vite build``
over the same tree and starve each other. One process builds under an
exclusive flock; the rest serve the existing dist (stale is acceptable) or,
when none exists yet, block until the builder finishes. Staleness is checked
once inside :func:`_do_build_web_ui`, after the lock is held.
"""
"""Build the web UI if npm is available, serialized across processes by flock: one builds, the
rest serve the existing dist (stale is fine) or block until the first build exists. Staleness is
checked inside :func:`_do_build_web_ui` after the lock is held."""
if not (web_dir / "package.json").exists():
return True
try:
@@ -449,14 +425,11 @@ def _relay_npm_output(result: subprocess.CompletedProcess) -> None:
def _web_npm_install_context(web_dir: Path) -> tuple[Path, tuple[str, ...]]:
"""``(cwd, workspace_args)`` for installing the web workspace's deps.
Scoped to ``--workspace web`` so the root ``apps/*`` glob never pulls desktop
(Electron + node-pty) into a web build; no args when ``web/`` has its own
lockfile (``_workspace_root`` returns web_dir and ``--workspace`` would fail).
From the workspace root this must name the SAME closure as ``hermes update``'s
``_update_node_dependencies()`` (ui-tui + web + root): ``npm ci`` deletes
node_modules before reifying, so a narrower closure silently prunes what the
update step just installed while still exiting 0. ui-tui is only named when
present (prebuilt/partial checkouts lack it and npm fails hard otherwise).
``--workspace web`` keeps desktop (Electron + node-pty) out of a web build; no
args when ``web/`` has its own lockfile. From the root this must name the SAME
closure as ``hermes update``'s ``_update_node_dependencies()`` (ui-tui + web +
root): ``npm ci`` wipes node_modules first, so a narrower closure silently
prunes what update just installed. ui-tui is named only when present.
"""
from hermes_cli.main import _is_termux_startup_environment
if _is_termux_startup_environment():
@@ -491,7 +464,6 @@ def _do_build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
return True
from hermes_constants import with_hermes_node_path
npm = _resolve_node_runtime_npm()
if not npm:
if fatal: