Skip to content
macOSDeep Dive Published Updated 10 min readViews unavailable

Migrating Legacy SMJobBless Helpers to SMAppService on macOS

Maintain legacy SMJobBless helpers safely, distinguish helper and plist naming, and migrate privileged services to macOS SMAppService.

A macOS application that needs to do something genuinely privileged - open a BPF device for packet capture, rewrite a system-owned configuration file, install a packet filter rule - should not run its whole user interface as root. The established design is a factored application: an unprivileged app for the interface and a small privileged helper tool, installed with administrator authorization and launched by launchd as root. SMJobBless is the legacy Service Management API for installing that helper. Apple deprecated it in macOS 13 and directs developers to SMAppService; this article is a migration and maintenance reference for products that still support the older path, not a recommendation to start new code with SMJobBless.

This function is narrow on purpose. It copies one bundled executable into the system helper location, installs its launchd job, and then gets out of the way. Every ongoing design question - which client may talk to the helper, which version is installed, how arguments reach it, how it is updated - is answered by code-signing requirements, bundle layout, and an explicit IPC version handshake. Misunderstanding any of those turns a routine update into kSMErrorInvalidSignature or a helper that can never be upgraded.

What bless installs, and what it does not

SMJobBless submits the executable for a given label as a launchd job, which removes the need for a setuid helper invoked through AuthorizationExecuteWithPrivileges to install a launchd property list (Apple: SMJobBless). The SDK header states the same contract directly: the routine exists so installers do not need a setuid helper for plist installation (ServiceManagement.h in the macOS 27.0 SDK, verified locally).

Three scope rules follow from that contract:

  1. The only supported domain is kSMDomainSystemLaunchd. The header documents the domain constants and warns that passing any other domain to SMJobBless produces undefined behavior.
  2. The caller must hold an authorization reference containing the kSMRightBlessPrivilegedHelper right (com.apple.ServiceManagement.blesshelper). The standard factored-application flow is AuthorizationCreate followed by AuthorizationCopyRights shortly before the privileged operation, then AuthorizationFree when the session ends (Apple: Authorization Services Tasks).
  3. AuthorizationExecuteWithPrivileges is deprecated, not removed: Apple’s current reference still documents the function and says it runs a tool as root. The installed SDK header marks it deprecated in the macOS 10.7 SDK and directs callers to a launchd-launched helper tool or the Service Management framework instead (Security.framework/Headers/Authorization.h, verified locally). Do not build new install logic on it.

Blessing is also unavailable from inside an app sandbox: the authorization services guide notes that the API allows privilege escalation and is not supported within a sandbox. A sandboxed app needs a different architecture, not a broader entitlement request.

The five packaging requirements are the security policy

Apple documents five requirements, and the SDK header and the online reference agree on all five (Apple: SMJobBless; ServiceManagement.h):

  1. Both the calling app and the helper tool must be signed.
  2. The app’s Info.plist must contain an SMPrivilegedExecutables dictionary. Each key is a globally unique reverse-DNS helper label; each value is a code-signing requirement string that decides whether a future version of the app still owns the installed tool (Apple: SMPrivilegedExecutables).
  3. The helper tool must embed an Info.plist with an SMAuthorizedClients array of requirement strings describing which clients may install or remove it.
  4. The helper must embed a launchd plist in the __TEXT,__launchd_plist Mach-O section (link time -sectcreate). Label is the only required key.
  5. The helper must live in the app bundle at Contents/Library/LaunchServices, and its filename must equal its launchd job label - for label com.example.app.helper, the file is com.example.app.helper.

A minimal app-side declaration looks like this (illustrative; replace the requirement with your own issuance policy):

<key>SMPrivilegedExecutables</key>
<dict>
    <key>com.example.app.helper</key>
    <string>identifier "com.example.app.helper" and anchor apple generic and certificate leaf[subject.CN] = "Developer ID Application: Example Inc."</string>
</dict>

And the helper embeds the mirror half plus its job description:

<key>SMAuthorizedClients</key>
<array>
    <string>identifier "com.example.app" and anchor apple generic and certificate leaf[subject.CN] = "Developer ID Application: Example Inc."</string>
</array>
<key>Label</key>
<string>com.example.app.helper</string>

The requirement language itself - identifier, anchor apple, certificate, boolean composition - is defined in Apple TN2206: macOS Code Signing In Depth. A useful local check of the same machinery is:

codesign -d --requirements - /bin/ls
# designated => identifier "com.apple.ls" and anchor apple

That output was observed on this Mac. It illustrates a designated requirement; craft the helper’s requirement from your certificate authority’s issuance rules, because reissued certificates change identity details the requirement must still match.

Two consequences deserve emphasis. First, when the framework extracts the embedded launchd plist, it sets ProgramArguments to a single element pointing at the installed helper in the standard system location - on this Mac, /Library/PrivilegedHelperTools, observed holding helpers such as com.microsoft.autoupdate.helper. You cannot specify your own Program or ProgramArguments, so pass parameters over IPC, never over argv. Second, TN2206 lists Contents/Library/LaunchServices as the standard location for Service Management helpers and requires nested code to be signed before the outer bundle. Sign inside out, then verify:

codesign --verify --deep --strict --verbose=2 Example.app

That command approximates the Gatekeeper-style check TN2206 recommends; it does not prove your bless requirements match.

The update rule: success when installed means you must version explicitly

The header and the online reference both state that if the job is already installed, SMJobBless returns success. That correct installer behavior is the most common helper bug: the app blesses once, ships a fixed helper, calls bless again, gets true, and keeps talking to the old helper.

The fix is an explicit version handshake over the helper’s IPC channel, which is the architecture Apple’s EvenBetterAuthorizationSample demonstrates: factor privileged operations into a launchd-run helper using SMJobBless (10.6) plus NSXPCConnection (10.8), and keep the app-to-helper protocol narrow. The sample’s motivating case is exactly the packet-capture tool whose BPF access requires privilege but whose policy stays in the app.

A practical handshake has three steps:

  1. After connecting to the helper over XPC, ask for its protocol/build version before issuing any privileged command.
  2. Compare against the version bundled in the running app. If the installed helper is older, obtain authorization again and call SMJobBless with the bundled label; the framework replaces the installed tool only if the calling app still satisfies the ownership requirement in SMPrivilegedExecutables.
  3. Reconnect and re-check the version. If the version did not change, stop and report - repeating bless in a loop cannot repair a requirement mismatch.
// Illustrative: version gate before any privileged command.
// Requires a signed app, an installed helper, and admin authorization;
// error handling below is abbreviated.
let connection = NSXPCConnection(machServiceName: "com.example.app.helper",
                                 options: .privileged)
connection.resume()
// Query helperVersion over the XPC protocol first. If it is older than
// bundledHelperVersion, re-authorize and call SMJobBless again, then
// reconnect and verify the version actually changed.

Ownership across updates is decided by requirement evaluation, not by filename. If the new app’s chain no longer satisfies the installed tool’s SMAuthorizedClients, or the new tool no longer satisfies the ownership requirement, the update fails with kSMErrorInvalidSignature or kSMErrorToolNotValid (ServiceManagement.framework/Headers/SMErrors.h, verified locally). Changing Team ID or editing requirement strings without a migration plan produces this outcome - test upgrades from every shipped requirement variant, not only the previous build.

The modern replacement and its own constraints

SMJobBless was introduced in macOS 10.6 and deprecated in macOS 13.0; it was not removed merely by being deprecated. The SDK header marks it __OSX_DEPRECATED(10.6, 13.0, "Please use SMAppService instead"). On macOS 13 and later, new privileged-service designs should use SMAppService:

  • daemon(plistName:) initializes a launch-daemon service whose plist lives in the app’s Contents/Library/LaunchDaemons directory; register()/unregister() replace installing plists in /Library/LaunchDaemons (Apple: SMAppService).
  • The local SMAppService.h header (macOS 27.0 SDK) adds the operational constraints: apps containing daemons must be notarized; the plist may use the BundleProgram key so the app stays relocatable; if the plist or executable changes, re-register or it may not launch; and for boot-time startup the app should live in /Applications.
// Illustrative macOS 13+ path; LaunchDaemon-containing apps must be notarized.
// /Applications is recommended for boot-time accessibility, not universally required.
let daemon = SMAppService.daemon(plistName: "com.example.app.helper.plist")
try daemon.register()

The daemon still needs a narrow IPC interface, a version handshake, per-request authorization checks, and an uninstall path. In the deprecated SMJobRemove API, wait == true asks the call to block until a running job exits, commonly for several seconds; wait == false does not wait for process exit. Keep any waiting path off the main thread. The modern SMAppService.unregisterAndReturnError() kills a running LoginItem, LaunchAgent, or LaunchDaemon but does not wait for it to be reaped; unregisterWithCompletionHandler returns promptly and invokes its completion handler after the running process has been killed when successful.

Failure modes and diagnosis

Symptom Likely cause Check
kSMErrorInvalidSignature Requirement strings no longer match after re-signing, Team ID change, or helper/app rebuilt under a different identity codesign -d -r- on both app and helper; compare against SMPrivilegedExecutables / SMAuthorizedClients
kSMErrorToolNotValid Helper missing from Contents/Library/LaunchServices, filename differs from label, or embedded __TEXT,__launchd_plist section absent Verify bundle layout, Label, and the -sectcreate link step
kSMErrorAuthorizationFailure Authorization reference lacks kSMRightBlessPrivilegedHelper, or preauthorization was skipped Request the bless right with interaction allowed immediately before blessing
Update appears to succeed but the client sees an unexpected helper version A successful bless result does not establish that the connected helper speaks the expected protocol version Query a version/build identifier over authenticated XPC, reconnect after the update, and verify the response before sending privileged requests
Helper receives no arguments Custom ProgramArguments were specified Remove them; pass parameters over XPC - the framework rewrites ProgramArguments
kSMErrorJobNotFound on removal Label mismatch between removal call and installed job Use the exact reverse-DNS label from the embedded plist
Removal hangs the UI Deprecated SMJobRemove was called with wait == true for a running job Use a background queue for any blocking legacy removal; modern SMAppService offers completion-handler unregister
kSMErrorLaunchDeniedByUser / kSMErrorServiceUnavailable User denied launch, or the service is unavailable Report the denial plainly; do not retry silently in a loop
Daemon never starts at boot (SMAppService) App is not accessible before login, or plist/executable changed without re-registration For boot-time startup, follow Apple’s /Applications recommendation and re-register after plist or executable updates

Keep two different naming rules separate. SMJobBless requires the helper executable under Contents/Library/LaunchServices to be named exactly as the embedded launchd Label. A launchd configuration file is a different file; the local launchd.plist(5) manual describes the <Label>.plist convention for plist filenames. SMAppService.daemon(plistName:) refers to the bundle plist in Contents/Library/LaunchDaemons, not to the helper executable. For the signing half, TN2206’s designated-requirement discussion explains why updates must satisfy the prior helper’s ownership requirement rather than merely carrying a newer build number.

Verification checklist

Test on a signed, notarized build installed the way users install it. Exercise first install, reinstall, upgrade from each shipped requirement variant, removal while busy, crash and relaunch, and launch-denied flows. Confirm each failure surfaces a specific error rather than a generic alert, and re-verify on the oldest supported release, because the SMJobBless/SMAppService boundary at macOS 13 decides which path runs.

The contract is small: one signed helper, one label, one embedded plist, mutual requirement strings, explicit authorization at install time, and a versioned IPC protocol afterward.

Related:

Sources:

Comments