Merge pull request #84903 from NousResearch/salv/41236
feat(desktop): auto-detect Linux keychain backend for secure token storage (salvage #41236)
This commit is contained in:
commit
0c3f60fe4f
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
hsearcy
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue