LaunchAgent Path Mismatch Debugging

LaunchAgent Path Mismatch Debugging

LaunchAgent path mismatches are among the most deceptive failures in macOS service management. The agent loads, the process appears in launchctl list, but the binary never actually executes — or worse, an outdated binary executes silently because the path resolves to a stale symlink or a previous installation artifact. This post walks through the exact diagnostic sequence used in OpenClaw infrastructure to identify, isolate, and resolve these mismatches before they cascade into session management failures.

Understanding What “Path Mismatch” Actually Means

A LaunchAgent path mismatch occurs when the ProgramArguments key in a .plist file points to a path that either does not exist, resolves to the wrong binary, or is inaccessible due to permission boundaries. There are three distinct failure modes:

  1. Hard miss: The path literally does not exist. launchd will log an error and the agent will enter a throttled state.
  2. Soft miss: The path exists but resolves through a symlink to the wrong target — often a leftover from a Homebrew upgrade or a version-pinned install.
  3. Permission miss: The binary exists and resolves correctly, but the owning user context lacks execute permissions, or the file is quarantined.

Each mode produces different behavior and requires a different diagnostic entry point.

Initial Triage with launchctl and log

Start by confirming the agent’s registered state:

launchctl list | grep com.yourorg.agentname

A registered agent shows a PID if running, or a dash with an exit code if it has stopped. An exit code of 78 (EX_CONFIG) almost always indicates a path resolution failure. An exit code of 1 with no corresponding crash log points toward a permission issue.

Next, pull the actual error from the unified log:

log show --predicate 'subsystem == "com.apple.launchd"' \
  --style syslog \
  --last 5m \
  | grep com.yourorg.agentname

A hard miss produces a line like:

launchd: (com.yourorg.agentname) Failed to exec: No such file or directory

A permission miss produces:

launchd: (com.yourorg.agentname) Failed to exec: Permission denied

If you see neither, you are likely dealing with a soft miss where the binary resolves but is the wrong version.

Resolving the Actual Binary Path

Do not trust what the plist says. Verify what the filesystem sees:

# Read the ProgramArguments value directly
/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" \
  ~/Library/LaunchAgents/com.yourorg.agentname.plist

# Then resolve the full chain
BINARY_PATH=$(/usr/libexec/PlistBuddy -c "Print :ProgramArguments:0" \
  ~/Library/LaunchAgents/com.yourorg.agentname.plist)

ls -la "$BINARY_PATH"
file "$BINARY_PATH"
readlink -f "$BINARY_PATH"

The readlink -f call is critical. If the plist points to /usr/local/bin/mycli and that symlink resolves to /usr/local/Cellar/mycli/1.2.0/bin/mycli, but version 1.3.0 is now installed and the old Cellar path has been removed, you have a classic Homebrew-induced soft miss.

Confirm the binary architecture matches the running system:

arch
file "$(readlink -f "$BINARY_PATH")"

An arm64 system running an x86_64-only binary without Rosetta installed will fail silently at the exec layer, not at the path resolution layer — which is why this step matters even when ls confirms the file exists.

Checking Quarantine and Gatekeeper State

A newly downloaded or copied binary may carry the quarantine extended attribute:

xattr -l "$(readlink -f "$BINARY_PATH")"

If you see com.apple.quarantine in the output, launchd will refuse to execute it without a user-interactive authorization step that can never happen in a daemon context. Clear it explicitly:

xattr -d com.apple.quarantine "$(readlink -f "$BINARY_PATH")"

For agents deployed programmatically across machines, embed this removal in your provisioning step, not as a manual remediation.

Validating plist Syntax and Ownership

A malformed plist can cause launchd to ignore the ProgramArguments key entirely and fail with a generic config error:

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

Ownership is equally important. LaunchAgents in ~/Library/LaunchAgents/ must be owned by the user loading them. LaunchDaemons in /Library/LaunchDaemons/ must be owned by root. A group-writable plist is rejected entirely for security reasons:

ls -l ~/Library/LaunchAgents/com.yourorg.agentname.plist
# Expected: -rw-r--r--  1 youruser  staff

If the plist is world-writable or group-writable, launchd will refuse to load it and log Path had bad ownership/permissions.

Forcing a Clean Reload

After correcting the path or permissions, do not rely on a simple bootout/bootstrap cycle if the agent was previously throttled. A throttled agent has a backoff timer that survives a naive reload:

# Use the domain target syntax for user agents
launchctl bootout gui/$(id -u) \
  ~/Library/LaunchAgents/com.yourorg.agentname.plist

launchctl bootstrap gui/$(id -u) \
  ~/Library/LaunchAgents/com.yourorg.agentname.plist

# Confirm the new state
launchctl print gui/$(id -u)/com.yourorg.agentname

The launchctl print output includes the last exit code, throttle interval, and program path as launchd has resolved it — not as the plist declares it. Comparing the program field in this output against your expected binary path is the definitive check.

References

Write a comment