LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging: Tracing the Gap Between Registration and Reality

When a LaunchAgent silently fails to start, the culprit is often not a permissions error or a malformed plist — it’s a path mismatch. The executable path declared in the plist doesn’t match where the binary actually lives on disk. launchd loads the job, reports it as registered, and then quietly does nothing when the trigger fires. No crash, no log entry at the expected path, just silence. This post walks through the systematic process of diagnosing and resolving path mismatches in LaunchAgent configurations.


Understanding How launchd Resolves Paths

launchd does not use the shell’s PATH environment variable. When it evaluates the ProgramArguments key in a plist, it expects a fully qualified, absolute path to the executable. If you write /usr/local/bin/mytool and that binary lives at /opt/homebrew/bin/mytool, the job will fail — but the failure mode is subtle. launchd records the job as loaded, and on Apple Silicon Macs running Homebrew, this exact discrepancy is one of the most common sources of silent failures.

The distinction between Program and ProgramArguments matters here too. If both keys are present, Program specifies the executable and ProgramArguments provides the argument vector. If only ProgramArguments is present, the first element is treated as the executable. A mismatch in either location produces the same silent failure.


Initial Diagnostic Pass

Start with launchctl list to confirm the agent is actually loaded into the session:

launchctl list | grep com.example.myagent

A healthy registered job shows a PID when running, or - when idle, followed by the exit code and the label. An exit code of 78 indicates a configuration error — the plist was parsed but the job could not be started, frequently due to a missing executable.

-   78   com.example.myagent

Pull the full job description to inspect what path launchd actually has on record:

launchctl print gui/$(id -u)/com.example.myagent

Look at the program field in the output. This is the resolved value launchd intends to execute. Compare it against what you have on disk:

which mytool
ls -la /usr/local/bin/mytool
ls -la /opt/homebrew/bin/mytool

On Apple Silicon, Homebrew installs to /opt/homebrew, not /usr/local. If your plist was written for an Intel Mac and migrated without updating, the path is wrong and launchd cannot resolve it.


Reading the Actual Error from the System Log

launchd sends its error messages to the unified log, not to stderr or a predictable file path. The right tool is log:

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

Trigger the job manually in another terminal:

launchctl start com.example.myagent

The stream will show something like:

launchd[1]: (com.example.myagent) posix_spawn("/usr/local/bin/mytool", ...): No such file or directory

That message confirms the mismatch. You now have the exact path launchd is trying to use. For historical failures rather than live ones, query the log archive with a time window:

log show --predicate 'subsystem == "com.apple.launchd" AND process == "launchd"' \
  --last 1h --info

Correcting the Plist

Once you have confirmed the correct binary path, update the plist. A minimal corrected example:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.example.myagent</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/mytool</string>
        <string>--config</string>
        <string>/Users/operator/.config/mytool/config.toml</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/mytool.stdout.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/mytool.stderr.log</string>
</dict>
</plist>

Adding StandardOutPath and StandardErrorPath is not strictly related to the path mismatch, but it pays dividends immediately — any future failure during execution will produce a file you can inspect without touching the unified log.

After editing the plist, unload and reload the agent to force launchd to re-read it:

launchctl unload ~/Library/LaunchAgents/com.example.myagent.plist
launchctl load ~/Library/LaunchAgents/com.example.myagent.plist
launchctl list | grep com.example.myagent

Confirm the exit code column shows 0 or a - with a valid PID.


Hardening Against Future Drift

Path mismatches tend to recur after system upgrades, architecture migrations, or tool reinstalls. A few operational habits reduce recurrence:

Use which at plist generation time, not at write time. If you have any tooling that generates or templates plists, have it call which mytool during generation and embed the result rather than hardcoding an assumed path.

Validate plists before deploying them. plutil -lint catches structural errors but not path errors. Pair it with a direct existence check:

plutil -lint ~/Library/LaunchAgents/com.example.myagent.plist && \
  /usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" \
    ~/Library/LaunchAgents/com.example.myagent.plist | xargs ls -la

Pin the interpreter path for script-based agents. If your ProgramArguments runs a shell script, the first element is often /bin/bash or /usr/bin/python3. Verify those too — /usr/bin/python3 on a fresh macOS install may be a stub that prompts for Xcode Command Line Tools rather than a functional interpreter.


Path mismatches in LaunchAgents are frustrating precisely because launchd treats them as a quiet non-event. Combining launchctl print, the unified log stream, and a systematic comparison of declared versus actual paths resolves most cases in under ten minutes once you know where to look.


References

Write a comment