tmux-ssh-agent-survival¶
Modules
Keep SSH agent forwarding alive across tmux reattaches — and across any
long-lived, detached process — by pinning SSH_AUTH_SOCK to a stable symlink
that every new login re-points at the current live forwarded agent socket.
The problem¶
When you ssh -A into a host, sshd creates a fresh agent socket for that
session (something like /tmp/ssh-XXXX/agent.1234) and sets SSH_AUTH_SOCK to
it. A tmux server — and every pane inside it — captures whatever
SSH_AUTH_SOCK was set when the server first started.
Detach, disconnect, reconnect from a new SSH session, tmux attach, and the
socket your panes are still pointing at is dead. The old /tmp/ssh-XXXX/
directory was cleaned up when the first session ended. Everything that needs the
agent now fails:
git push # Permission denied (publickey)
ssh some-other-host # falls back to asking for a password
sudo -A / nested ssh # no key, no love
The insight¶
Never let long-lived processes hold the ephemeral socket path. Give them a
stable path instead — ~/.ssh/agent.sock — and make each new login
re-point that symlink at the current live socket.
- Long-lived processes (tmux panes, detached jobs) read the stable path once and keep working forever.
- Every fresh SSH login re-links the stable path to the new real socket, so
agent forwarding "just works" after
tmux attachfrom a new session — no per-prompt refresh, no wrapper.
Traps this encodes (the load-bearing details)¶
-
Only adopt a live forwarded socket. A session that forwards no agent (Tailscale SSH, mosh, a plain
sshwithout-A) exports noSSH_AUTH_SOCK. Blindly re-linking there would clobber a perfectly goodagent.sock. The hook only relinks when it sees a real socket that isn't already the stable path. -
ssh-add -lexit codes are the liveness oracle. It exits0(keys present) or1(agent alive, no keys) when the socket is live, and>1only when the socket is dead. So "is my stable link stale?" isexit > 1, and "is this candidate socket usable?" isexit <= 1. Getting these thresholds backwards silently breaks self-healing. -
Wrap the probe in
timeout 1. A hung upstream SSH multiplexer / ControlMaster can makessh-add -lblock forever. Without the timeout that stalls every shell startup on the host. -
Self-heal from a candidate directory. If the stable link is stale and the current session forwarded nothing, the hook walks newest-first sockets under
agentDir(globs.*) and relinks to the first live one it finds. The fish variant must glob into a variable first (set -l __socks …; if set -q __socks[1]) — unlike bash, a barefor x in (ls -t …/s.*)misbehaves in fish when nothing matches (it can end up listing the cwd instead of the empty set).setnever errors on a no-match glob, so it's the safe gate. -
~/.ssh/rccovers non-interactive connections. sshd runs~/.ssh/rcon every connection — includinggit,rsync,scpwhere no login shell ever starts — so the stable link is refreshed even then. Caveat: installing a~/.ssh/rcdisables sshd's built-in xauth handling, so on X11 forwarding hosts the rc has to replicate that xauth cookie line itself (installXauth = true). -
Absolute path to
timeout. The hooks use a fixed store path totimeout(not a baretimeout) so they work even where coreutils isn't on the default interactive PATH (e.g. macOS).
Usage¶
This is a home-manager module. Import it and enable:
{
imports = [ ./tmux-ssh-agent-survival ];
programs.sshAgentSurvival = {
enable = true;
installFishHook = true; # if you use fish; bash hook is on by default
# installXauth = true; # only on X11Forwarding hosts
};
}
Then make your clients use the stable path. In ~/.ssh/config:
or export SSH_AUTH_SOCK=$HOME/.ssh/agent.sock from your login profile.
Options¶
| Option | Default | Meaning |
|---|---|---|
enable |
false |
Turn the module on. |
stableSocket |
$HOME/.ssh/agent.sock |
Stable path processes latch onto; point your clients here. |
agentDir |
$HOME/.ssh/agent |
Directory of candidate live sockets to self-heal from (s.*). |
installBashHook |
true |
Inject the hook into interactive bash startup. |
installFishHook |
false |
Inject the hook into interactive fish startup. |
installSshRc |
true |
Install ~/.ssh/rc to also refresh on non-interactive conns. |
installXauth |
false |
Replicate sshd's xauth handling in the rc (X11 hosts only). |
Notes / caveats¶
- Not just for tmux. The same mechanism rescues
screen,nohup'd jobs, and any daemon started inside an SSH session — anything that outlives the connection that spawned it. - The
agentDirself-heal is optional. The directSSH_AUTH_SOCKadoption path (trap #1) covers the common case on its own.agentDironly matters if something else in your setup drops candidate agent sockets there for the hook to fall back on. - Plain NixOS without home-manager: port
initExtratoprograms.bash.interactiveShellInit(orenvironment.interactiveShellInit) and write the rc text to the user's~/.ssh/rcyourself. The shell logic is unchanged. - Agent forwarding is a trust decision. Anyone with root on the remote host
can use your forwarded agent for as long as you're connected. Prefer
per-host
ForwardAgentin~/.ssh/configover a blanketForwardAgent yes.
See also¶
- tmux-ssh-agent-stable-sock — covers the same stable-symlink trick but uses the
programs.stableAgentSocknamespace, bundles an optional opinionated tmux config, and enables the fish hook by default alongside bash.
Source¶
modules/tmux-ssh-agent-survival/default.nix
# tmux-ssh-agent-survival
#
# A home-manager module that keeps SSH agent forwarding alive across tmux
# reattaches (and any long-lived, detached process) by pinning SSH_AUTH_SOCK to
# a stable symlink that every new login re-points at the current live forwarded
# agent socket.
#
# The problem: when you `ssh -A` into a host, sshd creates a *fresh* agent
# socket (e.g. /tmp/ssh-XXXX/agent.NNN) and sets SSH_AUTH_SOCK to it. A tmux
# server (and every pane inside it) captured the SSH_AUTH_SOCK from whichever
# SSH session first started it. Reattach from a *new* SSH session and that old
# socket is dead — agent-backed git pushes, sudo-over-ssh, nested ssh all break
# with "Permission denied (publickey)".
#
# The fix: never let processes hold the ephemeral socket path. Point them at a
# stable path ($HOME/.ssh/agent.sock by default) and have each new login
# re-point that symlink at the current live socket. Long-lived processes read
# the stable path once and keep working forever.
#
# This is a home-manager module. To use it under plain NixOS without
# home-manager, port `initExtra` -> programs.bash.interactiveShellInit (or
# environment.interactiveShellInit) and write the rc file to the user's
# ~/.ssh/rc yourself.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.programs.sshAgentSurvival;
# The bash/fish interactive hooks and the ~/.ssh/rc body are generated by a
# shared pure function so non-module consumers can reuse the identical logic.
hooks = import ./hooks.nix {
inherit pkgs lib;
sock = cfg.stableSocket;
dir = cfg.agentDir;
installXauth = cfg.installXauth;
};
inherit (hooks) bashSshHook fishSshHook sshRc;
in
{
options.programs.sshAgentSurvival = {
enable = lib.mkEnableOption "SSH agent socket survival across tmux reattach";
stableSocket = lib.mkOption {
type = lib.types.str;
default = "$HOME/.ssh/agent.sock";
description = ''
Stable socket path that long-lived processes latch onto. Point your
clients at this (e.g. `SSH_AUTH_SOCK`, or an `IdentityAgent` line in
~/.ssh/config). Shell-expanded at runtime, so `$HOME` is fine.
'';
};
agentDir = lib.mkOption {
type = lib.types.str;
default = "$HOME/.ssh/agent";
description = ''
Directory scanned (newest-first, glob `s.*`) for live candidate sockets
when the stable link goes stale. Populate it however your setup drops
forwarded/agent sockets (e.g. a `Match` block or a launcher symlinking
each new agent socket here). Leave as the default if you only rely on
the direct SSH_AUTH_SOCK adoption path.
'';
};
installBashHook = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Inject the hook into interactive bash startup.";
};
installFishHook = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Inject the hook into interactive fish startup.";
};
installSshRc = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Install ~/.ssh/rc so the stable link is also refreshed on
non-interactive connections (git/rsync/scp), not just login shells.
'';
};
installXauth = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Replicate sshd's built-in xauth handling inside ~/.ssh/rc. Enable only
on hosts with X11Forwarding, since installing an rc file disables
sshd's own xauth cookie injection.
'';
};
};
config = lib.mkIf cfg.enable {
programs.bash.initExtra = lib.mkIf cfg.installBashHook bashSshHook;
programs.fish.interactiveShellInit = lib.mkIf cfg.installFishHook fishSshHook;
home.file.".ssh/rc" = lib.mkIf cfg.installSshRc { text = sshRc; };
};
}