LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging: When Your Agent Runs from the Wrong Place

LaunchAgents on macOS are deceptively simple until they aren’t. One of the most frustrating failure modes is a path mismatch — where launchd loads your plist successfully, the job appears in launchctl list, but the agent silently fails or runs the wrong binary. No crash, no obvious error, just a process that never does what you expect. This post walks through the diagnostic chain for path mismatch issues, with concrete commands and the specific error patterns to watch for.

Understanding How launchd Resolves Paths

When launchd reads a plist, it does not inherit your shell’s PATH or any user environment variables set in .zshrc, .bash_profile, or similar files. The environment at launch time is minimal — typically just the system-level defaults. This means any relative path in your ProgramArguments key is resolved from an unpredictable working directory, usually /, and any binary that depends on PATH lookup will fail silently.

Consider a plist like this:

<key>ProgramArguments</key>
<array>
    <string>python3</string>
    <string>/Users/agent/scripts/monitor.py</string>
</array>

launchd will search for python3 using its own minimal PATH, which typically resolves to /usr/bin/python3 — not the Homebrew or pyenv version your agent was designed to use. Your script may load, run, import packages, and then crash because the wrong interpreter is missing a dependency. The job exit code may be 1 with no further context logged unless you have StandardErrorPath configured.

The Diagnostic Chain

Step 1: Confirm what binary is actually running.

launchctl list | grep com.yourorg.agentname

This shows the PID if the job is running. Then:

ps aux | grep <PID>
lsof -p <PID> | grep txt

The txt entries in lsof output show the actual binary mapped into the process. Compare this against what you intended to run.

Step 2: Check the effective working directory.

lsof -p <PID> | grep cwd

If the working directory is / or /var/root, that confirms launchd launched the process without a WorkingDirectory key, and any relative file references in your script will resolve incorrectly.

Step 3: Inspect the full environment the job sees.

launchctl environ <key>

Or load a diagnostic job that dumps its own environment:

<key>ProgramArguments</key>
<array>
    <string>/bin/sh</string>
    <string>-c</string>
    <string>env > /tmp/agent_env.txt</string>
</array>

Load it with launchctl load, trigger it, and read /tmp/agent_env.txt. You will typically see PATH=/usr/bin:/bin:/usr/sbin:/sbin — nothing from your user shell configuration.

Step 4: Review StandardErrorPath output.

If your plist doesn’t have this key, add it immediately:

<key>StandardOutPath</key>
<string>/tmp/com.yourorg.agentname.stdout.log</string>
<key>StandardErrorPath</key>
<string>/tmp/com.yourorg.agentname.stderr.log</string>

Reload the agent and reproduce the failure. Error messages that were previously silent will now be captured. A common output at this stage:

/bin/sh: python3: command not found

or, more subtly:

ModuleNotFoundError: No module named 'requests'

Both indicate path resolution is using the wrong environment.

Fixing the Mismatch

Use absolute paths for everything.

The cleanest fix is eliminating ambiguity entirely. Replace interpreter names with fully qualified paths:

<key>ProgramArguments</key>
<array>
    <string>/opt/homebrew/bin/python3</string>
    <string>/Users/agent/scripts/monitor.py</string>
</array>

Find the correct path with which python3 in your interactive shell, or use command -v python3 to get the resolved path without shell aliases interfering.

Set PATH explicitly in the plist.

For cases where the binary needs to locate other tools at runtime:

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

Set WorkingDirectory explicitly.

<key>WorkingDirectory</key>
<string>/Users/agent/scripts</string>

This anchors relative file paths in your script to a predictable location.

A Note on User vs. System Context

LaunchAgents in ~/Library/LaunchAgents/ run in the user’s login session context. LaunchDaemons in /Library/LaunchDaemons/ run as root in a system context. A common mismatch occurs when developers test a script interactively as themselves, then deploy it as a daemon where the home directory, keychain access, and filesystem visibility differ. Running launchctl print system/<service> versus launchctl print user/<UID>/<service> will show you the distinct execution contexts and help confirm which domain your agent actually lives in.

Validating Before Loading

Before loading any plist, validate its structure:

plutil -lint /Library/LaunchAgents/com.yourorg.agentname.plist

And confirm the binary it references actually exists and is executable:

test -x /opt/homebrew/bin/python3 && echo "OK" || echo "Missing or not executable"

These two checks catch the majority of path mismatch issues before they become runtime failures.

Path mismatches in LaunchAgents are almost always rooted in the assumption that launchd shares your shell’s context. It doesn’t. Treat every plist as running in a clean room with no inherited environment, and your agents will behave consistently across reboots, user switches, and deployment environments.

References

Write a comment