Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions macos/ForgePlatformInstaller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@ An `ENABLED` system job is accepted only after a fixed-label native readback
confirms the registered parent bundle, Team, helper program and exact current
installer version. An older registered helper fails closed; registration does
not silently replace it. A separate reviewed transition is required.
For the earlier 0.2.4 qualification daemon only, `helper
replace-qualification` requires the fixed registered parent identity plus a
fresh idle/zero-run system readback before `SMAppService.unregister()`.
It then demands independent absence of that exact system job and a native
`NOT_REGISTERED` or `NOT_FOUND` status before registering and reattesting the
bundled helper. Registration and transition share the installer's exclusive
host lock with self-update. An interrupted transition can resume through the ordinary
`helper register` command once the system job is absent; another enabled
parent or an ambiguous status remains blocked.
On a Mac where the service has never been seen, native status can initially be
`NOT_FOUND`; the fixed bundled service is registered once and its status read
again. A registration error accompanied by native `REQUIRES_APPROVAL` remains
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ enum ForgePlatformInstallerCLIApplication {
confirm: Confirmation,
registerHelper: @escaping InstallerCLIHelperRegistration.Registrar =
InstallerCLIHelperRegistration.liveRegistrar,
replaceQualificationHelper: @escaping InstallerCLIHelperRegistration.Registrar =
InstallerCLIHelperRegistration.liveQualificationReplacer,
challengeWriter: ChallengeWriter = writeChallengeToTTY,
stdout: Writer,
stderr: Writer
Expand Down Expand Up @@ -196,6 +198,16 @@ enum ForgePlatformInstallerCLIApplication {
confirm: confirm,
register: registerHelper
)
case .helperReplaceQualification:
result = await InstallerCLIHelperRegistration.run(
startup: startup,
currentVersion: currentVersion,
currentRelease: currentRelease,
options: invocation.options,
confirm: confirm,
register: replaceQualificationHelper,
replacingQualification: true
)
case .deploymentList:
result = await workflow.listDeployments()
case .deploymentPlan(let deployment):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,18 @@ enum InstallerCLIHelperRegistration {

static let liveRegistrar: Registrar = { expectedVersion in
let service = MacOSManagedInstallerPrivilegedHelperServiceController()
return await ManagedInstallerPrivilegedHelperRegistrationCoordinator(
guard let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator.production(
service: service
).ensureRegistered(expectedVersion: expectedVersion)
) else { return .failed(.transitionBusy) }
return await coordinator.ensureRegistered(expectedVersion: expectedVersion)
}

static let liveQualificationReplacer: Registrar = { expectedVersion in
let service = MacOSManagedInstallerPrivilegedHelperServiceController()
guard let coordinator = ManagedInstallerPrivilegedHelperRegistrationCoordinator.production(
service: service
) else { return .failed(.transitionBusy) }
return await coordinator.replaceLegacyQualification(expectedVersion: expectedVersion)
}

static func run(
Expand All @@ -18,11 +27,14 @@ enum InstallerCLIHelperRegistration {
currentRelease: VerifiedInstallerRelease,
options: InstallerCLIOptions,
confirm: ForgePlatformInstallerCLIApplication.Confirmation,
register: Registrar
register: Registrar,
replacingQualification: Bool = false
) async -> InstallerCLIResult {
if !options.assumeYes {
let accepted = options.nonInteractive ? false : await confirm(
"Registreer de geverifieerde installer-helper als systeemdaemon?"
replacingQualification
? "Vervang de exacte inactieve 0.2.4-kwalificatiehelper door de geverifieerde systeemhelper?"
: "Registreer de geverifieerde installer-helper als systeemdaemon?"
)
if !accepted {
return InstallerCLIResult(
Expand Down Expand Up @@ -72,6 +84,8 @@ enum InstallerCLIHelperRegistration {
case .serviceUnavailable: reason = "service-unavailable"
case .statusDrift: reason = "status-drift"
case .registeredParentMismatch: reason = "registered-parent-mismatch"
case .unregistrationFailed: reason = "unregistration-failed"
case .transitionBusy: reason = "transition-busy"
}
return InstallerCLIResult(
exitCode: .executionFailed,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public enum InstallerCLICommand: Equatable, Sendable {
case version
case status
case helperRegister
case helperReplaceQualification
case selfUpdateCheck
case selfUpdateApply
case deploymentList
Expand Down Expand Up @@ -113,6 +114,7 @@ public enum InstallerCLIParser {
forge-platform-installer version [--json]
forge-platform-installer status [--json]
forge-platform-installer helper register [--yes] [--non-interactive] [--json]
forge-platform-installer helper replace-qualification [--yes] [--non-interactive] [--json]
forge-platform-installer self-update check [--json]
forge-platform-installer self-update apply [--yes] [--json]
forge-platform-installer deployment list [--json]
Expand Down Expand Up @@ -237,6 +239,8 @@ public enum InstallerCLIParser {
command = .status
case ["helper", "register"]:
command = .helperRegister
case ["helper", "replace-qualification"]:
command = .helperReplaceQualification
case ["self-update", "check"]:
command = .selfUpdateCheck
case ["self-update", "apply"]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public enum ManagedInstallerPrivilegedHelperRegistrationFailure:
case serviceUnavailable
case statusDrift
case registeredParentMismatch
case unregistrationFailed
case transitionBusy
}

public struct ManagedInstallerPrivilegedHelperRegistrationReceipt:
Expand Down Expand Up @@ -58,7 +60,10 @@ public enum ManagedInstallerPrivilegedHelperRegistrationResult:
public protocol ManagedInstallerPrivilegedHelperServiceControlling: Sendable {
func readStatus() -> ManagedInstallerPrivilegedHelperStatus
func readRegisteredParentVersion() -> InstallerVersion?
func readIdleRegisteredParentVersion() -> InstallerVersion?
func readSystemJobAbsent() -> Bool
func register() throws
func unregister() throws
}

/// Thin ServiceManagement adapter. The daemon plist name is fixed by the
Expand All @@ -68,25 +73,37 @@ public final class MacOSManagedInstallerPrivilegedHelperServiceController:
ManagedInstallerPrivilegedHelperServiceControlling, @unchecked Sendable {
private let statusReader: @Sendable () -> SMAppService.Status
private let registrar: @Sendable () throws -> Void
private let unregistrar: @Sendable () throws -> Void
private let parentVersionReader: @Sendable () -> InstallerVersion?
private let idleParentVersionReader: @Sendable () -> InstallerVersion?
private let jobAbsenceReader: @Sendable () -> Bool

public init() {
let service = SMAppService.daemon(
plistName: ManagedInstallerPrivilegedHelperContract.plistName
)
statusReader = { service.status }
registrar = { try service.register() }
unregistrar = { try service.unregister() }
parentVersionReader = { RegisteredInstallerHelperParentReader().readVersion() }
idleParentVersionReader = { RegisteredInstallerHelperParentReader().readIdleVersion() }
jobAbsenceReader = { RegisteredInstallerHelperParentReader().readAbsent() }
}

init(
statusReader: @escaping @Sendable () -> SMAppService.Status,
registrar: @escaping @Sendable () throws -> Void,
parentVersionReader: @escaping @Sendable () -> InstallerVersion? = { nil }
parentVersionReader: @escaping @Sendable () -> InstallerVersion? = { nil },
idleParentVersionReader: @escaping @Sendable () -> InstallerVersion? = { nil },
jobAbsenceReader: @escaping @Sendable () -> Bool = { false },
unregistrar: @escaping @Sendable () throws -> Void = {}
) {
self.statusReader = statusReader
self.registrar = registrar
self.unregistrar = unregistrar
self.parentVersionReader = parentVersionReader
self.idleParentVersionReader = idleParentVersionReader
self.jobAbsenceReader = jobAbsenceReader
}

public func readStatus() -> ManagedInstallerPrivilegedHelperStatus {
Expand All @@ -106,12 +123,39 @@ public final class MacOSManagedInstallerPrivilegedHelperServiceController:
public func readRegisteredParentVersion() -> InstallerVersion? {
parentVersionReader()
}

public func readIdleRegisteredParentVersion() -> InstallerVersion? {
idleParentVersionReader()
}

public func readSystemJobAbsent() -> Bool {
jobAbsenceReader()
}

public func unregister() throws {
try unregistrar()
}
}

/// Inspects only the fixed system job. The parent identity is an independent
/// ServiceManagement readback, never a version supplied by the CLI caller.
struct RegisteredInstallerHelperParentReader: Sendable {
func readVersion() -> InstallerVersion? {
guard let observation = readObservation(), observation.status == 0 else { return nil }
return Self.parse(observation.output)
}

func readIdleVersion() -> InstallerVersion? {
guard let observation = readObservation(), observation.status == 0 else { return nil }
return Self.parseIdle(observation.output)
}

func readAbsent() -> Bool {
guard let observation = readObservation() else { return false }
return Self.isAbsent(status: observation.status, output: observation.output)
}

private func readObservation() -> (status: Int32, output: String)? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/launchctl")
process.arguments = ["print", "system/" + ManagedInstallerPrivilegedHelperContract.label]
Expand All @@ -138,10 +182,22 @@ struct RegisteredInstallerHelperParentReader: Sendable {
}
guard drained.wait(timeout: .now() + .seconds(2)) == .success,
process.terminationReason == .exit,
process.terminationStatus == 0,
let data = collector.collectedData(),
let output = String(data: data, encoding: .utf8) else { return nil }
return Self.parse(output)
return (process.terminationStatus, output)
}

static func isAbsent(status: Int32, output: String) -> Bool {
guard status == 113 else { return false }
return output.trimmingCharacters(in: .whitespacesAndNewlines) ==
"Bad request.\nCould not find service \"\(ManagedInstallerPrivilegedHelperContract.label)\" in domain for system"
}

static func parseIdle(_ output: String) -> InstallerVersion? {
let lines = output.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }
guard lines.filter({ $0 == "state = not running" }).count == 1,
lines.filter({ $0 == "runs = 0" }).count == 1 else { return nil }
return parse(output)
}

static func parse(_ output: String) -> InstallerVersion? {
Expand All @@ -155,7 +211,7 @@ struct RegisteredInstallerHelperParentReader: Sendable {
}
guard lines.first == "system/\(ManagedInstallerPrivilegedHelperContract.label) = {",
uniqueValue("managed_by") == "com.apple.xpc.ServiceManagement",
lines.filter({ $0 == "\"team-identifier\" => \"ZEML4LPXH4\"" }).count == 1,
lines.filter({ $0 == "\"team-identifier\" => \"\(ManagedInstallerHelperSignedParentBundleLocator.teamIdentifier)\"" }).count == 1,
lines.filter({ $0 == "\"signing-identifier\" => \"\(ManagedInstallerPrivilegedHelperContract.label)\"" }).count == 1,
uniqueValue("parent bundle identifier") ==
ManagedInstallerHelperSignedParentBundleLocator.bundleIdentifier,
Expand All @@ -172,13 +228,34 @@ struct RegisteredInstallerHelperParentReader: Sendable {
/// route. User approval remains an explicit non-ready state.
public actor ManagedInstallerPrivilegedHelperRegistrationCoordinator {
private let service: any ManagedInstallerPrivilegedHelperServiceControlling
private let operationLock: (any InstallerSelfUpdateOperationLocking)?

public init(service: any ManagedInstallerPrivilegedHelperServiceControlling) {
public init(
service: any ManagedInstallerPrivilegedHelperServiceControlling,
operationLock: (any InstallerSelfUpdateOperationLocking)? = nil
) {
self.service = service
self.operationLock = operationLock
}

public static func production(
service: any ManagedInstallerPrivilegedHelperServiceControlling
) -> ManagedInstallerPrivilegedHelperRegistrationCoordinator? {
guard let root = try? MacOSInstallerUserStateRoot.prepare() else { return nil }
return Self(service: service,
operationLock: FileInstallerSelfUpdateOperationLock(rootDirectory: root))
}

public func ensureRegistered(
expectedVersion: InstallerVersion
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
withRegistrationLock {
ensureRegisteredWhileLocked(expectedVersion: expectedVersion)
}
}

private func ensureRegisteredWhileLocked(
expectedVersion: InstallerVersion
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
switch service.readStatus() {
case .enabled:
Expand Down Expand Up @@ -211,6 +288,62 @@ public actor ManagedInstallerPrivilegedHelperRegistrationCoordinator {
}
}

/// One-time transition from the known idle 0.2.4 qualification daemon.
/// A crash after unregister leaves a visible NOT_REGISTERED state; the
/// ordinary fixed-label `register` route can safely finish from there.
public func replaceLegacyQualification(
expectedVersion: InstallerVersion
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
withRegistrationLock {
replaceLegacyQualificationWhileLocked(expectedVersion: expectedVersion)
}
}

private func replaceLegacyQualificationWhileLocked(
expectedVersion: InstallerVersion
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
let initialStatus = service.readStatus()
guard let legacy = try? InstallerVersion("0.2.4"),
legacy < expectedVersion,
initialStatus != .requiresApproval,
service.readRegisteredParentVersion() == legacy,
service.readIdleRegisteredParentVersion() == legacy,
service.readStatus() == initialStatus,
service.readRegisteredParentVersion() == legacy,
service.readIdleRegisteredParentVersion() == legacy else {
return .failed(.registeredParentMismatch)
}
do {
try service.unregister()
} catch {
return .failed(.unregistrationFailed)
}
guard service.readSystemJobAbsent() else {
return .failed(.statusDrift)
}
switch service.readStatus() {
case .notRegistered, .notFound:
return ensureRegisteredWhileLocked(expectedVersion: expectedVersion)
case .enabled, .requiresApproval:
return .failed(.statusDrift)
}
}

private func withRegistrationLock(
_ operation: () -> ManagedInstallerPrivilegedHelperRegistrationResult
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
guard let operationLock else { return operation() }
guard case .success(let lease) = operationLock
.acquireExclusiveSelfUpdateOperationLock() else {
return .failed(.transitionBusy)
}
let result = operation()
guard case .success = lease.releaseExclusiveSelfUpdateOperationLock() else {
return .failed(.transitionBusy)
}
return result
}

private func verifiedReady(
expectedVersion: InstallerVersion
) -> ManagedInstallerPrivilegedHelperRegistrationResult {
Expand Down
Loading
Loading