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
This commit is contained in:
Peter Steinberger
2026-08-09 14:50:15 -07:00
committed by GitHub
parent 0ef798d28d
commit 0dbdf994b3
75 changed files with 2371 additions and 661 deletions
+1
View File
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **macOS app profiles:** isolate named app instances across state, preferences, Keychain, Gateway services, and duplicate-instance ownership while keeping host-global login and node services untouched.
- **Developer workflow:** remove the obsolete scoped-commit helper and use standard Git commands in isolated worktrees.
- **Plugin uninstall cleanup:** remove exact recorded install paths from `plugins.load.paths` for marketplace, npm, and other managed installs while preserving parent, child, prefix, and unrelated paths.
- Fixed Crabbox hydration on unprivileged cloud sandboxes by falling back to a user-writable pnpm store when the shared `/var/cache/crabbox` cache is unavailable, preserving the hardlink import mode after hydration, and making Docker an explicit routed capability instead of an implicit install requirement.
File diff suppressed because it is too large Load Diff
+26
View File
@@ -20,6 +20,32 @@ the `--chat`/`--dashboard` auto-open helpers. Pairing, control-channel, and Mac
node services still start. Combine it with `--attach-only` when an external
process owns the local Gateway.
## App profiles
Launch a fully isolated app instance with the same profile name used by the CLI:
```bash
OPENCLAW_PROFILE=work /Applications/OpenClaw.app/Contents/MacOS/OpenClaw
```
Profile names use 164 lowercase letters, numbers, underscores, or hyphens and
must start with a letter or number. `default` selects the normal app; `gateway`,
`mac`, and `node` are reserved LaunchAgent identities.
`scripts/restart-mac.sh` intentionally rejects named profiles because its
packaging cleanup is host-global. Build/package normally, then launch the named
profile directly with the command above.
A named profile keeps state in `~/.openclaw-<name>`, uses its own app defaults,
Keychain services, duplicate-instance lock, and the CLI-managed Gateway service
`ai.openclaw.<name>`. Unless config or environment selects a port, each profile
derives a stable port in the profile `20000...59999` range. The app does not
install or modify the host-global Mac node
service or OpenClaw login item while a profile is active. The runtime child node
still runs in process as usual. App relocation, Sparkle updates, and post-update
service repair are disabled in profile mode; update the installed app through
the normal default-profile workflow.
## Packaging flow
```bash
@@ -7,7 +7,7 @@ struct AboutSettings: View {
weak var updater: UpdaterProviding?
@Environment(\.colorScheme) private var colorScheme
@State private var iconHover = false
@AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true
@AppStorage("autoUpdateEnabled", store: AppDefaults.standard) private var autoCheckEnabled = true
@State private var didLoadUpdaterState = false
var body: some View {
@@ -70,7 +70,9 @@ struct AboutSettings: View {
Button("Check for Updates…") { updater.checkForUpdates(nil) }
}
} else {
Text("Updates unavailable in this build.")
Text(AppProfile.current.isActive
? "App updates are unavailable while a profile is active."
: "Updates unavailable in this build.")
.foregroundStyle(.secondary)
.padding(.top, 4)
}
@@ -0,0 +1,125 @@
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
}()
}
+68 -65
View File
@@ -115,7 +115,7 @@ final class AppState {
@ObservationIgnored private var activeComputerPresenceUpdateGeneration: UInt64 = 0
var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
var launchAtLogin: Bool {
@@ -131,13 +131,13 @@ final class AppState {
}
var onboardingSeen: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.onboardingSeen, forKey: onboardingSeenKey) }
}
}
var debugPaneEnabled: Bool {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) }
self.ifNotPreview { AppDefaults.standard.set(self.debugPaneEnabled, forKey: debugPaneEnabledKey) }
CanvasManager.shared.refreshDebugStatus()
}
}
@@ -145,7 +145,7 @@ final class AppState {
var nativeSettingsPanesEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.nativeSettingsPanesEnabled, forKey: nativeSettingsPanesEnabledKey)
AppDefaults.standard.set(self.nativeSettingsPanesEnabled, forKey: nativeSettingsPanesEnabledKey)
}
}
}
@@ -153,7 +153,7 @@ final class AppState {
var swabbleEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
AppDefaults.standard.set(self.swabbleEnabled, forKey: swabbleEnabledKey)
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
}
@@ -163,7 +163,7 @@ final class AppState {
didSet {
// Preserve the raw editing state; sanitization happens when we actually use the triggers.
self.ifNotPreview {
UserDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
AppDefaults.standard.set(self.swabbleTriggerWords, forKey: swabbleTriggersKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
@@ -181,7 +181,7 @@ final class AppState {
}
var iconAnimationsEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
didSet { self.ifNotPreview { AppDefaults.standard.set(
self.iconAnimationsEnabled,
forKey: iconAnimationsEnabledKey) } }
}
@@ -189,7 +189,7 @@ final class AppState {
var showDockIcon: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
AppDefaults.standard.set(self.showDockIcon, forKey: showDockIconKey)
AppActivationPolicy.apply(showDockIcon: self.showDockIcon)
}
}
@@ -198,7 +198,7 @@ final class AppState {
var voiceWakeMicID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
AppDefaults.standard.set(self.voiceWakeMicID, forKey: voiceWakeMicKey)
if self.swabbleEnabled, !self.talkEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
@@ -210,13 +210,13 @@ final class AppState {
}
var voiceWakeMicName: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.voiceWakeMicName, forKey: voiceWakeMicNameKey) } }
}
var voiceWakeLocaleID: String {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
AppDefaults.standard.set(self.voiceWakeLocaleID, forKey: voiceWakeLocaleKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
@@ -225,13 +225,13 @@ final class AppState {
}
var voiceWakeAdditionalLocaleIDs: [String] {
didSet { self.ifNotPreview { UserDefaults.standard.set(
didSet { self.ifNotPreview { AppDefaults.standard.set(
self.voiceWakeAdditionalLocaleIDs,
forKey: voiceWakeAdditionalLocalesKey) } }
}
var voicePushToTalkEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(
didSet { self.ifNotPreview { AppDefaults.standard.set(
self.voicePushToTalkEnabled,
forKey: voicePushToTalkEnabledKey) } }
}
@@ -239,7 +239,7 @@ final class AppState {
var voiceWakeTriggersTalkMode: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
AppDefaults.standard.set(self.voiceWakeTriggersTalkMode, forKey: voiceWakeTriggersTalkModeKey)
if self.swabbleEnabled {
Task { await VoiceWakeRuntime.shared.refresh(state: self) }
}
@@ -252,7 +252,7 @@ final class AppState {
var talkEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
AppDefaults.standard.set(self.talkEnabled, forKey: talkEnabledKey)
Task { await TalkModeController.shared.setEnabled(self.talkEnabled) }
}
}
@@ -261,7 +261,7 @@ final class AppState {
var talkPhaseSoundsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
AppDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
}
}
}
@@ -269,7 +269,7 @@ final class AppState {
var talkShiftToStopEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
AppDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
Task { TalkSpeechInterruptMonitor.shared.setEnabled(self.talkShiftToStopEnabled && self.talkEnabled) }
}
}
@@ -279,7 +279,7 @@ final class AppState {
var seamColorHex: String?
var iconOverride: IconOverrideSelection {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) } }
}
var isWorking: Bool = false
@@ -289,7 +289,7 @@ final class AppState {
var heartbeatsEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
AppDefaults.standard.set(self.heartbeatsEnabled, forKey: heartbeatsEnabledKey)
Task { _ = await GatewayConnection.shared.setHeartbeatsEnabled(self.heartbeatsEnabled) }
}
}
@@ -297,7 +297,7 @@ final class AppState {
var connectionMode: ConnectionMode {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
self.ifNotPreview { AppDefaults.standard.set(self.connectionMode.rawValue, forKey: connectionModeKey) }
if oldValue != self.connectionMode {
self.markGatewayConfigDirty([.mode])
}
@@ -321,11 +321,11 @@ final class AppState {
}
var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
var quickChatEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.quickChatEnabled, forKey: quickChatEnabledKey) } }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.quickChatEnabled, forKey: quickChatEnabledKey) } }
}
var activeComputerPresenceEnabled: Bool {
@@ -342,7 +342,7 @@ final class AppState {
var peekabooBridgeEnabled: Bool {
didSet {
self.ifNotPreview {
UserDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
AppDefaults.standard.set(self.peekabooBridgeEnabled, forKey: peekabooBridgeEnabledKey)
}
self.applyPeekabooBridgeHostState()
}
@@ -361,7 +361,7 @@ final class AppState {
var remoteTarget: String {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
self.ifNotPreview { AppDefaults.standard.set(self.remoteTarget, forKey: remoteTargetKey) }
if oldValue != self.remoteTarget {
self.markGatewayConfigDirty([.remoteTarget, .remoteUrl, .remoteHostKeyPolicy])
}
@@ -410,7 +410,7 @@ final class AppState {
var remoteIdentity: String {
didSet {
self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
self.ifNotPreview { AppDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) }
if oldValue != self.remoteIdentity {
self.markGatewayConfigDirty([.remoteIdentity])
}
@@ -419,11 +419,11 @@ final class AppState {
}
var remoteProjectRoot: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteProjectRoot, forKey: remoteProjectRootKey) } }
}
var remoteCliPath: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
didSet { self.ifNotPreview { AppDefaults.standard.set(self.remoteCliPath, forKey: remoteCliPathKey) } }
}
@ObservationIgnored private var earBoostTask: Task<Void, Never>?
@@ -442,19 +442,20 @@ final class AppState {
let isPreview = preview || ProcessInfo.processInfo.isRunningTests
self.isPreview = isPreview
self.bundleLocationAllowsPersistentIntegration =
isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration()
!AppProfile.current.isActive &&
(isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration())
self.execApprovalsDefaultsAsyncResolver = execApprovalsDefaultsAsyncResolver
self.execApprovalsReadRetryDelay = execApprovalsReadRetryDelay
self.gatewayConfigSaver = gatewayConfigSaver
let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey)
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
let onboardingSeen = AppDefaults.standard.bool(forKey: onboardingSeenKey)
self.isPaused = AppDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false
self.onboardingSeen = onboardingSeen
self.debugPaneEnabled = UserDefaults.standard.bool(forKey: debugPaneEnabledKey)
self.nativeSettingsPanesEnabled = UserDefaults.standard.bool(forKey: nativeSettingsPanesEnabledKey)
let savedVoiceWake = UserDefaults.standard.bool(forKey: swabbleEnabledKey)
self.debugPaneEnabled = AppDefaults.standard.bool(forKey: debugPaneEnabledKey)
self.nativeSettingsPanesEnabled = AppDefaults.standard.bool(forKey: nativeSettingsPanesEnabledKey)
let savedVoiceWake = AppDefaults.standard.bool(forKey: swabbleEnabledKey)
self.swabbleEnabled = voiceWakeSupported ? savedVoiceWake : false
self.swabbleTriggerWords = UserDefaults.standard
self.swabbleTriggerWords = AppDefaults.standard
.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
self.voiceWakeTriggerChime = Self.loadChime(
key: voiceWakeTriggerChimeKey,
@@ -462,54 +463,54 @@ final class AppState {
self.voiceWakeSendChime = Self.loadChime(
key: voiceWakeSendChimeKey,
fallback: .system(name: "Glass"))
if let storedIconAnimations = UserDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
if let storedIconAnimations = AppDefaults.standard.object(forKey: iconAnimationsEnabledKey) as? Bool {
self.iconAnimationsEnabled = storedIconAnimations
} else {
self.iconAnimationsEnabled = true
UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
AppDefaults.standard.set(true, forKey: iconAnimationsEnabledKey)
}
if let storedShowDockIcon = UserDefaults.standard.object(forKey: showDockIconKey) as? Bool {
if let storedShowDockIcon = AppDefaults.standard.object(forKey: showDockIconKey) as? Bool {
self.showDockIcon = storedShowDockIcon
} else {
self.showDockIcon = true
UserDefaults.standard.set(true, forKey: showDockIconKey)
AppDefaults.standard.set(true, forKey: showDockIconKey)
}
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard
self.voiceWakeMicID = AppDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
self.voiceWakeMicName = AppDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? ""
self.voiceWakeLocaleID = AppDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
self.voiceWakeAdditionalLocaleIDs = AppDefaults.standard
.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
self.voicePushToTalkEnabled = UserDefaults.standard
self.voicePushToTalkEnabled = AppDefaults.standard
.object(forKey: voicePushToTalkEnabledKey) as? Bool ?? false
self.voiceWakeTriggersTalkMode = UserDefaults.standard
self.voiceWakeTriggersTalkMode = AppDefaults.standard
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
if let storedPhaseSounds = UserDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
self.talkEnabled = AppDefaults.standard.bool(forKey: talkEnabledKey)
if let storedPhaseSounds = AppDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
self.talkPhaseSoundsEnabled = storedPhaseSounds
} else {
self.talkPhaseSoundsEnabled = true
UserDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
AppDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
}
if let storedShiftToStop = UserDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
if let storedShiftToStop = AppDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
self.talkShiftToStopEnabled = storedShiftToStop
} else {
self.talkShiftToStopEnabled = true
UserDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
AppDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
}
self.seamColorHex = nil
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
if let storedHeartbeats = AppDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
self.heartbeatsEnabled = storedHeartbeats
} else {
self.heartbeatsEnabled = true
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
AppDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
}
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
if let storedOverride = AppDefaults.standard.string(forKey: iconOverrideKey),
let selection = IconOverrideSelection(rawValue: storedOverride)
{
self.iconOverride = selection
} else {
self.iconOverride = .system
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
AppDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
}
let configRoot = OpenClawConfigFile.loadDict()
@@ -528,7 +529,7 @@ final class AppState {
let hasConfigRemoteTarget = configRemote?.keys.contains("sshTarget") == true
let configRemoteTarget = (configRemote?["sshTarget"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
let storedRemoteTarget = AppDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote,
hasConfigRemoteTarget
{
@@ -551,21 +552,23 @@ final class AppState {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.remoteIdentity = hasConfigRemoteIdentity
? configRemoteIdentity
: UserDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.quickChatEnabled = UserDefaults.standard.object(forKey: quickChatEnabledKey) as? Bool ?? true
: AppDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty ?? ""
self.remoteProjectRoot = AppDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? ""
self.remoteCliPath = AppDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? ""
self.canvasEnabled = AppDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
self.quickChatEnabled = AppDefaults.standard.object(forKey: quickChatEnabledKey) as? Bool ?? true
self.activeComputerPresenceEnabled = Self.resolveActiveComputerPresenceEnabled()
self.execApprovalMode = .deny
self.execApprovalPolicyLoadState = .loading
self.peekabooBridgeEnabled = UserDefaults.standard
self.peekabooBridgeEnabled = AppDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview {
if !self.isPreview, !AppProfile.current.isActive {
Task.detached(priority: .utility) { [weak self] in
let current = await LaunchAgentManager.status()
await MainActor.run { [weak self] in self?.hydrateLaunchAtLogin(current) }
}
} else if !self.isPreview, AppProfile.current.isActive {
Self.logger.info("login-agent status skipped (unavailable under app profile)")
}
if self.swabbleEnabled, !PermissionManager.voiceWakePermissionsGranted() {
@@ -1125,7 +1128,7 @@ extension AppState {
// MARK: - Chime persistence
private static func loadChime(key: String, fallback: VoiceWakeChime) -> VoiceWakeChime {
guard let data = UserDefaults.standard.data(forKey: key) else { return fallback }
guard let data = AppDefaults.standard.data(forKey: key) else { return fallback }
if let decoded = try? JSONDecoder().decode(VoiceWakeChime.self, from: data) {
return decoded
}
@@ -1134,7 +1137,7 @@ extension AppState {
private func storeChime(_ chime: VoiceWakeChime, key: String) {
guard let data = try? JSONEncoder().encode(chime) else { return }
UserDefaults.standard.set(data, forKey: key)
AppDefaults.standard.set(data, forKey: key)
}
}
@@ -1556,7 +1559,7 @@ extension AppState {
}
extension AppState {
static func resolveActiveComputerPresenceEnabled(defaults: UserDefaults = .standard) -> Bool {
static func resolveActiveComputerPresenceEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool {
defaults.bool(forKey: activeComputerPresenceEnabledKey)
}
@@ -1575,7 +1578,7 @@ extension AppState {
private func scheduleActiveComputerPresenceUpdate() {
self.ifNotPreview {
let enabled = self.activeComputerPresenceEnabled
UserDefaults.standard.set(enabled, forKey: activeComputerPresenceEnabledKey)
AppDefaults.standard.set(enabled, forKey: activeComputerPresenceEnabledKey)
PresenceReporter.shared.sendImmediate(reason: "activity-sharing-changed")
self.activeComputerPresenceUpdateGeneration &+= 1
let generation = self.activeComputerPresenceUpdateGeneration
@@ -56,9 +56,9 @@ final class CLIInstallPrompter {
if Self.hasPendingManagedRestart() { return }
}
guard !status.isReady else { return }
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
let lastPrompt = AppDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
guard lastPrompt != version else { return }
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
AppDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
if let target = self.installTargetForCurrentBuild(confirmStable: true) {
Task { _ = await self.installCLI(target: target) }
@@ -224,15 +224,15 @@ final class CLIInstallPrompter {
}
static func hasPendingManagedRestart() -> Bool {
UserDefaults.standard.bool(forKey: cliManagedRestartPendingKey)
AppDefaults.standard.bool(forKey: cliManagedRestartPendingKey)
}
static func setPendingManagedRestart() {
UserDefaults.standard.set(true, forKey: cliManagedRestartPendingKey)
AppDefaults.standard.set(true, forKey: cliManagedRestartPendingKey)
}
static func clearPendingManagedRestart() {
UserDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey)
AppDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey)
}
static func shouldManageCLI(connectionMode: AppState.ConnectionMode) -> Bool {
+11 -9
View File
@@ -22,14 +22,14 @@ enum CLIInstallBuild {
}
enum CLIInstallPolicy {
static func storedPolicy(defaults: UserDefaults = .standard) -> String? {
static func storedPolicy(defaults: UserDefaults = AppDefaults.standard) -> String? {
defaults.string(forKey: cliInstallPolicyKey)
}
static func requiredGatewayVersionString(
appVersion: String?,
isDebug: Bool,
defaults: UserDefaults = .standard) -> String?
defaults: UserDefaults = AppDefaults.standard) -> String?
{
guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else {
return appVersion
@@ -315,8 +315,8 @@ enum CLIInstaller {
private static func rememberValidated(_ status: Status) {
guard case let .ready(location, version) = status else { return }
UserDefaults.standard.set(location, forKey: cliValidatedExecutableKey)
UserDefaults.standard.set(version, forKey: cliValidatedVersionKey)
AppDefaults.standard.set(location, forKey: cliValidatedExecutableKey)
AppDefaults.standard.set(version, forKey: cliValidatedVersionKey)
}
@discardableResult
@@ -470,11 +470,13 @@ enum CLIInstaller {
executable: String,
targetVersion: String,
restartGateway: Bool = true,
repair: Bool = false) -> [String]
repair: Bool = false,
profile: AppProfile = .current) -> [String]
{
var command = repair
? [executable, "update", "repair", "--json", "--timeout", "900", "--yes"]
: [executable, "update", "--tag", targetVersion, "--json", "--timeout", "900"]
let arguments = repair
? ["update", "repair", "--json", "--timeout", "900", "--yes"]
: ["update", "--tag", targetVersion, "--json", "--timeout", "900"]
var command = profile.localCLICommand(prefix: [executable], arguments: arguments)
if !restartGateway {
command.append("--no-restart")
}
@@ -559,7 +561,7 @@ enum CLIInstaller {
case .exact: "exact"
case let .channel(channel): channel.rawValue
}
UserDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
AppDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
}
private static func devCheckoutLocation(prefix: String) -> String {
@@ -28,7 +28,7 @@ extension CanvasWindowController {
static func loadRestoredFrame(sessionKey: String) -> NSRect? {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
guard let arr = UserDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
guard let arr = AppDefaults.standard.array(forKey: key) as? [Double], arr.count == 4 else { return nil }
let rect = NSRect(x: arr[0], y: arr[1], width: arr[2], height: arr[3])
if rect.width < CanvasLayout.minPanelSize.width || rect.height < CanvasLayout.minPanelSize.height { return nil }
return rect
@@ -36,7 +36,7 @@ extension CanvasWindowController {
static func storeRestoredFrame(_ frame: NSRect, sessionKey: String) {
let key = self.storedFrameDefaultsKey(sessionKey: sessionKey)
UserDefaults.standard.set(
AppDefaults.standard.set(
[Double(frame.origin.x), Double(frame.origin.y), Double(frame.size.width), Double(frame.size.height)],
forKey: key)
}
@@ -24,9 +24,12 @@ enum CommandResolver {
runtime: RuntimeResolution,
entrypoint: String,
subcommand: String,
extraArgs: [String]) -> [String]
extraArgs: [String],
profile: AppProfile = .current) -> [String]
{
[runtime.path, entrypoint, subcommand] + extraArgs
profile.localCLICommand(
prefix: [runtime.path, entrypoint],
arguments: [subcommand] + extraArgs)
}
static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] {
@@ -45,7 +48,7 @@ enum CommandResolver {
}
static func projectRoot() -> URL {
if let stored = UserDefaults.standard.string(forKey: projectRootDefaultsKey),
if let stored = AppDefaults.standard.string(forKey: projectRootDefaultsKey),
let url = expandPath(stored),
FileManager().fileExists(atPath: url.path)
{
@@ -60,7 +63,7 @@ enum CommandResolver {
}
static func setProjectRoot(_ path: String) {
UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey)
AppDefaults.standard.set(path, forKey: self.projectRootDefaultsKey)
}
static func projectRootPath() -> String {
@@ -73,7 +76,7 @@ enum CommandResolver {
let home = FileManager().homeDirectoryForCurrentUser
let projectRoot = self.projectRoot()
let validatedExecutable = self.validatedOpenClawExecutable(
defaults: .standard,
defaults: AppDefaults.standard,
fileManager: .default,
requiredVersion: GatewayEnvironment.expectedGatewayVersionString())
return self.preferredPaths(
@@ -267,7 +270,8 @@ enum CommandResolver {
switch await self.runtimeResolution(searchPaths: searchPaths) {
case let .success(runtime):
return MacNodeHostWorkerLaunch(
command: [runtime.path, sourceRunner.path, "node", "worker"],
command: self.nodeHostWorkerCommand(
prefix: [runtime.path, sourceRunner.path]),
currentDirectoryURL: root)
case let .failure(error):
throw error
@@ -277,13 +281,21 @@ enum CommandResolver {
#endif
}
static func nodeHostWorkerCommand(
prefix: [String],
profile: AppProfile = .current) -> [String]
{
profile.localCLICommand(prefix: prefix, arguments: ["node", "worker"])
}
static func openclawNodeCommand(
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil,
projectRoot: URL? = nil) async -> [String]
projectRoot: URL? = nil,
profile: AppProfile = .current) async -> [String]
{
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
if settings.mode == .remote, settings.transport == .ssh {
@@ -299,10 +311,10 @@ enum CommandResolver {
let root = projectRoot ?? self.projectRoot()
if let openclawPath = projectOpenClawExecutable(projectRoot: root) {
return [openclawPath, subcommand] + extraArgs
return profile.localCLICommand(prefix: [openclawPath], arguments: [subcommand] + extraArgs)
}
if let openclawPath = openclawExecutable(searchPaths: searchPaths) {
return [openclawPath, subcommand] + extraArgs
return profile.localCLICommand(prefix: [openclawPath], arguments: [subcommand] + extraArgs)
}
let runtimeResult = await self.runtimeResolution(searchPaths: searchPaths)
@@ -313,7 +325,8 @@ enum CommandResolver {
runtime: runtime,
entrypoint: entry,
subcommand: subcommand,
extraArgs: extraArgs)
extraArgs: extraArgs,
profile: profile)
}
case .failure:
break
@@ -321,7 +334,9 @@ enum CommandResolver {
if let pnpm = findExecutable(named: "pnpm", searchPaths: searchPaths) {
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
return [pnpm, "--silent", "openclaw", subcommand] + extraArgs
return profile.localCLICommand(
prefix: [pnpm, "--silent", "openclaw"],
arguments: [subcommand] + extraArgs)
}
switch runtimeResult {
@@ -338,10 +353,11 @@ enum CommandResolver {
static func openclawCommand(
subcommand: String,
extraArgs: [String] = [],
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
configRoot: [String: Any]? = nil,
searchPaths: [String]? = nil,
projectRoot: URL? = nil) async -> [String]
projectRoot: URL? = nil,
profile: AppProfile = .current) async -> [String]
{
await self.openclawNodeCommand(
subcommand: subcommand,
@@ -349,7 +365,8 @@ enum CommandResolver {
defaults: defaults,
configRoot: configRoot,
searchPaths: searchPaths,
projectRoot: projectRoot)
projectRoot: projectRoot,
profile: profile)
}
// MARK: - SSH helpers
@@ -503,7 +520,7 @@ enum CommandResolver {
}
static func connectionSettings(
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
configRoot: [String: Any]? = nil) -> RemoteSettings
{
let root = configRoot ?? OpenClawConfigFile.loadDict()
@@ -545,7 +562,7 @@ enum CommandResolver {
sshHostKeyPolicy: sshHostKeyPolicy)
}
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
static func connectionModeIsRemote(defaults: UserDefaults = AppDefaults.standard) -> Bool {
self.connectionSettings(defaults: defaults).mode == .remote
}
@@ -15,7 +15,7 @@ struct EffectiveConnectionMode: Equatable {
enum ConnectionModeResolver {
static func resolve(
root: [String: Any],
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
defaults: UserDefaults = AppDefaults.standard) -> EffectiveConnectionMode
{
let gateway = root["gateway"] as? [String: Any]
let configModeRaw = (gateway?["mode"] as? String) ?? ""
+5 -2
View File
@@ -3,7 +3,10 @@ import Foundation
// Stable identifier used for both the macOS LaunchAgent label and Nix-managed defaults suite.
// nix-openclaw writes app defaults into this suite to survive app bundle identifier churn.
let launchdLabel = "ai.openclaw.mac"
let gatewayLaunchdLabel = "ai.openclaw.gateway"
var gatewayLaunchdLabel: String {
AppProfile.current.gatewayLaunchAgentLabel
}
let nodeLaunchdLabel = "ai.openclaw.node"
let onboardingVersionKey = "openclaw.onboardingVersion"
let onboardingSeenKey = "openclaw.onboardingSeen"
@@ -41,7 +44,7 @@ let quickChatEnabledKey = "openclaw.quickChatEnabled"
let cameraEnabledKey = "openclaw.cameraEnabled"
let computerControlEnabledKey = "openclaw.computerControlEnabled"
func isComputerControlEnabled(defaults: UserDefaults = .standard) -> Bool {
func isComputerControlEnabled(defaults: UserDefaults = AppDefaults.standard) -> Bool {
// object(forKey:) preserves an explicit false; bool(forKey:) would conflate it with an unset default.
defaults.object(forKey: computerControlEnabledKey) as? Bool ?? true
}
@@ -465,7 +465,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
private func persistedLinkBrowserWidth() -> CGFloat? {
guard let number = UserDefaults.standard.object(
guard let number = AppDefaults.standard.object(
forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey) as? NSNumber
else { return nil }
let width = CGFloat(number.doubleValue)
@@ -492,7 +492,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
guard !self.linkBrowserItem.isCollapsed else { return }
let width = self.linkBrowser.frame.width
guard width.isFinite, width >= DashboardWindowLayout.linkBrowserMinWidth else { return }
UserDefaults.standard.set(Double(width), forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey)
AppDefaults.standard.set(Double(width), forKey: DashboardWindowLayout.linkBrowserWidthDefaultsKey)
}
private func requestBrowserProfileImportOfferIfNeeded() {
+15 -5
View File
@@ -159,12 +159,12 @@ enum DebugActions {
}
static var verboseLoggingEnabledMain: Bool {
UserDefaults.standard.bool(forKey: self.verboseDefaultsKey)
AppDefaults.standard.bool(forKey: self.verboseDefaultsKey)
}
static func toggleVerboseLoggingMain() async -> Bool {
let newValue = !self.verboseLoggingEnabledMain
UserDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey)
AppDefaults.standard.set(newValue, forKey: self.verboseDefaultsKey)
_ = try? await ControlChannel.shared.request(
method: "system-event",
params: ["text": AnyHashable("verbose-main:\(newValue ? "on" : "off")")])
@@ -177,15 +177,25 @@ enum DebugActions {
let task = Process()
// Relaunch shortly after this instance exits so we get a true restart even in debug.
task.launchPath = "/bin/sh"
task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path]
if let profile = AppProfile.current.name {
task.arguments = [
"-c",
"sleep 0.2; open -n --env OPENCLAW_PROFILE=\"$2\" \"$1\"",
"_",
url.path,
profile,
]
} else {
task.arguments = ["-c", "sleep 0.2; open -n \"$1\"", "_", url.path]
}
try? task.run()
NSApp.terminate(nil)
}
@MainActor
static func restartOnboarding() {
UserDefaults.standard.set(false, forKey: self.onboardingSeenKey)
UserDefaults.standard.set(0, forKey: onboardingVersionKey)
AppDefaults.standard.set(false, forKey: self.onboardingSeenKey)
AppDefaults.standard.set(0, forKey: onboardingVersionKey)
AppStateStore.shared.onboardingSeen = false
OnboardingController.shared.restart()
}
@@ -511,13 +511,19 @@ struct DebugSettings: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button {
LaunchdManager.startOpenClaw()
} label: {
Label("Restart OpenClaw", systemImage: "arrow.counterclockwise")
if AppProfile.current.isActive {
Text("Login-agent restart is unavailable under a profile.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Button {
LaunchdManager.startOpenClaw()
} label: {
Label("Restart OpenClaw", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
HStack(spacing: 8) {
+1 -1
View File
@@ -150,7 +150,7 @@ final class DeepLinkHandler {
}
private static func expectedKey() -> String {
let defaults = UserDefaults.standard
let defaults = AppDefaults.standard
if let key = defaults.string(forKey: deepLinkKeyKey), !key.isEmpty {
return key
}
@@ -16,7 +16,7 @@ actor DiagnosticsFileLog {
}
nonisolated static func isEnabled() -> Bool {
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
AppDefaults.standard.bool(forKey: debugFileLogEnabledKey)
}
nonisolated static func logDirectoryURL() -> URL {
@@ -28,7 +28,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
return
}
let userWantsDockHidden = (UserDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false
let userWantsDockHidden = (AppDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false
let visibleWindows = NSApp?.windows.filter { window in
window.isVisible &&
window.frame.width > 1 &&
@@ -107,7 +107,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
@objc
private func dockPreferenceChanged(_ notification: Notification) {
guard let userDefaults = notification.object as? UserDefaults,
userDefaults == UserDefaults.standard
userDefaults == AppDefaults.standard
else { return }
Task { @MainActor in
@@ -146,7 +146,34 @@ enum ExecApprovalsStore {
}
static func socketPath() -> String {
self.stateDirURL().appendingPathComponent("exec-approvals.sock").path
self.socketPath(
stateDirectoryURL: self.stateDirURL(),
profileActive: AppProfile.current.isActive)
}
static func socketPath(stateDirectoryURL: URL, profileActive: Bool) -> String {
let canonical = stateDirectoryURL.appendingPathComponent("exec-approvals.sock").path
let maximumLength = MemoryLayout.size(ofValue: sockaddr_un().sun_path)
guard canonical.utf8.count >= maximumLength, profileActive else {
return canonical
}
let digest = SHA256.hash(data: Data(canonical.utf8))
.prefix(8)
.map { String(format: "%02x", $0) }
.joined()
return "/tmp/openclaw-\(geteuid())/exec-approvals-\(digest).sock"
}
static func resolvedPersistedSocketPath(
existing: String?,
stateDirectoryURL: URL,
computed: String) -> String
{
let existing = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let oldCanonical = stateDirectoryURL.appendingPathComponent("exec-approvals.sock").path
return existing.isEmpty || (existing == oldCanonical && computed != oldCanonical)
? computed
: existing
}
private static func homeURL() -> URL {
@@ -163,7 +190,7 @@ enum ExecApprovalsStore {
return scopedStateDirectoryURL
}
guard let configured = OpenClawEnv.path("OPENCLAW_STATE_DIR") else {
return self.homeURL().appendingPathComponent(".openclaw", isDirectory: true)
return AppProfile.current.stateDirectoryURL(homeDirectory: self.homeURL())
}
let home = self.homeURL().path
let expanded: String = if configured == "~" {
@@ -293,10 +320,12 @@ enum ExecApprovalsStore {
if file.socket == nil {
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
}
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if path.isEmpty {
file.socket?.path = self.socketPath()
}
let existingSocketPath = file.socket?.path
let resolvedSocketPath = self.resolvedPersistedSocketPath(
existing: existingSocketPath,
stateDirectoryURL: self.stateDirURL(),
computed: self.socketPath())
file.socket?.path = resolvedSocketPath
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if token.isEmpty {
file.socket?.token = self.generateToken()
@@ -66,8 +66,17 @@ struct GeneralStatusPresentation: Equatable {
static func resolve(
mode: AppState.ConnectionMode,
isPaused: Bool,
controlState: ControlChannel.ConnectionState) -> Self
controlState: ControlChannel.ConnectionState,
localFailure: String? = nil) -> Self
{
if mode == .local, let localFailure {
return Self(
title: String(localized: "OpenClaw needs attention"),
subtitle: localFailure,
symbolName: "exclamationmark.triangle.fill",
tone: .attention,
showsConnectionAction: true)
}
if isPaused {
return Self(
title: String(localized: "OpenClaw paused"),
@@ -16,10 +16,14 @@ enum GatewayActivationBindingKeyStore {
// keychain password on every read. DEBUG is a config heuristic, not a
// signing check same accepted tradeoff as MacGatewayProfileStore.service.
#if DEBUG
private static let service = "ai.openclaw.onboarding-route-binding.debug"
private static let baseService = "ai.openclaw.onboarding-route-binding.debug"
#else
private static let service = "ai.openclaw.onboarding-route-binding"
private static let baseService = "ai.openclaw.onboarding-route-binding"
#endif
static var service: String {
AppProfile.current.keychainService(base: self.baseService)
}
private static let account = "credential-binding-v1"
private static let byteCount = 32
@@ -6,7 +6,7 @@ enum GatewayDiscoveryPreferences {
private static let preferredRouteBindingKey = "gateway.preferredStableIDRouteBinding.v1"
static func preferredStableID() -> String? {
let defaults = UserDefaults.standard
let defaults = AppDefaults.standard
let raw = defaults.string(forKey: self.preferredStableIDKey)
?? defaults.string(forKey: self.legacyPreferredStableIDKey)
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -16,19 +16,19 @@ enum GatewayDiscoveryPreferences {
static func setPreferredStableID(_ stableID: String?) {
// A caller without an endpoint binding cannot prove that a prior binding
// belongs to this id. The bound overload installs a fresh one below.
UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmed, !trimmed.isEmpty {
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
AppDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
AppDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
} else {
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
AppDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
AppDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
}
}
static func preferredRouteBinding() -> String? {
let raw = UserDefaults.standard.string(forKey: self.preferredRouteBindingKey)
let raw = AppDefaults.standard.string(forKey: self.preferredRouteBindingKey)
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}
@@ -38,10 +38,10 @@ enum GatewayDiscoveryPreferences {
guard self.preferredStableID() != nil,
let routeBinding = self.normalized(routeBinding)
else {
UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
return
}
UserDefaults.standard.set(routeBinding, forKey: self.preferredRouteBindingKey)
AppDefaults.standard.set(routeBinding, forKey: self.preferredRouteBindingKey)
}
/// Discovery ids name one concrete Gateway. Persist the non-secret fallback
@@ -97,7 +97,7 @@ enum GatewayDiscoveryPreferences {
@discardableResult
static func clearPreferredStableIDIfRouteBindingMismatch(_ currentRouteBinding: String?) -> Bool {
guard self.preferredStableID() != nil else {
UserDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
AppDefaults.standard.removeObject(forKey: self.preferredRouteBindingKey)
return false
}
guard let stored = self.preferredRouteBinding(),
@@ -86,6 +86,7 @@ actor GatewayEndpointStore {
let token: @Sendable () -> String?
let password: @Sendable () -> String?
let localPort: @Sendable () -> Int
let localUnavailableReason: @Sendable () -> String?
let remoteRouteIfRunning: @Sendable () async -> RemoteTunnelManager.Route?
let remoteRouteIsCurrent: @Sendable (RemoteTunnelManager.Route) async -> Bool
let canStartRemoteTunnel: @Sendable () -> Bool
@@ -113,6 +114,7 @@ actor GatewayEndpointStore {
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
},
localPort: { GatewayEnvironment.gatewayPort() },
localUnavailableReason: { GatewayEnvironment.profileGatewayPortConflict() },
remoteRouteIfRunning: { await RemoteTunnelManager.shared.controlTunnelRouteIfRunning() },
remoteRouteIsCurrent: { await RemoteTunnelManager.shared.isCurrentRoute($0) },
canStartRemoteTunnel: { GatewayEndpointStore.primaryAppLaunchAdmitted.withValue { $0 } },
@@ -364,19 +366,21 @@ actor GatewayEndpointStore {
private var endpointRevision: UInt64 = 0
private var resolutionGeneration: UInt64 = 0
private var activeSource: SourceSnapshot?
private var localUnavailableReason: String?
init(deps: Deps = .live) {
self.deps = deps
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
let modeRaw = AppDefaults.standard.string(forKey: connectionModeKey)
let initialMode: AppState.ConnectionMode
if let modeRaw {
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = UserDefaults.standard.bool(forKey: "openclaw.onboardingSeen")
let seen = AppDefaults.standard.bool(forKey: "openclaw.onboardingSeen")
initialMode = seen ? .local : .unconfigured
}
let port = deps.localPort()
self.localUnavailableReason = deps.localUnavailableReason()
let root = OpenClawConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
@@ -398,6 +402,10 @@ actor GatewayEndpointStore {
remoteTarget: "")
switch initialMode {
case .local:
if let reason = self.localUnavailableReason {
self.state = .unavailable(mode: .local, reason: reason)
return
}
let url = URL(string: "\(scheme)://\(host):\(port)")!
self.endpointRevision = 1
self.state = .ready(
@@ -440,6 +448,13 @@ actor GatewayEndpointStore {
self.state
}
func setLocalUnavailableReason(_ reason: String?) {
self.localUnavailableReason = reason
if let reason {
self.setState(.unavailable(mode: .local, reason: reason))
}
}
func refresh() async {
_ = await self.refreshIfCurrent()
}
@@ -506,6 +521,10 @@ actor GatewayEndpointStore {
case .local:
self.cancelRemoteEnsure()
guard await self.sourceIsCurrent(source, generation: generation) else { return }
if let reason = self.localUnavailableReason {
self.setState(.unavailable(mode: .local, reason: reason))
return
}
let url = URL(string: "\(source.scheme)://\(source.localHost):\(source.localPort)")!
self.publishReadyEndpoint(source: source, url: url)
case .remote:
@@ -914,6 +933,7 @@ extension GatewayEndpointStore {
generationIsCurrent: { generation in
AppStateStore.shared.gatewayRoutingGeneration == generation
},
profile: .current,
beforeConfigRead: {})
}
@@ -933,6 +953,7 @@ extension GatewayEndpointStore {
private static func liveSourceSnapshot(
appSnapshot: @escaping @MainActor @Sendable () -> LiveAppSnapshot,
generationIsCurrent: @escaping @MainActor @Sendable (UInt64) -> Bool,
profile: AppProfile,
beforeConfigRead: @escaping @Sendable () async -> Void) async -> SourceSnapshot
{
// Capture MainActor-owned selection facts before reading config. The
@@ -994,7 +1015,7 @@ extension GatewayEndpointStore {
env: env,
launchdSnapshot: launchdSnapshot),
deviceAuthGatewayID: deviceAuthGatewayID,
localPort: self.resolveGatewayPort(root: root, env: env),
localPort: self.resolveGatewayPort(root: root, env: env, profile: profile),
localHost: self.resolveLocalGatewayHost(
bindMode: bindMode,
customBindHost: customBindHost,
@@ -1039,16 +1060,11 @@ extension GatewayEndpointStore {
private static func resolveGatewayPort(
root: [String: Any],
env: [String: String],
defaults: UserDefaults = .standard) -> Int
defaults: UserDefaults = AppDefaults.standard,
profile: AppProfile) -> Int
{
if let raw = env["OPENCLAW_GATEWAY_PORT"],
let port = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
port > 0
{
return port
}
if let gateway = root["gateway"] as? [String: Any] {
let port: Int? = switch gateway["port"] {
let configPort: Int? = if let gateway = root["gateway"] as? [String: Any] {
switch gateway["port"] {
case let value as Int:
value
case let value as NSNumber:
@@ -1058,12 +1074,14 @@ extension GatewayEndpointStore {
default:
nil
}
if let port, port > 0 {
return port
}
} else {
nil
}
let stored = defaults.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789
return GatewayEnvironment.resolvedGatewayPort(
environment: env,
configPort: configPort,
storedPort: defaults.integer(forKey: "gatewayPort"),
profile: profile)
}
private static func resolveGatewayBindMode(
@@ -1249,6 +1267,7 @@ extension GatewayEndpointStore {
@MainActor
static func _testLiveSourceSnapshot(
state: AppState,
profile: AppProfile = .current,
beforeConfigRead: @escaping @Sendable () async -> Void) async -> SourceSnapshot
{
await self.liveSourceSnapshot(
@@ -1262,6 +1281,7 @@ extension GatewayEndpointStore {
generationIsCurrent: { generation in
state.gatewayRoutingGeneration == generation
},
profile: profile,
beforeConfigRead: beforeConfigRead)
}
@@ -108,17 +108,42 @@ enum GatewayEnvironment {
private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.env")
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let profilePortReservation: ProfileGatewayPortReservation = .acquire(
profile: .current,
port: GatewayEnvironment.selectedGatewayPort())
static func gatewayPort() -> Int {
if let raw = ProcessInfo.processInfo.environment["OPENCLAW_GATEWAY_PORT"] {
guard AppProfile.current.isActive else { return self.selectedGatewayPort() }
return self.profilePortReservation.port
}
static func profileGatewayPortConflict() -> String? {
guard AppProfile.current.isActive else { return nil }
return self.profilePortReservation.conflict
}
private static func selectedGatewayPort() -> Int {
self.resolvedGatewayPort(
environment: ProcessInfo.processInfo.environment,
configPort: OpenClawConfigFile.gatewayPort(),
storedPort: AppDefaults.standard.integer(forKey: "gatewayPort"),
profile: .current)
}
static func resolvedGatewayPort(
environment: [String: String],
configPort: Int?,
storedPort: Int,
profile: AppProfile) -> Int
{
if let raw = environment["OPENCLAW_GATEWAY_PORT"] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if let parsed = Int(trimmed), parsed > 0 { return parsed }
}
if let configPort = OpenClawConfigFile.gatewayPort(), configPort > 0 {
if let configPort, configPort > 0 {
return configPort
}
let stored = UserDefaults.standard.integer(forKey: "gatewayPort")
return stored > 0 ? stored : 18789
return storedPort > 0 ? storedPort : profile.defaultGatewayPort
}
static func expectedGatewayVersion() -> Semver? {
@@ -238,6 +263,7 @@ enum GatewayEnvironment {
}
static func resolveGatewayCommand(
profile: AppProfile = .current,
searchPathsProvider: @Sendable () async -> [String] = CommandResolver.preferredPathsAsync) async
-> GatewayCommandResolution
{
@@ -261,16 +287,26 @@ enum GatewayEnvironment {
let bind = self.preferredGatewayBind() ?? "loopback"
switch environment.commandSource {
case let .executable(gatewayBin):
let cmd = [gatewayBin, "gateway", "--port", "\(port)", "--bind", bind]
let cmd = self.gatewayCommand(prefix: [gatewayBin], port: port, bind: bind, profile: profile)
return GatewayCommandResolution(status: environment.status, command: cmd)
case let .project(runtime, entrypoint):
let cmd = [runtime.path, entrypoint, "gateway", "--port", "\(port)", "--bind", bind]
let cmd = self.gatewayCommand(
prefix: [runtime.path, entrypoint],
port: port,
bind: bind,
profile: profile)
return GatewayCommandResolution(status: environment.status, command: cmd)
case nil:
return GatewayCommandResolution(status: environment.status, command: nil)
}
}
static func gatewayCommand(prefix: [String], port: Int, bind: String, profile: AppProfile) -> [String] {
profile.localCLICommand(
prefix: prefix,
arguments: ["gateway", "--port", "\(port)", "--bind", bind])
}
private static func preferredGatewayBind() -> String? {
if CommandResolver.connectionModeIsRemote() {
return nil
@@ -7,7 +7,7 @@ enum GatewayLaunchAgentManager {
}
private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd")
private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent"
private static let disableLaunchAgentMarker = "disable-launchagent"
/// A first-run daemon command may wait behind state integrity checks and the shared startup-
/// migration lease. Keep the app from killing healthy migration work before it can finish.
static let startupMigrationTolerance: TimeInterval = 120
@@ -18,13 +18,75 @@ enum GatewayLaunchAgentManager {
return testingDisableLaunchAgentMarkerURL
}
#endif
return FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(self.disableLaunchAgentMarker)
let root = AppProfile.current.isActive
? OpenClawPaths.stateDirURL
: FileManager().homeDirectoryForCurrentUser.appendingPathComponent(".openclaw", isDirectory: true)
return root.appendingPathComponent(self.disableLaunchAgentMarker)
}
private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
self.plistURL(
homeDirectory: FileManager().homeDirectoryForCurrentUser,
profile: .current)
}
static func plistURL(homeDirectory: URL, profile: AppProfile) -> URL {
homeDirectory.appendingPathComponent(
"Library/LaunchAgents/\(profile.gatewayLaunchAgentLabel).plist")
}
static func conflictingProfileClaimOwner(
port: Int,
excludingLabel: String,
homeDirectory: URL) -> String?
{
let directory = homeDirectory.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
guard FileManager.default.fileExists(atPath: directory.path) else { return nil }
guard let entries = try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil)
else {
return "installed profile Gateway claims cannot be inspected"
}
for url in entries {
guard url.pathExtension == "plist" else { continue }
let label = url.deletingPathExtension().lastPathComponent
guard label != excludingLabel,
let profile = self.profile(forLaunchAgentLabel: label)
else { continue }
let owner = profile.name ?? "default"
let artifacts = self.generatedEnvironmentArtifacts(
directory: profile.stateDirectoryURL(homeDirectory: homeDirectory)
.appendingPathComponent("service-env", isDirectory: true),
profile: profile)
guard let snapshot = LaunchAgentPlist.snapshot(
url: url,
generatedEnvironmentFileURL: artifacts.environment,
generatedEnvironmentWrapperURL: artifacts.wrapper),
self.isCanonicalGatewayClaim(snapshot)
else { continue }
guard let claimedPort = snapshot.port else {
return "profile \"\(owner)\" has an unreadable Gateway reservation"
}
if claimedPort == port { return "profile \"\(owner)\" already reserves it" }
}
return nil
}
private static func profile(forLaunchAgentLabel label: String) -> AppProfile? {
let base = AppProfile(environment: [:])
if label == base.gatewayLaunchAgentLabel { return base }
let prefix = "ai.openclaw."
guard label.hasPrefix(prefix) else { return nil }
let name = String(label.dropFirst(prefix.count))
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": name])
return profile.name == name && profile.gatewayLaunchAgentLabel == label ? profile : nil
}
private static func isCanonicalGatewayClaim(_ snapshot: LaunchAgentPlistSnapshot) -> Bool {
snapshot.environment["OPENCLAW_SERVICE_MARKER"] == "openclaw" &&
snapshot.environment["OPENCLAW_SERVICE_KIND"] == "gateway" &&
snapshot.programArguments.contains("gateway")
}
private static var generatedEnvironmentDirectoryURL: URL {
@@ -138,11 +200,20 @@ enum GatewayLaunchAgentManager {
static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? {
let directory = self.generatedEnvironmentDirectoryURL
let artifacts = self.generatedEnvironmentArtifacts(directory: directory, profile: .current)
return LaunchAgentPlist.snapshot(
url: self.plistURL,
generatedEnvironmentFileURL: directory.appendingPathComponent("\(gatewayLaunchdLabel).env"),
generatedEnvironmentWrapperURL: directory.appendingPathComponent(
"\(gatewayLaunchdLabel)-env-wrapper.sh"))
generatedEnvironmentFileURL: artifacts.environment,
generatedEnvironmentWrapperURL: artifacts.wrapper)
}
static func generatedEnvironmentArtifacts(
directory: URL,
profile: AppProfile) -> (environment: URL, wrapper: URL)
{
(
directory.appendingPathComponent("\(profile.gatewayLaunchAgentLabel).env"),
directory.appendingPathComponent("\(profile.gatewayLaunchAgentLabel)-env-wrapper.sh"))
}
/// Empty means no Gateway LaunchAgent. Nil preserves an unreadable
@@ -113,6 +113,7 @@ final class GatewayProcessManager {
private var launchAgentReadinessRevision: UInt64 = 0
private var launchAgentInstallGeneration: UInt64?
private var launchAgentFreshInstallGeneration: UInt64?
private var profilePortConflict: String?
private var lastObservedGatewayPID: Int32?
/// Async readiness audits may outlive stop/restart. Only the current generation may publish
/// their failure state or retain a PID for a later repair.
@@ -146,6 +147,16 @@ final class GatewayProcessManager {
self.logger.info("gateway process skipped: remote mode active")
return
}
if active, self.profilePortConflict != nil {
self.profilePortConflict = nil
Task { await GatewayEndpointStore.shared.setLocalUnavailableReason(nil) }
}
if active, let conflict = GatewayEnvironment.profileGatewayPortConflict() {
self.desiredActive = false
self.recordProfilePortConflict(conflict)
Task { await GatewayEndpointStore.shared.setLocalUnavailableReason(conflict) }
return
}
self.logger.debug("gateway active requested active=\(active)")
self.desiredActive = active
self.refreshEnvironmentStatus()
@@ -159,6 +170,7 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async -> Bool {
guard !CommandResolver.connectionModeIsRemote() else { return false }
guard self.desiredActive else { return false }
guard self.profilePortConflict == nil else { return false }
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
@@ -522,6 +534,11 @@ final class GatewayProcessManager {
guard self.isCurrentGatewayStart(startGeneration) else { return true }
let instanceText = instance.map { self.describe(instance: $0) }
let hasListener = instance != nil
if hasListener,
await !(self.profileOwnsGateway(instance, port: port))
{
return true
}
let attemptAttach = {
try await self.probeGatewayHealth(timeoutMs: 2000)
@@ -534,6 +551,9 @@ final class GatewayProcessManager {
guard self.isCurrentGatewayStart(startGeneration) else { return true }
let attachedInstance = await PortGuardian.shared.describe(port: port)
guard self.isCurrentGatewayStart(startGeneration) else { return true }
if await !(self.profileOwnsGateway(attachedInstance, port: port)) {
return true
}
let snap = decodeHealthSnapshot(from: data)
let attachedInstanceText = attachedInstance.map { self.describe(instance: $0) }
let details = self.describe(details: attachedInstanceText, port: port, snap: snap)
@@ -581,6 +601,45 @@ final class GatewayProcessManager {
return false
}
static func profileAllowsExistingGatewayAttachment(
profile: AppProfile,
listenerPID: Int32?,
managedServicePID: Int32?) -> Bool
{
guard profile.isActive else { return true }
guard let listenerPID, let managedServicePID else { return false }
return listenerPID == managedServicePID
}
private func profileOwnsGateway(_ instance: PortGuardian.Descriptor?, port: Int) async -> Bool {
guard AppProfile.current.isActive else { return true }
let managedPID = await GatewayLaunchAgentManager.runningGatewayPID()
guard Self.profileAllowsExistingGatewayAttachment(
profile: .current,
listenerPID: instance?.pid,
managedServicePID: managedPID)
else {
await self.failProfilePortOwnership(port: port)
return false
}
return true
}
private func failProfilePortOwnership(port: Int) async {
let message = "Gateway port \(port) is already owned by another process or OpenClaw profile. " +
"Set gateway.port to a free port for profile \(AppProfile.current.name ?? "named")."
self.recordProfilePortConflict(message)
await GatewayEndpointStore.shared.setLocalUnavailableReason(message)
}
private func recordProfilePortConflict(_ message: String) {
self.profilePortConflict = message
self.status = .failed(message)
self.lastFailureReason = message
self.appendLog("[gateway] \(message)\n")
self.logger.error("\(message, privacy: .public)")
}
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown"
if let snap {
@@ -737,6 +796,7 @@ extension GatewayProcessManager {
_ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs))
guard !Task.isCancelled else { return }
let instance = await PortGuardian.shared.describe(port: context.port)
guard await self.profileOwnsGateway(instance, port: context.port) else { return }
guard self.publishLaunchdGatewayReady(
instance: instance,
context: context,
@@ -1000,6 +1060,7 @@ extension GatewayProcessManager {
_ = try await self.probeGatewayHealth(timeoutMs: min(1500, remainingMs))
guard !Task.isCancelled else { return false }
let instance = await PortGuardian.shared.describe(port: readinessPort)
guard await self.profileOwnsGateway(instance, port: readinessPort) else { return false }
return self.publishGatewayReadinessSuccess(
instance: instance,
startGeneration: startGeneration,
@@ -88,11 +88,11 @@ struct GeneralSettings: View {
SettingsCardGroup("App") {
SettingsCardToggleRow(
title: "Launch at login",
subtitle: self.state.bundleLocationAllowsPersistentIntegration
? "Automatically start OpenClaw after you sign in."
: "Move OpenClaw to Applications before enabling launch at login.",
subtitle: .verbatim(
self
.launchAtLoginPresentation.subtitle),
binding: self.$state.launchAtLogin)
.disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin)
.disabled(self.launchAtLoginPresentation.isDisabled)
SettingsCardToggleRow(
title: "Show Dock icon",
@@ -221,7 +221,8 @@ struct GeneralSettings: View {
let presentation = GeneralStatusPresentation.resolve(
mode: self.state.connectionMode,
isPaused: self.state.isPaused,
controlState: ControlChannel.shared.state)
controlState: ControlChannel.shared.state,
localFailure: self.gatewayManager.lastFailureReason)
return HStack(alignment: .center, spacing: 14) {
ZStack {
@@ -371,6 +372,7 @@ struct GeneralSettings: View {
Spacer(minLength: 18)
if ControlChannel.shared.state == .connected,
self.localGatewayFailure == nil,
let ping = ControlChannel.shared.lastPingMs
{
Text("\(Int(ping)) ms")
@@ -399,9 +401,10 @@ struct GeneralSettings: View {
}
private var connectionStatusTint: Color {
if self.localGatewayFailure != nil { return .red }
switch ControlChannel.shared.state {
case .connected: .green
case .connecting, .disconnected, .degraded: .orange
case .connected: return .green
case .connecting, .disconnected, .degraded: return .orange
}
}
@@ -414,6 +417,7 @@ struct GeneralSettings: View {
}
private var connectionStatusSubtitle: String {
if let failure = self.localGatewayFailure { return failure }
switch self.state.connectionMode {
case .local:
return "OpenClaw starts and monitors the Gateway on this Mac."
@@ -429,6 +433,10 @@ struct GeneralSettings: View {
}
}
private var localGatewayFailure: String? {
self.state.connectionMode == .local ? self.gatewayManager.lastFailureReason : nil
}
private var gatewayModeGroup: some View {
SettingsCardGroup("Gateway") {
SettingsCardRow(
@@ -805,10 +813,11 @@ struct GeneralSettings: View {
}
private var gatewayStatusColor: Color {
if self.localGatewayFailure != nil { return .red }
switch self.gatewayStatus.kind {
case .ok: .green
case .checking: .secondary
case .missingNode, .missingGateway, .incompatible, .error: .orange
case .ok: return .green
case .checking: return .secondary
case .missingNode, .missingGateway, .incompatible, .error: return .orange
}
}
}
@@ -887,12 +896,41 @@ extension GeneralSettings {
alert.runModal()
}
private var launchAtLoginPresentation: LaunchAtLoginPresentation {
.resolve(
profile: .current,
bundleLocationAllowsPersistentIntegration: self.state.bundleLocationAllowsPersistentIntegration,
isEnabled: self.state.launchAtLogin)
}
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID, state: self.state)
}
}
struct LaunchAtLoginPresentation: Equatable {
let subtitle: String
let isDisabled: Bool
static func resolve(
profile: AppProfile,
bundleLocationAllowsPersistentIntegration: Bool,
isEnabled: Bool) -> Self
{
if profile.isActive {
return Self(
subtitle: String(localized: "Launch at login is unavailable while an app profile is active."),
isDisabled: true)
}
return Self(
subtitle: bundleLocationAllowsPersistentIntegration
? String(localized: "Automatically start OpenClaw after you sign in.")
: String(localized: "Move OpenClaw to Applications before enabling launch at login."),
isDisabled: !bundleLocationAllowsPersistentIntegration && !isEnabled)
}
}
#if DEBUG
struct GeneralSettings_Previews: PreviewProvider {
static var previews: some View {
@@ -1,12 +1,18 @@
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() async -> Bool {
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()
}
@@ -20,9 +26,14 @@ enum LaunchAgentManager {
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)
@@ -107,6 +118,9 @@ enum LaunchAgentManager {
@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",
@@ -117,3 +131,17 @@ enum LaunchAgentManager {
}
}
}
#if DEBUG
extension LaunchAgentManager {
private nonisolated(unsafe) static var testingLaunchctlCalls: [[String]] = []
static func _testResetLaunchctlCalls() {
self.testingLaunchctlCalls = []
}
static func _testLaunchctlCallSnapshot() -> [[String]] {
self.testingLaunchctlCalls
}
}
#endif
@@ -1,6 +1,8 @@
import Foundation
import OSLog
enum LaunchdManager {
private static let logger = Logger(subsystem: "ai.openclaw", category: "app.login-agent")
private static func runLaunchctl(_ args: [String]) {
let process = Process()
process.launchPath = "/bin/launchctl"
@@ -9,6 +11,10 @@ enum LaunchdManager {
}
static func startOpenClaw() {
guard !AppProfile.current.isActive else {
self.logger.info("login-agent restart skipped (unavailable under app profile)")
return
}
let userTarget = "gui/\(getuid())/\(launchdLabel)"
self.runLaunchctl(["kickstart", "-k", userTarget])
}
+2 -1
View File
@@ -15,7 +15,8 @@ enum LogLocator {
}
private static var gatewayLog: URL {
logDir.appendingPathComponent("openclaw-gateway.log")
let suffix = AppProfile.current.name.map { "-\($0)" } ?? ""
return logDir.appendingPathComponent("openclaw-gateway\(suffix).log")
}
private static func ensureLogDirExists() {
@@ -9,7 +9,7 @@ enum AppLogSettings {
static let logLevelKey = appLogLevelKey
static func logLevel() -> Logger.Level {
if let raw = UserDefaults.standard.string(forKey: self.logLevelKey),
if let raw = AppDefaults.standard.string(forKey: self.logLevelKey),
let level = Logger.Level(rawValue: raw)
{
return level
@@ -18,11 +18,11 @@ enum AppLogSettings {
}
static func setLogLevel(_ level: Logger.Level) {
UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
AppDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
}
static func fileLoggingEnabled() -> Bool {
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
AppDefaults.standard.bool(forKey: debugFileLogEnabledKey)
}
}
@@ -62,10 +62,14 @@ actor MacGatewayProfileStore {
// poisoning path. Release-config ad-hoc builds stay out of scope; running
// those against saved Keychain items is already unsupported.
#if DEBUG
private static let service = "ai.openclaw.gateway-profiles.debug"
private static let baseService = "ai.openclaw.gateway-profiles.debug"
#else
private static let service = "ai.openclaw.gateway-profiles"
private static let baseService = "ai.openclaw.gateway-profiles"
#endif
static var service: String {
AppProfile.current.keychainService(base: self.baseService)
}
private static let registryAccount = "registry-v1"
private static let currentLegacyPrimaryMigrationVersion = 1
+162 -39
View File
@@ -1,8 +1,10 @@
import AppKit
import Darwin
import Dispatch
import Foundation
import MenuBarExtraAccess
import Observation
import OpenClawKit
import OSLog
import Security
import SwiftUI
@@ -13,14 +15,25 @@ struct OpenClawApp: App {
@Environment(\.openWindow) private var openWindow
@State private var state: AppState
private static let logger = Logger(subsystem: "ai.openclaw", category: "app")
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private var gatewayManager: GatewayProcessManager {
.shared
}
private var controlChannel: ControlChannel {
.shared
}
private var activityStore: WorkActivityStore {
.shared
}
@State private var statusItem: NSStatusItem?
@State private var statusItemMouseRouter = StatusItemMouseRouter()
@State private var isMenuPresented = false
@State private var isChatWindowVisible = false
@State private var tailscaleService = TailscaleService.shared
private var tailscaleService: TailscaleService {
.shared
}
@MainActor
private func updateStatusHighlight() {
@@ -28,6 +41,22 @@ struct OpenClawApp: App {
}
init() {
if let error = AppProfile.current.validationError {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "OpenClaw profile is invalid"
alert.informativeText = error.localizedDescription
alert.runModal()
Darwin.exit(2)
}
if AppProfile.current.isActive,
!DeviceIdentityStore.configureStateDirectory(OpenClawPaths.stateDirURL)
{
fatalError("Device identity state root was already used before app profile configuration")
}
guard GatewayTLSStore.configureKeychainServiceSuffix(AppProfile.current.keychainServiceSuffix) else {
fatalError("Gateway TLS Keychain namespace was already used by another app profile")
}
OpenClawLogging.bootstrapIfNeeded()
Self.applyAttachOnlyOverrideIfNeeded()
@@ -366,11 +395,11 @@ private struct SettingsWindowOpenRegistrar: View {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private static let dashboardURL = URL(string: "openclaw://dashboard")!
private var state: AppState?
private var terminationCleanupTask: Task<Void, Never>?
private var terminationDeadlineTask: Task<Void, Never>?
private var terminationCleanupFinished = false
private var profileInstanceLock: AppInstanceLock?
private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat")
var nodeTerminationCleanup: @MainActor () async -> Void = {
await TalkMLXSpeechSynthesizer.shared.shutdown()
@@ -386,7 +415,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
var openDashboardAction: @MainActor () -> Void = { AppNavigationActions.openDashboard() }
let updaterController: UpdaterProviding = makeUpdaterController()
let updaterController: UpdaterProviding
override init() {
let environment = ProcessInfo.processInfo.environment
let hasReplacementMetadata = ApplicationRelocator.hasReplacementHandoffMetadata(
environment: environment)
let isReplacementHandoff = hasReplacementMetadata &&
ApplicationRelocator.acceptReplacementHandoff(environment: environment)
if hasReplacementMetadata, !isReplacementHandoff {
fputs("OpenClaw replacement handoff authentication failed.\n", stderr)
Darwin.exit(2)
}
let ownership = AppInstanceLock.acquire(
url: AppProfile.current.instanceLockURL(),
waitMilliseconds: isReplacementHandoff ? 5000 : 0)
if let exitCode = Self.processExitCode(for: ownership) {
fputs("OpenClaw profile is already running.\n", stderr)
Darwin.exit(exitCode)
}
var profileInstanceLock: AppInstanceLock?
var instanceOwnershipFailure: String?
switch ownership {
case let .acquired(lock):
profileInstanceLock = lock
case .busy:
break
case let .failed(message):
instanceOwnershipFailure = message
}
self.profileInstanceLock = profileInstanceLock
self.updaterController = instanceOwnershipFailure == nil
? makeUpdaterController()
: DisabledUpdaterController()
super.init()
if let instanceOwnershipFailure {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "OpenClaw could not claim its instance lock"
alert.informativeText = instanceOwnershipFailure
alert.runModal()
Darwin.exit(2)
}
}
static func processExitCode(for ownership: AppInstanceLockAcquisition) -> Int32? {
if case .busy = ownership { return 0 }
return nil
}
func applicationWillFinishLaunching(_: Notification) {
// URL/reopen callbacks can create the dashboard before didFinishLaunching.
@@ -471,34 +547,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}
#endif
let environment = ProcessInfo.processInfo.environment
let launchPolicy = AppLaunchPresentationPolicy.current
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() {
if launchPolicy.allowsAutomaticPresentation {
NSWorkspace.shared.open(Self.dashboardURL)
}
NSApp.terminate(nil)
return
}
switch ApplicationRelocator.handleLaunch() {
case .terminating:
return
case let .continueLaunch(startUpdater):
if startUpdater {
if OpenClawConfigFile.gatewayUpdateChannel() == nil {
self.updaterController.startAfterResolvingGatewayUpdateChannel()
} else {
self.updaterController.start()
if !AppProfile.current.isActive {
switch ApplicationRelocator.handleLaunch() {
case .terminating:
return
case let .continueLaunch(startUpdater):
if startUpdater {
if OpenClawConfigFile.gatewayUpdateChannel() == nil {
self.updaterController.startAfterResolvingGatewayUpdateChannel()
} else {
self.updaterController.start()
}
}
}
}
@@ -741,7 +801,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
expectedConnectionMode: AppState.ConnectionMode,
expectedRouteIdentity: String?)
{
let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey)
let seenVersion = AppDefaults.standard.integer(forKey: onboardingVersionKey)
let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen
guard shouldShow else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
@@ -756,11 +816,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
OnboardingController.shared.show()
}
}
}
private func isDuplicateInstance() -> Bool {
guard let bundleID = Bundle.main.bundleIdentifier else { return false }
let running = NSWorkspace.shared.runningApplications.filter { $0.bundleIdentifier == bundleID }
return running.count > 1
enum AppInstanceLockAcquisition {
case acquired(AppInstanceLock)
case busy
case failed(String)
}
final class AppInstanceLock {
/// Keep the descriptor open for the process lifetime. Never unlink the path:
/// another opener could then lock a different inode and admit a duplicate.
private let descriptor: Int32
private init(descriptor: Int32) {
self.descriptor = descriptor
}
static func acquire(url: URL, waitMilliseconds: Int = 0) -> AppInstanceLockAcquisition {
if let error = self.preparePrivateStateRoot(url.deletingLastPathComponent()) {
return .failed(error)
}
let descriptor = Darwin.open(url.path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600)
guard descriptor >= 0 else { return .failed(String(cString: strerror(errno))) }
var status = stat()
guard fstat(descriptor, &status) == 0,
status.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
status.st_uid == geteuid()
else {
Darwin.close(descriptor)
return .failed("Instance lock is not a safe file owned by the current user.")
}
_ = fchmod(descriptor, 0o600)
let deadline = DispatchTime.now() + .milliseconds(max(0, waitMilliseconds))
while flock(descriptor, LOCK_EX | LOCK_NB) != 0 {
guard errno == EWOULDBLOCK, DispatchTime.now() < deadline else {
let result: AppInstanceLockAcquisition = errno == EWOULDBLOCK
? .busy
: .failed(String(cString: strerror(errno)))
Darwin.close(descriptor)
return result
}
usleep(50000)
}
return .acquired(AppInstanceLock(descriptor: descriptor))
}
private static func preparePrivateStateRoot(_ root: URL) -> String? {
var status = stat()
if lstat(root.path, &status) != 0 {
guard errno == ENOENT else { return String(cString: strerror(errno)) }
guard mkdir(root.path, 0o700) == 0 else { return String(cString: strerror(errno)) }
guard lstat(root.path, &status) == 0 else { return String(cString: strerror(errno)) }
}
guard status.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR),
status.st_uid == geteuid(),
status.st_mode & 0o777 == 0o700
else {
return "App profile state directory must be an owner-only 0700 directory."
}
return nil
}
deinit {
_ = flock(self.descriptor, LOCK_UN)
Darwin.close(self.descriptor)
}
}
@@ -991,11 +1111,14 @@ private func isDeveloperIDSigned(bundleURL: URL) -> Bool {
@MainActor
private func makeUpdaterController() -> UpdaterProviding {
guard AppProfile.current.validationError == nil, !AppProfile.current.isActive else {
return DisabledUpdaterController()
}
let bundleURL = Bundle.main.bundleURL
let isBundledApp = bundleURL.pathExtension == "app"
guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() }
let defaults = UserDefaults.standard
let defaults = AppDefaults.standard
let autoUpdateKey = "autoUpdateEnabled"
// Default to true; honor the user's last choice otherwise.
let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true
@@ -24,9 +24,11 @@ struct MenuContent: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var browserControlEnabled = true
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = Logger.Level.info.rawValue
@AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false
@AppStorage(cameraEnabledKey, store: AppDefaults.standard) private var cameraEnabled: Bool = false
@AppStorage(appLogLevelKey, store: AppDefaults.standard)
private var appLogLevelRaw: String = Logger.Level.info.rawValue
@AppStorage(debugFileLogEnabledKey, store: AppDefaults.standard)
private var appFileLoggingEnabled: Bool = false
init(state: AppState, updater: UpdaterProviding?) {
self._state = Bindable(wrappedValue: state)
@@ -387,6 +389,11 @@ struct MenuContent: View {
}
private var healthStatus: (label: String, color: Color) {
if self.state.connectionMode == .local,
let failure = GatewayProcessManager.shared.lastFailureReason
{
return (failure, .red)
}
if self.state.connectionMode == .remote {
let live = GatewayConnectionPresentation(state: self.controlChannel.state)
switch live.tone {
@@ -216,6 +216,13 @@ extension MenuSessionsInjector {
cursor += 1
}
if let notice = self.nodesStore.persistentServiceNotice {
menu.insertItem(
self.makeMessageItem(text: notice, symbolName: "info.circle", width: width),
at: cursor)
cursor += 1
}
if case .connecting = ControlChannel.shared.state {
menu.insertItem(
self.makeMessageItem(text: "Connecting…", symbolName: "circle.dashed", width: width),
@@ -289,6 +296,7 @@ extension MenuSessionsInjector {
return self.sortedNodeEntries().isEmpty
|| self.nodesStore.lastError?.nonEmpty != nil
|| self.nodesStore.statusMessage?.nonEmpty != nil
|| self.nodesStore.persistentServiceNotice != nil
}
private func buildContextSubmenu(
@@ -65,13 +65,13 @@ final class MacNodeModeCoordinator: NSObject {
static let shared = MacNodeModeCoordinator()
static var nodeIdentityProfile: GatewayDeviceIdentityProfile {
self.resolveNodeIdentityProfile(
defaults: .standard,
defaults: AppDefaults.standard,
isExistingInstallation: AppStateStore.shared.onboardingSeen)
}
static func prepareNodeIdentityProfile(isExistingInstallation: Bool) {
_ = self.resolveNodeIdentityProfile(
defaults: .standard,
defaults: AppDefaults.standard,
isExistingInstallation: isExistingInstallation)
}
@@ -166,7 +166,7 @@ final class MacNodeModeCoordinator: NSObject {
self.nodeHostWorkerRetryPolicy = nodeHostWorkerRetryPolicy
self.refreshEvents = refreshEvents.stream
self.refreshContinuation = refreshEvents.continuation
self.lastObservedPaused = initialPaused ?? UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.lastObservedPaused = initialPaused ?? AppDefaults.standard.bool(forKey: pauseDefaultsKey)
self.lastObservedComputerControlEnabled = initialComputerControlEnabled ??
isComputerControlEnabled()
super.init()
@@ -176,7 +176,7 @@ final class MacNodeModeCoordinator: NSObject {
self,
selector: #selector(self.refreshNodeConfiguration),
name: UserDefaults.didChangeNotification,
object: UserDefaults.standard)
object: AppDefaults.standard)
self.notificationCenter.addObserver(
self,
selector: #selector(self.refreshNodeConfiguration),
@@ -273,7 +273,7 @@ final class MacNodeModeCoordinator: NSObject {
func refresh() {
self.refresh(
isPaused: UserDefaults.standard.bool(forKey: pauseDefaultsKey),
isPaused: AppDefaults.standard.bool(forKey: pauseDefaultsKey),
computerControlEnabled: isComputerControlEnabled())
}
@@ -389,7 +389,7 @@ final class MacNodeModeCoordinator: NSObject {
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var refreshIterator = self.refreshEvents.makeAsyncIterator()
let defaults = UserDefaults.standard
let defaults = AppDefaults.standard
while !Task.isCancelled {
// A stop/refresh immediately followed by start/unpause must not install
@@ -815,7 +815,7 @@ final class MacNodeModeCoordinator: NSObject {
codexThreadCatalogEnabled: Bool,
claudeSessionCatalogEnabled: Bool) -> [String]
{
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
let rawLocationMode = AppDefaults.standard.string(forKey: locationModeKey) ?? "off"
let computerControlEnabled = isComputerControlEnabled()
return Self.resolvedCaps(
browserControlEnabled: browserControlEnabled,
@@ -848,7 +848,8 @@ final class MacNodeModeCoordinator: NSObject {
} else {
switch await CLIInstaller.status() {
case let .ready(location, _):
launch = MacNodeHostWorkerLaunch(command: [location, "node", "worker"])
launch = MacNodeHostWorkerLaunch(command: CommandResolver.nodeHostWorkerCommand(
prefix: [location]))
case let status:
throw MacNodeHostWorker.WorkerError.unavailable(status.message)
}
@@ -49,7 +49,7 @@ final class MacNodePresenceReporter {
private let idleSecondsProvider: IdleSecondsProvider
init(
reportingEnabled: Bool = UserDefaults.standard.bool(forKey: activeComputerPresenceEnabledKey),
reportingEnabled: Bool = AppDefaults.standard.bool(forKey: activeComputerPresenceEnabledKey),
idleSecondsProvider: @escaping IdleSecondsProvider = {
guard AXIsProcessTrusted() else { return nil }
return SystemPresenceInfo.lastHardwareInputSeconds()
@@ -980,11 +980,11 @@ extension MacNodeRuntime {
}
private nonisolated static func canvasEnabled() -> Bool {
UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
AppDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
}
private nonisolated static func cameraEnabled() -> Bool {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
AppDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
nonisolated static func computerControlEnabledDefault() -> Bool {
@@ -992,15 +992,15 @@ extension MacNodeRuntime {
}
private nonisolated static func locationMode() -> OpenClawLocationMode {
let raw = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
let raw = AppDefaults.standard.string(forKey: locationModeKey) ?? "off"
return OpenClawLocationMode(rawValue: raw) ?? .off
}
private nonisolated static func locationPreciseEnabled() -> Bool {
if UserDefaults.standard.object(forKey: locationPreciseKey) == nil {
if AppDefaults.standard.object(forKey: locationPreciseKey) == nil {
return true
}
return UserDefaults.standard.bool(forKey: locationPreciseKey)
return AppDefaults.standard.bool(forKey: locationPreciseKey)
}
private static func errorResponse(
@@ -8,7 +8,8 @@ enum NodeServiceManager {
.appendingPathComponent("Library/LaunchAgents/\(nodeLaunchdLabel).plist")
}
static func start() async -> String? {
static func start(profile: AppProfile = .current) async -> String? {
if self.skipUnderProfile(profile, action: "start") { return nil }
let result = await self.runServiceCommandResult(
["start"],
timeout: 20,
@@ -20,7 +21,8 @@ enum NodeServiceManager {
return nil
}
static func stop() async -> String? {
static func stop(profile: AppProfile = .current) async -> String? {
if self.skipUnderProfile(profile, action: "stop") { return nil }
let result = await self.runServiceCommandResult(
["stop"],
timeout: 15,
@@ -32,7 +34,8 @@ enum NodeServiceManager {
return nil
}
static func restart() async -> String? {
static func restart(profile: AppProfile = .current) async -> String? {
if self.skipUnderProfile(profile, action: "restart") { return nil }
let result = await self.runServiceCommandResult(
["restart"],
timeout: 20,
@@ -46,13 +49,15 @@ enum NodeServiceManager {
/// Empty means no node LaunchAgent. Nil means the on-disk ownership proof
/// exists but could not be read, so callers must not treat it as external.
static func launchdProgramArguments() -> [String]? {
self.launchdProgramArguments(
static func launchdProgramArguments(profile: AppProfile = .current) -> [String]? {
if self.skipUnderProfile(profile, action: "status") { return [] }
return self.launchdProgramArguments(
plistURL: self.launchdPlistURL,
fileManager: .default)
}
static func waitUntilRunning() async -> Bool {
static func waitUntilRunning(profile: AppProfile = .current) async -> Bool {
if self.skipUnderProfile(profile, action: "status poll") { return false }
var consecutiveRunningChecks = 0
for attempt in 0..<20 {
let result = await self.runServiceCommandResult(
@@ -77,6 +82,12 @@ enum NodeServiceManager {
}
extension NodeServiceManager {
private static func skipUnderProfile(_ profile: AppProfile, action: String) -> Bool {
guard profile.isActive else { return false }
self.logger.info("node service \(action, privacy: .public) skipped (unavailable under app profile)")
return true
}
private static func serviceCommand(_ args: [String]) async -> [String] {
await CommandResolver.openclawCommand(
subcommand: "node",
@@ -107,6 +118,9 @@ extension NodeServiceManager {
timeout: Double,
quiet: Bool) async -> CommandResult
{
#if DEBUG
self.testingServiceCommandCalls.append(args)
#endif
let command = await self.serviceCommand(args)
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
@@ -187,6 +201,9 @@ extension NodeServiceManager {
plistURL: URL,
fileManager: FileManager) -> [String]?
{
#if DEBUG
self.testingOwnershipReadCount += 1
#endif
guard fileManager.fileExists(atPath: plistURL.path) else { return [] }
return LaunchAgentPlist.snapshot(url: plistURL)?.programArguments
}
@@ -206,6 +223,18 @@ extension NodeServiceManager {
#if DEBUG
extension NodeServiceManager {
private nonisolated(unsafe) static var testingServiceCommandCalls: [[String]] = []
private nonisolated(unsafe) static var testingOwnershipReadCount = 0
static func _testResetPersistentServiceCalls() {
self.testingServiceCommandCalls = []
self.testingOwnershipReadCount = 0
}
static func _testPersistentServiceCallSnapshot() -> (commands: [[String]], ownershipReads: Int) {
(self.testingServiceCommandCalls, self.testingOwnershipReadCount)
}
static func _testServiceCommand(_ args: [String]) async -> [String] {
await self.serviceCommand(args)
}
@@ -51,6 +51,7 @@ final class NodesStore {
var nodes: [NodeInfo] = []
var lastError: String?
var statusMessage: String?
let persistentServiceNotice: String?
var isLoading = false
private(set) var localNodeIdentityState: LocalNodeIdentityState = .loading
@@ -65,11 +66,15 @@ final class NodesStore {
@ObservationIgnored private var localNodeIdentityPreparationTask: Task<Void, Never>?
init(
appProfile: AppProfile = .current,
localNodeIdentityProfile: GatewayDeviceIdentityProfile = MacNodeModeCoordinator.nodeIdentityProfile,
localNodeIDLoader: @escaping @Sendable (GatewayDeviceIdentityProfile) -> String? = { profile in
DeviceIdentityStore.loadOrCreatePersisted(profile: profile)?.deviceId
})
{
self.persistentServiceNotice = appProfile.isActive
? "Persistent Mac node service unavailable under app profile; runtime node remains available."
: nil
self.localNodeIdentityProfile = localNodeIdentityProfile
self.localNodeIDLoader = localNodeIDLoader
}
+13 -13
View File
@@ -151,7 +151,7 @@ enum OnboardingSystemAgentResumeStore {
static func isPending(
for routeIdentity: String?,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date()) -> Bool
{
self.pendingState(for: routeIdentity, defaults: defaults, now: now) != .none
@@ -162,7 +162,7 @@ enum OnboardingSystemAgentResumeStore {
routeIdentity: String?,
activationOwner: ActivationOwner? = nil,
activationTimeoutMs: Double = OnboardingSystemAgentResumeStore.maximumActivationTimeoutMs,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date())
-> Date?
{
@@ -183,7 +183,7 @@ enum OnboardingSystemAgentResumeStore {
routeIdentity: String,
activationOwner: ActivationOwner? = nil,
deadline: Date,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date())
{
guard let routeIdentity = normalized(routeIdentity) else { return }
@@ -199,7 +199,7 @@ enum OnboardingSystemAgentResumeStore {
static func markVerified(
ifOwnedBy routeIdentity: String?,
activationOwner: ActivationOwner? = nil,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date())
{
guard let routeIdentity = normalized(routeIdentity) else { return }
@@ -219,7 +219,7 @@ enum OnboardingSystemAgentResumeStore {
static func markCompleted(
ifOwnedBy routeIdentity: String?,
activationOwner: ActivationOwner? = nil,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date()) -> Bool
{
guard let routeIdentity = normalized(routeIdentity) else { return false }
@@ -238,7 +238,7 @@ enum OnboardingSystemAgentResumeStore {
static func activationOwner(
for routeIdentity: String?,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date()) -> ActivationOwner?
{
guard let routeIdentity = normalized(routeIdentity) else { return nil }
@@ -248,7 +248,7 @@ enum OnboardingSystemAgentResumeStore {
static func isOwned(
by activationOwner: ActivationOwner,
for routeIdentity: String?,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date()) -> Bool
{
guard let routeIdentity = normalized(routeIdentity),
@@ -259,7 +259,7 @@ enum OnboardingSystemAgentResumeStore {
static func pendingState(
for routeIdentity: String?,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date()) -> PendingState
{
guard let routeIdentity = normalized(routeIdentity),
@@ -282,7 +282,7 @@ enum OnboardingSystemAgentResumeStore {
static func clear(
ifOwnedBy routeIdentity: String,
activationOwner: ActivationOwner? = nil,
defaults: UserDefaults = .standard) -> Bool
defaults: UserDefaults = AppDefaults.standard) -> Bool
{
guard let routeIdentity = normalized(routeIdentity) else { return false }
var records = self.loadRecords(defaults: defaults)
@@ -294,7 +294,7 @@ enum OnboardingSystemAgentResumeStore {
return true
}
static func clear(defaults: UserDefaults = .standard) {
static func clear(defaults: UserDefaults = AppDefaults.standard) {
defaults.removeObject(forKey: onboardingSystemAgentPendingKey)
defaults.removeObject(forKey: onboardingSystemAgentPendingRetiredKey)
}
@@ -537,8 +537,8 @@ final class OnboardingController: NSObject, NSWindowDelegate {
var busyReason: String?
static func markComplete() {
UserDefaults.standard.set(true, forKey: onboardingSeenKey)
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
AppDefaults.standard.set(true, forKey: onboardingSeenKey)
AppDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
AppStateStore.shared.onboardingSeen = true
DashboardManager.shared.handleOnboardingCompletion()
}
@@ -827,7 +827,7 @@ struct OnboardingView: View {
localDisplayName: InstanceIdentity.displayName,
filterLocalGateways: false),
aiSetupGateway: GatewayConnection = .shared,
systemAgentDefaults: UserDefaults = .standard,
systemAgentDefaults: UserDefaults = AppDefaults.standard,
aiSetupRouteIdentityProvider: (@MainActor () -> String?)? = nil,
configuredGatewayProbeTimeoutMs: Double = 15000,
gatewaySelectionPersister: (@MainActor () -> Bool)? = nil,
@@ -110,7 +110,7 @@ final class OnboardingAISetupModel {
init(
gateway: GatewayConnection = .shared,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
routeIdentityProvider: @escaping @MainActor () -> String? = {
OnboardingSystemAgentResumeStore.selectedRouteIdentity()
},
@@ -1047,8 +1047,12 @@ extension OnboardingView {
self.openSettings(tab: .skills)
}
self.skillsOverview
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
.disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin)
if AppProfile.current.isActive {
LabeledContent("Launch at login", value: "Unavailable under profile")
} else {
Toggle("Launch at login", isOn: self.$state.launchAtLogin)
.disabled(!self.state.bundleLocationAllowsPersistentIntegration && !self.state.launchAtLogin)
}
}
}
.task { await self.maybeLoadOnboardingSkills() }
@@ -23,8 +23,7 @@ enum OpenClawPaths {
return URL(fileURLWithPath: override, isDirectory: true)
}
}
let home = FileManager().homeDirectoryForCurrentUser
return home.appendingPathComponent(".openclaw", isDirectory: true)
return AppProfile.current.stateDirectoryURL()
}
private static func resolveConfigCandidate(in dir: URL) -> URL? {
@@ -93,8 +93,9 @@ struct PermissionsSettings: View {
private struct LocationAccessSettings: View {
private static let controlWidth: CGFloat = 180
@AppStorage(locationModeKey) private var locationModeRaw: String = OpenClawLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
@AppStorage(locationModeKey, store: AppDefaults.standard)
private var locationModeRaw: String = OpenClawLocationMode.off.rawValue
@AppStorage(locationPreciseKey, store: AppDefaults.standard) private var locationPreciseEnabled: Bool = true
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
var body: some View {
@@ -97,6 +97,12 @@ actor PortGuardian {
self.logger.warning(message)
continue
}
if AppProfile.current.isActive {
self.logger.error(
"profile port \(port, privacy: .public) held by \(listener.command, privacy: .public) " +
"(pid \(listener.pid, privacy: .public)); preserving conflict")
continue
}
if await Self.terminateProcess(listener.pid) {
let message = """
port \(port) was held by \(listener.command)
+14 -8
View File
@@ -61,7 +61,7 @@ enum PostAppUpdateReceiptStore {
static func record(
fromVersion: String,
toVersion: String,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date())
{
let from = fromVersion.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -73,7 +73,7 @@ enum PostAppUpdateReceiptStore {
static func pending(
currentVersion: String?,
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt?
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt?
{
guard let currentVersion = normalized(currentVersion),
let data = defaults.data(forKey: postAppUpdateReceiptKey),
@@ -87,7 +87,7 @@ enum PostAppUpdateReceiptStore {
currentVersion: String?,
onboardingSeen: Bool,
allowsUpdateWorkflow: Bool = true,
defaults: UserDefaults = .standard,
defaults: UserDefaults = AppDefaults.standard,
now: Date = Date()) -> PostAppUpdateReceipt?
{
guard let currentVersion = normalized(currentVersion) else { return nil }
@@ -116,7 +116,7 @@ enum PostAppUpdateReceiptStore {
return receipt
}
static func clear(defaults: UserDefaults = .standard) {
static func clear(defaults: UserDefaults = AppDefaults.standard) {
defaults.removeObject(forKey: postAppUpdateReceiptKey)
}
@@ -124,7 +124,7 @@ enum PostAppUpdateReceiptStore {
static func setGatewayUpdateIncomplete(
_ incomplete: Bool,
receipt: PostAppUpdateReceipt,
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt
{
let updated = PostAppUpdateReceipt(
fromVersion: receipt.fromVersion,
@@ -140,7 +140,7 @@ enum PostAppUpdateReceiptStore {
@discardableResult
static func recordNotificationFailure(
receipt: PostAppUpdateReceipt,
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt
{
// One later-launch retry handles restart races. The bound prevents
// permanent auth/schema errors from reopening this window forever.
@@ -159,7 +159,7 @@ enum PostAppUpdateReceiptStore {
static func setNotificationInFlight(
_ inFlight: Bool,
receipt: PostAppUpdateReceipt,
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
defaults: UserDefaults = AppDefaults.standard) -> PostAppUpdateReceipt
{
let updated = PostAppUpdateReceipt(
fromVersion: receipt.fromVersion,
@@ -266,7 +266,8 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
private var task: Task<Void, Never>?
@discardableResult
func startIfNeeded() -> Bool {
func startIfNeeded(profile: AppProfile = .current) -> Bool {
guard !profile.isActive else { return false }
guard let receipt = PostAppUpdateReceiptStore.pendingForLaunch(
currentVersion: GatewayEnvironment.appVersionString(),
onboardingSeen: AppStateStore.shared.onboardingSeen,
@@ -341,6 +342,10 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
}
private func finishUpdate(receipt: PostAppUpdateReceipt) async {
guard !AppProfile.current.isActive else {
self.finishSilently()
return
}
let connectionMode = AppStateStore.shared.connectionMode
guard CLIInstallPrompter.shouldManageCLI(connectionMode: connectionMode) else {
self.finishSilently()
@@ -549,6 +554,7 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
}
private func verifyRuntime(connectionMode: AppState.ConnectionMode) async -> Bool {
guard !AppProfile.current.isActive else { return true }
guard case .ready = await CLIInstaller.managedStatus() else {
self.fail(
message: String(localized: "Gateway verification failed."),
@@ -14,14 +14,14 @@ extension ProcessInfo {
}
static func resolveStableNixSuite(bundleIdentifier: String?, isAppBundle: Bool) -> UserDefaults? {
// The app's own defaults domain is already represented by UserDefaults.standard.
// The app's own defaults domain is already represented by AppDefaults.standard.
// Passing that same identifier to suiteName triggers Foundation's nonsensical-suite warning.
guard isAppBundle, bundleIdentifier != launchdLabel else { return nil }
return UserDefaults(suiteName: launchdLabel)
}
/// Nix deployments may write defaults into a stable suite (`ai.openclaw.mac`) even if the shipped
/// app bundle identifier changes (and therefore `UserDefaults.standard` domain changes).
/// app bundle identifier changes (and therefore `AppDefaults.standard` domain changes).
static func resolveNixMode(
environment: [String: String],
standard: UserDefaults,
@@ -45,7 +45,7 @@ extension ProcessInfo {
isAppBundle: isAppBundle)
return Self.resolveNixMode(
environment: self.environment,
standard: .standard,
standard: AppDefaults.standard,
stableSuite: stableSuite,
isAppBundle: isAppBundle)
}
@@ -0,0 +1,58 @@
import Darwin
import Foundation
final class ProfileGatewayPortReservation: @unchecked Sendable {
let port: Int
let conflict: String?
private let lock: AppInstanceLock?
private init(port: Int, conflict: String?, lock: AppInstanceLock?) {
self.port = port
self.conflict = conflict
self.lock = lock
}
static func acquire(
profile: AppProfile,
port: Int,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
temporaryDirectory: URL = URL(fileURLWithPath: "/tmp", isDirectory: true)) -> Self
{
guard let profileName = profile.name else { return Self(port: port, conflict: nil, lock: nil) }
let lockURL = temporaryDirectory
.appendingPathComponent("openclaw-\(geteuid())-app-profile-ports", isDirectory: true)
.appendingPathComponent("\(port).lock")
let lock: AppInstanceLock
switch AppInstanceLock.acquire(url: lockURL) {
case let .acquired(acquired): lock = acquired
case .busy:
return self.conflict(
profile: profileName,
port: port,
reason: "another running OpenClaw profile already reserves it")
case let .failed(reason):
return self.conflict(
profile: profileName,
port: port,
reason: "the profile reservation could not be verified (\(reason))")
}
if let reason = GatewayLaunchAgentManager.conflictingProfileClaimOwner(
port: port,
excludingLabel: profile.gatewayLaunchAgentLabel,
homeDirectory: homeDirectory)
{
return self.conflict(profile: profileName, port: port, reason: reason)
}
return Self(port: port, conflict: nil, lock: lock)
}
private static func conflict(profile: String, port: Int, reason: String) -> Self {
let message = "Profile \"\(profile)\" cannot use Gateway port \(port) because \(reason). " +
"Set gateway.port to a free port for this profile, or stop/uninstall the other Gateway."
return Self(
port: port,
conflict: message,
lock: nil)
}
}
@@ -3,6 +3,6 @@ import KeyboardShortcuts
extension KeyboardShortcuts.Name {
/// KeyboardShortcuts owns UserDefaults persistence for the recorded chord.
static let toggleQuickChat = Self(
"toggleQuickChat",
AppProfile.current.name.map { "toggleQuickChat-\($0)" } ?? "toggleQuickChat",
initial: .init(.space, modifiers: [.option]))
}
@@ -56,6 +56,7 @@ struct SettingsRootView: View {
self.detailContainer
}
.navigationSplitViewStyle(.balanced)
.defaultAppStorage(AppDefaults.standard)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in
@@ -154,10 +154,10 @@ final class WebChatManager {
AppNavigationActions.openSettings(tab: .gateways)
return
}
let preferredID = UserDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey)
let preferredID = AppDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey)
switch Self.promptForGatewayProfile(profiles: profiles, preferredID: preferredID) {
case let .profile(profile):
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
AppDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
try await self.show(profile: profile)
case .manage:
AppNavigationActions.openSettings(tab: .gateways)
@@ -173,7 +173,7 @@ final class WebChatManager {
func openGatewayWindow(profile: MacGatewayProfile) {
Task { @MainActor [weak self] in
do {
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
AppDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
try await self?.show(profile: profile)
} catch {
Self.showProfileError(error, message: "Could Not Open Gateway Window")
@@ -17,7 +17,7 @@ private enum WebChatSwiftUILayout {
}
enum WebChatTracePreferences {
static func displayOptions(defaults: UserDefaults = .standard) -> OpenClawChatDisplayOptions {
static func displayOptions(defaults: UserDefaults = AppDefaults.standard) -> OpenClawChatDisplayOptions {
if let legacyValue = defaults.object(
forKey: OpenClawChatWindowShell.assistantTraceDefaultsKey) as? Bool
{
@@ -841,9 +841,9 @@ private struct MacChatSurface: View {
@State private var appState = AppStateStore.shared
@State private var talkController = TalkModeController.shared
@State private var audioInputCatalog = MacChatAudioInputCatalog()
@AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey)
@AppStorage(OpenClawChatWindowShell.assistantReasoningDefaultsKey, store: AppDefaults.standard)
private var showsReasoning = WebChatTracePreferences.displayOptions().contains(.reasoning)
@AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey)
@AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey, store: AppDefaults.standard)
private var showsToolActivity = WebChatTracePreferences.displayOptions().contains(.toolActivity)
private let userAccent: Color?
@@ -1110,9 +1110,9 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
},
onThinkingPreferenceChanged: { level in
if let level {
UserDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
AppDefaults.standard.set(level, forKey: webChatThinkingLevelDefaultsKey)
} else {
UserDefaults.standard.removeObject(forKey: webChatThinkingLevelDefaultsKey)
AppDefaults.standard.removeObject(forKey: webChatThinkingLevelDefaultsKey)
}
},
onVerbosePreferenceChanged: { level in
@@ -1221,7 +1221,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
onClosed?()
}
static func persistedThinkingLevel(defaults: UserDefaults = .standard) -> String? {
static func persistedThinkingLevel(defaults: UserDefaults = AppDefaults.standard) -> String? {
let stored = defaults.string(forKey: webChatThinkingLevelDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
@@ -1233,14 +1233,14 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
return stored
}
static func persistedVerboseLevel(defaults: UserDefaults = .standard) -> String? {
static func persistedVerboseLevel(defaults: UserDefaults = AppDefaults.standard) -> String? {
let stored = defaults.string(forKey: webChatVerboseLevelDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return OpenClawChatViewModel.verboseLevelOptions.contains(stored ?? "") ? stored : nil
}
static func persistVerbosePreference(_ level: String?, defaults: UserDefaults = .standard) {
static func persistVerbosePreference(_ level: String?, defaults: UserDefaults = AppDefaults.standard) {
if let level {
defaults.set(level, forKey: webChatVerboseLevelDefaultsKey)
} else {
@@ -0,0 +1,73 @@
import Foundation
import Testing
struct AppProfileSourceInvariantTests {
@Test func `profile persistence has one defaults and gateway-label owner`() throws {
let macRoot = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceRoot = macRoot.appendingPathComponent("Sources/OpenClaw", isDirectory: true)
let files = try #require(FileManager.default.enumerator(
at: sourceRoot,
includingPropertiesForKeys: nil)?.allObjects as? [URL])
.filter { $0.pathExtension == "swift" }
.sorted { $0.path < $1.path }
var directStandardOwners: [String] = []
var hardcodedGatewayLabelOwners: [String] = []
var unscopedAppStorageOwners: [String] = []
for file in files {
let source = try String(contentsOf: file, encoding: .utf8)
if source.contains("UserDefaults.standard") {
directStandardOwners.append(file.lastPathComponent)
}
if source.contains("\"ai.openclaw.gateway\"") {
hardcodedGatewayLabelOwners.append(file.lastPathComponent)
}
if source.split(separator: "\n").contains(where: {
$0.contains("@AppStorage(") && !$0.contains("store: AppDefaults.standard")
}) {
unscopedAppStorageOwners.append(file.lastPathComponent)
}
#expect(!source.contains("UserDefaults = .standard"), "Unscoped defaults in \(file.path)")
}
#expect(directStandardOwners == ["AppProfile.swift"])
#expect(hardcodedGatewayLabelOwners == ["AppProfile.swift"])
#expect(unscopedAppStorageOwners.sorted() == ["DebugSettings.swift", "GeneralSettings.swift"])
let settingsRoot = try String(
contentsOf: sourceRoot.appendingPathComponent("SettingsRootView.swift"),
encoding: .utf8)
#expect(settingsRoot.contains(".defaultAppStorage(AppDefaults.standard)"))
let menuBar = try String(
contentsOf: sourceRoot.appendingPathComponent("MenuBar.swift"),
encoding: .utf8)
let delegateInit = try #require(menuBar.range(of: "override init()"))
let ownershipGate = try #require(menuBar.range(
of: "AppInstanceLock.acquire",
range: delegateInit.lowerBound..<menuBar.endIndex))
let updaterConstruction = try #require(menuBar.range(
of: "? makeUpdaterController()",
range: ownershipGate.lowerBound..<menuBar.endIndex))
#expect(ownershipGate.lowerBound < updaterConstruction.lowerBound)
#expect(menuBar.contains("if let exitCode = Self.processExitCode(for: ownership)"))
#expect(menuBar.contains("Darwin.exit(exitCode)"))
#expect(!menuBar.contains("@State private var tailscaleService = TailscaleService.shared"))
let gatewayManager = try String(
contentsOf: sourceRoot.appendingPathComponent("GatewayProcessManager.swift"),
encoding: .utf8)
#expect(gatewayManager.components(separatedBy: "profileOwnsGateway(").count - 1 >= 5)
let portGuardian = try String(
contentsOf: sourceRoot.appendingPathComponent("PortGuardian.swift"),
encoding: .utf8)
let profilePreserve = try #require(portGuardian.range(of: "if AppProfile.current.isActive"))
let firstTerminate = try #require(portGuardian.range(
of: "terminateProcess",
range: profilePreserve.lowerBound..<portGuardian.endIndex))
#expect(profilePreserve.lowerBound < firstTerminate.lowerBound)
}
}
@@ -0,0 +1,273 @@
import Darwin
import Foundation
import Testing
@testable import OpenClaw
@testable import OpenClawKit
@Suite(.serialized)
struct AppProfileTests {
@Test func `default profile preserves historical identities`() {
if AppProfile.current.name == nil, AppProfile.current.validationError == nil {
#expect(AppDefaults.standard === UserDefaults.standard)
}
for raw in [nil, "", " default ", "Default"] {
let profile = AppProfile(environment: raw.map { ["OPENCLAW_PROFILE": $0] } ?? [:])
#expect(profile.name == nil)
#expect(profile.validationError == nil)
#expect(profile.gatewayLaunchAgentLabel == "ai.openclaw.gateway")
#expect(profile.defaultsSuiteName == nil)
#expect(profile.keychainService(base: "ai.openclaw.test") == "ai.openclaw.test")
#expect(GatewayTLSStore.resolvedKeychainService(suffix: profile.keychainServiceSuffix) ==
"ai.openclaw.tls-pinning")
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")).path ==
"/Users/test/.openclaw")
#expect(profile.cliRootArguments.isEmpty)
}
#if DEBUG
#expect(MacGatewayProfileStore.service == "ai.openclaw.gateway-profiles.debug")
#expect(GatewayActivationBindingKeyStore.service == "ai.openclaw.onboarding-route-binding.debug")
#else
#expect(MacGatewayProfileStore.service == "ai.openclaw.gateway-profiles")
#expect(GatewayActivationBindingKeyStore.service == "ai.openclaw.onboarding-route-binding")
#endif
}
@Test func `named profile owns every persistent namespace`() {
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work_2"])
#expect(profile.name == "work_2")
#expect(profile.validationError == nil)
#expect(profile.gatewayLaunchAgentLabel == "ai.openclaw.work_2")
#expect(profile.defaultsSuiteName == "ai.openclaw.mac.profile.work_2")
#expect(profile.keychainService(base: "ai.openclaw.test") == "ai.openclaw.test.profile.work_2")
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")).path ==
"/Users/test/.openclaw-work_2")
#expect(profile.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")) !=
AppProfile(environment: ["OPENCLAW_PROFILE": "personal"])
.stateDirectoryURL(homeDirectory: URL(fileURLWithPath: "/Users/test")))
#expect(profile.cliRootArguments == ["--profile", "work_2"])
#expect(GatewayTLSStore.resolvedKeychainService(suffix: profile.keychainServiceSuffix) ==
"ai.openclaw.tls-pinning.profile.work_2")
#expect(CommandResolver.nodeHostWorkerCommand(
prefix: ["/usr/bin/node", "/repo/scripts/run-node.mjs"],
profile: profile) == [
"/usr/bin/node", "/repo/scripts/run-node.mjs", "--profile", "work_2", "node", "worker",
])
#expect(CommandResolver.nodeHostWorkerCommand(
prefix: ["/opt/openclaw"],
profile: profile) == [
"/opt/openclaw", "--profile", "work_2", "node", "worker",
])
}
@Test func `default worker commands preserve exact argument shapes`() {
let profile = AppProfile(environment: [:])
#expect(CommandResolver.nodeHostWorkerCommand(
prefix: ["/usr/bin/node", "/repo/scripts/run-node.mjs"],
profile: profile) == ["/usr/bin/node", "/repo/scripts/run-node.mjs", "node", "worker"])
#expect(CommandResolver.nodeHostWorkerCommand(
prefix: ["/opt/openclaw"],
profile: profile) == ["/opt/openclaw", "node", "worker"])
}
@Test func `invalid and colliding profile names fail closed`() {
let invalid = [
"_work", "work space", "work/escape", String(repeating: "a", count: 65),
"Work", "gateway", "mac", "node",
]
for raw in invalid {
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": raw])
#expect(profile.name == nil)
#expect(profile.validationError != nil)
}
#expect(AppProfile(environment: ["OPENCLAW_PROFILE": String(repeating: "a", count: 64)]).name != nil)
}
@Test func `colliding profile apps cannot reserve the same exact port`() throws {
let root = try self.makeReservationRoot()
defer { try? FileManager.default.removeItem(at: root) }
let home = root.appendingPathComponent("home", isDirectory: true)
try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
let first = ProfileGatewayPortReservation.acquire(
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p1402"]),
port: 55636,
homeDirectory: home,
temporaryDirectory: root)
let second = ProfileGatewayPortReservation.acquire(
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"]),
port: 55636,
homeDirectory: home,
temporaryDirectory: root)
#expect(first.conflict == nil)
#expect(second.conflict?.contains("p2380") == true)
#expect(second.conflict?.contains("reservation reserves") == false)
}
@Test func `persisted other profile gateway reserves its exact port`() throws {
let root = try self.makeReservationRoot()
defer { try? FileManager.default.removeItem(at: root) }
let home = root.appendingPathComponent("home", isDirectory: true)
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
let serviceEnv = home.appendingPathComponent(".openclaw-p1402/service-env", isDirectory: true)
try FileManager.default.createDirectory(at: serviceEnv, withIntermediateDirectories: true)
let wrapper = serviceEnv.appendingPathComponent("ai.openclaw.p1402-env-wrapper.sh")
let environment = serviceEnv.appendingPathComponent("ai.openclaw.p1402.env")
try Data("#!/bin/sh\n".utf8).write(to: wrapper)
try Data("""
export OPENCLAW_SERVICE_MARKER='openclaw'
export OPENCLAW_SERVICE_KIND='gateway'
""".utf8).write(to: environment)
let data = try PropertyListSerialization.data(
fromPropertyList: [
"ProgramArguments": [
"/bin/sh", wrapper.path, environment.path,
"node", "openclaw.mjs", "gateway", "--port", "55636",
],
],
format: .xml,
options: 0)
try data.write(to: agents.appendingPathComponent("ai.openclaw.p1402.plist"))
let reservation = ProfileGatewayPortReservation.acquire(
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"]),
port: 55636,
homeDirectory: home,
temporaryDirectory: root)
#expect(reservation.conflict?.contains("profile \"p1402\"") == true)
}
private func makeReservationRoot() throws -> URL {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("app-profile-port-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
return root
}
@Test func `profile launch at login reason outranks bundle location`() {
let profiled = LaunchAtLoginPresentation.resolve(
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]),
bundleLocationAllowsPersistentIntegration: false,
isEnabled: false)
#expect(profiled.subtitle == "Launch at login is unavailable while an app profile is active.")
#expect(profiled.isDisabled)
let defaultUnavailable = LaunchAtLoginPresentation.resolve(
profile: AppProfile(environment: [:]),
bundleLocationAllowsPersistentIntegration: false,
isEnabled: false)
#expect(defaultUnavailable.subtitle == "Move OpenClaw to Applications before enabling launch at login.")
#expect(defaultUnavailable.isDisabled)
let defaultAvailable = LaunchAtLoginPresentation.resolve(
profile: AppProfile(environment: [:]),
bundleLocationAllowsPersistentIntegration: true,
isEnabled: false)
#expect(defaultAvailable.subtitle == "Automatically start OpenClaw after you sign in.")
#expect(!defaultAvailable.isDisabled)
}
@MainActor @Test func `profile lock excludes only the same profile`() throws {
#expect(AppDelegate.processExitCode(for: .busy) == 0)
#expect(AppDelegate.processExitCode(for: .failed("test")) == nil)
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("app-profile-lock-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
let defaultProfile = AppProfile(environment: [:])
let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
let defaultURL = defaultProfile.instanceLockURL(systemTemporaryDirectory: root)
let namedURL = workProfile.instanceLockURL(systemTemporaryDirectory: root)
#expect(defaultURL != namedURL)
#expect(workProfile.instanceLockURL(systemTemporaryDirectory: root) ==
AppProfile(environment: [
"OPENCLAW_PROFILE": "work",
"OPENCLAW_STATE_DIR": "/different/state",
]).instanceLockURL(systemTemporaryDirectory: root))
var defaultLock: AppInstanceLock?
switch AppInstanceLock.acquire(url: defaultURL) {
case let .acquired(lock): defaultLock = lock
case .busy, .failed: Issue.record("Expected default lock acquisition")
}
if case .busy = AppInstanceLock.acquire(url: defaultURL) {} else {
Issue.record("Expected same-profile lock rejection")
}
var namedLock: AppInstanceLock?
switch AppInstanceLock.acquire(url: namedURL) {
case let .acquired(lock): namedLock = lock
case .busy, .failed: Issue.record("Expected cross-profile lock acquisition")
}
#expect(defaultLock != nil)
#expect(namedLock != nil)
let defaultAttributes = try FileManager.default.attributesOfItem(
atPath: defaultURL.deletingLastPathComponent().path)
#expect((defaultAttributes[.posixPermissions] as? NSNumber)?.intValue == 0o700)
defaultLock = nil
namedLock = nil
if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else {
Issue.record("Expected released default lock to transfer")
}
var namedFirst: AppInstanceLock?
if case let .acquired(lock) = AppInstanceLock.acquire(url: namedURL) {
namedFirst = lock
} else {
Issue.record("Expected named-first lock acquisition")
}
if case .acquired = AppInstanceLock.acquire(url: defaultURL) {} else {
Issue.record("Expected named-to-default coexistence")
}
#expect(namedFirst != nil)
}
@Test func `profile lock reports unsafe ownership errors`() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("app-profile-lock-error-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) }
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path)
let target = root.appendingPathComponent("target")
let link = root.appendingPathComponent("app-instance.lock")
try Data().write(to: target)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target)
if case .failed = AppInstanceLock.acquire(url: link) {} else {
Issue.record("Expected symlink lock rejection")
}
let unsafeURL = AppProfile(environment: ["OPENCLAW_PROFILE": "unsafe"])
.instanceLockURL(systemTemporaryDirectory: root)
let unsafe = unsafeURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: unsafe, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o777], ofItemAtPath: unsafe.path)
if case .failed = AppInstanceLock.acquire(url: unsafeURL) {} else {
Issue.record("Expected unsafe state directory rejection")
}
}
@Test func `long profile approvals socket uses deterministic private short path`() {
let longState = URL(fileURLWithPath: "/Users/" + String(repeating: "very-long/", count: 16))
let first = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true)
let second = ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: true)
#expect(first == second)
#expect(first.hasPrefix("/tmp/openclaw-\(geteuid())/exec-approvals-"))
#expect(first.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path))
#expect(ExecApprovalsStore.socketPath(stateDirectoryURL: longState, profileActive: false)
.hasPrefix(longState.path))
let oldCanonical = longState.appendingPathComponent("exec-approvals.sock").path
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
existing: nil,
stateDirectoryURL: longState,
computed: first) == first)
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
existing: oldCanonical,
stateDirectoryURL: longState,
computed: first) == first)
#expect(ExecApprovalsStore.resolvedPersistedSocketPath(
existing: "/custom/approvals.sock",
stateDirectoryURL: longState,
computed: first) == "/custom/approvals.sock")
}
}
@@ -176,6 +176,23 @@ struct CLIInstallerTests {
"--yes",
"--no-restart",
])
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
#expect(CLIInstaller.managedUpdateCommand(
executable: "/opt/openclaw",
targetVersion: "2026.7.4",
profile: profile) == [
"/opt/openclaw", "--profile", "work", "update", "--tag", "2026.7.4",
"--json", "--timeout", "900",
])
#expect(CLIInstaller.managedUpdateCommand(
executable: "/opt/openclaw",
targetVersion: "2026.7.4",
repair: true,
profile: profile) == [
"/opt/openclaw", "--profile", "work", "update", "repair", "--json",
"--timeout", "900", "--yes",
])
}
@Test func `managed update parses structured updater diagnostics`() throws {
@@ -152,6 +152,35 @@ private actor GatewayEndpointRemoteEnsureGate {
}
struct GatewayEndpointStoreTests {
@MainActor
@Test func `live local source uses canonical default and named profile ports`() async throws {
let configPath = TestIsolation.tempConfigPath()
try Data(#"{"gateway":{"mode":"local"}}"#.utf8)
.write(to: URL(fileURLWithPath: configPath))
defer { try? FileManager.default.removeItem(atPath: configPath) }
try await TestIsolation.withIsolatedState(
env: [
"OPENCLAW_CONFIG_PATH": configPath,
"OPENCLAW_GATEWAY_PORT": nil,
],
defaults: ["gatewayPort": nil])
{
let state = AppState(preview: true)
let base = await GatewayEndpointStore._testLiveSourceSnapshot(
state: state,
profile: AppProfile(environment: [:]),
beforeConfigRead: {})
let workProfile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
let work = await GatewayEndpointStore._testLiveSourceSnapshot(
state: state,
profile: workProfile,
beforeConfigRead: {})
#expect(base.localPort == 18789)
#expect(work.localPort == workProfile.defaultGatewayPort)
}
}
private func makeLaunchAgentSnapshot(
env: [String: String] = [:],
token: String? = nil,
@@ -238,6 +267,7 @@ struct GatewayEndpointStoreTests {
token: token,
password: { nil },
localPort: { 18789 },
localUnavailableReason: { nil },
remoteRouteIfRunning: remoteRouteIfRunning,
remoteRouteIsCurrent: remoteRouteIsCurrent,
canStartRemoteTunnel: canStartRemoteTunnel,
@@ -264,6 +294,27 @@ struct GatewayEndpointStoreTests {
return ConnectionModeResolver.resolve(root: root, defaults: defaults)
}
@Test func `local conflict remains unavailable across refresh until cleared`() async throws {
let source = self.source(mode: .local)
let store = self.makeStore(sourceSnapshot: { source })
await store.setLocalUnavailableReason("Profile port conflict")
await store.refresh()
#expect(await store.currentState() == .unavailable(
mode: .local,
reason: "Profile port conflict"))
await #expect(throws: Error.self) {
_ = try await store.requireEndpoint()
}
await store.setLocalUnavailableReason(nil)
await store.refresh()
guard case .ready = await store.currentState() else {
Issue.record("Expected local endpoint to recover after conflict clears")
return
}
}
private func dashboardURL(
_ endpoint: String,
mode: AppState.ConnectionMode,
@@ -554,8 +605,7 @@ extension GatewayEndpointStoreTests {
let sourceGate = GatewayEndpointSourceGate(source)
let store = self.makeStore(
sourceSnapshot: { await sourceGate.snapshot() },
liveSourceIsCurrent: { $0.routingGeneration == 7 },
)
liveSourceIsCurrent: { $0.routingGeneration == 7 })
_ = try await store.requireEndpoint()
#expect(await sourceGate.reads() == 1)
@@ -651,8 +701,7 @@ extension GatewayEndpointStoreTests {
let routeGate = GatewayEndpointRouteLookupGate()
let store = self.makeStore(
sourceSnapshot: { await sourceGate.snapshot() },
remoteRouteIfRunning: { await routeGate.lookup() },
)
remoteRouteIfRunning: { await routeGate.lookup() })
let staleRequest = Task { try await store.requireEndpoint() }
await routeGate.waitUntilStarted()
@@ -741,8 +790,7 @@ extension GatewayEndpointStoreTests {
let sourceGate = GatewayEndpointSourceGate(fallbackSource)
let store = self.makeStore(
sourceSnapshot: { await sourceGate.snapshot() },
token: { "local-token" },
)
token: { "local-token" })
let initialURL = try #require(URL(string: "ws://127.0.0.1:18789"))
await sourceGate.suspendNextRead()
@@ -813,8 +861,7 @@ extension GatewayEndpointStoreTests {
sourceSnapshot: { source },
remoteRouteIfRunning: { await remoteGate.routeIfRunning() },
remoteRouteIsCurrent: { await remoteGate.isCurrent($0) },
ensureRemoteTunnel: { await remoteGate.ensure() },
)
ensureRemoteTunnel: { await remoteGate.ensure() })
let cancelledWaiter = Task { try await store.requireEndpoint() }
await remoteGate.waitUntilEnsureStarts()
@@ -849,8 +896,7 @@ extension GatewayEndpointStoreTests {
sourceSnapshot: { source },
remoteRouteIfRunning: { await remoteGate.routeIfRunning() },
remoteRouteIsCurrent: { await remoteGate.isCurrent($0) },
ensureRemoteTunnel: { await remoteGate.ensure() },
)
ensureRemoteTunnel: { await remoteGate.ensure() })
let first = Task { try await store.requireEndpoint() }
await remoteGate.waitUntilEnsureStarts()
@@ -151,6 +151,43 @@ struct GatewayEnvironmentTests {
}
}
@Test func `named profiles derive stable distinct gateway ports after explicit precedence`() {
let work = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
let personal = AppProfile(environment: ["OPENCLAW_PROFILE": "personal"])
let workPort = GatewayEnvironment.resolvedGatewayPort(
environment: [:],
configPort: nil,
storedPort: 0,
profile: work)
#expect((20000..<60000).contains(workPort))
#expect(workPort == work.defaultGatewayPort)
#expect(workPort != personal.defaultGatewayPort)
#expect(GatewayEnvironment.resolvedGatewayPort(
environment: ["OPENCLAW_GATEWAY_PORT": "21001"],
configPort: 22001,
storedPort: 23001,
profile: work) == 21001)
#expect(GatewayEnvironment.resolvedGatewayPort(
environment: [:],
configPort: 22001,
storedPort: 23001,
profile: work) == 22001)
#expect(GatewayEnvironment.resolvedGatewayPort(
environment: [:],
configPort: nil,
storedPort: 23001,
profile: work) == 23001)
#expect(AppProfile(environment: [:]).defaultGatewayPort == 18789)
#expect(GatewayEnvironment.gatewayCommand(
prefix: ["/opt/openclaw"],
port: workPort,
bind: "loopback",
profile: work) == [
"/opt/openclaw", "--profile", "work", "gateway", "--port", "\(workPort)",
"--bind", "loopback",
])
}
@Test func `expected gateway version from string uses parser`() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
@@ -4,10 +4,116 @@ import Testing
@Suite(.serialized)
struct GatewayLaunchAgentManagerTests {
@Test func `gateway launchd artifacts follow default and named profile labels`() {
let home = URL(fileURLWithPath: "/Users/test", isDirectory: true)
let directory = URL(fileURLWithPath: "/state/service-env", isDirectory: true)
let base = AppProfile(environment: [:])
let work = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
#expect(GatewayLaunchAgentManager.plistURL(homeDirectory: home, profile: base).path ==
"/Users/test/Library/LaunchAgents/ai.openclaw.gateway.plist")
#expect(GatewayLaunchAgentManager.plistURL(homeDirectory: home, profile: work).path ==
"/Users/test/Library/LaunchAgents/ai.openclaw.work.plist")
let baseArtifacts = GatewayLaunchAgentManager.generatedEnvironmentArtifacts(
directory: directory,
profile: base)
let workArtifacts = GatewayLaunchAgentManager.generatedEnvironmentArtifacts(
directory: directory,
profile: work)
#expect(baseArtifacts.environment.path == "/state/service-env/ai.openclaw.gateway.env")
#expect(baseArtifacts.wrapper.path == "/state/service-env/ai.openclaw.gateway-env-wrapper.sh")
#expect(workArtifacts.environment.path == "/state/service-env/ai.openclaw.work.env")
#expect(workArtifacts.wrapper.path == "/state/service-env/ai.openclaw.work-env-wrapper.sh")
}
@Test func `gateway daemon command selects named profile at the root`() async throws {
let root = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: root) }
let executable = root.appendingPathComponent("node_modules/.bin/openclaw")
try makeExecutableForTests(at: executable)
let command = await CommandResolver.openclawCommand(
subcommand: "gateway",
extraArgs: ["status", "--json"],
projectRoot: root,
profile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]))
#expect(command == [
executable.path, "--profile", "work", "gateway", "status", "--json",
])
}
@Test func `daemon commands tolerate first run state migrations`() {
#expect(GatewayLaunchAgentManager.startupMigrationTolerance >= 120)
}
@Test func `malformed canonical profile claims fail closed`() throws {
let home = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: home) }
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
let data = try PropertyListSerialization.data(
fromPropertyList: [
"ProgramArguments": ["node", "openclaw.mjs", "gateway"],
"EnvironmentVariables": [
"OPENCLAW_SERVICE_MARKER": "openclaw",
"OPENCLAW_SERVICE_KIND": "gateway",
],
],
format: .xml,
options: 0)
try data.write(to: agents.appendingPathComponent("ai.openclaw.p1402.plist"))
let claim = GatewayLaunchAgentManager.conflictingProfileClaimOwner(
port: 55636,
excludingLabel: "ai.openclaw.p2380",
homeDirectory: home)
#expect(claim?.contains("p1402") == true)
}
@Test func `same prefix ssh tunnel is not a profile Gateway claim`() throws {
let home = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: home) }
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
let data = try PropertyListSerialization.data(
fromPropertyList: [
"ProgramArguments": ["/usr/bin/ssh", "-N", "-L", "55636:127.0.0.1:18789"],
],
format: .xml,
options: 0)
try data.write(to: agents.appendingPathComponent("ai.openclaw.gateway-tunnel.plist"))
try Data("not a plist".utf8).write(to: agents.appendingPathComponent("ai.openclaw.unrelated.plist"))
#expect(GatewayLaunchAgentManager.conflictingProfileClaimOwner(
port: 55636,
excludingLabel: "ai.openclaw.qa",
homeDirectory: home) == nil)
}
@Test func `default generated Gateway claim is recognized`() throws {
let home = try makeTempDirForTests()
defer { try? FileManager.default.removeItem(at: home) }
let agents = home.appendingPathComponent("Library/LaunchAgents", isDirectory: true)
try FileManager.default.createDirectory(at: agents, withIntermediateDirectories: true)
let data = try PropertyListSerialization.data(
fromPropertyList: [
"ProgramArguments": ["node", "openclaw.mjs", "gateway", "--port", "18789"],
"EnvironmentVariables": [
"OPENCLAW_SERVICE_MARKER": "openclaw",
"OPENCLAW_SERVICE_KIND": "gateway",
],
],
format: .xml,
options: 0)
try data.write(to: agents.appendingPathComponent("ai.openclaw.gateway.plist"))
#expect(GatewayLaunchAgentManager.conflictingProfileClaimOwner(
port: 18789,
excludingLabel: "ai.openclaw.qa",
homeDirectory: home)?.contains("default") == true)
}
@Test func `reads Gateway service ownership command directly from launchd`() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("openclaw-gateway-\(UUID().uuidString).plist")
@@ -7,6 +7,29 @@ import Testing
@Suite(.serialized)
@MainActor
struct GatewayProcessManagerTests {
@Test func `colliding profile ports cannot attach another profile gateway`() {
let first = AppProfile(environment: ["OPENCLAW_PROFILE": "p1402"])
let second = AppProfile(environment: ["OPENCLAW_PROFILE": "p2380"])
#expect(first.defaultGatewayPort == 55636)
#expect(second.defaultGatewayPort == 55636)
#expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment(
profile: first,
listenerPID: 1402,
managedServicePID: 1402))
#expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment(
profile: second,
listenerPID: 1402,
managedServicePID: 2380))
#expect(!GatewayProcessManager.profileAllowsExistingGatewayAttachment(
profile: second,
listenerPID: 1402,
managedServicePID: nil))
#expect(GatewayProcessManager.profileAllowsExistingGatewayAttachment(
profile: AppProfile(environment: [:]),
listenerPID: 1402,
managedServicePID: nil))
}
private func availableGatewayPort() throws -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
guard fd >= 0 else {
@@ -114,12 +137,16 @@ struct GatewayProcessManagerTests {
private nonisolated func gatewayTask(
healthSucceedsAfter unavailableResponses: Int?,
stallsFirstHealthResponse: Bool = false) -> GatewayTestWebSocketTask
stallsFirstHealthResponse: Bool = false,
healthResponseGates: [AsyncTestGate] = []) -> GatewayTestWebSocketTask
{
GatewayTestWebSocketTask(
sendHook: { task, message, sendIndex in
guard sendIndex > 0 else { return }
guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
if healthResponseGates.indices.contains(sendIndex - 1) {
await healthResponseGates[sendIndex - 1].wait()
}
if stallsFirstHealthResponse, sendIndex == 1 { return }
if unavailableResponses.map({ sendIndex <= $0 }) ?? true {
let response = Data(
@@ -1074,8 +1101,11 @@ struct GatewayProcessManagerTests {
@Test func `new launchd gateway can cross multiple readiness deadlines`() async throws {
let port = 19116
let url = try #require(URL(string: "ws://example.invalid"))
let (_, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
self.gatewayTask(healthSucceedsAfter: 2)
let responseGates = [AsyncTestGate(), AsyncTestGate()]
let (session, connection, manager) = self.makeGatewayReadinessFixture(url: url) {
self.gatewayTask(
healthSucceedsAfter: 2,
healthResponseGates: responseGates)
}
defer { manager.setTestingConnection(nil) }
@@ -1097,8 +1127,18 @@ struct GatewayProcessManagerTests {
manager._testStartLaunchdGatewayReadiness(
port: port,
pid: 4242,
readinessWindow: 0.05,
firstInstallReadinessBudget: 0.15)
readinessWindow: 0.2,
firstInstallReadinessBudget: 5)
// Release each response only after its 200 ms window so the test owns
// both deadline crossings instead of depending on runner scheduling.
await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 2 }
#expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 2)
try await Task.sleep(for: .milliseconds(250))
responseGates[0].open()
await self.waitForCondition { session.latestTask()?.snapshotSendCount() ?? 0 >= 3 }
#expect(session.latestTask()?.snapshotSendCount() ?? 0 >= 3)
try await Task.sleep(for: .milliseconds(250))
responseGates[1].open()
await manager.waitForStartupAttempt()
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot()
@@ -3,6 +3,19 @@ import Testing
@MainActor
struct GeneralSettingsStatusTests {
@Test func `local profile conflict outranks connected state`() {
let status = GeneralStatusPresentation.resolve(
mode: .local,
isPaused: true,
controlState: .connected,
localFailure: "Profile p2380 cannot use Gateway port 55636")
#expect(status.title == "OpenClaw needs attention")
#expect(status.subtitle.contains("55636"))
#expect(status.tone == .attention)
#expect(status.showsConnectionAction)
}
@Test func `connected remote gateway is healthy`() {
let status = GeneralStatusPresentation.resolve(
mode: .remote,
@@ -3,6 +3,26 @@ import Testing
@testable import OpenClaw
struct LaunchAgentManagerTests {
@Test func `active profile performs no login agent reads or writes`() async {
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
var writes: [String] = []
LaunchAgentManager._testResetLaunchctlCalls()
#expect(await !(LaunchAgentManager.status(profile: profile)))
#expect(await !(LaunchAgentManager.set(
enabled: true,
bundlePath: "/Applications/OpenClaw.app",
profile: profile,
writePlist: { writes.append($0) })))
#expect(await !(LaunchAgentManager.set(
enabled: false,
bundlePath: "/Applications/OpenClaw.app",
profile: profile,
writePlist: { writes.append($0) })))
#expect(writes.isEmpty)
#expect(LaunchAgentManager._testLaunchctlCallSnapshot().isEmpty)
}
@Test func `enabling an already loaded login job only refreshes its plist`() async {
var persistedBundlePaths: [String] = []
let reloaded = await LaunchAgentManager.set(
@@ -293,8 +293,10 @@ struct MacNodeHostWorkerTests {
@Test func `ready worker exit notifies its route owner`() async throws {
try await confirmation("unexpected worker exit") { confirmed in
let exitGate = AsyncTestGate()
let worker = MacNodeHostWorker(session: GatewayNodeSession()) {
confirmed()
exitGate.open()
}
let script = """
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":["system"],"commands":["system.run"],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}'
@@ -304,7 +306,7 @@ struct MacNodeHostWorkerTests {
_ = try await worker.start(launch: MacNodeHostWorkerLaunch(
command: ["/bin/sh", "-c", script]))
try? await Task.sleep(for: .milliseconds(200))
await exitGate.wait()
}
}
@@ -3,6 +3,20 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct NodeServiceManagerTests {
@Test func `active profile performs no persistent node service work`() async {
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
NodeServiceManager._testResetPersistentServiceCalls()
#expect(await NodeServiceManager.start(profile: profile) == nil)
#expect(await NodeServiceManager.stop(profile: profile) == nil)
#expect(await NodeServiceManager.restart(profile: profile) == nil)
#expect(NodeServiceManager.launchdProgramArguments(profile: profile) == [])
#expect(await !(NodeServiceManager.waitUntilRunning(profile: profile)))
let snapshot = NodeServiceManager._testPersistentServiceCallSnapshot()
#expect(snapshot.commands.isEmpty)
#expect(snapshot.ownershipReads == 0)
}
@Test func `builds node service commands with current CLI shape`() async throws {
try await TestIsolation.withUserDefaultsValues(["openclaw.gatewayProjectRootPath": nil]) {
let tmp = try makeTempDirForTests()
@@ -6,6 +6,20 @@ import Testing
@Suite(.serialized)
@MainActor
struct NodesStoreTests {
@Test func `named profile keeps durable node service unavailability informational`() {
let store = NodesStore(
appProfile: AppProfile(environment: ["OPENCLAW_PROFILE": "work"]),
localNodeIdentityProfile: .node,
localNodeIDLoader: { _ in nil })
#expect(store.persistentServiceNotice ==
"Persistent Mac node service unavailable under app profile; runtime node remains available.")
#expect(store.lastError == nil)
store.statusMessage = "Refreshing devices…"
#expect(store.persistentServiceNotice != nil)
#expect(store.lastError == nil)
}
@Test func `local node identity is prepared once`() async {
let loader = LocalNodeIdentityLoader(results: ["node-id"])
let store = NodesStore(
@@ -68,7 +68,7 @@ enum TestIsolation {
}
}
let userDefaults = UserDefaults.standard
let userDefaults = AppDefaults.standard
var previousDefaults: [String: Any?] = [:]
for (key, value) in defaults {
previousDefaults[key] = userDefaults.object(forKey: key)
@@ -7,6 +7,19 @@ import Testing
@Suite(.serialized)
@MainActor
struct UpdateOrchestrationTests {
@MainActor
@Test func `post update performs no service work under active profile`() {
let profile = AppProfile(environment: ["OPENCLAW_PROFILE": "work"])
NodeServiceManager._testResetPersistentServiceCalls()
GatewayLaunchAgentManager.clearTestingDaemonCommandCalls()
#expect(!PostUpdateController.shared.startIfNeeded(profile: profile))
let snapshot = NodeServiceManager._testPersistentServiceCallSnapshot()
#expect(snapshot.commands.isEmpty)
#expect(snapshot.ownershipReads == 0)
#expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty)
}
@Test func `Sparkle receipt appears only after the target app launches`() throws {
let suite = "UpdateOrchestrationTests.post-update.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
@@ -340,7 +353,7 @@ struct UpdateOrchestrationTests {
onStart: { starts += 1 })
updater.startAfterResolvingGatewayUpdateChannel()
for _ in 0 ..< 10 where !updater.isAvailable {
for _ in 0..<10 where !updater.isAvailable {
await Task.yield()
}
@@ -356,7 +369,7 @@ struct UpdateOrchestrationTests {
onStart: { starts += 1 })
updater.startAfterResolvingGatewayUpdateChannel()
for _ in 0 ..< 10 where !updater.isAvailable {
for _ in 0..<10 where !updater.isAvailable {
await Task.yield()
}
@@ -46,9 +46,29 @@ public struct DeviceIdentity: Codable, Sendable, Equatable {
}
}
struct DeviceIdentityStateRootState {
private(set) var url: URL?
private(set) var used = false
mutating func configure(_ url: URL) -> Bool {
let normalized = url.standardizedFileURL
if let configured = self.url { return configured == normalized }
guard !self.used else { return false }
self.url = normalized
return true
}
mutating func resolve() -> URL? {
self.used = true
return self.url
}
}
enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
@TaskLocal static var scopedStateDirURL: URL?
private static let configuredStateLock = NSLock()
private nonisolated(unsafe) static var configuredState = DeviceIdentityStateRootState()
/// Entitlements are baked into the code signature, so resolve the gate once per process.
/// Every identity load and DeviceAuthStore read/write resolves the state dir through here;
@@ -65,6 +85,10 @@ enum DeviceIdentityPaths {
temporaryDirectory: FileManager.default.temporaryDirectory)
}
static func configureStateDirURL(_ url: URL) -> Bool {
self.configuredStateLock.withLock { self.configuredState.configure(url) }
}
static func stateDirURL(
overrideURL: URL?,
legacyStateDirURL: URL?,
@@ -90,6 +114,9 @@ enum DeviceIdentityPaths {
if let scopedStateDirURL {
return scopedStateDirURL
}
if let configured = self.configuredStateLock.withLock({ self.configuredState.resolve() }) {
return configured
}
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -204,6 +231,11 @@ public enum DeviceIdentityStore {
self.loadOrCreate(profile: .primary)
}
@discardableResult
public static func configureStateDirectory(_ url: URL) -> Bool {
DeviceIdentityPaths.configureStateDirURL(url)
}
#if compiler(>=6.4)
nonisolated(nonsending) static func withStateDirectory<T>(
_ url: URL,
@@ -264,6 +264,25 @@ struct GatewayTLSKeychainOperations: @unchecked Sendable {
delete: { SecItemDelete($0) })
}
struct GatewayTLSKeychainNamespaceState {
private(set) var suffix: String?
private(set) var used = false
mutating func configure(suffix: String) -> Bool {
if let configured = self.suffix {
return configured == suffix
}
guard !self.used || suffix.isEmpty else { return false }
self.suffix = suffix
return true
}
mutating func service(base: String) -> String {
self.used = true
return base + (self.suffix ?? "")
}
}
public enum GatewayTLSStore {
@TaskLocal static var keychainOperations = GatewayTLSKeychainOperations.live
@@ -273,7 +292,19 @@ public enum GatewayTLSStore {
case unavailable
}
private static let keychainService = "ai.openclaw.tls-pinning"
private static let baseKeychainService = "ai.openclaw.tls-pinning"
private static let keychainServiceLock = NSLock()
private nonisolated(unsafe) static var keychainNamespace = GatewayTLSKeychainNamespaceState()
private static var keychainService: String {
self.keychainServiceLock.withLock {
self.keychainNamespace.service(base: self.baseKeychainService)
}
}
private static var usesDefaultKeychainService: Bool {
self.keychainServiceLock.withLock { (self.keychainNamespace.suffix ?? "").isEmpty }
}
private static let keychainAccountPrefix = "fingerprint.v3."
private static let legacyCanonicalAccountPrefix = "fingerprint.v2."
@@ -282,6 +313,19 @@ public enum GatewayTLSStore {
private static let legacyKeyPrefix = "gateway.tls."
private static let firstUseClaims = GatewayTLSFirstUseClaims()
/// The macOS app profile is immutable for the process lifetime. Configure its
/// Keychain namespace before constructing any Gateway connection.
@discardableResult
public static func configureKeychainServiceSuffix(_ suffix: String) -> Bool {
self.keychainServiceLock.withLock {
self.keychainNamespace.configure(suffix: suffix)
}
}
static func resolvedKeychainService(suffix: String) -> String {
self.baseKeychainService + suffix
}
public static func loadFingerprint(stableID: String) -> String? {
guard case let .value(fingerprint) = self.loadFingerprintResult(stableID: stableID) else {
return nil
@@ -512,6 +556,7 @@ public enum GatewayTLSStore {
}
private static func readLegacyDefaultsFingerprint(stableID: String) -> FingerprintRead {
guard self.usesDefaultKeychainService else { return .missing }
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return .unavailable }
let key = self.legacyKeyPrefix + stableID
guard let value = defaults.object(forKey: key) else { return .missing }
@@ -608,8 +653,10 @@ public enum GatewayTLSStore {
} ?? true
guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return removedV2 }
let removedRaw = self.deleteFingerprint(account: stableID)
UserDefaults(suiteName: self.legacySuiteName)?
.removeObject(forKey: self.legacyKeyPrefix + stableID)
if self.usesDefaultKeychainService {
UserDefaults(suiteName: self.legacySuiteName)?
.removeObject(forKey: self.legacyKeyPrefix + stableID)
}
return removedRaw && removedV2
}
@@ -624,6 +671,7 @@ public enum GatewayTLSStore {
}
private static func clearAllLegacyFingerprints() {
guard self.usesDefaultKeychainService else { return }
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(self.legacyKeyPrefix) {
defaults.removeObject(forKey: key)
@@ -74,6 +74,30 @@ private func deviceAuthEntry(
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test func `process state root configures once before identity use`() {
var state = DeviceIdentityStateRootState()
let work = URL(fileURLWithPath: "/Users/test/.openclaw-work", isDirectory: true)
let other = URL(fileURLWithPath: "/Users/test/.openclaw-other", isDirectory: true)
let configuredWork = state.configure(work)
let reconfiguredWork = state.configure(work)
let configuredOther = state.configure(other)
let resolvedWork = state.resolve()
let configuredWorkAfterUse = state.configure(work)
let configuredOtherAfterUse = state.configure(other)
#expect(configuredWork)
#expect(reconfiguredWork)
#expect(!configuredOther)
#expect(resolvedWork == work)
#expect(configuredWorkAfterUse)
#expect(!configuredOtherAfterUse)
var usedDefault = DeviceIdentityStateRootState()
let resolvedDefault = usedDefault.resolve()
let configuredDefaultAfterUse = usedDefault.configure(work)
#expect(resolvedDefault == nil)
#expect(!configuredDefaultAfterUse)
}
@Test
func `task scoped state directories isolate concurrent identity stores`() async throws {
let fixture = DeviceIdentityMigrationFixture()
@@ -105,6 +105,30 @@ private func gatewayTLSTestTrust(systemTrusted: Bool) throws -> SecTrust {
}
struct GatewayTLSPinningTests {
@Test func `keychain namespace configures once and fails closed after use`() {
var state = GatewayTLSKeychainNamespaceState()
let configuredWork = state.configure(suffix: ".profile.work")
let reconfiguredWork = state.configure(suffix: ".profile.work")
let configuredOther = state.configure(suffix: ".profile.other")
let workService = state.service(base: "ai.openclaw.tls-pinning")
let configuredWorkAfterUse = state.configure(suffix: ".profile.work")
let configuredDefaultAfterUse = state.configure(suffix: "")
#expect(configuredWork)
#expect(reconfiguredWork)
#expect(!configuredOther)
#expect(workService == "ai.openclaw.tls-pinning.profile.work")
#expect(configuredWorkAfterUse)
#expect(!configuredDefaultAfterUse)
var usedDefault = GatewayTLSKeychainNamespaceState()
let defaultService = usedDefault.service(base: "ai.openclaw.tls-pinning")
let configuredDefault = usedDefault.configure(suffix: "")
let configuredProfileAfterDefaultUse = usedDefault.configure(suffix: ".profile.work")
#expect(defaultService == "ai.openclaw.tls-pinning")
#expect(configuredDefault)
#expect(!configuredProfileAfterDefaultUse)
}
private func withFakeKeychain<T>(_ operation: (GatewayTLSFakeKeychain) throws -> T) rethrows -> T {
let keychain = GatewayTLSFakeKeychain()
return try GatewayTLSStore.$keychainOperations.withValue(keychain.operations) {
+15 -8
View File
@@ -153,6 +153,12 @@ fi
if [[ "$TARGET_ONLY" -eq 1 && -n "$APP_BUNDLE" ]]; then
fail "--target-only does not accept OPENCLAW_APP_BUNDLE"
fi
if [[ -n "${OPENCLAW_PROFILE:-}" ]]; then
normalized_profile="$(printf '%s' "${OPENCLAW_PROFILE}" | tr '[:upper:]' '[:lower:]')"
if [[ "${normalized_profile}" != "default" ]]; then
fail "restart-mac.sh cannot safely target one app profile; launch that profile directly instead"
fi
fi
canonicalize_app_bundle
mkdir -p "$(dirname "$LOG_PATH")"
@@ -505,14 +511,15 @@ fi
# 4) Launch the installed app in the foreground so the menu bar extra appears.
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
run_step "launch app" env -i \
HOME="${HOME}" \
USER="${USER:-$(id -un)}" \
LOGNAME="${LOGNAME:-$(id -un)}" \
TMPDIR="${TMPDIR:-/tmp}" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
LANG="${LANG:-en_US.UTF-8}" \
/usr/bin/open "${OPEN_ARGS[@]}"
LAUNCH_ENV=(
"HOME=${HOME}"
"USER=${USER:-$(id -un)}"
"LOGNAME=${LOGNAME:-$(id -un)}"
"TMPDIR=${TMPDIR:-/tmp}"
"PATH=/usr/bin:/bin:/usr/sbin:/sbin"
"LANG=${LANG:-en_US.UTF-8}"
)
run_step "launch app" env -i "${LAUNCH_ENV[@]}" /usr/bin/open "${OPEN_ARGS[@]}"
# 5) Verify the app is alive.
sleep 1.5
+107 -35
View File
@@ -263,9 +263,11 @@ async function runRestartLaunchAgentWithFakeTimers(args: Parameters<typeof resta
}
}
function expectLaunchctlEnableBootstrapOrder(env: Record<string, string | undefined>) {
function expectLaunchctlEnableBootstrapOrder(
env: Record<string, string | undefined>,
label = "ai.openclaw.gateway",
) {
const domain = typeof process.getuid === "function" ? `gui/${process.getuid()}` : "gui/501";
const label = "ai.openclaw.gateway";
const plistPath = resolveLaunchAgentPlistPath(env);
const serviceId = `${domain}/${label}`;
const enableIndex = state.launchctlCalls.findIndex(
@@ -1643,11 +1645,9 @@ describe("launchd install", () => {
"print",
"bootout",
"unload",
"bootout",
"unload",
"enable",
"bootstrap",
"bootout",
"print",
"enable",
"bootstrap",
]);
@@ -1742,14 +1742,7 @@ describe("launchd install", () => {
expect(state.files.get(plistPath)).toBe(previous);
expect(state.serviceLoaded).toBe(false);
expect(state.serviceRunning).toBe(false);
expect(launchctlCommandNames()).toEqual([
"print",
"bootout",
"unload",
"enable",
"bootstrap",
"bootout",
]);
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]);
});
it("removes generated artifacts after a failed fresh install", async () => {
@@ -1761,19 +1754,49 @@ describe("launchd install", () => {
state.serviceRunning = false;
state.bootstrapError = "Operation not permitted";
state.bootstrapTransient = true;
state.bootoutError = "Boot-out failed: 5: Input/output error";
state.bootoutCode = 5;
await expect(
installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
environment: { OPENCLAW_GATEWAY_PORT: "19000" },
}),
).rejects.toThrow("launchctl bootstrap failed: Operation not permitted");
const error = await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
environment: { OPENCLAW_GATEWAY_PORT: "19000" },
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("launchctl bootstrap failed: Operation not permitted");
expect(state.files.has(plistPath)).toBe(false);
expect(state.files.has(envFilePath)).toBe(false);
expect(state.files.has(wrapperPath)).toBe(false);
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]);
});
it("fails closed when rollback cannot determine the replacement state", async () => {
const env = createDefaultLaunchdEnv();
const plistPath = resolveLaunchAgentPlistPath(env);
state.printNotLoadedRemaining = 1;
state.printError = "launchctl print permission denied";
state.printFailuresRemaining = 1;
state.bootstrapError = "Operation not permitted";
const error = await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"launchctl bootstrap failed: Operation not permitted",
);
expect((error as Error).message).toContain(
"The previous LaunchAgent supervision could not be restored.",
);
expect((error as Error).cause).toBeInstanceOf(Error);
expect(((error as Error).cause as Error).message).toContain("could not determine whether");
expect(state.files.has(plistPath)).toBe(true);
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap", "print"]);
});
it("restores the exact prior plist and supervision after external bootstrap failure", async () => {
@@ -1826,7 +1849,7 @@ describe("launchd install", () => {
"unload",
"enable",
"bootstrap",
"bootout",
"print",
"enable",
"bootstrap",
]);
@@ -1892,22 +1915,71 @@ describe("launchd install", () => {
expect(state.launchctlCalls).toEqual([]);
});
it("enables service before bootstrap without self-restarting the fresh agent", async () => {
const env = createDefaultLaunchdEnv();
await installLaunchAgent({
env,
stdout: new PassThrough(),
it.each([
{
name: "default gateway",
env: createDefaultLaunchdEnv(),
label: "ai.openclaw.gateway",
programArguments: defaultProgramArguments,
});
},
{
name: "profiled gateway",
env: { HOME: "/Users/test", OPENCLAW_PROFILE: "qa" },
label: "ai.openclaw.qa",
programArguments: defaultProgramArguments,
},
{
name: "node service",
env: { HOME: "/Users/test", OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.node" },
label: "ai.openclaw.node",
programArguments: ["node", "node-host.js"],
},
])(
"installs a fresh $name without booting out the absent job",
async ({ env, label, programArguments }) => {
state.bootoutError = "Boot-out failed: 5: Input/output error";
state.bootoutCode = 5;
await installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments,
});
const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? "";
expect(plist).toContain("<key>Comment</key>\n <string>OpenClaw Gateway</string>");
expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION");
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env);
const installKickstartIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "kickstart" && c[2] === serviceId,
const plist = state.files.get(resolveLaunchAgentPlistPath(env)) ?? "";
expect(plist).not.toContain("OPENCLAW_SERVICE_VERSION");
const { serviceId } = expectLaunchctlEnableBootstrapOrder(env, label);
const installKickstartIndex = state.launchctlCalls.findIndex(
(c) => c[0] === "kickstart" && c[2] === serviceId,
);
expect(installKickstartIndex).toBe(-1);
expect(launchctlCommandNames()).toEqual(["print", "enable", "bootstrap"]);
},
);
it("keeps loaded reinstall deactivation failures fatal", async () => {
const env = createDefaultLaunchdEnv();
const plistPath = resolveLaunchAgentPlistPath(env);
state.files.set(
plistPath,
createTestLaunchAgentPlist({
label: "ai.openclaw.gateway",
programArguments: defaultProgramArguments,
}),
);
expect(installKickstartIndex).toBe(-1);
state.bootoutError = "Boot-out failed: 5: Input/output error";
state.bootoutCode = 5;
await expect(
installLaunchAgent({
env,
stdout: new PassThrough(),
programArguments: defaultProgramArguments,
}),
).rejects.toThrow(
"launchctl bootout failed during LaunchAgent install: Boot-out failed: 5: Input/output error",
);
expect(launchctlCommandNames()).toEqual(["print", "bootout", "print", "bootout"]);
});
it("writes a version-free node service description", async () => {
+21 -5
View File
@@ -1487,9 +1487,19 @@ async function restoreLaunchAgentInstall(params: {
snapshot: LaunchAgentInstallSnapshot;
}): Promise<void> {
const serviceTarget = `${params.domain}/${params.label}`;
const bootout = await execLaunchctl(["bootout", serviceTarget]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
// A failed bootstrap may leave no registered job. Restore files directly in
// that state; only a loaded replacement must be removed before rollback.
const currentState = await probeLaunchAgentState(serviceTarget);
if (currentState.state === "unknown") {
throw new Error(
`launchctl print could not determine whether ${serviceTarget} is loaded during LaunchAgent rollback: ${currentState.detail ?? "unknown error"}`,
);
}
if (currentState.state !== "not-loaded") {
const bootout = await execLaunchctl(["bootout", serviceTarget]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
}
}
await restoreLaunchAgentInstallArtifacts({
env: params.env,
@@ -1546,9 +1556,15 @@ async function activateLaunchAgent(params: {
// the plist write cannot race us into two KeepAlive managers.
await assertNoSystemLaunchDaemonOwnership(label);
for (const legacy of params.snapshot.legacy) {
await deactivateLaunchAgentDefinition(domain, legacy.plistPath);
if (legacy.loaded) {
await deactivateLaunchAgentDefinition(domain, legacy.plistPath);
}
}
// Plist-form bootout reports EIO for a valid definition that was never loaded.
// The pre-publication snapshot is the authoritative cutover fact.
if (params.snapshot.loaded) {
await deactivateLaunchAgentDefinition(domain, params.plistPath);
}
await deactivateLaunchAgentDefinition(domain, params.plistPath);
// launchd can persist "disabled" state even after bootout + plist removal; clear it before bootstrap.
await bootstrapLaunchAgentOrThrow({
domain,
+37
View File
@@ -217,6 +217,30 @@ function runRestartArgParser(...args: string[]) {
return spawnSync("bash", [harnessPath, ...args], { encoding: "utf8" });
}
function runProfileGuard(profile: string) {
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-profile-test-"));
tempRoots.push(root);
const script = readFileSync(restartScriptPath, "utf8");
const start = script.indexOf('if [[ -n "${OPENCLAW_PROFILE:-}" ]]');
const guardBlock = script.slice(start, script.indexOf("canonicalize_app_bundle", start));
const harnessPath = join(root, "profile-guard.sh");
writeFileSync(
harnessPath,
[
"#!/bin/bash",
"set -euo pipefail",
'fail() { printf "ERROR: %s\\n" "$*" >&2; exit 1; }',
guardBlock,
'printf "safe\\n"',
].join("\n"),
);
chmodSync(harnessPath, 0o755);
return spawnSync("/bin/bash", [harnessPath], {
encoding: "utf8",
env: { ...process.env, OPENCLAW_PROFILE: profile },
});
}
function runLaunchArgBuilder(...args: string[]) {
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
tempRoots.push(root);
@@ -404,6 +428,19 @@ describe("scripts/restart-mac.sh", () => {
expect(script).not.toContain('LOG_PATH="${OPENCLAW_RESTART_LOG:-/tmp/openclaw-restart.log}"');
});
it("rejects named app profiles before global process or launchd cleanup", () => {
const script = readFileSync(restartScriptPath, "utf8");
expect(script).toContain('if [[ -n "${OPENCLAW_PROFILE:-}"');
expect(script).toContain("restart-mac.sh cannot safely target one app profile");
expect(script.indexOf("cannot safely target one app profile")).toBeLessThan(
script.indexOf("\nacquire_lock\n"),
);
expect(runProfileGuard("work").status).toBe(1);
expect(runProfileGuard("default").stdout.trim()).toBe("safe");
expect(runProfileGuard("Default").stdout.trim()).toBe("safe");
});
it("does not remove a live restart lock it did not acquire", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-restart-mac-test-"));
tempRoots.push(root);