Skip to content
C.W.K.
Stream
Lesson 03 of 06 · published

The GUI Session, the ssh Session, and the launchd Job

~18 min · macos-citizen, ssh, aqua, launchd, codesign, fleet

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A single probe, from one host, at one moment, is evidence about the probe."

One User, Three Contexts

When you log in at a Mac's screen, macOS builds an Aqua session around you: the window server, a login keychain that can show a prompt, and the gui/<uid> launchd domain. An ssh login as the same user gets the same files and a different security session. A launchd job is a third context again, with its own environment. Fleet tooling that forgets which one it is in produces errors that look like bugs in the app.

What an ssh Session Can and Cannot Do

Measured from ssh on a Mac whose owner was logged in at the console:

  • The login keychain answers "User interaction is not allowed", so codesign with any identity stored there fails with the opaque errSecInternalComponent. That includes the self-signed identity and the Apple Development identity an iOS archive needs.
  • launchctl asuser fails with "Could not switch to audit session … Operation not permitted". Switching sessions needs root.
  • Signals work normally, and a direct Apple Event to an app (tell application "Spark" to quit) worked, while anything through System Events hung until error -1712. Run osascript in the background with a deadline, because a quit blocked by an unsaved-changes dialog never returns.

open is the case to hold loosely. A July probe on one peer returned exit 0 and launched nothing. A September probe on another Mac launched an AppKit app that reached applicationDidFinishLaunching. The two measurements disagree, so neither is a law. What both prove is that the exit status says nothing: check for the process.

Doing GUI-Session Work From ssh

The dependable bridge is a one-shot LaunchAgent bootstrapped into gui/<uid>. The job runs inside the Aqua session, so the keychain works and signing succeeds, with no window. The family's iOS archive runs this way from remote sessions. The tempting alternative, telling Terminal to run a script through AppleScript, typed the path into whichever tab was in front, which was the owner's live system monitor. Never drive a person's apps to reach their session.

The bridge needs a console user. At the login window the GUI domain does not exist, and launchctl bootstrap gui/<uid> fails with 125: Domain does not support specified action. For an always-on agent that is fine: its plist in ~/Library/LaunchAgents is the durable activation, and RunAtLoad starts it at the next login. Report staged instead of failing. A one-shot job has no next login to wait for, so for it the same missing console is a failure it names.

A launchd Job Is Not Your Shell

A job's PATH is launchd's /usr/bin:/bin:/usr/sbin:/sbin unless its plist sets one, so Homebrew tools are not found. A plist that sets PATH to add Homebrew can drop /usr/sbin, which is how an engine's lsof probe reported every port as having no listener. Local network privacy differs too. Tools run from Terminal or over ssh are allowed automatically, launchd agents are not. An engine running as an agent got "No route to host" for a LAN hostname that the same user's ssh shell reached.

Code

Run a signing pipeline in the GUI session from ssh, as a one-shot LaunchAgent·bash
#!/bin/zsh
set -euo pipefail
uid=$(id -u); label=com.example.spark.archive-once
job="$PWD/build/$label.plist"; log="$PWD/build/archive-once.log"

console=$(stat -f %Su /dev/console)
[[ $console == "$USER" ]] || { echo "HARD_FAIL: no GUI session for $USER (console=$console); a one-shot job has no next login" >&2; exit 1; }

mkdir -p build; rm -f "$log"
cat > "$job" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>$label</string>
  <key>ProgramArguments</key><array><string>/bin/zsh</string><string>$PWD/scripts/archive.sh</string></array>
  <key>WorkingDirectory</key><string>$PWD</string>
  <key>EnvironmentVariables</key><dict>
    <key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><false/>
  <key>StandardOutPath</key><string>$log</string>
  <key>StandardErrorPath</key><string>$log</string>
</dict></plist>
PLIST

launchctl bootstrap "gui/$uid" "$job"
for _ in {1..1800}; do grep -q '^PIPELINE_EXIT=' "$log" 2>/dev/null && break; sleep 1; done
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
code=$(sed -n 's/^PIPELINE_EXIT=//p' "$log" 2>/dev/null | tail -1)
[[ -n $code ]] || { echo "TIMEOUT: no exit line in $log" >&2; exit 1; }
echo "PIPELINE_EXIT=$code"; exit "$code"

External links

Exercise

From a second Mac, ssh into the Mac that holds your signing identity. Compile a one-line C program and try codesign --sign on it directly, recording the error. Then write a tiny scripts/sign-probe.sh that signs the same file and prints PIPELINE_EXIT=0, and run it through the one-shot GUI-domain job above. Record whether it signed. Finally, have the job print $PATH with and without the EnvironmentVariables block and compare with your ssh shell's PATH.
Hint
set -e in the job script means a failed codesign exits before the exit line is printed, so the poll ends in TIMEOUT. Print the exit line from a trap on EXIT if you want failures reported with their status.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.