# Recipe — macOS enablement for agent work (permissions, app by app)

> The **new-Mac runbook for Apple's permission system**: trigger every prompt DELIBERATELY, in one sitting,
> in the right order — instead of meeting them one at a time over months, each as a mystery hang. A grant
> that is missing rarely says so: the usual symptom is a service that looks healthy and does nothing.
>
> **Candor:** every trap below was **measured on real fleet work**, not read in documentation — the dates are
> the incidents. The **sequence itself is unrun**: no machine has been enabled start-to-finish with it yet
> (the first will be a Mac Studio, Nov 2026). Treat the traps as evidence and the ORDER as a proposal until
> then; `maturity: authored`, not dogfooded. Where a prompt cannot be pre-granted without MDM, this recipe says so rather than
> pretending a command exists.

```
Legend  ✔ check · ⚠ gotcha · 🔴 needs a human at the keyboard (no command substitutes)
        This recipe creates nothing in any cloud.
```

## What you end up with

A machine where agents, daemons and CLIs run unattended: background services survive reboot, secrets are
readable without a prompt, and nothing is silently waiting on a dialog nobody saw. Plus a verification
sweep that distinguishes *granted* from *never asked*.

```
§0 identity ──► §1 the sitting (GUI, in order) ──► §2 per-surface grants ──► §3 make them stick
                                                        │
                    §4 headless traps ◄─────────────────┘        §5 launchd     §6 verify
```

## §0 Decide identity BEFORE granting anything

Grants are keyed to **(user, binary identity)**. Change either later and you re-do the sitting.

- **Which account runs services?** A dedicated service account that stays logged in, or your own. Every
  grant below is per-account: granting as you does nothing for the service account.
- **Short username** — renaming later invalidates paths baked into agents, plists and configs.
- **Hostname** — it ends up in claims, logs and mesh names. Pick the final one now.
- 🔴 **Auto-login vs FileVault.** With FileVault ON, auto-login is disabled: after a reboot, **no user
  session exists, so no launchd *agent* runs** until someone unlocks — including your mesh client and
  every agent that depends on it. macOS 26+ can unlock over ssh from the LAN with a password (keys are
  refused pre-boot, and the mesh is not up yet), see `man apple_ssh_and_filevault`. Decide deliberately:
  FileVault off + auto-login for an unattended box, or FileVault on and accept one manual unlock per boot.
  *(Measured 2026-09-16: a service host with FileVault on had no agents running after reboot; turning it
  off with auto-login made reboot recovery hands-free.)*

## §1 The sitting — order matters

Do all of this **at the keyboard, in a GUI Terminal**, logged in as the account that will run the work.
Not over ssh, not over screen sharing where a prompt may render on the other session (see §4).

1. **Enable remote access you will need later** (Remote Login for ssh, Screen Sharing). Do it first so
   later steps can be verified from another machine — but keep *using* the GUI session for this sitting.
2. **Install the tools** (package manager, runtimes, CLIs, agent harness, mesh client, vault).
3. **Set login items / background items** for anything that must survive reboot (mesh client especially —
   a GUI mesh app is per-session and starts on *login*, not on boot).
4. **Trigger each permission prompt on purpose** (§2) and answer it. This is the whole point of the sitting.
5. **Then** set up unattended secrets (vault arming, Touch ID) — it needs the GUI session (§4).
6. **Then** install launchd jobs (§5), because they inherit the grants you just made.
7. **Finally** verify from a second machine (§6).

## §2 Per-surface grants

System Settings → Privacy & Security. Each row: who needs it, the symptom when it is missing, and how to
provoke the prompt so you answer it now rather than at 2am.

| Surface | Typical consumer | Symptom when missing | Provoke it |
|---|---|---|---|
| **Files & Folders → Removable Volumes** | any service whose data lives on an external/attached disk | ⚠ **the killer**: the process starts, launchd reports `state = running`, and it **hangs before opening a port** with an empty log | run the binary once from Terminal with its data dir on that volume |
| **Files & Folders → Network Volumes** | backup/sync tools reading a mounted share | same silent stall | `ls` the share from the app, not from your shell |
| **Full Disk Access** | backup, indexing, anything reading another app's data | permission-denied on paths that exist | run the tool's scan once |
| **Automation** (app → app) | any script driving another app (`osascript`, window managers) | ⚠ the command **hangs until answered**; over ssh nobody can answer | run the smallest `osascript` the tool needs |
| **Accessibility** | window/desk managers, UI automation | windows silently do not move | run the arrange command once |
| **Screen Recording** | screenshot/vision tooling | black or empty captures, no error | take one screenshot |
| **Local Network** | any daemon dialing another machine on the LAN | ⚠ `nc` to the port **succeeds while the app's own dial is refused** — the probe lies | run the app's own connect, not `nc` |
| **Microphone / Speech Recognition** | voice input tooling | silence, no error | one short capture |
| **Login Items & Extensions → Background Items** | launchd agents, helpers | job "installed" but never runs after reboot | check the list after installing each job |
| **Keychain access** | vault/secret tooling | prompts per read, or refusals in non-GUI sessions (§4) | one read, then arm unattended |

> **Prefer provoking to pre-granting.** Most of these cannot be granted before the app has asked at least
> once: the app does not appear in the list until it requests. Deliberately provoking each one is what
> turns a month of ambushes into twenty minutes.

## §3 Make grants stick across updates

