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.
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 identifier | managername | Description |
|---|---|---|
system | System | Home of root daemons; /Library/LaunchDaemons runs here |
gui/<uid> | Aqua | Created only after a user completes a GUI login through loginwindow; loginwindow unlocks the login keychain here |
user/<uid> | Background | User-level but not tied to GUI; SSH sessions land here |
login/<asid> | LoginWindow / StandardIO | While 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:
| Behavior | GUI Terminal (Aqua) | SSH Session (Background) |
|---|---|---|
| login keychain | Unlocked by loginwindow | Locked; needs security unlock-keychain |
| TouchID for sudo | pam_tid.so works | Can’t prompt without GUI |
| TCC privacy permissions | Inherits Terminal.app’s grants | Determined by sshd’s own TCC entries — empty by default |
pbcopy / pbpaste | Reads/writes the system clipboard | Writes to the GUI user’s clipboard if there’s a GUI login; fails otherwise |
osascript dialogs | Appear on the user’s screen | Need launchctl asuser to surface — only if a GUI session exists |
Notification Center via terminal-notifier | Works | Often fails because it can’t reach NotificationCenter |
| Environment variables | Inherits the launchd user session (including everything launchctl setenv set) | Only what sshd forwards in plus what the shell startup files export |
| Telltale variables | TERM_PROGRAM, __CFBundleIdentifier | SSH_CLIENT, SSH_CONNECTION, SSH_TTY |
| Parent process chain | Terminal.app → login → zsh | launchd → sshd-session → zsh |
afplay, say | Plays through the user’s speakers | Plays 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.
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.