Skip to main content
Corey Daley

Two Gates Guard Unattended macOS UI Tests

The most suspicious thing a CI machine can do is ask a question.

My Mac mini is supposed to be boring infrastructure. It lives in a closet with no monitor, runs Forgejo and a CI runner, and builds and signs a Mac App Store app before running its XCUITest suite. The entire value of a box like that is that I never have to visit it.

Then the UI tests started stopping on macOS permission dialogs. Not once — every run. I’d screen-share in, click Allow, rerun the job, and the next build would ask again. A permission that has to be granted by a human every time isn’t a permission. It’s a tiny recurring outage with an OK button.

The fix was realizing I’d been staring at two different macOS security systems that interrupt the same workflow. TCC controls privacy grants like Accessibility and Apple Events. Authorization Services controls whether the host can enter UI-automation mode at all. You can satisfy one completely and still get stopped by the other — and because both surface as “macOS keeps asking me to approve the tests,” conflating them is the easiest mistake in the world.

The runner is the thing that needs trust

XCUITest doesn’t grant your app permission to drive itself. Xcode builds a separate test runnerYourAppUITests-Runner.app — and that runner is the process macOS sees reaching into the accessibility tree and synthesizing events. So the first useful question isn’t “did I grant my app Accessibility?” It’s “what exactly is macOS evaluating?”

codesign -dr - /path/to/YourAppUITests-Runner.app

The part that matters is everything after designated =>:

designated => identifier "com.example.YourAppUITests.xctrunner"
  and anchor apple generic
  and certificate leaf[subject.OU] = "TEAMID1234"

If the runner is ad-hoc signed, that requirement effectively tracks the exact binary — rebuild the tests, the identity changes, and TCC treats it as a brand-new app that needs approving again. Mine wasn’t ad-hoc; the requirement was already stable, keyed on the bundle id and my developer team. That told me something important: the repeated prompt wasn’t ordinary “new binary, new TCC row” churn.

But stable signing only means a grant could persist. It doesn’t grant anything. And on a headless box, “click Allow once” is not a deployment strategy — it’s undocumented state with a mouse attached. Reproducible CI needs the grant to be managed, not manually clicked. For TCC, macOS gives you two realistic choices.

Two ways to pre-grant TCC, one of them sane

  1. Write the TCC database directly. Accessibility lives in the SIP-protected system store under /Library/Application Support/com.apple.TCC/. Editing it means disabling System Integrity Protection. On the machine that builds and signs my App Store release, weakening SIP to silence a dialog is the wrong kind of exciting.
  2. Deliver a PPPC profile through MDM. A Privacy Preferences Policy Control profile can pre-grant TCC services — but sensitive ones like Accessibility are only honored when the profile arrives through Mobile Device Management. A .mobileconfig you double-click is silently ignored for these services. It has to come from an MDM the device is enrolled in.

SIP-off was out. So the answer was: run my own MDM.

A one-device MDM

This is lighter than it sounds. MicroMDM is a single Go binary with built-in SCEP and TLS. Mine runs on the same mini it manages, which sounds absurd until you remember what MDM is buying here: a trusted delivery channel for one profile, not an org chart.

It runs as a user LaunchAgent — an Agent, not a root LaunchDaemon, because the server only needs to be up when there’s a logged-in session (which a box running GUI tests always has) and an Agent needs no root:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>            <string>local.micromdm</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/me/.local/bin/micromdm-run.sh</string>
    </array>
    <key>RunAtLoad</key>        <true/>
    <key>KeepAlive</key>        <true/>
    <key>StandardOutPath</key>  <string>/Users/me/mdm/logs/micromdm.log</string>
    <key>StandardErrorPath</key><string>/Users/me/mdm/logs/micromdm.log</string>
</dict>
</plist>

There’s one genuinely annoying step: MDM needs an APNs push certificate, and on a non-enterprise account that means the community mdmcert.download flow plus a trip to identity.apple.com. (A small gotcha — that service rejects free webmail addresses and wants a domain you control.) It’s a once-a-year errand, not a hard one. With the push cert loaded and the mini enrolled — a manual profile approval that makes it user-approved MDM, which is the bar PPPC requires — the only thing left is the payload.

The profile has to survive certificate rollover

A PPPC payload is a com.apple.TCC.configuration-profile-policy block. For each service, the fields that matter are the bundle identifier, identifier type, code requirement, and authorization:

