"""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,
close_preview=False,
)
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())
# This pins the LAUNCH contract; the real codesign signature probe is out of scope here.
monkeypatch.setattr(cli_main.shutil, "which", lambda name: None)
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]
def test_gui_close_preview_flag_forwards_to_packaged_exe(tmp_path, monkeypatch):
"""`hermes desktop --close-preview` must reach the Electron binary so the
running instance's single-instance handler can close a fullscreened
preview pane the user cannot otherwise escape (#97213)."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Patching hermes_cli.main_desktop.subprocess.run swaps the shared stdlib
# module's attribute, so the desktop-entry install's interpreter probe
# (` -I -c 'import hermes_cli.main'`) would be captured as a
# launch too. Neutralize the entry install the way the other foreground
# tests do; this test is only about the flag reaching the packaged exe.
monkeypatch.setattr(main_desktop, "_register_linux_desktop_entry", lambda **kw: None)
packaged_exe = _make_packaged_executable(root, monkeypatch)
launched: list[list[str]] = []
def fake_run(cmd, **kw):
launched.append(list(cmd))
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_desktop.subprocess.run", side_effect=fake_run), \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(close_preview=True))
assert exc.value.code == 0
if sys.platform.startswith("linux"):
# A present non-setuid chrome-sandbox makes the launcher pass
# --disable-setuid-sandbox before the forwarded flag.
assert launched == [[str(packaged_exe), "--disable-setuid-sandbox", "--close-preview"]]
else:
assert launched == [[str(packaged_exe), "--close-preview"]]
@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")
framework = app / "Contents" / "Frameworks" / "Electron Framework.framework"
_write_info_plist(framework, "com.github.Electron.framework")
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
def test_desktop_macos_local_codesign_preserves_framework_entitlements(tmp_path, monkeypatch):
"""Hardened runtime frameworks must retain Electron's JIT entitlements."""
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
framework = app / "Contents" / "Frameworks" / "Electron Framework.framework"
framework_call = next(call for call in calls if call[-1] == str(framework))
assert framework_call[5:7] == ["--options", "runtime"]
assert framework_call[7:9] == [
"--entitlements",
str(desktop_dir / "electron" / "entitlements.mac.inherit.plist"),
]
# --- 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_configured_identity_failure_never_falls_back_to_adhoc(tmp_path, monkeypatch, capsys):
"""A configured identity failing over a PUBLISHER-signed install must not degrade (#123748).
Replacing a Team-ID installation with an ad-hoc or locally signed build swaps
the signature anchor the keychain ACLs and TCC grants are bound against,
orphaning safeStorage credentials. The fixup refuses, keeps the existing
bundle, and names the remedy. (Over a locally-signed install the same
failure retries identifier-pinned ad-hoc instead — covered by
``test_relaunchable_fixup_failed_identity_uses_pinned_adhoc_before_legacy``.)
``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]] = []
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: "Hermes Local Signing")
# The bundle being re-signed in place is publisher-signed (Team ID): a degraded
# replacement would orphan its keychain ACLs and TCC grants.
monkeypatch.setattr(
main_desktop, "_macos_signature_summary",
lambda codesign, app: {"team": "TEAMID123", "identifier": "com.nousresearch.hermes",
"verified": True},
)
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
# The old behavior fell through to the legacy deep ad-hoc re-sign — must not happen.
assert not any("--deep" in c for c in calls)
assert not any("delete-generic-password" in c for c in calls)
out = capsys.readouterr().out
assert "publisher-signed" in out and "no ad-hoc fallback" in out
@pytest.mark.platforms("macos")
def test_relaunchable_fixup_configured_identity_success_still_signs(tmp_path, monkeypatch):
"""A configured identity that signs successfully keeps the working path (#123748)."""
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: "Hermes Local Signing")
def fake_local_codesign(app, *, desktop_dir, identity):
calls.append(["local-codesign", identity])
return True
monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", fake_local_codesign)
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 ["local-codesign", "Hermes Local Signing"] in calls
assert not any("--deep" in c for c in calls)
assert not any("delete-generic-password" in c for c in calls)
@pytest.mark.platforms("macos")
def test_promote_staged_desktop_app_refuses_an_unsigned_staging(tmp_path, monkeypatch, capsys):
"""A fixup refusal must stop the promotion, not just log (#123748 review).
``_promote_staged_desktop_app`` used to call the fixup for its side effect and
discard the False, so a configured identity that failed still promoted a staged
bundle whose signature was never established over the live app. The refusal now
follows the same previous-app-kept error path as the integrity check.
"""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
staging = desktop_dir / "release" / ".staging-update"
exe = _make_packaged_executable(root, monkeypatch)
# Re-create the same layout inside the staging dir the promoter scans.
staged_exe = staging / exe.relative_to(exe.parents[4])
staged_exe.parent.mkdir(parents=True, exist_ok=True)
staged_exe.write_bytes(exe.read_bytes())
swapped: list[Path] = []
def fake_fixup(dir_, *, publisher_signing_configured=None, release_dir=None):
return False # the refusal under test
def fake_swap(dir_, st_):
swapped.append(st_)
return None
monkeypatch.setattr(main_desktop, "_desktop_macos_relaunchable_fixup", fake_fixup)
monkeypatch.setattr(main_desktop, "_swap_staged_desktop_app", fake_swap)
with pytest.raises(RuntimeError, match="previous desktop app"):
main_desktop._promote_staged_desktop_app(desktop_dir, staging)
assert not swapped, "the live app must not be swapped when signing was refused"
assert not staging.exists(), "the refused staging is discarded"
out = capsys.readouterr().out
assert "not promoting" in out
@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)
def test_relaunchable_fixup_failed_identity_uses_pinned_adhoc_before_legacy(tmp_path, monkeypatch):
"""A locked keychain must not skip identifier-pinned ad-hoc for cdhash-only.
``desktop.macos_signing_identity`` fails over SSH (login keychain locked).
The next try is the same inside-out signer with ``identity="-"``, which
keeps the identifier designated requirement and entitlements. Legacy
``codesign --deep --sign -`` is only the last resort (#121857).
"""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes"
exe.parent.mkdir(parents=True)
exe.write_text("", encoding="utf-8")
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.delenv("CSC_LINK", raising=False)
monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False)
monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda _app: False)
monkeypatch.setattr(
main_desktop, "_desktop_macos_local_signing_identity", lambda: "Hermes Local Signing",
)
seen: list[str] = []
def fake_codesign(_app, *, desktop_dir, identity="-"):
seen.append(identity)
if identity != "-":
raise subprocess.CalledProcessError(1, ["codesign", "--sign", identity])
return True
calls: list[list[str]] = []
def fake_run(cmd, **kwargs):
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", fake_codesign)
monkeypatch.setattr(
main_desktop.shutil, "which",
lambda name: "/usr/bin/codesign" if name == "codesign" else None,
)
monkeypatch.setattr(main_desktop.subprocess, "run", fake_run)
assert main_desktop._desktop_macos_relaunchable_fixup(desktop_dir) is True
assert seen == ["Hermes Local Signing", "-"]
assert not any(cmd[:5] == ["/usr/bin/codesign", "--force", "--deep", "--sign", "-"] for cmd in calls)
def test_relaunchable_fixup_legacy_when_pinned_adhoc_also_fails(tmp_path, monkeypatch):
"""Legacy deep ad-hoc still runs when identifier-pinned signing fails too."""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes"
exe.parent.mkdir(parents=True)
exe.write_text("", encoding="utf-8")
app = exe.parents[2]
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.delenv("CSC_LINK", raising=False)
monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False)
monkeypatch.setattr(main_desktop, "_desktop_macos_has_valid_real_signature", lambda _app: False)
monkeypatch.setattr(
main_desktop, "_desktop_macos_local_signing_identity", lambda: "Hermes Local Signing",
)
seen: list[str] = []
def fake_codesign(_app, *, desktop_dir, identity="-"):
seen.append(identity)
raise subprocess.CalledProcessError(1, ["codesign", "--sign", identity])
calls: list[list[str]] = []
def fake_run(cmd, **kwargs):
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
monkeypatch.setattr(main_desktop, "_desktop_macos_local_codesign", fake_codesign)
monkeypatch.setattr(
main_desktop.shutil, "which",
lambda name: "/usr/bin/codesign" if name == "codesign" else None,
)
monkeypatch.setattr(main_desktop.subprocess, "run", fake_run)
assert main_desktop._desktop_macos_relaunchable_fixup(desktop_dir) is True
assert seen == ["Hermes Local Signing", "-"]
assert ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app)] 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-*"))