← All posts

My macOS Agent Workers Went Dark Until I Moved Them From LaunchAgent to LaunchDaemon

On macOS 15.7+, Local Network Privacy silently blocks user-space LAN connections. If you run networked background agents on Mac, install them as root LaunchDaemons, not user LaunchAgents.

  • macOS
  • launchd
  • agents
  • networking
  • devops

I run a fleet of agent workers across a pile of Mac minis on mixed macOS versions, anywhere from 15.5 to 26.3. They do zero-touch enrollment work and talk to a coordinator over the LAN. For months they were rock solid. Then I rolled out a batch of newer machines and they kept going dark — reachable for a while, then silently unreachable for hours, then back. No crash, no error in my logs. The process was alive. It just couldn’t open TCP connections to other machines on the same network.

I burned a day chasing firewall rules and DNS before I found the real cause: Local Network Privacy.

Starting around macOS 15.7, Apple tightened enforcement of LNP. Any user-space process that wants to open a LAN connection needs the user’s explicit consent — the same prompt apps get. A background worker installed as a LaunchAgent (the plist lives in ~/Library/LaunchAgents/) runs in your user context. With no one logged in to click “Allow,” its LAN connections get blocked. Silently. That’s why my older 15.5 box kept working: enforcement hadn’t tightened there yet, which masked the bug and made me think my newer install was broken.

The fix is in Apple’s own Technical Note TN3179: LaunchDaemons run in the root context and are exempt from LNP. So the answer is to install the worker as a daemon, not an agent — plist in /Library/LaunchDaemons/, owned by root, loaded into the system domain.

Why does root-context exemption work? LNP is built around user consent. A root daemon has no interactive user to consent on behalf of, so the gate doesn’t apply. Running as root is the mechanism, not a side effect.

You don’t want your worker actually running as root, though. launchd lets you drop privileges with UserName and GroupName keys — the daemon starts as root, gets the LNP exemption, then runs your code as a normal user. Best of both.

The general principle: on modern macOS, any background process that needs LAN connectivity must be a root LaunchDaemon. A LaunchAgent will appear to work and then fail in ways that look like a network problem but aren’t. If you’re shipping networked daemons to Macs, default to LaunchDaemon from day one and drop privileges explicitly — don’t wait for the silent failures to teach you.

The LaunchDaemon checklist give me the detail

Install the plist to /Library/LaunchDaemons/ (root-owned, 644). Because there’s no logged-in user, you have to set things a LaunchAgent gets for free: HOME, log paths, and pre-created log files.

<!-- /Library/LaunchDaemons/com.example.agentworker.plist -->
<key>UserName</key>   <string>worker</string>
<key>GroupName</key>  <string>staff</string>
<key>EnvironmentVariables</key>
<dict>
  <key>HOME</key> <string>/Users/worker</string>
  <key>PATH</key> <string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key>   <string>/var/log/agentworker.log</string>
<key>StandardErrorPath</key> <string>/var/log/agentworker.err</string>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>

Pre-create the logs so the dropped-privilege user can write them, then bootstrap into the system domain (not gui/$UID):

sudo touch /var/log/agentworker.log /var/log/agentworker.err
sudo chown worker:staff /var/log/agentworker.*
sudo chown root:wheel /Library/LaunchDaemons/com.example.agentworker.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.agentworker.plist
sudo launchctl print system/com.example.agentworker   # verify it's running

To test the LNP theory directly: keep the LaunchAgent version running with no user logged in, then try a LAN connection (nc -vz <peer> <port>). It hangs. The daemon version connects immediately.