<key>Services</key>
<dict>
  <key>Accessibility</key>
  <array>
    <dict>
      <key>IdentifierType</key>  <string>bundleID</string>
      <key>Identifier</key>      <string>com.example.YourAppUITests.xctrunner</string>
      <key>CodeRequirement</key>
      <string>identifier "com.example.YourAppUITests.xctrunner" and anchor apple generic and certificate leaf[subject.OU] = "TEAMID1234"</string>
      <key>Authorization</key>   <string>Allow</string>
    </dict>
  </array>
  <key>PostEvent</key>
  <array>
    <dict>
      <key>IdentifierType</key>  <string>bundleID</string>
      <key>Identifier</key>      <string>com.example.YourAppUITests.xctrunner</string>
      <key>CodeRequirement</key>
      <string>identifier "com.example.YourAppUITests.xctrunner" and anchor apple generic and certificate leaf[subject.OU] = "TEAMID1234"</string>
      <key>Authorization</key>   <string>Allow</string>
    </dict>
  </array>
</dict>

Accessibility lets the runner drive the Mac; PostEvent covers the CoreGraphics event posting that synthesizes input. If your tests send Apple Events to Finder, System Events, or the app under test, add an AppleEvents entry too — and watch out, because that grant is about a sender and a receiver, not just the runner: alongside the usual fields it needs AEReceiverIdentifier, AEReceiverIdentifierType, and AEReceiverCodeRequirement for each target app.

Now look at the CodeRequirement. The lazy version pins to a single signing certificate — and Apple Development certs rotate, so a routine renewal months from now silently breaks the grant and you’re left debugging a mystery. Keying on the bundle id and team OU instead, without pinning to one leaf certificate, means a rollover changes nothing. That one decision is the difference between “set it and forget it” and a confusing failure next year.

Push the profile to the enrolled device, then verify it landed — and here’s the part that trips people up. An MDM-delivered grant does not show up in the TCC.db access table everyone tells you to query. It lands in a separate file:

sudo plutil -p "/Library/Application Support/com.apple.TCC/MDMOverrides.plist"

There it was: Accessibility, PostEvent, and Apple Events, all granted by policy, immune to re-signing. I’d solved it. I ran the tests to celebrate.

And the test still stopped

A dialog appeared on the screen-shared desktop:

XCTest is trying to Enable UI Automation. Enter the password for the user “Corey” to allow this.

A password. Not Allow / Don’t Allow — a lock icon asking for admin credentials. That’s the tell. TCC asks whether an app may control or access something; Authorization Services asks whether the host may enter a privileged mode at all. No PPPC profile touches that second question. I’d built an entire MDM to grant a permission, and macOS calmly revealed a second, unrelated lock on the same door.

Apple ships a separate tool for this gate. Once you know the prompt is Authorization Services and not TCC, it’s one command (it needs admin, hence sudo):

sudo /usr/bin/automationmodetool enable-automationmode-without-authentication

Check the state with no arguments:

$ automationmodetool
Automation Mode is disabled.
This device DOES NOT REQUIRE user authentication to enable Automation Mode.

That second line is the goal. The setting persists across reboots. It grants no privacy access and installs no profile — it just removes the interactive password gate so XCTest can flip on UI-automation mode by itself.

Classify the prompt before you fix it

The takeaway isn’t “set up MDM” or “run automationmodetool.” It’s that a permission prompt is not one thing on macOS. When automation gets stuck, the first move isn’t hunting for the button — it’s naming the subsystem:

  1. Inspect what macOS is evaluating: codesign -dr - YourAppUITests-Runner.app.
  2. If the dialog asks whether an app may control or access something → it’s TCC. Pre-grant it with an MDM-delivered PPPC profile.
  3. Verify the managed grant in MDMOverrides.plist, not TCC.db.
  4. If the dialog asks for a password to enable UI Automation → it’s Authorization Services. Run automationmodetool.

The first problem is about trusting a process; the second is about allowing a machine state. MicroMDM isn’t in that closet because I wanted an MDM server — it’s there because PPPC needs a real management channel. automationmodetool isn’t a smaller PPPC — it’s a different lock entirely.

The mini runs its UI tests in silence now. Jobs come in, the runner gets its managed permissions, XCTest enables Automation Mode without a password, and the tests pass or fail for reasons that belong to the code. That’s all I ever wanted from the machine: not magic — just the courtesy of failing unattended.

When your automation stops to ask for permission, do you know which system is asking?