"""Tests for ``hermes gui`` desktop launcher wiring.""" from __future__ import annotations import argparse import os import subprocess import sys import time import types from pathlib import Path from unittest.mock import patch import pytest from hermes_cli import main as cli_main from hermes_cli import main_desktop from hermes_cli import main_install_repair from hermes_cli import main_web_build @pytest.fixture(autouse=True) def _prepared_build_environment(monkeypatch): from hermes_cli import source_build monkeypatch.setattr(source_build, "source_build_env", lambda env=None, **kw: {**os.environ, **(env or {})}) @pytest.fixture(autouse=True) def _isolate_xdg_data_home(tmp_path, monkeypatch): """Keep desktop-entry writes out of the developer's real home directory. ``cmd_gui`` registers an XDG launcher entry, and ``desktop_entry_path()`` resolves it under ``XDG_DATA_HOME`` (falling back to ``~/.local/share``). While these tests faked the host as darwin the Linux-only registration never ran, so nothing escaped. Running them on their real host makes that call live, and on a Linux dev box it wrote a ``hermes.desktop`` pointing ``Exec=`` at the test's throwaway npm stub into the user's actual applications menu. The hermetic conftest deliberately does NOT redirect ``HOME`` (subprocesses depend on it being stable), so this has to be pinned per-file. """ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg-data")) @pytest.fixture(autouse=True) def _stable_keychain_detection(monkeypatch): """Pin Linux keychain detection to the fast GNOME env path. On Linux, ``cmd_gui`` falls back to a D-Bus ping via ``subprocess.run`` when no keychain env var is present. Tests here mock ``subprocess.run`` with strict ``side_effect`` lists, so an unpinned probe would silently consume an item meant for the build/launch calls. Detection-specific tests clear these vars again via ``_clear_keychain_env``. """ monkeypatch.delenv("KDE_SESSION_VERSION", raising=False) monkeypatch.delenv("KDE_FULL_SESSION", raising=False) monkeypatch.delenv("HERMES_DESKTOP_PASSWORD_STORE", raising=False) monkeypatch.setenv("GNOME_KEYRING_CONTROL", "/run/user/1000/keyring") def _ns(**kw): defaults = dict( skip_build=False, build_only=False, force_build=False, source=False, fake_boot=False, ignore_existing=False, hermes_root=None, cwd=None, setup_tcc_identity=False, identity=None, ) defaults.update(kw) return argparse.Namespace(**defaults) def _make_desktop_tree(tmp_path: Path) -> Path: root = tmp_path / "hermes-agent" desktop_dir = root / "apps" / "desktop" desktop_dir.mkdir(parents=True) (desktop_dir / "package.json").write_text("{}", encoding="utf-8") return root def _make_packaged_executable(root: Path, monkeypatch) -> Path: """Create the packaged-app path layout electron-builder emits on THIS host. The layout is keyed off the real ``sys.platform`` rather than a caller- supplied override: ``cmd_gui`` resolves the executable through the same branch, so faking the platform here only proved the test and the code agreed about a host neither was running on. Note the Linux arm also lays down ``chrome-sandbox``. ``cmd_gui`` refuses to launch without it (Electron's setuid sandbox helper), which the old darwin-by-default fake concealed — on Linux the packaged tree genuinely has to include it. """ desktop_dir = root / "apps" / "desktop" if sys.platform == "darwin": exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes" elif sys.platform == "win32": exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe" else: exe = desktop_dir / "release" / "linux-unpacked" / "hermes" exe.parent.mkdir(parents=True, exist_ok=True) exe.write_text("", encoding="utf-8") if sys.platform not in ("darwin", "win32"): (exe.parent / "chrome-sandbox").write_text("", encoding="utf-8") return exe def _staging_dir_from(cmd) -> Path: """Extract the ``-c.directories.output=`` electron-builder override ``cmd_gui`` appends to ``npm run pack`` (stage-and-swap, #86443).""" for arg in cmd: if isinstance(arg, str) and arg.startswith("-c.directories.output="): return Path(arg.split("=", 1)[1]) raise AssertionError(f"no staging output override in {cmd!r}") def _packaged_exe_rel() -> Path: """Packaged-exe path relative to electron-builder's output dir on THIS host.""" if sys.platform == "darwin": return Path("mac-arm64") / "Hermes.app" / "Contents" / "MacOS" / "Hermes" if sys.platform == "win32": return Path("win-unpacked") / "Hermes.exe" return Path("linux-unpacked") / "hermes" def _pack_into_staging(root: Path, content: str = "", returncode: int = 0): """``subprocess.run`` side effect mimicking a real ``npm run pack``: lays the packaged app down inside the STAGING dir named on the command line (never in release/), then returns *returncode*. Non-pack commands (the launch) return success.""" def _run(cmd, **kwargs): if len(cmd) >= 3 and cmd[1:3] == ["run", "builder"]: exe = _staging_dir_from(cmd) / _packaged_exe_rel() exe.parent.mkdir(parents=True, exist_ok=True) exe.write_text(content, encoding="utf-8") if sys.platform not in ("darwin", "win32"): (exe.parent / "chrome-sandbox").write_text("", encoding="utf-8") return subprocess.CompletedProcess(cmd, returncode) return subprocess.CompletedProcess(cmd, 0) return _run @pytest.mark.parametrize("local", [False, True]) def test_source_launch_reads_bom_electron_path_without_provisioning(tmp_path, monkeypatch, local): root = _make_desktop_tree(tmp_path) desktop = root / "apps" / "desktop" (desktop / "dist").mkdir() (desktop / "dist" / "index.html").write_text("prepared renderer", encoding="utf-8") electron = root / "node_modules" / "electron" (electron / "dist").mkdir(parents=True) (electron / "package.json").write_text("{}", encoding="utf-8") executable = electron / "dist" / "électron" executable.touch() (electron / "path.txt").write_text(executable.name + "\n", encoding="utf-8-sig") monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.setattr(main_desktop, "_desktop_launch_env", lambda args: ({}, [])) monkeypatch.setattr(main_desktop, "_register_linux_desktop_entry", lambda **kw: None) calls = [] monkeypatch.setattr(main_desktop.subprocess, "run", lambda cmd, **kw: calls.append(cmd) or subprocess.CompletedProcess(cmd, 0)) args = _ns(source=True, skip_build=True, local=local) with pytest.raises(SystemExit) as exit_info: main_desktop.cmd_gui(args) assert exit_info.value.code == 0 assert calls == [[str(executable), ".", *(["--local"] if local else [])]] executable.unlink() with pytest.raises(SystemExit) as exit_info: main_desktop.cmd_gui(args) assert exit_info.value.code == 1 assert len(calls) == 1 def _stamped_macos_bundle(app: Path, asar: bytes) -> Path: (app / "Contents" / "MacOS").mkdir(parents=True) (app / "Contents" / "MacOS" / "Hermes").write_bytes(b"\xcf\xfa\xed\xfe") (app / "Contents" / "Resources").mkdir() (app / "Contents" / "Resources" / "app.asar").write_bytes(asar) (app / "Contents" / "Resources" / "install-stamp.json").write_text('{"updateMechanism": "self"}') return app @pytest.mark.platforms("macos") def test_packaged_launch_opens_the_refreshed_installed_app(tmp_path, monkeypatch): """#52339: Finder and the Dock open the installed Hermes.app, so ``hermes desktop`` must launch that copy (brought up to the checkout build) instead of a second bundle under release/.""" import shutil root = _make_desktop_tree(tmp_path) _stamped_macos_bundle(root / "apps" / "desktop" / "release" / "mac-arm64" / "Hermes.app", b"checkout build") installed = _stamped_macos_bundle(tmp_path / "Applications" / "Hermes.app", b"stale build") monkeypatch.setattr("hermes_cli.gui_uninstall.packaged_gui_app_paths", lambda: [installed]) monkeypatch.setattr("hermes_constants.get_default_hermes_root", lambda **kw: tmp_path) # root is its hermes-agent monkeypatch.setattr(main_desktop, "_stage_macos_bundle_copy", lambda src, dst: shutil.copytree(src, dst, symlinks=True)) monkeypatch.setattr(main_desktop, "_running_macos_app_bundles", lambda: set()) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.setattr(main_desktop, "_desktop_launch_env", lambda args: ({}, [])) calls = [] monkeypatch.setattr(main_desktop.subprocess, "run", lambda cmd, **kw: calls.append(cmd) or subprocess.CompletedProcess(cmd, 0)) with pytest.raises(SystemExit) as exit_info: main_desktop.cmd_gui(_ns(skip_build=True)) assert exit_info.value.code == 0 assert calls == [[str(installed / "Contents" / "MacOS" / "Hermes")]] assert (installed / "Contents" / "Resources" / "app.asar").read_bytes() == b"checkout build" def test_packaged_renderer_bom_does_not_bypass_entry_validation(tmp_path): import json import struct from hermes_cli.desktop_update_verify import _verify_packaged_entry resources = tmp_path / "resources" dist = resources / "app.asar.unpacked" / "dist" dist.mkdir(parents=True) entry = b"export {};" (dist / "main.mjs").write_bytes(entry) package = json.dumps({"main": "dist/main.mjs"}).encode("utf-8") header = json.dumps({"files": { "package.json": {"size": len(package), "offset": "0"}, "dist": {"files": {"main.mjs": {"size": len(entry), "unpacked": True}}}, }}).encode("utf-8") padded = header + b"\0" * (-len(header) % 4) (resources / "app.asar").write_bytes( struct.pack("<4I", 4, 8 + len(padded), 4 + len(padded), len(header)) + padded + package) index = dist / "index.html" index.write_text('café', encoding="utf-8-sig") _verify_packaged_entry(resources) index.write_text("café", encoding="utf-8-sig") with pytest.raises(RuntimeError, match="renderer has no local module entry"): _verify_packaged_entry(resources) index.write_bytes(b"\xef\xbb\xbf\xff") with pytest.raises(RuntimeError, match="renderer entry is invalid"): _verify_packaged_entry(resources) # Dependency admission and staging are exercised by test_desktop_source_build.py. # --- package-manager (Homebrew/pip) installs: no desktop source tree ------- # (#61056: `hermes desktop` under Homebrew looked for apps/desktop inside the # Cellar site-packages, which the formula does not ship.) def test_site_packages_install_kind_detects_brew_and_pip(): brew_root = Path("/opt/homebrew/Cellar/hermes-agent/2026.7.7.2/libexec/site-packages") pip_root = Path("/usr/lib/python3/dist-packages") venv_root = Path("/home/u/proj/venv/lib/python3.12/site-packages") assert main_desktop._site_packages_install_kind(brew_root) == "homebrew" assert main_desktop._site_packages_install_kind(pip_root) == "pip" assert main_desktop._site_packages_install_kind(venv_root) == "pip" assert main_desktop._site_packages_install_kind(Path("/home/u/hermes-agent")) is None def test_gui_brew_install_prints_brew_guidance_not_venv_hint(tmp_path, monkeypatch, capsys): """A Homebrew install has no apps/desktop tree and can never build one — the error must say so instead of implying a broken checkout.""" root = tmp_path / "Cellar" / "hermes-agent" / "2026.7.7.2" / "libexec" / "site-packages" root.mkdir(parents=True) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.setattr(main_desktop, "_launch_installed_macos_desktop_app", lambda: False) with pytest.raises(SystemExit) as exc: main_desktop.cmd_gui(_ns()) assert exc.value.code == 1 out = capsys.readouterr().out assert "Desktop GUI source not found" in out assert "Homebrew" in out def test_gui_brew_install_launches_installed_app_when_present(tmp_path, monkeypatch): """Prefer the separately installed /Applications/Hermes.app over failing.""" root = tmp_path / "Cellar" / "hermes-agent" / "2026.7.7.2" / "libexec" / "site-packages" root.mkdir(parents=True) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) launched = [] monkeypatch.setattr(main_desktop, "_launch_installed_macos_desktop_app", lambda: launched.append(1) or True) with pytest.raises(SystemExit) as exc: main_desktop.cmd_gui(_ns()) assert exc.value.code == 0 assert launched == [1] @pytest.mark.parametrize("exists,platform", [(True, "darwin"), (False, "darwin"), (True, "linux")]) def test_launch_installed_macos_desktop_app_gates_on_bundle_and_platform(tmp_path, monkeypatch, exists, platform): monkeypatch.setattr(main_desktop.sys, "platform", platform) exe = Path("/Applications/Hermes.app/Contents/MacOS/Hermes") monkeypatch.setattr(main_desktop.Path, "is_file", lambda self: exists if self == exe else Path.is_file(self)) calls = [] if exists and platform == "darwin": import hermes_cli.bundled_app as bundled_app monkeypatch.setattr(bundled_app, "launch_detached", lambda argv, **kw: calls.append(argv) or 4321) assert main_desktop._launch_installed_macos_desktop_app() is (exists and platform == "darwin") if exists and platform == "darwin": assert calls == [[str(exe)]] # ── Content-hash stamp tests ────────────────────────────────────────── # ── Electron build-cache recovery tests ─────────────────────────────── # ── electronDist (re)download helper tests (#47266) ─────────────────── # --- macOS TCC-stable local signing (relaunch fixup) ----------------------- def _write_info_plist(bundle: Path, identifier: str) -> None: import plistlib info = bundle / "Contents" / "Info.plist" info.parent.mkdir(parents=True, exist_ok=True) info.write_bytes(plistlib.dumps({"CFBundleIdentifier": identifier})) def _make_signable_app(desktop_dir: Path) -> Path: """Build a fake packaged Hermes.app with the pieces the signer must find.""" ent_dir = desktop_dir / "electron" ent_dir.mkdir(parents=True, exist_ok=True) (ent_dir / "entitlements.mac.plist").write_text("", encoding="utf-8") (ent_dir / "entitlements.mac.inherit.plist").write_text("", encoding="utf-8") app = desktop_dir / "release" / "mac-arm64" / "Hermes.app" _write_info_plist(app, "com.nousresearch.hermes") (app / "Contents" / "MacOS").mkdir(parents=True) (app / "Contents" / "MacOS" / "Hermes").write_text("", encoding="utf-8") helper = app / "Contents" / "Frameworks" / "Hermes Helper.app" _write_info_plist(helper, "com.nousresearch.hermes.helper") native_dir = app / "Contents" / "Resources" / "app.asar.unpacked" / "node_modules" / "pty" native_dir.mkdir(parents=True) (native_dir / "pty.node").write_text("", encoding="utf-8") (app / "Contents" / "Frameworks" / "chrome_crashpad_handler").write_text("", encoding="utf-8") return app def _collect_codesign_calls(monkeypatch): calls = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) return subprocess.CompletedProcess(cmd, 0) monkeypatch.setattr( cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None ) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) return calls def test_desktop_macos_local_codesign_signs_native_binaries(tmp_path, monkeypatch): """The standalone Mach-O pass must actually find files inside the bundle. Regression: an absolute-path parts check always matches the outer Hermes.app component, silently skipping every .node/.dylib/crashpad binary — codesign then rejects the outer signature (nested code unsigned). """ desktop_dir = tmp_path / "apps" / "desktop" app = _make_signable_app(desktop_dir) calls = _collect_codesign_calls(monkeypatch) assert main_desktop._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is True signed = [c[-1] for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]] assert str(app / "Contents" / "Resources" / "app.asar.unpacked" / "node_modules" / "pty" / "pty.node") in signed assert str(app / "Contents" / "Frameworks" / "chrome_crashpad_handler") in signed # --- desktop --setup-tcc-identity ------------------------------------------ def _fake_proc(cmd, returncode=0, stdout="", stderr=""): return subprocess.CompletedProcess(cmd, returncode, stdout=stdout, stderr=stderr) @pytest.mark.platforms("macos") def test_setup_tcc_identity_creates_cert_imports_trusts_and_configures(tmp_path, monkeypatch, capsys): """Fresh identity: openssl generates, security imports + trusts, config is written.""" monkeypatch.setattr( cli_main.shutil, "which", lambda name: {"openssl": "/usr/bin/openssl", "security": "/usr/bin/security", "codesign": "/usr/bin/codesign"}.get(name), ) monkeypatch.setattr(cli_main.Path, "home", classmethod(lambda cls: tmp_path)) identity = "Hermes Local Signing" calls = [] state = {"trusted": False} def fake_run(cmd, **kwargs): calls.append(list(cmd)) if cmd[:4] == ["/usr/bin/security", "find-identity", "-v", "-p"]: # Valid only after import AND trust have both happened — mirrors # real macOS, where an untrusted self-signed cert is invisible to # the -v listing (the #77189 review finding). if state["trusted"]: return _fake_proc(cmd, stdout=f' 1) ABCD "{identity}"\n 1 valid identities found') return _fake_proc(cmd, stdout=" 0 valid identities found") if cmd[0] == "/usr/bin/security" and cmd[1] == "add-trusted-cert": state["trusted"] = True return _fake_proc(cmd) return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) # Avoid writing the real user config. monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) assert main_desktop._desktop_macos_setup_tcc_identity(identity) is True # openssl cert generation + pkcs12 export + security import + trust all ran. assert any(c[0] == "/usr/bin/openssl" and "req" in c for c in calls) assert any(c[0] == "/usr/bin/openssl" and "pkcs12" in c for c in calls) assert any(c[0] == "/usr/bin/security" and c[1] == "import" for c in calls) assert any(c[0] == "/usr/bin/security" and c[1] == "add-trusted-cert" for c in calls) # The trust step targets the codeSign policy specifically. trust_call = next(c for c in calls if c[1:2] == ["add-trusted-cert"]) assert "codeSign" in trust_call and "trustRoot" in trust_call # Temp files cleaned up. assert not list(tmp_path.glob("hermes-tcc-*")) @pytest.mark.platforms("macos") def test_setup_tcc_identity_retries_pkcs12_with_legacy_on_mac_verification_failure(tmp_path, monkeypatch, capsys): """OpenSSL 3: first import fails with the MAC-verification signature, the -legacy re-export imports cleanly (the exact failure @ctaylor86 hit live).""" monkeypatch.setattr( cli_main.shutil, "which", lambda name: {"openssl": "/usr/bin/openssl", "security": "/usr/bin/security", "codesign": "/usr/bin/codesign"}.get(name), ) monkeypatch.setattr(cli_main.Path, "home", classmethod(lambda cls: tmp_path)) identity = "Hermes Local Signing" calls = [] state = {"legacy_exported": False, "trusted": False} def fake_run(cmd, **kwargs): calls.append(list(cmd)) if cmd[:4] == ["/usr/bin/security", "find-identity", "-v", "-p"]: if state["trusted"]: return _fake_proc(cmd, stdout=f' 1) ABCD "{identity}"\n 1 valid identities found') return _fake_proc(cmd, stdout=" 0 valid identities found") if cmd[0] == "/usr/bin/openssl" and "pkcs12" in cmd: state["legacy_exported"] = "-legacy" in cmd return _fake_proc(cmd) if cmd[0] == "/usr/bin/security" and cmd[1] == "import": if not state["legacy_exported"]: return _fake_proc( cmd, returncode=1, stderr="security: SecKeychainItemImport: MAC verification failed during PKCS12 import (wrong password?)", ) return _fake_proc(cmd) if cmd[0] == "/usr/bin/security" and cmd[1] == "add-trusted-cert": state["trusted"] = True return _fake_proc(cmd) return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) assert main_desktop._desktop_macos_setup_tcc_identity(identity) is True # Two pkcs12 exports (plain then -legacy) and two import attempts. pkcs12_calls = [c for c in calls if c[0] == "/usr/bin/openssl" and "pkcs12" in c] assert len(pkcs12_calls) == 2 assert "-legacy" not in pkcs12_calls[0] and "-legacy" in pkcs12_calls[1] assert len([c for c in calls if c[0] == "/usr/bin/security" and c[1] == "import"]) == 2 @pytest.mark.platforms("macos") def test_setup_tcc_identity_fails_when_trust_step_fails(tmp_path, monkeypatch, capsys): """A cert that imports but cannot be trusted for codeSign is a failure, not a silent success.""" monkeypatch.setattr( cli_main.shutil, "which", lambda name: {"openssl": "/usr/bin/openssl", "security": "/usr/bin/security", "codesign": "/usr/bin/codesign"}.get(name), ) monkeypatch.setattr(cli_main.Path, "home", classmethod(lambda cls: tmp_path)) def fake_run(cmd, **kwargs): if cmd[:4] == ["/usr/bin/security", "find-identity", "-v", "-p"]: return _fake_proc(cmd, stdout=" 0 valid identities found") if cmd[0] == "/usr/bin/security" and cmd[1] == "add-trusted-cert": return _fake_proc(cmd, returncode=1, stderr="SecTrustSettingsSetTrustSettings: authorization denied") return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is False @pytest.mark.platforms("macos") def test_setup_tcc_identity_fails_when_identity_never_becomes_valid(tmp_path, monkeypatch, capsys): """Postcondition gate: import + trust both 'succeed' but find-identity -v still lists nothing → report failure with guidance (the silent-success bug from the original PR).""" monkeypatch.setattr( cli_main.shutil, "which", lambda name: {"openssl": "/usr/bin/openssl", "security": "/usr/bin/security", "codesign": "/usr/bin/codesign"}.get(name), ) monkeypatch.setattr(cli_main.Path, "home", classmethod(lambda cls: tmp_path)) def fake_run(cmd, **kwargs): if cmd[:4] == ["/usr/bin/security", "find-identity", "-v", "-p"]: return _fake_proc(cmd, stdout=" 0 valid identities found") return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is False @pytest.mark.platforms("macos") def test_setup_tcc_identity_skips_generation_when_already_valid(tmp_path, monkeypatch, capsys): """Idempotent: an existing VALID identity is reused, not regenerated.""" monkeypatch.setattr( cli_main.shutil, "which", lambda name: {"openssl": "/usr/bin/openssl", "security": "/usr/bin/security", "codesign": "/usr/bin/codesign"}.get(name), ) monkeypatch.setattr(cli_main.Path, "home", classmethod(lambda cls: tmp_path)) calls = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) if cmd[:4] == ["/usr/bin/security", "find-identity", "-v", "-p"]: return _fake_proc(cmd, stdout=' 1) ABCD "Hermes Local Signing"\n 1 valid identities found') return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is True # No openssl generation, no security import — only find-identity + config. assert not any(c[0] == "/usr/bin/openssl" for c in calls) assert not any(c[0] == "/usr/bin/security" and c[1] == "import" for c in calls) @pytest.mark.platforms("macos") def test_setup_tcc_identity_untrusted_existing_cert_is_repaired(tmp_path, monkeypatch, capsys): """A cert that EXISTS but is not valid (CSSMERR_TP_NOT_TRUSTED) is repaired — regenerated/trusted — instead of being reported as already done. The original name-in-output probe treated this state as success.""" monkeypatch.setattr( cli_main.shutil, "which", lambda name: {"openssl": "/usr/bin/openssl", "security": "/usr/bin/security", "codesign": "/usr/bin/codesign"}.get(name), ) monkeypatch.setattr(cli_main.Path, "home", classmethod(lambda cls: tmp_path)) calls = [] state = {"trusted": False} def fake_run(cmd, **kwargs): calls.append(list(cmd)) if cmd[:4] == ["/usr/bin/security", "find-identity", "-v", "-p"]: # -v never lists the untrusted cert; it only appears once the # repair path has run add-trusted-cert. if state["trusted"]: return _fake_proc(cmd, stdout=' 1) ABCD "Hermes Local Signing"\n 1 valid identities found') return _fake_proc(cmd, stdout=" 0 valid identities found") if cmd[0] == "/usr/bin/security" and cmd[1] == "add-trusted-cert": state["trusted"] = True return _fake_proc(cmd) return _fake_proc(cmd) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) monkeypatch.setattr(main_desktop, "_desktop_packaged_executable", lambda d: None) monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", lambda d: True) monkeypatch.setattr("hermes_cli.config.set_config_value", lambda key, value: None) assert main_desktop._desktop_macos_setup_tcc_identity("Hermes Local Signing") is True assert any(c[0] == "/usr/bin/security" and c[1] == "add-trusted-cert" for c in calls) def test_cmd_gui_setup_tcc_identity_exits_before_build(tmp_path, monkeypatch): """`hermes desktop --setup-tcc-identity` calls the setup and exits 0/1 without building or launching the app.""" root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch) with patch("hermes_cli.main_desktop._desktop_macos_setup_tcc_identity", return_value=True) as mock_setup, \ patch("hermes_cli.source_build.prepare_source_dependencies") as mock_install, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(setup_tcc_identity=True, identity="Hermes Local Signing")) assert exc.value.code == 0 mock_setup.assert_called_once_with("Hermes Local Signing") mock_install.assert_not_called() @pytest.mark.platforms("macos") def test_relaunchable_fixup_stable_identity_never_touches_keychain(tmp_path, monkeypatch): """A successful stable-identity re-sign must NOT delete the safeStorage item. Regression for review feedback on #90961: deleting the keychain item permanently orphans every safeStorage-backed credential (gateway token, native OAuth access/refresh tokens — see electron/main.ts). On the stable path the cert-anchored designated requirement is stable across rebuilds, so after the first launch the keychain ACL already matches and deleting the item would destroy working credentials on every update. ``platforms("macos")``: the fixup no-ops on non-macOS (sys.platform guard), and the subject is codesign against a real ``.app`` bundle layout. """ root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.delenv("CSC_LINK", raising=False) monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False) _make_packaged_executable(root, monkeypatch) calls: list[list[str]] = [] monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: "Developer ID Application: Example" ) monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", lambda app, **kw: True) monkeypatch.setattr( cli_main.subprocess, "run", lambda cmd, **kw: calls.append(list(cmd)) or subprocess.CompletedProcess(cmd, 0), ) assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is True assert not any("delete-generic-password" in c for c in calls) @pytest.mark.platforms("macos") def test_relaunchable_fixup_legacy_adhoc_failure_never_touches_keychain(tmp_path, monkeypatch): """A failed fallback re-sign must preserve the keychain item (no deletion). Regression for review feedback on #90961: the fallback previously deleted the safeStorage item unconditionally, even when ``codesign`` failed (``check=False`` result was ignored). A failed recovery can permanently orphan gateway and native OAuth credentials without producing a verified successor app/key identity. The fixup must check the codesign result, run strict verification, and leave the keychain untouched on failure. ``platforms("macos")``: the fixup no-ops on non-macOS (sys.platform guard), and the subject is codesign against a real ``.app`` bundle layout. """ root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.delenv("CSC_LINK", raising=False) monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False) exe = _make_packaged_executable(root, monkeypatch) app = exe.parents[2] calls: list[list[str]] = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) # First subprocess call is the xattr clear (exit 0); the deep sign # fails with a non-zero exit. if cmd[:2] == ["/usr/bin/codesign", "--force"]: return subprocess.CompletedProcess(cmd, 1) return subprocess.CompletedProcess(cmd, 0) monkeypatch.setattr( cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None ) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: None) def boom(*a, **kw): raise subprocess.CalledProcessError(1, ["codesign"]) monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", boom) assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is False assert ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app)] in calls assert not any("--verify" in c for c in calls) assert not any("delete-generic-password" in c for c in calls) @pytest.mark.platforms("macos") def test_relaunchable_fixup_legacy_adhoc_success_still_verifies_and_never_deletes(tmp_path, monkeypatch): """A successful fallback re-sign runs strict verification, no deletion. The legacy ad-hoc fallback signs, verifies with ``codesign --verify --deep --strict``, and leaves the safeStorage keychain item untouched. The keychain prompt macOS shows instead is recoverable ("Always Allow" updates the ACL partition list and preserves the key); deletion is not. ``platforms("macos")``: the fixup no-ops on non-macOS (sys.platform guard), and the subject is codesign against a real ``.app`` bundle layout. """ root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.delenv("CSC_LINK", raising=False) monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False) exe = _make_packaged_executable(root, monkeypatch) app = exe.parents[2] calls: list[list[str]] = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) return subprocess.CompletedProcess(cmd, 0) monkeypatch.setattr( cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None ) monkeypatch.setattr(cli_main.subprocess, "run", fake_run) monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda a: False) monkeypatch.setattr(main_desktop, "_desktop_macos_local_signing_identity", lambda: None) def boom(*a, **kw): raise subprocess.CalledProcessError(1, ["codesign"]) monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", boom) assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is True assert ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app)] in calls assert ["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)] in calls assert not any("delete-generic-password" in c for c in calls) # --- desktop.* launch options (config.yaml) ------------------------------- # --- Linux launcher entry registration ------------------------------------ @pytest.mark.platforms("linux") def test_gui_registers_linux_desktop_entry_before_launch(tmp_path, monkeypatch): """A terminal launch (no DESKTOP_STARTUP_ID) still installs the entry before spawning Electron.""" root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.delenv("DESKTOP_STARTUP_ID", raising=False) packaged_exe = _make_packaged_executable(root, monkeypatch) registered: list[Path] = [] monkeypatch.setattr("hermes_cli.linux_desktop_entry.is_supported", lambda: True) monkeypatch.setattr( "hermes_cli.linux_desktop_entry.install_desktop_entry", lambda project_root: registered.append(project_root) or (tmp_path / "hermes.desktop"), ) launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) with patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.main.subprocess.run", return_value=launch_ok), \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) assert registered == [root] @pytest.mark.platforms("linux") def test_gui_shell_launch_defers_desktop_entry_until_window_reveal(tmp_path, monkeypatch): """An app-grid launch (DESKTOP_STARTUP_ID set) writes the entry only after Electron reports its window on screen — never before the spawn, while gnome-shell has the app in STARTING (#111906). Electron gets the pipe's write end via HERMES_DESKTOP_READY_FD.""" root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) monkeypatch.setenv("DESKTOP_STARTUP_ID", "gnome-shell/Hermes/1-0_TIME1") monkeypatch.setattr("hermes_cli.linux_desktop_entry.time.sleep", lambda _s: None) packaged_exe = _make_packaged_executable(root, monkeypatch) events: list[str] = [] monkeypatch.setattr("hermes_cli.linux_desktop_entry.is_supported", lambda: True) monkeypatch.setattr( "hermes_cli.linux_desktop_entry.install_desktop_entry", lambda project_root: events.append(f"install:{project_root}") or (tmp_path / "hermes.desktop"), ) def fake_electron(cmd, **kwargs): events.append("spawn") fd = int(kwargs["env"]["HERMES_DESKTOP_READY_FD"]) assert fd in kwargs["pass_fds"] os.write(fd, b"r") # main window revealed deadline = time.monotonic() + 10 while not any(e.startswith("install:") for e in events) and time.monotonic() < deadline: time.sleep(0.01) return subprocess.CompletedProcess(cmd, 0) with patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.main.subprocess.run", side_effect=fake_electron), \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) assert exc.value.code == 0 assert events == ["spawn", f"install:{root}"] assert packaged_exe.exists() @pytest.mark.platforms("linux") def test_gui_launches_even_when_desktop_entry_install_fails(tmp_path, monkeypatch): """Launcher plumbing is a convenience — it must never block the app.""" root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) packaged_exe = _make_packaged_executable(root, monkeypatch) def boom(_project_root): raise OSError("read-only /home") monkeypatch.setattr("hermes_cli.linux_desktop_entry.is_supported", lambda: True) monkeypatch.setattr("hermes_cli.linux_desktop_entry.install_desktop_entry", boom) launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0) with patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns()) assert exc.value.code == 0 launched = mock_run.call_args.args[0] if sys.platform.startswith("linux"): assert launched == [str(packaged_exe), "--disable-setuid-sandbox"] else: assert launched == [str(packaged_exe)] @pytest.mark.parametrize( "raw,expected", [ ("gnome-libsecret", "gnome-libsecret"), ("KWallet6", "kwallet6"), ("basic", "basic"), ("auto", "auto"), ("keychain-of-wonders", "auto"), (True, "auto"), ], ) def test_desktop_launch_options_normalizes_password_store(raw, expected): cfg = {"desktop": {"password_store": raw}} with patch("hermes_cli.config.load_config", return_value=cfg): _, _, store, _, _ = main_desktop._desktop_launch_options() assert store == expected @pytest.mark.parametrize( "raw,expected", [ ("x11", "x11"), ("WAYLAND", "wayland"), ("auto", "auto"), ("bogus", "auto"), (True, "auto"), ], ) def test_desktop_launch_options_normalizes_ozone_hint(raw, expected): """``desktop.ozone_platform_hint`` normalizes to x11/wayland/auto.""" cfg = {"desktop": {"ozone_platform_hint": raw}} with patch("hermes_cli.config.load_config", return_value=cfg): _, _, _, hint, _ = main_desktop._desktop_launch_options() assert hint == expected @pytest.mark.parametrize( "raw,expected", [ # Absent key stays ON — the composer must be reachable by # accessibility-driven dictation out of the box (#118083 family: # #118271 / #92607). (None, True), (True, True), (False, False), ("on", True), ("1", True), ("yes", True), ("0", False), ("false", False), ("OFF", False), (" no ", False), # `disabled` is a natural spelling of the opt-out; both word lists # (launcher + packaged-launch yaml reader) accept it, so the two # entry points stay in lockstep. ("disabled", False), ("Disabled", False), ("enabled", True), # YAML parses a bare `0` as int and `0.0` as float — neither is a # str, so the normalization must cover numeric scalars too, not just # the quoted forms above. (0, False), (1, True), (0.0, False), # Unknown strings don't disable the feature (fail-open, like the # other desktop launch options fail to "auto"). ("wibble", True), ], ) def test_desktop_launch_options_normalizes_renderer_accessibility(raw, expected): """``desktop.renderer_accessibility`` defaults to ON; only explicit false words opt out.""" cfg = {"desktop": {} if raw is None else {"renderer_accessibility": raw}} with patch("hermes_cli.config.load_config", return_value=cfg): _, _, _, _, renderer_a11y = main_desktop._desktop_launch_options() assert renderer_a11y is expected def test_desktop_environment_bridges_only_the_accessibility_opt_out(monkeypatch): """ON (the default) sets nothing; the opt-out bridges to HERMES_DESKTOP_RENDERER_ACCESSIBILITY=0, and an explicit env var wins.""" monkeypatch.delenv("HERMES_DESKTOP_RENDERER_ACCESSIBILITY", raising=False) monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"desktop": {}}) env, _ = main_desktop._desktop_launch_env(_ns()) assert "HERMES_DESKTOP_RENDERER_ACCESSIBILITY" not in env monkeypatch.setattr( "hermes_cli.config.load_config", lambda: {"desktop": {"renderer_accessibility": False}}) env, _ = main_desktop._desktop_launch_env(_ns()) assert env["HERMES_DESKTOP_RENDERER_ACCESSIBILITY"] == "0" monkeypatch.setenv("HERMES_DESKTOP_RENDERER_ACCESSIBILITY", "0") monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"desktop": {}}) env, _ = main_desktop._desktop_launch_env(_ns()) assert env["HERMES_DESKTOP_RENDERER_ACCESSIBILITY"] == "0" # --- desktop.password_store detection & bridging (linux) ------------------ def _clear_keychain_env(monkeypatch): for var in ( "KDE_SESSION_VERSION", "KDE_FULL_SESSION", "GNOME_KEYRING_CONTROL", "HERMES_DESKTOP_PASSWORD_STORE", ): monkeypatch.delenv(var, raising=False) @pytest.mark.parametrize( "kde_version,expected", [ ("6", "kwallet6"), ("5", "kwallet5"), ("4", "kwallet"), ], ) def test_detect_linux_password_store_prefers_kde_session(monkeypatch, kde_version, expected): _clear_keychain_env(monkeypatch) monkeypatch.setenv("KDE_SESSION_VERSION", kde_version) assert main_desktop._detect_linux_password_store() == expected def test_detect_linux_password_store_kde_full_session(monkeypatch): _clear_keychain_env(monkeypatch) monkeypatch.setenv("KDE_FULL_SESSION", "true") assert main_desktop._detect_linux_password_store() == "kwallet" def test_detect_linux_password_store_gnome_keyring(monkeypatch): _clear_keychain_env(monkeypatch) monkeypatch.setenv("GNOME_KEYRING_CONTROL", "/run/user/1000/keyring") assert main_desktop._detect_linux_password_store() == "gnome-libsecret" def test_detect_linux_password_store_via_dbus_secret_service(monkeypatch): _clear_keychain_env(monkeypatch) ping_ok = subprocess.CompletedProcess(["dbus-send"], 0) with patch("hermes_cli.main.subprocess.run", return_value=ping_ok) as mock_run: assert main_desktop._detect_linux_password_store() == "gnome-libsecret" assert "--dest=org.freedesktop.secrets" in mock_run.call_args.args[0] def test_detect_linux_password_store_none_when_no_keychain(monkeypatch): _clear_keychain_env(monkeypatch) ping_fail = subprocess.CompletedProcess(["dbus-send"], 1) with patch("hermes_cli.main.subprocess.run", return_value=ping_fail): assert main_desktop._detect_linux_password_store() is None with patch("hermes_cli.main.subprocess.run", side_effect=FileNotFoundError): assert main_desktop._detect_linux_password_store() is None @pytest.mark.platforms("linux") def test_gui_linux_packaged_launch_bridges_detected_password_store(tmp_path, monkeypatch): _clear_keychain_env(monkeypatch) root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.main_desktop._desktop_linux_sandbox_fixup", return_value=True), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ patch("hermes_cli.main_desktop._detect_linux_password_store", return_value="gnome-libsecret"), \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) launch_env = mock_run.call_args_list[-1].kwargs["env"] assert launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "gnome-libsecret" @pytest.mark.platforms("linux") @pytest.mark.parametrize("explicit,configured,detected,expected", [ (None, "auto", "gnome-libsecret", "gnome-libsecret"), (None, "kwallet6", None, "kwallet6"), ("basic", "kwallet6", None, "basic"), ]) def test_desktop_environment_precedence(monkeypatch, explicit, configured, detected, expected): _clear_keychain_env(monkeypatch) monkeypatch.delenv('ELECTRON_OZONE_PLATFORM_HINT', raising=False) monkeypatch.setattr('hermes_cli.config.load_config', lambda: { 'desktop': {'password_store': configured, 'ozone_platform_hint': 'x11'}}) def detect(): assert configured == 'auto' and explicit is None return detected monkeypatch.setattr(main_desktop, '_detect_linux_password_store', detect) if explicit: monkeypatch.setenv('HERMES_DESKTOP_PASSWORD_STORE', explicit) env, _ = main_desktop._desktop_launch_env(_ns()) assert env['HERMES_DESKTOP_PASSWORD_STORE'] == expected assert env['ELECTRON_OZONE_PLATFORM_HINT'] == 'x11' monkeypatch.setenv('ELECTRON_OZONE_PLATFORM_HINT', 'wayland') assert main_desktop._desktop_launch_env(_ns())[0]['ELECTRON_OZONE_PLATFORM_HINT'] == 'wayland' @pytest.mark.platforms("linux") def test_gui_linux_source_launch_bridges_detected_password_store(tmp_path, monkeypatch): _clear_keychain_env(monkeypatch) root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) electron = root / "node_modules/electron" (electron / "dist").mkdir(parents=True) (electron / "path.txt").write_text("electron") (electron / "dist/electron").touch() ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ patch("hermes_cli.main_desktop._detect_linux_password_store", return_value="kwallet6"), \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns(source=True)) assert mock_run.call_args_list[-1].args[0] == [str(electron / "dist/electron"), "."] launch_env = mock_run.call_args_list[-1].kwargs["env"] assert launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "kwallet6" @pytest.mark.platforms("macos") def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): _clear_keychain_env(monkeypatch) root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), \ patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ patch("hermes_cli.main_desktop._detect_linux_password_store") as mock_detect, \ patch("hermes_cli.main.subprocess.run", side_effect=_pack_into_staging(root)) as mock_run, \ pytest.raises(SystemExit): cli_main.cmd_gui(_ns()) mock_detect.assert_not_called() launch_env = mock_run.call_args_list[-1].kwargs["env"] assert "HERMES_DESKTOP_PASSWORD_STORE" not in launch_env # --------------------------------------------------------------------------- # #58275: the Windows packaged launch must detach from the parent console # (Popen + windows_detach_flags, DEVNULL stdio, immediate exit 0) instead of # a console-inheriting subprocess.run that dies with the launching shell. # #59848: on the foreground platforms, Ctrl-C in the attached terminal must # exit cleanly instead of raising a KeyboardInterrupt traceback. # --------------------------------------------------------------------------- @pytest.mark.platforms("windows") def test_gui_win32_launches_detached_and_returns(tmp_path, monkeypatch): import hermes_cli._subprocess_compat as _subproc_compat root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) packaged_exe = _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.main.subprocess.Popen") as mock_popen, \ patch("hermes_cli.main.subprocess.run") as mock_run, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(skip_build=True)) # Parent returns cleanly so the user can close the launching shell. assert exc.value.code == 0 # The blocking, console-inheriting run() path must NOT be used for launch. mock_run.assert_not_called() # Detached spawn happened exactly once, targeting the packaged exe with the # Windows detach creationflags and fully severed stdio. mock_popen.assert_called_once() call = mock_popen.call_args assert call.args[0][0] == str(packaged_exe) assert call.kwargs["creationflags"] == _subproc_compat.windows_detach_flags() assert call.kwargs["stdin"] is subprocess.DEVNULL assert call.kwargs["stdout"] is subprocess.DEVNULL assert call.kwargs["stderr"] is subprocess.DEVNULL @pytest.mark.platforms("windows") def test_gui_win32_detach_falls_back_without_breakaway(tmp_path, monkeypatch): import hermes_cli._subprocess_compat as _subproc_compat root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) breakaway_denied = PermissionError("breakaway denied") breakaway_denied.winerror = 5 with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.main.subprocess.Popen", side_effect=[breakaway_denied, None]) as mock_popen, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(skip_build=True)) assert exc.value.code == 0 assert mock_popen.call_count == 2 assert ( mock_popen.call_args_list[0].kwargs["creationflags"] == _subproc_compat.windows_detach_flags() ) assert ( mock_popen.call_args_list[1].kwargs["creationflags"] == _subproc_compat.windows_detach_flags_without_breakaway() ) @pytest.mark.platforms("windows") def test_gui_win32_detach_reraises_non_breakaway_oserror(tmp_path, monkeypatch): root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) spawn_error = OSError("The system cannot find the file specified") spawn_error.winerror = 2 # ERROR_FILE_NOT_FOUND — unrelated to breakaway. with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.main.subprocess.Popen", side_effect=[spawn_error, None]) as mock_popen, \ pytest.raises(OSError) as exc: cli_main.cmd_gui(_ns(skip_build=True)) assert exc.value.winerror == 2 # Only the first (breakaway) attempt ran; no doomed retry masked the error. assert mock_popen.call_count == 1 @pytest.mark.platforms("macos", "linux") def test_gui_foreground_launch_ctrl_c_exits_cleanly(tmp_path, monkeypatch, capsys): """Ctrl-C during the attached launch is a clean close, not a traceback (#59848). On the foreground platforms the launcher intentionally stays attached to the Electron child; a KeyboardInterrupt raised through subprocess.run must exit 0 with a short message instead of the raw traceback the reporter saw (which, unhandled, aborts the whole CLI process). """ root = _make_desktop_tree(tmp_path) monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) # Patching hermes_cli.main.subprocess.run swaps the shared stdlib module's # attribute, so EVERY subprocess.run in the process raises. Three # best-effort pre-launch paths call it BEFORE the attached launch — outside # the KeyboardInterrupt handler under test — so neutralize each the way the # other foreground tests do: the Linux password-store detection (its # org.freedesktop.secrets D-Bus ping — contextlib.suppress(Exception) # cannot swallow the KeyboardInterrupt, which then aborts the whole pytest # session), the Linux desktop-entry install (its refresh_desktop_databases # probe) and the sandbox fixup's `unshare` user-namespace probe. monkeypatch.setattr(main_desktop, "_detect_linux_password_store", lambda: None) monkeypatch.setattr(main_desktop, "_register_linux_desktop_entry", lambda **kw: None) monkeypatch.setattr(main_desktop, "_desktop_linux_userns_sandbox_available", lambda: True) packaged_exe = _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) def _interrupted_launch(*call_args, **kwargs): raise KeyboardInterrupt() with patch("hermes_cli.source_build.prepare_source_dependencies", return_value=ok), \ patch("hermes_cli.main_desktop._desktop_build_needed", return_value=False), \ patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), \ patch("hermes_cli.config.load_config", return_value={}), \ patch("hermes_cli.main.subprocess.run", side_effect=_interrupted_launch) as mock_run, \ patch("hermes_cli.main.subprocess.Popen") as mock_popen, \ pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(skip_build=True)) assert exc.value.code == 0 mock_popen.assert_not_called() assert mock_run.call_count == 1 assert mock_run.call_args.args[0][0] == str(packaged_exe) assert "closed" in capsys.readouterr().out.lower() # --------------------------------------------------------------------------- # #86443: stage-and-swap — a failed Desktop rebuild must never remove the # working app. electron-builder packs IN PLACE (before-pack.mjs wipes # release/ first), so cmd_gui now packs into a staging dir and only # renames it over release/ after the staged result verifies. # --------------------------------------------------------------------------- def _gui_build_patches(root: Path, run_side_effect): return [ patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), # Staging doubles are text; the PE-validation suite owns real binaries. patch("hermes_cli.main_desktop._desktop_exe_integrity_error", return_value=None), patch("hermes_cli.source_build.prepare_source_dependencies", return_value=subprocess.CompletedProcess(["npm", "ci"], 0)), patch("hermes_cli.main_desktop._desktop_build_needed", return_value=True), patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), patch("hermes_cli.main_desktop._register_linux_desktop_entry"), patch("hermes_cli.main_desktop._stop_desktop_processes_locking_build", return_value=[]), patch("hermes_cli.main.subprocess.run", side_effect=run_side_effect), ] def test_swap_staged_desktop_app_promotes_staged_tree_and_drops_previous(tmp_path): root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") staging = main_desktop._desktop_staging_dir(desktop_dir) staged_exe = staging / _packaged_exe_rel() staged_exe.parent.mkdir(parents=True) staged_exe.write_text("new", encoding="utf-8") promoted = main_desktop._swap_staged_desktop_app(desktop_dir, staging) assert promoted == live_exe assert live_exe.read_text(encoding="utf-8") == "new" assert not staging.exists() assert sorted(p.name for p in (desktop_dir / "release").iterdir()) == [_packaged_exe_rel().parts[0]] def test_swap_staged_desktop_app_without_staged_exe_keeps_live_app(tmp_path): """Zero-exit pack that produced nothing: live app untouched, staging gone.""" root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") staging = main_desktop._desktop_staging_dir(desktop_dir) (staging / "linux-unpacked" / "resources").mkdir(parents=True) # partial tree, no exe assert main_desktop._swap_staged_desktop_app(desktop_dir, staging) is None assert live_exe.read_text(encoding="utf-8") == "old" assert not staging.exists() def test_swap_staged_desktop_app_rolls_back_when_second_rename_fails(tmp_path, monkeypatch): root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") staging = main_desktop._desktop_staging_dir(desktop_dir) staged_exe = staging / _packaged_exe_rel() staged_exe.parent.mkdir(parents=True) staged_exe.write_text("new", encoding="utf-8") real_rename = cli_main.os.rename calls = {"n": 0} def flaky_rename(src, dst): calls["n"] += 1 if calls["n"] == 2: # staged → live raise OSError("EXDEV simulated") return real_rename(src, dst) monkeypatch.setattr(cli_main.os, "rename", flaky_rename) assert main_desktop._swap_staged_desktop_app(desktop_dir, staging) is None assert live_exe.read_text(encoding="utf-8") == "old" assert not (live_exe.parent.parent / (live_exe.parent.name + ".previous")).exists() def test_swap_staged_desktop_app_stops_live_renderer_before_rename(tmp_path): """#109643: a renderer alive through the promotion rename keeps fetching its old hashed chunks from disk and dies on the next lazy import — the swap must ask for running desktop processes to stop on EVERY platform.""" root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") staging = main_desktop._desktop_staging_dir(desktop_dir) staged_exe = staging / _packaged_exe_rel() staged_exe.parent.mkdir(parents=True) staged_exe.write_text("new", encoding="utf-8") with patch("hermes_cli.main_desktop._stop_desktop_processes_locking_build", return_value=[4321]) as stop: promoted = main_desktop._swap_staged_desktop_app(desktop_dir, staging) assert promoted == live_exe stop.assert_called_once_with(desktop_dir, also_posix=True) def test_stop_desktop_processes_locking_build_posix_swap_bypasses_early_return(tmp_path, monkeypatch): """#109643: also_posix=True must run the scan on POSIX (the default pack-time call stays Windows-only — the staging pack never touches the live tree).""" root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") class _FakeProc: def __init__(self, pid, exe): self.info = {"pid": pid, "exe": exe} self.pid = pid def terminate(self): return None target = _FakeProc(100, str(live_exe)) outsider = _FakeProc(200, "/usr/bin/unrelated") class _FakePsutil: @staticmethod def process_iter(attrs): return [target, outsider] @staticmethod def wait_procs(victims, timeout=5): return [], [] monkeypatch.setitem(sys.modules, "psutil", _FakePsutil) assert main_desktop._stop_desktop_processes_locking_build(desktop_dir) == [] assert main_desktop._stop_desktop_processes_locking_build(desktop_dir, also_posix=True) == [100] def _stop_spares_the_desktop_driving_this_update(tmp_path, monkeypatch, *, also_posix): """A Desktop's own process tree runs this update (a historical Desktop's piped `hermes update` child, or the launch-time tail its backend runs). Stopping that Desktop kills the update with it. Its renderer/GPU/zygote helpers run the same exe but are not our ancestors; stopping them leaves a main process that can neither draw nor quit. Only an unrelated Desktop from the same release tree is stopped.""" root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") class _FakeProc: def __init__(self, pid, exe, children=()): self.info = {"pid": pid, "exe": exe} self.pid = pid self._children = list(children) def exe(self): return self.info["exe"] def children(self, recursive=False): assert recursive return self._children def terminate(self): return None renderer = _FakeProc(101, str(live_exe)) gpu = _FakeProc(102, str(live_exe)) driver = _FakeProc(100, str(live_exe), children=[renderer, gpu]) # A non-Desktop ancestor's tree (think init) must not spare everything. shell = _FakeProc(1, "/usr/bin/bash", children=[driver, renderer, gpu, _FakeProc(300, str(live_exe))]) other = shell._children[-1] class _FakePsutil: @staticmethod def Process(pid): assert pid == os.getpid() return types.SimpleNamespace(parents=lambda: [driver, shell]) @staticmethod def process_iter(attrs): return [driver, renderer, gpu, other] @staticmethod def wait_procs(victims, timeout=5): return [], [] monkeypatch.setitem(sys.modules, "psutil", _FakePsutil) assert main_desktop._stop_desktop_processes_locking_build(desktop_dir, also_posix=also_posix) == [300] @pytest.mark.platforms("posix") def test_posix_swap_spares_the_desktop_driving_this_update(tmp_path, monkeypatch): """A historical Desktop runs `hermes update` as a piped child and relaunches itself afterwards; stopping it breaks the update's stdout (EPIPE).""" _stop_spares_the_desktop_driving_this_update(tmp_path, monkeypatch, also_posix=True) @pytest.mark.platforms("windows") @pytest.mark.parametrize("also_posix", [False, True]) def test_windows_stop_spares_the_desktop_driving_this_update(tmp_path, monkeypatch, also_posix): """A Desktop's own backend runs the launch-time update tail; stopping that Desktop killed the tail before it could clear its markers, so every launch repeated it (#123499). The pack-time call and the swap alike spare it.""" _stop_spares_the_desktop_driving_this_update(tmp_path, monkeypatch, also_posix=also_posix) @pytest.mark.platforms("windows") def test_windows_build_under_its_own_desktop_skips_instead_of_killing_it(tmp_path, monkeypatch, capsys): """#123499: a Desktop's backend runs the interrupted-update tail at launch. Packing there can only end in a promotion the Desktop's exe lock refuses, and stopping that Desktop kills the tail first. The build is skipped, so the tail finishes and clears its markers.""" desktop_dir = _make_desktop_tree(tmp_path) / "apps" / "desktop" live_exe = desktop_dir / "release" / _packaged_exe_rel() live_exe.parent.mkdir(parents=True) live_exe.write_text("old", encoding="utf-8") backend = types.SimpleNamespace(pid=40, exe=lambda: str(tmp_path / "python.exe")) desktop = types.SimpleNamespace(pid=41, exe=lambda: str(live_exe)) monkeypatch.setitem(sys.modules, "psutil", types.SimpleNamespace( Process=lambda pid: types.SimpleNamespace(parents=lambda: [backend, desktop]))) monkeypatch.setattr(main_desktop, "_stop_desktop_processes_locking_build", lambda *a, **k: pytest.fail("must not stop the Desktop running this build")) monkeypatch.setattr("pm.progress.run_contained", lambda *a, **k: pytest.fail("must not pack")) assert main_desktop.build_prepared_desktop(desktop_dir, source_mode=False, npm="npm", env={}) is None assert "pid 41" in capsys.readouterr().out assert not list(desktop_dir.glob(f"{main_desktop._DESKTOP_STAGING_PREFIX}*")) def test_gui_failed_pack_leaves_previous_app_untouched(tmp_path, monkeypatch, capsys): """Every pack attempt fails → the pre-existing app is exactly as it was, no staging dir remains, exit is non-zero.""" root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) live_exe = _make_packaged_executable(root, monkeypatch) live_exe.write_text("good build", encoding="utf-8") monkeypatch.setenv("ELECTRON_MIRROR", "https://example.test/electron/") def failing_pack(cmd, **kwargs): if cmd[1:3] != ["run", "builder"]: return subprocess.CompletedProcess(cmd, 0) # Mimic before-pack.mjs wiping appOutDir inside the OUTPUT dir it was # given, then dying (corrupt Electron zip → ENOENT on rename). out = _staging_dir_from(cmd) / _packaged_exe_rel().parts[0] out.mkdir(parents=True, exist_ok=True) (out / "resources").mkdir(exist_ok=True) raise subprocess.CalledProcessError(1, cmd) patches = _gui_build_patches(root, failing_pack) for p in patches: p.start() try: with pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(build_only=True)) finally: for p in patches: p.stop() assert exc.value.code == 1 assert live_exe.read_text(encoding="utf-8") == "good build" assert not list(desktop_dir.glob(".staging-*")) assert not list((desktop_dir / "release").glob("*.previous")) def test_gui_successful_pack_swaps_new_app_into_release(tmp_path, monkeypatch): root = _make_desktop_tree(tmp_path) monkeypatch.setattr(main_desktop, "_desktop_exe_integrity_error", lambda exe: None) desktop_dir = root / "apps" / "desktop" monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) live_exe = _make_packaged_executable(root, monkeypatch) live_exe.write_text("old build", encoding="utf-8") patches = _gui_build_patches(root, _pack_into_staging(root, content="new build")) for p in patches: p.start() try: cli_main.cmd_gui(_ns(build_only=True)) finally: for p in patches: p.stop() assert live_exe.read_text(encoding="utf-8") == "new build" assert not list(desktop_dir.glob(".staging-*")) assert not list((desktop_dir / "release").glob("*.previous")) def test_gui_zero_exit_pack_without_artifact_keeps_previous_app(tmp_path, monkeypatch, capsys): root = _make_desktop_tree(tmp_path) desktop_dir = root / "apps" / "desktop" monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) live_exe = _make_packaged_executable(root, monkeypatch) live_exe.write_text("good build", encoding="utf-8") def empty_pack(cmd, **kwargs): if cmd[1:3] == ["run", "builder"]: _staging_dir_from(cmd).mkdir(parents=True, exist_ok=True) return subprocess.CompletedProcess(cmd, 0) patches = _gui_build_patches(root, empty_pack) for p in patches: p.start() try: with pytest.raises(SystemExit) as exc: cli_main.cmd_gui(_ns(build_only=True)) finally: for p in patches: p.stop() assert exc.value.code == 1 assert live_exe.read_text(encoding="utf-8") == "good build" assert not list(desktop_dir.glob(".staging-*"))