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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user