Files
openclaw/apps/macos/Sources/OpenClaw/AppProfile.swift
T
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

126 lines
4.6 KiB
Swift

import Darwin
import Foundation
struct AppProfile: Equatable, Sendable {
struct ValidationError: LocalizedError, Equatable, Sendable {
let rawValue: String
let reason: String
var errorDescription: String? {
"Invalid OPENCLAW_PROFILE \"\(self.rawValue)\": \(self.reason)"
}
}
static let current = Self(environment: ProcessInfo.processInfo.environment)
private static let reservedLaunchAgentNames: Set<String> = ["gateway", "mac", "node"]
let name: String?
let validationError: ValidationError?
init(environment: [String: String]) {
let raw = environment["OPENCLAW_PROFILE"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if raw.isEmpty || raw.lowercased() == "default" {
self.name = nil
self.validationError = nil
return
}
if let reason = Self.invalidReason(raw) {
self.name = nil
self.validationError = ValidationError(rawValue: raw, reason: reason)
return
}
self.name = raw
self.validationError = nil
}
var isActive: Bool {
self.name != nil
}
var gatewayLaunchAgentLabel: String {
// Keep this byte-for-byte aligned with src/daemon/constants.ts
// resolveGatewayLaunchAgentLabel; the CLI owns the managed service.
self.name.map { "ai.openclaw.\($0)" } ?? "ai.openclaw.gateway"
}
var defaultsSuiteName: String? {
// Named profiles need a stable domain even when dev and packaged bundle ids differ.
self.name.map { "\(launchdLabel).profile.\($0)" }
}
var keychainServiceSuffix: String {
self.name.map { ".profile.\($0)" } ?? ""
}
func keychainService(base: String) -> String {
base + self.keychainServiceSuffix
}
func stateDirectoryURL(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
let directory = self.name.map { ".openclaw-\($0)" } ?? ".openclaw"
return homeDirectory.appendingPathComponent(directory, isDirectory: true)
}
var cliRootArguments: [String] {
self.name.map { ["--profile", $0] } ?? []
}
func localCLICommand(prefix: [String], arguments: [String]) -> [String] {
prefix + self.cliRootArguments + arguments
}
var instanceLockName: String {
self.name.map { "ai.openclaw.mac.profile.\($0)" } ?? "ai.openclaw.mac"
}
func instanceLockURL(systemTemporaryDirectory: URL = URL(fileURLWithPath: "/tmp", isDirectory: true)) -> URL {
// Service/defaults/Keychain identity follows only the profile; state overrides must not split this lock.
systemTemporaryDirectory
.appendingPathComponent("openclaw-\(geteuid())-app-instances", isDirectory: true)
.appendingPathComponent("\(self.instanceLockName).lock", isDirectory: false)
}
var defaultGatewayPort: Int {
guard let name else { return 18789 }
var hash: UInt32 = 2_166_136_261
for byte in name.utf8 {
hash = (hash ^ UInt32(byte)) &* 16_777_619
}
return 20000 + Int(hash % 40000)
}
private static func invalidReason(_ value: String) -> String? {
// Mirror src/cli/profile-utils.ts, then apply the stricter macOS native-service rules.
guard value.utf8.count <= 64,
value.utf8.first.map(self.isASCIIAlphanumeric) == true,
value.utf8.allSatisfy({ Self.isASCIIAlphanumeric($0) || $0 == 45 || $0 == 95 })
else {
return "use 1-64 letters, numbers, underscores, or hyphens, starting with a letter or number"
}
guard value == value.lowercased() else {
return "macOS profile names must be lowercase so state and LaunchAgent identities cannot collide"
}
guard !self.reservedLaunchAgentNames.contains(value) else {
return "\"\(value)\" is reserved by an existing OpenClaw LaunchAgent"
}
return nil
}
private static func isASCIIAlphanumeric(_ byte: UInt8) -> Bool {
(48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte)
}
}
enum AppDefaults {
/// UserDefaults synchronizes access internally; the selected suite is immutable for this process.
nonisolated(unsafe) static let standard: UserDefaults = {
guard let suiteName = AppProfile.current.defaultsSuiteName else {
return UserDefaults.standard
}
guard let defaults = UserDefaults(suiteName: suiteName) else {
fatalError("Could not create UserDefaults suite \(suiteName)")
}
return defaults
}()
}