fix(cli): launch-context-independent Linux desktop-entry Exec (salvaged from #94874)

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 <gkhn.yldrmlr@gmail.com>
This commit is contained in:
Teknium
2026-08-31 08:24:10 -07:00
parent 54ee290bcb
commit 6fe933e709
3 changed files with 1260 additions and 36 deletions

View File

@@ -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:

View File

@@ -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 ``<python> 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
# `<python> 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
``<checkout>/venv/bin/python <checkout>/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 `<checkout>-old` or `<checkout>.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
# <root>/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 ``<checkout>/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: ``<venv>/bin-extra/python`` is not
inside ``<venv>/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 ``#!<venv>/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 <abs>`), 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)

View File

@@ -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] = <checkout>/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=<python> 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 (`<python> 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 `<python> 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 "<python> 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 `<checkout><suffix>/...` 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):
"""``<venv>/bin-extra/python`` is NOT inside ``<venv>/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()