jenkinsmacoscidevops

Jenkins on macOS: The GUI Session Agent — Complete Visual Guide

Share X in
Jenkins on macOS: The GUI Session Agent — Complete Visual Guide

01 Why GUI Context Matters 02 Auto-Login & Keychain 03 LaunchAgent Plist 04 Setup Script + Jenkins 05 Pitfalls & Checklist 06 The Domain Trap

Why the GUI session matters

A Jenkins agent launched via ssh or launchctl into a non-interactive session is a different animal from one running inside the logged-in Aqua session. For pure-shell jobs it makes no difference. The moment you sign with a keychain, drive a Simulator, run XCUITests, or route audio/video — it decides everything.

GUI session vs. background session

Two ways the agent process can be born — and what each can reach

GUI Session Agent launchd in gui/<uid> domain Access: WindowServer Access: login keychain Access: Simulator / Xcode codesign consent dialog WORKS Background Agent ssh / system launchd domain No WindowServer session No GUI keychain bootstrap Simulator: not usable codesign consent dialog FAILS same agent.jar securityd decision key not in partition list? -> can it render the prompt? NO -> errSecInternalComponent YES -> indefinite HANG (nobody to click Allow)

When a private key in a keychain is used by a process that is not in the key’s partition list, Security.framework must ask the user. Where it asks depends entirely on the session the process lives in:

codesign wants to use the key

Key is found and opened; ACL allows request to proceed.

is codesign in the key’s partition list?

If security set-key-partition-list … codesign: was applied — approval is silent, rc=0, no prompt.

No partition entry -> can securityd render a prompt?

Process has an Aqua session → dialog is registered and waits forever. No Aqua session → framework takes the fast-fail path: errSecInternalComponent (rc=1, ~0.3s).

Jobs that flat-out need the GUI session

🔑

iOS / macOS codesign

Keychain key access + Xcode requires a GUI login; non-partitioned keys otherwise error or hang.

📱

Simulator

CoreSimulator spawns UIKit apps in the GUI session; headless launches either fail or render nothing.

🧪

XCUITest

Automation needs a WindowServer-target — the test runner must reach the login session.

🎬

Video / audio capture

Element capture, AVFoundation output, screen recording require WindowServer + screen recording permission.

🗝️

Keychain-backed secrets

Any binary that gets a password from the login keychain for the first time triggers a consent prompt.

🧯

System prompt handling

macOS dialogs (updates, Camera/WiFi keys, Gatekeeper) only auto-dismiss in an interactive session.

Auto-login & keychain

A GUI-session agent is useless if the session doesn’t exist after a reboot. Three things must survive power cycles: the logged-in user, the unlocked keychain, and a machine that never sleeps.

1. Enable auto-login

System Settings → Users & Groups

Enable Automatically log in as for the dedicated build user (e.g. dk). macOS requires the user to be in the Administrators group.

Confirm fileVault/secure-boot status

Auto-login is skipped if FileVault is on (unless capable “secure token” user). For a build Mac this is usually fine — the box is on a locked LAN.

Reboot test

No account is more reliable than one you re-test. After `sudo reboot`, confirm the console lands on the desktop, not the login window.

Boot → GUI session → agent lifecycle

Power on auto-login user → Aqua session boots loginwindow + WindowServer → launchd gui/uid loads LaunchAgents → agent.jar connects RunAtLoad + KeepAlive

2. Keychain auto-unlock

A freshly booted login keychain is locked. Any agent job touching a key will hang on “unlock login keychain” unless it is pre-unlocked. Two mechanisms:

🔓

Unlock on login (launchd uses the session unlocked state)

Because the agent runs inside the GUI session, the login keychain is unlocked as part of sign-in. Keep the screen-saver password disabled, or the session becomes idle-locked and the agent loses access.

🗂️

Ephemeral keychains for CI (the cleaner pattern)

Create a per-build keychain, import the p12, apply the partition list, delete on exit:

keychain-per-build.shcopy

# each build gets its own keychain -> zero state leakage
KC="codesign-exp-${BUILD_NUMBER}"
security create-keychain -p "$KC_PASS" "$KC"
security unlock-keychain    -p "$KC_PASS" "$KC"
security import "$P12_FILE" -k "$KC" -P "$P12_PASS" \
          -T /usr/bin/codesign -f pkcs12
# put it in the USER SEARCH LIST - --keychain alone is not enough
security list-keychains -d user -s "$KC" ~/Library/Keychains/login.keychain-db
# no GUI prompt for codesign from here on:
security set-key-partition-list -S "apple-tool:,apple:,codesign:" \
          -s -k "$KC_PASS" "$KC"
