LaunchAgent Path Mismatch Debugging
LaunchAgent Path Mismatch Debugging
LaunchAgents are one of macOS’s most reliable persistence mechanisms—until they aren’t. A path mismatch between what a .plist file declares and where the actual executable lives is among the most common and frustratingly silent failure modes in agent-based automation. The agent loads, launchctl reports no errors, and yet nothing runs. This post walks through the exact diagnostic sequence for identifying and resolving LaunchAgent path mismatches, with particular attention to the edge cases that make these failures hard to spot.
Understanding How launchd Resolves Paths
When launchd reads a .plist file, it does not search $PATH for the executable named in the ProgramArguments key. It expects an absolute path. If that path does not exist at load time, launchd will often register the job silently and only fail when it attempts to start the process.
The relevant key in a typical plist looks like this:
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/myagent</string>
<string>--config</string>
<string>/etc/myagent/config.yaml</string>
</array>
If /usr/local/bin/myagent was installed via Homebrew on Apple Silicon, the real path is /opt/homebrew/bin/myagent. That single character difference causes complete, silent failure.
The Diagnostic Sequence
Step 1: Check the job’s known state
launchctl list | grep com.example.myagent
A result like - 78 com.example.myagent means the last exit code was 78 (EX_CONFIG), which is a direct signal of a path or configuration problem. Exit code 2 often indicates the binary was not found at all.
Step 2: Inspect the plist directly
cat ~/Library/LaunchAgents/com.example.myagent.plist
Capture the value under ProgramArguments and test it in isolation:
/usr/local/bin/myagent --version
If that returns command not found, you have confirmed the mismatch. Now find where the binary actually lives:
which myagent
# or, more reliably:
command -v myagent
Step 3: Read the system log
launchd writes diagnostic output to the unified log. Filter it directly:
log show --predicate 'subsystem == "com.apple.launchd"' \
--info --last 10m | grep myagent
Or use log stream in a separate terminal before manually triggering the agent:
log stream --predicate 'process == "launchd"' --info
Common messages that confirm a path mismatch:
posix_spawnp("/usr/local/bin/myagent", ...): No such file or directory
Step 4: Verify file permissions and ownership
A path can exist but still be unreachable due to permissions. The plist itself must be owned by the user loading it, not writable by group or other, and the target binary must be executable:
ls -l ~/Library/LaunchAgents/com.example.myagent.plist
# Expected: -rw-r--r-- 1 youruser staff
ls -l /opt/homebrew/bin/myagent
# Expected: -rwxr-xr-x 1 youruser admin
A plist that is group-writable will be rejected with a security error, not a path error—but the symptom looks identical from the outside.
Common Path Mismatch Scenarios
Homebrew on Apple Silicon vs Intel
Intel Macs use /usr/local as the Homebrew prefix. Apple Silicon uses /opt/homebrew. Plists written on one architecture and copied to another fail immediately.
Fix: Use a consistent variable or detect at install time:
BREW_PREFIX=$(brew --prefix)
sed -i '' "s|/usr/local|${BREW_PREFIX}|g" com.example.myagent.plist
Symlinks and the /private prefix
macOS /tmp is a symlink to /private/tmp. If your plist references /tmp/myagent.sock as a StandardOutPath or working directory, some subsystems resolve the canonical path and flag a mismatch. Always use the canonical path:
realpath /tmp
# /private/tmp
Python virtual environments
A common pattern is referencing /path/to/venv/bin/python in a plist. If the virtual environment was recreated without updating the plist, the interpreter path changes (especially with version-specific directories like python3.11 vs python3.12).
Always reference a stable wrapper script rather than the interpreter directly, and version-pin the wrapper path explicitly.
Reloading Safely After a Fix
After correcting the path, unload and reload the agent properly. Skipping the unload step is a frequent source of confusion where old, broken configuration lingers in launchd’s job registry:
launchctl unload ~/Library/LaunchAgents/com.example.myagent.plist
launchctl load ~/Library/LaunchAgents/com.example.myagent.plist
On macOS Ventura and later, prefer the bootstrap/bootout domain model:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.example.myagent.plist
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myagent.plist
Confirm the agent started successfully and captured the right PID:
launchctl list com.example.myagent
A PID key present in the output means the process is running. The absence of PID with a non-zero LastExitStatus means the path problem persists.
Building a Pre-Install Validation Step
For any tooling that installs LaunchAgents programmatically, add a validation function before writing the plist:
validate_plist_binary() {
local binary_path="$1"
if [[ ! -x "$binary_path" ]]; then
echo "ERROR: Binary not found or not executable: $binary_path" >&2
return 1
fi
echo "OK: $binary_path"
}
validate_plist_binary "$(brew --prefix)/bin/myagent"
Running this check during install—before the plist is written—prevents the silent failure cycle entirely.
Write a comment