← 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 was watching the fleet dashboard when the newer Mac minis started dropping off. One minute a worker was green and reporting in over the local network — the next it was gone. Then back, an hour later. Then gone again. Like a heartbeat skipping, except the process hadn’t crashed. It was still alive. I could see it in the process list. It just couldn’t open a TCP connection — the basic three-way handshake one machine uses to say “hello” to another — to anything else plugged into the same switch.

These are the agent workers that handle zero-touch enrollment across my fleet: Apple’s auto-setup mechanism where a Mac configures itself straight out of the box with nobody at the keyboard. The workers check in with a coordinator box over the LAN. For months they were rock solid — a pile of Mac minis spanning macOS 15.5 all the way up to 26.3, humming along. Then I rolled out a batch of newer machines and they started going dark.

I burned a full day on this. I chased firewall rules first — maybe the newer macOS had tightened pf or added a default-deny somewhere. Nothing. Then DNS — maybe the coordinator’s hostname wasn’t resolving on the new boxes and the connections were timing out somewhere I wasn’t looking. Also nothing. Two dead ends, one wasted day, and a fleet of workers that kept silently coming and going like a bad radio signal.

The real cause was Local Network Privacy.

That’s it. That’s the whole thing. Now the deep version.

Starting around macOS 15.7, Apple tightened enforcement of Local Network Privacy — LNP for short, the gate that makes an app pop up that dialog asking your permission before it can reach out to other devices on your home or office network. Any user-space process — meaning any code that isn’t the operating system kernel itself — that wants to open a LAN connection now needs the user’s explicit consent. The same “Allow this app to find and connect to devices on your local network?” prompt that pops up for ordinary apps.

A background worker installed as a LaunchAgent — which is just a macOS background job described by a plist file (an XML config) sitting in ~/Library/LaunchAgents/ — runs in your user context. It’s you, as far as the OS is concerned. With no one logged in to click “Allow,” its LAN connections get blocked.

Silently.

No log line. No error code. No crash dump. The process is alive and well, and its connection just never opens. The socket call blocks forever, and nothing anywhere tells you why.

That’s the worst kind of bug. You don’t find out it happened until you need what’s gone.

And here’s the detail that really got me: my older box on macOS 15.5 kept working the whole time. LNP enforcement hadn’t tightened there yet. The bug was already present — the worker was a LaunchAgent that needed LAN access — but the older OS let it slide. That masked the problem completely and made me think my newer install was broken. I was debugging the wrong machine.

The fix is sitting in Apple’s own Technical Note TN3179: LaunchDaemons run in the root context — root being the system’s all-powerful superuser account — and are exempt from LNP. So the answer is to install the worker as a daemon instead of an agent: plist goes in /Library/LaunchDaemons/ (notice the system-wide Library, not your home folder’s), owned by root, loaded into the system domain — which is launchd’s top-level namespace — rather than the per-user gui/$UID one.

Why does root-context exemption work? Because LNP is built entirely around user consent. A root daemon has no interactive user to consent on its behalf. There’s no one to show the dialog to. So the gate doesn’t apply. Running as root is the mechanism here, not a side effect.

Now, you don’t want your worker actually running as root. That’s a security hole you don’t need — a compromised worker with root privileges can do anything. launchd — macOS’s built-in process manager, the thing that starts, stops, and supervises every background job on the system — lets you drop privileges with two keys: UserName and GroupName. The daemon starts as root just long enough to clear the LNP gate, then runs your actual code as a normal, unprivileged user.

Best of both worlds.

If you ship networked daemons to Macs, here’s the rule: default to LaunchDaemon from day one and drop privileges explicitly. A LaunchAgent will appear to work — it’ll pass your smoke tests, it’ll run fine while you’re logged in — and then fail in production in ways that look exactly like a network problem but aren’t. Don’t wait for the silent failures to teach you, the way they taught me.

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.


Now let me verify this against every rule and constraint.