Install macOS helper from PKG for XPC enroll

Browser enrollment used osascript on every elevate. Ship a signed
privileged helper installed at PKG time so probo:// can enroll over
XPC with no second admin prompt. Add make install/uninstall/clean for
local PKG test loops, and show alerts only on failure.

Mirror the Go lint path for the macOS SPM package: Make
targets, root configs, and a Linux CI job. Keep checks
syntax-only so they do not need a macOS SDK. Format the
existing sources so the new gates start clean.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-07-20 18:18:04 +02:00
parent 754d12d583
commit 85864a580c
42 changed files with 1903 additions and 341 deletions

View File

@@ -9,6 +9,7 @@
@@PKG_ARCH@@ reserved (legacy; host filter uses @@HOST_ARCHS@@)
@@HOST_ARCHS@@ host arch filter used by Installer.app
(e.g. arm64 or arm64,x86_64 for universal)
@@IDENTIFIER@@ package identifier (default com.probo.agent)
-->
<installer-gui-script minSpecVersion="2">
<title>Probo Device Posture Agent @@VERSION@@</title>
@@ -38,20 +39,20 @@
<license file="license.txt" mime-type="text/plain"/>
<conclusion file="conclusion.html" mime-type="text/html"/>
<pkg-ref id="com.getprobo.agent"
<pkg-ref id="@@IDENTIFIER@@"
version="@@VERSION@@"
onConclusion="none">probo-agent-component.pkg</pkg-ref>
<choices-outline>
<line choice="default">
<line choice="com.getprobo.agent"/>
<line choice="@@IDENTIFIER@@"/>
</line>
</choices-outline>
<choice id="default"/>
<choice id="com.getprobo.agent"
<choice id="@@IDENTIFIER@@"
title="Probo Device Posture Agent"
description="Installs probo-agent, the menu bar helper LaunchAgent, and Probo Agent.app for probo:// enrollment. The LaunchDaemon starts after enrollment.">
<pkg-ref id="com.getprobo.agent"/>
<pkg-ref id="@@IDENTIFIER@@"/>
</choice>
</installer-gui-script>

View File

