Introduction

For a while now, I’ve been running Obsidian in Flatpak on Linux.

The reason was simple: easier package management, a clean system thanks to sandboxing, and better update discipline than dropping a random binary into /usr/local/bin. For a proprietary app like Obsidian that ships its own distribution, Flatpak just makes sense. That was all there was to it.

Then I started using community plugins seriously — and everything changed.

Git sync, AI coding assistance, local CLI integration: almost every plugin that does something interesting assumes it can spawn subprocesses in the same environment as your shell. Flatpak’s sandbox quietly breaks that assumption.

This is the battle log.


Flatpak’s Sandbox Looks Transparent. It Isn’t.

Let me set the stage.

Flatpak runs applications in an isolated sandbox. Your home directory is mounted. The network works by default. In day-to-day use you barely notice the sandbox at all.

The problems start when an app tries to spawn a subprocess.

What Flatpak restricts:
  - Process namespace (isolated from host processes)
  - Filesystem (only explicitly granted paths are accessible)
  - Environment variables (host shell settings don't reach the sandbox)

What Flatpak passes through:
  - Network (allowed by default)
  - Home directory (mounted)

“The file is right there but I can’t execute it.” “The command exists but it’s not in PATH.” These are classic Flatpak symptoms, born from this gap. Error messages often don’t surface either, which makes debugging hard.


Battle One: Obsidian Git and the SSH Agent

The first wall was SSH authentication in Obsidian Git.

Connecting to GitHub from the terminal worked fine. But the Obsidian Git plugin kept asking for a passphrase, or failing authentication entirely.

The cause was the reach of SSH_AUTH_SOCK.

When you start ssh-agent in a terminal, the SSH_AUTH_SOCK it exports only reaches that shell’s child processes. Flatpak Obsidian, launched from the GNOME app launcher, sits outside that process tree entirely.

Terminal's ssh-agent
  └─ terminal process
      └─ zsh (SSH_AUTH_SOCK is set)
                              ← wall is here
Flatpak Obsidian (SSH_AUTH_SOCK never arrives)

I tried unifying everything through GNOME Keyring (gcr), but ran into a different problem: gcr-ssh-agent hung during the signing operation in a way I couldn’t reliably fix.

The solution that stuck was running OpenSSH ssh-agent as a systemd user service, giving it a fixed socket path that exists before any shell or GUI app starts.

# ~/.config/systemd/user/ssh-agent.service
[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

With a fixed socket at /run/user/1000/ssh-agent.socket, I could expose it to Flatpak explicitly and reference it in a dedicated Obsidian Git wrapper script with SSH_AUTH_SOCK and BatchMode=yes hardcoded.

The full story is in the previous post: Migrating to zsh Broke Obsidian Git — A Battle with SSH Agent and Flatpak


Battle Two: The Codex CLI and stdio JSON-RPC

With SSH sorted, things were stable for a while. Then AI plugin integration became my next problem.

When you use AI coding assistance inside Obsidian, how it works depends entirely on the plugin — and the differences matter a lot in Flatpak.

Plugin + IntegrationTransportCLI runtimeFlatpak impact
Smart Composer + CodexHTTP API (REST)Network works → no problem
Claudian + Claude Codestdio JSON-RPCBun standalone ELF (self-contained)Worked from day one
Claudian + Codex CLIstdio JSON-RPCNode.js script (#!/usr/bin/env node)Broke

Smart Composer with a Codex subscription worked out of the box because it calls the OpenAI REST API over the network. Flatpak doesn’t block network access.

Claudian’s Codex integration didn’t, because it spawns the local codex CLI as a subprocess and communicates over stdio using JSON-RPC.

Claudian plugin
  → spawn: codex app-server --listen stdio://
  → JSON-RPC over stdin/stdout

I Wrote a Wrapper Script

To call /home/rbcn2000/.npm-global/bin/codex from inside Flatpak, I needed flatpak-spawn --host to launch it as a host-side process. My first attempt:

#!/usr/bin/env zsh

exec flatpak-spawn --host zsh -i -c '/home/rbcn2000/.npm-global/bin/codex "$@"' -- "$@"

The -i (interactive) flag was there to load .zshrc and get nvm’s Node.js into PATH. That was the mistake.

zsh -i Poisons stdout

I set this as the Codex CLI path in Claudian’s settings. The integration still didn’t work. No error message.

Here’s why.

zsh -i starts as an interactive shell, which means it executes .zshrc in full. My .zshrc includes:

eval "$(zoxide init zsh)"     # may write to stdout
eval "$(starship init zsh)"   # writes initialization output to stdout
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

Claudian’s Codex integration starts reading stdout as a JSON-RPC stream the moment the process launches.

Expected stdout:
  {"jsonrpc":"2.0","id":1,"result":{...}}

Actual stdout:
  (starship / zoxide initialization output)
  {"jsonrpc":"2.0","id":1,"result":{...}}

The JSON parser fails on the first non-JSON line. The connection drops. The error is reported as “could not connect” — with no hint about what was on stdout. The root cause is invisible.

The Fix: Remove the Intermediate Shell

#!/usr/bin/env bash

HOST_PATH="/usr/bin:/usr/local/bin:/home/rbcn2000/.npm-global/bin:$PATH"

exec flatpak-spawn --host env PATH="$HOST_PATH" \
  node /home/rbcn2000/.npm-global/lib/node_modules/@openai/codex/bin/codex.js "$@"

Three changes:

  1. No intermediate shell — nothing can pollute stdout
  2. node called directly — skips symlink resolution and shebang lookup
  3. PATH built explicitly for the host — Flatpak’s sandbox PATH doesn’t include /usr/bin or npm-global, so I assemble the environment that flatpak-spawn --host will pass to the host process myself

That worked.

Why Claude Code Worked From the Start

It’s worth asking why Claudian’s Claude Code integration never needed any of this. The answer is a fundamental difference in how the two tools are built.

Inspecting the Claude Code binary:

$ file ~/.local/share/claude/versions/2.1.177
ELF 64-bit LSB executable, x86-64, dynamically linked

$ ldd ~/.local/share/claude/versions/2.1.177
  librt.so.1, libc.so.6, libpthread.so.0, libdl.so.2, libm.so.6

$ ls -lh ~/.local/share/claude/versions/2.1.177
-rwxr-xr-x  239M  claude

Dependencies: only libc family. No libnode.so. No libv8.so. Size: 239 MB.

This is the signature of a Bun bun build --compile standalone ELF. Bun statically bundles its JavaScript engine (JavaScriptCore) directly into the binary at compile time. The result needs no external Node.js runtime — it’s fully self-contained.

Claude Code (Bun standalone):
  ELF binary
    └─ JavaScriptCore (statically bundled)
    └─ application code (statically bundled)
  → doesn't care what's in PATH
  → runs from anywhere, including Flatpak's sandbox

Codex CLI (Node.js script):
  codex.js  ← #!/usr/bin/env node
    → looks up `node` in PATH at runtime
    → nvm's node isn't in Flatpak's PATH
    → fails

If Codex moves to a native Rust binary, the story changes again. A Rust binary is also self-contained, with no external runtime dependency. The wrapper problem would simply disappear.

The discussion of “Claude Code is Bun, Codex is moving to Rust” has been floating around lately — but for Flatpak users, it maps directly onto a concrete lived experience: why did one work and the other didn’t? An abstract build-system choice showed up as the difference between “works on first try” and “two hours of silent debugging.”


Why Community Plugins Don’t Handle Flatpak

After these two battles, the structural reasons became clear.

① Flatpak Obsidian users are a thin slice of a thin slice

Linux desktop users are a minority among Obsidian’s userbase. Flatpak Obsidian users are a minority among Linux desktop users. If you’re a plugin developer on macOS, Windows, or even a .deb/AppImage Linux setup, you’ll never encounter Flatpak’s behavior. There’s no incentive to test for it.

② Flatpak failures are invisible

command not found or permission denied are easy to debug. Flatpak problems tend to appear as timeouts and silent connection failures — exactly the symptoms both of my issues produced. It’s hard to file a useful bug report for “it just doesn’t connect,” and hard for a developer to reproduce without a Flatpak environment.

③ Plugins assume “same environment as the shell”

Any plugin that spawns a subprocess implicitly assumes that PATH, environment variables, and sockets like SSH_AUTH_SOCK have the same values as in a terminal session. Flatpak silently breaks that assumption.


The Decision: Move to AppImage

Every workaround I added made the overall setup more fragile.

  • For SSH agent: a systemd service, a wrapper script, a .desktop file override
  • For Codex CLI: a flatpak-spawn wrapper, manually assembled PATH

Each fix is correct in isolation. Together, they form a web of dependencies where changing one thing can break another. Upgrade nvm → PATH shifts. Adjust systemd → socket path changes.

AppImage is a different approach. It doesn’t sandbox anything. It runs as a regular process in the host environment, inheriting your PATH, your SSH_AUTH_SOCK, your nvm — everything.

AppImage Obsidian:
  PATH              → inherited from host
  SSH_AUTH_SOCK     → inherited from host
  subprocess spawn  → runs in host environment
  flatpak-spawn     → not needed

The maintenance calculus has flipped. AppImage is now the simpler choice.


Takeaways

Pushing Flatpak Obsidian to its limits produced two real lessons:

SSH agent: Environment variables don’t teleport — they propagate through process inheritance. A socket that works in your terminal doesn’t exist in a GUI app spawned from a launcher. Anchoring the agent to a fixed path via systemd removes the dependency on how the app was launched.

Codex CLI / stdio JSON-RPC: When a plugin communicates over stdio as a protocol, the spawned process must produce nothing but protocol output. An interactive shell between the plugin and the binary is a silent killer — init scripts write to stdout, the JSON parser fails, the connection drops with no error trace.

Neither failure was Flatpak’s fault. Flatpak sandboxed exactly what it was supposed to sandbox. The failure was in not accounting for those boundaries earlier.

Never put an interactive shell between a caller and a subprocess that uses stdio as a protocol.

That’s the single most concrete rule I can extract from this.

And the broader one: workarounds compound. At some point, the maintenance cost of staying exceeds the switching cost of leaving. Moving to AppImage is an acknowledgment of reaching that point.

cover image: unsplash