the KodeLab

macOS GUI Terminal vs. SSH Sessions: Why launchctl managername Matters

1,741 words 9 min read
macOS GUI Terminal vs. SSH Sessions: Why launchctl managername Matters

The previous post — macOS SSH and Claude Code “Logged Out”: The Keychain Unlock Fix — covered the symptom of an SSH session finding the login keychain locked. Once you sit with that for a moment, a more fundamental question pops up: same machine, same user account, same ~/.zshrc getting sourced — why do GUI Terminal and an SSH shell behave so differently? The answer lives inside macOS’s launchd domain design, and launchctl managername is the most direct tool for poking at it.

GUI Terminal and SSH each fall into a different launchd domain — Aqua vs. Background — which dictates which resources they can reach
GUI Terminal and SSH each fall into a different launchd domain — Aqua vs. Background — which dictates which resources they can reach

A quick experiment: same account, two different answers

Open Terminal.app sitting at the Mac and run launchctl managername. The output is Aqua. Now SSH in from another machine — same account, same command — and you’ll see Background. Just by changing which door you walked through, launchd has placed you into two completely different domains.

# Inside the GUI Terminal
$ launchctl managername
Aqua

# After SSH'ing in
$ launchctl managername
Background

Aqua means you’re inside a graphical user session. Background means you’re in a launchd domain that belongs to the same uid but has no GUI attached. These aren’t decorative labels — launchd uses them to decide which services run, whether the keychain has been unlocked, how TCC permissions are inherited, whether pbcopy can talk to the pasteboard server, and a whole cascade of behaviors downstream.

What is a launchd domain

macOS’s launchd (pid 1) splits the system into several domains, each one managing its own set of services and environment. The common ones:

Domain identifiermanagernameDescription
systemSystemHome of root daemons; /Library/LaunchDaemons runs here
gui/<uid>AquaCreated only after a user completes a GUI login through loginwindow; loginwindow unlocks the login keychain here
user/<uid>BackgroundUser-level but not tied to GUI; SSH sessions land here
login/<asid>LoginWindow / StandardIOWhile loginwindow is showing, or for stdio/getty-style special sessions

The plist files we drop into ~/Library/LaunchAgents are “user agents,” but loading them into gui/<uid> versus user/<uid> produces different behavior. brew services defaults to installing into gui/<uid>, which is why brew services list over SSH sometimes shows a different state than the GUI — the two domains genuinely have separate service tables.

# Services loaded in the GUI session
launchctl print gui/$(id -u) | head

# Services in the non-GUI user domain
launchctl print user/$(id -u) | head

# Manually load an agent into the GUI domain
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.plist

What actually differs between GUI Terminal and an SSH session

Here’s a checklist of differences you actually run into. Every row is caused by the launchd domain split, not by ~/.zshrc:

BehaviorGUI Terminal (Aqua)SSH Session (Background)
login keychainUnlocked by loginwindowLocked; needs security unlock-keychain
TouchID for sudopam_tid.so worksCan’t prompt without GUI
TCC privacy permissionsInherits Terminal.app’s grantsDetermined by sshd’s own TCC entries — empty by default
pbcopy / pbpasteReads/writes the system clipboardWrites to the GUI user’s clipboard if there’s a GUI login; fails otherwise
osascript dialogsAppear on the user’s screenNeed launchctl asuser to surface — only if a GUI session exists
Notification Center via terminal-notifierWorksOften fails because it can’t reach NotificationCenter
Environment variablesInherits the launchd user session (including everything launchctl setenv set)Only what sshd forwards in plus what the shell startup files export
Telltale variablesTERM_PROGRAM, __CFBundleIdentifierSSH_CLIENT, SSH_CONNECTION, SSH_TTY
Parent process chainTerminal.apploginzshlaunchdsshd-sessionzsh
afplay, sayPlays through the user’s speakersPlays on the GUI side if there’s a GUI login; otherwise silent

The one that bites people most often is TCC. From the GUI Terminal, the first time you read ~/Desktop or ~/Documents, the system pops up a permission dialog; click Allow and it remembers you. Run the same command over SSH and the request gets attributed to sshd, which has no TCC entries by default — so ls ~/Desktop returns Operation not permitted, even though it’s the same person logged in.

To let sshd into protected directories, open System Settings → Privacy & Security → Full Disk Access and add /usr/libexec/sshd-keygen-wrapper (the path varies slightly by macOS release; some versions also need sshd itself added). Apple’s official docs say almost nothing about this; most people end up finding it through someone else’s gotcha post.

From the GUI Terminal, ls ~/Desktop is attributed to Terminal.app and allowed; from SSH, the same command is attributed to sshd and denied
From the GUI Terminal, ls ~/Desktop is attributed to Terminal.app and allowed; from SSH, the same command is attributed to sshd and denied

Keychain unlock is just one branch of this same problem

The earlier keychain post handled the first row of that table — the login keychain only gets unlocked inside gui/<uid> by loginwindow. SSH lands in user/<uid>, the keychain stays locked, and tools that read tokens from it (Claude Code, gh, the git osxkeychain helper) all show as “logged out.” The other rows in the table are all variations on the same design — different surface, same root cause.

In other words: if you treat your Mac as a personal machine with a human at the keyboard, Apple’s design works fine. Daily life happens inside a GUI session, every permission and resource is unlocked, and you never notice the seams. But once you treat the Mac as a server and operate it long-term over SSH, that same design becomes a slow drip of surprises.

Workarounds for SSH permission walls

Once you understand the root cause, here are some approaches in order from “safest but most annoying” to “most automated.” Each one has a tradeoff. Pick based on whether the Mac is yours alone or shared, and whether it’s physically near you.

Option 1: Connect via Screen Sharing, then open Terminal (safe but tedious)

The most direct workaround — instead of SSH’ing into the Background domain and fighting permissions, use Screen Sharing to drop into the GUI first. Turn on Screen Sharing in System Settings → General → Sharing, then connect from another Mac via Finder’s sidebar (Share Screen) or a vnc:// URL. You’re now staring at the Mac’s actual desktop. Open Terminal.app there, run launchctl managername, and you’ll see Aqua — keychain, TCC, pbcopy all work normally. The problem is sidestepped at the source.

This is what I personally fall back to on my mac mini in emergencies, but it’s not what I want for everyday work — the screen has to be redrawn over the network, latency is higher than plain text SSH, keyboard shortcuts collide with the local machine, you can’t pipe commands from your local editor, and spinning up a remote desktop just to run a one-liner is overkill. It’s good for occasional logins, password entry, anything that genuinely needs a GUI. It’s bad for ops.

Option 2: Auto-login + a longer keychain idle-lock interval

If the Mac is a server you keep at home for personal use, the easiest setup is to enable automatic login in System Settings → Users. After every reboot, loginwindow runs the GUI login flow on its own, the gui/<uid> domain comes up, the keychain unlocks, and the relevant TCC grants come along with it. Subsequent SSH sessions still land in user/<uid>, but the keychain’s unlocked state is shared across domains, so they can read tokens without issue. The keychain unlock post covers this in more detail.

Important caveat: automatic login only solves the keychain and GUI-resource piece. It doesn’t grant sshd any TCC permissions on its own — ~/Desktop and other protected areas still need Option 3.

Option 3: Grant sshd TCC permission

Open System Settings → Privacy & Security → Full Disk Access and add sshd-keygen-wrapper (path: /usr/libexec/sshd-keygen-wrapper — in Finder press Cmd+Shift+G and paste it). After that, SSH sessions can read ~/Desktop, ~/Library/Mail, ~/Library/Messages, and the other protected directories without hitting Operation not permitted.

A serious caveat: this effectively grants Full Disk Access to “anyone who successfully SSH’s in.” Don’t do this on a public or multi-user machine. Pair it with key-only login, password-login disabled, and AllowUsers restrictions to be reasonably safe. If you only need access to specific folders, the per-folder Files and Folders panel offers finer-grained control.

Option 4: Push commands that need GUI through launchctl asuser

If your SSH session needs to launch a GUI application, or run an osascript that only makes sense in an Aqua session, use launchctl asuser to dispatch the command into the target uid’s GUI session:

# Run as uid 501 in their Aqua session
launchctl asuser 501 osascript -e 'display notification "Hello" with title "From SSH"'

# Compare: running it directly over SSH usually produces nothing
osascript -e 'display notification "Hello" with title "From SSH"'

Handy for ops scripts, deployment automation, anything that wants to reach into a GUI behavior from SSH. The catch: if no one is logged into the GUI at all, launchctl asuser has no GUI session to target and the command fails. That’s part of why automatic login is recommended above.

Option 5: Print the session type from ~/.zshrc for easier debugging

Since launchctl managername is the key signal, just bake it into your shell startup so every new shell tells you which kind of session it is. Then when something behaves oddly you can tell at a glance whether the domain is to blame:

# Add to ~/.zshrc
if [[ -o interactive ]]; then
  printf "launchd domain: %s\n" "$(launchctl managername 2>/dev/null)"
fi

Wrap-up

The real dividing line between a GUI Terminal and an SSH session isn’t the shell — it’s the launchd domain: Aqua (gui/<uid>) versus Background (user/<uid>). From that single split, keychain unlock, TCC, pbcopy, TouchID, osascript, notifications, and a long list of other behaviors all branch out. Next time something works locally on the Mac but mysteriously doesn’t over SSH, run launchctl managername first — you’ll know within a second whether the domain split is the cause. For the keychain row specifically, macOS SSH and Claude Code “Logged Out”: The Keychain Unlock Fix goes into the full story.