- **A grant follows the binary's code identity.** An **ad-hoc signed** binary (most downloaded CLIs, and
  anything you compile locally) is identified by its exact bytes, so **every upgrade is a new identity and
  re-prompts**. A binary signed with a stable identity (Developer ID, or your own certificate re-used for
  every build) keeps its grants. *(Measured 2026-09-16: an ad-hoc-signed storage server re-asks after each
  version bump; a service with a stable signing identifier does not.)*
- **Keep the bundle/signing identifier constant across releases**, or every machine re-prompts after an update.
- **Do not move the binary after granting.** Install to a stable path (`$HOME/.<tool>/bin/<tool>` with a
  version dir behind a symlink) so upgrades swap the symlink, not the path.
- ⚠ **Downloads carry quarantine only from browsers.** A `curl`-fetched binary has **no quarantine xattr**
  and runs without a Gatekeeper dialog; the same file downloaded in a browser is quarantined and blocked
  on first run. Prefer `curl` + a checksum you verify over a browser download you then have to un-quarantine.

## §4 Headless traps (the ones that cost the most)

An **ssh session is not a GUI session**, and several subsystems refuse it outright.

- ⚠ **The login keychain is unreadable over ssh.** Every armed/unattended secret read fails there with
  *"User interaction is not allowed"* — not just yours, all of them. **Arming and first reads must happen
  in a GUI session.** *(Measured 2026-09-17: an agent on another machine read the same vault fine locally
  and got refusals for every name over ssh — two true readings, different session contexts.)*
- ⚠ **Automation prompts cannot be answered over ssh.** An `osascript` probe run remotely **hangs
  indefinitely** while a dialog waits on a screen nobody is looking at. Never use AppleScript as a remote
  probe. *(Measured 2026-09-17.)*
- ⚠ **TCC decisions are fork-lineage sticky.** What a process may do partly depends on who launched it, so
  a service bootstrapped over ssh can behave differently from the same service started in a GUI session.
  **Bootstrap anything that touches protected resources from a GUI Terminal once**, then let launchd own it.
- ⚠ **A restart is blocked while other users are logged in.** A GUI restart request returns success and then
  does nothing, waiting on an authorization dialog. Log other users out first, or use `sudo shutdown -r now`.
  *(Measured 2026-09-16: a "restart" sat for 157 seconds with uptime unchanged.)*

## §5 launchd: agent vs daemon, and the limits nobody sets

- **Agent** (`~/Library/LaunchAgents`) runs inside a user session: no login, no agent. **Daemon**
  (`/Library/LaunchDaemons`, root) runs before login — but it still cannot read an encrypted disk before
  unlock, and it has no GUI session to answer prompts.
- ⚠ **A launchd job gets 256 open files by default** (`launchctl limit maxfiles` → `256 unlimited`). Real
  server workloads blow through that and surface as *other people's* errors: HTTP 500s, dropped
  connections, corrupt-looking internal state. Set them explicitly:
  ```xml
  <key>SoftResourceLimits</key><dict><key>NumberOfFiles</key><integer>61440</integer></dict>
  <key>HardResourceLimits</key><dict><key>NumberOfFiles</key><integer>61440</integer></dict>
  ```
  and have the wrapper log `ulimit -n` at start so the limit it actually got is in the log.
  *(Measured 2026-09-17: `Too many open files` under real load, invisible until the server's own log was read.)*
- **`KeepAlive` with `SuccessfulExit=false`** restarts on failure but respects a clean stop — which is what
  makes a refusing wrapper (wrong disk, already running) self-healing rather than a crash loop.
- ⚠ **launchd state is not health.** `state = running` is true of a process stalled on a permission prompt.
  **Health is a request from another machine**, never `launchctl print`.

## §6 Verification sweep — granted vs never asked

```bash
# 1. What did TCC decide recently, and for whom? (run on the machine, last 30 min)
log show --last 30m --style compact --predicate 'subsystem == "com.apple.TCC"' \
  | grep -E 'AUTHREQ_(PROMPTING|ATTRIBUTION)' | tail -20
#    AUTHREQ_PROMPTING + your binary = a dialog is (or was) on screen, unanswered

# 2. Background/login items actually registered
launchctl print "gui/$(id -u)" | grep -A2 -i 'com\.<your-org>\.'

# 3. Open-file limit the job really got (not what you wrote)
launchctl limit maxfiles; grep 'nofile=' "$HOME/Library/Logs/<service>.log" | tail -1

# 4. Secrets readable unattended IN A GUI SESSION vs over ssh — expect a difference, and know it
<vault> run NAME -- sh -c '[ -n "$NAME" ] && echo READABLE'          # GUI Terminal
ssh localhost '<vault> run NAME -- sh -c '\''echo READABLE'\'''       # expect refusal; that is normal

# 5. The only health check that counts: from ANOTHER machine
curl -fs http://<host>:<port>/health && echo REACHABLE
```

✔ A grant is confirmed when the consumer works **from the context it will really run in** — not when the
checkbox looks right.

## Deliberately not included

- **MDM / PPPC configuration profiles** — the real answer at fleet scale: they pre-grant TCC surfaces
  without prompts. Out of scope for a personally-owned machine, and worth revisiting if the fleet ever
  grows past hand-setup.
- **`tccutil reset`** — it only *revokes* (per service, or per bundle id). It cannot grant, and resetting a
  working surface means re-doing the sitting. Useful when testing a first-run experience; never a fix.
- **Disabling SIP or Gatekeeper** — not a shortcut, and not one this recipe will offer.
- **App-specific setup** (mesh login, vault init, harness config) — each tool's own plan owns that; this
  recipe owns only the permission surfaces they all trip over.