# ... sign ...
security list-keychains -d user -s ~/Library/Keychains/login.keychain-db
security delete-keychain "$KC"

3. Sleep / screen / update hardening

⚡ Prevent sleep

sudo pmset -a sleep 0 and sudo pmset -a displaysleep 0. A sleeping Mac drops the WindowServer session, breaking the GUI context mid-build.

🖥️ Disable screen lock

System Settings → Lock Screen → “Require password” = Never. An idle-locked session freezes keychain access.

📴 Block auto-updates

Turn off “Install macOS updates” and “Install application updates from the App Store” — a midnight reboot kills the next morning’s build.

🔌 Disable deep sleep

sudo pmset -a sleep 0 plus setting System Settings → Battery → “Prevent automatic sleeping on power adapter”.

The LaunchAgent plist

The agent must be a user LaunchAgent (bootstrap domain gui/), not a Linux-style systemd user. This is the piece that puts the process inside the Aqua session.

Anatomy — what each key does

KeyValue in our agentWhy
Labelcom.jenkins.ios-m4Unique reverse-DNS id; launchctl uses it (no .plist suffix).
ProgramArgumentsjava -jar agent.jar -url -secret -name ios-m4 -webSocket -workDir JNLP inbound agent startup line, exactly as Jenkins shows on the node page.
RunAtLoadtrueStart immediately at login.
KeepAlivetrueAuto-restart if the agent JVM dies. Non-negotiable for a build box.
WorkingDirectory~/jenkins-agent-bWhere remoting/jar lives; launchd’s default cwd is / otherwise.
StandardOutPath / StandardErrorPath~/jenkins-agent-b/agent.logCatch the agent’s own stderr so a silent connect-failure is debuggable.
EnvironmentVariablesPATH, LANG, HOME, RBENV_ROOTlaunchd gives a minimal env; real builds need the full PATH of the user shell.

The complete plist

~/Library/LaunchAgents/com.jenkins.ios-m4.plistcopy

<?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>com.jenkins.ios-m4</string>

  <key>ProgramArguments</key>
  <array>
    <string>/opt/homebrew/opt/openjdk@21/bin/java</string>
    <string>-jar</string>
    <string>/Users/dk/jenkins-agent-b/agent.jar</string>
    <string>-url</string>
    <string>http://192.0.2.10:30880/</string>
    <string>-secret</string>
    <string>REPLACE_WITH_AGENT_SECRET</string>
    <string>-name</string>
    <string>ios-m4</string>
    <string>-webSocket</string>
    <string>-workDir</string>
    <string>/Users/dk/jenkins-agent-b</string>
  </array>

  <key>WorkingDirectory</key>
  <string>/Users/dk/jenkins-agent-b</string>

  <key>RunAtLoad</key>
  <true/>

  <key>KeepAlive</key>
  <true/>

  <key>EnvironmentVariables</key>
  <dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/opt/homebrew/opt/openjdk@21/bin:/Users/dk/.rbenv/shims:/Users/dk/.rbenv/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    <key>LANG</key>
    <string>en_US.UTF-8</string>
    <key>HOME</key>
    <string>/Users/dk</string>
    <key>RBENV_ROOT</key>
    <string>/Users/dk/.rbenv</string>
  </dict>

  <key>StandardOutPath</key>
  <string>/Users/dk/jenkins-agent-b/agent.log</string>
  <key>StandardErrorPath</key>
  <string>/Users/dk/jenkins-agent-b/agent.log</string>
</dict>
</plist>

Load & verify (no sudo needed)

terminalcopy

# validate the plist syntax first
plutil -lint ~/Library/LaunchAgents/com.jenkins.ios-m4.plist

# load into the user GUI domain (macOS 13+: bootstrap; fall back to load)
launchctl bootstrap "gui/$(id -u)" \
  ~/Library/LaunchAgents/com.jenkins.ios-m4.plist || \
launchctl load -w ~/Library/LaunchAgents/com.jenkins.ios-m4.plist

# confirm it is running under the GUI domain - the 'gui/' is the magic bit
launchctl print "gui/$(id -u)" | grep -A3 jenkins

# watchdog the connect
tail -f ~/jenkins-agent-b/agent.log
# expect the "Connected" line from the remoting jar

Setup script + Jenkins side

A single idempotent installer does the whole job — down below is the exact flow used for ios-m4 (from the pulse repo, jenkins-k8s/ios/agent/setup-ios-m4-agent.sh).

The installer flow

Preconditions

Java 21 at a known path (/opt/homebrew/opt/openjdk@21/bin/java), python3, and AGENT_SECRET from the Jenkins node page.

