"""Tests for the Linux XDG desktop entry installed by ``hermes desktop``.""" from __future__ import annotations import io import os import stat import struct import sys from pathlib import Path import pytest from hermes_cli import linux_desktop_entry as lde @pytest.fixture 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 def _make_project(tmp_path: Path) -> Path: root = tmp_path / "hermes-agent" icon = root / "apps" / "desktop" / "assets" / "icon.png" icon.parent.mkdir(parents=True) icon.write_bytes(b"\x89PNG fake") return root def _png_ihdr(width: int, height: int) -> bytes: """Minimal PNG prefix whose IHDR the installer can parse (no pixels).""" return ( b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\r" + b"IHDR" + struct.pack(">II", width, height) ) def _stub_install(tmp_path, monkeypatch) -> None: hermes_bin = tmp_path / "bin" / "hermes" hermes_bin.parent.mkdir(exist_ok=True) 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 _parse(entry_text: str) -> dict: values = {} for line in entry_text.splitlines(): if "=" in line and not line.startswith("["): key, val = line.split("=", 1) values[key] = val return values 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() 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: []) # 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) assert entry == xdg_home / "applications" / "hermes.desktop" values = _parse(entry.read_text(encoding="utf-8")) # Exec must be the absolute path of the resolved binary. The launcher # runs with a minimal PATH, so a bare `hermes` would not resolve. assert values["Exec"] == f"{hermes_bin} desktop" assert Path(values["Exec"].split(" ")[0]).is_absolute() # Icon must be an absolute path to the real icon in the checkout. icon_path = Path(values["Icon"]) assert icon_path.is_absolute() assert icon_path == lde.icon_path(root) 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 256x256/ (indexed; never scalable, which is SVG-only). dest = xdg_home / "icons" / "hicolor" / "256x256" / "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" assert values["Terminal"] == "false" 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(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) assert entry.stat().st_mode & stat.S_IXUSR def test_exec_falls_back_to_interpreter_module(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: 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"] assert exec_line.endswith("-m hermes_cli.main desktop") assert Path(exec_line.split(" ")[0]).is_absolute() # #90292: the shell installer's bash wrapper makes argv[0] the repo `hermes` # python script whose `#!/usr/bin/env python3` shebang resolves to the SYSTEM # 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 ): 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.chmod(0o755) 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 = os.path.abspath(sys.executable) assert exec_line.split(" ")[0].strip('"') == interpreter assert str(hermes_bin) in exec_line assert exec_line.endswith("desktop") def test_exec_leaves_shell_wrapper_launchers_alone(tmp_path, xdg_home, monkeypatch): 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.chmod(0o755) 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"] # A bash wrapper execs the venv python itself — no interpreter prefix. assert exec_line == f"{hermes_bin} desktop" def test_exec_leaves_venv_shebang_scripts_alone(tmp_path, xdg_home, monkeypatch): import sys root = _make_project(tmp_path) hermes_bin = tmp_path / "bin" / "hermes" hermes_bin.parent.mkdir() 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(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] # Console-script with the venv's own interpreter in the shebang: correct # as-is, prefixing would only add noise. assert exec_line == f"{hermes_bin} desktop" # The persisted entry must be launch-context independent: whatever process # writes it, the next launch reads and rewrites the same bytes. argv[0] # differs per launch path (wrapper / repo script / python -m), so a # checkout-internal argv[0] must not be persisted — the resolver falls # through to PATH, where the installer's durable wrapper lives. def _argv0_context(monkeypatch, argv0: str) -> None: import sys monkeypatch.setattr(sys, "argv", [argv0, "desktop"]) def test_exec_converges_from_repo_script_argv0_to_installed_wrapper( tmp_path, xdg_home, monkeypatch ): """A broken interpreter-form entry must self-heal to the wrapper form. Launching with argv[0] = /hermes (what the broken entry itself spawns) previously re-persisted the same broken form forever — the bootstrap loop that kept #90492 from repairing existing installs. """ import sys root = _make_project(tmp_path) repo_script = root / "hermes" # checkout-internal launcher candidate repo_script.write_text( "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" ) repo_script.chmod(0o755) wrapper = tmp_path / "installed" / "bin" / "hermes" wrapper.parent.mkdir(parents=True) wrapper.write_text(f'#!/bin/bash\nexec {sys.executable} "$@"\n', encoding="utf-8") wrapper.chmod(0o755) # argv[0] = repo script; PATH lookup finds the installed wrapper. _argv0_context(monkeypatch, str(repo_script)) monkeypatch.setattr( "shutil.which", lambda name: str(wrapper) if name == "hermes" else None ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] # Converged on the durable wrapper — NOT the repo script, and NOT an # interpreter-prefixed form pinning sys.executable. assert exec_line == f"{wrapper} desktop" def test_exec_never_persists_a_bare_interpreter_command( tmp_path, xdg_home, monkeypatch ): """The `python -m hermes_cli.main` relaunch context must not write `Exec= desktop` — a command line no DE can run.""" import sys root = _make_project(tmp_path) wrapper = tmp_path / "installed" / "bin" / "hermes" wrapper.parent.mkdir(parents=True) wrapper.write_text("#!/bin/bash\nexit 0\n", encoding="utf-8") wrapper.chmod(0o755) interpreter = tmp_path / "uv" / "cpython-3.11.15" / "bin" / "python3.11" interpreter.parent.mkdir(parents=True) interpreter.write_bytes(b"\x7fELF fake") interpreter.chmod(0o755) # argv[0] IS the interpreter (python -m context); PATH has the wrapper. _argv0_context(monkeypatch, str(interpreter)) monkeypatch.setattr( "shutil.which", lambda name: str(wrapper) if name == "hermes" else None ) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] first_token = exec_line.split(" ")[0].strip('"') assert Path(first_token) != interpreter assert not ( Path(first_token).name.startswith("python") and "desktop" in exec_line.split(" ", 1)[1] ), f"persisted an unrunnable bare-interpreter Exec: {exec_line}" assert exec_line == f"{wrapper} desktop" def test_exec_keeps_resolver_fallback_when_no_wrapper_on_path( tmp_path, xdg_home, monkeypatch ): """No wrapper anywhere → #90492's runnable fallback, never a dead Exec. With argv[0] checkout-internal and PATH + known locations both empty, the resolver returns None and resolve_exec_command emits the runnable `sys.executable -m hermes_cli.main desktop` fallback. Persisting the interpreter itself (` desktop`) would be unrunnable by any DE; persisting the repo script alone dies on its env shebang. """ import sys root = _make_project(tmp_path) repo_script = root / "hermes" repo_script.write_text( "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" ) repo_script.chmod(0o755) _argv0_context(monkeypatch, str(repo_script)) monkeypatch.setattr("shutil.which", lambda name: None) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) def fake_resolve(): # Mirror resolve_hermes_bin's chain: argv[0] → relative → PATH → None. return sys.argv[0] if sys.argv[0] else None monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] # The runnable module fallback — NOT the bare repo script (its env # shebang would escape the venv under a DE) and NOT ` desktop`. assert exec_line.endswith("-m hermes_cli.main desktop") assert Path(exec_line.split(" ")[0].strip('"')).is_absolute() assert str(repo_script) not in exec_line def test_exec_uses_known_wrapper_when_path_lookup_misses( tmp_path, xdg_home, monkeypatch ): """Stripped-PATH session + wrapper at the known installer location. systemd user sessions and autostart relaunches often run without ~/.local/bin on PATH. When shutil.which finds nothing, the resolver must probe the known durable locations directly instead of silently persisting a checkout-internal Exec line. """ import sys root = _make_project(tmp_path) repo_script = root / "hermes" repo_script.write_text( "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" ) repo_script.chmod(0o755) # The wrapper exists at the known location but is NOT on PATH. # Realistic installer shim: execs this checkout's venv python on the # checkout's hermes script (the aidiyet check requires it to target # the writing checkout). known_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes" known_wrapper.parent.mkdir(parents=True) known_wrapper.write_text( f'#!/bin/bash\nexec {root / "venv" / "bin" / "python"} {root / "hermes"} "$@"\n', encoding="utf-8", ) known_wrapper.chmod(0o755) monkeypatch.setenv("HOME", str(tmp_path / "known-home")) _argv0_context(monkeypatch, str(repo_script)) monkeypatch.setattr("shutil.which", lambda name: None) def fake_resolve(): return sys.argv[0] if sys.argv[0] else None monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] # The probe found the wrapper despite the PATH miss. assert exec_line == f"{known_wrapper} desktop" def test_exec_rejects_known_wrapper_from_another_checkout( tmp_path, xdg_home, monkeypatch ): """A known-location wrapper that targets a DIFFERENT checkout is skipped. On machines with multiple installs over time, ~/.local/bin/hermes may belong to another checkout. Persisting it would make the entry stable but silently point at that other installation — the failure class the aidiyet check exists to prevent. The runnable module fallback must win instead. """ import sys root = _make_project(tmp_path) repo_script = root / "hermes" repo_script.write_text( "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" ) repo_script.chmod(0o755) # A shim belonging to a DIFFERENT checkout. other_root = tmp_path / "other-install" other_root.mkdir() foreign_wrapper = tmp_path / "known-home" / ".local" / "bin" / "hermes" foreign_wrapper.parent.mkdir(parents=True) foreign_wrapper.write_text( f"#!/bin/bash\nexec {other_root / 'venv' / 'bin' / 'python'} " f'{other_root / "hermes"} "$@"\n', encoding="utf-8", ) foreign_wrapper.chmod(0o755) monkeypatch.setenv("HOME", str(tmp_path / "known-home")) _argv0_context(monkeypatch, str(repo_script)) monkeypatch.setattr("shutil.which", lambda name: None) def fake_resolve(): return sys.argv[0] if sys.argv[0] else None monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] # The foreign wrapper was rejected; the runnable module fallback won. assert str(foreign_wrapper) not in exec_line assert exec_line.endswith("-m hermes_cli.main desktop") @pytest.mark.parametrize( ("layout", "env_overrides", "expected"), [ pytest.param( "user", {}, "HOME-SET-BY-TEST/.local/bin/hermes", id="user-layout", ), pytest.param( "termux", {"PREFIX": "PREFIX-SET-BY-TEST"}, "PREFIX-SET-BY-TEST/bin/hermes", id="termux-prefix-first", ), pytest.param( "root-fhs", {"__EUID0__": "1"}, "/usr/local/bin/hermes", id="root-fhs", ), pytest.param( "non-root-no-fhs", {"__EUID0__": "0"}, "HOME-SET-BY-TEST/.local/bin/hermes", id="non-root-excludes-fhs", ), ], ) def test_known_wrapper_candidates_cover_installer_layouts( layout, env_overrides, expected, monkeypatch ): """_known_wrapper_candidates mirrors get_command_link_dir() layouts. Termux ($PREFIX/bin) outranks everything; root FHS (/usr/local/bin) applies only to euid 0; the user layout (~/.local/bin) is always a candidate. Locking these in protects against silent regressions in the stripped-PATH probe path. """ import os sentinel_home = "/home/__sentinel_home__" monkeypatch.setenv("HOME", sentinel_home) for key, value in env_overrides.items(): if key == "__EUID0__": monkeypatch.setattr(lde.os, "geteuid", lambda: 0 if value == "1" else 1000) else: monkeypatch.setenv(key, value) candidates = [str(c) for c in lde._known_wrapper_candidates()] expected_resolved = expected.replace("HOME-SET-BY-TEST", sentinel_home) assert expected_resolved in candidates if layout == "termux": # PREFIX outranks the user layout. assert candidates[0] == expected_resolved if layout == "root-fhs": # Root FHS outranks the user layout. assert candidates.index("/usr/local/bin/hermes") < candidates.index( f"{sentinel_home}/.local/bin/hermes" ) if layout == "non-root-no-fhs": # Non-root euid: /usr/local/bin must be excluded outright. assert "/usr/local/bin/hermes" not in candidates def test_install_is_idempotent_and_skips_cache_refresh(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) monkeypatch.setattr( "hermes_cli.relaunch.resolve_hermes_bin", lambda: "/usr/bin/hermes" ) calls: list[Path] = [] monkeypatch.setattr( lde, "refresh_desktop_databases", lambda d: calls.append(d) or [] ) lde.install_desktop_entry(root) assert len(calls) == 1 # Unchanged content → no rewrite, no menu-cache churn on every launch. lde.install_desktop_entry(root) assert len(calls) == 1 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(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) # A broken absolute path renders as no icon. The themed name resolves # when Hermes is installed some other way. assert _parse(entry.read_text(encoding="utf-8"))["Icon"] == "hermes" @pytest.mark.macos_only def test_install_is_a_noop_on_macos(tmp_path): """Faking darwin only renamed the host — the real macOS runner is the only place the `sys.platform` guard is exercised against a real host.""" assert lde.install_desktop_entry(_make_project(tmp_path)) is None @pytest.mark.windows_only def test_install_is_a_noop_on_windows(tmp_path): """As above for Windows: a fake left POSIX paths and a POSIX XDG layout in place, so the no-op was never proven against a real one.""" assert lde.install_desktop_entry(_make_project(tmp_path)) is None # --------------------------------------------------------------------------- # Cache refresh tool gating # --------------------------------------------------------------------------- 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, ) monkeypatch.setattr(lde, "_run_quiet", lambda cmd: ran.append(cmd) or True) return ran def test_refresh_runs_kbuildsycoca6_when_present(monkeypatch, tmp_path): ran = _stub_tools(monkeypatch, {"update-desktop-database", "kbuildsycoca6"}) tools = lde.refresh_desktop_databases(tmp_path) assert tools == ["update-desktop-database", "kbuildsycoca6"] assert ran == [ ["/usr/bin/update-desktop-database", str(tmp_path)], ["/usr/bin/kbuildsycoca6", "--noincremental"], ] def test_refresh_falls_back_to_kbuildsycoca5(monkeypatch, tmp_path): ran = _stub_tools(monkeypatch, {"kbuildsycoca5"}) tools = lde.refresh_desktop_databases(tmp_path) assert tools == ["kbuildsycoca5"] assert ran == [["/usr/bin/kbuildsycoca5", "--noincremental"]] def test_refresh_prefers_kbuildsycoca6_over_5(monkeypatch, tmp_path): ran = _stub_tools(monkeypatch, {"kbuildsycoca6", "kbuildsycoca5"}) lde.refresh_desktop_databases(tmp_path) assert [cmd[0] for cmd in ran] == ["/usr/bin/kbuildsycoca6"] def test_refresh_skips_missing_tools(monkeypatch, tmp_path): ran = _stub_tools(monkeypatch, set()) assert lde.refresh_desktop_databases(tmp_path) == [] assert ran == [] def test_refresh_reports_only_tools_that_succeeded(monkeypatch, tmp_path): monkeypatch.setattr(lde.shutil, "which", lambda name: f"/usr/bin/{name}") # update-desktop-database fails (exit != 0). kbuildsycoca6 succeeds. monkeypatch.setattr(lde, "_run_quiet", lambda cmd: "kbuildsycoca" in cmd[0]) assert lde.refresh_desktop_databases(tmp_path) == ["kbuildsycoca6"] def test_run_quiet_swallows_missing_binary(tmp_path): assert lde._run_quiet([str(tmp_path / "definitely-not-a-binary")]) is False def test_exec_arg_quoting_handles_spaces(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) spaced = tmp_path / "my apps" / "hermes" spaced.parent.mkdir() spaced.write_text("", encoding="utf-8") monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", lambda: str(spaced)) 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'"{spaced}" desktop' @pytest.mark.skipif( sys.platform == "win32", reason="Symlinks require elevated privileges on Windows" ) def test_running_interpreter_keeps_venv_semantic_path(tmp_path, monkeypatch): """Lexical preserved only when pyvenv.cfg marks the path as a venv.""" # venv layout: bin/python symlink -> base, pyvenv.cfg at venv root base = tmp_path / "base" / "python3.11" base.parent.mkdir(parents=True) base.write_text("", encoding="utf-8") venv_root = tmp_path / "venv" venv_bin = venv_root / "bin" venv_bin.mkdir(parents=True) (venv_root / "pyvenv.cfg").write_text("home = /base\n", encoding="utf-8") venv_python = venv_bin / "python" venv_python.symlink_to(base) monkeypatch.setattr(lde.sys, "executable", str(venv_python)) assert lde._running_interpreter() == str(venv_python) # non-venv symlink: resolve instead (durability over lexical) plain_root = tmp_path / "plain" / "bin" plain_root.mkdir(parents=True) plain_link = plain_root / "python3" plain_link.symlink_to(base) monkeypatch.setattr(lde.sys, "executable", str(plain_link)) assert lde._running_interpreter() == str(base) def test_running_interpreter_resolves_plain_interpreter(monkeypatch): """A non-symlinked, non-venv executable resolves to itself.""" monkeypatch.setattr(lde.sys, "executable", "/usr/bin/python3") out = lde._running_interpreter() assert Path(out).is_absolute() def test_can_import_probe_runs_and_caches(tmp_path): """The probe executes the real interpreter and memoizes the answer. Asserts the two things that hold on ANY host: the probe returns a definite boolean for a real interpreter (not None, not an exception path), and the per-path cache is populated so the second call pays no subprocess. Host-dependent capability itself (True vs False) is deliberately NOT asserted - a CI host with hermes pip-installed system-wide would legitimately answer True. """ import time real = Path("/usr/bin/python3") if not real.exists(): pytest.skip("no system python to probe") lde._probe_cache.pop(str(real), None) try: first = lde._can_import_hermes_cli(real) assert isinstance(first, bool) assert str(real) in lde._probe_cache t0 = time.monotonic() second = lde._can_import_hermes_cli(real) assert second is first assert time.monotonic() - t0 < 0.05 # cache hit: no subprocess finally: lde._probe_cache.pop(str(real), None) def test_exec_falls_back_to_running_interpreter_when_probe_fails( tmp_path, xdg_home, monkeypatch ): """A candidate interpreter that fails the import probe is not persisted.""" import sys as _s root = _make_project(tmp_path) interpreter = tmp_path / "uv" / "bin" / "python3.11" interpreter.parent.mkdir(parents=True) interpreter.write_bytes(b"\x7fELF fake") interpreter.chmod(0o755) _argv0_context(monkeypatch, str(interpreter)) monkeypatch.setattr("shutil.which", lambda name: None) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) def fake_resolve(): return _s.argv[0] if _s.argv[0] else None monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) # Force the probe to fail for whatever interpreter gets chosen first. monkeypatch.setattr(lde, "_can_import_hermes_cli", lambda p: False) # And the fallback interpreter must itself pass (it always should). monkeypatch.setattr( lde, "_running_interpreter_fallback", lambda: os.path.abspath(sys.executable), ) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] # Runnable module form under the RUNNING interpreter - never the # unprobeable ELF fake, never a bare " desktop". assert exec_line.endswith("-m hermes_cli.main desktop") first = exec_line.split(" ")[0].strip('"') assert first == os.path.abspath(sys.executable) assert str(interpreter) not in exec_line @pytest.mark.parametrize( "suffix", ["-old", ".bak", "-copy"], ) def test_wrapper_ownership_rejects_sibling_extensions(suffix, tmp_path): """A shim execing `/...` must NOT pass ownership. Bare substring matching accepted these; the boundary-aware matcher must reject them (stable-but-wrong entry pointing at the renamed old install). """ checkout = tmp_path / "hermes-agent" checkout.mkdir() evil = tmp_path / "evil-shim" evil.write_text( f"#!/bin/bash\n" f"exec {checkout}{suffix}/venv/bin/python " f'{checkout}{suffix}/hermes "$@"\n', encoding="utf-8", ) assert lde._wrapper_targets_checkout(evil, checkout) is False @pytest.mark.skipif( sys.platform == "win32", reason="Symlinks require elevated privileges on Windows" ) def test_wrapper_ownership_accepts_shim_via_symlinked_home(tmp_path, monkeypatch): """Installer writes $INSTALL_DIR lexically; the root stays lexical too. With /home -> /real-home, a shim that references the lexical checkout path must match the lexical checkout root (the resolved root alone would never match the shim's text). """ home_link = tmp_path / "home-link" home_real = tmp_path / "home-real" home_real.mkdir() home_link.symlink_to(home_real) lexical_checkout = home_link / "hermes-agent" (home_real / "hermes-agent").mkdir() shim = home_link / ".local" / "bin" / "hermes" shim.parent.mkdir(parents=True) shim.write_text( f"#!/bin/bash\n" f"exec {lexical_checkout}/venv/bin/python " f'{lexical_checkout}/hermes "$@"\n', encoding="utf-8", ) assert lde._wrapper_targets_checkout(shim, lexical_checkout) is True # And the end-to-end probe finds it via the lexical root: make the # shim executable, point HOME at the symlinked home, and give the # resolver a checkout-internal primary (the repo script) so the # probe leg actually engages. shim.chmod(0o755) monkeypatch.setenv("HOME", str(home_link)) monkeypatch.setattr("shutil.which", lambda name: None) repo_script = lexical_checkout / "hermes" repo_script.write_text("#!/usr/bin/env python3\n", encoding="utf-8") monkeypatch.setattr(sys, "argv", [str(repo_script), "desktop"]) assert lde._resolve_hermes_bin_for_desktop_entry( resolve_fn=lambda: sys.argv[0] if sys.argv[0] else None, checkout_root=lexical_checkout, ) == str(shim) def test_needs_interpreter_case_insensitive_match(tmp_path, monkeypatch): """Interpreter paths with uppercase must not flag own venv scripts. The shebang is lowercased for comparison; the interpreter dir must be too (conda env names, usernames, uv's ephemeral .tmpXXX dirs all carry uppercase - an asymmetric compare would prefix the venv's own console script spuriously). """ venv_bin = tmp_path / "MyEnv" / "bin" venv_bin.mkdir(parents=True) interpreter = venv_bin / "python" interpreter.write_text("", encoding="utf-8") console_script = venv_bin / "hermes" console_script.write_text(f"#!{interpreter}\nimport hermes_cli\n", encoding="utf-8") monkeypatch.setattr(lde.sys, "executable", str(interpreter)) assert lde._needs_interpreter(console_script) is False def test_needs_interpreter_rejects_sibling_directory(tmp_path, monkeypatch): """``/bin-extra/python`` is NOT inside ``/bin``. Substring matching accepted it (the parent dir appears verbatim inside the sibling path), skipping the interpreter prefix for a script whose shebang actually points OUTSIDE the venv. Path-component comparison rejects it. """ venv_bin = tmp_path / "venv" / "bin" venv_bin.mkdir(parents=True) interp = venv_bin / "python" interp.write_text("", encoding="utf-8") monkeypatch.setattr(lde.sys, "executable", str(interp)) sibling_script = tmp_path / "sibling" sibling_script.write_text( f"#!{tmp_path}/venv/bin-extra/python\nimport hermes_cli\n", encoding="utf-8", ) assert lde._needs_interpreter(sibling_script) is True def test_needs_interpreter_strips_flags_before_comparing(tmp_path, monkeypatch): """A flagged own-venv shebang is not misclassified by the flag token.""" venv_bin = tmp_path / "venv" / "bin" venv_bin.mkdir(parents=True) interp = venv_bin / "python" interp.write_text("", encoding="utf-8") monkeypatch.setattr(lde.sys, "executable", str(interp)) flagged = tmp_path / "flagged" flagged.write_text(f"#!{interp} -S\nimport hermes_cli\n", encoding="utf-8") assert lde._needs_interpreter(flagged) is False def test_needs_interpreter_env_shebang_always_escapes(tmp_path, monkeypatch): """``env`` resolves through the DE's PATH - not the installer's.""" venv_bin = tmp_path / "venv" / "bin" venv_bin.mkdir(parents=True) interp = venv_bin / "python" interp.write_text("", encoding="utf-8") monkeypatch.setattr(lde.sys, "executable", str(interp)) # Even when env itself sits in the venv bin (parent equality would # pass), the PATH resolution semantics mean the shebang escapes. env_script = tmp_path / "envscript" env_script.write_text( f"#!{venv_bin}/env python3\nimport hermes_cli\n", encoding="utf-8" ) assert lde._needs_interpreter(env_script) is True # ...unless env carries an absolute venv interpreter after -S. env_abs = tmp_path / "envabs" env_abs.write_text( f"#!/usr/bin/env -S {interp}\nimport hermes_cli\n", encoding="utf-8" ) assert lde._needs_interpreter(env_abs) is False def test_probe_skips_wrapper_with_escaping_python_shebang( tmp_path, xdg_home, monkeypatch ): """A checkout-referencing wrapper with an env shebang is skipped. Ownership alone would accept it (the body references this checkout), but its `#!/usr/bin/env python3` shebang dies in the DE context. The shebang-safety gate skips it; the module fallback wins. Idea credited to autumn8's #92122 rung-2 check. """ import sys as _s root = _make_project(tmp_path) repo_script = root / "hermes" repo_script.write_text( "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" ) repo_script.chmod(0o755) # A wrapper that targets this checkout but cannot run itself. broken_wrapper = xdg_home / ".local" / "bin" / "hermes" broken_wrapper.parent.mkdir(parents=True) broken_wrapper.write_text( f"#!/usr/bin/env python3\n# launcher for {root}\nimport hermes_cli\n", encoding="utf-8", ) broken_wrapper.chmod(0o755) monkeypatch.setenv("HOME", str(xdg_home)) _argv0_context(monkeypatch, str(repo_script)) monkeypatch.setattr("shutil.which", lambda name: None) def fake_resolve(): return sys.argv[0] if sys.argv[0] else None monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] assert str(broken_wrapper) not in exec_line assert exec_line.endswith("-m hermes_cli.main desktop") def test_probe_accepts_shell_launcher_wrapper(tmp_path, xdg_home, monkeypatch): """A bash launcher is safe by construction and still wins the probe.""" root = _make_project(tmp_path) repo_script = root / "hermes" repo_script.write_text( "#!/usr/bin/env python3\nimport hermes_cli\n", encoding="utf-8" ) repo_script.chmod(0o755) good_wrapper = xdg_home / ".local" / "bin" / "hermes" good_wrapper.parent.mkdir(parents=True) good_wrapper.write_text( f"#!/bin/bash\nexec {root / 'venv' / 'bin' / 'python'} " f'{root / "hermes"} "$@"\n', encoding="utf-8", ) good_wrapper.chmod(0o755) monkeypatch.setenv("HOME", str(xdg_home)) _argv0_context(monkeypatch, str(repo_script)) monkeypatch.setattr("shutil.which", lambda name: None) def fake_resolve(): return sys.argv[0] if sys.argv[0] else None monkeypatch.setattr("hermes_cli.relaunch.resolve_hermes_bin", fake_resolve) monkeypatch.setattr(lde, "refresh_desktop_databases", lambda _dir: []) entry = lde.install_desktop_entry(root) exec_line = _parse(entry.read_text(encoding="utf-8"))["Exec"] assert exec_line == f"{good_wrapper} desktop" def test_install_icon_handles_truncated_png_header(tmp_path, xdg_home, monkeypatch): """A truncated PNG (valid signature + IHDR tag, <24 bytes) must not raise struct.error out of the fail-safe: it lands in 256x256/ like any other unknown-size raster.""" 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" / "256x256" / "apps" / "hermes.png" assert dest.is_file() def test_hicolor_subdir_puts_rasters_in_indexed_dirs_never_scalable(): """Panel lookup uses fixed sizes. scalable/ is SVG-only.""" assert lde._hicolor_subdir(None) == "256x256" assert lde._hicolor_subdir((1024, 1024)) == "256x256" assert lde._hicolor_subdir((512, 512)) == "512x512" assert lde._hicolor_subdir((256, 256)) == "256x256" assert lde._hicolor_subdir((48, 48)) == "48x48" assert lde._hicolor_subdir((24, 24)) == "24x24" assert lde._hicolor_subdir((64, 32)) == "256x256" def test_install_places_1024_png_in_256x256_not_scalable( tmp_path, xdg_home, monkeypatch ): """The shipped desktop asset is 1024×1024. A PNG in scalable/ is what Cinnamon's panel rasterizes as a mangled low-res icon.""" root = _make_project(tmp_path) lde.icon_path(root).write_bytes(_png_ihdr(1024, 1024)) _stub_install(tmp_path, monkeypatch) entry = lde.install_desktop_entry(root) values = _parse(entry.read_text(encoding="utf-8")) dest = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png" stale = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png" assert values["Icon"] == "hermes" assert dest.is_file() assert dest.read_bytes() == lde.icon_path(root).read_bytes() assert not stale.exists() def test_install_removes_stale_scalable_png(tmp_path, xdg_home, monkeypatch): """v2026.8.31 wrote the PNG into scalable/. A later hermes desktop must delete that leftover so Cinnamon does not keep using it.""" root = _make_project(tmp_path) lde.icon_path(root).write_bytes(_png_ihdr(1024, 1024)) _stub_install(tmp_path, monkeypatch) stale = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png" stale.parent.mkdir(parents=True) stale.write_bytes(b"old scalable png") lde.install_desktop_entry(root) dest = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png" assert dest.is_file() assert not stale.exists() def test_install_exact_48_png_uses_48x48_dir(tmp_path, xdg_home, monkeypatch): root = _make_project(tmp_path) lde.icon_path(root).write_bytes(_png_ihdr(48, 48)) _stub_install(tmp_path, monkeypatch) lde.install_desktop_entry(root) dest = xdg_home / "icons" / "hicolor" / "48x48" / "apps" / "hermes.png" assert dest.is_file() assert not ( xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png" ).exists() def test_install_resizes_decodable_png_to_panel_sizes( tmp_path, xdg_home, monkeypatch ): """A decodeable PNG is Lanczos-resized so the 24px slot is actually 24px.""" from PIL import Image root = _make_project(tmp_path) im = Image.new("RGBA", (64, 64), (255, 255, 255, 255)) for x in range(16, 48): for y in range(16, 48): im.putpixel((x, y), (0, 0, 0, 255)) buf = io.BytesIO() im.save(buf, format="PNG") lde.icon_path(root).write_bytes(buf.getvalue()) _stub_install(tmp_path, monkeypatch) lde.install_desktop_entry(root) dest_24 = xdg_home / "icons" / "hicolor" / "24x24" / "apps" / "hermes.png" dest_256 = xdg_home / "icons" / "hicolor" / "256x256" / "apps" / "hermes.png" stale = xdg_home / "icons" / "hicolor" / "scalable" / "apps" / "hermes.png" assert dest_24.is_file() assert dest_256.is_file() assert not stale.exists() assert struct.unpack(">II", dest_24.read_bytes()[16:24]) == (24, 24) assert struct.unpack(">II", dest_256.read_bytes()[16:24]) == (256, 256)