mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
fix(macos): keep background hosts out of GUI keychain (#123500)
Background-only Bridge and Mac-node launches keep GUI onboarding and saved Gateway-profile Keychain state cold while preserving environment/config-owned Gateway connectivity.
This commit is contained in:
committed by
GitHub
parent
8cfc415af4
commit
252bb545b4
@@ -17,8 +17,11 @@ scripts/restart-mac.sh --background-only # keep services running without automat
|
||||
|
||||
`--background-only` suppresses first-run onboarding, update and CLI prompts, and
|
||||
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.
|
||||
node services still start. It also keeps GUI-owned onboarding and saved Gateway
|
||||
profile Keychain state cold, so a signer or ACL transition cannot raise a
|
||||
SecurityAgent prompt during unattended work. The primary Gateway route still
|
||||
comes from the normal environment/config endpoint. Combine it with
|
||||
`--attach-only` when an external process owns the local Gateway.
|
||||
|
||||
## App profiles
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ struct AppLaunchPresentationPolicy: Equatable {
|
||||
!self.backgroundOnly
|
||||
}
|
||||
|
||||
/// GUI-owned Keychain items may present SecurityAgent when a newly signed build is not in an item's ACL.
|
||||
/// Background hosts keep that state cold; config and environment still own their primary Gateway route.
|
||||
var allowsGatewayUIKeychainAccess: Bool {
|
||||
!self.backgroundOnly
|
||||
}
|
||||
|
||||
func shouldAutoOpenChat(arguments: [String]) -> Bool {
|
||||
self.allowsAutomaticPresentation &&
|
||||
(arguments.contains("--chat") || arguments.contains("--webchat"))
|
||||
|
||||
@@ -9,7 +9,9 @@ private let dashboardManagerLogger = Logger(subsystem: "ai.openclaw", category:
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DashboardManager {
|
||||
static let shared = DashboardManager()
|
||||
static let shared = DashboardManager(
|
||||
automaticGatewayProfileRefreshEnabled:
|
||||
AppLaunchPresentationPolicy.current.allowsGatewayUIKeychainAccess)
|
||||
|
||||
private struct AuxiliaryWindowInstance {
|
||||
var target: DashboardGatewayTarget
|
||||
@@ -46,6 +48,7 @@ final class DashboardManager {
|
||||
@ObservationIgnored private let endpointStateProvider: @Sendable () async -> GatewayEndpointState
|
||||
@ObservationIgnored private let mainWindowAutosaveName: String
|
||||
@ObservationIgnored private let observesGatewayChanges: Bool
|
||||
@ObservationIgnored private let automaticGatewayProfileRefreshEnabled: Bool
|
||||
private(set) var gatewayEntries: [DashboardGatewayEntry] = []
|
||||
private(set) var frontmostDashboardTarget: DashboardGatewayTarget?
|
||||
@ObservationIgnored private var gatewayRefreshObservers: [NSObjectProtocol] = []
|
||||
@@ -73,6 +76,7 @@ final class DashboardManager {
|
||||
await GatewayEndpointStore.shared.currentState()
|
||||
},
|
||||
observeGatewayChanges: Bool = true,
|
||||
automaticGatewayProfileRefreshEnabled: Bool = true,
|
||||
mainWindowAutosaveName: String = DashboardWindowLayout.windowFrameAutosaveName)
|
||||
{
|
||||
self.authTokenProvider = authTokenProvider
|
||||
@@ -80,7 +84,8 @@ final class DashboardManager {
|
||||
self.endpointStateProvider = endpointStateProvider
|
||||
self.mainWindowAutosaveName = mainWindowAutosaveName
|
||||
self.observesGatewayChanges = observeGatewayChanges
|
||||
if observeGatewayChanges {
|
||||
self.automaticGatewayProfileRefreshEnabled = automaticGatewayProfileRefreshEnabled
|
||||
if observeGatewayChanges, automaticGatewayProfileRefreshEnabled {
|
||||
let names: [Notification.Name] = [
|
||||
MacGatewayProfileStore.didChangeNotification,
|
||||
.openclawConfigDidChange,
|
||||
@@ -125,11 +130,6 @@ final class DashboardManager {
|
||||
await self.handleEndpointState(endpointState)
|
||||
}
|
||||
|
||||
func configure(updater: UpdaterProviding) {
|
||||
self.updater = updater
|
||||
Task { await self.refreshGatewaySnapshots() }
|
||||
}
|
||||
|
||||
/// The card's native update path only makes sense when the app owns the
|
||||
/// local gateway and the post-relaunch repair is allowed to run; otherwise
|
||||
/// (external CLI, write-disabled launchd, extended-stable pin) the card
|
||||
@@ -1142,6 +1142,14 @@ extension DashboardManager {
|
||||
}
|
||||
}
|
||||
|
||||
extension DashboardManager {
|
||||
func configure(updater: UpdaterProviding) {
|
||||
self.updater = updater
|
||||
guard self.automaticGatewayProfileRefreshEnabled else { return }
|
||||
Task { await self.refreshGatewaySnapshots() }
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension DashboardManager {
|
||||
/// Test instances skip `observeEndpointChanges()` so the shared endpoint
|
||||
@@ -1152,6 +1160,8 @@ extension DashboardManager {
|
||||
endpointStateProvider: @escaping @Sendable () async -> GatewayEndpointState = {
|
||||
.unavailable(mode: .unconfigured, reason: "not configured")
|
||||
},
|
||||
observeGatewayChanges: Bool = false,
|
||||
automaticGatewayProfileRefreshEnabled: Bool = true,
|
||||
primaryEndpointProvider: (@Sendable (AppState.ConnectionMode) async throws
|
||||
-> GatewayConnection.EndpointSnapshot)? = nil,
|
||||
profileEndpointProvider: (@Sendable (String) async throws
|
||||
@@ -1163,7 +1173,8 @@ extension DashboardManager {
|
||||
authTokenProvider: authTokenProvider,
|
||||
routeProbe: routeProbe,
|
||||
endpointStateProvider: endpointStateProvider,
|
||||
observeGatewayChanges: false,
|
||||
observeGatewayChanges: observeGatewayChanges,
|
||||
automaticGatewayProfileRefreshEnabled: automaticGatewayProfileRefreshEnabled,
|
||||
mainWindowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
|
||||
manager.testPrimaryEndpointProvider = primaryEndpointProvider
|
||||
manager.testProfileEndpointProvider = profileEndpointProvider
|
||||
@@ -1183,6 +1194,10 @@ extension DashboardManager {
|
||||
self.mainTarget
|
||||
}
|
||||
|
||||
func _testGatewayRefreshObserverCount() -> Int {
|
||||
self.gatewayRefreshObservers.count
|
||||
}
|
||||
|
||||
func _testOpenWindow(for target: DashboardGatewayTarget) async {
|
||||
await self.openWindow(for: target)
|
||||
}
|
||||
|
||||
@@ -1061,7 +1061,17 @@ extension GatewayConnection {
|
||||
}
|
||||
|
||||
static func defaultActivationBindingKey() -> SymmetricKey? {
|
||||
GatewayActivationBindingKeyStore.loadOrCreate()
|
||||
self.activationBindingKey(
|
||||
launchPolicy: .current,
|
||||
loadOrCreate: GatewayActivationBindingKeyStore.loadOrCreate)
|
||||
}
|
||||
|
||||
static func activationBindingKey(
|
||||
launchPolicy: AppLaunchPresentationPolicy,
|
||||
loadOrCreate: () -> SymmetricKey?) -> SymmetricKey?
|
||||
{
|
||||
guard launchPolicy.allowsGatewayUIKeychainAccess else { return nil }
|
||||
return loadOrCreate()
|
||||
}
|
||||
|
||||
private static func activationOwnershipFingerprint(
|
||||
|
||||
@@ -616,9 +616,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
DashboardManager.shared.preloadIfConfigured()
|
||||
if launchPolicy.allowsAutomaticPresentation {
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
DashboardManager.shared.preloadIfConfigured()
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -6,15 +7,17 @@ struct AppLaunchPresentationPolicyTests {
|
||||
let policy = AppLaunchPresentationPolicy(arguments: ["OpenClaw"])
|
||||
|
||||
#expect(policy.allowsAutomaticPresentation)
|
||||
#expect(policy.allowsGatewayUIKeychainAccess)
|
||||
#expect(policy.shouldAutoOpenChat(arguments: ["OpenClaw", "--chat"]))
|
||||
#expect(policy.shouldAutoOpenDashboard(arguments: ["OpenClaw", "--dashboard"]))
|
||||
}
|
||||
|
||||
@Test func `background-only wins over automatic presentation flags`() {
|
||||
let arguments = ["OpenClaw", "--background-only", "--chat", "--dashboard"]
|
||||
let arguments = ["OpenClaw", "--attach-only", "--background-only", "--chat", "--dashboard"]
|
||||
let policy = AppLaunchPresentationPolicy(arguments: arguments)
|
||||
|
||||
#expect(!policy.allowsAutomaticPresentation)
|
||||
#expect(!policy.allowsGatewayUIKeychainAccess)
|
||||
#expect(!policy.shouldAutoOpenChat(arguments: arguments))
|
||||
#expect(!policy.shouldAutoOpenDashboard(arguments: arguments))
|
||||
}
|
||||
@@ -24,6 +27,33 @@ struct AppLaunchPresentationPolicyTests {
|
||||
let policy = AppLaunchPresentationPolicy(arguments: arguments)
|
||||
|
||||
#expect(policy.allowsAutomaticPresentation)
|
||||
#expect(policy.allowsGatewayUIKeychainAccess)
|
||||
#expect(policy.shouldAutoOpenDashboard(arguments: arguments))
|
||||
}
|
||||
|
||||
@Test func `background launch never calls the prompt bearing activation key loader`() {
|
||||
var loadCount = 0
|
||||
let key = GatewayConnection.activationBindingKey(
|
||||
launchPolicy: AppLaunchPresentationPolicy(arguments: ["OpenClaw", "--background-only"]),
|
||||
loadOrCreate: {
|
||||
loadCount += 1
|
||||
return SymmetricKey(size: .bits256)
|
||||
})
|
||||
|
||||
#expect(key == nil)
|
||||
#expect(loadCount == 0)
|
||||
}
|
||||
|
||||
@Test func `interactive launch retains the activation binding key`() {
|
||||
var loadCount = 0
|
||||
let key = GatewayConnection.activationBindingKey(
|
||||
launchPolicy: AppLaunchPresentationPolicy(arguments: ["OpenClaw"]),
|
||||
loadOrCreate: {
|
||||
loadCount += 1
|
||||
return SymmetricKey(size: .bits256)
|
||||
})
|
||||
|
||||
#expect(key != nil)
|
||||
#expect(loadCount == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,42 @@ struct DashboardGatewaysBridgeTests {
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct DashboardManagerGatewayTargetTests {
|
||||
@Test func `background configuration keeps the gateway profile registry cold`() async {
|
||||
var catalogReads = 0
|
||||
let manager = DashboardManager._testMake(
|
||||
observeGatewayChanges: true,
|
||||
automaticGatewayProfileRefreshEnabled: false,
|
||||
gatewayEntriesProvider: {
|
||||
catalogReads += 1
|
||||
return []
|
||||
})
|
||||
|
||||
manager.configure(updater: DashboardGatewayTestUpdater())
|
||||
NotificationCenter.default.post(name: MacGatewayProfileStore.didChangeNotification, object: nil)
|
||||
for _ in 0..<20 {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(catalogReads == 0)
|
||||
#expect(manager._testGatewayRefreshObserverCount() == 0)
|
||||
}
|
||||
|
||||
@Test func `interactive configuration retains the gateway profile refresh`() async {
|
||||
var catalogReads = 0
|
||||
let manager = DashboardManager._testMake(
|
||||
gatewayEntriesProvider: {
|
||||
catalogReads += 1
|
||||
return []
|
||||
})
|
||||
|
||||
manager.configure(updater: DashboardGatewayTestUpdater())
|
||||
for _ in 0..<20 where catalogReads == 0 {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
#expect(catalogReads == 1)
|
||||
}
|
||||
|
||||
@Test func `primary window configuration retains resolved TLS policy`() async throws {
|
||||
let state = AppStateStore.shared
|
||||
let originalMode = state.connectionMode
|
||||
|
||||
@@ -33,6 +33,12 @@ In the macOS app: **Settings -> Enable Peekaboo Bridge**. The toggle requires **
|
||||
|
||||
When enabled (and Computer Control is on), OpenClaw starts a local UNIX socket server at `~/Library/Application Support/OpenClaw/<socket-name>`. If disabled, the host stops and `peekaboo` falls back to other available hosts. The coordinator also maintains legacy socket symlinks (`clawdbot`, `clawdis`, `moltbot` under Application Support) pointing at the current socket for older `peekaboo` installs.
|
||||
|
||||
For an unattended elevation host, launch OpenClaw with `--attach-only --background-only`. Background-only mode does
|
||||
not preload dashboard, onboarding, or saved Gateway-profile Keychain state, so a signer or Keychain ACL transition
|
||||
cannot interrupt automation with SecurityAgent prompts. The Bridge still starts on its local socket; the control
|
||||
channel and Mac-node runtime continue using the primary Gateway route supplied through the normal environment/config
|
||||
path.
|
||||
|
||||
## Client discovery order
|
||||
|
||||
Peekaboo clients typically try hosts in this order:
|
||||
|
||||
Reference in New Issue
Block a user