Work dir + agent.jar

Create ~/jenkins-agent-b/remoting, download agent.jar from /jnlpJars/agent.jar, validate size ≥ 1 MB (a truncated jar makes `java -jar` fail cryptically).

Write the plist via plistlib

Python generates the LaunchAgent from env vars — keeps the secret out of the repo and interpolates paths consistently.

Validate + bootstrap

plutil -lint then launchctl bootstrap “gui/$(id -u)” (user domain — no sudo, fully permitted on a login session).

Verify connection

Poll agent.log for “Connected” for up to 30s; fail loudly if not.

Jenkins node config

Controller <-> Agent

Jenkins Controller Mode: Java Web Start secret: auto-generated node page shows -secret ios-m4 (agent) Labels: ios-m4 mac m4 ios Remote FS: ~/jenkins-agent-b Launch method: Inbound TLS/WS (webSocket) JNLP agent.jar agent connects OUT -> no inbound ports needed -webSocket keeps NAT simple

Getting the secret safely

🔐

No hardcoded secrets

The node page at /computer/ios-m4/slave-agent.jnlp exposes the one-time secret. In our flow it is passed as AGENT_SECRET= ./setup-ios-m4-agent.sh, injected into the plist at write time, and never committed.

1

agent.jar download

~30s

connect window

gui/uid

bootstrap domain

Pitfalls & checklist

The whole guide distilled into the parts that actually break production CI.

The codesign saga (real builds, ios-m4)

BuildKindPartition listGUI session at that momentResult
codesign-ls-nopartition #1pipelineskippednot reachableerrSecInternalComponent rc=1, ~0.3s
codesign-ls-nopartition #2pipelineskippedreachableHANG 15-min timeout → ABORTED
codesign-freestyle-ls-nopartition #1freestyleskippedreachableHANG watchdog kill rc=137
codesign-freestyle-ls-partition #1freestyleapplied—SUCCESS 13.3s, sig size 3699

Headless CI decision table

VariantUI session seenEnd stateRule
partition list applied—instant SUCCESS (no prompt)use this for headings
partition list skippednoneinstant error, rc=1keeps the diagnostic
partition list skippedGUI reachableindefinite hangMUST wrap codesign in a watchdog (SIGTERM won’t stop it — use kill -9)

Final checklist — a GUI session agent done right

Auto-login for the build user

Console lands on the desktop after every reboot; FileVault considered consciously.

Screen lock & sleep disabled

pmset sleep 0, screensaver password off, auto-updates off.

User LaunchAgent, not systemd/service

A launchd plist bootstrapped into gui/$(id -u) with KeepAlive=true + RunAtLoad=true.

Prove the domain from inside a job

Add launchctl managername as the first line of any signing job. It must print Aqua. System means a LaunchDaemon is running your builds — see Tab 06.

Full PATH in EnvironmentVariables

launchd env is minimal — inject the full user shell PATH or every build fails at `command not found`.

Keychain partition list applied before any sign

security set-key-partition-list -S apple-tool:,apple:,codesign: right after p12 import — no prompt, no hang.

Per-build ephemeral keychains

create → unlock → import → sign → restore search list → delete. Zero cross-build state.

External watchdog on any key-touching command

Jenkins’ polite SIGTERM won’t stop a blocked codesign; wrap in a shell watchdog that uses kill -9.

Re-test after every reboot

Run one real signing job after a power cycle — auto-login and keychain unlock are the two things most likely to quietly regress.

The domain trap: same user, wrong session

A real Apple Distribution identity signed cleanly from Terminal, Xcode and xcodebuild on a Mac mini running macOS Tahoe — and failed every single time from Jenkins. Same uid. Same $HOME. Same p12. Partition list applied. Every comparison anyone suggested came back identical. This is the evidence, the red herrings that cost a day, and the one command that ended it.

The symptom — everything matches, except the result

Diagnostics printed by the signing script immediately before codesign, in both contexts:

CheckTerminal.appJenkins job (agent “mobileapp”)
whoami / idhellodk · uid=502(hellodk) gid=20(staff)hellodk · uid=502(hellodk) gid=20(staff)
$HOME/Users/hellodk/Users/hellodk
list-keychains -d usercodesign-…-PARTITION-dblogin.keychain-dbcodesign-…-PARTITION-dblogin.keychain-db
find-identity -v -p codesigning1 valid identity · 77E0EB9D…DCF81 valid identity · 77E0EB9D…DCF8
set-key-partition-listapplied, rc=0applied, rc=0
codesign —force —sign … —keychain …rc=0 Signature size=4784rc=1 errSecInternalComponent
codesign stderrnot captured (mistake — see below)Warning: unable to build chain to self-signed root for signer “Apple Distribution: …”./ls-binary-test: errSecInternalComponent
Jenkins build result—Finished: SUCCESS — the script printed VERDICT: SIGNING FAILED but never exit 1

