Sandboxed helper keeps running after the app is turned off in Background App Activity

Short version: we run a sandboxed helper as a hidden service account, started at boot by an SMAppService daemon. It works, even before login. But when the user turns our app off in Background App Activity, only the daemon stops. The helper keeps running. Is this setup supported, and what's the right way to manage the helper?

What we want

A Developer ID signed, notarized app (not Mac App Store) with a helper that parses untrusted input. The helper should:

  • run as a dedicated, hidden, non-login local account;
  • use App Sandbox, with its own container;
  • be available before anyone logs in (after FileVault unlock).

What we built

An unsandboxed root LaunchDaemon, registered with SMAppService.daemon, runs this at boot:

launchctl bootstrap user/<serviceUID> <fixed-agent-plist>

The agent plist uses LimitLoadToSessionType=Background. The helper is a nested app in the same bundle, with com.apple.security.app-sandbox=true. We don't create a GUI session, change UID after the sandbox starts, or use private APIs.

What we measured

macOS 27.0 (26A428), arm64, dummy data only:

  • Register and approve: the daemon starts. The helper starts as UID 60000, its container works, and reads outside it are denied.
  • Turn the app off in Background App Activity: the daemon gets SIGTERM and stops. The helper keeps running (same PID).
  • Turn it back on: the daemon starts again. Its bootstrap returns exit 5, because the old helper is still loaded.
  • Call unregister(): the daemon stops. The helper keeps running.
  • Cold boot (tested with a plain /Library/LaunchDaemons job, not yet SMAppService): the helper started and worked before login finished.

For comparison, running the same sandboxed helper as a system daemon with UserName set to this account fails before main: Incoming message euid:60000 does not match secinitd uid:0.

Questions

  1. Is this setup supported for shipping, including the sandbox starting before anyone logs in?
  2. Does the approval for daemon-bundled helpers cover a helper bootstrapped into another account's domain?
  3. Our plan: when the daemon gets SIGTERM, it runs bootout on the helper and its domain, and it treats bootstrap exit 5 as "already loaded". Is that the intended pattern, or is there a supported way for the helper to follow the app's Background App Activity setting?
  4. If this setup isn't supported, what public mechanism gives a sandboxed helper its own non-root identity before login?

I can share the plists, entitlements and logs from a minimal reproducer.

You’re on very shaky compatibility ground here. What not doing anything wrong per se, but it’s quite unusual and thus I’m not surprised you’ve encountered some sharp edges.

In your setup, where on disk is <fixed-agent-plist>?

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thanks, Quinn. It depends on the setup:

  • In the reproducer from the post, it's a fixed file inside the app bundle: /Applications/<App>.app/Contents/Library/LaunchAgents/<label>.plist, bootstrapped by that absolute path.
  • In our current build, the daemon writes it at each start to /var/run/<label>.plist (owned by root, mode 0600) and bootstraps that file. Its ProgramArguments point at the nested helper inside the app bundle: /Applications/<App>.app/Contents/Helpers/<Helper>.app/Contents/MacOS/<Helper>. We generate it because it passes the app's build number as an argument; that could move into the helper itself.

We can use whichever location you'd consider least fragile.

What we're really after is both App Sandbox and a dedicated non-root identity for the helper, before login, since it parses untrusted input. Is there a supported way to get both?

If not, we'd pick one of two configurations that already work for us. Which would you consider on firmer ground?

  1. SMAppService.daemon running as root, with App Sandbox.
  2. SMAppService.daemon with UserName set to the service account, without App Sandbox.
Is there a supported way to get both?

I don’t think so. The only way to change users with launchd is to create a daemon with the UserName property, and that’s not gonna play well with App Sandbox.

Which would you consider on firmer ground?

Definitely the first one. While it’s somewhat unusual, there’s an existing use case that relies on this, namely, a Network Extension that’s packaged as a system extension. And yep, that configuration uncovered some weird edge cases, but we treated those as bugs to be fixed (for example, this).

However, I wouldn’t necessarily rule out your current approach. As I said, it’s not actually doing anything wrong, it’s just weird. If I were in your shoes I’d try this:

  1. Lay down a launchd.plist file in /Library/LaunchAgents.
  2. With LimitLoadToSessionType set to Background [1].
  3. And AssociatedBundleIdentifiers set to your app’s bundle.
  4. Restart the Mac [2].
  5. And then, in your daemon, start the agent using launchctl kickstart rather than launchctl bootstrap.

The idea here is to declare everything statically so that the background task management system understands that both your daemon and your agent are ‘owned’ by your app.

Note It would be better if SMAppService let you install a system-wide agent, but that’s not currently possible (r. 92457638). If this experiment pans out then I may ask you to file your own bug about that, just so that the Service Management team gets a better understanding of the demand for it.

Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] These session types are documented in the launchctl man page.

[2] We might be able to find a way to avoid this in your real product, but for the sake of this experiment let’s just keep it simple.

Thanks, Quinn. We tried your static-plist suggestion on macOS 27.0 (26A428) in our Developer ID signed and notarized app:

  • A root-owned plist at /Library/LaunchAgents/<agent-label>.plist has LimitLoadToSessionType=Background and AssociatedBundleIdentifiers=<app-bundle-id>. It launches the nested App Sandbox helper.
  • Our SMAppService system daemon calls launchctl kickstart user/<serviceUID>/<agent-label>. It bootstraps that fixed plist only if the job is not loaded in the service account's domain.
  • After a cold boot and FileVault unlock, the helper published readiness as the hidden service account while /dev/console still belonged to root, before GUI login.
  • Turning the app off in Background App Activity stopped the daemon. Its control socket closed, so the helper exited; the agent job stayed loaded. Turning the app back on started a new daemon and helper.

These are observations on one OS release. Is this static plist plus kickstart arrangement within the intended launchd and Background Task Management model for a hidden-account Background agent, including before login? On disable or unregister, should our daemon leave the agent job loaded and let the helper exit on socket EOF, removing the plist only on explicit uninstall? We can file Feedback for system-wide SMAppService agent registration if useful.

Sandboxed helper keeps running after the app is turned off in Background App Activity
 
 
Q