From 252bb545b47ab3c3d5bbf09a20f52728f174b3cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 13 Aug 2026 23:28:26 -0700 Subject: [PATCH] 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. --- apps/macos/README.md | 7 ++-- .../AppLaunchPresentationPolicy.swift | 6 ++++ .../Sources/OpenClaw/DashboardManager.swift | 31 +++++++++++----- .../Sources/OpenClaw/GatewayConnection.swift | 12 ++++++- apps/macos/Sources/OpenClaw/MenuBar.swift | 8 +++-- .../AppLaunchPresentationPolicyTests.swift | 32 ++++++++++++++++- .../DashboardGatewaysTests.swift | 36 +++++++++++++++++++ docs/platforms/mac/peekaboo.md | 6 ++++ 8 files changed, 123 insertions(+), 15 deletions(-) diff --git a/apps/macos/README.md b/apps/macos/README.md index 8e05b25a7515..8b778e3efde9 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -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 diff --git a/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift b/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift index a199603d3673..e4a03a5572fa 100644 --- a/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift +++ b/apps/macos/Sources/OpenClaw/AppLaunchPresentationPolicy.swift @@ -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")) diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift index bc13ebaf4571..2ead97f2f990 100644 --- a/apps/macos/Sources/OpenClaw/DashboardManager.swift +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -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) } diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index efe6a16af5c8..553e2ed5f765 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -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( diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index eafce59af4a7..19e0daf12b5b 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -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 diff --git a/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift index b924bbdc4c8c..72914d36d401 100644 --- a/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/AppLaunchPresentationPolicyTests.swift @@ -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) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift index fb8beb365c13..516c482b8efb 100644 --- a/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardGatewaysTests.swift @@ -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 diff --git a/docs/platforms/mac/peekaboo.md b/docs/platforms/mac/peekaboo.md index b3f59a00a955..22be3e73d58a 100644 --- a/docs/platforms/mac/peekaboo.md +++ b/docs/platforms/mac/peekaboo.md @@ -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/`. 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: