diff --git a/apps/desktop/electron/bootstrap-platform.test.ts b/apps/desktop/electron/bootstrap-platform.test.ts index 9ba60314e65f4..4cc206813e575 100644 --- a/apps/desktop/electron/bootstrap-platform.test.ts +++ b/apps/desktop/electron/bootstrap-platform.test.ts @@ -6,7 +6,8 @@ import { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, - isWslEnvironment + isWslEnvironment, + resolveLinuxPasswordStore } from './bootstrap-platform' test('isWslEnvironment detects WSL2 env vars on linux', () => { @@ -84,3 +85,41 @@ test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both wa null ) }) + +test('resolveLinuxPasswordStore applies known backends on linux', () => { + for (const store of ['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6', 'basic']) { + assert.deepEqual( + resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: store }, platform: 'linux' }), + { store, warning: null } + ) + } +}) + +test('resolveLinuxPasswordStore is a no-op when the env var is unset', () => { + assert.deepEqual(resolveLinuxPasswordStore({ env: {}, platform: 'linux' }), { store: null, warning: null }) + assert.deepEqual( + resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: ' ' }, platform: 'linux' }), + { store: null, warning: null } + ) +}) + +test('resolveLinuxPasswordStore ignores the env var off linux', () => { + assert.deepEqual( + resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: 'gnome-libsecret' }, platform: 'darwin' }), + { store: null, warning: null } + ) + assert.deepEqual( + resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: 'kwallet6' }, platform: 'win32' }), + { store: null, warning: null } + ) +}) + +test('resolveLinuxPasswordStore warns on unknown values instead of applying them', () => { + const result = resolveLinuxPasswordStore({ + env: { HERMES_DESKTOP_PASSWORD_STORE: 'keychain-of-wonders' }, + platform: 'linux' + }) + + assert.equal(result.store, null) + assert.match(String(result.warning), /keychain-of-wonders/) +}) diff --git a/apps/desktop/electron/bootstrap-platform.ts b/apps/desktop/electron/bootstrap-platform.ts index 066616c496e2b..34ba0e76b7a25 100644 --- a/apps/desktop/electron/bootstrap-platform.ts +++ b/apps/desktop/electron/bootstrap-platform.ts @@ -107,4 +107,44 @@ function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: Node return null } -export { bundledRuntimeImportCheck, detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } +const LINUX_PASSWORD_STORES = new Set(['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6', 'basic']) + +/** + * Resolve the Chromium `--password-store` switch for Linux safeStorage. + * + * Without the switch Chromium often fails to pick a keychain backend when the + * app is launched outside a full desktop session, safeStorage reports + * encryption as unavailable, and hardening.ts refuses to persist remote + * gateway tokens. The `hermes desktop` launcher detects the session keychain + * (or reads `desktop.password_store` from config.yaml) and bridges the value + * in via HERMES_DESKTOP_PASSWORD_STORE. + * + * Returns `{ store, warning }`: `store` is the validated backend to apply (or + * null to leave Chromium's default), `warning` is a message to log for + * unrecognized values. Pure + dependency-free so it can be unit-tested and + * called before app ready. + */ +function resolveLinuxPasswordStore(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + + const requested = String(env.HERMES_DESKTOP_PASSWORD_STORE || '').trim() + + if (platform !== 'linux' || !requested) { + return { store: null, warning: null } + } + + if (!LINUX_PASSWORD_STORES.has(requested)) { + return { store: null, warning: `ignoring unknown HERMES_DESKTOP_PASSWORD_STORE value: ${requested}` } + } + + return { store: requested, warning: null } +} + +export { + bundledRuntimeImportCheck, + detectRemoteDisplay, + isWindowsBinaryPathInWsl, + isWslEnvironment, + resolveLinuxPasswordStore +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7c294f3307d8e..23ad06c89bdf9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -46,7 +46,7 @@ import { } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' import { shouldLatchBackendStartFailure, shouldLatchRemoteReauthFailure } from './backend-start-failure' -import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' +import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment, resolveLinuxPasswordStore } from './bootstrap-platform' import { decideBootstrapRepair } from './bootstrap-repair-guard' import { runBootstrap } from './bootstrap-runner' import { applyConnectionChange, resolveTerminalConnection } from './connection-apply' @@ -334,6 +334,22 @@ if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) { console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration') } +// Linux: point Chromium at the session's keychain backend so safeStorage can +// encrypt remote gateway tokens (hardening.ts refuses to persist them without +// it). The value arrives via HERMES_DESKTOP_PASSWORD_STORE, bridged by the +// `hermes desktop` launcher from detection or `desktop.password_store` in +// config.yaml. Must run before app `ready` — the switch only applies pre-launch. +const PASSWORD_STORE = resolveLinuxPasswordStore() + +if (PASSWORD_STORE.warning) { + console.warn(`[hermes] ${PASSWORD_STORE.warning}`) +} + +if (PASSWORD_STORE.store) { + app.commandLine.appendSwitch('password-store', PASSWORD_STORE.store) + console.log(`[hermes] using password-store backend: ${PASSWORD_STORE.store}`) +} + // Windows sandbox / GPU breakpoint crash recovery (#38216). // // Some hosts (AMD RX 6000 drivers, orphan AppContainer SIDs under %LOCALAPPDATA%, diff --git a/contributors/emails/hfsearcy@gmail.com b/contributors/emails/hfsearcy@gmail.com new file mode 100644 index 0000000000000..3296956c59481 --- /dev/null +++ b/contributors/emails/hfsearcy@gmail.com @@ -0,0 +1 @@ +hsearcy diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 423cb66f1b8b8..05b621db293af 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -3221,6 +3221,17 @@ DEFAULT_CONFIG = { # false - always keep GPU acceleration on, even over a remote display. # Bridged to the HERMES_DESKTOP_DISABLE_GPU env var the Electron app reads. "disable_gpu": "auto", + # Linux keychain backend for secure token storage (Chromium's + # --password-store switch, which safeStorage needs before it can + # encrypt remote gateway tokens): + # "auto" - detect the session keychain: KWallet via KDE session env + # vars, GNOME Keyring / any org.freedesktop.secrets + # provider (e.g. KeePassXC) via D-Bus (default). + # "gnome-libsecret" / "kwallet" / "kwallet5" / "kwallet6" / "basic" + # - force a specific backend ("basic" = unencrypted store). + # Ignored on macOS/Windows. Bridged to the HERMES_DESKTOP_PASSWORD_STORE + # env var the Electron app reads, so an explicit env var still wins. + "password_store": "auto", # macOS only: optional persistent code-signing identity (a cert in the # login keychain — a self-signed "Code Signing" cert from Keychain # Access works; no Apple Developer account needed) used to re-sign diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e81b57a493cd5..9d77124909668 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7002,23 +7002,69 @@ def _desktop_linux_sandbox_fixup(packaged_executable: Path) -> bool: return True -def _desktop_launch_options() -> tuple[list[str], str]: +_LINUX_PASSWORD_STORES = frozenset({"gnome-libsecret", "kwallet", "kwallet5", "kwallet6", "basic"}) + + +def _detect_linux_password_store() -> str | None: + """Detect the Chromium password-store backend for the current Linux session. + + Electron's safeStorage only reports encryption as available when Chromium + selects the right keychain backend, and Chromium's own detection routinely + fails under `hermes desktop` because the launcher environment doesn't look + like a full desktop session. Probe order: KDE session env vars, GNOME + Keyring's control socket, then a D-Bus ping of org.freedesktop.secrets + (covers any Secret Service implementation, e.g. KeePassXC). Returns None + when no keychain daemon is reachable. + """ + kde_version = os.environ.get("KDE_SESSION_VERSION", "").strip() + if kde_version == "6": + return "kwallet6" + if kde_version == "5": + return "kwallet5" + if kde_version: + return "kwallet" + if os.environ.get("KDE_FULL_SESSION"): + return "kwallet" + if os.environ.get("GNOME_KEYRING_CONTROL"): + return "gnome-libsecret" + try: + result = subprocess.run( + [ + "dbus-send", "--session", "--print-reply", "--reply-timeout=2000", + "--dest=org.freedesktop.secrets", + "/org/freedesktop/secrets", + "org.freedesktop.DBus.Peer.Ping", + ], + capture_output=True, + timeout=5, + ) + if result.returncode == 0: + return "gnome-libsecret" + except Exception: + pass + return None + + +def _desktop_launch_options() -> tuple[list[str], str, str]: """Read `desktop.*` launch options from config.yaml. - Returns ``(electron_flags, disable_gpu)`` where ``electron_flags`` is a list - of extra Electron CLI flags and ``disable_gpu`` is one of "auto"/"1"/"0" - (normalized for the HERMES_DESKTOP_DISABLE_GPU env var the Electron app - reads). Best-effort: any config error yields the safe defaults - ``([], "auto")`` so a malformed config never blocks the launch. + Returns ``(electron_flags, disable_gpu, password_store)`` where + ``electron_flags`` is a list of extra Electron CLI flags, ``disable_gpu`` + is one of "auto"/"1"/"0" (normalized for the HERMES_DESKTOP_DISABLE_GPU + env var the Electron app reads), and ``password_store`` is "auto" or one + of the Chromium password-store backends (unknown values normalize to + "auto"). Best-effort: any config error yields the safe defaults + ``([], "auto", "auto")`` so a malformed config never blocks the launch. """ flags: list[str] = [] disable_gpu = "auto" + password_store = "auto" try: from hermes_cli.config import load_config desktop_cfg = (load_config() or {}).get("desktop") or {} except Exception: - return flags, disable_gpu + return flags, disable_gpu, password_store raw_flags = desktop_cfg.get("electron_flags") if isinstance(raw_flags, str): @@ -7037,7 +7083,13 @@ def _desktop_launch_options() -> tuple[list[str], str]: disable_gpu = "0" else: disable_gpu = "auto" - return flags, disable_gpu + + raw_store = desktop_cfg.get("password_store", "auto") + if isinstance(raw_store, str): + low_store = raw_store.strip().lower() + if low_store in _LINUX_PASSWORD_STORES: + password_store = low_store + return flags, disable_gpu, password_store def _register_linux_desktop_entry() -> None: @@ -7091,10 +7143,25 @@ def cmd_gui(args: argparse.Namespace): # `desktop.disable_gpu`). The GPU policy is bridged to the env var the # Electron app already reads; an explicit env var still wins over config so # `HERMES_DESKTOP_DISABLE_GPU=... hermes desktop` keeps working. - config_electron_flags, config_disable_gpu = _desktop_launch_options() + config_electron_flags, config_disable_gpu, config_password_store = _desktop_launch_options() if config_disable_gpu != "auto" and "HERMES_DESKTOP_DISABLE_GPU" not in os.environ: env["HERMES_DESKTOP_DISABLE_GPU"] = config_disable_gpu + # Linux keychain backend for safeStorage (`desktop.password_store`). + # Chromium needs the --password-store switch to pick the right keychain; + # without it safeStorage.isEncryptionAvailable() is often false and the + # desktop app refuses to persist remote gateway tokens. Config wins over + # detection; an explicit env var wins over both so + # `HERMES_DESKTOP_PASSWORD_STORE=... hermes desktop` keeps working. + if sys.platform == "linux" and "HERMES_DESKTOP_PASSWORD_STORE" not in os.environ: + password_store = ( + config_password_store + if config_password_store != "auto" + else _detect_linux_password_store() + ) + if password_store: + env["HERMES_DESKTOP_PASSWORD_STORE"] = password_store + source_mode = getattr(args, "source", False) skip_build = getattr(args, "skip_build", False) force_build = getattr(args, "force_build", False) diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 96b968c5f96bd..6d310bfd6aab6 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -31,6 +31,22 @@ def _isolate_xdg_data_home(tmp_path, monkeypatch): 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, @@ -557,3 +573,209 @@ def test_gui_skips_desktop_entry_off_linux(tmp_path, monkeypatch): cli_main.cmd_gui(_ns()) assert exc.value.code == 0 + +@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 = cli_main._desktop_launch_options() + assert store == expected + + +# --- 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 cli_main._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 cli_main._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 cli_main._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 cli_main._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 cli_main._detect_linux_password_store() is None + with patch("hermes_cli.main.subprocess.run", side_effect=FileNotFoundError): + assert cli_main._detect_linux_password_store() is None + + +@pytest.mark.linux_only +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.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main._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._detect_linux_password_store", return_value="gnome-libsecret"), \ + patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) 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.linux_only +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) + + ok = subprocess.CompletedProcess([], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.config.load_config", return_value={}), \ + patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ + patch("hermes_cli.main._detect_linux_password_store", return_value="kwallet6"), \ + patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \ + pytest.raises(SystemExit): + cli_main.cmd_gui(_ns(source=True)) + + assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."] + launch_env = mock_run.call_args_list[1].kwargs["env"] + assert launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "kwallet6" + + +@pytest.mark.linux_only +def test_gui_config_password_store_skips_detection(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) + cfg = {"desktop": {"password_store": "kwallet6"}} + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.config.load_config", return_value=cfg), \ + patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ + patch("hermes_cli.main._detect_linux_password_store") as mock_detect, \ + patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) 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 launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "kwallet6" + + +@pytest.mark.linux_only +def test_gui_explicit_password_store_env_wins_over_config_and_detection(tmp_path, monkeypatch): + _clear_keychain_env(monkeypatch) + monkeypatch.setenv("HERMES_DESKTOP_PASSWORD_STORE", "basic") + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch) + + ok = subprocess.CompletedProcess([], 0) + cfg = {"desktop": {"password_store": "kwallet6"}} + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \ + patch("hermes_cli.config.load_config", return_value=cfg), \ + patch("hermes_cli.linux_desktop_entry.install_desktop_entry", return_value=None), \ + patch("hermes_cli.main._detect_linux_password_store") as mock_detect, \ + patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) 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 launch_env["HERMES_DESKTOP_PASSWORD_STORE"] == "basic" + + +@pytest.mark.macos_only +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.main._run_npm_install_deterministic", return_value=ok), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.main._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._detect_linux_password_store") as mock_detect, \ + patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) 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