From 8bcff3bad933b45b39d9a514cd156e5ddd88b75e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 06:31:02 -0700 Subject: [PATCH] fix(macos): recover after installed app replacement (#108991) * fix(macos): relaunch trusted app replacements * refactor(macos): split relocator extension * fix(macos): revalidate replacement bundle in child * chore(macos): refresh native i18n inventory * style(macos): satisfy Swift formatting and dead-code gates --- apps/.i18n/native-source.json | 16 +- .../OpenClaw/ApplicationRelocator.swift | 884 +++++++++++++++++- apps/macos/Sources/OpenClaw/MenuBar.swift | 13 +- .../OpenClaw/TerminationSignalWatcher.swift | 10 +- .../ApplicationRelocatorTests.swift | 382 +++++++- 5 files changed, 1224 insertions(+), 81 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 7ccb374d569f..5e9e2be8599e 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -26947,7 +26947,7 @@ }, { "kind": "ui-call", - "line": 130, + "line": 217, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "OpenClaw couldn’t be installed in Applications. Move it there manually, then open that copy.", "surface": "apple", @@ -26955,7 +26955,7 @@ }, { "kind": "conditional-branch", - "line": 282, + "line": 907, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "Replace the older OpenClaw in Applications?", "surface": "apple", @@ -26963,7 +26963,7 @@ }, { "kind": "conditional-branch", - "line": 283, + "line": 908, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "Install OpenClaw in Applications?", "surface": "apple", @@ -26971,7 +26971,7 @@ }, { "kind": "conditional-branch", - "line": 285, + "line": 910, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "This copy is newer than the installed app. OpenClaw will replace it and reopen from Applications.", "surface": "apple", @@ -26979,7 +26979,7 @@ }, { "kind": "conditional-branch", - "line": 286, + "line": 911, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "OpenClaw will copy itself to Applications and reopen there so updates and launch at login stay reliable.", "surface": "apple", @@ -26987,7 +26987,7 @@ }, { "kind": "conditional-branch", - "line": 288, + "line": 913, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "Install and Relaunch", "surface": "apple", @@ -26995,7 +26995,7 @@ }, { "kind": "conditional-branch", - "line": 288, + "line": 913, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "Replace and Relaunch", "surface": "apple", @@ -27003,7 +27003,7 @@ }, { "kind": "ui-call", - "line": 312, + "line": 938, "path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift", "source": "OpenClaw is installed in Applications, but couldn’t reopen automatically. Open it there manually.", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift b/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift index 30ee555880a5..18f3ab895fbc 100644 --- a/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift +++ b/apps/macos/Sources/OpenClaw/ApplicationRelocator.swift @@ -1,8 +1,17 @@ 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 { @@ -39,7 +48,75 @@ enum ApplicationRelocator { case terminating } + struct ApplicationOnDisk: Equatable, Sendable { + let bundleIdentifier: String + let executableURL: URL + } + + enum ReplacementAction: Equatable, Sendable { + case unchanged + case waitForTrustedReplacement + case relaunch + } + + 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 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 inheritedReplacementSupervisor: KeepAliveSupervisor? + private static var supervisorRestorationWatcher: Process? + private static var authenticatedReplacementSourceBundleURL: URL? + 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, @@ -56,9 +133,8 @@ enum ApplicationRelocator { guard let installedIdentity = candidate.identity, candidate.isTrusted, installedIdentity.bundleIdentifier == currentIdentity.bundleIdentifier, - self - .compareBuild(installedIdentity.buildVersion, currentIdentity.buildVersion) != - .orderedAscending + compareBuild(installedIdentity.buildVersion, currentIdentity.buildVersion) != + .orderedAscending else { continue } return .handOff(candidate.url) } @@ -97,7 +173,7 @@ enum ApplicationRelocator { if transientRoots.contains(where: { self.isInside(path, root: $0) }) { return true } - return self.isInside(path, root: "/Volumes") && isReadOnlyVolume + return isInside(path, root: "/Volumes") && isReadOnlyVolume } static func handleLaunch( @@ -105,29 +181,40 @@ enum ApplicationRelocator { fileManager: FileManager = .default, processInfo: ProcessInfo = .processInfo) -> LaunchDisposition { - let environment = self.currentEnvironment( + 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 self.relaunchAndTerminate(at: destination) + return relaunchAndTerminate(at: destination) case let .offerInstall(destination, replacing): - guard self.confirmInstall(replacing: replacing) else { + guard confirmInstall(replacing: replacing) else { return .continueLaunch(startUpdater: false) } do { - try self.install( + try install( source: environment.bundleURL, destination: destination, replacing: replacing, fileManager: fileManager) - return self.relaunchAndTerminate(at: destination) + return relaunchAndTerminate(at: destination) } catch { self.logger.error("Could not install app: \(error.localizedDescription, privacy: .public)") - self.showFailure( + showFailure( "OpenClaw couldn’t be installed in Applications. Move it there manually, then open that copy.") return .continueLaunch(startUpdater: false) } @@ -135,7 +222,7 @@ enum ApplicationRelocator { let message = "OpenClaw is running from a temporary location. " + "Move it to Applications manually to enable updates and launch at login." - self.showFailure(message) + showFailure(message) return .continueLaunch(startUpdater: false) } } @@ -154,7 +241,9 @@ enum ApplicationRelocator { return true } - let bundleURL = bundle.bundleURL.standardizedFileURL + let bundleURL = replacementSourceBundleURL( + environment: processInfo.environment, + fallback: bundle.bundleURL) let isReadOnlyVolume = (try? bundleURL.resourceValues(forKeys: [.volumeIsReadOnlyKey]))? .volumeIsReadOnly ?? false return !self.isTransientLocation( @@ -162,13 +251,241 @@ enum ApplicationRelocator { 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 = bundle.bundleURL.standardizedFileURL + let bundleURL = self.replacementSourceBundleURL( + environment: processInfo.environment, + fallback: bundle.bundleURL) let homeDirectory = fileManager.homeDirectoryForCurrentUser.standardizedFileURL let appName = bundleURL.lastPathComponent let destinations = [ @@ -211,6 +528,303 @@ enum ApplicationRelocator { 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 + + 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: + 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 + } + self.bundleReplacementCheckPending = false + self.logger.notice("Installed app changed; relaunching trusted replacement") + self.bundleReplacementHandoffInProgress = true + let scheduled = self.scheduleReplacementRelaunch( + at: snapshot.bundleURL, + launchReference: launchReference, + codeDirectoryHash: launchCodeDirectoryHash) + if !scheduled { + self.bundleReplacementHandoffInProgress = false + try? await Task.sleep(for: .seconds(1)) + continue + } + 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, @@ -227,18 +841,29 @@ enum ApplicationRelocator { matching requirement: SecRequirement?, fileManager: FileManager) -> Bool { - guard let requirement, - let executableURL = bundle.executableURL, - fileManager.isExecutableFile(atPath: executableURL.path) - else { return false } + 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(bundle.bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess, + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &code) == errSecSuccess, let code else { return false } return SecStaticCodeCheckValidity( code, - SecCSFlags(rawValue: kSecCSCheckAllArchitectures), + SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode), requirement) == errSecSuccess } @@ -295,26 +920,239 @@ enum ApplicationRelocator { 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.processInfo.processIdentifier), + 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.") + 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) -> Bool + { + 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 { + self.logger.error("Could not spawn the trusted replacement") + return false + } + + Task { @MainActor in + let handoffStatus = await Task.detached(priority: .utility) { + self.awaitReplacementHandoff(on: spawned.readyDescriptor) + }.value + Darwin.close(spawned.readyDescriptor) + guard handoffStatus == "READY" else { + Darwin.kill(spawned.processIdentifier, SIGKILL) + await Task.detached(priority: .utility) { + self.reapProcess(spawned.processIdentifier) + }.value + self.logger.error("Trusted replacement did not complete its authenticated handoff") + self.bundleReplacementHandoffInProgress = false + self.bundleReplacementCheckPending = true + try? await Task.sleep(for: .seconds(1)) + self.bundleDirectoryDidChange() + return + } + self.cancelSupervisorRestorationWatcher() + TerminationSignalWatcher.scheduleExitFailsafe() + NSApp.terminate(nil) + } + return true + } + + 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 awaitReplacementHandoff(on descriptor: Int32) -> String? { + var event = pollfd(fd: descriptor, events: Int16(POLLIN | POLLHUP), revents: 0) + guard poll(&event, 1, 10000) > 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" diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 8c7d4c86ab15..abe0ab931e41 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -504,7 +504,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor func applicationDidFinishLaunching(_: Notification) { - if self.isDuplicateInstance() { + let environment = ProcessInfo.processInfo.environment + let hasReplacementHandoff = ApplicationRelocator.hasReplacementHandoffMetadata( + environment: environment) + let isReplacementHandoff = ApplicationRelocator.acceptReplacementHandoff( + environment: environment) + if hasReplacementHandoff, !isReplacementHandoff { + NSApp.terminate(nil) + return + } + // Only a child whose signed parent and inherited readiness pipe authenticate + // may overlap the old process during replacement handoff. + if !isReplacementHandoff, self.isDuplicateInstance() { NSWorkspace.shared.open(Self.dashboardURL) NSApp.terminate(nil) return diff --git a/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift b/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift index eaf589fbe092..f5524d3904b5 100644 --- a/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift +++ b/apps/macos/Sources/OpenClaw/TerminationSignalWatcher.swift @@ -48,10 +48,16 @@ final class TerminationSignalWatcher { // Ensure any pairing prompt can't accidentally approve during shutdown. NodePairingApprovalPrompter.shared.stop() DevicePairingApprovalPrompter.shared.stop() + Self.scheduleExitFailsafe() NSApp.terminate(nil) + } - // Safety net: don't hang forever if something blocks termination. - DispatchQueue.main.asyncAfter(deadline: .now() + AppTerminationTiming.signalExitFailsafeSeconds) { + static func scheduleExitFailsafe() { + // AppKit waits in a nested event loop while async termination cleanup runs. + // A main-queue failsafe cannot fire from that loop, so enforce the deadline off-main. + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + AppTerminationTiming.signalExitFailsafeSeconds) + { exit(0) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ApplicationRelocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ApplicationRelocatorTests.swift index 85297a651127..1425937b0422 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ApplicationRelocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ApplicationRelocatorTests.swift @@ -1,6 +1,6 @@ import Foundation -import Testing @testable import OpenClaw +import Testing @Suite("Application Relocator Tests") @MainActor @@ -8,7 +8,8 @@ struct ApplicationRelocatorTests { private let home = URL(fileURLWithPath: "/Users/tester") private let current = ApplicationRelocator.ApplicationIdentity( bundleIdentifier: "ai.openclaw.mac", - buildVersion: "100") + buildVersion: "100" + ) @Test func `stable application locations continue normally`() { @@ -20,7 +21,8 @@ struct ApplicationRelocatorTests { ] for path in paths { let recommendation = ApplicationRelocator.recommendation( - for: self.environment(path: path, readOnlyVolume: false)) + for: environment(path: path, readOnlyVolume: false) + ) #expect(recommendation == .continueLaunch) } } @@ -28,9 +30,11 @@ struct ApplicationRelocatorTests { @Test func `debug and test builds never relocate`() { let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Users/tester/Downloads/OpenClaw.app", - debugOrTesting: true)) + debugOrTesting: true + ) + ) #expect(recommendation == .continueLaunch) } @@ -39,9 +43,11 @@ struct ApplicationRelocatorTests { func `transient copy offers system installation when available`() { let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app") let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Users/tester/Downloads/OpenClaw.app", - candidates: [self.missing(destination, writable: true)])) + candidates: [missing(destination, writable: true)] + ) + ) #expect(recommendation == .offerInstall(destination: destination, replacing: false)) } @@ -50,10 +56,12 @@ struct ApplicationRelocatorTests { func `read only mounted copy offers installation`() { let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app") let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Volumes/OpenClaw/OpenClaw.app", - candidates: [self.missing(destination, writable: true)], - readOnlyVolume: true)) + candidates: [missing(destination, writable: true)], + readOnlyVolume: true + ) + ) #expect(recommendation == .offerInstall(destination: destination, replacing: false)) } @@ -62,9 +70,11 @@ struct ApplicationRelocatorTests { func `translocated copy offers installation`() { let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app") let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/private/var/folders/x/AppTranslocation/y/d/OpenClaw.app", - candidates: [self.missing(destination, writable: true)])) + candidates: [missing(destination, writable: true)] + ) + ) #expect(recommendation == .offerInstall(destination: destination, replacing: false)) } @@ -74,12 +84,15 @@ struct ApplicationRelocatorTests { let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app") for build in ["100", "110"] { let installed = ApplicationRelocator.ApplicationIdentity( - bundleIdentifier: self.current.bundleIdentifier, - buildVersion: build) + bundleIdentifier: current.bundleIdentifier, + buildVersion: build + ) let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Users/tester/Downloads/OpenClaw.app", - candidates: [self.installed(destination, identity: installed)])) + candidates: [self.installed(destination, identity: installed)] + ) + ) #expect(recommendation == .handOff(destination)) } } @@ -88,12 +101,15 @@ struct ApplicationRelocatorTests { func `older installed build can be replaced`() { let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app") let installed = ApplicationRelocator.ApplicationIdentity( - bundleIdentifier: self.current.bundleIdentifier, - buildVersion: "90") + bundleIdentifier: current.bundleIdentifier, + buildVersion: "90" + ) let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Users/tester/Downloads/OpenClaw.app", - candidates: [self.installed(destination, identity: installed)])) + candidates: [self.installed(destination, identity: installed)] + ) + ) #expect(recommendation == .offerInstall(destination: destination, replacing: true)) } @@ -101,17 +117,20 @@ struct ApplicationRelocatorTests { @Test func `different same named app is never replaced`() { let systemDestination = URL(fileURLWithPath: "/Applications/OpenClaw.app") - let userDestination = self.home.appendingPathComponent("Applications/OpenClaw.app") + let userDestination = home.appendingPathComponent("Applications/OpenClaw.app") let unrelated = ApplicationRelocator.ApplicationIdentity( bundleIdentifier: "example.unrelated", - buildVersion: "999") + buildVersion: "999" + ) let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Users/tester/Desktop/OpenClaw.app", candidates: [ - self.installed(systemDestination, identity: unrelated), - self.missing(userDestination, writable: true), - ])) + installed(systemDestination, identity: unrelated), + missing(userDestination, writable: true), + ] + ) + ) #expect(recommendation == .offerInstall(destination: userDestination, replacing: false)) } @@ -119,14 +138,16 @@ struct ApplicationRelocatorTests { @Test func `untrusted same identity app never receives handoff`() { let systemDestination = URL(fileURLWithPath: "/Applications/OpenClaw.app") - let userDestination = self.home.appendingPathComponent("Applications/OpenClaw.app") + let userDestination = home.appendingPathComponent("Applications/OpenClaw.app") let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Users/tester/Downloads/OpenClaw.app", candidates: [ - self.installed(systemDestination, identity: self.current, trusted: false), - self.missing(userDestination, writable: true), - ])) + installed(systemDestination, identity: current, trusted: false), + missing(userDestination, writable: true), + ] + ) + ) #expect(recommendation == .offerInstall(destination: userDestination, replacing: false)) } @@ -134,48 +155,279 @@ struct ApplicationRelocatorTests { @Test func `unwritable destinations require manual installation`() { let recommendation = ApplicationRelocator.recommendation( - for: self.environment( + for: environment( path: "/Users/tester/Downloads/OpenClaw.app", candidates: [ - self.missing(URL(fileURLWithPath: "/Applications/OpenClaw.app"), writable: false), - self.missing(self.home.appendingPathComponent("Applications/OpenClaw.app"), writable: false), - ])) + missing(URL(fileURLWithPath: "/Applications/OpenClaw.app"), writable: false), + missing(home.appendingPathComponent("Applications/OpenClaw.app"), writable: false), + ] + ) + ) #expect(recommendation == .cannotInstall) } + @Test + func `unchanged executable does not relaunch`() { + let launched = Data([10]) + + let action = ApplicationRelocator.replacementAction( + launchedCodeDirectoryHash: launched, + installedCodeDirectoryHash: launched, + sameBundleIdentifier: true, + trusted: true + ) + + #expect(action == .unchanged) + } + + @Test + func `missing or untrusted replacement waits`() { + let launched = Data([10]) + let replacement = Data([11]) + + #expect(ApplicationRelocator.replacementAction( + launchedCodeDirectoryHash: launched, + installedCodeDirectoryHash: nil, + sameBundleIdentifier: true, + trusted: true + ) == .waitForTrustedReplacement) + #expect(ApplicationRelocator.replacementAction( + launchedCodeDirectoryHash: launched, + installedCodeDirectoryHash: replacement, + sameBundleIdentifier: true, + trusted: false + ) == .waitForTrustedReplacement) + #expect(ApplicationRelocator.replacementAction( + launchedCodeDirectoryHash: launched, + installedCodeDirectoryHash: replacement, + sameBundleIdentifier: false, + trusted: true + ) == .waitForTrustedReplacement) + } + + @Test + func `trusted replacement relaunches`() { + let launched = Data([10]) + let replacement = Data([11]) + + let action = ApplicationRelocator.replacementAction( + launchedCodeDirectoryHash: launched, + installedCodeDirectoryHash: replacement, + sameBundleIdentifier: true, + trusted: true + ) + + #expect(action == .relaunch) + } + + @Test + func `unauthenticated replacement marker cannot bypass duplicate launch rejection`() { + let forgedHandoff = [ + "OPENCLAW_REPLACEMENT_SOURCE_BUNDLE": "/Applications/OpenClaw.app", + "OPENCLAW_REPLACEMENT_PARENT_PID": "1", + "OPENCLAW_REPLACEMENT_CODE_HASH": "ZmFrZQ==", + "OPENCLAW_REPLACEMENT_READY_FD": "19", + ] + #expect(ApplicationRelocator.hasReplacementHandoffMetadata(environment: forgedHandoff)) + #expect(!ApplicationRelocator.acceptReplacementHandoff(environment: forgedHandoff)) + #expect(!ApplicationRelocator.acceptReplacementHandoff(environment: [:])) + #expect(!ApplicationRelocator.acceptReplacementHandoff(environment: [ + "OPENCLAW_REPLACEMENT_SOURCE_BUNDLE": "relative/OpenClaw.app", + ])) + } + + @Test + func `kernel team identifier skips csops token header`() { + let teamBlob = [UInt8](repeating: 0, count: 8) + Array("example-team".utf8) + [0] + + #expect(ApplicationRelocator.teamIdentifier(fromCSOpsToken: teamBlob) == "example-team") + #expect(ApplicationRelocator.teamIdentifier(fromCSOpsToken: [0, 0, 0]) == nil) + } + + @Test + func `replacement requirement preserves Developer ID certificate class`() { + let requirement = ApplicationRelocator.developerIDRequirementString( + bundleIdentifier: "ai.openclaw.mac", + teamIdentifier: "Y5PE65HELJ" + ) + + #expect(requirement?.contains("1.2.840.113635.100.6.2.6") == true) + #expect(requirement?.contains("1.2.840.113635.100.6.1.13") == true) + #expect(requirement?.contains("subject.OU] = \"Y5PE65HELJ\"") == true) + #expect(ApplicationRelocator.developerIDRequirementString( + bundleIdentifier: "ai.openclaw.mac\" or true", + teamIdentifier: "Y5PE65HELJ" + ) == nil) + } + + @Test + func `bundle file reference stays bound after canonical path replacement`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ApplicationRelocatorFileReferenceTests-\(UUID().uuidString)") + let canonicalBundle = root.appendingPathComponent("OpenClaw.app", isDirectory: true) + let archivedBundle = root.appendingPathComponent("OpenClaw.previous.app", isDirectory: true) + let executableRelativePath = "Contents/MacOS/OpenClaw" + let canonicalExecutable = canonicalBundle.appendingPathComponent(executableRelativePath) + try FileManager.default.createDirectory( + at: canonicalExecutable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + try Data("validated".utf8).write(to: canonicalExecutable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: canonicalExecutable.path) + let reference = try #require(ApplicationRelocator.bundleFileReference( + bundleURL: canonicalBundle, + executableURL: canonicalExecutable + )) + + try FileManager.default.moveItem(at: canonicalBundle, to: archivedBundle) + try FileManager.default.createDirectory( + at: canonicalExecutable.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("unvalidated".utf8).write(to: canonicalExecutable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: canonicalExecutable.path) + + #expect(try Data(contentsOf: reference.executableURL) == Data("validated".utf8)) + #expect(try Data(contentsOf: canonicalExecutable) == Data("unvalidated".utf8)) + #expect(ApplicationRelocator.bundleFileReference( + bundleURL: canonicalBundle, + executableURL: canonicalExecutable + ) != reference) + } + + @Test + func `replacement handoff launches the bound executable without filesystem locks`() { + let reference = ApplicationRelocator.BundleFileReference( + deviceIdentifier: 42, + fileIdentifier: 99, + executableRelativePath: "Contents/MacOS/OpenClaw" + ) + + #expect(reference.bundleURL.path == "/.vol/42/99") + #expect(reference.executableURL.path == "/.vol/42/99/Contents/MacOS/OpenClaw") + } + + @Test + func `verified KeepAlive launchd supervisor retains relaunch ownership`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ApplicationRelocatorLaunchAgentTests-\(UUID().uuidString)") + let home = root.appendingPathComponent("home") + let executable = root.appendingPathComponent("OpenClaw.app/Contents/MacOS/OpenClaw") + let serviceName = "ai.openclaw.mac.test-node" + let launchAgentURL = home.appendingPathComponent("Library/LaunchAgents/\(serviceName).plist") + try FileManager.default.createDirectory( + at: launchAgentURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + try writeLaunchAgentPlist( + at: launchAgentURL, + label: serviceName, + executable: executable.path, + keepAlive: true + ) + #expect(ApplicationRelocator.relaunchStrategy( + xpcServiceName: serviceName, + executableURL: executable, + homeDirectory: home + ) == .externalSupervisor) + + try writeLaunchAgentPlist( + at: launchAgentURL, + label: serviceName, + executable: executable.path, + keepAlive: false + ) + #expect(ApplicationRelocator.relaunchStrategy( + xpcServiceName: serviceName, + executableURL: executable, + homeDirectory: home + ) == .openAfterTermination) + #expect(ApplicationRelocator.relaunchStrategy( + xpcServiceName: "application.ai.openclaw.mac.123", + executableURL: executable, + homeDirectory: home + ) == .openAfterTermination) + #expect(ApplicationRelocator.relaunchStrategy( + xpcServiceName: nil, + executableURL: executable, + homeDirectory: home + ) == .openAfterTermination) + } + + @Test + func `replacement metadata is read from disk instead of Bundle cache`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ApplicationRelocatorTests-\(UUID().uuidString)") + let bundleURL = root.appendingPathComponent("OpenClaw.app") + let contentsURL = bundleURL.appendingPathComponent("Contents") + try FileManager.default.createDirectory( + at: contentsURL.appendingPathComponent("MacOS"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: root) } + + try writeInfoPlist( + at: contentsURL.appendingPathComponent("Info.plist"), + bundleIdentifier: "ai.openclaw.mac", + executable: "OpenClaw" + ) + #expect(ApplicationRelocator.applicationOnDisk(at: bundleURL) == .init( + bundleIdentifier: "ai.openclaw.mac", + executableURL: contentsURL.appendingPathComponent("MacOS/OpenClaw") + )) + + try writeInfoPlist( + at: contentsURL.appendingPathComponent("Info.plist"), + bundleIdentifier: "ai.openclaw.mac.replaced", + executable: "OpenClawNext" + ) + #expect(ApplicationRelocator.applicationOnDisk(at: bundleURL) == .init( + bundleIdentifier: "ai.openclaw.mac.replaced", + executableURL: contentsURL.appendingPathComponent("MacOS/OpenClawNext") + )) + } + @Test func `launch at login hydration does not persist the current bundle path`() { #expect(!AppState.shouldPersistLaunchAtLoginChange( isInitializing: false, isHydrating: true, isEnabling: true, - bundleLocationAllowsPersistentIntegration: true)) + bundleLocationAllowsPersistentIntegration: true + )) #expect(!AppState.shouldPersistLaunchAtLoginChange( isInitializing: false, isHydrating: false, isEnabling: true, - bundleLocationAllowsPersistentIntegration: false)) + bundleLocationAllowsPersistentIntegration: false + )) #expect(AppState.shouldPersistLaunchAtLoginChange( isInitializing: false, isHydrating: false, isEnabling: false, - bundleLocationAllowsPersistentIntegration: false)) + bundleLocationAllowsPersistentIntegration: false + )) } private func environment( path: String, candidates: [ApplicationRelocator.InstallCandidate] = [], readOnlyVolume: Bool = false, - debugOrTesting: Bool = false) -> ApplicationRelocator.Environment - { + debugOrTesting: Bool = false + ) -> ApplicationRelocator.Environment { ApplicationRelocator.Environment( bundleURL: URL(fileURLWithPath: path), - homeDirectory: self.home, - currentIdentity: self.current, + homeDirectory: home, + currentIdentity: current, candidates: candidates, isReadOnlyVolume: readOnlyVolume, - isDebugOrTesting: debugOrTesting) + isDebugOrTesting: debugOrTesting + ) } private func missing(_ url: URL, writable: Bool) -> ApplicationRelocator.InstallCandidate { @@ -184,19 +436,55 @@ struct ApplicationRelocatorTests { exists: false, isWritable: writable, isTrusted: false, - identity: nil) + identity: nil + ) } private func installed( _ url: URL, identity: ApplicationRelocator.ApplicationIdentity, - trusted: Bool = true) -> ApplicationRelocator.InstallCandidate - { + trusted: Bool = true + ) -> ApplicationRelocator.InstallCandidate { ApplicationRelocator.InstallCandidate( url: url, exists: true, isWritable: true, isTrusted: trusted, - identity: identity) + identity: identity + ) + } + + private func writeInfoPlist( + at url: URL, + bundleIdentifier: String, + executable: String + ) throws { + let data = try PropertyListSerialization.data( + fromPropertyList: [ + "CFBundleIdentifier": bundleIdentifier, + "CFBundleExecutable": executable, + ], + format: .xml, + options: 0 + ) + try data.write(to: url, options: .atomic) + } + + private func writeLaunchAgentPlist( + at url: URL, + label: String, + executable: String, + keepAlive: Bool + ) throws { + let data = try PropertyListSerialization.data( + fromPropertyList: [ + "KeepAlive": keepAlive, + "Label": label, + "ProgramArguments": [executable, "--attach-only"], + ], + format: .xml, + options: 0 + ) + try data.write(to: url, options: .atomic) } }