diff --git a/hermes_cli/main_dashboard.py b/hermes_cli/main_dashboard.py index eebc210325..7313ef75f9 100644 --- a/hermes_cli/main_dashboard.py +++ b/hermes_cli/main_dashboard.py @@ -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//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//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=`` 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=`` 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) diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index 9088ea087a..28fbe1d6f2 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -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--``. - - 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--``: 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/`` → ``.previous``, ``/`` → - ``release/``, 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): diff --git a/hermes_cli/main_tui_launch.py b/hermes_cli/main_tui_launch.py index 2a23780d5a..8454273835 100644 --- a/hermes_cli/main_tui_launch.py +++ b/hermes_cli/main_tui_launch.py @@ -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=)`` 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: diff --git a/hermes_cli/main_web_build.py b/hermes_cli/main_web_build.py index e5f1c16878..ba152938d9 100644 --- a/hermes_cli/main_web_build.py +++ b/hermes_cli/main_web_build.py @@ -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: