2026-09-03

A launchd job that dies with no code change

Nothing in the script changed. The permission under it did.

If part of your self-hosted setup runs on a Mac, sooner or later you'll have a launchd job that worked yesterday and does nothing today. You didn't touch the script. Nothing in the repo changed. It just stopped. On macOS this specific shape of failure is common enough to name: it's TCC (the privacy subsystem behind Full Disk Access) silently denying the job's access to a protected folder, and it dresses itself up as a broken script every single time.

What it actually looks like

There are two failure shapes, and neither one says "permission denied" anywhere obvious.

Both shapes are the same root cause: something in the job's call chain tried to touch a TCC-protected folder, typically anything under ~/Documents, and macOS blocked it instead of returning a clean error. A hang happens when the blocked call is a kernel-level open() with no one able to answer the (headless, unattended) permission prompt. A crash-loop happens when bash itself fails during shell-init trying to resolve its own working directory under that same protected path.

Why it reads like a broken script

The reason this fools people is that the exact same command, run by hand in Terminal, works. Your interactive shell already carries Full Disk Access, so testing the failure by re-running it yourself proves nothing except that you have permissions launchd doesn't. Chase the app logic instead and you'll burn an evening on the wrong layer entirely, a stale venv, a PATH difference, a leaked semaphore, before it occurs to you that the process that owns the failure isn't your code.

Telling a single-job failure from system-wide TCC breakage

Before you touch anything, find out whether this is one job or all of them. Kickstart a completely unrelated job you know was working yesterday and touch nothing else:

launchctl kickstart -k gui/$(id -u)/com.example.myjob

If that unrelated, previously-fine job fails the same way the instant you test it, this is systemic, TCC state changed on the Mac rather than in your repo, and fixing the script in front of you won't help. If it runs clean, the problem really is scoped to the one job, which usually means that job (or an interpreter it shells out to) was never granted access in the first place.

Read the job's actual stderr, don't guess

Skip theorising and go straight to what launchd itself recorded. Two checks, in order: the exit code, then the log bytes.

launchctl print gui/$(id -u)/com.example.myjob | grep "last exit"
# then read the actual StandardErrorPath file from the plist, not a cached copy

If the process is hanging rather than crashing, grepping the log gets you nothing, because there's no log output at all yet. For that shape, sample the stuck process directly. It reads the stack from outside and needs no cooperation from the blocked process:

sample <pid> 3 -file /tmp/launchd-hang.txt

A hang whose sample bottoms out in something like getcwd() or open$NOCANCEL, sitting below your own code in the stack, is TCC waiting on a decision no headless process can make. It will sit there forever rather than erroring out.

The fix targets a binary, not your script

Here's the part that catches people out even once they've correctly diagnosed TCC: the grant isn't attached to your script, or to the folder it lives in, or to the job's label. It's attached to the specific executable that opens the protected file. Moving your launch script to a path outside ~/Documents doesn't fix anything, because the interpreter that actually reads your files under ~/Documents still isn't the one holding the grant.

In practice that binary is usually /bin/bash (if your plist calls bash to run a script) or whichever python3 / node the script shells out to, and it's frequently not obvious which one, because a Homebrew upgrade can silently repoint a bare python3 on PATH to a brand-new point release that was never granted access, even while an older point release sitting right next to it was.

Fixing this requires a human at System Settings -> Privacy & Security -> Full Disk Access, adding or re-toggling the specific binary in question. There is no command-line grant, and no way for a script or agent to do this on its own behalf, the whole point of TCC is that the decision has to come from someone sitting at the keyboard, not from the process asking for access. If you're debugging this remotely with no one at the machine, the honest status is 'diagnosed, blocked on a human', not 'fixed'.

Topics

Still stuck? Send it to Emsden Studio and get a straight answer on what it is and what fixing it costs.

More notes

All notes