launchd, not cron, on a Mac

Christian Drexler

Cron exists on macOS and it works, so the first scheduled job I put on a Mac went into a crontab. It ran until the next reboot, then did not, and the reason took an afternoon. Since then everything scheduled on a Mac lives in a launchd plist. Here is why, and the smallest version that works.

Why launchd

Three things cron does not give you on a Mac. A launchd agent loaded from ~/Library/LaunchAgents is started again after a reboot, as soon as your user session is up, with no extra step. It runs as your user with your environment, so the keychain, your PATH additions and your login items are where the job expects them. And it has first-class stdout and stderr paths, so you choose where the log goes instead of hunting for mail in /var/mail. Put logs under ~/Library/Logs, where Console.app already looks.

It also restarts a job that should stay up (KeepAlive), which cron cannot do at all.

The minimal plist

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
  <key>Label</key><string>local.daily-report</string>
  <key>ProgramArguments</key><array>
    <string>/usr/bin/python3</string>
    <string>/Users/me/jobs/daily_report.py</string>
  </array>
  <key>StartCalendarInterval</key><dict>
    <key>Hour</key><integer>7</integer><key>Minute</key><integer>40</integer>
  </dict>
  <key>StandardOutPath</key><string>/Users/me/Library/Logs/daily-report.log</string>
  <key>StandardErrorPath</key><string>/Users/me/Library/Logs/daily-report.log</string>
</dict></plist>

Save it as ~/Library/LaunchAgents/local.daily-report.plist (the file name must match the label), then launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.daily-report.plist. launchctl list | grep local. shows it loaded, and launchctl kickstart -k gui/$(id -u)/local.daily-report runs it now rather than waiting for 07:40. Paths are absolute everywhere: launchd has no idea what ~ or your shell's PATH mean. For "every N seconds" instead of a time of day, use StartInterval with a number.

The gotcha: Operation not permitted

The job ran by hand. Loaded under launchd it failed on the first line with /bin/bash: /Users/me/Documents/jobs/run.sh: Operation not permitted.

The script was under ~/Documents. macOS treats Documents, Desktop and Downloads as protected folders, and every binary that wants to read them needs permission from the user. When you run the script from Terminal, Terminal has that permission (you clicked a dialog once, long ago). Under launchd there is no Terminal in the chain; the interpreter itself, /bin/bash or /usr/bin/python3, asks for access, there is nobody at the screen to answer, and the call is refused. Oddly it is per binary: on my machine bash was denied in Documents while python3 sailed through, which made the failure look random until I read the full error.

Two fixes. The clean one: keep job scripts outside the protected folders, somewhere like ~/jobs or ~/.local/bin, and point the plist there. The quick one: System Settings, Privacy and Security, Full Disk Access, add the interpreter from the plist (/bin/bash, /usr/bin/python3). I use the first. Granting a shell full disk access to work around a folder choice is the wrong trade.

If a job runs by hand and not under launchd, check the log for that exact phrase before anything else. It has been the answer four times out of five.