Skip to main content

Command Palette

Search for a command to run...

Migrating to zsh Broke Obsidian Git.

A Battle with SSH Agent and Flatpak.

Updated
8 min read
Migrating to zsh Broke Obsidian Git.

Introduction

In the previous post, I quit .bashrc and gave bash and zsh separate responsibilities. The terminal environment was clean.

The next day, I opened Obsidian and Git sync had stopped.

[email protected]: Permission denied (publickey).
fatal: Could not read from remote repository.

It looked like "switching to zsh broke Obsidian." But the shell wasn't the real culprit. The ssh-agent's scope simply wasn't reaching GUI applications.

This is the record of how I tracked down that cause and fought my way through the Flatpak sandbox to fix it.


The Structure of the Problem

Checking from the terminal, the SSH keys were visible.

echo "$SSH_AUTH_SOCK"   # /run/user/1000/gcr/ssh
ssh-add -l              # work-github / personal-github

Yet Obsidian Git plugin kept asking for a passphrase every single time.

Terminal (zsh) → OK
Obsidian (GUI) → NG

The problem broke down like this:

ssh-agent launched from a shell
  → only valid within that shell's process tree

Obsidian launched by the GUI
  → never reads .zshrc
  → never reaches the ssh-agent

In other words, this wasn't a shell configuration problem — it was a login session design problem.


Why Did It Work Before?

A question immediately surfaced: when everything was crammed into .bashrc, Obsidian Git worked fine. Why?

The answer turned out to involve GNOME Keyring (gcr).

gcr-ssh-agent integrates with the GNOME login session and automatically exposes SSH keys when the login keyring is unlocked. That meant Obsidian had always been talking to gcr directly, without going through the shell at all.

It wasn't that passphrases were unnecessary — it was that GNOME Keyring had been silently providing the previously-saved passphrase all along.


Attempting gcr-ssh-agent Unification (and Failing)

My first plan was to leverage this mechanism and route everything through gcr.

gcr-ssh-agent
  → /run/user/1000/gcr/ssh
  → shared by Terminal / Editor / Obsidian / Flatpak

I exposed the gcr socket to the Flatpak Obsidian sandbox.

flatpak override --user \
  --filesystem=xdg-run/gcr \
  --env=SSH_AUTH_SOCK=/run/user/1000/gcr/ssh \
  md.obsidian.Obsidian

It worked — temporarily. GitHub authentication went through.

Then I rebooted. The problem came back.

Signing hangs

ssh-add -l              # personal-github / work-github → visible
ssh -T github-personal  # → hangs (no response)

The verbose log showed GitHub was accepting the key.

Offering public key: id_ed25519_personal
Server accepts key: id_ed25519_personal
signing using ssh-ed25519   ← hangs here

The signing request sent to gcr-ssh-agent was never coming back.

Keys resurrect after ssh-add -D

ssh-add -D   # All identities removed.
ssh-add -l   # personal-github / work-github  ← instantly back

gcr was auto-publishing keys from its keyring storage. And that auto-restored state was broken for signing.

Cleaning up via Seahorse was risky

