mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 17:11:42 -06:00
86e92486aa
* fix(macos): bound app replacement handoffs * chore: leave release notes to release flow * chore(macos): refresh native i18n inventory * chore(apps): sync native i18n inventory * fix(macos): revalidate terminal replacement target
551 lines
21 KiB
Swift
551 lines
21 KiB
Swift
import Foundation
|
|
@testable import OpenClaw
|
|
import Testing
|
|
|
|
@Suite("Application Relocator Tests")
|
|
@MainActor
|
|
struct ApplicationRelocatorTests {
|
|
private let home = URL(fileURLWithPath: "/Users/tester")
|
|
private let current = ApplicationRelocator.ApplicationIdentity(
|
|
bundleIdentifier: "ai.openclaw.mac",
|
|
buildVersion: "100"
|
|
)
|
|
|
|
@Test
|
|
func `stable application locations continue normally`() {
|
|
let paths = [
|
|
"/Applications/OpenClaw.app",
|
|
"/Users/tester/Applications/OpenClaw.app",
|
|
"/Users/tester/Tools/OpenClaw.app",
|
|
"/Volumes/External/Apps/OpenClaw.app",
|
|
]
|
|
for path in paths {
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(path: path, readOnlyVolume: false)
|
|
)
|
|
#expect(recommendation == .continueLaunch)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `debug and test builds never relocate`() {
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Users/tester/Downloads/OpenClaw.app",
|
|
debugOrTesting: true
|
|
)
|
|
)
|
|
|
|
#expect(recommendation == .continueLaunch)
|
|
}
|
|
|
|
@Test
|
|
func `transient copy offers system installation when available`() {
|
|
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Users/tester/Downloads/OpenClaw.app",
|
|
candidates: [missing(destination, writable: true)]
|
|
)
|
|
)
|
|
|
|
#expect(recommendation == .offerInstall(destination: destination, replacing: false))
|
|
}
|
|
|
|
@Test
|
|
func `read only mounted copy offers installation`() {
|
|
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Volumes/OpenClaw/OpenClaw.app",
|
|
candidates: [missing(destination, writable: true)],
|
|
readOnlyVolume: true
|
|
)
|
|
)
|
|
|
|
#expect(recommendation == .offerInstall(destination: destination, replacing: false))
|
|
}
|
|
|
|
@Test
|
|
func `translocated copy offers installation`() {
|
|
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/private/var/folders/x/AppTranslocation/y/d/OpenClaw.app",
|
|
candidates: [missing(destination, writable: true)]
|
|
)
|
|
)
|
|
|
|
#expect(recommendation == .offerInstall(destination: destination, replacing: false))
|
|
}
|
|
|
|
@Test
|
|
func `equal or newer installed build receives handoff`() {
|
|
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
|
for build in ["100", "110"] {
|
|
let installed = ApplicationRelocator.ApplicationIdentity(
|
|
bundleIdentifier: current.bundleIdentifier,
|
|
buildVersion: build
|
|
)
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Users/tester/Downloads/OpenClaw.app",
|
|
candidates: [self.installed(destination, identity: installed)]
|
|
)
|
|
)
|
|
#expect(recommendation == .handOff(destination))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `older installed build can be replaced`() {
|
|
let destination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
|
let installed = ApplicationRelocator.ApplicationIdentity(
|
|
bundleIdentifier: current.bundleIdentifier,
|
|
buildVersion: "90"
|
|
)
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Users/tester/Downloads/OpenClaw.app",
|
|
candidates: [self.installed(destination, identity: installed)]
|
|
)
|
|
)
|
|
|
|
#expect(recommendation == .offerInstall(destination: destination, replacing: true))
|
|
}
|
|
|
|
@Test
|
|
func `different same named app is never replaced`() {
|
|
let systemDestination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
|
let userDestination = home.appendingPathComponent("Applications/OpenClaw.app")
|
|
let unrelated = ApplicationRelocator.ApplicationIdentity(
|
|
bundleIdentifier: "example.unrelated",
|
|
buildVersion: "999"
|
|
)
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Users/tester/Desktop/OpenClaw.app",
|
|
candidates: [
|
|
installed(systemDestination, identity: unrelated),
|
|
missing(userDestination, writable: true),
|
|
]
|
|
)
|
|
)
|
|
|
|
#expect(recommendation == .offerInstall(destination: userDestination, replacing: false))
|
|
}
|
|
|
|
@Test
|
|
func `untrusted same identity app never receives handoff`() {
|
|
let systemDestination = URL(fileURLWithPath: "/Applications/OpenClaw.app")
|
|
let userDestination = home.appendingPathComponent("Applications/OpenClaw.app")
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Users/tester/Downloads/OpenClaw.app",
|
|
candidates: [
|
|
installed(systemDestination, identity: current, trusted: false),
|
|
missing(userDestination, writable: true),
|
|
]
|
|
)
|
|
)
|
|
|
|
#expect(recommendation == .offerInstall(destination: userDestination, replacing: false))
|
|
}
|
|
|
|
@Test
|
|
func `unwritable destinations require manual installation`() {
|
|
let recommendation = ApplicationRelocator.recommendation(
|
|
for: environment(
|
|
path: "/Users/tester/Downloads/OpenClaw.app",
|
|
candidates: [
|
|
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 `replacement handoff retries back off and stop after three attempts`() {
|
|
let policy = ApplicationRelocator.replacementHandoffPolicy
|
|
|
|
#expect(policy.maximumAttempts == 3)
|
|
#expect(policy.failureAction(
|
|
failedAttempt: 1,
|
|
hasKeepAliveSupervisor: false
|
|
) == .retry(after: .seconds(2)))
|
|
#expect(policy.failureAction(
|
|
failedAttempt: 2,
|
|
hasKeepAliveSupervisor: false
|
|
) == .retry(after: .seconds(4)))
|
|
#expect(policy.failureAction(
|
|
failedAttempt: 3,
|
|
hasKeepAliveSupervisor: false
|
|
) == .stopMonitoring)
|
|
#expect(policy.failureAction(
|
|
failedAttempt: 3,
|
|
hasKeepAliveSupervisor: true
|
|
) == .terminateForSupervisor)
|
|
}
|
|
|
|
@Test
|
|
func `replacement handoff timeout scales with system load`() {
|
|
let policy = ApplicationRelocator.replacementHandoffPolicy
|
|
|
|
#expect(policy.timeoutMilliseconds(loadAverage: nil, activeProcessorCount: 12) == 15000)
|
|
#expect(policy.timeoutMilliseconds(loadAverage: 12, activeProcessorCount: 12) == 15000)
|
|
#expect(policy.timeoutMilliseconds(loadAverage: 24, activeProcessorCount: 12) == 30000)
|
|
#expect(policy.timeoutMilliseconds(loadAverage: 100, activeProcessorCount: 12) == 60000)
|
|
#expect(policy.timeoutMilliseconds(loadAverage: 100, activeProcessorCount: 0) == 15000)
|
|
}
|
|
|
|
@Test
|
|
func `replacement arriving during final handoff gets a fresh recovery pass`() {
|
|
let failedTarget = Data([10])
|
|
|
|
#expect(!ApplicationRelocator.shouldContinueReplacementRecovery(
|
|
afterFailedTarget: failedTarget,
|
|
latestAction: .relaunch,
|
|
latestTargetHash: failedTarget
|
|
))
|
|
#expect(ApplicationRelocator.shouldContinueReplacementRecovery(
|
|
afterFailedTarget: failedTarget,
|
|
latestAction: .relaunch,
|
|
latestTargetHash: Data([11])
|
|
))
|
|
#expect(ApplicationRelocator.shouldContinueReplacementRecovery(
|
|
afterFailedTarget: failedTarget,
|
|
latestAction: .waitForTrustedReplacement,
|
|
latestTargetHash: nil
|
|
))
|
|
#expect(ApplicationRelocator.shouldContinueReplacementRecovery(
|
|
afterFailedTarget: failedTarget,
|
|
latestAction: .unchanged,
|
|
latestTargetHash: nil
|
|
))
|
|
}
|
|
|
|
@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
|
|
))
|
|
#expect(!AppState.shouldPersistLaunchAtLoginChange(
|
|
isInitializing: false,
|
|
isHydrating: false,
|
|
isEnabling: true,
|
|
bundleLocationAllowsPersistentIntegration: false
|
|
))
|
|
#expect(AppState.shouldPersistLaunchAtLoginChange(
|
|
isInitializing: false,
|
|
isHydrating: false,
|
|
isEnabling: false,
|
|
bundleLocationAllowsPersistentIntegration: false
|
|
))
|
|
}
|
|
|
|
private func environment(
|
|
path: String,
|
|
candidates: [ApplicationRelocator.InstallCandidate] = [],
|
|
readOnlyVolume: Bool = false,
|
|
debugOrTesting: Bool = false
|
|
) -> ApplicationRelocator.Environment {
|
|
ApplicationRelocator.Environment(
|
|
bundleURL: URL(fileURLWithPath: path),
|
|
homeDirectory: home,
|
|
currentIdentity: current,
|
|
candidates: candidates,
|
|
isReadOnlyVolume: readOnlyVolume,
|
|
isDebugOrTesting: debugOrTesting
|
|
)
|
|
}
|
|
|
|
private func missing(_ url: URL, writable: Bool) -> ApplicationRelocator.InstallCandidate {
|
|
ApplicationRelocator.InstallCandidate(
|
|
url: url,
|
|
exists: false,
|
|
isWritable: writable,
|
|
isTrusted: false,
|
|
identity: nil
|
|
)
|
|
}
|
|
|
|
private func installed(
|
|
_ url: URL,
|
|
identity: ApplicationRelocator.ApplicationIdentity,
|
|
trusted: Bool = true
|
|
) -> ApplicationRelocator.InstallCandidate {
|
|
ApplicationRelocator.InstallCandidate(
|
|
url: url,
|
|
exists: true,
|
|
isWritable: true,
|
|
isTrusted: trusted,
|
|
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)
|
|
}
|
|
}
|