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.

Answered by DTS Engineer in 907581022
Is this static plist plus kickstart arrangement within the intended … model … ?

Your overall goal isn’t well supported by macOS as things currently stand, and this approach is my best response to that. Specifically, you have the bits in place such that, if the background task management subsystem starts to notice your agent, it should understand that the agent is associated with your app.

However, you’re gonna need to do some testing, both to ensure compatibility with any older releases you support and to confirm that things still work as new OS releases are seeded.

Then again, you should be doing that anyway (-:

On disable or unregister, should our daemon leave the agent job loaded and let the helper exit on socket EOF … ?

That’s what I’d recommend. It keeps the system configuration static, which is kind of the launchd way. And the cost of a loaded job that’s not actually started is very small.

We can file Feedback for system-wide SMAppService agent registration if useful.

Please do. This doesn’t have to be complicated. You can just ask that it be dup’d to the existing bug (r. 92457638). That acts to both register your interest and get you notified if things change.

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

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.

Accepted Answer
Is this static plist plus kickstart arrangement within the intended … model … ?

Your overall goal isn’t well supported by macOS as things currently stand, and this approach is my best response to that. Specifically, you have the bits in place such that, if the background task management subsystem starts to notice your agent, it should understand that the agent is associated with your app.

However, you’re gonna need to do some testing, both to ensure compatibility with any older releases you support and to confirm that things still work as new OS releases are seeded.

Then again, you should be doing that anyway (-:

On disable or unregister, should our daemon leave the agent job loaded and let the helper exit on socket EOF … ?

That’s what I’d recommend. It keeps the system configuration static, which is kind of the launchd way. And the cost of a loaded job that’s not actually started is very small.

We can file Feedback for system-wide SMAppService agent registration if useful.

Please do. This doesn’t have to be complicated. You can just ask that it be dup’d to the existing bug (r. 92457638). That acts to both register your interest and get you notified if things change.

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

Thank you so much for all the help!

I've filed FB25019969 requesting system-wide SMAppService agent registration and asking Apple to duplicate it to r. 92457638.

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