LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging: When Your Daemon Thinks It Lives Somewhere Else

LaunchAgent path mismatches are one of the more frustrating classes of macOS service failures because they fail silently. The agent loads, launchd reports it as registered, but the binary never executes — or worse, it executes the wrong version. Understanding how launchd resolves paths and where mismatches originate will save hours of confusion when deploying or updating OpenClaw components.

How launchd Resolves Binary Paths

When launchd reads a .plist file, it takes the ProgramArguments array at face value. There is no $PATH expansion, no shell interpolation, and no symlink chasing at load time. The path you specify is the path launchd will attempt to execve() — full stop.

This means that a plist containing:

<key>ProgramArguments</key>
<array>
    <string>openclaw-agent</string>
</array>

will fail immediately because launchd does not search $PATH. The correct form requires an absolute path:

<key>ProgramArguments</key>
<array>
    <string>/usr/local/bin/openclaw-agent</string>
    <string>--config</string>
    <string>/etc/openclaw/agent.conf</string>
</array>

The distinction matters especially after package manager migrations. If a previous Homebrew installation placed the binary at /usr/local/bin/openclaw-agent and a new installation targeting Apple Silicon placed it at /opt/homebrew/bin/openclaw-agent, the plist still points to the old location — and launchd will not warn you.

Diagnosing the Mismatch

Start with launchctl list to check whether the agent is registered and what its last exit code was:

launchctl list | grep com.openclaw.agent

A healthy entry looks like:

12345   0   com.openclaw.agent

An unhealthy entry typically shows a PID of - and a non-zero exit code:

-   78  com.openclaw.agent

Exit code 78 is EX_CONFIG, which launchd uses to indicate that the plist itself or the referenced binary is misconfigured. Exit code 2 often means the binary was found but rejected its arguments. Exit code 13 is EACCES — the binary exists but the process lacks permission to execute it.

To inspect what launchd actually stored about the job, use launchctl print:

launchctl print system/com.openclaw.agent
# or for user agents:
launchctl print gui/$(id -u)/com.openclaw.agent

Look for the program field in the output. That is the resolved path launchd will execute. Cross-reference it against what is actually on disk:

ls -la /usr/local/bin/openclaw-agent
file /usr/local/bin/openclaw-agent

If the path in launchctl print points to a location where no file exists, you have confirmed the mismatch. If the file exists but file reports it as a symlink, follow the chain:

readlink -f /usr/local/bin/openclaw-agent

A broken symlink is a common post-migration artifact and produces a confusing failure because ls shows the file while execve() fails.

Checking the Plist Directly

Locate the installed plist and verify its contents have not drifted from your template:

cat ~/Library/LaunchAgents/com.openclaw.agent.plist
# or for system-level:
cat /Library/LaunchDaemons/com.openclaw.agent.plist

Validate the plist structure before reloading:

plutil -lint /Library/LaunchDaemons/com.openclaw.agent.plist

A valid file returns OK. Any structural error will prevent launchd from loading the job entirely, which is a different failure mode from a path mismatch but worth ruling out first.

The WorkingDirectory Trap

A subtler form of path mismatch involves relative paths inside the process itself rather than in the plist. If openclaw-agent opens a config file with a relative path like ./agent.conf, its behavior depends entirely on the WorkingDirectory key in the plist.

Without WorkingDirectory set, launchd starts the process with a working directory of /. A relative config path will resolve to /agent.conf, which does not exist, and the agent will exit with an error that looks like a configuration problem rather than a path problem.

Always set WorkingDirectory explicitly:

<key>WorkingDirectory</key>
<string>/etc/openclaw</string>

Then validate what the process actually sees by temporarily enabling StandardOutPath logging:

<key>StandardOutPath</key>
<string>/tmp/openclaw-agent.stdout</string>
<key>StandardErrorPath</key>
<string>/tmp/openclaw-agent.stderr</string>

Reload the agent and inspect both files:

launchctl unload ~/Library/LaunchAgents/com.openclaw.agent.plist
launchctl load ~/Library/LaunchAgents/com.openclaw.agent.plist
sleep 2
cat /tmp/openclaw-agent.stderr

The stderr output will frequently name the exact file or path the process could not open, making the mismatch immediately visible.

Correcting and Reloading Cleanly

Once you have identified and corrected the plist, do not simply overwrite the file and expect launchd to pick up the change. You must explicitly unload the old job definition before loading the corrected one:

launchctl unload /Library/LaunchDaemons/com.openclaw.agent.plist
# overwrite the plist with corrected version
launchctl load /Library/LaunchDaemons/com.openclaw.agent.plist

On macOS Ventura and later, use the bootstrap and bootout subcommands for system-level daemons:

sudo launchctl bootout system/com.openclaw.agent
sudo launchctl bootstrap system /Library/LaunchDaemons/com.openclaw.agent.plist

After reloading, confirm the PID appears in launchctl list and that the exit code column shows 0.

Preventing Recurrence

Encode the binary path as a variable in your deployment tooling and validate it exists before writing the plist. A simple pre-flight check in a shell deployment script:

BINARY_PATH=$(which openclaw-agent)
if [ ! -x "$BINARY_PATH" ]; then
    echo "ERROR: openclaw-agent not found or not executable at $BINARY_PATH"
    exit 1
fi

Generating plists programmatically from a single source of truth — rather than maintaining static files — eliminates the class of drift bugs where the plist and the installed binary diverge after an upgrade.

References

Write a comment