Post-Candide

After the garden...


Pre-launching tmux on macOS: a TCC rabbit hole

I wanted something simple: a tmux server running at login, with a pre-configured session ready the moment I open iTerm2: just attach and go.

Simple. Right. Right?

The setup #

The initial idea is rather clean: two launchd agents:

Both plists use LimitLoadToSessionType = Aqua so they run in the GUI session, giving them (in theory) access to the user environment. The session setup script polls for the tmux server to come up, then builds the session.

It worked for a few reboots. Then there was a macOS update…

Problem 1: fish can’t read its own config #

After the update, new tmux panes would open to this:

  Welcome to fish, the friendly interactive shell
  Type help for instructions on how to use fish
  source: Error encountered while sourcing file
  '/Users/postcandide/.config/fish/functions/fish_user_key_bindings.fish':
  source: Operation not permitted
  source: Error encountered while sourcing file
  '/Users/postcandide/.config/fish/functions/fish_prompt.fish':
  source: Operation not permitted
  postcandide@garden /Users/postcandide >

Fish was starting, but broken: no prompt function, no key bindings, wrong PATH. The “Operation not permitted” is EPERM, not EACCES. This is macOS TCC (Transparency, Consent, and Control) actively refusing the file access.

The culprit: my dotfiles live in ~/Documents/Admin/dot-files, and /.config/fish is a symlink into that directory. The kernel resolves the symlink, sees ~/Documents, and TCC intervenes.

The natural response is to go to System Settings → Privacy & Security → Full Disk Access and add fish. I did. It didn’t help.

Here is why (I think): when a process is spawned by launchd, the responsible process for TCC purposes is launchd itself — not fish, not tmux, not iTerm2. You cannot grant Full Disk Access to launchd. The permission dialog will never appear. There is no entry to add. But I don’t think the wall it’s the full story: it was working before the update so either there are conditions where you can obtain TCC for your launchd but there are not documented (lol) and I stumbled on it by accident prior to the update or 26.4 added another layer of security. Mysteries.

Fix: move dotfiles out of /Documents #

The only reliable fix is to stop having fish config resolve through a TCC-protected directory. ~/Documents is protected. ~/Desktop is protected. ~/Downloads is protected.

$HOME itself is not (for now?). So:

mv ~/Documents/Admin/dot-files ~/.dotfiles

Then re-run init.sh (which rebuilds all symlinks from the new location) and re-run the launchd install script. One hour of fixing hardcoded paths in Emacs-persisted files, restoring the Elfeed database, and checking that nothing else pointed into ~/Documents. This how I like spending my afternoons, right Apple?

Problem 2: SSH to local machines fails #

With fish now loading cleanly, I opened a tmux pane and tried to SSH to one of my servers:

ssh: connect to host nas-main.internal port 667: No route to host

EHOSTUNREACH. The OS is actively refusing to route the packet to the LAN. This is the Local Network TCC privacy control, introduced and progressively tightened through macOS Ventura, Sonoma, and Sequoia.

Same root cause as before: the fish shells in those tmux panes were spawned by the launchd agent, which has no Local Network permission, and neither does launchd’s process tree. No dialog will appear. No entry can be added in System Settings for a command-line tool invoked this way.

I could edit ~/etc/hosts but why would I need to do that when I have a DNS, right Apple? Even then, it wouldn’t probably help, the block is not at DNS resolution, it is at the TCP routing level for RFC-1918 addresses.

The actual solution #

Stop trying to fight TCC from launchd. Accept its limits and work around them.

iTerm2 does have Local Network permission (you granted it when it first asked). Any process iTerm2 spawns inherits that permission. So the session setup just needs to run in iTerm2’s context, not launchd’s.

And so, the architecture:

# Bootstrap tmux login session from iTerm2 (which has local network permission).
# Skipped when already inside tmux to avoid re-entrant setup.
if test "$TERM_PROGRAM" = iTerm.app; and not set -q TMUX
    if not /opt/homebrew/bin/tmux has-session -t login 2>/dev/null
        ~/.local/bin/tmux-setup-session.sh
    end
end

There is one tradeoff: the session does not exist until you open iTerm2. In practice this is fine — you need iTerm2 to use it anyway but it cost a few seconds.

The Emacs footnote #

Emacs is launched via launchd in the same way and has the same Local Network TCC problem. The difference is that Emacs has a UI: the first time something tries to make a local network connection from an Emacs buffer, macOS shows a permission dialog. You click Allow, and subsequent connections work. The second SSH attempt succeeds.

Command-line processes started from launchd get no such dialog. They just fail silently with EHOSTUNREACH and leave you wondering what changed.

Lessons #

  1. Launchd agents are not GUI processes. LimitLoadToSessionType = Aqua does not give them TCC permissions — it just associates them with the GUI session for scheduling purposes.
  2. TCC checks the responsible process, not the calling process. Granting Full Disk Access or Local Network to fish, tmux, or bash does nothing when they are spawned by launchd. The responsible process is launchd, which cannot be granted permissions through the normal UI.
  3. /Documents, ~/Desktop, and ~/Downloads are TCC-protected. Dotfiles that live there and are accessed via symlinks will break for any process outside a normal GUI app context. Move your dotfiles to somewhere under $HOME that is not in that list. My desire to keep all important files within 1st party folders clashed with Apple’s TCC.
  4. The workaround for network access is to delegate to a GUI app. If you need LAN access in a background process, either get it to run in the context of an app that already has permission, or accept that the first attempt may trigger a dialog.

Apple’s intentions here are presumably good. The execution, for power users who rely on headless background processes and shell infrastructure, is an ongoing disaster of silent failures, undocumented responsible-process semantics, cryptic error messages (or rather, they let the error cascade downstream) and TCC databases that reset after system updates. The correct response, apparently, is to restructure your entire workflow around the assumption that anything launchd touches is sandboxed by default.

The true solution? #

When this M1 Pro breaks, it will be the first time in 20 years I will seriously ponder not buying another Apple laptop. The hardware is excellent, I can’t stand the paper cuts I have to go through. Best wishes to Asahi Linux, you’d be our saviors.

FUCK YOU APPLE, YOU USED TO BE BETTER.