import AppKit import Darwin import Dispatch import Foundation import OSLog import Security @_silgen_name("csops") private func csops( _: pid_t, _: UInt32, _: UnsafeMutableRawPointer?, _: Int) -> Int32 @MainActor enum ApplicationRelocator { struct ApplicationIdentity: Equatable, Sendable { let bundleIdentifier: String let buildVersion: String } struct InstallCandidate: Equatable, Sendable { let url: URL let exists: Bool let isWritable: Bool let isTrusted: Bool let identity: ApplicationIdentity? } struct Environment: Equatable, Sendable { let bundleURL: URL let homeDirectory: URL let currentIdentity: ApplicationIdentity? let candidates: [InstallCandidate] let isReadOnlyVolume: Bool let isDebugOrTesting: Bool } enum Recommendation: Equatable, Sendable { case continueLaunch case handOff(URL) case offerInstall(destination: URL, replacing: Bool) case cannotInstall } enum LaunchDisposition: Equatable, Sendable { case continueLaunch(startUpdater: Bool) case terminating } struct ApplicationOnDisk: Equatable, Sendable { let bundleIdentifier: String let executableURL: URL } enum ReplacementAction: Equatable, Sendable { case unchanged case waitForTrustedReplacement case relaunch } enum ReplacementHandoffFailureAction: Equatable, Sendable { case retry(after: Duration) case stopMonitoring case terminateForSupervisor var isTerminal: Bool { switch self { case .retry: false case .stopMonitoring, .terminateForSupervisor: true } } } struct ReplacementHandoffPolicy: Equatable, Sendable { let maximumAttempts: Int let initialBackoffSeconds: Int let maximumBackoffSeconds: Int let baseTimeoutMilliseconds: Int32 let maximumTimeoutMilliseconds: Int32 func failureAction( failedAttempt: Int, hasKeepAliveSupervisor: Bool) -> ReplacementHandoffFailureAction { guard failedAttempt < self.maximumAttempts else { return hasKeepAliveSupervisor ? .terminateForSupervisor : .stopMonitoring } let exponent = max(0, failedAttempt - 1) let backoff = min( self.maximumBackoffSeconds, self.initialBackoffSeconds * (1 << exponent)) return .retry(after: .seconds(backoff)) } func timeoutMilliseconds( loadAverage: Double?, activeProcessorCount: Int) -> Int32 { guard let loadAverage, loadAverage.isFinite, loadAverage > 0, activeProcessorCount > 0 else { return self.baseTimeoutMilliseconds } let loadPerProcessor = loadAverage / Double(activeProcessorCount) let multiplier = min(4, max(1, loadPerProcessor)) let scaled = Double(self.baseTimeoutMilliseconds) * multiplier let rounded = ceil(scaled / 5000) * 5000 return min(self.maximumTimeoutMilliseconds, Int32(rounded)) } } enum RelaunchStrategy: Equatable, Sendable { case openAfterTermination case externalSupervisor } struct BundleFileReference: Equatable, Sendable { let deviceIdentifier: UInt64 let fileIdentifier: UInt64 let executableRelativePath: String var executableURL: URL { self.bundleURL.appendingPathComponent(self.executableRelativePath) } var bundleURL: URL { URL( fileURLWithPath: "/.vol/\(self.deviceIdentifier)/\(self.fileIdentifier)", isDirectory: true) } } private struct KeepAliveSupervisor: Sendable { let label: String let plistURL: URL } private struct BundleReplacementSnapshot: Sendable { let bundleURL: URL let bundleIdentifier: String let executableURL: URL let codeDirectoryHash: Data let requirementData: Data } private struct ReplacementEvaluation: Sendable { let action: ReplacementAction let launchReference: BundleFileReference? let launchCodeDirectoryHash: Data? } private enum ReplacementScheduleResult { case scheduled case failed(supervisor: KeepAliveSupervisor?) } private static let logger = Logger(subsystem: "ai.openclaw", category: "app-relocation") private static var bundleReplacementSnapshot: BundleReplacementSnapshot? private static var bundleReplacementSource: DispatchSourceFileSystemObject? private static var bundleReplacementRecoveryTask: Task? private static var bundleReplacementCheckPending = false private static var bundleReplacementHandoffInProgress = false private static var bundleReplacementHandoffAttempt = 0 private static var bundleReplacementHandoffTargetHash: Data? private static var inheritedReplacementSupervisor: KeepAliveSupervisor? private static var supervisorRestorationWatcher: Process? private static var authenticatedReplacementSourceBundleURL: URL? nonisolated static let replacementHandoffPolicy = ReplacementHandoffPolicy( maximumAttempts: 3, initialBackoffSeconds: 2, maximumBackoffSeconds: 8, baseTimeoutMilliseconds: 15000, maximumTimeoutMilliseconds: 60000) private nonisolated static let replacementSourceBundleEnvironmentKey = "OPENCLAW_REPLACEMENT_SOURCE_BUNDLE" private nonisolated static let replacementParentPIDEnvironmentKey = "OPENCLAW_REPLACEMENT_PARENT_PID" private nonisolated static let replacementCodeHashEnvironmentKey = "OPENCLAW_REPLACEMENT_CODE_HASH" private nonisolated static let replacementReadyFDEnvironmentKey = "OPENCLAW_REPLACEMENT_READY_FD" private nonisolated static let replacementBootoutTargetEnvironmentKey = "OPENCLAW_REPLACEMENT_BOOTOUT_TARGET" private nonisolated static let replacementSupervisorLabelEnvironmentKey = "OPENCLAW_REPLACEMENT_SUPERVISOR_LABEL" private nonisolated static let replacementSupervisorPlistEnvironmentKey = "OPENCLAW_REPLACEMENT_SUPERVISOR_PLIST" static func recommendation(for environment: Environment) -> Recommendation { guard !environment.isDebugOrTesting, self.isTransientLocation( environment.bundleURL, homeDirectory: environment.homeDirectory, isReadOnlyVolume: environment.isReadOnlyVolume) else { return .continueLaunch } if let currentIdentity = environment.currentIdentity { for candidate in environment.candidates { guard let installedIdentity = candidate.identity, candidate.isTrusted, installedIdentity.bundleIdentifier == currentIdentity.bundleIdentifier, compareBuild(installedIdentity.buildVersion, currentIdentity.buildVersion) != .orderedAscending else { continue } return .handOff(candidate.url) } for candidate in environment.candidates { guard candidate.isWritable, candidate.isTrusted, let installedIdentity = candidate.identity, installedIdentity.bundleIdentifier == currentIdentity.bundleIdentifier else { continue } return .offerInstall(destination: candidate.url, replacing: true) } } if let destination = environment.candidates.first(where: { !$0.exists && $0.isWritable }) { return .offerInstall(destination: destination.url, replacing: false) } return .cannotInstall } static func isTransientLocation( _ bundleURL: URL, homeDirectory: URL, isReadOnlyVolume: Bool) -> Bool { let path = bundleURL.standardizedFileURL.path let homePath = homeDirectory.standardizedFileURL.path let stableRoots = ["/Applications", "\(homePath)/Applications"] if stableRoots.contains(where: { self.isInside(path, root: $0) }) { return false } if path.contains("/AppTranslocation/") { return true } let transientRoots = ["\(homePath)/Downloads", "\(homePath)/Desktop"] if transientRoots.contains(where: { self.isInside(path, root: $0) }) { return true } return isInside(path, root: "/Volumes") && isReadOnlyVolume } static func handleLaunch( bundle: Bundle = .main, fileManager: FileManager = .default, processInfo: ProcessInfo = .processInfo) -> LaunchDisposition { let environment = currentEnvironment( bundle: bundle, fileManager: fileManager, processInfo: processInfo) switch self.recommendation(for: environment) { case .continueLaunch: #if DEBUG let monitorDebugReplacement = processInfo.environment["OPENCLAW_MONITOR_APP_REPLACEMENT"] == "1" #else let monitorDebugReplacement = true #endif if !processInfo.isRunningTests, !processInfo.isPreview, monitorDebugReplacement { let monitoredBundleURL = replacementSourceBundleURL( environment: processInfo.environment, fallback: bundle.bundleURL) startBundleReplacementMonitoring(bundle: bundle, at: monitoredBundleURL) } return .continueLaunch(startUpdater: true) case let .handOff(destination): return relaunchAndTerminate(at: destination) case let .offerInstall(destination, replacing): guard confirmInstall(replacing: replacing) else { return .continueLaunch(startUpdater: false) } do { try install( source: environment.bundleURL, destination: destination, replacing: replacing, fileManager: fileManager) return relaunchAndTerminate(at: destination) } catch { self.logger.error("Could not install app: \(error.localizedDescription, privacy: .public)") showFailure( "OpenClaw couldn’t be installed in Applications. Move it there manually, then open that copy.") return .continueLaunch(startUpdater: false) } case .cannotInstall: let message = "OpenClaw is running from a temporary location. " + "Move it to Applications manually to enable updates and launch at login." showFailure(message) return .continueLaunch(startUpdater: false) } } static func currentBundleAllowsPersistentIntegration( bundle: Bundle = .main, fileManager: FileManager = .default, processInfo: ProcessInfo = .processInfo) -> Bool { #if DEBUG let debugBuild = true #else let debugBuild = false #endif if debugBuild || processInfo.isRunningTests || processInfo.isPreview { return true } let bundleURL = replacementSourceBundleURL( environment: processInfo.environment, fallback: bundle.bundleURL) let isReadOnlyVolume = (try? bundleURL.resourceValues(forKeys: [.volumeIsReadOnlyKey]))? .volumeIsReadOnly ?? false return !self.isTransientLocation( bundleURL, homeDirectory: fileManager.homeDirectoryForCurrentUser, isReadOnlyVolume: isReadOnlyVolume) } } extension ApplicationRelocator { nonisolated static func replacementAction( launchedCodeDirectoryHash: Data, installedCodeDirectoryHash: Data?, sameBundleIdentifier: Bool, trusted: Bool) -> ReplacementAction { guard let installedCodeDirectoryHash else { return .waitForTrustedReplacement } guard installedCodeDirectoryHash != launchedCodeDirectoryHash else { return .unchanged } guard sameBundleIdentifier, trusted else { return .waitForTrustedReplacement } return .relaunch } static func relaunchStrategy( xpcServiceName: String?, executableURL: URL?, homeDirectory: URL, fileManager: FileManager = .default) -> RelaunchStrategy { self.verifiedKeepAliveSupervisor( xpcServiceName: xpcServiceName, executableURL: executableURL, homeDirectory: homeDirectory, fileManager: fileManager) == nil ? .openAfterTermination : .externalSupervisor } private static func verifiedKeepAliveSupervisor( xpcServiceName: String?, executableURL: URL?, homeDirectory: URL, fileManager _: FileManager = .default) -> KeepAliveSupervisor? { guard let serviceName = xpcServiceName?.trimmingCharacters(in: .whitespacesAndNewlines), !serviceName.isEmpty, serviceName != "0", !serviceName.hasPrefix("application."), URL(fileURLWithPath: serviceName).lastPathComponent == serviceName, let executableURL else { return nil } let launchAgentURLs = [ homeDirectory.appendingPathComponent("Library/LaunchAgents/\(serviceName).plist"), URL(fileURLWithPath: "/Library/LaunchAgents/\(serviceName).plist"), URL(fileURLWithPath: "/System/Library/LaunchAgents/\(serviceName).plist"), ] let expectedExecutable = executableURL.standardizedFileURL.path for url in launchAgentURLs { if let supervisor = keepAliveSupervisor( label: serviceName, plistURL: url, expectedExecutablePath: expectedExecutable) { return supervisor } } return nil } private static func inheritedSupervisor( environment: [String: String], monitoredBundleURL: URL) -> KeepAliveSupervisor? { guard let label = environment[replacementSupervisorLabelEnvironmentKey], let plistPath = environment[replacementSupervisorPlistEnvironmentKey], plistPath.hasPrefix("/"), let executablePath = applicationOnDisk(at: monitoredBundleURL)?.executableURL.path else { return nil } let plistURL = URL(fileURLWithPath: plistPath).standardizedFileURL let allowedDirectories = [ FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/LaunchAgents"), URL(fileURLWithPath: "/Library/LaunchAgents"), URL(fileURLWithPath: "/System/Library/LaunchAgents"), ].map(\.standardizedFileURL.path) guard allowedDirectories.contains(plistURL.deletingLastPathComponent().path) else { return nil } return self.keepAliveSupervisor( label: label, plistURL: plistURL, expectedExecutablePath: executablePath) } private static func keepAliveSupervisor( label: String, plistURL: URL, expectedExecutablePath: String) -> KeepAliveSupervisor? { guard !label.isEmpty, label != "0", !label.hasPrefix("application."), URL(fileURLWithPath: label).lastPathComponent == label, let data = try? Data(contentsOf: plistURL), let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], plist["Label"] as? String == label, plist["KeepAlive"] as? Bool == true else { return nil } let configuredExecutable = (plist["Program"] as? String) ?? (plist["ProgramArguments"] as? [String])?.first guard configuredExecutable.map({ URL(fileURLWithPath: $0).standardizedFileURL.path }) == URL(fileURLWithPath: expectedExecutablePath).standardizedFileURL.path else { return nil } return KeepAliveSupervisor(label: label, plistURL: plistURL) } static func acceptReplacementHandoff( environment: [String: String], bundle: Bundle = .main) -> Bool { guard let sourcePath = environment[replacementSourceBundleEnvironmentKey], sourcePath.hasPrefix("/"), URL(fileURLWithPath: sourcePath).pathExtension == "app", let parentPIDText = environment[replacementParentPIDEnvironmentKey], let parentPID = pid_t(parentPIDText), parentPID == getppid(), let expectedHashText = environment[replacementCodeHashEnvironmentKey], let expectedHash = Data(base64Encoded: expectedHashText), expectedHash == kernelCodeDirectoryHash(), let readyFDText = environment[replacementReadyFDEnvironmentKey], let readyFD = Int32(readyFDText), readyFD >= 3, fcntl(readyFD, F_GETFD) != -1, let bundleIdentifier = bundle.bundleIdentifier, let identity = runningCodeIdentity(bundleIdentifier: bundleIdentifier), let executableURL = bundle.executableURL, // Revalidate the bound bundle in the child. The parent cannot make // the sealed resources immutable across posix_spawn. trustedCodeDirectoryHash( at: bundle.bundleURL, executableURL: executableURL, matching: identity.requirementData) == expectedHash, process(parentPID, matches: identity.requirementData) else { return false } let monitoredBundleURL = URL(fileURLWithPath: sourcePath).standardizedFileURL let supervisor = self.inheritedSupervisor( environment: environment, monitoredBundleURL: monitoredBundleURL) let hasSupervisorMetadata = environment[replacementSupervisorLabelEnvironmentKey] != nil || environment[self.replacementSupervisorPlistEnvironmentKey] != nil guard !hasSupervisorMetadata || supervisor != nil else { self.writeHandoffStatus("FAIL", to: readyFD) return false } self.inheritedReplacementSupervisor = supervisor if let supervisor, !startSupervisorRestorationWatcher(supervisor) { self.writeHandoffStatus("FAIL", to: readyFD) return false } let bootoutTarget = environment[replacementBootoutTargetEnvironmentKey] if let target = bootoutTarget { guard let supervisor, target == "gui/\(getuid())/\(supervisor.label)", bootoutLaunchdTarget(target) else { self.cancelSupervisorRestorationWatcher() self.writeHandoffStatus("FAIL", to: readyFD) return false } } self.authenticatedReplacementSourceBundleURL = monitoredBundleURL self.writeHandoffStatus("READY", to: readyFD) return true } nonisolated static func hasReplacementHandoffMetadata(environment: [String: String]) -> Bool { environment[self.replacementParentPIDEnvironmentKey] != nil || environment[self.replacementReadyFDEnvironmentKey] != nil || environment[self.replacementCodeHashEnvironmentKey] != nil } private static func bootoutLaunchdTarget(_ target: String) -> Bool { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/launchctl") process.arguments = ["bootout", target] do { try process.run() process.waitUntilExit() return process.terminationStatus == 0 } catch { self.logger.error("Could not unload launchd owner: \(error.localizedDescription, privacy: .public)") return false } } private nonisolated static func writeHandoffStatus(_ status: String, to descriptor: Int32) { defer { Darwin.close(descriptor) } _ = fcntl(descriptor, F_SETNOSIGPIPE, 1) let bytes = Array(status.utf8) _ = bytes.withUnsafeBytes { buffer in Darwin.write(descriptor, buffer.baseAddress, buffer.count) } } private nonisolated static func process(_ pid: pid_t, matches requirementData: Data) -> Bool { var requirement: SecRequirement? guard SecRequirementCreateWithData(requirementData as CFData, SecCSFlags(), &requirement) == errSecSuccess, let requirement else { return false } let attributes = [kSecGuestAttributePid as String: NSNumber(value: pid)] as CFDictionary var code: SecCode? guard SecCodeCopyGuestWithAttributes(nil, attributes, SecCSFlags(), &code) == errSecSuccess, let code else { return false } return SecCodeCheckValidity(code, SecCSFlags(), requirement) == errSecSuccess } nonisolated static func applicationOnDisk(at bundleURL: URL) -> ApplicationOnDisk? { let infoPlistURL = bundleURL.appendingPathComponent("Contents/Info.plist") guard let data = try? Data(contentsOf: infoPlistURL), let values = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], let bundleIdentifier = values["CFBundleIdentifier"] as? String, !bundleIdentifier.isEmpty, let executableName = values["CFBundleExecutable"] as? String, !executableName.isEmpty, URL(fileURLWithPath: executableName).lastPathComponent == executableName else { return nil } return ApplicationOnDisk( bundleIdentifier: bundleIdentifier, executableURL: bundleURL .appendingPathComponent("Contents/MacOS") .appendingPathComponent(executableName)) } private static func currentEnvironment( bundle: Bundle, fileManager: FileManager, processInfo: ProcessInfo) -> Environment { let bundleURL = self.replacementSourceBundleURL( environment: processInfo.environment, fallback: bundle.bundleURL) let homeDirectory = fileManager.homeDirectoryForCurrentUser.standardizedFileURL let appName = bundleURL.lastPathComponent let destinations = [ URL(fileURLWithPath: "/Applications").appendingPathComponent(appName), homeDirectory.appendingPathComponent("Applications").appendingPathComponent(appName), ] let currentRequirement = self.designatedRequirement(for: bundleURL) let candidates = destinations.map { destination in let exists = fileManager.fileExists(atPath: destination.path) let installedBundle = exists ? Bundle(url: destination) : nil return InstallCandidate( url: destination, exists: exists, isWritable: self.canWrite(destination: destination, fileManager: fileManager), isTrusted: installedBundle.map { self.isTrustedInstalledApp($0, matching: currentRequirement, fileManager: fileManager) } ?? false, identity: installedBundle.flatMap(self.identity(for:))) } #if DEBUG let debugBuild = true #else let debugBuild = false #endif let isReadOnlyVolume = (try? bundleURL.resourceValues(forKeys: [.volumeIsReadOnlyKey]))? .volumeIsReadOnly ?? false return Environment( bundleURL: bundleURL, homeDirectory: homeDirectory, currentIdentity: self.identity(for: bundle), candidates: candidates, isReadOnlyVolume: isReadOnlyVolume, isDebugOrTesting: debugBuild || processInfo.isRunningTests || processInfo.isPreview) } private static func identity(for bundle: Bundle) -> ApplicationIdentity? { guard let bundleIdentifier = bundle.bundleIdentifier, let buildVersion = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String else { return nil } return ApplicationIdentity(bundleIdentifier: bundleIdentifier, buildVersion: buildVersion) } private static func replacementSourceBundleURL( environment _: [String: String], fallback: URL) -> URL { self.authenticatedReplacementSourceBundleURL ?? fallback.standardizedFileURL } private static func startBundleReplacementMonitoring(bundle: Bundle, at monitoredBundleURL: URL) { self.bundleReplacementRecoveryTask?.cancel() self.bundleReplacementRecoveryTask = nil self.bundleReplacementSource?.cancel() self.bundleReplacementSource = nil self.bundleReplacementSnapshot = nil self.bundleReplacementCheckPending = false self.bundleReplacementHandoffInProgress = false self.bundleReplacementHandoffAttempt = 0 self.bundleReplacementHandoffTargetHash = nil let bundleURL = monitoredBundleURL.standardizedFileURL guard bundleURL.pathExtension == "app", let bundleIdentifier = bundle.bundleIdentifier, let installedApp = applicationOnDisk(at: bundleURL), installedApp.bundleIdentifier == bundleIdentifier, let runningIdentity = runningCodeIdentity(bundleIdentifier: bundleIdentifier) else { self.logger.warning("Installed app replacement monitoring is unavailable") return } self.bundleReplacementSnapshot = BundleReplacementSnapshot( bundleURL: bundleURL, bundleIdentifier: bundleIdentifier, executableURL: installedApp.executableURL, codeDirectoryHash: runningIdentity.codeDirectoryHash, requirementData: runningIdentity.requirementData) let descriptor = open(bundleURL.deletingLastPathComponent().path, O_EVTONLY | O_CLOEXEC) guard descriptor >= 0 else { self.logger.error("Could not monitor installed app directory: errno \(errno)") self.bundleReplacementSnapshot = nil return } let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: descriptor, eventMask: [.write, .delete, .rename, .revoke], queue: .main) source.setEventHandler { Task { @MainActor in ApplicationRelocator.bundleDirectoryDidChange() } } source.setCancelHandler { Darwin.close(descriptor) } self.bundleReplacementSource = source source.resume() // Reconcile once after arming. This closes the launch-to-watch window even // when the directory event coalesced before the source became active. self.bundleDirectoryDidChange() self.logger.notice("Monitoring the installed app for signed replacement") } private static func bundleDirectoryDidChange() { self.bundleReplacementCheckPending = true guard !self.bundleReplacementHandoffInProgress, self.bundleReplacementRecoveryTask == nil, let snapshot = bundleReplacementSnapshot else { return } self.bundleReplacementRecoveryTask = Task { @MainActor in defer { self.bundleReplacementRecoveryTask = nil if self.bundleReplacementCheckPending { self.bundleDirectoryDidChange() } } // Atomic replacements can briefly remove the bundle or expose it before // code-signature validation is complete. Keep the old process alive until // the complete replacement is present and trusted. var attempt = 0 while !Task.isCancelled { self.bundleReplacementCheckPending = false let evaluation = await Task.detached(priority: .utility) { self.replacementEvaluationOnDisk(for: snapshot) }.value switch evaluation.action { case .unchanged: self.bundleReplacementHandoffAttempt = 0 self.bundleReplacementHandoffTargetHash = nil if self.bundleReplacementCheckPending { continue } return case .waitForTrustedReplacement: if attempt == 120 { self.logger.warning( "Installed app is still incomplete or untrusted; continuing replacement recovery") } let retryDelay: Duration = attempt < 120 ? .milliseconds(250) : .seconds(5) attempt += 1 try? await Task.sleep(for: retryDelay) guard !Task.isCancelled else { return } case .relaunch: guard let launchReference = evaluation.launchReference, let launchCodeDirectoryHash = evaluation.launchCodeDirectoryHash else { try? await Task.sleep(for: .milliseconds(250)) continue } if self.bundleReplacementHandoffTargetHash != launchCodeDirectoryHash { self.bundleReplacementHandoffTargetHash = launchCodeDirectoryHash self.bundleReplacementHandoffAttempt = 0 } self.bundleReplacementHandoffAttempt += 1 let handoffAttempt = self.bundleReplacementHandoffAttempt let maximumAttempts = self.replacementHandoffPolicy.maximumAttempts self.bundleReplacementCheckPending = false self.logger.notice( "Relaunching trusted replacement (attempt \(handoffAttempt)/\(maximumAttempts))") self.bundleReplacementHandoffInProgress = true let scheduleResult = self.scheduleReplacementRelaunch( at: snapshot.bundleURL, launchReference: launchReference, codeDirectoryHash: launchCodeDirectoryHash, attempt: handoffAttempt) if case let .failed(supervisor) = scheduleResult { await self.handleReplacementHandoffFailure( attempt: handoffAttempt, targetCodeDirectoryHash: launchCodeDirectoryHash, supervisor: supervisor, reason: "Could not spawn the trusted replacement") } // The task defer clears recovery ownership before draining the // retry check queued by the failure handler. return } } } } private nonisolated static func replacementEvaluationOnDisk( for snapshot: BundleReplacementSnapshot) -> ReplacementEvaluation { guard let installedApp = applicationOnDisk(at: snapshot.bundleURL) else { return ReplacementEvaluation( action: .waitForTrustedReplacement, launchReference: nil, launchCodeDirectoryHash: nil) } guard let launchReference = bundleFileReference( bundleURL: snapshot.bundleURL, executableURL: installedApp.executableURL) else { return ReplacementEvaluation( action: .waitForTrustedReplacement, launchReference: nil, launchCodeDirectoryHash: nil) } let sameBundleIdentifier = installedApp.bundleIdentifier == snapshot.bundleIdentifier let installedCodeDirectoryHash = self.trustedCodeDirectoryHash( at: snapshot.bundleURL, executableURL: installedApp.executableURL, matching: snapshot.requirementData) // Security validates the canonical path. Re-capture its object identity // afterward so the launch reference can only name that validated bundle. guard self.bundleFileReference( bundleURL: snapshot.bundleURL, executableURL: installedApp.executableURL) == launchReference else { return ReplacementEvaluation( action: .waitForTrustedReplacement, launchReference: nil, launchCodeDirectoryHash: nil) } let action = self.replacementAction( launchedCodeDirectoryHash: snapshot.codeDirectoryHash, installedCodeDirectoryHash: installedCodeDirectoryHash, sameBundleIdentifier: sameBundleIdentifier, trusted: installedCodeDirectoryHash != nil) return ReplacementEvaluation( action: action, launchReference: action == .relaunch ? launchReference : nil, launchCodeDirectoryHash: action == .relaunch ? installedCodeDirectoryHash : nil) } nonisolated static func bundleFileReference( bundleURL: URL, executableURL: URL) -> BundleFileReference? { let bundlePath = bundleURL.standardizedFileURL.path let executablePath = executableURL.standardizedFileURL.path let prefix = bundlePath + "/" guard executablePath.hasPrefix(prefix) else { return nil } var bundleInformation = stat() guard stat(bundlePath, &bundleInformation) == 0, bundleInformation.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) else { return nil } let relativePath = String(executablePath.dropFirst(prefix.count)) guard !relativePath.isEmpty else { return nil } let reference = BundleFileReference( deviceIdentifier: UInt64(truncatingIfNeeded: bundleInformation.st_dev), fileIdentifier: UInt64(bundleInformation.st_ino), executableRelativePath: relativePath) var canonicalExecutableInformation = stat() var referencedExecutableInformation = stat() guard stat(executablePath, &canonicalExecutableInformation) == 0, stat(reference.executableURL.path, &referencedExecutableInformation) == 0, canonicalExecutableInformation.st_dev == referencedExecutableInformation.st_dev, canonicalExecutableInformation.st_ino == referencedExecutableInformation.st_ino, canonicalExecutableInformation.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG), canonicalExecutableInformation.st_mode & mode_t(S_IXUSR) != 0 else { return nil } return reference } private static func runningCodeIdentity( bundleIdentifier: String) -> (codeDirectoryHash: Data, requirementData: Data)? { guard let codeDirectoryHash = kernelCodeDirectoryHash(), let teamIdentifier = kernelTeamIdentifier(), let requirementString = developerIDRequirementString( bundleIdentifier: bundleIdentifier, teamIdentifier: teamIdentifier) else { return nil } var requirement: SecRequirement? var requirementData: CFData? guard SecRequirementCreateWithString( requirementString as CFString, SecCSFlags(), &requirement) == errSecSuccess, let requirement, SecRequirementCopyData(requirement, SecCSFlags(), &requirementData) == errSecSuccess, let requirementData else { return nil } return (codeDirectoryHash, requirementData as Data) } nonisolated static func developerIDRequirementString( bundleIdentifier: String, teamIdentifier: String) -> String? { guard self.isRequirementToken(bundleIdentifier), self.isRequirementToken(teamIdentifier) else { return nil } return "identifier \"\(bundleIdentifier)\" and anchor apple generic and " + "certificate 1[field.1.2.840.113635.100.6.2.6] exists and " + "certificate leaf[field.1.2.840.113635.100.6.1.13] exists and " + "certificate leaf[subject.OU] = \"\(teamIdentifier)\"" } private static func kernelCodeDirectoryHash() -> Data? { var bytes = [UInt8](repeating: 0, count: 20) let result = bytes.withUnsafeMutableBytes { csops(getpid(), 5, $0.baseAddress, $0.count) } return result == 0 ? Data(bytes) : nil } private static func kernelTeamIdentifier() -> String? { // XNU kern_proc.c prefixes CS_OPS_TEAMID with an 8-byte fake blob header; // the NUL-terminated identifier starts immediately after that header. var bytes = [UInt8](repeating: 0, count: 8 + 64) let result = bytes.withUnsafeMutableBytes { csops(getpid(), 14, $0.baseAddress, $0.count) } guard result == 0 else { return nil } return self.teamIdentifier(fromCSOpsToken: bytes) } nonisolated static func teamIdentifier(fromCSOpsToken bytes: [UInt8]) -> String? { guard bytes.count > 8 else { return nil } let payload = bytes.dropFirst(8) guard let terminator = payload.firstIndex(of: 0), terminator > payload.startIndex else { return nil } return String(bytes: payload[.. Bool { !value.isEmpty && value.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.contains($0) || $0 == "." || $0 == "-" } } private nonisolated static func codeDirectoryHash(for code: SecStaticCode) -> Data? { var information: CFDictionary? guard SecCodeCopySigningInformation(code, SecCSFlags(), &information) == errSecSuccess, let information else { return nil } return (information as NSDictionary)[kSecCodeInfoUnique] as? Data } private nonisolated static func trustedCodeDirectoryHash( at bundleURL: URL, executableURL: URL, matching requirementData: Data) -> Data? { guard FileManager.default.isExecutableFile(atPath: executableURL.path) else { return nil } var requirement: SecRequirement? guard SecRequirementCreateWithData(requirementData as CFData, SecCSFlags(), &requirement) == errSecSuccess, let requirement else { return nil } var code: SecStaticCode? guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess, let code, SecStaticCodeCheckValidity( code, SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode), requirement) == errSecSuccess else { return nil } return self.codeDirectoryHash(for: code) } private static func designatedRequirement(for bundleURL: URL) -> SecRequirement? { var code: SecStaticCode? guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess, let code else { return nil } var requirement: SecRequirement? guard SecCodeCopyDesignatedRequirement(code, SecCSFlags(), &requirement) == errSecSuccess else { return nil } return requirement } private static func isTrustedInstalledApp( _ bundle: Bundle, matching requirement: SecRequirement?, fileManager: FileManager) -> Bool { guard let executableURL = bundle.executableURL else { return false } return self.isTrustedInstalledApp( at: bundle.bundleURL, executableURL: executableURL, matching: requirement, fileManager: fileManager) } private static func isTrustedInstalledApp( at bundleURL: URL, executableURL: URL, matching requirement: SecRequirement?, fileManager: FileManager) -> Bool { guard let requirement, fileManager.isExecutableFile(atPath: executableURL.path) else { return false } var code: SecStaticCode? guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess, let code else { return false } return SecStaticCodeCheckValidity( code, SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode), requirement) == errSecSuccess } private static func canWrite(destination: URL, fileManager: FileManager) -> Bool { var ancestor = destination.deletingLastPathComponent() while !fileManager.fileExists(atPath: ancestor.path) { let parent = ancestor.deletingLastPathComponent() guard parent != ancestor else { return false } ancestor = parent } return fileManager.isWritableFile(atPath: ancestor.path) } private static func install( source: URL, destination: URL, replacing: Bool, fileManager: FileManager) throws { let parent = destination.deletingLastPathComponent() try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) let staging = parent.appendingPathComponent(".\(destination.lastPathComponent).installing-\(UUID().uuidString)") defer { try? fileManager.removeItem(at: staging) } try fileManager.copyItem(at: source, to: staging) if replacing { let backupName = ".\(destination.lastPathComponent).backup-\(UUID().uuidString)" _ = try fileManager.replaceItemAt( destination, withItemAt: staging, backupItemName: backupName) try? fileManager.removeItem(at: parent.appendingPathComponent(backupName)) } else { try fileManager.moveItem(at: staging, to: destination) } } private static func confirmInstall(replacing: Bool) -> Bool { let alert = NSAlert() alert.messageText = replacing ? "Replace the older OpenClaw in Applications?" : "Install OpenClaw in Applications?" alert.informativeText = replacing ? "This copy is newer than the installed app. OpenClaw will replace it and reopen from Applications." : "OpenClaw will copy itself to Applications and reopen there so updates and launch at login stay reliable." alert.alertStyle = .informational alert.addButton(withTitle: replacing ? "Replace and Relaunch" : "Install and Relaunch") let cancel = alert.addButton(withTitle: "Not Now") cancel.keyEquivalent = "\u{1b}" NSApp.activate(ignoringOtherApps: true) return alert.runModal() == .alertFirstButtonReturn } private static func relaunchAndTerminate(at destination: URL) -> LaunchDisposition { let helper = Process() helper.executableURL = URL(fileURLWithPath: "/bin/sh") let processInfo = ProcessInfo.processInfo helper.arguments = [ "-c", "while /bin/kill -0 \"$2\" 2>/dev/null; do /bin/sleep 0.1; done; exec /usr/bin/open -n \"$1\"", "openclaw-relocation", destination.path, String(processInfo.processIdentifier), ] do { try helper.run() TerminationSignalWatcher.scheduleExitFailsafe() NSApp.terminate(nil) return .terminating } catch { self.logger.error("Could not schedule relaunch: \(error.localizedDescription, privacy: .public)") self.showFailure( "OpenClaw is installed in Applications, but couldn’t reopen automatically. Open it there manually.") return .continueLaunch(startUpdater: false) } } private static func startSupervisorRestorationWatcher(_ supervisor: KeepAliveSupervisor) -> Bool { guard self.supervisorRestorationWatcher == nil else { return true } let watcher = Process() let processIdentifier = ProcessInfo.processInfo.processIdentifier let domain = "gui/\(getuid())" watcher.executableURL = URL(fileURLWithPath: "/bin/sh") watcher.arguments = [ "-c", "while /bin/kill -0 \"$1\" 2>/dev/null; do /bin/sleep 0.1; done; " + "/bin/launchctl bootstrap \"$2\" \"$3\" 2>/dev/null || true; " + "/bin/launchctl kickstart \"$4\"", "openclaw-supervisor-restoration", String(processIdentifier), domain, supervisor.plistURL.path, "\(domain)/\(supervisor.label)", ] do { try watcher.run() self.supervisorRestorationWatcher = watcher return true } catch { self.logger.error("Could not schedule launchd restoration: \(error.localizedDescription, privacy: .public)") return false } } private static func cancelSupervisorRestorationWatcher() { guard let watcher = supervisorRestorationWatcher else { return } self.supervisorRestorationWatcher = nil guard watcher.isRunning else { return } Darwin.kill(watcher.processIdentifier, SIGKILL) watcher.waitUntilExit() } private static func scheduleReplacementRelaunch( at destination: URL, launchReference: BundleFileReference, codeDirectoryHash: Data, attempt: Int) -> ReplacementScheduleResult { let processInfo = ProcessInfo.processInfo let activeSupervisor = self.verifiedKeepAliveSupervisor( xpcServiceName: processInfo.environment["XPC_SERVICE_NAME"], executableURL: Bundle.main.executableURL, homeDirectory: FileManager.default.homeDirectoryForCurrentUser) let supervisor = self.inheritedReplacementSupervisor ?? activeSupervisor let forwardedArguments = Array(processInfo.arguments.dropFirst()) let bootoutTarget = activeSupervisor.map { "gui/\(getuid())/\($0.label)" } // The volfs path pins atomic bundle swaps and direct exec bypasses mutable // Info.plist routing. The child must also prove its kernel CDHash over the // inherited pipe before the old process yields ownership. guard let spawned = spawnReplacement( launchReference: launchReference, sourceBundleURL: destination, codeDirectoryHash: codeDirectoryHash, supervisor: supervisor, bootoutTarget: bootoutTarget, forwardedArguments: forwardedArguments) else { return .failed(supervisor: supervisor) } let timeoutMilliseconds = self.replacementHandoffPolicy.timeoutMilliseconds( loadAverage: self.currentSystemLoadAverage(), activeProcessorCount: processInfo.activeProcessorCount) Task { @MainActor in let handoffStatus = await Task.detached(priority: .utility) { self.awaitReplacementHandoff( on: spawned.readyDescriptor, timeoutMilliseconds: timeoutMilliseconds) }.value Darwin.close(spawned.readyDescriptor) guard handoffStatus == "READY" else { Darwin.kill(spawned.processIdentifier, SIGKILL) await Task.detached(priority: .utility) { self.reapProcess(spawned.processIdentifier) }.value await self.handleReplacementHandoffFailure( attempt: attempt, targetCodeDirectoryHash: codeDirectoryHash, supervisor: supervisor, reason: "Trusted replacement handoff timed out after \(timeoutMilliseconds) ms") return } self.cancelSupervisorRestorationWatcher() TerminationSignalWatcher.scheduleExitFailsafe() NSApp.terminate(nil) } return .scheduled } private static func handleReplacementHandoffFailure( attempt: Int, targetCodeDirectoryHash: Data, supervisor: KeepAliveSupervisor?, reason: String) async { let action = self.replacementHandoffPolicy.failureAction( failedAttempt: attempt, hasKeepAliveSupervisor: supervisor != nil) if action.isTerminal, await self.shouldContinueReplacementRecovery(afterFailedTarget: targetCodeDirectoryHash) { self.bundleReplacementHandoffInProgress = false self.bundleReplacementCheckPending = true self.bundleDirectoryDidChange() return } switch action { case let .retry(delay): self.bundleReplacementCheckPending = true let maximumAttempts = self.replacementHandoffPolicy.maximumAttempts self.logger.error( "Replacement handoff failed: \(reason, privacy: .public); attempt \(attempt)/\(maximumAttempts)") do { try await Task.sleep(for: delay) } catch { return } guard self.bundleReplacementSnapshot != nil else { return } self.bundleReplacementHandoffInProgress = false self.bundleDirectoryDidChange() case .stopMonitoring: self.disableBundleReplacementMonitoring() self.logger.critical( """ \(reason, privacy: .public); stopped app replacement monitoring after \(attempt) attempts. \ Quit and reopen OpenClaw to load the installed replacement. """) case .terminateForSupervisor: guard let supervisor, self.startSupervisorRestorationWatcher(supervisor) else { self.disableBundleReplacementMonitoring() self.logger.critical( """ \(reason, privacy: .public); could not schedule verified KeepAlive recovery. \ Stopped app replacement monitoring after \(attempt) attempts. """) return } self.disableBundleReplacementMonitoring() self.logger.critical( """ \(reason, privacy: .public); exiting after \(attempt) attempts so verified KeepAlive supervisor \ \(supervisor.label, privacy: .public) can restart the installed app. """) TerminationSignalWatcher.scheduleExitFailsafe() NSApp.terminate(nil) } } private static func shouldContinueReplacementRecovery(afterFailedTarget failedTargetHash: Data) async -> Bool { guard let snapshot = self.bundleReplacementSnapshot else { return false } // The coalescing bit can be cleared after an event arrives during the // earlier async evaluation. Terminal actions always revalidate the disk. self.bundleReplacementCheckPending = false let evaluation = await Task.detached(priority: .utility) { self.replacementEvaluationOnDisk(for: snapshot) }.value if self.bundleReplacementCheckPending { return true } return self.shouldContinueReplacementRecovery( afterFailedTarget: failedTargetHash, latestAction: evaluation.action, latestTargetHash: evaluation.launchCodeDirectoryHash) } nonisolated static func shouldContinueReplacementRecovery( afterFailedTarget failedTargetHash: Data, latestAction: ReplacementAction, latestTargetHash: Data?) -> Bool { switch latestAction { case .unchanged, .waitForTrustedReplacement: true case .relaunch: latestTargetHash != failedTargetHash } } private static func disableBundleReplacementMonitoring() { self.bundleReplacementSource?.cancel() self.bundleReplacementSource = nil self.bundleReplacementSnapshot = nil self.bundleReplacementCheckPending = false self.bundleReplacementHandoffInProgress = false self.bundleReplacementHandoffAttempt = 0 self.bundleReplacementHandoffTargetHash = nil } private static func spawnReplacement( launchReference: BundleFileReference, sourceBundleURL: URL, codeDirectoryHash: Data, supervisor: KeepAliveSupervisor?, bootoutTarget: String?, forwardedArguments: [String]) -> (processIdentifier: pid_t, readyDescriptor: Int32)? { let childReadyDescriptor: Int32 = 19 var descriptors = [Int32](repeating: -1, count: 2) guard pipe(&descriptors) == 0 else { return nil } let readDescriptor = descriptors[0] let writeDescriptor = descriptors[1] var environmentAssignments = [ "\(replacementSourceBundleEnvironmentKey)=\(sourceBundleURL.path)", "\(self.replacementParentPIDEnvironmentKey)=\(getpid())", "\(self.replacementCodeHashEnvironmentKey)=\(codeDirectoryHash.base64EncodedString())", "\(self.replacementReadyFDEnvironmentKey)=\(childReadyDescriptor)", ] if let supervisor { environmentAssignments += [ "\(self.replacementSupervisorLabelEnvironmentKey)=\(supervisor.label)", "\(self.replacementSupervisorPlistEnvironmentKey)=\(supervisor.plistURL.path)", ] } if let bootoutTarget { environmentAssignments.append("\(self.replacementBootoutTargetEnvironmentKey)=\(bootoutTarget)") } // The detached child is no longer owned by the current launchd job. Do not // let it inherit that job's identity and attempt a second bootout later. let arguments = [ "/usr/bin/env", "-u", "XPC_SERVICE_NAME", "-u", replacementSourceBundleEnvironmentKey, "-u", replacementParentPIDEnvironmentKey, "-u", replacementCodeHashEnvironmentKey, "-u", replacementReadyFDEnvironmentKey, "-u", replacementBootoutTargetEnvironmentKey, "-u", replacementSupervisorLabelEnvironmentKey, "-u", replacementSupervisorPlistEnvironmentKey, ] + environmentAssignments + [launchReference.executableURL.path] + forwardedArguments var cArguments = arguments.map { strdup($0) } + [nil] defer { cArguments.compactMap(\.self).forEach { free($0) } } var fileActions: posix_spawn_file_actions_t? var attributes: posix_spawnattr_t? guard posix_spawn_file_actions_init(&fileActions) == 0, posix_spawnattr_init(&attributes) == 0 else { Darwin.close(readDescriptor) Darwin.close(writeDescriptor) return nil } defer { posix_spawn_file_actions_destroy(&fileActions) posix_spawnattr_destroy(&attributes) } guard posix_spawn_file_actions_adddup2(&fileActions, writeDescriptor, childReadyDescriptor) == 0, posix_spawnattr_setflags( &attributes, Int16(POSIX_SPAWN_SETSID | POSIX_SPAWN_CLOEXEC_DEFAULT)) == 0 else { Darwin.close(readDescriptor) Darwin.close(writeDescriptor) return nil } if readDescriptor != childReadyDescriptor, posix_spawn_file_actions_addclose(&fileActions, readDescriptor) != 0 { Darwin.close(readDescriptor) Darwin.close(writeDescriptor) return nil } if writeDescriptor != childReadyDescriptor, posix_spawn_file_actions_addclose(&fileActions, writeDescriptor) != 0 { Darwin.close(readDescriptor) Darwin.close(writeDescriptor) return nil } var processIdentifier = pid_t() let spawnResult = cArguments.withUnsafeMutableBufferPointer { buffer in posix_spawn( &processIdentifier, "/usr/bin/env", &fileActions, &attributes, buffer.baseAddress, environ) } Darwin.close(writeDescriptor) guard spawnResult == 0 else { Darwin.close(readDescriptor) return nil } return (processIdentifier, readDescriptor) } private nonisolated static func currentSystemLoadAverage() -> Double? { var loadAverage = 0.0 return getloadavg(&loadAverage, 1) == 1 ? loadAverage : nil } private nonisolated static func awaitReplacementHandoff( on descriptor: Int32, timeoutMilliseconds: Int32) -> String? { var event = pollfd(fd: descriptor, events: Int16(POLLIN | POLLHUP), revents: 0) guard poll(&event, 1, timeoutMilliseconds) > 0 else { return nil } var bytes = [UInt8](repeating: 0, count: 16) let count = bytes.withUnsafeMutableBytes { buffer in Darwin.read(descriptor, buffer.baseAddress, buffer.count) } guard count > 0 else { return nil } return String(bytes: bytes.prefix(count), encoding: .utf8) } private nonisolated static func reapProcess(_ processIdentifier: pid_t) { var processStatus: Int32 = 0 while waitpid(processIdentifier, &processStatus, 0) == -1, errno == EINTR {} } private static func showFailure(_ message: String) { let alert = NSAlert() alert.messageText = "Move OpenClaw to Applications" alert.informativeText = message alert.alertStyle = .warning alert.addButton(withTitle: "OK") alert.runModal() } private static func compareBuild(_ lhs: String, _ rhs: String) -> ComparisonResult { lhs.compare(rhs, options: .numeric) } private static func isInside(_ path: String, root: String) -> Bool { path == root || path.hasPrefix(root + "/") } }