Files
openclaw/apps/macos/Sources/OpenClaw/LaunchAgentManager.swift
Peter Steinberger 0dbdf994b3 feat(macos): isolate named app profiles (#121136)
* feat(macos): isolate named app profiles

* refactor(macos): isolate profile launch ownership

* fix(macos): avoid overlapping approvals socket access

* fix(macos): declare profile defaults concurrency ownership

* fix(macos): return profiled node launch arguments

* chore(i18n): refresh macOS profile source inventory

* fix(macos): gate profile startup before services

* test(macos): evaluate profile state before assertions

* fix(daemon): skip absent launchd deactivation

* fix(macos): fail closed on profile port conflicts

* chore(i18n): refresh profile conflict inventory

* fix(macos): ignore non-gateway launch agent claims

* test(macos): stabilize profile lifecycle timing

* fix(macos): remove stale dashboard URL

* chore(macos): refresh native source baseline
2026-08-09 14:50:15 -07:00

148 lines
5.2 KiB
Swift

import Foundation
import OSLog
enum LaunchAgentManager {
private static let logger = Logger(subsystem: "ai.openclaw", category: "app.login-agent")
private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/ai.openclaw.mac.plist")
}
static func status(profile: AppProfile = .current) async -> Bool {
if profile.isActive {
self.logger.info("login-agent status skipped (unavailable under app profile)")
return false
}
guard FileManager().fileExists(atPath: self.plistURL.path) else { return false }
return await self.isLoaded()
}
private static func isLoaded() async -> Bool {
let result = await self.runLaunchctl(["print", "gui/\(getuid())/\(launchdLabel)"])
return result == 0
}
@discardableResult
static func set(
enabled: Bool,
bundlePath: String,
profile: AppProfile = .current,
loaded: Bool? = nil,
writePlist: ((String) -> Void)? = nil) async -> Bool
{
if profile.isActive {
self.logger.info("login-agent change skipped (unavailable under app profile)")
return false
}
if enabled {
let persist = writePlist ?? { self.writePlist(bundlePath: $0) }
persist(bundlePath)
let alreadyLoaded = if let loaded {
loaded
} else {
await self.isLoaded()
}
// Startup hydrates the toggle from launchd. Reinstalling the active job here
// would boot out the app that is still responsible for bootstrapping it again.
guard !alreadyLoaded else { return false }
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])
_ = await self.runLaunchctl(["bootstrap", "gui/\(getuid())", self.plistURL.path])
_ = await self.runLaunchctl(["kickstart", "-k", "gui/\(getuid())/\(launchdLabel)"])
} else {
// Disable autostart going forward but leave the current app running.
// bootout would terminate the launchd job immediately (and crash the app if launched via agent).
try? FileManager().removeItem(at: self.plistURL)
}
return true
}
private static func writePlist(bundlePath: String) {
let plist = self.plistContents(bundlePath: bundlePath)
try? plist.write(to: self.plistURL, atomically: true, encoding: .utf8)
}
static func plistContents(
bundlePath: String,
preferredPaths: [String] = CommandResolver.preferredPaths()) -> String
{
let path = self.escapePlistText(preferredPaths.joined(separator: ":"))
let profileEnvironment = self.profileEnvironmentPlistEntries()
return """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.openclaw.mac</string>
<key>ProgramArguments</key>
<array>
<string>\(bundlePath)/Contents/MacOS/OpenClaw</string>
</array>
<key>WorkingDirectory</key>
<string>\(FileManager().homeDirectoryForCurrentUser.path)</string>
<key>RunAtLoad</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>\(path)</string>\(profileEnvironment)
</dict>
<key>StandardOutPath</key>
<string>\(LogLocator.launchdLogPath)</string>
<key>StandardErrorPath</key>
<string>\(LogLocator.launchdLogPath)</string>
</dict>
</plist>
"""
}
private static func profileEnvironmentPlistEntries() -> String {
["OPENCLAW_CONFIG_PATH", "OPENCLAW_STATE_DIR"].compactMap { key in
guard let value = OpenClawEnv.path(key) else { return nil }
return """
<key>\(key)</key>
<string>\(self.escapePlistText(value))</string>
"""
}.joined()
}
private static func escapePlistText(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
@discardableResult
private static func runLaunchctl(_ args: [String]) async -> Int32 {
#if DEBUG
self.testingLaunchctlCalls.append(args)
#endif
do {
return try await BoundedProcess.run(
path: "/bin/launchctl",
arguments: args,
timeout: 5).terminationStatus
} catch {
return -1
}
}
}
#if DEBUG
extension LaunchAgentManager {
private nonisolated(unsafe) static var testingLaunchctlCalls: [[String]] = []
static func _testResetLaunchctlCalls() {
self.testingLaunchctlCalls = []
}
static func _testLaunchctlCallSnapshot() -> [[String]] {
self.testingLaunchctlCalls
}
}
#endif