LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging: When Your Agent Refuses to Start

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: your plist looks correct, launchctl reports the job as loaded, but the agent silently refuses to start, exits immediately, or logs nothing at all. This post walks through the diagnostic methodology for tracking down path-related failures in LaunchAgent configurations.

Understanding How launchd Resolves Paths

Unlike a shell, launchd does not source your user environment. It does not read .zshrc, .bash_profile, or any user-defined PATH exports. When a LaunchAgent executes, it inherits a minimal environment — typically just PATH=/usr/bin:/bin:/usr/sbin:/sbin. This is the root cause of the majority of “it works in terminal, not as an agent” bugs.

A plist that references /usr/local/bin/python3 will work. A plist that references python3 and relies on PATH expansion will fail silently.

<!-- This will fail silently -->
<key>ProgramArguments</key>
<array>
    <string>python3</string>
    <string>/Users/ops/agent/run.py</string>
</array>

<!-- This is correct -->
<key>ProgramArguments</key>
<array>
    <string>/usr/local/bin/python3</string>
    <string>/Users/ops/agent/run.py</string>
</array>

Use which python3 in your terminal to resolve the absolute path, then hard-code that into the plist.

The Four Paths That Can Go Wrong

In a typical LaunchAgent plist, there are four distinct path references that each need to be verified independently:

  1. The plist file location — must live in ~/Library/LaunchAgents/ for user-scoped agents
  2. The Program or ProgramArguments[0] binary — must be an absolute path to an executable
  3. The working directory (WorkingDirectory) — must exist and be accessible at launch time
  4. Log file paths (StandardOutPath, StandardErrorPath) — parent directories must exist before the agent starts

A mismatch or non-existent path in any of these four locations will cause the agent to fail, and launchctl will often report the job as “loaded” anyway.

Diagnostic Commands

Check load status and recent exit codes:

launchctl list | grep com.yourorg.agentname

The output columns are PID, last exit status, and label. An exit status of 78 means the configuration file was malformed. Exit status 1 usually means the binary ran but failed. Exit status -1 or no PID with status 0 often points to a path resolution failure where the binary was never found.

Read the raw plist back through launchctl:

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

This prints the live loaded configuration, including resolved paths. Compare this output against your plist file on disk to catch silent substitution issues.

Verify binary permissions and existence:

ls -la /usr/local/bin/python3
file /usr/local/bin/python3

Confirm the binary exists, is executable, and is the architecture your system supports. Apple Silicon systems running Rosetta can produce confusing failures here.

Check the log output directly:

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

Then in a separate terminal, trigger the agent:

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

The -k flag kills any running instance first, -p prints the PID on success. Watch the log stream for the exact error message.

Working Directory and Log Path Failures

A common trap is configuring StandardOutPath to a directory that does not exist yet at the time the agent first runs. launchd will not create intermediate directories. If /Users/ops/logs/agent.log is specified but /Users/ops/logs/ does not exist, the agent will fail before your script executes a single line.

The fix is either to create the directory in advance or to add a bootstrapping step:

<key>ProgramArguments</key>
<array>
    <string>/bin/bash</string>
    <string>-c</string>
    <string>mkdir -p /Users/ops/logs && exec /usr/local/bin/python3 /Users/ops/agent/run.py</string>
</array>

This delegates directory creation to the script itself, before handing off to the real process.

Tilde Expansion Does Not Work

One subtle but consistent trap: launchd does not expand ~ in plist paths. A path written as ~/Library/Logs/agent.log will be interpreted literally as a path beginning with the tilde character, not your home directory.

Always use fully qualified paths:

<!-- Wrong -->
<string>~/Library/Logs/agent.log</string>

<!-- Correct -->
<string>/Users/ops/Library/Logs/agent.log</string>

If your agent is deployed across multiple users, use a postinstall script or configuration management to write the correct home directory path at install time.

Reloading After Changes

After editing a plist, you must unload and reload it explicitly. Simply saving the file does not update the running configuration:

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

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

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

The older load/unload commands still work but produce deprecation warnings in system logs on recent OS versions.

Summary

Path mismatch failures in LaunchAgents cluster around four predictable causes: relative binary names that assume shell PATH expansion, tilde notation that launchd never expands, missing parent directories for log files, and stale loaded configurations after plist edits. Methodical verification of each path reference — combined with launchctl print and targeted log streaming — resolves the vast majority of silent startup failures without needing to instrument the agent code itself.

References

Write a comment