The cron had one job. Once a day, walk into a Next.js repo on
/Volumes/b/, run two GitHub-API fetcher scripts, and if the
generated JSON had changed, commit + push. The push would trigger
the deploy pipeline already in place. End-to-end maybe ninety
seconds of work on the machine.
The plist was twenty lines. StartCalendarInterval for 03:17,
ProgramArguments pointed at the shell script, logs to
~/Library/Logs/. Symlink the plist into ~/Library/LaunchAgents/,
launchctl bootstrap "gui/$UID" …, done.
Bootstrap failed: 5: Input/output error
Try re-running the command as root for richer errors.
That was day one of three.
What I was actually fighting
Three macOS facts intersecting, all subtle, none of which I knew when I started.
noowners
/Volumes/b/ is the external SSD where most of my work lives.
mount | grep '/Volumes/b' shows:
/dev/disk7s1 on /Volumes/b (hfs, local, nodev, nosuid, journaled, noowners)
That noowners is the kernel telling the volume “ignore
POSIX ownership; treat every file as belonging to whoever’s
asking.” macOS does this by default on removable / external
volumes because it doesn’t trust their ownership records. It
matters because the next two layers cascade off it.
TCC
TCC is Transparency, Consent, and Control. It’s the framework
behind System Settings → Privacy & Security. It guards a
list of locations: ~/Documents, ~/Downloads, ~/Desktop,
every external or noowners volume, and a few others. To read
a file in any of those locations, the binary doing the reading
needs to be in TCC’s allow-list for Full Disk Access.
When I open Terminal and cat a file on /Volumes/b/, it works,
because Terminal.app has FDA already (you almost certainly granted
it years ago without remembering). When launchd spawns my shell
script at 3am, the resulting bash process has no inherited
Terminal-FDA. It tries to open() the script’s file
descriptor and the kernel returns EPERM (Operation not
permitted).
This is the symptom the err log shows. Once you see it, you recognise it everywhere.
Per-binary identity
TCC keys off code-signed binary identity. That sentence is
load-bearing. When I tried to be clever (created a wrapper script at
~/bin/captainrandom-refresh.sh that just cdd into the working
directory and ran the real script) and added that wrapper to FDA
via System Settings, the grant appeared to take. The UI showed the script with a toggle, the toggle was on,
everything looked correct.
It did nothing. The wrapper’s own bash was still
unprivileged for /Volumes/b/. Granting FDA to a script in
System Settings is a UI fiction: macOS doesn’t actually
attach a TCC grant to the script, it tries to attach it to
/bin/bash, which is a system-managed binary that already has a
fixed TCC profile that doesn’t include external-volume
reads. The grant has nowhere to land.
I tried FD redirection (exec /bin/bash < script.sh, opening
the file in the FDA-granted parent then handing the FD to a
child). Same result. The wrapper itself can’t open() the
file in the first place, so there’s no FD to hand down.
That was day two.
The fix
What TCC can attach a grant to is an .app bundle with a real
CFBundleIdentifier. The bundle has a stable identity macOS can
target, and child processes spawned from the bundle inherit the
grant.
The cheap way to get a real bundle is osacompile, the
AppleScript compiler. Wrap a do shell script invocation:
osacompile -o ~/Applications/CaptainRandomCron.app -e \
'do shell script "/bin/bash /Volumes/b/.../scripts/local-refresh.sh \
>> $HOME/Library/Logs/captainrandom-refresh.out.log \
2>> $HOME/Library/Logs/captainrandom-refresh.err.log"'
By default osacompile doesn’t add a CFBundleIdentifier,
so the grant still has nothing stable to attach to. Patch the
Info.plist with PlistBuddy and ad-hoc re-sign:
/usr/libexec/PlistBuddy -c \
"Add :CFBundleIdentifier string co.uk.captainrandom.cron" \
~/Applications/CaptainRandomCron.app/Contents/Info.plist
codesign --force --deep --sign - ~/Applications/CaptainRandomCron.app
Now the .app has a real bundle ID (co.uk.captainrandom.cron)
and a self-signed code signature. TCC has something to bind a
grant to.
Update the LaunchAgent plist to point at the .app’s
applet (the AppleScript runtime executable that lives inside
the bundle):
<key>ProgramArguments</key>
<array>
<string>/Users/<your-username>/Applications/CaptainRandomCron.app/Contents/MacOS/applet</string>
</array>
System Settings → Privacy & Security → Full Disk
Access → drag the .app in → toggle on. launchctl bootstrap the LaunchAgent. launchctl kickstart -k to force a
test run.
[2026-05-21T00:16:51Z] cd /Volumes/b/01_PROFESSIONAL/Web_Development/captain_random
[workshop-xp] fetching repos for CaptainRandom00 …
[workshop-xp] 36 repos
…
[2026-05-21T00:17:20Z] pushed data refresh for 2026-05-21
Twenty-nine seconds end-to-end. The push triggered the deploy
pipeline. Two minutes later the live site’s last-modified
header flipped.
What I’d tell past-me
Don’t bother granting FDA to a script. It looks like it works in System Settings. It doesn’t. The toggle is real in the UI, the effect is not. If you find yourself doing this, stop and build a bundle.
Don’t symlink LaunchAgent plists from /Volumes/*. The
noowners mount option breaks launchd’s plist loader with
the same Bootstrap failed: 5: Input/output error regardless of
how correct the plist content is. Copy the plist into
~/Library/LaunchAgents/ as a real file. If you want to keep the
source in a repo, copy on install + reload on update.
The AppleScript shim is fine. It’s an osacompile
one-liner, the applet is a real Mach-O binary, TCC accepts it.
You don’t need to write an actual AppleScript. do shell script passes through to your real shell logic and the shim
disappears.
If you ever rebuild the .app, re-grant FDA. The grant
follows the bundle identifier, but a fresh osacompile output
has no identifier until you add it back with PlistBuddy. If you
don’t re-grant, the EPERM returns immediately.
Why I’m writing this down
The diagnosis chain took me three days. Most of the time I was
chasing the wrong layer: shell quoting, plist syntax, a chmod +x
miss. The real failure was always TCC. The error messages don’t name
TCC. The launchd logs don’t name TCC. You only find it by
reading the right Stack Overflow thread or by stumbling into the
right Apple developer forum post. The diagnosis chain is in the
build log at
docs/devlog.md
in three entries dated 2026-05-19, 2026-05-20, and 2026-05-21.
This post is the consolidated form.
If you’re building anything on macOS that needs scheduled
file access outside the home volume (backups, sync jobs, build
scripts that touch external SSDs), the .app-bundle pattern is
what you’ll end up at. Skip the three days; go straight
there.