@@ -48,6 +48,9 @@
<li>Installs <code>Probo Agent.app</code> to
<code>/Applications</code> so browser enrollment deep links
(<code>probo://</code>) work.</li>
<li>Installs the privileged helper
<code>com.probo.agent.helper</code> so browser enrollment can
finish without a second admin password prompt.</li>
<li>Registers the menu bar helper LaunchAgent
<code>com.probo.agent.tray</code> in
<code>/Library/LaunchAgents</code>.</li>

View File

@@ -11,28 +11,26 @@
# --output PATH Output .pkg path. Defaults to
# dist/probo-agent_${VER}_darwin_${ARCH}.pkg.
#
# Optional environment variables (auditor-mode compatible):
# CODESIGN_IDENTITY Developer ID Application identity. When
# set, signs the agent binary and Probo
# Agent.app with hardened runtime before
# packaging.
# Required environment variables:
# CODESIGN_IDENTITY Developer ID Application identity. Signs the
# agent binary, Probo Agent.app, and embedded helper.
# APPLE_TEAM_ID Apple Developer Team ID (helper client requirement).
#
# Optional (auditor-mode compatible):
# INSTALLER_IDENTITY Developer ID Installer identity. When
# set, passes --sign to productbuild.
# APPLE_ID Apple ID for notarytool store-credentials.
# APPLE_ID_PASSWORD App-specific password; used only to
# populate a keychain profile (not passed
# to long-lived notarytool submit).
# APPLE_TEAM_ID Apple Developer Team ID.
# NOTARYTOOL_KEYCHAIN_PROFILE Existing notarytool keychain profile.
# Defaults to probo-agent-notary when
# storing from APPLE_ID / APPLE_ID_PASSWORD.
# NOTARYTOOL_KEYCHAIN_PROFILE Keychain profile name for store/submit.
# Defaults to probo-agent-notary.
#
# Notarization is enabled when NOTARYTOOL_KEYCHAIN_PROFILE is set, or
# when APPLE_ID and APPLE_ID_PASSWORD are both set (APPLE_TEAM_ID also
# required). CODESIGN_IDENTITY and INSTALLER_IDENTITY are then required.
# The script stores credentials into the keychain profile when a
# password is provided, then notarizes and staples the .app before
# packaging and the signed .pkg via --keychain-profile.
# Notarization is enabled when APPLE_ID and APPLE_ID_PASSWORD are both
# set. INSTALLER_IDENTITY is then required. The script stores credentials
# into the keychain profile, then notarizes and staples the .app before
# packaging and the signed .pkg via --keychain-profile so the password
# is not on submit argv for the long --wait.
#
# Must run on macOS: pkgbuild, productbuild, and swift build are
# Apple-only tools. The build also compiles Probo Agent.app (the
@@ -47,13 +45,13 @@ BINARY=""
ARCH=""
VERSION=""
OUTPUT=""
IDENTIFIER="com.getprobo.agent"
IDENTIFIER="com.probo.agent"
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
INSTALLER_IDENTITY="${INSTALLER_IDENTITY:-}"
APPLE_ID="${APPLE_ID:-}"
APPLE_ID_PASSWORD="${APPLE_ID_PASSWORD:-}"
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-}"
NOTARYTOOL_KEYCHAIN_PROFILE="${NOTARYTOOL_KEYCHAIN_PROFILE:-probo-agent-notary}"
usage() {
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
@@ -101,28 +99,42 @@ case "${ARCH}" in
;;
esac
# --arch universal advertises both hostArchitectures in Distribution.xml.
# Refuse a single-slice binary so Installer cannot install on a CPU the
# --arch sets hostArchitectures in Distribution.xml. Refuse a binary that
# lacks the advertised slice(s) so Installer cannot install on a CPU the
# agent cannot run on.
if [ "${ARCH}" = "universal" ]; then
if ! command -v lipo >/dev/null 2>&1; then
echo "error: lipo is required to validate a universal --binary (run on macOS)" >&2
exit 1
fi
BINARY_ARCHS="$(lipo -archs "${BINARY}")"
has_arm64=false
has_x86_64=false
for arch_slice in ${BINARY_ARCHS}; do
case "${arch_slice}" in
arm64) has_arm64=true ;;
x86_64) has_x86_64=true ;;
esac
done
if [ "${has_arm64}" != true ] || [ "${has_x86_64}" != true ]; then
echo "error: --arch universal requires a fat binary with arm64 and x86_64 slices (got: ${BINARY_ARCHS}); use lipo -create" >&2
exit 2
fi
if ! command -v lipo >/dev/null 2>&1; then
echo "error: lipo is required to validate --binary architecture (run on macOS)" >&2
exit 1
fi
BINARY_ARCHS="$(lipo -archs "${BINARY}")"
has_arm64=false
has_x86_64=false
for arch_slice in ${BINARY_ARCHS}; do
case "${arch_slice}" in
arm64) has_arm64=true ;;
x86_64) has_x86_64=true ;;
esac
done
case "${ARCH}" in
amd64)
if [ "${has_x86_64}" != true ]; then
echo "error: --arch amd64 requires a binary with an x86_64 slice (got: ${BINARY_ARCHS})" >&2
exit 2
fi
;;
arm64)
if [ "${has_arm64}" != true ]; then
echo "error: --arch arm64 requires a binary with an arm64 slice (got: ${BINARY_ARCHS})" >&2
exit 2
fi
;;
universal)
if [ "${has_arm64}" != true ] || [ "${has_x86_64}" != true ]; then
echo "error: --arch universal requires a fat binary with arm64 and x86_64 slices (got: ${BINARY_ARCHS}); use lipo -create" >&2
exit 2
fi
;;
esac
if [ -z "${VERSION}" ]; then
VERSION="$(cat "${REPO_ROOT}/cmd/probo-agent/VERSION")"
@@ -140,30 +152,26 @@ if ! command -v swift >/dev/null 2>&1; then
echo "error: swift is required to build Probo Agent.app (run on macOS)" >&2
exit 1
fi
if [ -z "${CODESIGN_IDENTITY}" ]; then
echo "error: CODESIGN_IDENTITY is required (privileged helper must be signed)" >&2
exit 2
fi
if [ -z "${APPLE_TEAM_ID}" ]; then
echo "error: APPLE_TEAM_ID is required (SMAuthorizedClients team requirement)" >&2
exit 2
fi
notarize_enabled=false
if [ -n "${NOTARYTOOL_KEYCHAIN_PROFILE}" ]; then
notarize_enabled=true
elif [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ] && [ -n "${APPLE_TEAM_ID}" ]; then
NOTARYTOOL_KEYCHAIN_PROFILE="probo-agent-notary"
if [ -n "${APPLE_ID}" ] && [ -n "${APPLE_ID_PASSWORD}" ]; then
notarize_enabled=true
fi
if [ "${notarize_enabled}" = true ]; then
if [ -z "${CODESIGN_IDENTITY}" ]; then
echo "error: notarization requires CODESIGN_IDENTITY" >&2
exit 2
fi
if [ -z "${INSTALLER_IDENTITY}" ]; then
echo "error: notarization requires INSTALLER_IDENTITY" >&2
exit 2
fi
if [ "${notarize_enabled}" = true ] && [ -z "${INSTALLER_IDENTITY}" ]; then
echo "error: notarization requires INSTALLER_IDENTITY" >&2
exit 2
fi
sign_macho() {
local path="$1"
if [ -z "${CODESIGN_IDENTITY}" ]; then
return 0
fi
codesign \
--force \
--options runtime \
@@ -175,8 +183,15 @@ sign_macho() {
sign_app_bundle() {
local app_path="$1"
if [ -z "${CODESIGN_IDENTITY}" ]; then
return 0
local helper_path="${app_path}/Contents/Library/LaunchServices/com.probo.agent.helper"
if [ -x "${helper_path}" ]; then
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${helper_path}"
codesign --verify --verbose=2 "${helper_path}"
fi
codesign \
--force \
@@ -194,11 +209,8 @@ sign_app_bundle() {
}
ensure_notarytool_credentials() {
if [ -z "${APPLE_ID_PASSWORD}" ]; then
return 0
fi
if [ -z "${APPLE_ID}" ]; then
echo "error: APPLE_ID_PASSWORD requires APPLE_ID to store notarytool credentials" >&2
if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_ID_PASSWORD}" ]; then
echo "error: APPLE_ID and APPLE_ID_PASSWORD are required to store notarytool credentials" >&2
exit 2
fi
# Password appears on argv only for this short-lived store. Submits
@@ -327,6 +339,7 @@ sed \
-e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@PKG_ARCH@@|${PKG_ARCH}|g" \
-e "s|@@HOST_ARCHS@@|${HOST_ARCHS}|g" \
-e "s|@@IDENTIFIER@@|${IDENTIFIER}|g" \
"${SCRIPT_DIR}/Distribution.xml.tmpl" > "${DISTRIBUTION}"
mkdir -p "$(dirname "${OUTPUT}")"

View File

@@ -1,2 +1,6 @@
# SwiftPM
.build/
.swiftpm/
# Rendered by build-app.sh from *.tmpl (do not commit)
Shared/*.generated.swift

View File

@@ -0,0 +1,43 @@
import Foundation
import ProboAgentShared
public struct EnrollPreflightResult: Decodable {
public let server: String
public let token: String
public let alreadyEnrolled: Bool
public let configDir: String
}
public enum EnrollmentFlow {
private static let agentExecutablePath = ProboAgentHelperConstants.agentExecutablePath
public static func runPreflight(rawURL: String) throws -> EnrollPreflightResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: agentExecutablePath)
process.arguments = ["enroll-url", "--preflight", rawURL]
let output = Pipe()
process.standardOutput = output
process.standardError = output
try process.run()
let data = try output.fileHandleForReading.readToEnd() ?? Data()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
throw HelperClientError.operationFailed(process.terminationStatus, message)
}
return try JSONDecoder().decode(EnrollPreflightResult.self, from: data)
}
public static func installViaHelper(preflight: EnrollPreflightResult) throws {
try HelperClient.shared.install(
serverURL: preflight.server,
enrollmentToken: preflight.token,
configDir: preflight.configDir
)
}
}

View File

@@ -0,0 +1,214 @@
import Foundation
import ProboAgentShared
public enum HelperClientError: LocalizedError {
case helperNotInstalled
case connectionFailed(String)
case operationFailed(Int32, String?)
public var errorDescription: String? {
switch self {
case .helperNotInstalled:
return """
Privileged helper is not installed. Reinstall the Probo Agent \
package, then try enrollment again.
"""
case .connectionFailed(let message):
return "Cannot connect to privileged helper: \(message)"
case .operationFailed(let code, let message):
if let message, !message.isEmpty {
return message
}
return "Privileged operation failed (exit \(code))."
}
}
}
/// Once-only sync bridge for an in-flight XPC call. Reply and proxy error
/// handlers both finish here so disconnects unblock waiters immediately.
private final class XPCCallCompletion: @unchecked Sendable {
private let lock = NSLock()
private let semaphore = DispatchSemaphore(value: 0)
private var finished = false
private var error: Error?
func succeed() {
complete(nil)
}
func fail(_ error: Error) {
complete(error)
}
private func complete(_ error: Error?) {
lock.lock()
defer { lock.unlock() }
guard !finished else { return }
finished = true
self.error = error
semaphore.signal()
}
/// Waits for succeed/fail. Throws the stored error, or `timedOut` on timeout.
func wait(timeout: TimeInterval, timedOut: @autoclosure () -> Error) throws {
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
throw timedOut()
}
if let error {
throw error
}
}
/// Waits for succeed/fail. Returns `false` on timeout; throws the stored error.
func wait(timeout: TimeInterval) throws -> Bool {
if semaphore.wait(timeout: .now() + timeout) == .timedOut {
return false
}
if let error {
throw error
}
return true
}
}
final public class HelperClient {
public static let shared = HelperClient()
/// Serializes public install within this process so privileged helper
/// work never overlaps. Does not coordinate across processes.
private let operationLock = NSLock()
private init() {}
public func install(
serverURL: String,
enrollmentToken: String,
configDir: String = ProboAgentHelperConstants.defaultConfigDir
) throws {
operationLock.lock()
defer { operationLock.unlock() }
try ensureHelperReady()
try withRemoteProxy { proxy, completion in
proxy.install(
serverURL: serverURL,
enrollmentToken: enrollmentToken,
configDir: configDir
) { exitCode, output in
if exitCode != 0 {
completion.fail(HelperClientError.operationFailed(exitCode, output))
} else {
completion.succeed()
}
}
// Headroom over probo-agent install's 60s deadline plus local
// service/tray setup so we report the command's real outcome.
try completion.wait(
timeout: 120,
timedOut: HelperClientError.connectionFailed(
"install timed out waiting for privileged helper"
))
}
}
/// The helper is installed by the PKG postinstall (as root). Enrollment
/// never calls SMJobBless — no admin prompt on the browser path.
private func ensureHelperReady() throws {
guard isHelperInstalled() else {
throw HelperClientError.helperNotInstalled
}
let installedVersion = try installedHelperVersion()
if installedVersion == nil {
throw HelperClientError.connectionFailed(
"helper is installed but not responding; reinstall the Probo Agent package"
)
}
if installedVersion != ProboAgentHelperConstants.helperVersion {
NSLog(
"probo-agent helper client: version mismatch (installed=%@ expected=%@)",
installedVersion ?? "nil",
ProboAgentHelperConstants.helperVersion
)
}
try verifyHelperResponds()
}
private func isHelperInstalled() -> Bool {
FileManager.default.fileExists(
atPath: "/Library/PrivilegedHelperTools/\(ProboAgentHelperConstants.helperLabel)"
)
}
private func installedHelperVersion() throws -> String? {
try withRemoteProxy { proxy, completion in
var version: String?
proxy.getVersion { value in
version = value
completion.succeed()
}
guard try completion.wait(timeout: 10) else {
return nil
}
return version
}
}
private func verifyHelperResponds() throws {
try withRemoteProxy { proxy, completion in
var ok = false
proxy.ping { value in
ok = value
completion.succeed()
}
try completion.wait(
timeout: 10,
timedOut: HelperClientError.connectionFailed(
"helper did not respond to ping (timeout)")
)
if !ok {
throw HelperClientError.connectionFailed("helper did not respond to ping")
}
}
}
/// Creates a dedicated XPC connection for the duration of `body`, then
/// invalidates it. Callers must finish waiting for replies inside `body`
/// so the connection outlives the reply.
private func withRemoteProxy<T>(
_ body: (ProboAgentHelperProtocol, XPCCallCompletion) throws -> T
) throws -> T {
let connection = NSXPCConnection(
machServiceName: ProboAgentHelperConstants.machServiceName,
options: .privileged
)
connection.remoteObjectInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self)
connection.resume()
defer { connection.invalidate() }
let completion = XPCCallCompletion()
guard
let proxy = connection.remoteObjectProxyWithErrorHandler({ error in
NSLog(
"probo-agent helper XPC error: %@",
error.localizedDescription
)
completion.fail(
HelperClientError.connectionFailed(error.localizedDescription)
)
}) as? ProboAgentHelperProtocol
else {
throw HelperClientError.connectionFailed("cannot create remote proxy")
}
return try body(proxy, completion)
}
}

View File

@@ -0,0 +1,103 @@
import Foundation
import ProboAgentShared
import os
final class Helper: NSObject, ProboAgentHelperProtocol, NSXPCListenerDelegate {
private static let log = Logger(
subsystem: "com.probo.agent.helper",
category: "Helper"
)
func getVersion(withReply reply: @escaping (String) -> Void) {
reply(ProboAgentHelperConstants.helperVersion)
}
func ping(withReply reply: @escaping (Bool) -> Void) {
Self.log.info("ping")
reply(true)
}
func install(
serverURL: String,
enrollmentToken: String,
configDir: String,
withReply reply: @escaping (Int32, String?) -> Void
) {
let trimmedServer = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = enrollmentToken.trimmingCharacters(in: .whitespacesAndNewlines)
let dir = configDir.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedServer.isEmpty, !trimmedToken.isEmpty else {
reply(1, "server URL and enrollment token are required")
return
}
// Pass the token via env rather than argv so it does not show up in
// process listings (ps / Activity Monitor). Install already accepts
// PROBO_ENROLLMENT_TOKEN when --enrollment-token is omitted.
var args = [
"install",
"--server", trimmedServer,
]
if !dir.isEmpty {
args.append(contentsOf: ["--dir", dir])
}
var environment = ProcessInfo.processInfo.environment
environment["PROBO_ENROLLMENT_TOKEN"] = trimmedToken
let result = runAgent(args: args, environment: environment)
reply(result.exitCode, result.output)
}
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection)
-> Bool
{
guard ClientAuth.accepts(connection: connection) else {
Self.log.error(
"refused XPC connection pid=\(connection.processIdentifier, privacy: .public)")
return false
}
Self.log.info(
"accepted XPC connection pid=\(connection.processIdentifier, privacy: .public)")
connection.exportedInterface = NSXPCInterface(with: ProboAgentHelperProtocol.self)
connection.exportedObject = self
connection.resume()
return true
}
private struct CommandResult {
let exitCode: Int32
let output: String?
}
private func runAgent(args: [String], environment: [String: String]? = nil) -> CommandResult {
let process = Process()
process.executableURL = URL(fileURLWithPath: ProboAgentHelperConstants.agentExecutablePath)
process.arguments = args
if let environment {
process.environment = environment
}
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
let data = try pipe.fileHandleForReading.readToEnd() ?? Data()
process.waitUntilExit()
let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return CommandResult(
exitCode: process.terminationStatus,
output: text?.isEmpty == false ? text : nil
)
} catch {
return CommandResult(exitCode: 1, output: error.localizedDescription)
}
}
}

View File

@@ -0,0 +1,21 @@
<?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>CFBundleIdentifier</key>
<string>com.probo.agent.helper</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Probo Agent Helper</string>
<key>CFBundleShortVersionString</key>
<string>@@VERSION@@</string>
<key>CFBundleVersion</key>
<string>@@VERSION@@</string>
<key>SMAuthorizedClients</key>
<array>
<string>@@CLIENT_DESIGNATED_REQUIREMENT@@</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,18 @@
<?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>com.probo.agent.helper</string>
<key>MachServices</key>
<dict>
<key>com.probo.agent.helper</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.probo.agent.url-handler</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Foundation
import ProboAgentShared
import os
private let log = Logger(subsystem: "com.probo.agent.helper", category: "main")
let helper = Helper()
let listener = NSXPCListener(machServiceName: ProboAgentHelperConstants.machServiceName)
listener.delegate = helper
listener.resume()
log.info("listening on \(ProboAgentHelperConstants.machServiceName, privacy: .public)")
RunLoop.main.run()

View File

@@ -6,7 +6,8 @@
Placeholders are substituted by build-app.sh:
@@VERSION@@ agent version, e.g. 0.1.0
@@VERSION@@ agent version, e.g. 0.1.0
@@HELPER_DESIGNATED_REQUIREMENT@@ codesign requirement for embedded helper
-->
<plist version="1.0">
<dict>
@@ -15,7 +16,7 @@
<key>CFBundleExecutable</key>
<string>probo-agent-url-handler</string>
<key>CFBundleIdentifier</key>
<string>com.getprobo.agent.url-handler</string>
<string>com.probo.agent.url-handler</string>
<key>CFBundleName</key>
<string>Probo Agent</string>
<key>CFBundleDisplayName</key>
@@ -41,5 +42,10 @@
</array>
</dict>
</array>
<key>SMPrivilegedExecutables</key>
<dict>
<key>com.probo.agent.helper</key>
<string>@@HELPER_DESIGNATED_REQUIREMENT@@</string>
</dict>
</dict>
</plist>

View File

@@ -4,11 +4,43 @@ import PackageDescription
let package = Package(
name: "probo-agent-url-handler",
platforms: [
.macOS(.v11),
.macOS(.v11)
],
products: [
.library(name: "ProboAgentShared", targets: ["ProboAgentShared"]),
.library(name: "HelperClient", targets: ["HelperClient"]),
.executable(name: "com.probo.agent.helper", targets: ["com.probo.agent.helper"]),
.executable(name: "probo-agent-url-handler", targets: ["probo-agent-url-handler"]),
],
targets: [
.target(
name: "ProboAgentShared",
path: "Shared",
exclude: [
"HelperVersion.generated.swift.tmpl",
"SigningConstants.generated.swift.tmpl",
],
linkerSettings: [
.linkedFramework("Security")
]
),
.target(
name: "HelperClient",
dependencies: ["ProboAgentShared"],
path: "HelperClient"
),
.executableTarget(
name: "com.probo.agent.helper",
dependencies: ["ProboAgentShared"],
path: "HelperTool",
exclude: ["Info.plist.tmpl", "Launchd.plist.tmpl"],
linkerSettings: [
.linkedFramework("Security")
]
),
.executableTarget(
name: "probo-agent-url-handler",
dependencies: ["HelperClient", "ProboAgentShared"],
path: "URLHandlerSources"
),
]

View File

@@ -0,0 +1,183 @@
import Foundation
import Security
import os
public enum ClientAuth {
private static let log = Logger(
subsystem: "com.probo.agent.helper",
category: "ClientAuth"
)
/// Validates an incoming XPC connection from the Probo Agent URL handler app.
public static func accepts(connection: NSXPCConnection) -> Bool {
guard let code = copyGuestCode(for: connection) else {
return false
}
// Developer ID + hardened runtime sets CS_RUNTIME on the code directory
// (kSecCodeInfoFlags). kSecCodeInfoStatus is only present for some
// dynamic queries and was missing here, which rejected every client.
if !hasAcceptableCodeStatus(code) {
return false
}
guard let requirement = clientRequirement() else {
log.error("reject XPC client (missing client requirement)")
return false
}
var secRequirement: SecRequirement?
guard
SecRequirementCreateWithString(
requirement as CFString,
SecCSFlags(),
&secRequirement
) == errSecSuccess, let secRequirement
else {
log.error("reject XPC client (invalid requirement string)")
return false
}
let check = SecCodeCheckValidity(code, SecCSFlags(), secRequirement)
if check != errSecSuccess {
log.error("reject XPC client (requirement not satisfied, status=\(check))")
return false
}
log.info("accepted XPC client pid=\(connection.processIdentifier, privacy: .public)")
return true
}
private static func copyGuestCode(for connection: NSXPCConnection) -> SecCode? {
if let token = auditToken(from: connection) {
var mutableToken = token
let tokenData = withUnsafeBytes(of: &mutableToken) { Data($0) }
if let code = copyGuestCode(attributes: [
kSecGuestAttributeAudit as String: tokenData
]) {
return code
}
log.info("audit-token guest lookup failed; trying pid")
} else {
log.info("no audit token on XPC connection; trying pid")
}
let pid = connection.processIdentifier
guard pid > 0 else {
log.error("reject XPC client (invalid pid)")
return nil
}
return copyGuestCode(attributes: [
kSecGuestAttributePid as String: NSNumber(value: pid)
])
}
private static func copyGuestCode(attributes: [String: Any]) -> SecCode? {
var code: SecCode?
let status = SecCodeCopyGuestWithAttributes(
nil,
attributes as CFDictionary,
SecCSFlags(),
&code
)
guard status == errSecSuccess else {
log.error("reject XPC client (SecCodeCopyGuestWithAttributes=\(status))")
return nil
}
return code
}
private static func hasAcceptableCodeStatus(_ code: SecCode) -> Bool {
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess,
let staticCode
else {
log.error("reject XPC client (cannot copy static code)")
return false
}
var csInfo: CFDictionary?
guard
SecCodeCopySigningInformation(
staticCode,
SecCSFlags(rawValue: kSecCSDynamicInformation),
&csInfo
) == errSecSuccess,
let info = csInfo as? [String: Any]
else {
log.error("reject XPC client (cannot read signing information)")
return false
}
// Prefer dynamic status when present; fall back to code-directory flags
// (where hardened-runtime CS_RUNTIME lives for Developer ID binaries).
let statusValue = uint32Value(info[kSecCodeInfoStatus as String])
let flagsValue = uint32Value(info[kSecCodeInfoFlags as String])
let bits = statusValue ?? flagsValue
guard let bits else {
log.error("reject XPC client (no status/flags in signing info)")
return false
}
// Accept hardened-runtime clients (Developer ID + --options runtime sets
// CS_RUNTIME). Also accept the older CS_HARD|CS_KILL pair.
let csHard: UInt32 = 0x100
let csKill: UInt32 = 0x200
let csRuntime: UInt32 = 0x10000
let hasHardKill = (bits & (csHard | csKill)) == (csHard | csKill)
let hasRuntime = (bits & csRuntime) == csRuntime
if !hasHardKill && !hasRuntime {
log.error(
"reject XPC client (bits=0x\(String(bits, radix: 16)), need runtime or hard|kill)"
)
return false
}
return true
}
private static func uint32Value(_ value: Any?) -> UInt32? {
switch value {
case let number as NSNumber:
return number.uint32Value
case let value as UInt32:
return value
case let value as Int:
return UInt32(truncatingIfNeeded: value)
default:
return nil
}
}
/// Reads NSXPCConnection.auditToken across SDK/runtime differences.
/// Modern macOS exposes it as an ObjC property; KVC may return Data or NSValue.
private static func auditToken(from connection: NSXPCConnection) -> audit_token_t? {
if let data = connection.value(forKey: "auditToken") as? Data,
data.count == MemoryLayout<audit_token_t>.size
{
return data.withUnsafeBytes { raw in
raw.load(as: audit_token_t.self)
}
}
if let value = connection.value(forKey: "auditToken") as? NSValue {
var token = audit_token_t()
value.getValue(&token)
return token
}
return nil
}
private static func clientRequirement() -> String? {
guard let teamID = ProboAgentSigningConstants.teamID, !teamID.isEmpty else {
return nil
}
return """
anchor apple generic and identifier "\(ProboAgentHelperConstants.clientBundleID)" \
and certificate leaf[subject.OU] = "\(teamID)"
"""
}
}

View File

@@ -0,0 +1,13 @@
import Foundation
@objc(ProboAgentHelperProtocol)
public protocol ProboAgentHelperProtocol: NSObjectProtocol {
func getVersion(withReply reply: @escaping (String) -> Void)
func ping(withReply reply: @escaping (Bool) -> Void)
func install(
serverURL: String,
enrollmentToken: String,
configDir: String,
withReply reply: @escaping (Int32, String?) -> Void
)
}

View File

@@ -0,0 +1,4 @@
// Generated by build-app.sh — do not edit.
enum ProboAgentHelperVersion {
static let value = "@@VERSION@@"
}

View File

@@ -0,0 +1,12 @@
import Foundation
public enum ProboAgentHelperConstants {
public static let machServiceName = "com.probo.agent.helper"
public static let helperLabel = "com.probo.agent.helper"
public static let clientBundleID = "com.probo.agent.url-handler"
public static let agentExecutablePath = "/usr/local/bin/probo-agent"
public static let defaultConfigDir = "/var/lib/probo-agent"
public static let enrolledMarkerPath = "/var/run/probo-agent/enrolled"
public static let helperVersion = ProboAgentHelperVersion.value
}

View File

@@ -0,0 +1,4 @@
// Generated by build-app.sh — do not edit.
enum ProboAgentSigningConstants {
static let teamID: String? = @@TEAM_ID_OPTION@@
}

View File

@@ -1,61 +1,10 @@
import AppKit
import Darwin
import Foundation
// Fixed install location written by the macOS PKG postinstall script
// (cmd/probo-agent/installer/macos/scripts/postinstall, BINARY).
private let agentExecutablePath = "/usr/local/bin/probo-agent"
private enum EnrollmentCallbackState: String, Codable {
case success
case failure
}
private struct EnrollmentCallbackPayload: Codable {
let state: EnrollmentCallbackState
let message: String?
}
private enum EnrollmentCallbackStore {
static var statusFileURL: URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("probo-agent-enrollment-status.json")
}
static var lockFileURL: URL {
FileManager.default.temporaryDirectory
.appendingPathComponent("probo-agent-enrollment-ui.lock")
}
static func isWizardRunning() -> Bool {
guard
let data = try? Data(contentsOf: lockFileURL),
let pidText = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
let pid = Int32(pidText),
pid > 0
else {
return false
}
return kill(pid, 0) == 0
}
static func writeStatus(
state: EnrollmentCallbackState,
message: String?
) {
let payload = EnrollmentCallbackPayload(state: state, message: message)
guard let data = try? JSONEncoder().encode(payload) else {
return
}
try? data.write(to: statusFileURL, options: [.atomic])
}
}
import HelperClient
private final class URLHandlerApp: NSObject, NSApplicationDelegate {
private var didReceiveURL = false
private var idleTimer: Timer?
override init() {
super.init()
@@ -69,127 +18,89 @@ private final class URLHandlerApp: NSObject, NSApplicationDelegate {
}
func applicationDidFinishLaunching(_ notification: Notification) {
Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in
if !self.didReceiveURL {
NSApp.terminate(nil)
}
NSLog("probo-agent url-handler: launched")
idleTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: false) { [weak self] _ in
guard let self, !self.didReceiveURL else { return }
NSLog("probo-agent url-handler: no URL received; exiting")
NSApp.terminate(nil)
}
}
func application(_ application: NSApplication, open urls: [URL]) {
guard let rawURL = urls.first?.absoluteString else { return }
beginEnrollmentIfNeeded(rawURL: rawURL)
}
@objc private func handleGetURLEvent(
_ event: NSAppleEventDescriptor,
withReplyEvent replyEvent: NSAppleEventDescriptor
) {
guard let rawURL = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue else {
reportFailure("Enrollment link is missing.")
presentFailure("Enrollment link is missing.")
return
}
guard !didReceiveURL else { return }
beginEnrollmentIfNeeded(rawURL: rawURL)
}
private func beginEnrollmentIfNeeded(rawURL: String) {
guard !didReceiveURL else { return }
didReceiveURL = true
idleTimer?.invalidate()
idleTimer = nil
runEnrollment(for: rawURL)
}
private func runEnrollment(for rawURL: String) {
let shouldNotifyWizard = EnrollmentCallbackStore.isWizardRunning()
NSLog("probo-agent url-handler: starting enrollment")
DispatchQueue.global(qos: .userInitiated).async {
let process = Process()
process.executableURL = URL(fileURLWithPath: agentExecutablePath)
process.arguments = ["enroll-url", rawURL]
let output = Pipe()
process.standardOutput = output
process.standardError = output
var outputData = Data()
let readHandle = output.fileHandleForReading
let readDone = DispatchSemaphore(value: 0)
readHandle.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
readDone.signal()
do {
NSLog("probo-agent url-handler: preflight…")
let preflight = try EnrollmentFlow.runPreflight(rawURL: rawURL)
if preflight.alreadyEnrolled {
NSLog("probo-agent url-handler: already enrolled")
DispatchQueue.main.async {
NSApp.terminate(nil)
}
return
}
outputData.append(chunk)
}
defer { readHandle.readabilityHandler = nil }
NSLog("probo-agent url-handler: install via helper…")
try EnrollmentFlow.installViaHelper(preflight: preflight)
NSLog("probo-agent url-handler: install completed")
do {
try process.run()
DispatchQueue.main.async {
NSApp.terminate(nil)
}
} catch {
readDone.signal()
NSLog(
"probo-agent url-handler: enrollment failed: %@",
error.localizedDescription
)
DispatchQueue.main.async {
self.reportFailure(
self.sanitizedFailureMessage(error.localizedDescription),
shouldNotifyWizard: shouldNotifyWizard
)
self.presentFailure(error.localizedDescription)
}
return
}
process.waitUntilExit()
readDone.wait()
guard process.terminationStatus == 0 else {
let message = String(data: outputData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
DispatchQueue.main.async {
self.reportFailure(
self.sanitizedFailureMessage(message),
shouldNotifyWizard: shouldNotifyWizard
)
}
return
}
DispatchQueue.main.async {
if shouldNotifyWizard {
EnrollmentCallbackStore.writeStatus(state: .success, message: nil)
}
NSApp.terminate(nil)
}
}
}
private func reportFailure(_ message: String, shouldNotifyWizard: Bool = EnrollmentCallbackStore.isWizardRunning()) {
if shouldNotifyWizard {
EnrollmentCallbackStore.writeStatus(state: .failure, message: message)
NSApp.terminate(nil)
return
}
showError(message)
private func presentFailure(_ message: String) {
presentAlert(title: "Enrollment failed", message: message, style: .warning)
}
private func sanitizedFailureMessage(_ raw: String?) -> String {
guard let raw else {
return "Enrollment failed. Please try again."
}
let normalized = raw.lowercased()
if normalized.contains("already enrolled") {
return "This device is already enrolled."
}
if normalized.contains("key") && normalized.contains("missing") {
return "Device API key is missing or invalid."
}
return "Enrollment failed. Please try again."
}
private func showError(_ message: String) {
let alert = NSAlert()
alert.messageText = "Enrollment failed"
alert.informativeText = message
alert.alertStyle = .warning
private func presentAlert(title: String, message: String, style: NSAlert.Style) {
// LSUIElement / .accessory apps otherwise show alerts that never appear.
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = style
alert.runModal()
NSApp.setActivationPolicy(.accessory)
NSApp.terminate(nil)
}
}

View File

@@ -1,14 +1,18 @@
#!/bin/bash
#
# Build Probo Agent.app — a headless macOS app bundle that registers
# the probo:// URL scheme and forwards enrollment links to probo-agent.
# Build Probo Agent.app — headless macOS app bundle with probo:// handler
# and embedded privileged helper (installed by PKG postinstall).
#
# Required arguments:
# --arch amd64, arm64, or universal
# --version Agent version, e.g. 0.1.0
# --output Parent directory; creates "Probo Agent.app" inside it
#
# Must run on macOS with the Swift toolchain (swift build).
# Required environment variables:
# CODESIGN_IDENTITY Developer ID Application identity. Signs the
# embedded helper (for SMPrivilegedExecutables DR),
# URL handler, and app bundle.
# APPLE_TEAM_ID Apple Developer Team ID for SMAuthorizedClients.
set -euo pipefail
@@ -18,7 +22,10 @@ ARCH=""
VERSION=""
OUTPUT=""
APP_NAME="Probo Agent.app"
EXECUTABLE_NAME="probo-agent-url-handler"
URL_HANDLER_NAME="probo-agent-url-handler"
HELPER_LABEL="com.probo.agent.helper"
CODESIGN_IDENTITY="${CODESIGN_IDENTITY:-}"
APPLE_TEAM_ID="${APPLE_TEAM_ID:-}"
usage() {
sed -ne '/^#/!q; s/^# \{0,1\}//; 2,$ p' < "$0"
@@ -60,33 +67,123 @@ if ! command -v swift >/dev/null 2>&1; then
echo "error: swift is required (run on macOS with Xcode or Swift toolchain)" >&2
exit 1
fi
if [ -z "${CODESIGN_IDENTITY}" ]; then
echo "error: CODESIGN_IDENTITY is required (privileged helper must be signed)" >&2
exit 2
fi
if [ -z "${APPLE_TEAM_ID}" ]; then
echo "error: APPLE_TEAM_ID is required (SMAuthorizedClients team requirement)" >&2
exit 2
fi
BUILD_DIR="$(mktemp -d -t probo-agent-url-handler-build)"
BUILD_DIR="$(mktemp -d -t probo-agent-enroll-ui-build)"
RENDER_DIR="${BUILD_DIR}/rendered"
trap 'rm -rf "${BUILD_DIR}"' EXIT
mkdir -p "${RENDER_DIR}"
client_requirement() {
if [ -z "${APPLE_TEAM_ID}" ]; then
echo "error: APPLE_TEAM_ID is required (client designated requirement)" >&2
exit 2
fi
printf 'anchor apple generic and identifier "com.probo.agent.url-handler" and certificate leaf[subject.OU] = "%s"' "${APPLE_TEAM_ID}"
}
team_id_option() {
if [ -n "${APPLE_TEAM_ID}" ]; then
printf '"%s"' "${APPLE_TEAM_ID}"
return
fi
printf 'nil'
}
sed \
-e "s|@@VERSION@@|${VERSION}|g" \
"${SCRIPT_DIR}/Shared/HelperVersion.generated.swift.tmpl" \
> "${SCRIPT_DIR}/Shared/HelperVersion.generated.swift"
sed \
-e "s|@@TEAM_ID_OPTION@@|$(team_id_option)|g" \
"${SCRIPT_DIR}/Shared/SigningConstants.generated.swift.tmpl" \
> "${SCRIPT_DIR}/Shared/SigningConstants.generated.swift"
HELPER_INFO_PLIST="${RENDER_DIR}/helper-info.plist"
HELPER_LAUNCHD_PLIST="${RENDER_DIR}/helper-launchd.plist"
sed \
-e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@CLIENT_DESIGNATED_REQUIREMENT@@|$(client_requirement)|g" \
"${SCRIPT_DIR}/HelperTool/Info.plist.tmpl" > "${HELPER_INFO_PLIST}"
cp "${SCRIPT_DIR}/HelperTool/Launchd.plist.tmpl" "${HELPER_LAUNCHD_PLIST}"
HELPER_LINKER_FLAGS=(
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist
-Xlinker "${HELPER_INFO_PLIST}"
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __launchd_plist
-Xlinker "${HELPER_LAUNCHD_PLIST}"
)
pushd "${SCRIPT_DIR}" >/dev/null
swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}"
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" --scratch-path "${BUILD_DIR}" --show-bin-path)"
BINARY="${BIN_DIR}/${EXECUTABLE_NAME}"
swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
--scratch-path "${BUILD_DIR}/swift" \
--product "${HELPER_LABEL}" \
"${HELPER_LINKER_FLAGS[@]}"
swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
--scratch-path "${BUILD_DIR}/swift" \
--product "${URL_HANDLER_NAME}"
BIN_DIR="$(swift build -c release "${SWIFT_ARCH_ARGS[@]}" \
--scratch-path "${BUILD_DIR}/swift" --show-bin-path)"
HELPER_BINARY="${BIN_DIR}/${HELPER_LABEL}"
URL_HANDLER_BINARY="${BIN_DIR}/${URL_HANDLER_NAME}"
popd >/dev/null
if [ ! -x "${BINARY}" ]; then
echo "error: release binary not found at ${BINARY}" >&2
if [ ! -x "${HELPER_BINARY}" ] || [ ! -x "${URL_HANDLER_BINARY}" ]; then
echo "error: expected release binaries were not produced" >&2
exit 1
fi
APP_ROOT="${OUTPUT}/${APP_NAME}"
CONTENTS="${APP_ROOT}/Contents"
MACOS="${CONTENTS}/MacOS"
LAUNCH_SERVICES="${CONTENTS}/Library/LaunchServices"
LAUNCH_DAEMONS="${CONTENTS}/Library/LaunchDaemons"
PLIST="${CONTENTS}/Info.plist"
EMBEDDED_HELPER="${LAUNCH_SERVICES}/${HELPER_LABEL}"
EMBEDDED_LAUNCHD="${LAUNCH_DAEMONS}/${HELPER_LABEL}.plist"
rm -rf "${APP_ROOT}"
mkdir -p "${MACOS}"
mkdir -p "${MACOS}" "${LAUNCH_SERVICES}" "${LAUNCH_DAEMONS}"
install -m 0755 "${BINARY}" "${MACOS}/${EXECUTABLE_NAME}"
install -m 0755 "${URL_HANDLER_BINARY}" "${MACOS}/${URL_HANDLER_NAME}"
install -m 0755 "${HELPER_BINARY}" "${EMBEDDED_HELPER}"
install -m 0644 "${HELPER_LAUNCHD_PLIST}" "${EMBEDDED_LAUNCHD}"
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${EMBEDDED_HELPER}"
codesign --verify --verbose=2 "${EMBEDDED_HELPER}"
# codesign prints "Executable=…" on stderr and either
# "# designated => …" (modern) or "designated => …" (older) on stdout.
HELPER_REQUIREMENT="$(
codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 \
| sed -n -e 's/^# designated => //p' -e 's/^designated => //p'
)"
if [ -z "${HELPER_REQUIREMENT}" ]; then
echo "error: cannot extract designated requirement from signed helper" >&2
codesign -d -r- "${EMBEDDED_HELPER}" 2>&1 >&2 || true
exit 1
fi
echo "Helper designated requirement: ${HELPER_REQUIREMENT}"
sed \
-e "s|@@VERSION@@|${VERSION}|g" \
-e "s|@@HELPER_DESIGNATED_REQUIREMENT@@|${HELPER_REQUIREMENT}|g" \
"${SCRIPT_DIR}/Info.plist.tmpl" > "${PLIST}"
if ! plutil -lint "${PLIST}" >/dev/null; then
@@ -98,4 +195,18 @@ if ! grep -q '<string>probo</string>' "${PLIST}"; then
exit 1
fi
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${MACOS}/${URL_HANDLER_NAME}"
codesign \
--force \
--options runtime \
--timestamp \
--sign "${CODESIGN_IDENTITY}" \
"${APP_ROOT}"
codesign --verify --verbose=2 "${APP_ROOT}"
echo "Built ${APP_ROOT}"

View File

@@ -0,0 +1,26 @@
#!/bin/bash
#
# Tear down any previous macOS install, then install a signed .pkg.
# Must run as root. Used by: make -C cmd/probo-agent install
#
# Usage: reinstall.sh /path/to/probo-agent_*.pkg
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PKG="${1:-}"
if [ "$(id -u)" -ne 0 ]; then
echo "error: must run as root (try: sudo make -C cmd/probo-agent install)" >&2
exit 1
fi
if [ -z "${PKG}" ] || [ ! -f "${PKG}" ]; then
echo "error: usage: $0 /path/to/probo-agent_*.pkg" >&2
exit 2
fi
"${SCRIPT_DIR}/uninstall.sh"
echo "Installing ${PKG}…"
installer -pkg "${PKG}" -target /
echo "PKG install complete."

View File

@@ -22,6 +22,10 @@ STATE_DIR="/var/lib/probo-agent"
RUN_DIR="/var/run/probo-agent"
CONF_FILE="/tmp/probo-agent.conf"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
HELPER_LABEL="com.probo.agent.helper"
HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist"
HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}"
APP_PATH="/Applications/Probo Agent.app"
TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST_NAME="${TRAY_LABEL}.plist"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -133,11 +137,10 @@ register_tray_launchagent() {
}
register_enrollment_url_scheme() {
local app_path lsregister
local lsregister
app_path="/Applications/Probo Agent.app"
if [ ! -d "${app_path}" ]; then
echo "warning: ${app_path} not found; cannot register probo:// URL scheme."
if [ ! -d "${APP_PATH}" ]; then
echo "warning: ${APP_PATH} not found; cannot register probo:// URL scheme."
return 0
fi
@@ -147,7 +150,7 @@ register_enrollment_url_scheme() {
return 0
fi
if ! "${lsregister}" -f "${app_path}"; then
if ! "${lsregister}" -f "${APP_PATH}"; then
echo "warning: failed to register probo:// URL scheme."
return 0
fi
@@ -155,6 +158,62 @@ register_enrollment_url_scheme() {
echo "Registered probo:// URL scheme."
}
# Install the privileged helper as root during PKG install so browser
# enrollment can use XPC without SMJobBless / an admin password prompt.
install_privileged_helper() {
local src_helper="${APP_PATH}/Contents/Library/LaunchServices/${HELPER_LABEL}"
if [ ! -x "${src_helper}" ]; then
echo "error: privileged helper missing at ${src_helper}"
return 1
fi
mkdir -p /Library/PrivilegedHelperTools /Library/LaunchDaemons
if [ -f "${HELPER_PLIST}" ]; then
launchctl bootout system "${HELPER_PLIST}" 2>/dev/null || true
fi
# Match SMJobBless-style permissions (root:wheel, not world-writable).
install -m 0544 -o root -g wheel "${src_helper}" "${HELPER_BINARY}"
cat > "${HELPER_PLIST}" <<EOF
<?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>${HELPER_LABEL}</string>
<key>Program</key>
<string>${HELPER_BINARY}</string>
<key>ProgramArguments</key>
<array>
<string>${HELPER_BINARY}</string>
</array>
<key>MachServices</key>
<dict>
<key>${HELPER_LABEL}</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.probo.agent.url-handler</string>
</array>
</dict>
</plist>
EOF
chmod 0644 "${HELPER_PLIST}"
chown root:wheel "${HELPER_PLIST}"
if ! launchctl bootstrap system "${HELPER_PLIST}"; then
echo "warning: could not bootstrap ${HELPER_LABEL}; first XPC connect may start it."
return 0
fi
echo "Installed privileged helper at ${HELPER_BINARY}."
return 0
}
# Restart a previously enrolled LaunchDaemon after upgrades. Preinstall
# boots it out so the binary can be replaced; without /tmp/probo-agent.conf
# enrollment is skipped and nothing else would load it again.
@@ -246,6 +305,11 @@ else
echo "No ${CONF_FILE} found; enrollment can be completed from the menu bar icon."
fi
if ! install_privileged_helper; then
echo "error: privileged helper installation failed; browser enrollment will not work."
exit 1
fi
restart_existing_daemon
register_tray_launchagent
register_enrollment_url_scheme

View File

@@ -12,6 +12,9 @@ LOG_FILE="/var/log/probo-agent-install.log"
TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
HELPER_LABEL="com.probo.agent.helper"
HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist"
HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}"
mkdir -p "$(dirname "${LOG_FILE}")"
exec > >(tee -a "${LOG_FILE}") 2>&1
@@ -54,6 +57,17 @@ if [ -f "${DAEMON_PLIST}" ]; then
echo "Booted out LaunchDaemon at ${DAEMON_PLIST}."
fi
if [ -f "${HELPER_PLIST}" ]; then
launchctl bootout system "${HELPER_PLIST}" 2>/dev/null || true
rm -f "${HELPER_PLIST}"
echo "Removed privileged helper LaunchDaemon at ${HELPER_PLIST}."
fi
if [ -f "${HELPER_BINARY}" ]; then
rm -f "${HELPER_BINARY}"
echo "Removed privileged helper binary at ${HELPER_BINARY}."
fi
if [ -f "${TRAY_PLIST}" ]; then
echo "Existing tray LaunchAgent will be replaced by postinstall."
fi

View File

@@ -0,0 +1,149 @@
#!/bin/bash
#
# Fully remove a macOS PKG install of probo-agent.
#
# Idempotent: missing pieces are skipped. Must run as root.
#
# Removes:
# - agent service, tray LaunchAgent, privileged helper
# - binary, Probo Agent.app (and .localized variants)
# - state/run dirs, logs
# - PKG receipt (com.probo.agent)
# - stale Launch Services registration for the URL handler
set -u
BINARY="/usr/local/bin/probo-agent"
STATE_DIR="/var/lib/probo-agent"
RUN_DIR="/var/run/probo-agent"
DAEMON_PLIST="/Library/LaunchDaemons/com.probo.agent.plist"
HELPER_LABEL="com.probo.agent.helper"
HELPER_PLIST="/Library/LaunchDaemons/${HELPER_LABEL}.plist"
HELPER_BINARY="/Library/PrivilegedHelperTools/${HELPER_LABEL}"
TRAY_LABEL="com.probo.agent.tray"
TRAY_PLIST="/Library/LaunchAgents/${TRAY_LABEL}.plist"
PKG_ID="com.probo.agent"
LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
log() {
printf '%s\n' "$*"
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
require_root() {
if [ "$(id -u)" -ne 0 ]; then
die "must run as root (try: sudo make -C cmd/probo-agent uninstall)"
fi
}
bootout_system_plist() {
local plist="$1"
if [ -f "${plist}" ]; then
launchctl bootout system "${plist}" 2>/dev/null || true
log "Booted out ${plist}"
fi
}
bootout_tray_for_user() {
local username="$1"
local user_uid
if [ -z "${username}" ] || \
[ "${username}" = "root" ] || \
[ "${username}" = "loginwindow" ]; then
return 0
fi
user_uid="$(id -u "${username}" 2>/dev/null || true)"
if [ -z "${user_uid}" ]; then
return 0
fi
launchctl bootout "gui/${user_uid}/${TRAY_LABEL}" 2>/dev/null || true
}
unregister_apps() {
local path
for path in \
"/Applications/Probo Agent.app" \
"/Applications/Probo Agent.localized/Probo Agent.app"
do
if [ -d "${path}" ] && [ -x "${LSREGISTER}" ]; then
"${LSREGISTER}" -u "${path}" 2>/dev/null || true
log "Unregistered Launch Services entry for ${path}"
fi
done
}
kill_leftovers() {
# Best-effort; deleted-but-running binaries otherwise keep claiming probo://.
pkill -x probo-agent-url-handler 2>/dev/null || true
pkill -f '/usr/local/bin/probo-agent tray' 2>/dev/null || true
pkill -f '/Library/PrivilegedHelperTools/com.probo.agent.helper' 2>/dev/null || true
# Agent daemon may still be running after plist bootout races.
pkill -x probo-agent 2>/dev/null || true
}
require_root
if [ "$(uname -s)" != "Darwin" ]; then
die "this uninstall script is macOS-only"
fi
log "=== probo-agent macOS uninstall $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="
# Prefer the agent's own uninstall for service/tray/state when present.
if [ -x "${BINARY}" ]; then
if "${BINARY}" uninstall; then
log "Ran: ${BINARY} uninstall"
else
log "warning: ${BINARY} uninstall failed; continuing with manual cleanup"
fi
else
log "Binary not found at ${BINARY}; skipping probo-agent uninstall"
fi
seen_users=" "
for username in $(users 2>/dev/null || true); do
case "${seen_users}" in
*" ${username} "*) continue ;;
esac
seen_users="${seen_users}${username} "
bootout_tray_for_user "${username}"
done
bootout_tray_for_user "$(stat -f "%Su" /dev/console 2>/dev/null || true)"
bootout_system_plist "${DAEMON_PLIST}"
bootout_system_plist "${HELPER_PLIST}"
kill_leftovers
rm -f "${DAEMON_PLIST}" "${HELPER_PLIST}" "${HELPER_BINARY}" "${TRAY_PLIST}"
log "Removed LaunchDaemon / LaunchAgent / helper files (if present)"
unregister_apps
rm -rf \
"/Applications/Probo Agent.app" \
"/Applications/Probo Agent.localized"
log "Removed Probo Agent.app (if present)"
rm -f "${BINARY}"
rm -rf "${STATE_DIR}" "${RUN_DIR}"
rm -f \
/var/log/probo-agent.log \
/var/log/probo-agent-install.log \
/tmp/probo-agent.conf
log "Removed binary, state, run dir, logs, and staged conf (if present)"
if pkgutil --pkg-info "${PKG_ID}" >/dev/null 2>&1; then
if ! pkgutil --forget "${PKG_ID}" >/dev/null; then
die "failed to forget PKG receipt ${PKG_ID}"
fi
log "Forgot PKG receipt ${PKG_ID}"
fi
log "=== uninstall done ==="