When I tried to delete SSH key entries in Seahorse (GNOME's keyring manager), I realized it looked like it would delete the actual private key files (~/.ssh/id_ed25519_personal) along with them.

That was the end of the gcr unification attempt.


What Remained Unexplained

I never found the root cause of gcr-ssh-agent's signing hang. Here's what I was able to rule out:

The key files were not corrupted
The public keys on GitHub were registered correctly
Exposing the Flatpak socket was not the sole cause
~/.ssh/config Host aliases were not misconfigured

→ gcr-ssh-agent's auto-restored state was unstable at signing time
→ Root cause: unknown

This isn't a verdict that gcr-ssh-agent is broken. This problem happened in this environment. Given that, I chose to prioritize determinism and switch to OpenSSH ssh-agent.


Pinning OpenSSH ssh-agent as a systemd User Service

I decided to manage a plain OpenSSH ssh-agent with a fixed socket path under systemd user.

mkdir -p ~/.config/systemd/user

cat > ~/.config/systemd/user/ssh-agent.service <<'EOF'
[Unit]
Description=OpenSSH key agent

[Service]
Type=simple
Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user enable --now ssh-agent.service

The socket is now pinned at /run/user/1000/ssh-agent.socket.

Updating ~/.ssh/config

I added explicit IdentityAgent directives so SSH connections don't depend on the current value of SSH_AUTH_SOCK.

Host github-personal
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_personal
    IdentitiesOnly yes
    AddKeysToAgent yes
    IdentityAgent /run/user/1000/ssh-agent.socket

Host github-work
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_work
    IdentitiesOnly yes
    AddKeysToAgent yes
    IdentityAgent /run/user/1000/ssh-agent.socket

Normalizing the socket in bash and zsh

Added to both .bashrc and .zshrc:

# OpenSSH ssh-agent (shared between .bashrc and .zshrc)
if [ -S "$XDG_RUNTIME_DIR/ssh-agent.socket" ]; then
  export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/ssh-agent.socket"
fi

Dealing with Flatpak

Flatpak's Obsidian runs inside its own sandbox. The host socket needs to be explicitly exposed.

flatpak override --user \
  --filesystem=xdg-run/ssh-agent.socket \
  --env=SSH_AUTH_SOCK=/run/user/1000/ssh-agent.socket \
  md.obsidian.Obsidian

# Remove gcr access
flatpak override --user \
  --nofilesystem=xdg-run/gcr \
  md.obsidian.Obsidian

The Obsidian Git wrapper

Since the Obsidian Git plugin uses /app/bin/git inside the Flatpak, I wrapped it.

cat > ~/.local/bin/obsidian-git <<'EOF'
#!/usr/bin/env bash

SOCK="/run/user/1000/ssh-agent.socket"

export SSH_AUTH_SOCK="$SOCK"
export GIT_SSH_COMMAND="ssh \
  -F /home/rbcn2000/.ssh/config \
  -o IdentityAgent=$SOCK \
  -o BatchMode=yes \
  -o ConnectTimeout=10 \
  -o ServerAliveInterval=5 \
  -o ServerAliveCountMax=1"

exec /app/bin/git "$@"
EOF

chmod +x ~/.local/bin/obsidian-git

BatchMode=yes is the critical part — it prevents the automated commit/sync process from silently hanging while waiting for a passphrase prompt that can never appear.

In the Obsidian Git plugin settings, set Custom Git binary path to /home/rbcn2000/.local/bin/obsidian-git.


Automating Key Loading After Reboot

Unlike gcr, OpenSSH ssh-agent does not restore keys automatically. After a reboot, the agent starts empty.

I considered bringing keychain back, but decided against it — it risks creating duplicate agents. Instead, I wrote a minimal script whose only job is loading the keys.

cat > ~/.local/bin/ssh-load-github <<'EOF'
#!/usr/bin/env bash

set -u

SOCK="/run/user/1000/ssh-agent.socket"
export SSH_AUTH_SOCK="$SOCK"

if [ ! -S "$SOCK" ]; then
  systemctl --user start ssh-agent.service 2>/dev/null || true
fi

if [ ! -S "$SOCK" ]; then
  echo "ssh-agent socket not found: $SOCK" >&2
  exit 1
fi

# Don't wait for passphrase input in non-interactive environments
if [ ! -t 0 ]; then
  exit 0
fi

CURRENT_KEYS="$(ssh-add -l 2>/dev/null || true)"

if ! printf '%s\n' "$CURRENT_KEYS" | grep -q 'personal-github'; then
  ssh-add "$HOME/.ssh/id_ed25519_personal" || exit 1
fi

CURRENT_KEYS="$(ssh-add -l 2>/dev/null || true)"

if ! printf '%s\n' "$CURRENT_KEYS" | grep -q 'work-github'; then
  ssh-add "$HOME/.ssh/id_ed25519_work" || exit 1
fi
EOF

chmod +x ~/.local/bin/ssh-load-github

Call it from .zshrc and .bashrc on interactive shell startup:

if [[ $- == *i* ]] && [ -t 0 ] && [ "${SSH_LOAD_GITHUB_ON_SHELL:-1}" = "1" ]; then
  "$HOME/.local/bin/ssh-load-github" 2>/dev/null || true
fi

Now, the first time a terminal opens after reboot, you enter the passphrase once — and that's it.


The .desktop Launcher Trap

I thought it was solved. There was one more problem.

After a reboot, launching Obsidian first meant Git didn't work.

The culprit was the TTY check inside ssh-load-github:

if [ ! -t 0 ]; then
  exit 0   # No TTY → do nothing
fi

When Obsidian launches from a .desktop file, there is no TTY. So ssh-load-github exits immediately, keys never get loaded, and Obsidian starts up with an empty agent.

The fix: SSH_ASKPASS for a GUI dialog

OpenSSH has a built-in mechanism for exactly this situation: SSH_ASKPASS.

sudo apt install ssh-askpass-gnome

I wrote a dedicated launcher that uses it:

cat > ~/.local/bin/open-obsidian <<'EOF'
#!/usr/bin/env bash

SOCK="/run/user/1000/ssh-agent.socket"
export SSH_AUTH_SOCK="$SOCK"

if [ ! -S "$SOCK" ]; then
  systemctl --user start ssh-agent.service 2>/dev/null || true
fi

if [ ! -S "$SOCK" ]; then
  echo "ssh-agent socket not found: $SOCK" >&2
  exit 1
fi

# Accept passphrase input via GTK dialog even without a TTY
export SSH_ASKPASS="/usr/lib/openssh/gnome-ssh-askpass"
export SSH_ASKPASS_REQUIRE=force

CURRENT_KEYS="$(ssh-add -l 2>/dev/null || true)"

if ! printf '%s\n' "$CURRENT_KEYS" | grep -q 'personal-github'; then
  ssh-add "$HOME/.ssh/id_ed25519_personal" || exit 1
fi

CURRENT_KEYS="$(ssh-add -l 2>/dev/null || true)"

if ! printf '%s\n' "$CURRENT_KEYS" | grep -q 'work-github'; then
  ssh-add "$HOME/.ssh/id_ed25519_work" || exit 1
fi

exec flatpak run md.obsidian.Obsidian "$@"
EOF

chmod +x ~/.local/bin/open-obsidian

Overriding the .desktop file

The system-side Flatpak .desktop file can't be edited directly — Flatpak updates would overwrite it. Instead, copy it to the user applications directory and redirect the Exec line.

cp /var/lib/flatpak/exports/share/applications/md.obsidian.Obsidian.desktop \
   ~/.local/share/applications/md.obsidian.Obsidian.desktop

sed -i 's|^Exec=.*|Exec=/home/rbcn2000/.local/bin/open-obsidian %U|' \
   ~/.local/share/applications/md.obsidian.Obsidian.desktop

update-desktop-database ~/.local/share/applications/

Now, launching Obsidian from the app launcher shows a GTK passphrase dialog first, then starts Obsidian.


The Final Architecture

systemd user
  ↓
OpenSSH ssh-agent.service
  ↓
/run/user/1000/ssh-agent.socket
  ├─ bash / zsh (ssh-load-github on shell startup)
  ├─ ~/.ssh/config (IdentityAgent explicit)
  └─ ~/.local/bin/open-obsidian  ← launched from .desktop
       └─ SSH_ASKPASS=gnome-ssh-askpass (GTK dialog)
            ↓
          Flatpak Obsidian
               ↓
             ~/.local/bin/obsidian-git (BatchMode=yes)
               ↓
             /app/bin/git

After a reboot, behavior is now fully symmetric:

Opening a terminal first:
  → ssh-load-github runs
  → passphrase entered once
  → shared across bash / zsh / Obsidian for the rest of the session

Opening Obsidian first:
  → open-obsidian runs
  → GTK dialog prompts for passphrase
  → shared across bash / zsh / Obsidian for the rest of the session

Separation of Concerns

In the end, the responsibilities around ssh-agent sorted themselves into a clear table:

ResponsibilityHandled by
Agent startupsystemd user ssh-agent.service
Agent selectionSSH_AUTH_SOCK / IdentityAgent
Key loading (terminal)ssh-load-github (TTY present)
Key loading (GUI launch)open-obsidian + SSH_ASKPASS (no TTY)
Unattended Git operationsBatchMode=yes

Tools like keychain that handle everything in one shot are convenient, but they make it easy to accidentally create duplicate agents or fail to reach GUI applications. Separating the responsibilities made it clear exactly what was happening at every step.


Lessons Learned

An environment variable is not a "setting" — it is the result of process inheritance.

Even if SSH_AUTH_SOCK is correctly set somewhere, it only reaches a child process if that child is spawned from a process that has it. Configuration written in .zshrc simply does not reach GUI applications. That was the root of everything.

And an additional lesson:

Sometimes a fixed socket with a simple agent is more deterministic than a polished GUI integration.

gcr-ssh-agent's desktop integration is elegant. But in this environment, its auto-restore mechanism turned out to be unstable at signing time. A plain OpenSSH ssh-agent on a fixed socket was far more predictable.