LaunchAgent Path Mismatch Debugging
LaunchAgent Path Mismatch Debugging: When Your Agent Refuses to Start
LaunchAgents are one of macOS’s most reliable persistence mechanisms — until they aren’t. A path mismatch between what your .plist declares and where the binary actually lives is one of the most common failure modes, and it fails silently enough to waste hours of debugging time. This post walks through the full diagnostic loop: identifying the mismatch, understanding why launchd behaves the way it does, and resolving it cleanly.
Understanding How launchd Resolves Paths
When launchd reads a LaunchAgent plist, it takes the ProgramArguments array at face value. There is no fuzzy matching, no $PATH lookup, and no fallback resolution. If the binary at index zero of ProgramArguments does not exist at the exact absolute path specified, launchd will either refuse to load the job or load it into a perpetual restart loop, depending on the macOS version and the KeepAlive setting.
A minimal plist looks like this:
<?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>/usr/local/bin/myagent</string>
<string>--config</string>
<string>/etc/myagent/config.yaml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
If /usr/local/bin/myagent does not exist — perhaps because a package manager installed it to /opt/homebrew/bin/myagent on an Apple Silicon machine — the agent will fail to launch with no obvious indication to the user.
Step 1: Confirm the Load Status
Start with launchctl list. This command queries the current session’s launchd and returns a table of loaded jobs.
launchctl list | grep myagent
Three outcomes are possible:
- No output at all: The plist was never loaded, or was unloaded.
- A PID is shown: The binary is running.
- A zero PID with a non-zero exit code: The binary launched and exited, which often indicates a crash or path error.
- 78 com.example.myagent
The - in the PID column with exit code 78 is a strong signal. Exit code 78 on macOS corresponds to EX_CONFIG, meaning launchd encountered a configuration error — commonly a path it cannot resolve.
Step 2: Read the Actual Error from launchd
Pull structured error information using launchctl print:
launchctl print gui/$(id -u)/com.example.myagent
Look for the last exit code and path fields. If launchd cannot find the binary, you will see output similar to:
last exit code = 78
last exit reason = spawn failed: No such file or directory
On older macOS versions, launchctl print may not be available. Fall back to the system log:
log show --predicate 'subsystem == "com.apple.launchd"' \
--info --last 5m | grep myagent
Or check the unified log more broadly:
log show --predicate 'process == "launchd"' \
--info --last 10m | grep -i "myagent\|spawn\|path"
Step 3: Verify the Binary Path Directly
Do not assume you know where the binary is. Verify it:
# Check the path declared in the plist
/usr/local/bin/myagent --version
# Find where the binary actually lives
which myagent
type -a myagent
find /usr /opt /usr/local -name "myagent" 2>/dev/null
On Apple Silicon Macs, Homebrew installs to /opt/homebrew/bin/ rather than /usr/local/bin/. A plist written on an Intel machine and copied to an Apple Silicon machine will have a stale path. This is the single most common source of path mismatches in cross-architecture deployments.
Step 4: Check Permissions and Executable Bits
A file can exist at the correct path but still fail if permissions are wrong:
ls -la /opt/homebrew/bin/myagent
# Expected: -rwxr-xr-x
If the executable bit is missing:
chmod +x /opt/homebrew/bin/myagent
Also verify that the file is not a broken symlink:
readlink -f /opt/homebrew/bin/myagent
A dangling symlink will produce a path that resolves to nothing, and launchd will report the same No such file or directory error even though the symlink itself appears in the filesystem.
Step 5: Correct the Plist and Reload
Once the correct path is confirmed, update the plist in-place:
# Unload the broken job first
launchctl unload ~/Library/LaunchAgents/com.example.myagent.plist
# Edit the plist
nano ~/Library/LaunchAgents/com.example.myagent.plist
# Change /usr/local/bin/myagent to /opt/homebrew/bin/myagent
# Reload
launchctl load ~/Library/LaunchAgents/com.example.myagent.plist
# Confirm
launchctl list | grep myagent
On macOS Ventura and later, prefer the bootstrap/bootout subcommands:
launchctl bootout gui/$(id -u) \
~/Library/LaunchAgents/com.example.myagent.plist
launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/com.example.myagent.plist
Preventing Path Mismatches at Install Time
The clean solution is to never hardcode architecture-specific paths in distributed plists. Use a wrapper script at a stable, architecture-independent location, or use command -v in an install script to resolve the real path at install time:
BINARY_PATH=$(command -v myagent)
sed -i '' "s|BINARY_PATH_PLACEHOLDER|${BINARY_PATH}|g" \
com.example.myagent.plist
This pattern resolves the path once during installation and embeds the correct absolute path, eliminating runtime ambiguity regardless of which architecture or package manager layout is in use.
Write a comment