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 <noreply@anthropic.com>
This commit is contained in:
parent
46e87b14fd
commit
66a2a4c15b
|
|
@ -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/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue