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.
- A hang: the process is running (it shows up in ps), 0% CPU, state S, forever. No log output at all, not even your script's first print statement. It never finished starting.
- A crash-loop: launchctl print shows last exit code = 126 and the run count climbing fast. The log shows something like shell-init: error retrieving current directory: getcwd: cannot access parent directories: Interrupted system call, or an InterruptedError raised from inside Python's own import machinery before your code has run a single line.
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.myjobIf 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 copyIf 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.txtA 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'.