LaunchAgent Path Mismatch Debugging

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

  1. launchctl list | grep <label> — check exit code
  2. launchctl print gui/<uid>/<label> — full service state
  3. plutil -lint — validate plist structure
  4. PlistBuddy Print :ProgramArguments — extract declared paths
  5. ls -la and file on the binary — confirm existence and permissions
  6. env -i test run — replicate launchd’s environment
  7. log stream with 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.

References

Write a comment