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
This commit is contained in:
Peter Steinberger
2026-07-16 06:31:02 -07:00
committed by GitHub
parent d2e9fa22bc
commit 8bcff3bad9
5 changed files with 1224 additions and 81 deletions
+8 -8
View File
@@ -26947,7 +26947,7 @@
},
{
"kind": "ui-call",
"line": 130,
"line": 217,
"path": "apps/macos/Sources/OpenClaw/ApplicationRelocator.swift",
"source": "OpenClaw couldnt 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 couldnt reopen automatically. Open it there manually.",
"surface": "apple",
@@ -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<Void, Never>?
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 couldnt 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[..<terminator], encoding: .utf8)
}
private nonisolated static func isRequirementToken(_ value: String) -> 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 couldnt reopen automatically. Open it there manually.")
self.showFailure(
"OpenClaw is installed in Applications, but couldnt 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"
+12 -1
View File
@@ -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
@@ -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)
}
}
@@ -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)
}
}