Red herrings — what we ruled out, and how

dead “login.keychain-db appears twice”

An earlier analysis read the Jenkins keychain block as a duplicated search list. It wasn’t: the block ran security default-keychain then list-keychains. Line 1 was the default-keychain output. Search lists were identical. Lesson: one command per output block, or you’ll compare noise.

dead “Bad p12 / missing private key”

find-identity -v — the -v does trust evaluation — reported 1 valid identity in both contexts. The identity was fine; the keychain was fine.

dead “Running as SYSTEM”

That line in the Jenkins console is Jenkins’ authorization principal for the build. It says nothing about the Unix user or the launchd domain. Ignore it for this class of problem.

not verified “Same script, both places”

The two runs printed different diagnostic sections — they were two revisions of the script. And the Terminal run never captured codesign’s stderr, so nobody knew whether the chain warning appeared there too. If it isn’t byte-identical, it isn’t a comparison.

useful The self-signed dummy control

Earlier (Tab 05) a self-signed dummy identity + partition list signed fine on the ios-m4 agent. A self-signed cert needs no intermediate; an Apple Distribution cert must chain through WWDR. That left two candidates: certificate chain or process session.

useful Xcode and xcodebuild both sign OK

Anything launched from the GUI login session works. Only Jenkins fails. The agent process, not the certificate, is the variable — so the next question is where does that process actually live?

launchd domains — why “same uid” proves nothing

launchd runs several domains. Which one a process is born into decides its audit/security session: whether securityd can talk to it about keychain consent, whether WindowServer exists for it, which trustd it evaluates certificates with. The Unix uid is orthogonal — a LaunchDaemon with UserName hellodk runs as uid 502 inside the system domain.

Domain targetPopulated fromlaunchctl managernameAqua session
system/Library/LaunchDaemons, /System/Library/LaunchDaemons — loaded at boot as root; UserName only changes the uidSystemno
user/Per-user background domain — exists without a GUI loginBackgroundno
login/Every login session — an SSH session gets its ownnot Aqua (we did not observe the exact string)no for SSH
gui/~/Library/LaunchAgents, /Library/LaunchAgents — created at GUI login, gone at the login windowAquayes

Same Uid, Two Domains — Where Each codesign Process Is Born

launchd (PID 1) system /Library/LaunchDaemons managername = System user/502 Background, No GUI managername = Background gui/502 ~/Library/LaunchAgents managername = Aqua jenkins-slave (PID 346) java (PID 44467) → bash codesign as uid 502 rc=1 errSecInternalComponent Nothing Of Ours Loads Here loginwindow → Terminal.app login (PID 43531) → zsh codesign as uid 502 rc=0 Signed

Why launchctl load lies to you

The legacy load/unload verbs take no domain argument. They load the job into whatever domain the shell you typed them in belongs to. The file’s location under ~/Library/LaunchAgents is a convention for auto-loading at login — it does not bind a manual load to the GUI domain.

Where you ran launchctl loadDomain the job lands inSession
Terminal.app / Screen Sharing on the consolegui/Aqua
An SSH sessionlogin/ of that SSH sessionnot Aqua — login keychain locked, no prompt path
sudo launchctl load of anythingsystemnot Aqua — even with UserName set
Auto-loaded from ~/Library/LaunchAgents at GUI logingui/Aqua
Plist in /Library/LaunchDaemons (this case)systemnot Aqua

explicit domain — the only unambiguous formcopy

# name the domain; correct no matter which shell you type it in
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.jenkins.mobileapp.plist
launchctl bootout   "gui/$(id -u)" ~/Library/LaunchAgents/com.jenkins.mobileapp.plist

# stop using these for build agents
# launchctl load   ~/Library/LaunchAgents/com.jenkins.mobileapp.plist
# launchctl unload ~/Library/LaunchAgents/com.jenkins.mobileapp.plist

The one command: launchctl managername

It prints the name of the launchd manager for the caller’s own domain. Run it from inside the job and the process tells you, in one word, where it lives. Actual outputs, verbatim:

Terminal.app on the consolecopy

$ launchctl managername
Aqua
$ ps -o pid,ppid,comm -p $$ $PPID
  PID  PPID COMM
43531   794 login
43532 43531 -zsh

Jenkins “Execute shell” on mobileappcopy

+ launchctl managername
System
+ ps -o pid,ppid,comm -p $$ $PPID
  PID  PPID COMM
