LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging: Tracing the Silent Failure

LaunchAgent path mismatches are one of the most deceptive failure modes in macOS automation infrastructure. The job loads cleanly, launchctl reports no errors, and yet your agent never runs — or worse, it runs intermittently with no obvious pattern. Understanding why this happens and how to systematically diagnose it will save hours of blind log-diving.


Why Path Mismatches Happen

A LaunchAgent plist defines the executable path, working directory, and any environment variables your process needs. When any of these paths drift from reality — due to a user rename, a Homebrew prefix change from /usr/local to /opt/homebrew on Apple Silicon, or a symlink that resolved differently at install time — the agent silently fails to execute.

The critical detail: launchctl validates plist syntax and schema, not filesystem reachability. A plist referencing /usr/local/bin/mytool on an M2 Mac where Homebrew lives at /opt/homebrew/bin/mytool will load without complaint. The failure surfaces only at execution time, and only if you know where to look.


The Diagnostic Stack

Start with the most direct question: what does launchd think it’s running?

# List the loaded agent and confirm its label
launchctl list | grep com.yourorg.agent

# Dump the resolved configuration
launchctl print gui/$(id -u)/com.yourorg.agent

The print subcommand is underused. It shows the live state including last exit code, pid, and the actual program path launchd has registered. An exit code of 78 (EX_CONFIG) means launchd found a problem at execution time. An exit code of 2 often means the binary itself exited with a usage error — frequently because it couldn’t find a config file at the hardcoded path.

Next, check the log stream directly rather than relying on Console.app:

log stream --predicate 'subsystem == "com.apple.launchd"' --level debug 2>/dev/null | grep com.yourorg.agent

For historical entries covering recent boot:

log show --predicate 'process == "launchd" AND eventMessage CONTAINS "com.yourorg.agent"' \
  --last 1h --info --debug

If the agent invokes a script rather than a binary, also filter on the script interpreter:

log show --predicate 'process == "bash" OR process == "python3"' \
  --last 30m --info 2>/dev/null | grep -A3 "your-agent-keyword"

Resolving the Mismatch

Once you’ve confirmed which path is wrong, the fix is almost always one of three things.

1. Absolute path divergence (most common on Apple Silicon)

<!-- Wrong on M-series Macs -->
<key>ProgramArguments</key>
<array>
  <string>/usr/local/bin/mytool</string>
</array>

<!-- Correct -->
<array>
  <string>/opt/homebrew/bin/mytool</string>
</array>

Use which mytool in a fresh shell to get the canonical path, not the path from your current session where aliases or PATH manipulation may obscure the truth.

2. Environment variable absence

LaunchAgents do not inherit your interactive shell environment. If your binary depends on PATH, DYLD_LIBRARY_PATH, or HOME being set to specific values, declare them explicitly:

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  <key>HOME</key>
  <string>/Users/youruser</string>
</dict>

A common trap: Python virtual environments. If your agent calls a venv interpreter, use the full path to the venv’s Python binary directly in ProgramArguments, not a wrapper script that activates the venv via source.

3. WorkingDirectory pointing to a nonexistent path

<key>WorkingDirectory</key>
<string>/Users/oldusername/projects/agent</string>

If the user was renamed or the directory moved, every relative path inside your program resolves to nothing. Always use absolute paths in WorkingDirectory and verify them after any system migration.


Reload and Verify

After editing a plist, do not just launchctl load again — unload it first, or launchd may cache the old configuration:

launchctl bootout gui/$(id -u)/com.yourorg.agent 2>/dev/null || true
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.yourorg.agent.plist
launchctl print gui/$(id -u)/com.yourorg.agent | grep -E "path|program|state|last exit"

Watch for state = waiting versus state = running versus no state entry at all. waiting with a configured StartInterval is healthy. Absence of a state line often means the plist was rejected silently.

For a quick sanity test before relying on launchd scheduling, run the exact ProgramArguments array manually as the target user, with no shell modifications:

env -i HOME=/Users/youruser PATH=/opt/homebrew/bin:/usr/bin:/bin /opt/homebrew/bin/mytool --config /etc/mytool.conf

The env -i flag strips your current environment completely, replicating the sparse environment launchd provides.


Systematic Prevention

Path mismatches compound over time in long-running infrastructure. Establish a validation step as part of any system migration or Homebrew prefix change:

#!/bin/bash
for plist in ~/Library/LaunchAgents/*.plist; do
  binary=$(defaults read "${plist%.plist}" ProgramArguments 2>/dev/null | \
           sed -n '2p' | tr -d ' "')
  [[ -x "$binary" ]] || echo "BROKEN: $plist -> $binary"
done

This won’t catch every edge case — nested arrays and Program keys versus ProgramArguments arrays need separate handling — but it flags the most common breakage quickly.


References

Write a comment