LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging

LaunchAgent path mismatches are among the most silent failure modes in macOS service management. The agent loads, launchctl reports it as running, and yet nothing executes — no logs, no errors, just a process that believes it is healthy while doing absolutely nothing. Understanding why this happens and how to reliably diagnose it is essential for anyone managing persistent background services on macOS.

What a Path Mismatch Actually Means

A LaunchAgent plist defines a ProgramArguments array that points to an executable. When the path in that array does not resolve to a real, executable binary at the moment launchd attempts to invoke it, the agent silently fails. This can happen for several reasons:

  • The binary was installed to a different prefix than the plist expects (e.g., /usr/local/bin vs /opt/homebrew/bin on Apple Silicon)
  • A version upgrade moved or renamed the binary
  • A symlink in the path chain was removed or redirected
  • The plist was written with a hardcoded path that assumed a specific user’s home directory but was loaded under a different user context

The critical detail is that launchd does not always surface these failures loudly. The job may be listed as loaded with no exit code recorded, particularly if the failure happens before the process even starts.

Initial Triage: Confirm What launchd Actually Sees

Start with the basics. List the loaded agent and pull its full state:

launchctl list | grep com.yourorg.youragent

A healthy entry shows a PID in the first column. A - in the PID column with a non-zero exit code in the second column means the process launched and died. A - with 0 means it either has not run yet or exited cleanly — which is its own problem if you expected it to be running.

For richer detail:

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

This prints the full job dictionary as launchd understands it, including the resolved program and program arguments. Compare this output carefully against your plist file. Discrepancies here indicate the plist was modified after load without a reload cycle.

Resolving the Actual Binary Path

Before assuming the plist is wrong, verify the binary path independently:

which yourbinary
ls -la $(which yourbinary)
file $(which yourbinary)

On Apple Silicon Macs, many tools installed via Homebrew live under /opt/homebrew/bin rather than /usr/local/bin. A plist written on an Intel machine and copied to an M-series machine without modification will silently fail because the path no longer resolves. Confirm arch context matters here too:

arch -arm64 which yourbinary
arch -x86_64 which yourbinary

If the binary exists under one arch context but not the other, your LaunchAgent may be invoking under the wrong architecture context entirely.

Symlink Chain Verification

Many path mismatches are actually broken symlinks rather than missing files. A path like /usr/local/bin/node may be a symlink pointing to a version manager shim that was subsequently uninstalled:

readlink -f /usr/local/bin/node

Walk the entire chain until you reach a real file or the first broken link. A broken intermediate link is invisible to a simple ls check but will cause the execution to fail.

Checking the Plist with plutil

Malformed plists can cause unexpected path behavior. Validate the plist before reloading:

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

Then inspect the actual keys:

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

Pay attention to the ProgramArguments array. A common mistake is placing interpreter flags inside the binary path string rather than as separate array elements:

<!-- Wrong: flags embedded in path string -->
<string>/usr/bin/python3 -u /path/to/script.py</string>

<!-- Correct: each argument as its own array element -->
<string>/usr/bin/python3</string>
<string>-u</string>
<string>/path/to/script.py</string>

Embedding flags in the path string causes launchd to search for a binary literally named python3 -u, which will never be found.

Using StandardErrorPath for Ground Truth

If you control the plist, add a StandardErrorPath key before your next reload:

<key>StandardErrorPath</key>
<string>/tmp/com.yourorg.youragent.err</string>

Unload and reload the agent:

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

Trigger the job manually if it is not set to run immediately, then check:

cat /tmp/com.yourorg.youragent.err

Shell errors like No such file or directory or bad interpreter will appear here and immediately identify the broken path component.

Environment Variable Traps

LaunchAgents run in a minimal environment. The PATH available to your interactive shell is not the PATH launchd uses. If your ProgramArguments calls a wrapper script that itself invokes other binaries by name rather than full path, those inner invocations will fail because /opt/homebrew/bin and similar directories are not in launchd’s default PATH.

The fix is to either use absolute paths throughout, or define a PATH explicitly in the plist:

<key>EnvironmentVariables</key>
<dict>
    <key>PATH</key>
    <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>

This is frequently the root cause when a script works perfectly from the terminal but does nothing under launchd — the binary it depends on is present but unreachable in launchd’s stripped environment.

References

Write a comment