44467   346 /usr/bin/java
44487 44467 /bin/bash

That is the whole diagnosis. Same uid 502, same home, same keychains, same identity — and one process is in System while the other is in Aqua. Confirm which plist owns it, one label per command (no piped grep across two domains — that ambiguity cost a round-trip):

terminalcopy

launchctl print "gui/$(id -u)/jenkins-slave" 2>&1 | head -15   # errors → not an agent
launchctl print system/jenkins-slave         2>&1 | head -15   # prints path = /Library/LaunchDaemons/… → daemon
ls -l ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons | grep -i jenkins

The fix — retire the daemon, bootstrap a user agent

Do this at the console (Terminal.app or Screen Sharing). Not over SSH — step 4 would land in the SSH session’s domain and you’d be back where you started. The sudo lines are yours to run; the daemon plist is root-owned.

Capture the daemon’s command line before touching it

launchctl print system/jenkins-slave | grep -iE ‘path|program|arguments’ -A8 — you need the exact java -jar agent.jar -url … -secret … -name mobileapp -webSocket -workDir … line, and the path of the plist.

Stop and retire the daemon

sudo launchctl bootout system /Library/LaunchDaemons/.plist then sudo mv /Library/LaunchDaemons/.plist ~/jenkins-daemon-plist.bak. Moving it out is not optional — left in place it comes back on the next boot and silently wins.

Write the user LaunchAgent

~/Library/LaunchAgents/com.jenkins.mobileapp.plist — same ProgramArguments as step 1; delete any UserName key; EnvironmentVariables with HOME=/Users/hellodk and the full PATH; RunAtLoad + KeepAlive true. Tab 03 has the template — substitute the user and paths.

Bootstrap into the GUI domain, explicitly

plutil -lint …plist → launchctl bootstrap “gui/$(id -u)” …plist → launchctl print “gui/$(id -u)/com.jenkins.mobileapp” | head -5.

Auto-login for the build user

System Settings → Users & Groups → Automatic Login → hellodk. Without it gui/502 does not exist after a reboot and the agent is simply down until someone logs in. This is the trade-off you accept for a working keychain session.

Re-run the failing job unchanged

Verification order matters: first launchctl managername in the job log must print Aqua — if not, step 4 ran from the wrong place and codesign output is meaningless. Then, and only then, look at codesign rc.

Still open — labelled honestly

The domain difference is verified. The mechanism by which it produces this particular pair of messages for an Apple-issued cert, when the self-signed dummy passed in the same daemon context, is inferred, not proven. Two candidates, each to be tested on its own after the session fix lands:

inferred WWDR chain in the system session

unable to build chain to self-signed root means trust evaluation could not reach the WWDR intermediate. Hypothesis: it lives in the login keychain, which is unlocked in Aqua and locked in a fresh system-domain security session. Test: import the intermediate into the ephemeral keychain and compare. Evidence to collect first: security verify-cert -c leaf.pem -p codeSign in both contexts, and where find-certificate -c “Apple Worldwide Developer Relations” finds it.

inferred Keychain lock-on-sleep = 300 s

The job printed lock-on-sleep timeout=300s for the ephemeral keychain — the create-keychain default. A 20-second ls sign never hits it; a real xcodebuild archive will, and fails with the same errSecInternalComponent mid-build. Fix: security set-keychain-settings “$KC” (no flags = never lock) right after create-keychain. Not observed failing yet; test as its own run.

evidence block — paste before codesign, run in both contextscopy

echo "=== LAUNCH CONTEXT ==="
launchctl managername
ps -o pid,ppid,comm -p $$ $PPID

echo "=== LEAF ISSUER ==="
security find-certificate -c "Apple Distribution" -p "$KC" > leaf.pem
openssl x509 -in leaf.pem -noout -subject -issuer -enddate

echo "=== WHERE IS WWDR ==="
for k in "$KC" "$HOME/Library/Keychains/login.keychain-db" /Library/Keychains/System.keychain; do
  echo "-- $k"
  security find-certificate -a -c "Apple Worldwide Developer Relations" -Z "$k" 2>&1 | grep -E 'SHA-1|"labl"'
done

echo "=== TRUST EVAL IN THIS SESSION ==="
security verify-cert -c leaf.pem -p codeSign
security verify-cert -c leaf.pem -p codeSign -L    # no network fetch

# and ALWAYS cat codesign's stderr, on success too:
codesign --force --sign "$IDENT" --keychain "$KC" --timestamp=none "$TARGET" > codesign-output.txt 2>&1
RC=$?; cat codesign-output.txt; exit $RC
Share this post X in

Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.