From 66a2a4c15b8824cb3d31a72cbbfcdebed5aa4b99 Mon Sep 17 00:00:00 2001 From: Houston Searcy Date: Tue, 14 Jul 2026 10:24:15 -0400 Subject: [PATCH 1/3] feat(desktop): auto-detect Linux keychain backend for secure token storage On Linux, Electron's safeStorage requires the --password-store Chromium switch to select the correct keychain backend. Without it, isEncryptionAvailable() returns false, hardening.ts refuses to persist remote gateway tokens, and users are forced back to the HERMES_DESKTOP_REMOTE_URL / HERMES_DESKTOP_REMOTE_TOKEN env fallback. - hermes_cli/main.py: _detect_linux_password_store() probes 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). The result is bridged into the desktop subprocess env as HERMES_DESKTOP_PASSWORD_STORE for both source and packaged launches. - The user override lives in config.yaml (desktop.password_store, default "auto") rather than a new user-facing HERMES_* env var, per AGENTS.md. An explicit HERMES_DESKTOP_PASSWORD_STORE env var still wins over config and detection, matching desktop.disable_gpu semantics. - apps/desktop/electron/bootstrap-platform.ts: resolveLinuxPasswordStore() validates the bridged value; main.ts applies it via app.commandLine.appendSwitch('password-store', ...) before app ready. Unknown values log a warning and are skipped. - Tests: detector + bridging coverage (packaged and source launch paths, config override, env-var precedence, linux-only gating) in tests/hermes_cli/test_gui_command.py; resolver coverage in bootstrap-platform.test.ts (vitest electron project). Co-Authored-By: Claude Fable 5 --- .../electron/bootstrap-platform.test.ts | 41 +++- apps/desktop/electron/bootstrap-platform.ts | 42 +++- apps/desktop/electron/main.ts | 23 +- hermes_cli/config.py | 11 + hermes_cli/main.py | 85 ++++++- tests/hermes_cli/test_gui_command.py | 226 +++++++++++++++++- 6 files changed, 411 insertions(+), 17 deletions(-) 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 e89e369bacee5..79b401af53a78 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -34,7 +34,12 @@ import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' import { canImportHermesCli, verifyHermesCli } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' -import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' +import { + detectRemoteDisplay, + isWindowsBinaryPathInWsl, + isWslEnvironment, + resolveLinuxPasswordStore +} from './bootstrap-platform' import { runBootstrap } from './bootstrap-runner' import { authModeFromStatus, @@ -186,6 +191,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}`) +} + ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) // Keep the renderer running at full speed while the window is in the background diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b95b1c666a895..584d2a86a5c4f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3298,6 +3298,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", }, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 45ce0a7a8c264..73d68a21c263b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5585,23 +5585,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): @@ -5620,7 +5666,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 cmd_gui(args: argparse.Namespace): @@ -5655,10 +5707,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 6fcddcb9974b5..ae969ac3e8730 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -13,6 +13,22 @@ import pytest from hermes_cli import main as cli_main +@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, @@ -1013,15 +1029,16 @@ def test_force_adhoc_signing_respects_explicit_caller_flag(monkeypatch): def test_desktop_launch_options_defaults_when_no_config(): with patch("hermes_cli.config.load_config", return_value={}): - flags, gpu = cli_main._desktop_launch_options() + flags, gpu, store = cli_main._desktop_launch_options() assert flags == [] assert gpu == "auto" + assert store == "auto" def test_desktop_launch_options_reads_flags_list(): cfg = {"desktop": {"electron_flags": ["--ozone-platform=x11", "--disable-gpu"]}} with patch("hermes_cli.config.load_config", return_value=cfg): - flags, gpu = cli_main._desktop_launch_options() + flags, gpu, _ = cli_main._desktop_launch_options() assert flags == ["--ozone-platform=x11", "--disable-gpu"] assert gpu == "auto" @@ -1029,7 +1046,7 @@ def test_desktop_launch_options_reads_flags_list(): def test_desktop_launch_options_splits_flag_string(): cfg = {"desktop": {"electron_flags": "--ozone-platform=x11 --disable-gpu"}} with patch("hermes_cli.config.load_config", return_value=cfg): - flags, _ = cli_main._desktop_launch_options() + flags, _, _ = cli_main._desktop_launch_options() assert flags == ["--ozone-platform=x11", "--disable-gpu"] @@ -1047,12 +1064,211 @@ def test_desktop_launch_options_splits_flag_string(): def test_desktop_launch_options_normalizes_disable_gpu(raw, expected): cfg = {"desktop": {"disable_gpu": raw}} with patch("hermes_cli.config.load_config", return_value=cfg): - _, gpu = cli_main._desktop_launch_options() + _, gpu, _ = cli_main._desktop_launch_options() assert gpu == expected +@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 + + def test_desktop_launch_options_survives_config_error(): with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): - flags, gpu = cli_main._desktop_launch_options() + flags, gpu, store = cli_main._desktop_launch_options() assert flags == [] assert gpu == "auto" + assert store == "auto" + + +# --- 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 + + +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, platform="linux") + + 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.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" + + +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) + monkeypatch.setattr(cli_main.sys, "platform", "linux") + + 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.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" + + +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, platform="linux") + + 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.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" + + +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, platform="linux") + + 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.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" + + +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, platform="darwin") + + 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.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 From e3215cfbdce8eeec6fd0c3103c08e8947c9e3d7e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:07:51 -0700 Subject: [PATCH 2/3] chore: map hfsearcy@gmail.com -> hsearcy for contributor attribution --- contributors/emails/hfsearcy@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/hfsearcy@gmail.com 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 From 40712da40f153c82aff387fe63f585289c097dc3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:19:12 -0700 Subject: [PATCH 3/3] test: adapt #41236 password-store tests to real-host _make_packaged_executable Main's helper no longer takes a platform kwarg (real-host layout since the sys.platform-fake removal); mark the five password-store tests linux_only/ macos_only per the don't-fake-the-host policy, and stub the Linux desktop-entry registration those cmd_gui runs now reach. --- tests/hermes_cli/test_gui_command.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index cfa21e49c99d6..6d310bfd6aab6 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -648,11 +648,12 @@ def test_detect_linux_password_store_none_when_no_keychain(monkeypatch): 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, platform="linux") + _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) @@ -663,6 +664,7 @@ def test_gui_linux_packaged_launch_bridges_detected_password_store(tmp_path, mon 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): @@ -672,11 +674,11 @@ def test_gui_linux_packaged_launch_bridges_detected_password_store(tmp_path, mon 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) - monkeypatch.setattr(cli_main.sys, "platform", "linux") ok = subprocess.CompletedProcess([], 0) @@ -685,6 +687,7 @@ def test_gui_linux_source_launch_bridges_detected_password_store(tmp_path, monke 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): @@ -695,11 +698,12 @@ def test_gui_linux_source_launch_bridges_detected_password_store(tmp_path, monke 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, platform="linux") + _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) cfg = {"desktop": {"password_store": "kwallet6"}} @@ -711,6 +715,7 @@ def test_gui_config_password_store_skips_detection(tmp_path, monkeypatch): 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): @@ -721,12 +726,13 @@ def test_gui_config_password_store_skips_detection(tmp_path, monkeypatch): 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, platform="linux") + _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) cfg = {"desktop": {"password_store": "kwallet6"}} @@ -738,6 +744,7 @@ def test_gui_explicit_password_store_env_wins_over_config_and_detection(tmp_path 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): @@ -748,11 +755,12 @@ def test_gui_explicit_password_store_env_wins_over_config_and_detection(tmp_path 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, platform="darwin") + _make_packaged_executable(root, monkeypatch) ok = subprocess.CompletedProcess([], 0) @@ -762,6 +770,7 @@ def test_gui_password_store_bridge_is_linux_only(tmp_path, monkeypatch): 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):