LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging

LaunchAgent path mismatches are one of the most quietly frustrating failure modes in macOS automation. The agent loads, the system reports it as active, but nothing executes — or worse, it executes once and silently stops. Understanding exactly how launchd resolves paths, and where that resolution can go wrong, turns a multi-hour debugging session into a five-minute fix.

How launchd Resolves Paths

When launchd reads a .plist file and prepares to launch a job, it does not inherit a normal user shell environment. There is no PATH expansion, no shell profile sourcing, and no ~ tilde resolution in the way you might expect from a terminal session. Every path in the ProgramArguments array is taken literally.

This matters because a plist like this will silently fail:

<key>ProgramArguments</key>
<array>
    <string>python3</string>
    <string>~/scripts/sync_agent.py</string>
</array>

launchd will attempt to execute a binary named python3 by searching PATH — but PATH in a launchd context is a minimal set, typically /usr/bin:/bin:/usr/sbin:/sbin. If your python3 lives at /usr/local/bin/python3 (Homebrew) or /opt/homebrew/bin/python3 (Apple Silicon Homebrew), it will not be found. The tilde ~ in the script argument also will not expand to /Users/yourusername.

Diagnosing the Failure

The first tool to reach for is launchctl, combined with the system log.

Check the job status:

launchctl list | grep com.yourorg.agentname

A successful, idle job shows a 0 exit code in the second column. A path-related failure often shows 78 (configuration error) or a non-zero process exit.

Stream the relevant log output:

log stream --predicate 'subsystem == "com.apple.launchd"' --level debug

Or target your specific label:

log show --predicate 'eventMessage contains "com.yourorg.agentname"' --last 10m

Look for messages containing posix_spawn, execve failed, or No such file or directory. These are the fingerprints of a path mismatch.

Manually trigger the job to capture immediate output:

launchctl kickstart -kp gui/$(id -u)/com.yourorg.agentname

The -p flag pipes stdout/stderr directly to your terminal session, bypassing any StandardOutPath configuration in the plist. This is invaluable for seeing exactly what error the process emits on launch.

The Three Most Common Path Failures

1. Binary not in launchd’s minimal PATH

Fix this by using the absolute path to your interpreter:

<key>ProgramArguments</key>
<array>
    <string>/opt/homebrew/bin/python3</string>
    <string>/Users/yourname/scripts/sync_agent.py</string>
</array>

Find the correct absolute path with which python3 in your normal shell, then hard-code it.

2. Tilde expansion in script paths

launchd does not expand ~. Replace every ~/ prefix with the full /Users/username/ path. If you are deploying agents for multiple users, use EnvironmentVariables with HOME set explicitly, or generate plist files dynamically with the expanded path baked in at install time.

3. Working directory assumptions

Scripts often construct relative paths assuming the current working directory. launchd sets the working directory to / by default. Add the WorkingDirectory key explicitly:

<key>WorkingDirectory</key>
<string>/Users/yourname/scripts</string>

Or, better, make all paths inside your script absolute using os.path.dirname(os.path.abspath(__file__)) in Python, or $(cd "$(dirname "$0")" && pwd) in shell.

Validating the Plist Before Loading

Before loading any agent, validate the plist structure:

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

Then do a dry-run check on the resolved binary path by running the ProgramArguments manually from a clean environment that mimics launchd:

env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin /opt/homebrew/bin/python3 /Users/yourname/scripts/sync_agent.py

The env -i flag strips your current environment entirely, giving you a close approximation of what launchd will see. If this command fails, your agent will fail.

Reloading After Corrections

After editing a plist, always unload before reloading to force launchd to re-read the file from disk:

launchctl unload ~/Library/LaunchAgents/com.yourorg.agentname.plist
launchctl load ~/Library/LaunchAgents/com.yourorg.agentname.plist

On macOS Ventura and later, the preferred syntax is:

launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.yourorg.agentname.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yourorg.agentname.plist

Note that launchctl load will silently succeed even with a broken plist in many cases — checking launchctl list afterward and triggering a kickstart is always worth doing.

A Reliable Debugging Checklist

When an agent fails to execute:

  1. Run launchctl list | grep <label> and note the exit code.
  2. Check logs with log show scoped to the last few minutes.
  3. Run launchctl kickstart -kp to capture live stderr output.
  4. Verify every path in ProgramArguments is absolute.
  5. Confirm no tilde characters appear anywhere in the plist.
  6. Test with env -i to simulate the minimal launch environment.
  7. Ensure the binary and script files have execute permissions (chmod +x).
  8. Validate the plist with plutil -lint.

Path mismatches are simple errors, but launchd’s silent failure behavior makes them easy to chase in the wrong direction. Working through this checklist in order typically surfaces the problem within the first three steps.

References

Write a comment