LaunchAgent Path Mismatch Debugging
- LaunchAgent Path Mismatch Debugging: Tracking Down Silent Failures in macOS Service Automation
- What Is a Path Mismatch and Why Does It Fail Silently?
- Step 1: Verify the Exit Code with launchctl
- Step 2: Validate the Plist Before Loading
- Step 3: Test the Exact Execution Context
- Step 4: Check for Sandbox and Permission Boundaries
- Step 5: Unload, Fix, Reload Discipline
- Summary Diagnostic Sequence
- References
LaunchAgent Path Mismatch Debugging: Tracking Down Silent Failures in macOS Service Automation
LaunchAgent path mismatches are among the most frustrating silent failures in macOS service automation. The agent loads, launchctl reports no errors, and yet your service never runs. No crash report, no system log entry worth reading, just absence. This post walks through the specific mechanics of how path mismatches manifest, why they fail silently, and the exact diagnostic sequence to resolve them.
What Is a Path Mismatch and Why Does It Fail Silently?
A LaunchAgent plist defines a ProgramArguments array that points to an executable. When the path in that array does not exactly match a real, accessible binary, launchd will attempt the launch, fail to find the executable, and in many configurations simply stop — recording a 127 exit code (command not found) or a 2 (no such file or directory) that is easy to miss if you are not watching for it.
The silence is partly architectural. launchd is not an interactive shell. It does not resolve $PATH, it does not follow shell aliases, and it does not expand ~ in plist values. A path that works perfectly in your terminal session may be completely opaque to launchd.
<!-- This will fail silently — launchd does not expand ~ -->
<key>ProgramArguments</key>
<array>
<string>~/bin/my-agent</string>
</array>
<!-- Correct: absolute path required -->
<key>ProgramArguments</key>
<array>
<string>/Users/ops/bin/my-agent</string>
</array>
Step 1: Verify the Exit Code with launchctl
Start every debug session by asking launchctl what actually happened after the last attempted run.
# For user-space agents (GUI session)
launchctl list | grep com.example.myagent
# Output columns: PID, last exit code, label
# A dash in PID column means it is not currently running
# 127 = command not found, 2 = no such file
If you see exit code 127, the binary path is wrong or unresolvable. Exit code 2 usually means the path resolves to a directory boundary that doesn’t exist. Exit code 78 means the plist itself is malformed.
For a more verbose view:
launchctl print gui/$(id -u)/com.example.myagent
This prints the full service state including the last exit reason, which is far more useful than the truncated list output.
Step 2: Validate the Plist Before Loading
Before chasing a runtime path issue, confirm the plist itself is structurally valid:
plutil -lint ~/Library/LaunchAgents/com.example.myagent.plist
Then check what path is actually declared:
/usr/libexec/PlistBuddy -c "Print :ProgramArguments" \
~/Library/LaunchAgents/com.example.myagent.plist
Take that output and test the first element directly in your shell:
ls -la /Users/ops/bin/my-agent
file /Users/ops/bin/my-agent
Common failures at this stage: the binary exists but lacks execute permission (chmod +x was never run), the path points to a symlink whose target moved, or the binary is a script with a broken shebang line.
Step 3: Test the Exact Execution Context
Because launchd does not inherit your login environment, replicate its execution context manually:
# Strip your environment down to what launchd sees
env -i HOME=/Users/ops USER=ops /Users/ops/bin/my-agent
This frequently surfaces issues invisible in a normal terminal: missing dynamic libraries, Python virtual environment paths baked into a script, or a binary compiled against a Homebrew dependency that lives in /opt/homebrew/lib and is not available without the usual shell profile loading.
For agents that use a script interpreter, always use an absolute path in the shebang:
#!/usr/bin/env python3 # Wrong for launchd — env may not be found
#!/usr/local/bin/python3 # Correct, but verify this path exists
#!/opt/homebrew/bin/python3 # ARM Mac Homebrew location
Verify interpreter location with which python3 in your shell, then confirm it matches what the script declares.
Step 4: Check for Sandbox and Permission Boundaries
On macOS 13 and later, Full Disk Access and other TCC (Transparency, Consent, and Control) permissions affect whether a LaunchAgent can reach the paths it references. An agent trying to write to ~/Documents without FDA will fail with EPERM, which can look like a path problem.
# Watch real-time log output while manually triggering the agent
log stream --predicate 'subsystem == "com.apple.launchd"' --level debug &
launchctl kickstart -k gui/$(id -u)/com.example.myagent
Look for Sandbox or deny strings in the log stream. These confirm a permissions boundary rather than a pure path issue.
Step 5: Unload, Fix, Reload Discipline
Once you identify the problem, follow strict unload-before-modify discipline. Editing a plist while it is loaded produces undefined behavior:
launchctl bootout gui/$(id -u) \
~/Library/LaunchAgents/com.example.myagent.plist
# Make your fix, then:
launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/com.example.myagent.plist
launchctl list | grep com.example.myagent
Note that bootstrap/bootout are the modern replacements for load/unload on macOS 10.11 and later. Using the legacy forms on modern systems can mask errors.
Summary Diagnostic Sequence
launchctl list | grep <label>— check exit codelaunchctl print gui/<uid>/<label>— full service stateplutil -lint— validate plist structurePlistBuddy Print :ProgramArguments— extract declared pathsls -laandfileon the binary — confirm existence and permissionsenv -itest run — replicate launchd’s environmentlog streamwith predicate — catch sandbox denials
Path mismatches in LaunchAgents are almost always solvable within this sequence. The key discipline is treating launchd as an isolated execution environment with no inherited context, and verifying every assumption about paths, permissions, and interpreter locations from first principles.
Write a comment