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:
Peter Steinberger
2026-08-13 23:28:26 -07:00
committed by GitHub
parent 8cfc415af4
commit 252bb545b4
8 changed files with 123 additions and 15 deletions
+5 -2
View File
@@ -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(
+5 -3
View File
@@ -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
+6
View File
@@ -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: