LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging: When Your Agent Refuses to Load

LaunchAgents are one of macOS’s most reliable mechanisms for running persistent background processes — until they aren’t. One of the most frustrating failure modes is the path mismatch, where a LaunchAgent plist is syntactically valid, references an executable that exists on disk, and still silently fails to load or run. This post walks through the diagnostic methodology for tracking down path mismatches, explains why they occur, and provides the specific commands you need to resolve them efficiently.


Understanding What “Path Mismatch” Actually Means

A path mismatch in LaunchAgent context isn’t always a simple “file not found” error. It encompasses several distinct failure categories:

  • The ProgramArguments key references an absolute path that resolves differently under the launch context (e.g., symlinks that don’t follow in restricted environments)
  • The executable exists but is not accessible to the user context under which launchd is loading the agent
  • The working directory (WorkingDirectory key) doesn’t exist or is inaccessible at load time
  • A wrapper script references binaries using relative paths or $PATH expansion that launchd does not honor

The critical thing to understand is that launchd does not inherit a normal shell environment. There is no $PATH, no ~ expansion, and no sourced profile. Every path in your plist must be fully qualified and resolvable without environment expansion.


Initial Triage: Loading State and Error Codes

Start with launchctl to check whether the agent is even registered:

launchctl list | grep com.yourorg.youragent

The output columns are: PID, last exit status, and label. A - in the PID column means the agent is not currently running. An exit status of 78 is particularly relevant — it maps to EX_CONFIG, indicating a configuration error that often originates from a bad path.

To get more detail on a specific agent:

launchctl list com.yourorg.youragent

This returns a dictionary including LastExitStatus. If you see a non-zero value, convert it:

# Exit status 256 means the underlying exit code was 1
# Exit status 32512 often means "executable not found" (errno 127 from the shell)
python3 -c "import os; print(os.strerror(72))"

For agents that fail before logging begins, check the system log:

log show --predicate 'subsystem == "com.apple.launchd"' --last 10m --info

Or more targeted:

log show --predicate 'process == "launchd" AND eventMessage CONTAINS "youragent"' --last 30m

Verifying the Executable Path

The most direct diagnostic is validating what launchd will actually see. Pull the ProgramArguments value from the plist:

/usr/libexec/PlistBuddy -c "Print :ProgramArguments" \
  ~/Library/LaunchAgents/com.yourorg.youragent.plist

Take the first element (the executable) and run a full verification:

EXEC_PATH="/usr/local/bin/myagent"

# Does it exist?
test -e "$EXEC_PATH" && echo "exists" || echo "missing"

# Is it executable?
test -x "$EXEC_PATH" && echo "executable" || echo "not executable"

# Is it a symlink, and where does it actually resolve?
readlink -f "$EXEC_PATH"

# What user will launchd run this as?
stat -f "%Su %Sg %Sp" "$EXEC_PATH"

A common trap: /usr/local/bin/myagent is a symlink to a Homebrew-managed binary inside /opt/homebrew/... on Apple Silicon. The symlink resolves fine in a terminal session but may traverse into a path that carries different permissions or doesn’t exist if Homebrew updated the package and rotated the binary path.


The Sandbox and User Context Problem

Even if the path resolves correctly, the agent may fail because launchd loads agents in a constrained context. Check if the plist itself has a UserName or GroupName key that differs from the file’s owner:

/usr/libexec/PlistBuddy -c "Print :UserName" \
  ~/Library/LaunchAgents/com.yourorg.youragent.plist

If the plist specifies a different user than the logged-in session owner, the agent won’t load from ~/Library/LaunchAgents at all — that directory is exclusively for the GUI session user. System-level agents that must run as another user belong in /Library/LaunchDaemons/, not LaunchAgents.

Validate plist syntax and key correctness with:

plutil -lint ~/Library/LaunchAgents/com.yourorg.youragent.plist

And do a full schema check with:

plutil -p ~/Library/LaunchAgents/com.yourorg.youragent.plist

Forcing a Clean Reload

When you’ve corrected a path issue, don’t assume launchctl load on an already-registered agent will pick up your changes. The correct sequence is:

# Unload first (suppress error if not loaded)
launchctl unload ~/Library/LaunchAgents/com.yourorg.youragent.plist 2>/dev/null

# Force load and watch for immediate error output
launchctl load -w ~/Library/LaunchAgents/com.yourorg.youragent.plist

# Confirm it's running
launchctl list com.yourorg.youragent

On macOS Ventura and later, prefer the bootstrap/bootout domain approach:

DOMAIN="gui/$(id -u)"
launchctl bootout $DOMAIN ~/Library/LaunchAgents/com.yourorg.youragent.plist
launchctl bootstrap $DOMAIN ~/Library/LaunchAgents/com.yourorg.youragent.plist

Capturing What the Agent Actually Sees

If the agent loads but misbehaves, add temporary output keys to your plist to capture its runtime environment:

<key>StandardOutPath</key>
<string>/tmp/youragent.stdout.log</string>
<key>StandardErrorPath</key>
<string>/tmp/youragent.stderr.log</string>

Then add a diagnostic line at the top of your wrapper script:

#!/bin/bash
echo "PATH=$PATH" >> /tmp/youragent.env.log
echo "PWD=$(pwd)" >> /tmp/youragent.env.log
echo "USER=$(whoami)" >> /tmp/youragent.env.log
which myexecutable >> /tmp/youragent.env.log 2>&1

This frequently reveals that the agent is running with a minimal PATH like /usr/bin:/bin:/usr/sbin:/sbin — which excludes /usr/local/bin and /opt/homebrew/bin entirely.


Summary

Path mismatch debugging in LaunchAgents demands that you abandon shell assumptions. Launchd is not a shell; it does not expand variables, follow your profile, or inherit your session PATH. Every path must be absolute, every executable must be reachable by the exact user context launchd will use, and every symlink chain must resolve cleanly. When in doubt, instrument the agent’s own startup to log what it actually sees at runtime — that evidence eliminates guesswork faster than any external diagnostic tool.


References

Write a comment