Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion apps/desktop/electron/bootstrap-platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment
isWslEnvironment,
resolveLinuxPasswordStore
} from './bootstrap-platform'

test('isWslEnvironment detects WSL2 env vars on linux', () => {
Expand Down Expand Up @@ -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/)
})
42 changes: 41 additions & 1 deletion apps/desktop/electron/bootstrap-platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
18 changes: 17 additions & 1 deletion apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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%,
Expand Down
1 change: 1 addition & 0 deletions contributors/emails/hfsearcy@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hsearcy
11 changes: 11 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -3221,6 +3221,17 @@
# 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
Expand Down
85 changes: 76 additions & 9 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading