From 6fe933e7091f8dfa1fe29785e12ea6e5abba6ffc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:24:10 -0700 Subject: [PATCH] fix(cli): launch-context-independent Linux desktop-entry Exec (salvaged from #94874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites resolve_exec_command so the generated .desktop Exec no longer depends on how the installer happened to be launched: fixes the bare repo-script form whose shebang escapes the venv, and the symlinked-venv form that .resolve() dereferenced into the base interpreter store. Salvaged squashed from PR #94874 (24 commits) after the original branch was found to carry stray __pycache__/.gitignore payload. Co-authored-by: Gökhan --- hermes_cli/gui_uninstall.py | 16 +- hermes_cli/linux_desktop_entry.py | 528 ++++++++++++- tests/hermes_cli/test_linux_desktop_entry.py | 752 ++++++++++++++++++- 3 files changed, 1260 insertions(+), 36 deletions(-) diff --git a/hermes_cli/gui_uninstall.py b/hermes_cli/gui_uninstall.py index aa587d655f..dc0d991ce3 100644 --- a/hermes_cli/gui_uninstall.py +++ b/hermes_cli/gui_uninstall.py @@ -147,12 +147,20 @@ def packaged_gui_app_paths() -> "list[Path]": data = os.environ.get("XDG_DATA_HOME") data_base = Path(data) if data else (home / ".local" / "share") paths += [ - # The launcher entry `hermes desktop` installs. Its icon lives - # in the checkout, not in the installed app. + # The launcher entry `hermes desktop` installs. Its icon is + # also copied into the hicolor tree (see + # linux_desktop_entry._install_icon_to_hicolor) — remove + # every size dir the installer could have written. desktop_entry_path(), # Some packaged builds emit this casing. data_base / "applications" / "Hermes.desktop", + data_base / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png", ] + # Fixed-size hicolor dirs: the icon is copied at its native size + # (read from the PNG header), so sweep the standard ones plus the + # 1024x1024 dir the shipped asset lands in. + for size in ("256x256", "512x512", "1024x1024"): + paths.append(data_base / "icons" / "hicolor" / size / "apps" / "hermes.png") return paths @@ -230,7 +238,9 @@ def _remove_path(path: Path) -> bool: return False -def uninstall_gui(hermes_home: "Path | None" = None, *, remove_userdata: bool = True) -> "list[Path]": +def uninstall_gui( + hermes_home: "Path | None" = None, *, remove_userdata: bool = True +) -> "list[Path]": """Remove the desktop GUI's artifacts, leaving the agent + user data intact. Removes: diff --git a/hermes_cli/linux_desktop_entry.py b/hermes_cli/linux_desktop_entry.py index a813702a25..3a95229e76 100644 --- a/hermes_cli/linux_desktop_entry.py +++ b/hermes_cli/linux_desktop_entry.py @@ -19,14 +19,15 @@ for the freedesktop menu cache, and ``kbuildsycoca6``/``kbuildsycoca5`` for Plasma. Run each tool only when it exists. A missing tool is not an error. -Import-light and side-effect-free at import time: the uninstaller and the -Electron main process both use this without loading the full CLI. +Import-light and side-effect-free at import time: the uninstaller uses +this without loading the full CLI. """ from __future__ import annotations import os import shutil +import struct import subprocess import sys from pathlib import Path @@ -57,16 +58,126 @@ def icon_path(project_root: Path) -> Path: return project_root / "apps" / "desktop" / "assets" / "icon.png" -def resolve_exec_command() -> str: +def _running_interpreter() -> str: + """The venv-semantic interpreter path for the persisted ``Exec=`` line. + ``sys.executable`` inside a venv is commonly a SYMLINK into a shared + base-interpreter tree (uv, pyenv, conda). ``Path.resolve()`` follows it + out of the venv, and CPython discovers ``pyvenv.cfg`` from the + *lexical* argv[0] — so a dereferenced path boots without the venv's + site-packages and dies on the first third-party import (#90292, one + level up; identified in #80547's review and confirmed on real Zorin/uv + hardware in this PR's review). + + Keep the lexical path only when it actually is venv-semantic (a + ``pyvenv.cfg`` sits at or above it in the tree); otherwise the + dereferenced absolute path is the more durable form (survives the + symlink being re-pointed or its parent moving). + + Idea credit: the lexical-preservation rule was independently proposed + in #92516/#94115/#94544 and by nosliwhtes' review of this PR; the + pyvenv.cfg-detection refinement here keeps both properties. + """ + lexical = os.path.abspath(sys.executable) + path = Path(lexical) + for base in (path.parent, *path.parent.parents): + if (base / "pyvenv.cfg").is_file(): + return lexical + return str(path.resolve()) + + +_probe_cache: "dict[str, bool]" = {} + + +def _can_import_hermes_cli(interpreter: Path) -> bool: + """Whether *interpreter* can import ``hermes_cli.main`` unaided. + + Runs the import in a subprocess under ``-I`` (isolated mode: no + user site, no PYTHONPATH inheritance, no cwd on ``sys.path``) from + a neutral cwd, so the answer matches what a cold desktop + environment would get — a checkout cwd or an inherited + ``PYTHONPATH`` cannot produce a false positive. Bounded by a + timeout so a hung interpreter cannot stall entry generation. + + Result is cached per interpreter path for the process lifetime, so + a desktop launch pays the subprocess cost at most once. + + Probe design per @nosliwhtes' isolated-mode capability check + (#92122 lineage, commit 4150501f641). + """ + key = str(interpreter) + cached = _probe_cache.get(key) + if cached is not None: + return cached + try: + result = subprocess.run( + [key, "-I", "-c", "import hermes_cli.main"], + cwd=os.path.abspath(os.sep), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + timeout=15, + ) + ok = result.returncode == 0 + except (OSError, subprocess.SubprocessError): + # Unprobeable (missing binary, spawn failure, timeout): do not + # punish the entry on infra hiccups — assume capable and let the + # existing fallback chain handle a genuinely broken interpreter. + # This error-derived answer is deliberately NOT cached: one + # transient hiccup must not freeze the "capable" assumption for + # the whole session; the next install attempt re-probes. + return True + _probe_cache[key] = ok + return ok + + +def _running_interpreter_fallback() -> str: + """The interpreter to persist when the candidate fails the import probe. + + The RUNNING interpreter by definition has ``hermes_cli`` importable + (this module is executing), so the module-form entry under it is the + safe landing when every candidate path failed the capability check. + """ + return os.path.abspath(sys.executable) + + +def resolve_exec_command(project_root: Optional[Path] = None) -> str: """Build the absolute ``Exec=`` command line for ``hermes desktop``. Prefer the real ``hermes`` executable (argv[0] or PATH). When Hermes runs as a module with no launcher installed, use the current interpreter, also absolute. + + The persisted entry must be launch-context independent: whatever + process writes it, the next launch must read and rewrite the same + bytes. ``resolve_hermes_bin()`` prefers ``sys.argv[0]``, which differs + per launch path (wrapper, repo script, ``python -m``), so for this + one caller an argv[0] that points inside the checkout is not a + durable installed launcher — skip it and resolve from PATH instead. + Otherwise a broken entry keeps regenerating itself (the repo-script + form pins a mutable uv interpreter path; the ``python -m`` form + persists a bare `` desktop`` that no DE can run). + + ``project_root`` pins which checkout counts as "internal"; defaults to + the running checkout. """ from hermes_cli.relaunch import resolve_hermes_bin - bin_path = resolve_hermes_bin() + bin_path = _resolve_hermes_bin_for_desktop_entry( + resolve_hermes_bin, checkout_root=project_root + ) + interpreter = _running_interpreter() + if not _can_import_hermes_cli(Path(interpreter)): + # The candidate interpreter cannot actually import hermes_cli.main + # (checked in isolated mode from a neutral cwd — so the probe can't + # be fooled by a checkout cwd or an inherited PYTHONPATH). Persisting + # it would write a dead entry: the DE spawns the Exec line in a cold + # environment where exactly this import has to succeed. Fall back to + # the module form under the RUNNING interpreter, which by definition + # has the CLI importable. Probe design follows the isolated-mode + # capability check proposed by @nosliwhtes (#92122 review lineage, + # commit 4150501f641) — cached here per-process so a desktop launch + # pays the subprocess cost at most once. + interpreter = _running_interpreter_fallback() if bin_path: resolved = Path(bin_path).resolve() if _needs_interpreter(resolved): @@ -78,14 +189,308 @@ def resolve_exec_command() -> str: # third-party import (#90292) — silently, since Terminal=false. # sys.executable is the interpreter actually running Hermes (the # venv one), so prefix it explicitly. - argv = [str(Path(sys.executable).resolve()), str(resolved), "desktop"] + argv = [interpreter, str(resolved), "desktop"] else: argv = [str(resolved), "desktop"] else: - argv = [str(Path(sys.executable).resolve()), "-m", "hermes_cli.main", "desktop"] + argv = [ + interpreter, + "-m", + "hermes_cli.main", + "desktop", + ] return " ".join(_quote_exec_arg(a) for a in argv) +def _resolve_hermes_bin_for_desktop_entry( + resolve_fn=None, + checkout_root: Optional[Path] = None, +) -> Optional[str]: + """Resolve the launcher binary for the persisted ``.desktop`` entry. + + Wraps :func:`hermes_cli.relaunch.resolve_hermes_bin` with one + desktop-entry-specific rule: an ``argv[0]`` that points inside this + checkout is a launch-context artifact (the repo ``hermes`` script the + wrapper execs with, or an interpreter binary surfaced by programmatic + relaunch paths), not a durable installed launcher. Persisting it makes + the entry a function of however the previous launch happened — the + bootstrap loop behind #90492's incomplete fix. Skip argv[0]/relative + candidates in that case and fall through to PATH, where the shell + installer's wrapper lives. + + ``resolve_fn`` is injectable for tests. + """ + if resolve_fn is None: + from hermes_cli.relaunch import resolve_hermes_bin as resolve_fn + + if checkout_root is None: + checkout_root = _project_root() + # Keep the LEXICAL form: _inside_checkout resolves candidates for its + # own comparison anyway, and _wrapper_targets_checkout needs the + # lexical root because the installer writes $INSTALL_DIR lexically + # into the shim text (symlinked homes would otherwise mismatch). + # Production callers pass main.py's realpath'd PROJECT_ROOT; the + # module-lexical root derived from __file__ is added alongside so a + # symlinked home still matches the shim's lexically-written paths. + checkout_root = Path(os.path.abspath(checkout_root)) + module_lexical_root = _project_root() + original_argv0 = sys.argv[0] + + def _inside_checkout(candidate: str) -> bool: + try: + path = Path(candidate).resolve() + except OSError: + return False + # The repo `hermes` script and anything else shipped in the tree is + # checkout-internal. Compare against BOTH the lexical and resolved + # roots (checkout_root is kept lexical; candidates resolve, so a + # symlinked home needs the resolved comparison too). + resolved_root = None + try: + resolved_root = checkout_root.resolve() + except OSError: + pass + for root in {checkout_root, resolved_root}: + if root is not None and (path == root or root in path.parents): + return True + # The `python -m hermes_cli.main` relaunch context surfaces the + # invoking interpreter (or a non-executable main.py, which the + # resolver already skips) as argv[0]; an interpreter is never a + # durable, launchable entry target (it would persist a bare + # ` desktop`). Compare against the *invoking* interpreter + # (argv[0]'s own file), not sys.executable — under test harnesses + # they differ. + try: + if path.samefile(original_argv0) and _is_interpreter(path): + return True + except OSError: + pass + return False + + def _is_interpreter(candidate: Path) -> bool: + """A python interpreter binary (``bin/python*``), not a launcher. + + Strict basename match — accepts ``python``, ``python3``, + ``python3.11``, ``python2.7``; rejects lookalikes such as + ``python3-config``, ``pythonw``, and anything else merely + *containing* "python". Regex approach proposed independently in + #94051; kept here with the parent-dir guard so a script named + ``python`` outside a bin/Scripts tree is not misclassified. + """ + import re + + name = candidate.name.lower() + if not re.fullmatch(r"python[23]?(\d+)?(\.\d+)?", name): + return False + return candidate.parent.name in {"bin", "scripts"} + + # Resolve the primary FIRST and only rerun the resolver with argv[0] + # hidden when the primary could actually be checkout-internal: for an + # already-external primary the comparison can never change the + # outcome, so skipping the rerun saves a resolver call and shortens + # the window in which a concurrent reader could see the mutated + # sys.argv. + primary = resolve_fn() + + # A primary that is NOT checkout-internal and not the invoking + # interpreter is an external launcher (e.g. /opt/.../bin/hermes from + # another install method, or a venv console script). It must be + # evaluated BEFORE any known-location probing: probing first could + # silently switch the entry to a different installation (#94443 + # review case 3). + if primary and not _inside_checkout(primary): + return primary + + # Only reroute when argv[0] actually drove the resolution: re-run the + # resolver with argv[0] hidden and compare. If PATH yields nothing, + # keep the resolver's original answer (its fallback chain stays + # authoritative; #90492 semantics preserved). + sys.argv[0] = "" + try: + rerouted = resolve_fn() + finally: + sys.argv[0] = original_argv0 + + if primary and _inside_checkout(primary) and rerouted: + return rerouted + + if rerouted is None and primary: + # argv[0] was checkout-internal AND PATH had no `hermes` — common + # in stripped systemd user sessions and autostart relaunches. + # The installer's wrapper lives at known locations; probe them + # directly before giving up, otherwise we'd silently persist the + # checkout-internal form this fix exists to prevent. The probe + # runs only after the primary was proven non-durable above, and + # each candidate must itself target THIS checkout (a wrapper + # from another install would make the entry stable-but-wrong — + # same failure class the external-primary-first rule avoids). + probe = _known_wrapper_candidates() + for candidate in probe: + if candidate.is_file() and os.access(candidate, os.X_OK): + if not _wrapper_shebang_safe(candidate): + # The wrapper targets this checkout but its own shebang + # would die in the DE context (e.g. `#!/usr/bin/env + # python3` resolving past the venv): skip it the same + # way a foreign-install wrapper is skipped. Idea + # credited to autumn8's #92122 rung-2 safety check; + # implemented on our ownership machinery. + continue + if _wrapper_targets_checkout( + candidate, checkout_root + ) or _wrapper_targets_checkout(candidate, module_lexical_root): + return str(candidate) + # No durable wrapper for THIS checkout exists anywhere (PATH + # miss, known locations miss or belong to another install). + # Persisting the checkout-internal primary would produce an + # entry that regenerates itself or dies on the venv escape; + # dropping to None lets resolve_exec_command emit its runnable + # module fallback. + return None + return primary + + +def _wrapper_shebang_safe(wrapper: Path) -> bool: + """Whether an executable wrapper can actually run in the DE context. + + A wrapper whose own shebang escapes the venv (``#!/usr/bin/env + python3`` or a bare interpreter name) would die exactly like the + broken entry this module exists to fix — the checkout reference in + its body does not save it. Native binaries and shell launchers are + safe by construction (they exec the right interpreter themselves). + A python-shebang wrapper is safe only when its interpreter resolves + to the RUNNING venv's interpreter directory. + """ + try: + with open(wrapper, "rb") as fh: + head = fh.read(4096) + except OSError: + return False + if head[:4] == b"\x7fELF" or head.startswith(b"MZ"): + return True + if not head.startswith(b"#!"): + # No shebang: the kernel cannot exec it directly either — but it + # may be sourced or exec'd via `sh` by DE-specific glue. Fail + # safe toward the module fallback. + return False + shebang = head.decode("utf-8", errors="replace").splitlines()[0] + tokens = shebang[2:].strip().split() + if not tokens: + return False + interp = Path(tokens[0]) + # `#!/usr/bin/env bash` (the installer's own launcher form): `env` + # here is the standard trick to find bash on PATH, and the script + # itself execs the right interpreter. Only python-flavored `env` + # shebangs are the escape hazard. + if interp.name == "env": + # Skip env's own flags (-S, -u VAR, ...) and inspect the first + # real token: `env -S bash` is still a shell launcher. + target = next( + (Path(t) for t in tokens[1:] if not t.startswith("-")), + Path(""), + ) + if target.name in ("bash", "sh", "dash", "zsh", "ksh"): + return True + return not _shebang_escapes_running_env(shebang) + if interp.name in ("bash", "sh", "dash", "zsh", "ksh"): + # A shell launcher execs the right interpreter itself. + return True + if "python" not in interp.name.lower(): + # Not a python interpreter either — fail safe toward the module + # fallback rather than trusting an unknown interpreter. + return False + # Python wrapper: its shebang must stay inside the RUNNING venv. + return not _shebang_escapes_running_env(shebang) + + +def _wrapper_targets_checkout(wrapper: Path, checkout_root: Path) -> bool: + """Whether a candidate launcher script actually launches THIS checkout. + + Expects the LEXICAL checkout root (the caller keeps it un-resolved): + the installer writes ``$INSTALL_DIR`` lexically into the shim, so on a + symlinked home the shim text and the resolved root would never match. + Both lexical and resolved forms of the root are tried regardless, to + tolerate either caller convention. + + The installer's shim is a small bash script that execs + ``/venv/bin/python /hermes``; a venv console + script carries the venv interpreter in its shebang. Either way, a + text launcher belonging to this installation references the + checkout path (or its venv) somewhere in its first few KB. A + binary launcher (PyInstaller & friends) cannot be inspected that + way — accept it, since binary installs are self-contained and the + external-primary-first rule has already had its say. + """ + try: + head = wrapper.read_bytes()[:4096] + except OSError: + return False + if b"\x7fELF" in head[:4] or head.startswith(b"MZ"): + # Native binary: cannot verify, and cannot be another checkout's + # bash shim either — accept. + return True + try: + text = head.decode("utf-8", errors="replace") + except Exception: # noqa: BLE001 - defensive decode + return False + # Boundary-aware matching: a bare substring test would also accept + # sibling paths that EXTEND this checkout's path (an old install + # renamed aside as `-old` or `.bak`), silently + # pointing the entry at that other installation. Require the + # reference to end the path (quote, whitespace, or end-of-line + # right after the root) or continue INTO it. + # Compare both the resolved root and its lexical form: the installer + # writes $INSTALL_DIR lexically, so with a symlinked home + # (/home/user -> /mnt/disk/home/user) the shim's text carries the + # lexical path while checkout_root arrives resolved. + roots = {str(checkout_root)} + lexical_root = os.path.abspath(str(checkout_root)) + roots.add(lexical_root) + try: + resolved_lexical = str(Path(lexical_root).resolve()) + roots.add(resolved_lexical) + except OSError: + pass + for root in roots: + for terminator in ('"', "'", " ", "\n", "\t", "\r", "$", "\x00"): + if root + terminator in text: + return True + if text.rstrip("\r\n").endswith(root): + return True + # The shim's exec line continues INTO the checkout (…/python + # /hermes …): a path-continuation boundary is also a match. + if root + "/" in text: + return True + return False + + +def _known_wrapper_candidates(): + """Durable installed-launcher locations, most likely first. + + Mirrors the installer's ``get_command_link_dir()`` layouts: user + (``~/.local/bin``), root FHS (``/usr/local/bin``), and Termux + (``$PREFIX/bin``). The wrapper is always named ``hermes``. + """ + candidates = [] + home = Path.home() + prefix = os.environ.get("PREFIX") + if prefix: + candidates.append(Path(prefix) / "bin" / "hermes") + if hasattr(os, "geteuid") and os.geteuid() == 0: + candidates.append(Path("/usr/local/bin/hermes")) + candidates.append(home / ".local" / "bin" / "hermes") + return candidates + + +def _project_root() -> Path: + """This file lives at ``/hermes_cli/linux_desktop_entry.py``. + + Lexical (no .resolve()): callers feed this into shim-text matching + where the installer's lexically-written $INSTALL_DIR must be able to + match; symlinked homes would break a resolved comparison. + """ + return Path(os.path.abspath(__file__)).parent.parent + + def _needs_interpreter(bin_path: Path) -> bool: """Whether ``bin_path`` is a Python script that must run under ``sys.executable`` to see Hermes' venv (rather than its own shebang).""" @@ -98,16 +503,59 @@ def _needs_interpreter(bin_path: Path) -> bool: # Native binary (uv tool shim, PyInstaller, distro package) — its own # loader is self-sufficient. return False - shebang = head.decode("utf-8", errors="replace").strip().lower() - if "python" not in shebang: + shebang = head.decode("utf-8", errors="replace").strip() + if "python" not in shebang.lower(): # A shell wrapper (e.g. the installer's bash launcher) execs the venv # python itself — leave it alone. return False - # A python shebang pointing INSIDE the running interpreter's environment - # already resolves correctly; anything else (``/usr/bin/env python3``, - # a system path) would escape the venv when spawned by the DE. - exe_dir = str(Path(sys.executable).resolve().parent) - return exe_dir not in shebang + return _shebang_escapes_running_env(shebang) + + +def _shebang_escapes_running_env(shebang: str) -> bool: + """Whether a python shebang resolves OUTSIDE the running interpreter's env. + + Tokenizes the shebang (interpreter path plus any flags) and compares + PATH COMPONENTS, never substrings: ``/bin-extra/python`` is not + inside ``/bin`` even though it starts with it (sibling-directory + confusion; independently surfaced in nosliwhtes' #92122 hardening + ``b96427d0`` — reimplemented here with two extensions). + + Extensions over the parent-equality form: + + * ``env`` shebangs (``#!/usr/bin/env python3``) ALWAYS escape: ``env`` + resolves through PATH, which in the DE's cold environment is not the + interactive PATH that installed the venv — the parent-equality form + could be fooled when the resolved ``env`` binary happens to sit in + the same directory tree. + * Flags after the interpreter (``-S``, ``-E``...) are stripped before + comparing, so a legitimate ``#!/bin/python -S`` is not + misclassified by comparing against the flag token. + + The comparison uses the LEXICAL interpreter directory (abspath, not + resolve()): on uv venvs the resolved parent is the base interpreter's + dir, which makes a valid ``.venv/bin/python`` shebang look foreign + (#94443 review case 1). Both sides use the SAME case operation + (``.lower()``): interpreter paths legitimately carry uppercase (conda + env names, usernames, uv's ephemeral build dirs) and an asymmetric + compare would flag the venv's own console script as foreign. + """ + tokens = shebang[2:].strip().split() + if not tokens: + # Bare "#!python" with no path: resolves via PATH — escapes. + return True + interp = Path(tokens[0]) + if interp.name in ("env", "env.exe"): + # PATH-resolved interpreter: the DE environment's PATH decides, + # not the installing shell's — treat as escaping. A real + # ``env -S`` venv-absolute form (`env -S `), rare but valid, + # still resolves the actual interpreter from the second token. + rest = [t for t in tokens[1:] if not t.startswith("-")] + if rest and Path(rest[0]).is_absolute(): + interp = Path(rest[0]) + else: + return True + running_dir = os.path.dirname(os.path.abspath(sys.executable)).lower() + return str(interp.parent).lower() != running_dir def _quote_exec_arg(arg: str) -> str: @@ -116,7 +564,7 @@ def _quote_exec_arg(arg: str) -> str: Reserved characters require double quotes. Inside the quotes, escape a backslash and a double quote with a backslash. """ - if not any(c in arg for c in ' \t\n"\'\\><~|&;$*?#()`'): + if not any(c in arg for c in " \t\n\"'\\><~|&;$*?#()`"): return arg escaped = arg.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' @@ -176,6 +624,36 @@ def _run_quiet(cmd: "list[str]") -> bool: return result.returncode == 0 +def _install_icon_to_hicolor(icon: Path) -> bool: + """Copy the app icon into the user's hicolor icon theme tree. + + The freedesktop icon lookup finds an installed ``apps/hermes.png`` + by the unqualified name ``hermes``, so the entry can reference the + icon without an absolute checkout path. The size subdirectory must + be one the theme actually indexes (hicolor's index.theme lists + fixed sizes and ``scalable`` — an unindexed dir like ``1024x1024`` + would never be found), so the icon lands in ``scalable`` unless the + source is exactly 256x256, which goes to the fixed-size dir. + Idempotent via content-compare; OSError caught internally (False) — + the caller then falls back to the absolute path. + """ + try: + raw = icon.read_bytes() + is_256 = False + if len(raw) >= 24 and raw[:8] == b"\x89PNG\r\n\x1a\n" and raw[12:16] == b"IHDR": + width, height = struct.unpack(">II", raw[16:24]) + is_256 = (width, height) == (256, 256) + subdir = "256x256" if is_256 else "scalable" + dest = _xdg_data_home() / "icons" / "hicolor" / subdir / "apps" / "hermes.png" + if dest.is_file() and dest.read_bytes() == raw: + return True + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(icon, dest) + return True + except OSError: + return False + + def install_desktop_entry(project_root: Path) -> Optional[Path]: """Write (or refresh) the Hermes desktop entry. Return its path. @@ -187,10 +665,16 @@ def install_desktop_entry(project_root: Path) -> Optional[Path]: entry_path = desktop_entry_path() icon = icon_path(project_root) - # Use the themed name when the checkout has no icon (a lite or - # packaged install). A broken absolute path renders as no icon. + # Prefer the themed name: the icon is COPIED into the user's hicolor + # tree, so the entry outlives the checkout (moving/archiving the + # checkout would break an absolute Icon= path — the same + # durability class the Exec line was fixed for). Fall back to the + # absolute path only when the copy is impossible (read-only tree), + # and to the themed name when the checkout has no icon at all. icon_value = str(icon) if icon.is_file() else "hermes" - contents = render_desktop_entry(resolve_exec_command(), icon_value) + if icon.is_file() and _install_icon_to_hicolor(icon): + icon_value = "hermes" + contents = render_desktop_entry(resolve_exec_command(project_root), icon_value) try: entry_path.parent.mkdir(parents=True, exist_ok=True) @@ -198,7 +682,15 @@ def install_desktop_entry(project_root: Path) -> Optional[Path]: # churn the menu caches. if entry_path.is_file() and entry_path.read_text(encoding="utf-8") == contents: return entry_path - entry_path.write_text(contents, encoding="utf-8") + # Atomic replace: an interrupted plain write can leave a zero-byte + # entry, which permanently breaks the taskbar pin (nothing later + # rewrites a file that exists at the right path). The temp+rename + # dance in utils.atomic_write_text is the codebase's shared + # implementation — ported from #80547, which closed unmerged with + # this piece unlanded. + from utils import atomic_write_text + + atomic_write_text(entry_path, contents, create_mode=0o755) # Some launchers (and older Plasma) offer the entry only when it # is executable. entry_path.chmod(0o755) diff --git a/tests/hermes_cli/test_linux_desktop_entry.py b/tests/hermes_cli/test_linux_desktop_entry.py index 5bc73fb5b2..8d8c246af5 100644 --- a/tests/hermes_cli/test_linux_desktop_entry.py +++ b/tests/hermes_cli/test_linux_desktop_entry.py @@ -2,7 +2,9 @@ from __future__ import annotations +import os import stat +import sys from pathlib import Path import pytest @@ -14,6 +16,9 @@ from hermes_cli import linux_desktop_entry as lde def xdg_home(tmp_path, monkeypatch) -> Path: data_home = tmp_path / "xdg-data" monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + # Isolate the known-wrapper probe too: tests must never see the real + # ~/.local/bin/hermes on the dev machine. + monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setattr(lde.sys, "platform", "linux") return data_home @@ -35,7 +40,9 @@ def _parse(entry_text: str) -> dict: return values -def test_install_writes_entry_with_absolute_exec_and_icon(tmp_path, xdg_home, monkeypatch): +def test_install_writes_entry_with_absolute_exec_and_icon( + tmp_path, xdg_home, monkeypatch +): root = _make_project(tmp_path) hermes_bin = tmp_path / "bin" / "hermes" hermes_bin.parent.mkdir() @@ -44,6 +51,9 @@ def test_install_writes_entry_with_absolute_exec_and_icon(tmp_path, xdg_home, mo "hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin) ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + # Keep the icon install out of the way: this test pins the + # absolute-path FALLBACK (copy impossible / not attempted here). + monkeypatch.setattr(lde, "_install_icon_to_hicolor", lambda _icon: False) entry = lde.install_desktop_entry(root) @@ -59,7 +69,60 @@ def test_install_writes_entry_with_absolute_exec_and_icon(tmp_path, xdg_home, mo icon_path = Path(values["Icon"]) assert icon_path.is_absolute() assert icon_path == lde.icon_path(root) - assert icon_path.read_bytes() == b"\x89PNG fake" + + +def test_install_prefers_themed_icon_from_hicolor(tmp_path, xdg_home, monkeypatch): + """When the icon installs into hicolor, the entry uses the themed name. + + The themed name survives a moved/archived checkout; an absolute + Icon= path does not (the same durability class the Exec line was + fixed for). + """ + root = _make_project(tmp_path) + hermes_bin = tmp_path / "bin" / "hermes" + hermes_bin.parent.mkdir() + hermes_bin.write_text("", encoding="utf-8") + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin) + ) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + + values = _parse(entry.read_text(encoding="utf-8")) + assert values["Icon"] == "hermes" + + # And the icon really landed in the hicolor tree: the fixture icon is + # a fake PNG (no valid IHDR), so the size is unknown and the icon + # lands under scalable/. + dest = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png" + assert dest.is_file() + assert dest.read_bytes() == lde.icon_path(root).read_bytes() + + +def test_install_icon_copy_failure_falls_back_to_absolute( + tmp_path, xdg_home, monkeypatch +): + """An impossible icon copy keeps the absolute path (never breaks).""" + root = _make_project(tmp_path) + hermes_bin = tmp_path / "bin" / "hermes" + hermes_bin.parent.mkdir() + hermes_bin.write_text("", encoding="utf-8") + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin) + ) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + def _boom(src, dst): + raise OSError("read-only tree") + + monkeypatch.setattr(lde.shutil, "copyfile", _boom) + + entry = lde.install_desktop_entry(root) + values = _parse(entry.read_text(encoding="utf-8")) + # The real helper catches the copy OSError and returns False, so the + # caller falls back to the absolute path without raising. + assert values["Icon"] == str(lde.icon_path(root)) assert values["Type"] == "Application" assert values["Name"] == "Hermes" @@ -68,7 +131,9 @@ def test_install_writes_entry_with_absolute_exec_and_icon(tmp_path, xdg_home, mo def test_installed_entry_is_executable(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) - monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes") + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes" + ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) @@ -93,21 +158,27 @@ def test_exec_falls_back_to_interpreter_module(tmp_path, xdg_home, monkeypatch): # interpreter when the DE spawns the .desktop entry → ModuleNotFoundError, # silent (Terminal=false). The Exec line must prefix sys.executable for any # resolved bin that is a python script escaping the running venv. -def test_exec_prefixes_interpreter_for_env_shebang_python_script(tmp_path, xdg_home, monkeypatch): +def test_exec_prefixes_interpreter_for_env_shebang_python_script( + tmp_path, xdg_home, monkeypatch +): import sys root = _make_project(tmp_path) hermes_bin = tmp_path / "bin" / "hermes" hermes_bin.parent.mkdir() - hermes_bin.write_text("#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8") + hermes_bin.write_text( + "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" + ) hermes_bin.chmod(0o755) - monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin)) + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin) + ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] - interpreter = str(Path(sys.executable).resolve()) + interpreter = os.path.abspath(sys.executable) assert exec_line.split(" ")[0].strip('"') == interpreter assert str(hermes_bin) in exec_line assert exec_line.endswith("desktop") @@ -117,9 +188,13 @@ def test_exec_leaves_shell_wrapper_launchers_alone(tmp_path, xdg_home, monkeypat root = _make_project(tmp_path) hermes_bin = tmp_path / "bin" / "hermes" hermes_bin.parent.mkdir() - hermes_bin.write_text('#!/bin/bash\nexec /opt/hermes/venv/bin/python "$@"\n', encoding="utf-8") + hermes_bin.write_text( + '#!/bin/bash\nexec /opt/hermes/venv/bin/python "$@"\n', encoding="utf-8" + ) hermes_bin.chmod(0o755) - monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin)) + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin) + ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) @@ -135,10 +210,12 @@ def test_exec_leaves_venv_shebang_scripts_alone(tmp_path, xdg_home, monkeypatch) root = _make_project(tmp_path) hermes_bin = tmp_path / "bin" / "hermes" hermes_bin.parent.mkdir() - interpreter = str(Path(sys.executable).resolve()) + interpreter = os.path.abspath(sys.executable) hermes_bin.write_text(f"#!{interpreter}\nimport hermes_cli\n", encoding="utf-8") hermes_bin.chmod(0o755) - monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin)) + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin) + ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) @@ -149,11 +226,304 @@ def test_exec_leaves_venv_shebang_scripts_alone(tmp_path, xdg_home, monkeypatch) assert exec_line == f"{hermes_bin} desktop" +# The persisted entry must be launch-context independent: whatever process +# writes it, the next launch reads and rewrites the same bytes. argv[0] +# differs per launch path (wrapper / repo script / python -m), so a +# checkout-internal argv[0] must not be persisted — the resolver falls +# through to PATH, where the installer's durable wrapper lives. +def _argv0_context(monkeypatch, argv0: str) -> None: + import sys + + monkeypatch.setattr(sys, "argv", [argv0, "desktop"]) + + +def test_exec_converges_from_repo_script_argv0_to_installed_wrapper( + tmp_path, xdg_home, monkeypatch +): + """A broken interpreter-form entry must self-heal to the wrapper form. + + Launching with argv[0] = /hermes (what the broken entry + itself spawns) previously re-persisted the same broken form forever — + the bootstrap loop that kept #90492 from repairing existing installs. + """ + import sys + + root = _make_project(tmp_path) + repo_script = root / "hermes" # checkout-internal launcher candidate + repo_script.write_text( + "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" + ) + repo_script.chmod(0o755) + wrapper = tmp_path / "installed" / "bin" / "hermes" + wrapper.parent.mkdir(parents=True) + wrapper.write_text(f'#!/bin/bash\nexec {sys.executable} "$@"\n', encoding="utf-8") + wrapper.chmod(0o755) + + # argv[0] = repo script; PATH lookup finds the installed wrapper. + _argv0_context(monkeypatch, str(repo_script)) + monkeypatch.setattr( + "shutil.which", lambda name: str(wrapper) if name == "hermes" else None + ) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + + # Converged on the durable wrapper — NOT the repo script, and NOT an + # interpreter-prefixed form pinning sys.executable. + assert exec_line == f"{wrapper} desktop" + + +def test_exec_never_persists_a_bare_interpreter_command( + tmp_path, xdg_home, monkeypatch +): + """The `python -m hermes_cli.main` relaunch context must not write + `Exec= desktop` — a command line no DE can run.""" + import sys + + root = _make_project(tmp_path) + wrapper = tmp_path / "installed" / "bin" / "hermes" + wrapper.parent.mkdir(parents=True) + wrapper.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") + wrapper.chmod(0o755) + + interpreter = tmp_path / "uv" / "cpython-3.11.15" / "bin" / "python3.11" + interpreter.parent.mkdir(parents=True) + interpreter.write_bytes(b"\x7fELF fake") + interpreter.chmod(0o755) + + # argv[0] IS the interpreter (python -m context); PATH has the wrapper. + _argv0_context(monkeypatch, str(interpreter)) + monkeypatch.setattr( + "shutil.which", lambda name: str(wrapper) if name == "hermes" else None + ) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + + first_token = exec_line.split(" ")[0].strip('"') + assert Path(first_token) != interpreter + assert not ( + Path(first_token).name.startswith("python") + and "desktop" in exec_line.split(" ", 1)[1] + ), f"persisted an unrunnable bare-interpreter Exec: {exec_line}" + assert exec_line == f"{wrapper} desktop" + + +def test_exec_keeps_resolver_fallback_when_no_wrapper_on_path( + tmp_path, xdg_home, monkeypatch +): + """No wrapper anywhere → #90492's runnable fallback, never a dead Exec. + + With argv[0] checkout-internal and PATH + known locations both empty, + the resolver returns None and resolve_exec_command emits the runnable + `sys.executable -m hermes_cli.main desktop` fallback. Persisting the + interpreter itself (` desktop`) would be unrunnable by any DE; + persisting the repo script alone dies on its env shebang. + """ + import sys + + root = _make_project(tmp_path) + repo_script = root / "hermes" + repo_script.write_text( + "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" + ) + repo_script.chmod(0o755) + + _argv0_context(monkeypatch, str(repo_script)) + monkeypatch.setattr("shutil.which", lambda name: None) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + def fake_resolve(): + # Mirror resolve_hermes_bin's chain: argv[0] → relative → PATH → None. + return sys.argv[0] if sys.argv[0] else None + + monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + + # The runnable module fallback — NOT the bare repo script (its env + # shebang would escape the venv under a DE) and NOT ` desktop`. + assert exec_line.endswith("-m hermes_cli.main desktop") + assert Path(exec_line.split(" ")[0].strip('"')).is_absolute() + assert str(repo_script) not in exec_line + + +def test_exec_uses_known_wrapper_when_path_lookup_misses( + tmp_path, xdg_home, monkeypatch +): + """Stripped-PATH session + wrapper at the known installer location. + + systemd user sessions and autostart relaunches often run without + ~/.local/bin on PATH. When shutil.which finds nothing, the resolver + must probe the known durable locations directly instead of silently + persisting a checkout-internal Exec line. + """ + import sys + + root = _make_project(tmp_path) + repo_script = root / "hermes" + repo_script.write_text( + "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" + ) + repo_script.chmod(0o755) + + # The wrapper exists at the known location but is NOT on PATH. + # Realistic installer shim: execs this checkout's venv python on the + # checkout's hermes script (the aidiyet check requires it to target + # the writing checkout). + known_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes" + known_wrapper.parent.mkdir(parents=True) + known_wrapper.write_text( + f'#!/bin/bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n', + encoding="utf-8", + ) + known_wrapper.chmod(0o755) + monkeypatch.setenv("HOME", str(tmp_path / "known-home")) + + _argv0_context(monkeypatch, str(repo_script)) + monkeypatch.setattr("shutil.which", lambda name: None) + + def fake_resolve(): + return sys.argv[0] if sys.argv[0] else None + + monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + + # The probe found the wrapper despite the PATH miss. + assert exec_line == f"{known_wrapper} desktop" + + +def test_exec_rejects_known_wrapper_from_another_checkout( + tmp_path, xdg_home, monkeypatch +): + """A known-location wrapper that targets a DIFFERENT checkout is skipped. + + On machines with multiple installs over time, ~/.local/bin/hermes may + belong to another checkout. Persisting it would make the entry stable + but silently point at that other installation — the failure class the + aidiyet check exists to prevent. The runnable module fallback must win + instead. + """ + import sys + + root = _make_project(tmp_path) + repo_script = root / "hermes" + repo_script.write_text( + "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" + ) + repo_script.chmod(0o755) + + # A shim belonging to a DIFFERENT checkout. + other_root = tmp_path / "other-install" + other_root.mkdir() + foreign_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes" + foreign_wrapper.parent.mkdir(parents=True) + foreign_wrapper.write_text( + f"#!/bin/bash\nexec {other_root / 'venv' / 'bin' / 'python'} " + f'{other_root / "hermes"} "$@"\n', + encoding="utf-8", + ) + foreign_wrapper.chmod(0o755) + monkeypatch.setenv("HOME", str(tmp_path / "known-home")) + + _argv0_context(monkeypatch, str(repo_script)) + monkeypatch.setattr("shutil.which", lambda name: None) + + def fake_resolve(): + return sys.argv[0] if sys.argv[0] else None + + monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + + # The foreign wrapper was rejected; the runnable module fallback won. + assert str(foreign_wrapper) not in exec_line + assert exec_line.endswith("-m hermes_cli.main desktop") + + +@pytest.mark.parametrize( + ("layout", "env_overrides", "expected"), + [ + pytest.param( + "user", + {}, + "HOME-SET-BY-TEST/.local/bin/hermes", + id="user-layout", + ), + pytest.param( + "termux", + {"PREFIX": "PREFIX-SET-BY-TEST"}, + "PREFIX-SET-BY-TEST/bin/hermes", + id="termux-prefix-first", + ), + pytest.param( + "root-fhs", + {"__EUID0__": "1"}, + "/usr/local/bin/hermes", + id="root-fhs", + ), + pytest.param( + "non-root-no-fhs", + {"__EUID0__": "0"}, + "HOME-SET-BY-TEST/.local/bin/hermes", + id="non-root-excludes-fhs", + ), + ], +) +def test_known_wrapper_candidates_cover_installer_layouts( + layout, env_overrides, expected, monkeypatch +): + """_known_wrapper_candidates mirrors get_command_link_dir() layouts. + + Termux ($PREFIX/bin) outranks everything; root FHS (/usr/local/bin) + applies only to euid 0; the user layout (~/.local/bin) is always a + candidate. Locking these in protects against silent regressions in + the stripped-PATH probe path. + """ + import os + + sentinel_home = "/home/__sentinel_home__" + monkeypatch.setenv("HOME", sentinel_home) + for key, value in env_overrides.items(): + if key == "__EUID0__": + monkeypatch.setattr(lde.os, "geteuid", lambda: 0 if value == "1" else 1000) + else: + monkeypatch.setenv(key, value) + + candidates = [str(c) for c in lde._known_wrapper_candidates()] + + expected_resolved = expected.replace("HOME-SET-BY-TEST", sentinel_home) + assert expected_resolved in candidates + if layout == "termux": + # PREFIX outranks the user layout. + assert candidates[0] == expected_resolved + if layout == "root-fhs": + # Root FHS outranks the user layout. + assert candidates.index("/usr/local/bin/hermes") < candidates.index( + f"{sentinel_home}/.local/bin/hermes" + ) + if layout == "non-root-no-fhs": + # Non-root euid: /usr/local/bin must be excluded outright. + assert "/usr/local/bin/hermes" not in candidates + + def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) - monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes") + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes" + ) calls: list[Path] = [] - monkeypatch.setattr(lde, "refresh_desktop_databases", lambda d: calls.append(d) or []) + monkeypatch.setattr( + lde, "refresh_desktop_databases", lambda d: calls.append(d) or [] + ) lde.install_desktop_entry(root) assert len(calls) == 1 @@ -166,7 +536,9 @@ def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monke def test_install_without_source_icon_uses_themed_name(tmp_path, xdg_home, monkeypatch): root = tmp_path / "hermes-agent" root.mkdir() - monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes") + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes" + ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) @@ -198,7 +570,9 @@ def test_install_is_a_noop_on_windows(tmp_path): def _stub_tools(monkeypatch, available: "set[str]") -> "list[list[str]]": ran: list[list[str]] = [] monkeypatch.setattr( - lde.shutil, "which", lambda name: f"/usr/bin/{name}" if name in available else None + lde.shutil, + "which", + lambda name: f"/usr/bin/{name}" if name in available else None, ) monkeypatch.setattr(lde, "_run_quiet", lambda cmd: ran.append(cmd) or True) return ran @@ -264,3 +638,351 @@ def test_exec_arg_quoting_handles_spaces(tmp_path, xdg_home, monkeypatch): exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] assert exec_line == f'"{spaced}" desktop' + + +@pytest.mark.skipif( + sys.platform == "win32", reason="Symlinks require elevated privileges on Windows" +) +def test_running_interpreter_keeps_venv_semantic_path(tmp_path, monkeypatch): + """Lexical preserved only when pyvenv.cfg marks the path as a venv.""" + # venv layout: bin/python symlink -> base, pyvenv.cfg at venv root + base = tmp_path / "base" / "python3.11" + base.parent.mkdir(parents=True) + base.write_text("", encoding="utf-8") + venv_root = tmp_path / "venv" + venv_bin = venv_root / "bin" + venv_bin.mkdir(parents=True) + (venv_root / "pyvenv.cfg").write_text("home = /base\n", encoding="utf-8") + venv_python = venv_bin / "python" + venv_python.symlink_to(base) + + monkeypatch.setattr(lde.sys, "executable", str(venv_python)) + assert lde._running_interpreter() == str(venv_python) + + # non-venv symlink: resolve instead (durability over lexical) + plain_root = tmp_path / "plain" / "bin" + plain_root.mkdir(parents=True) + plain_link = plain_root / "python3" + plain_link.symlink_to(base) + monkeypatch.setattr(lde.sys, "executable", str(plain_link)) + assert lde._running_interpreter() == str(base) + + +def test_running_interpreter_resolves_plain_interpreter(monkeypatch): + """A non-symlinked, non-venv executable resolves to itself.""" + monkeypatch.setattr(lde.sys, "executable", "/usr/bin/python3") + out = lde._running_interpreter() + assert Path(out).is_absolute() + + +def test_can_import_probe_runs_and_caches(tmp_path): + """The probe executes the real interpreter and memoizes the answer. + + Asserts the two things that hold on ANY host: the probe returns a + definite boolean for a real interpreter (not None, not an exception + path), and the per-path cache is populated so the second call pays + no subprocess. Host-dependent capability itself (True vs False) is + deliberately NOT asserted - a CI host with hermes pip-installed + system-wide would legitimately answer True. + """ + import time + + real = Path("/usr/bin/python3") + if not real.exists(): + pytest.skip("no system python to probe") + lde._probe_cache.pop(str(real), None) + try: + first = lde._can_import_hermes_cli(real) + assert isinstance(first, bool) + assert str(real) in lde._probe_cache + t0 = time.monotonic() + second = lde._can_import_hermes_cli(real) + assert second is first + assert time.monotonic() - t0 < 0.05 # cache hit: no subprocess + finally: + lde._probe_cache.pop(str(real), None) + + +def test_exec_falls_back_to_running_interpreter_when_probe_fails( + tmp_path, xdg_home, monkeypatch +): + """A candidate interpreter that fails the import probe is not persisted.""" + import sys as _s + + root = _make_project(tmp_path) + interpreter = tmp_path / "uv" / "bin" / "python3.11" + interpreter.parent.mkdir(parents=True) + interpreter.write_bytes(b"\x7fELF fake") + interpreter.chmod(0o755) + + _argv0_context(monkeypatch, str(interpreter)) + monkeypatch.setattr("shutil.which", lambda name: None) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + def fake_resolve(): + return _s.argv[0] if _s.argv[0] else None + + monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) + # Force the probe to fail for whatever interpreter gets chosen first. + monkeypatch.setattr(lde, "_can_import_hermes_cli", lambda p: False) + # And the fallback interpreter must itself pass (it always should). + monkeypatch.setattr( + lde, + "_running_interpreter_fallback", + lambda: os.path.abspath(sys.executable), + ) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + + # Runnable module form under the RUNNING interpreter - never the + # unprobeable ELF fake, never a bare " desktop". + assert exec_line.endswith("-m hermes_cli.main desktop") + first = exec_line.split(" ")[0].strip('"') + assert first == os.path.abspath(sys.executable) + assert str(interpreter) not in exec_line + + +@pytest.mark.parametrize( + "suffix", + ["-old", ".bak", "-copy"], +) +def test_wrapper_ownership_rejects_sibling_extensions(suffix, tmp_path): + """A shim execing `/...` must NOT pass ownership. + + Bare substring matching accepted these; the boundary-aware matcher + must reject them (stable-but-wrong entry pointing at the renamed + old install). + """ + checkout = tmp_path / "hermes-agent" + checkout.mkdir() + evil = tmp_path / "evil-shim" + evil.write_text( + f"#!/bin/bash\n" + f"exec {checkout}{suffix}/venv/bin/python " + f'{checkout}{suffix}/hermes "$@"\n', + encoding="utf-8", + ) + assert lde._wrapper_targets_checkout(evil, checkout) is False + + +@pytest.mark.skipif( + sys.platform == "win32", reason="Symlinks require elevated privileges on Windows" +) +def test_wrapper_ownership_accepts_shim_via_symlinked_home(tmp_path, monkeypatch): + """Installer writes $INSTALL_DIR lexically; the root stays lexical too. + + With /home -> /real-home, a shim that references the lexical + checkout path must match the lexical checkout root (the resolved + root alone would never match the shim's text). + """ + home_link = tmp_path / "home-link" + home_real = tmp_path / "home-real" + home_real.mkdir() + home_link.symlink_to(home_real) + lexical_checkout = home_link / "hermes-agent" + (home_real / "hermes-agent").mkdir() + + shim = home_link / ".local" / "bin" / "hermes" + shim.parent.mkdir(parents=True) + shim.write_text( + f"#!/bin/bash\n" + f"exec {lexical_checkout}/venv/bin/python " + f'{lexical_checkout}/hermes "$@"\n', + encoding="utf-8", + ) + assert lde._wrapper_targets_checkout(shim, lexical_checkout) is True + # And the end-to-end probe finds it via the lexical root: make the + # shim executable, point HOME at the symlinked home, and give the + # resolver a checkout-internal primary (the repo script) so the + # probe leg actually engages. + shim.chmod(0o755) + monkeypatch.setenv("HOME", str(home_link)) + monkeypatch.setattr("shutil.which", lambda name: None) + repo_script = lexical_checkout / "hermes" + repo_script.write_text("#!/usr/bin/env python3\n", encoding="utf-8") + monkeypatch.setattr(sys, "argv", [str(repo_script), "desktop"]) + assert lde._resolve_hermes_bin_for_desktop_entry( + resolve_fn=lambda: sys.argv[0] if sys.argv[0] else None, + checkout_root=lexical_checkout, + ) == str(shim) + + +def test_needs_interpreter_case_insensitive_match(tmp_path, monkeypatch): + """Interpreter paths with uppercase must not flag own venv scripts. + + The shebang is lowercased for comparison; the interpreter dir must + be too (conda env names, usernames, uv's ephemeral .tmpXXX dirs all + carry uppercase - an asymmetric compare would prefix the venv's own + console script spuriously). + """ + venv_bin = tmp_path / "MyEnv" / "bin" + venv_bin.mkdir(parents=True) + interpreter = venv_bin / "python" + interpreter.write_text("", encoding="utf-8") + + console_script = venv_bin / "hermes" + console_script.write_text(f"#!{interpreter}\nimport hermes_cli\n", encoding="utf-8") + monkeypatch.setattr(lde.sys, "executable", str(interpreter)) + + assert lde._needs_interpreter(console_script) is False + + +def test_needs_interpreter_rejects_sibling_directory(tmp_path, monkeypatch): + """``/bin-extra/python`` is NOT inside ``/bin``. + + Substring matching accepted it (the parent dir appears verbatim inside + the sibling path), skipping the interpreter prefix for a script whose + shebang actually points OUTSIDE the venv. Path-component comparison + rejects it. + """ + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + interp = venv_bin / "python" + interp.write_text("", encoding="utf-8") + monkeypatch.setattr(lde.sys, "executable", str(interp)) + + sibling_script = tmp_path / "sibling" + sibling_script.write_text( + f"#!{tmp_path}/venv/bin-extra/python\nimport hermes_cli\n", + encoding="utf-8", + ) + assert lde._needs_interpreter(sibling_script) is True + + +def test_needs_interpreter_strips_flags_before_comparing(tmp_path, monkeypatch): + """A flagged own-venv shebang is not misclassified by the flag token.""" + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + interp = venv_bin / "python" + interp.write_text("", encoding="utf-8") + monkeypatch.setattr(lde.sys, "executable", str(interp)) + + flagged = tmp_path / "flagged" + flagged.write_text(f"#!{interp} -S\nimport hermes_cli\n", encoding="utf-8") + assert lde._needs_interpreter(flagged) is False + + +def test_needs_interpreter_env_shebang_always_escapes(tmp_path, monkeypatch): + """``env`` resolves through the DE's PATH - not the installer's.""" + venv_bin = tmp_path / "venv" / "bin" + venv_bin.mkdir(parents=True) + interp = venv_bin / "python" + interp.write_text("", encoding="utf-8") + monkeypatch.setattr(lde.sys, "executable", str(interp)) + + # Even when env itself sits in the venv bin (parent equality would + # pass), the PATH resolution semantics mean the shebang escapes. + env_script = tmp_path / "envscript" + env_script.write_text( + f"#!{venv_bin}/env python3\nimport hermes_cli\n", encoding="utf-8" + ) + assert lde._needs_interpreter(env_script) is True + + # ...unless env carries an absolute venv interpreter after -S. + env_abs = tmp_path / "envabs" + env_abs.write_text( + f"#!/usr/bin/env -S {interp}\nimport hermes_cli\n", encoding="utf-8" + ) + assert lde._needs_interpreter(env_abs) is False + + +def test_probe_skips_wrapper_with_escaping_python_shebang( + tmp_path, xdg_home, monkeypatch +): + """A checkout-referencing wrapper with an env shebang is skipped. + + Ownership alone would accept it (the body references this checkout), + but its `#!/usr/bin/env python3` shebang dies in the DE context. + The shebang-safety gate skips it; the module fallback wins. Idea + credited to autumn8's #92122 rung-2 check. + """ + import sys as _s + + root = _make_project(tmp_path) + repo_script = root / "hermes" + repo_script.write_text( + "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" + ) + repo_script.chmod(0o755) + + # A wrapper that targets this checkout but cannot run itself. + broken_wrapper = xdg_home / ".local" / "bin" / "hermes" + broken_wrapper.parent.mkdir(parents=True) + broken_wrapper.write_text( + f"#!/usr/bin/env python3\n# launcher for {root}\nimport hermes_cli\n", + encoding="utf-8", + ) + broken_wrapper.chmod(0o755) + monkeypatch.setenv("HOME", str(xdg_home)) + _argv0_context(monkeypatch, str(repo_script)) + monkeypatch.setattr("shutil.which", lambda name: None) + + def fake_resolve(): + return sys.argv[0] if sys.argv[0] else None + + monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + + assert str(broken_wrapper) not in exec_line + assert exec_line.endswith("-m hermes_cli.main desktop") + + +def test_probe_accepts_shell_launcher_wrapper(tmp_path, xdg_home, monkeypatch): + """A bash launcher is safe by construction and still wins the probe.""" + root = _make_project(tmp_path) + repo_script = root / "hermes" + repo_script.write_text( + "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" + ) + repo_script.chmod(0o755) + + good_wrapper = xdg_home / ".local" / "bin" / "hermes" + good_wrapper.parent.mkdir(parents=True) + good_wrapper.write_text( + f"#!/bin/bash\nexec {root / 'venv' / 'bin' / 'python'} " + f'{root / "hermes"} "$@"\n', + encoding="utf-8", + ) + good_wrapper.chmod(0o755) + monkeypatch.setenv("HOME", str(xdg_home)) + _argv0_context(monkeypatch, str(repo_script)) + monkeypatch.setattr("shutil.which", lambda name: None) + + def fake_resolve(): + return sys.argv[0] if sys.argv[0] else None + + monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] + assert exec_line == f"{good_wrapper} desktop" + + +def test_install_icon_handles_truncated_png_header(tmp_path, xdg_home, monkeypatch): + """A truncated PNG (valid signature + IHDR tag, <24 bytes) must not + raise struct.error out of the fail-safe: it lands in scalable/ like + any other unknown-size image.""" + root = _make_project(tmp_path) + icon = lde.icon_path(root) + icon.write_bytes( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR\x00\x00" # 22 bytes + ) + hermes_bin = tmp_path / "bin" / "hermes" + hermes_bin.parent.mkdir() + hermes_bin.write_text("", encoding="utf-8") + monkeypatch.setattr( + "hermes_cli.relaunch.resolve_hermes_bin", lambda: str(hermes_bin) + ) + monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) + + entry = lde.install_desktop_entry(root) + + values = _parse(entry.read_text(encoding="utf-8")) + assert values["Icon"] == "hermes" + dest = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png" + assert dest.is_file()