From 7a4a74e30b4fd6904896348dbd29bf36551d1b57 Mon Sep 17 00:00:00 2001 From: smiley Date: Tue, 1 Sep 2026 11:59:10 +0300 Subject: [PATCH 1/2] fix: order passless.service after gpg-agent.socket Prevents passless from forking before SSH_AUTH_SOCK is backed by a running agent, which otherwise permanently breaks the pass backend's git push for the life of the process (a forked process's environment is a fixed snapshot at fork time and is never refreshed later, even once the agent becomes available). This is an ordering-only addition (no Wants=), so it is a no-op for anyone not running gpg-agent's SSH support. Fixes #467 --- contrib/systemd/passless.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/systemd/passless.service b/contrib/systemd/passless.service index dc0fd26a..fc60d7d8 100644 --- a/contrib/systemd/passless.service +++ b/contrib/systemd/passless.service @@ -1,7 +1,7 @@ [Unit] Description=Passless FIDO2 Software Authenticator Documentation=https://github.com/pando85/passless -After=network-online.target +After=network-online.target gpg-agent.socket Wants=network-online.target [Service] From 014244905a8b0bf10841f341fe703d902a3b34ad Mon Sep 17 00:00:00 2001 From: smiley Date: Wed, 2 Sep 2026 13:20:55 +0300 Subject: [PATCH 2/2] fix: resolve SSH_AUTH_SOCK per Git sync instead of unit ordering The pass backend's Git sync shells out via prs_lib, which inherits SSH_AUTH_SOCK from the process environment as-is. That environment is a fixed snapshot taken once at passless startup and never refreshed, so if the agent isn't up yet (or exports its socket into the systemd user environment only after passless has already started), push silently and permanently breaks for the life of the process. Rather than depending on ordering against one specific systemd unit (gpg-agent.socket), re-resolve a live agent socket fresh before every prepare/finalize call, checking systemd's user environment table, the inherited process environment, and gpgconf's gpg-agent SSH socket in turn. This is agnostic to which agent is in use (gpg-agent, ssh-agent, KeePassXC, 1Password, ...) and self-heals if the agent starts, restarts, or updates its socket after passless does. Fixes #467 --- cmd/passless/src/storage/pass/sync.rs | 82 +++++++++++++++++++++++++++ contrib/systemd/passless.service | 2 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/cmd/passless/src/storage/pass/sync.rs b/cmd/passless/src/storage/pass/sync.rs index 170dc1e0..4fd99b43 100644 --- a/cmd/passless/src/storage/pass/sync.rs +++ b/cmd/passless/src/storage/pass/sync.rs @@ -10,6 +10,7 @@ use log::{debug, warn}; use prs_lib::Store; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::{Arc, Mutex}; #[derive(Debug, Clone)] @@ -224,6 +225,7 @@ impl PassGitSync { fn prepare_store(store_path: &Path) -> Result<(), String> { debug!("Preparing password-store Git sync"); + ensure_ssh_auth_sock(); let store = Store::open(store_path.to_string_lossy().as_ref()) .map_err(|error| format!("failed to open store: {error:?}"))?; store @@ -234,6 +236,7 @@ impl PassGitSync { fn finalize_store(store_path: &Path, message: &str) -> Result<(), String> { debug!("Finalizing password-store Git sync: {message}"); + ensure_ssh_auth_sock(); let store = Store::open(store_path.to_string_lossy().as_ref()) .map_err(|error| format!("failed to open store: {error:?}"))?; store @@ -243,6 +246,85 @@ impl PassGitSync { } } +/// Ensure `SSH_AUTH_SOCK` points at a live agent socket before Git sync +/// shells out. `prs_lib` spawns `git` inheriting the process environment +/// as-is, so this re-resolves and overwrites it fresh on every prepare/ +/// finalize call rather than trusting whatever passless itself inherited +/// once at startup (see #467: that snapshot is never refreshed later, even +/// once the agent becomes available). +fn ensure_ssh_auth_sock() { + let Some(socket) = resolve_ssh_auth_sock() else { + return; + }; + // SAFETY: mutating the process environment races with any concurrent + // env read/write on another thread. passless's other std::env::var(_os) + // call sites (agent runtime dir, E2E test flags, test vendor/product + // IDs) are one-time reads of unrelated keys made at startup or from + // request handlers that never overlap with a pass Git sync, so this is + // safe in practice for passless's current call graph even though the + // type system cannot prove it. + unsafe { + std::env::set_var("SSH_AUTH_SOCK", socket); + } +} + +/// Resolve a live SSH agent socket for Git operations against the password +/// store. Sources are tried in order, each covering a different class of +/// agent (gpg-agent's SSH support, plain ssh-agent, KeePassXC, 1Password, +/// ...) without hard-coding a dependency on any single one. +fn resolve_ssh_auth_sock() -> Option { + systemd_user_environment_ssh_auth_sock() + .or_else(inherited_ssh_auth_sock) + .or_else(gpg_agent_ssh_socket) +} + +/// Query the systemd user manager's own environment table. Autostart +/// scripts for ssh-agent, KeePassXC, 1Password, etc. commonly update it +/// (via `systemctl --user import-environment` or +/// `dbus-update-activation-environment`) after passless.service has +/// already started, which is exactly the case a one-time startup snapshot +/// misses. This is the most distro/agent-agnostic source since it does not +/// depend on ordering against any specific systemd unit. +fn systemd_user_environment_ssh_auth_sock() -> Option { + let output = Command::new("systemctl") + .args(["--user", "show-environment"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let value = stdout + .lines() + .find_map(|line| line.strip_prefix("SSH_AUTH_SOCK="))?; + let path = PathBuf::from(value); + path.exists().then_some(path) +} + +/// Fall back to whatever passless's own process environment already has, +/// for setups without a systemd user manager (e.g. run from a login shell). +fn inherited_ssh_auth_sock() -> Option { + let path = PathBuf::from(std::env::var_os("SSH_AUTH_SOCK")?); + path.exists().then_some(path) +} + +/// Fall back to gpg-agent's configured SSH support socket. `gpgconf` +/// reports the configured path even before the agent has been contacted; +/// connecting to it triggers systemd socket activation where +/// `gpg-agent.socket` is managed that way, so this does not require the +/// agent to already be running. +fn gpg_agent_ssh_socket() -> Option { + let output = Command::new("gpgconf") + .args(["--list-dirs", "agent-ssh-socket"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); + path.exists().then_some(path) +} + #[cfg(test)] mod tests { use super::*; diff --git a/contrib/systemd/passless.service b/contrib/systemd/passless.service index fc60d7d8..dc0fd26a 100644 --- a/contrib/systemd/passless.service +++ b/contrib/systemd/passless.service @@ -1,7 +1,7 @@ [Unit] Description=Passless FIDO2 Software Authenticator Documentation=https://github.com/pando85/passless -After=network-online.target gpg-agent.socket +After=network-online.target Wants=network-online.target [Service]