diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 91caf579749..41459292801 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -16217,70 +16217,6 @@ "surface": "apple", "id": "native.apple.4f3841d5de0135c4" }, - { - "kind": "conditional-branch", - "line": 263, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "New Mac wants to connect", - "surface": "apple", - "id": "native.apple.135336820846e999" - }, - { - "kind": "conditional-branch", - "line": 263, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "New device wants to connect", - "surface": "apple", - "id": "native.apple.18c2594347b2c96e" - }, - { - "kind": "conditional-branch", - "line": 267, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "this Mac app", - "surface": "apple", - "id": "native.apple.fbd59df61769188f" - }, - { - "kind": "conditional-branch", - "line": 267, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "this device", - "surface": "apple", - "id": "native.apple.172ef5ed341ad73a" - }, - { - "kind": "conditional-branch", - "line": 272, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "Approve Device", - "surface": "apple", - "id": "native.apple.809e76375508d27d" - }, - { - "kind": "conditional-branch", - "line": 272, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "Approve Mac", - "surface": "apple", - "id": "native.apple.b5182817237683bc" - }, - { - "kind": "conditional-branch", - "line": 308, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "New device", - "surface": "apple", - "id": "native.apple.40a3483823dbbd36" - }, - { - "kind": "conditional-branch", - "line": 308, - "path": "apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift", - "source": "OpenClaw Mac app", - "surface": "apple", - "id": "native.apple.0702e353696c907f" - }, { "kind": "conditional-branch", "line": 19, @@ -17403,15 +17339,15 @@ }, { "kind": "ui-named-argument", - "line": 55, + "line": 53, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", - "source": "Pairing approval pending (\\(self.pairingPrompter.pendingCount))\\(repairSuffix)", + "source": "Pairing approval pending (\\(self.pairingPrompter.pendingCount))", "surface": "apple", - "id": "native.apple.8c3b9bd94bc2f8ef" + "id": "native.apple.66280f3e99ab2f36" }, { "kind": "conditional-branch", - "line": 60, + "line": 57, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": " · \\(repairCount) repair", "surface": "apple", @@ -17419,7 +17355,7 @@ }, { "kind": "ui-named-argument", - "line": 62, + "line": 59, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Device pairing pending (\\(self.devicePairingPrompter.pendingCount))\\(repairSuffix)", "surface": "apple", @@ -17427,7 +17363,7 @@ }, { "kind": "ui-call", - "line": 72, + "line": 68, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Heartbeats", "surface": "apple", @@ -17435,7 +17371,7 @@ }, { "kind": "ui-call", - "line": 84, + "line": 80, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Browser Control", "surface": "apple", @@ -17443,7 +17379,7 @@ }, { "kind": "ui-call", - "line": 87, + "line": 83, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Allow Camera", "surface": "apple", @@ -17451,7 +17387,7 @@ }, { "kind": "ui-call", - "line": 94, + "line": 90, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Exec Approvals", "surface": "apple", @@ -17459,7 +17395,7 @@ }, { "kind": "ui-call", - "line": 97, + "line": 93, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Allow Canvas", "surface": "apple", @@ -17467,7 +17403,7 @@ }, { "kind": "ui-call", - "line": 105, + "line": 101, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Voice Wake", "surface": "apple", @@ -17475,7 +17411,7 @@ }, { "kind": "ui-call", - "line": 116, + "line": 112, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Dashboard", "surface": "apple", @@ -17483,7 +17419,7 @@ }, { "kind": "ui-call", - "line": 121, + "line": 117, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Chat", "surface": "apple", @@ -17491,7 +17427,7 @@ }, { "kind": "conditional-branch", - "line": 128, + "line": 124, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Close Canvas", "surface": "apple", @@ -17499,7 +17435,7 @@ }, { "kind": "conditional-branch", - "line": 128, + "line": 124, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Canvas", "surface": "apple", @@ -17507,7 +17443,7 @@ }, { "kind": "conditional-branch", - "line": 135, + "line": 131, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Stop Talk Mode", "surface": "apple", @@ -17515,7 +17451,7 @@ }, { "kind": "conditional-branch", - "line": 135, + "line": 131, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Talk Mode", "surface": "apple", @@ -17523,7 +17459,7 @@ }, { "kind": "ui-call", - "line": 140, + "line": 136, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Settings…", "surface": "apple", @@ -17531,7 +17467,7 @@ }, { "kind": "ui-call", - "line": 143, + "line": 139, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "About OpenClaw", "surface": "apple", @@ -17539,7 +17475,7 @@ }, { "kind": "ui-call", - "line": 145, + "line": 141, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Update ready, restart now?", "surface": "apple", @@ -17547,7 +17483,7 @@ }, { "kind": "ui-call", - "line": 147, + "line": 143, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Quit", "surface": "apple", @@ -17555,7 +17491,7 @@ }, { "kind": "conditional-branch", - "line": 179, + "line": 175, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "OpenClaw Not Configured", "surface": "apple", @@ -17563,7 +17499,7 @@ }, { "kind": "conditional-branch", - "line": 181, + "line": 177, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Remote OpenClaw Active", "surface": "apple", @@ -17571,7 +17507,7 @@ }, { "kind": "conditional-branch", - "line": 183, + "line": 179, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "OpenClaw Active", "surface": "apple", @@ -17579,7 +17515,7 @@ }, { "kind": "ui-call", - "line": 220, + "line": 216, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Debug", "surface": "apple", @@ -17587,7 +17523,7 @@ }, { "kind": "ui-call", - "line": 224, + "line": 220, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Config Folder", "surface": "apple", @@ -17595,7 +17531,7 @@ }, { "kind": "ui-call", - "line": 229, + "line": 225, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Run Health Check Now", "surface": "apple", @@ -17603,15 +17539,23 @@ }, { "kind": "ui-call", - "line": 234, + "line": 230, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Test Heartbeat", "surface": "apple", "id": "native.apple.406f1a5866bf0bd0" }, + { + "kind": "ui-call", + "line": 236, + "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", + "source": "Show Pairing Panel (Demo)", + "surface": "apple", + "id": "native.apple.79a6d2d396cd5477" + }, { "kind": "ui-named-argument", - "line": 240, + "line": 243, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Remote Tunnel", "surface": "apple", @@ -17619,7 +17563,7 @@ }, { "kind": "ui-call", - "line": 243, + "line": 246, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Reset Remote Tunnel", "surface": "apple", @@ -17627,7 +17571,7 @@ }, { "kind": "conditional-branch", - "line": 251, + "line": 254, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Verbose Logging (Main): Off", "surface": "apple", @@ -17635,7 +17579,7 @@ }, { "kind": "conditional-branch", - "line": 251, + "line": 254, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Verbose Logging (Main): On", "surface": "apple", @@ -17643,7 +17587,7 @@ }, { "kind": "ui-call", - "line": 256, + "line": 259, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Verbosity", "surface": "apple", @@ -17651,7 +17595,7 @@ }, { "kind": "conditional-branch", - "line": 264, + "line": 267, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "File Logging: Off", "surface": "apple", @@ -17659,7 +17603,7 @@ }, { "kind": "conditional-branch", - "line": 264, + "line": 267, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "File Logging: On", "surface": "apple", @@ -17667,7 +17611,7 @@ }, { "kind": "ui-call", - "line": 269, + "line": 272, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "App Logging", "surface": "apple", @@ -17675,7 +17619,7 @@ }, { "kind": "ui-call", - "line": 274, + "line": 277, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Session Store", "surface": "apple", @@ -17683,7 +17627,7 @@ }, { "kind": "ui-call", - "line": 280, + "line": 283, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Agent Events…", "surface": "apple", @@ -17691,7 +17635,7 @@ }, { "kind": "ui-call", - "line": 285, + "line": 288, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Open Log", "surface": "apple", @@ -17699,7 +17643,7 @@ }, { "kind": "ui-call", - "line": 290, + "line": 293, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Debug Voice Text", "surface": "apple", @@ -17707,7 +17651,7 @@ }, { "kind": "ui-call", - "line": 295, + "line": 298, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Send Test Notification", "surface": "apple", @@ -17715,7 +17659,7 @@ }, { "kind": "ui-call", - "line": 302, + "line": 305, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Restart Gateway", "surface": "apple", @@ -17723,7 +17667,7 @@ }, { "kind": "ui-call", - "line": 308, + "line": 311, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Restart Onboarding", "surface": "apple", @@ -17731,7 +17675,7 @@ }, { "kind": "ui-call", - "line": 313, + "line": 316, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Restart App", "surface": "apple", @@ -17739,7 +17683,7 @@ }, { "kind": "conditional-branch", - "line": 352, + "line": 355, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Main", "surface": "apple", @@ -17747,15 +17691,23 @@ }, { "kind": "conditional-branch", - "line": 352, + "line": 355, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Other", "surface": "apple", "id": "native.apple.5eca63e1a931e290" }, + { + "kind": "ui-modifier", + "line": 429, + "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", + "source": "Show pairing requests", + "surface": "apple", + "id": "native.apple.72d7e38d56c76ff8" + }, { "kind": "ui-call", - "line": 440, + "line": 454, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Refreshing microphones…", "surface": "apple", @@ -17763,7 +17715,7 @@ }, { "kind": "ui-call", - "line": 447, + "line": 461, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Microphone", "surface": "apple", @@ -17771,7 +17723,7 @@ }, { "kind": "ui-call", - "line": 468, + "line": 482, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Disconnected (using System default)", "surface": "apple", @@ -17915,7 +17867,7 @@ }, { "kind": "conditional-branch", - "line": 399, + "line": 428, "path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift", "source": "Node pairing approved", "surface": "apple", @@ -17923,7 +17875,7 @@ }, { "kind": "conditional-branch", - "line": 399, + "line": 428, "path": "apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift", "source": "Node pairing rejected", "surface": "apple", @@ -18985,6 +18937,94 @@ "surface": "apple", "id": "native.apple.b82621bc28d4c6e3" }, + { + "kind": "ui-call", + "line": 52, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "Not Now", + "surface": "apple", + "id": "native.apple.048e9cc88fb7af55" + }, + { + "kind": "conditional-branch", + "line": 67, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "Pairing Request", + "surface": "apple", + "id": "native.apple.57e27a9a3ef63ac4" + }, + { + "kind": "conditional-branch", + "line": 67, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "Pairing Requests", + "surface": "apple", + "id": "native.apple.61152c2ea359eaa0" + }, + { + "kind": "ui-modifier", + "line": 176, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "Copy full ID", + "surface": "apple", + "id": "native.apple.d3b539b5e822f581" + }, + { + "kind": "conditional-branch", + "line": 192, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "Approve Device", + "surface": "apple", + "id": "native.apple.01bd6552a4adee7c" + }, + { + "kind": "conditional-branch", + "line": 192, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "Approve Node", + "surface": "apple", + "id": "native.apple.b12ad4f00b1cd3d1" + }, + { + "kind": "ui-call", + "line": 214, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "Reject", + "surface": "apple", + "id": "native.apple.8878bb60553ad307" + }, + { + "kind": "conditional-branch", + "line": 258, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "A device wants to connect to OpenClaw.", + "surface": "apple", + "id": "native.apple.2e9d9d1f5d4346d4" + }, + { + "kind": "conditional-branch", + "line": 258, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "A node wants to connect to OpenClaw.", + "surface": "apple", + "id": "native.apple.11b1b5e9dd510766" + }, + { + "kind": "conditional-branch", + "line": 269, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "New device", + "surface": "apple", + "id": "native.apple.2412e85f7d11395e" + }, + { + "kind": "conditional-branch", + "line": 269, + "path": "apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift", + "source": "OpenClaw Mac app", + "surface": "apple", + "id": "native.apple.a09a61f4efe1e63e" + }, { "kind": "ui-named-argument", "line": 15, diff --git a/apps/macos/Sources/OpenClaw/DebugActions.swift b/apps/macos/Sources/OpenClaw/DebugActions.swift index 706d9cc2ca2..ba28619cd8e 100644 --- a/apps/macos/Sources/OpenClaw/DebugActions.swift +++ b/apps/macos/Sources/OpenClaw/DebugActions.swift @@ -251,6 +251,51 @@ enum DebugActions { proc.arguments = ["code", path] try? proc.run() } + + #if DEBUG + @MainActor + static func showPairingPanelDemo() { + let now = Date() + PairingApprovalCenter.shared.injectDemoCards([ + PairingApprovalCenter.Card( + kind: .node, + requestId: "demo-node-1", + subjectId: "19cec1c3301a7469d4fd71f5f81339508390dadda91b34aee15faf2849dccdc7", + displayName: "Peter's MacBook Pro", + platform: "macos 26.5", + deviceFamily: "Mac", + modelIdentifier: "MacBookPro18,3", + version: "2026.6.11", + coreVersion: "2026.6.10", + remoteIp: "192.168.1.42", + role: nil, + scopes: [], + caps: ["screen", "camera", "file"], + commands: ["system.run", "system.notify"], + isRepair: false, + previouslyPaired: false, + requestedAt: now.addingTimeInterval(-45)), + PairingApprovalCenter.Card( + kind: .device, + requestId: "demo-device-1", + subjectId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd", + displayName: "OpenClaw iPhone", + platform: "ios 26.4", + deviceFamily: nil, + modelIdentifier: nil, + version: nil, + coreVersion: nil, + remoteIp: "192.168.1.87", + role: "operator", + scopes: ["operator.read", "operator.write", "operator.approvals"], + caps: [], + commands: [], + isRepair: true, + previouslyPaired: true, + requestedAt: now.addingTimeInterval(-190)), + ]) + } + #endif } enum DebugActionError: LocalizedError { diff --git a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift index a8052de55e4..edeee0b418f 100644 --- a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift @@ -13,12 +13,17 @@ final class DevicePairingApprovalPrompter { private let logger = Logger(subsystem: "ai.openclaw", category: "device-pairing") private var task: Task? private var isStopping = false - private var isPresenting = false + private var listFetchGeneration = 0 private var queue: [PendingRequest] = [] var pendingCount: Int = 0 var pendingRepairCount: Int = 0 - private let alertState = PairingAlertState() - private var resolvedByRequestId: Set = [] + /// Device ids already paired on the gateway (from the last list fetch); + /// drives the "previously paired" trust signal on cards. + private var pairedDeviceIds: Set = [] + /// Requests that arrived via push after the last list fetch; their trust + /// state is unknown until fresh gateway truth applies (stale snapshots + /// must not produce a positive "previously paired" claim). + private var trustUnknownRequestIds: Set = [] private struct PairingList: Codable { let pending: [PendingRequest] @@ -53,14 +58,17 @@ final class DevicePairingApprovalPrompter { } } - private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent + private typealias PairingResolvedEvent = PairingPromptSupport.PairingResolvedEvent func start() { + PairingApprovalCenter.shared.register(kind: .device) { [weak self] card, decision in + await self?.handleDecision(card: card, decision: decision) + } self.startPushTask() } private func startPushTask() { - PairingAlertSupport.startPairingPushTask( + PairingPromptSupport.startPairingPushTask( task: &self.task, isStopping: &self.isStopping, loadPending: self.loadPendingRequestsFromGateway, @@ -68,33 +76,38 @@ final class DevicePairingApprovalPrompter { } func stop() { - self.stopPushTask() - self.updatePendingCounts() - self.resolvedByRequestId.removeAll(keepingCapacity: false) - } - - private func stopPushTask() { - PairingAlertSupport.stopPairingPrompter( + PairingPromptSupport.stopPairingPrompter( isStopping: &self.isStopping, task: &self.task, - queue: &self.queue, - isPresenting: &self.isPresenting, - state: self.alertState) + queue: &self.queue) + PairingApprovalCenter.shared.unregister(kind: .device) + self.updatePendingCounts() } private func loadPendingRequestsFromGateway() async { + // Push-triggered refreshes can overlap; only the newest snapshot may + // replace the queue or an older read would drop just-arrived requests. + self.listFetchGeneration += 1 + let generation = self.listFetchGeneration do { let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList) - await self.apply(list: list) + guard generation == self.listFetchGeneration else { return } + self.apply(list: list) } catch { self.logger.error("failed to load device pairing requests: \(error.localizedDescription, privacy: .public)") } } - private func apply(list: PairingList) async { - self.queue = list.pending.sorted(by: { $0.ts > $1.ts }) + private func apply(list: PairingList) { + if self.isStopping { + return + } + self.pairedDeviceIds = Set((list.paired ?? []).map(\.deviceId)) + self.queue = list.pending.sorted(by: { $0.ts < $1.ts }) + // This snapshot is authoritative for every pending request in it. + self.trustUnknownRequestIds.removeAll() self.updatePendingCounts() - self.presentNextIfNeeded() + self.syncCards() } private func updatePendingCounts() { @@ -102,71 +115,66 @@ final class DevicePairingApprovalPrompter { self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) } - private func presentNextIfNeeded() { + private func syncCards() { guard !self.isStopping else { return } - guard !self.isPresenting else { return } - guard let next = self.queue.first else { return } - self.isPresenting = true - self.presentAlert(for: next) + let cards = self.queue.map { self.card(for: $0) } + PairingApprovalCenter.shared.sync(kind: .device, cards: cards) } - private func presentAlert(for req: PendingRequest) { - self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)") - PairingAlertSupport.presentPairingAlert( - request: req, + private func card(for req: PendingRequest) -> PairingApprovalCenter.Card { + PairingApprovalCenter.Card( + kind: .device, requestId: req.requestId, - messageText: Self.alertTitle(for: req), - informativeText: Self.alertSummary(for: req), - buttonTitles: PairingAlertSupport.ButtonTitles(approve: Self.approveButtonTitle(for: req)), - accessoryView: Self.buildAccessoryView(for: req), - state: self.alertState, - onResponse: self.handleAlertResponse) + subjectId: req.deviceId, + displayName: req.displayName, + platform: req.platform, + deviceFamily: nil, + modelIdentifier: nil, + version: nil, + coreVersion: nil, + remoteIp: req.remoteIp, + role: req.role, + scopes: req.scopes ?? [], + caps: [], + commands: [], + isRepair: req.isRepair == true, + previouslyPaired: self.trustUnknownRequestIds.contains(req.requestId) + ? nil + : self.pairedDeviceIds.contains(req.deviceId), + requestedAt: Date(timeIntervalSince1970: req.ts / 1000)) } - private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { - var shouldRemove = response != .alertSecondButtonReturn - defer { - if shouldRemove { - if self.queue.first == request { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == request } - } - } - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - } - + private func handleDecision(card: PairingApprovalCenter.Card, decision: PairingApprovalCenter.Decision) async { guard !self.isStopping else { return } + guard let request = self.queue.first(where: { $0.requestId == card.requestId }) else { return } - if self.resolvedByRequestId.remove(request.requestId) != nil { - return - } - - switch response { - case .alertFirstButtonReturn: + switch decision { + case .approve: if await !(self.approve(requestId: request.requestId)) { // Stale request (expired or superseded on the gateway): re-sync the - // queue with gateway truth so accumulated stale alerts collapse at once. + // queue with gateway truth so accumulated stale cards collapse at once. await self.loadPendingRequestsFromGateway() + return } - case .alertSecondButtonReturn: - shouldRemove = false - if let idx = self.queue.firstIndex(of: request) { - self.queue.remove(at: idx) + case .reject: + if await !(self.reject(requestId: request.requestId)) { + // Failed reject leaves the request pending on the gateway; + // re-sync instead of hiding a still-live card. + await self.loadPendingRequestsFromGateway() + return } - self.queue.append(request) - return - case .alertThirdButtonReturn: - await self.reject(requestId: request.requestId) - default: - return } + + // Discard any in-flight list snapshot: it predates this resolution + // and applying it would resurrect the just-resolved card. + self.listFetchGeneration += 1 + self.queue.removeAll { $0.requestId == request.requestId } + self.updatePendingCounts() + self.syncCards() } private func approve(requestId: String) async -> Bool { - await PairingAlertSupport.approveRequest( + await PairingPromptSupport.approveRequest( requestId: requestId, kind: "device", logger: self.logger) @@ -175,8 +183,8 @@ final class DevicePairingApprovalPrompter { } } - private func reject(requestId: String) async { - await PairingAlertSupport.rejectRequest( + private func reject(requestId: String) async -> Bool { + await PairingPromptSupport.rejectRequest( requestId: requestId, kind: "device", logger: self.logger) @@ -185,10 +193,6 @@ final class DevicePairingApprovalPrompter { } } - private func endActiveAlert() { - PairingAlertSupport.endActiveAlert(state: self.alertState) - } - private func handle(push: GatewayPush) { switch push { case let .event(evt) where evt.event == "device.pair.requested": @@ -217,7 +221,7 @@ final class DevicePairingApprovalPrompter { /// The gateway keeps at most one live pending request per device, so a new /// requestId for the same device supersedes anything still queued for it. - /// Without this, missed/dropped resolve pushes pile up as alerts whose + /// Without this, missed/dropped resolve pushes pile up as cards whose /// approval can no longer succeed. Returns nil when the request is already queued. static func coalescedQueue(_ queue: [PendingRequest], adding req: PendingRequest) -> [PendingRequest]? { guard !queue.contains(where: { $0.requestId == req.requestId }) else { return nil } @@ -226,199 +230,23 @@ final class DevicePairingApprovalPrompter { private func enqueue(_ req: PendingRequest) { guard let next = Self.coalescedQueue(self.queue, adding: req) else { return } - let supersededActiveId = self.alertState.activeRequestId.flatMap { activeId in - self.queue.contains(where: { - $0.requestId == activeId && $0.deviceId == req.deviceId - }) ? activeId : nil - } self.queue = next - if let supersededActiveId { - // The visible alert is for a superseded requestId; close it so the - // fresh request presents instead of an approve that would no-op. - self.resolvedByRequestId.insert(supersededActiveId) - self.endActiveAlert() - } + self.trustUnknownRequestIds.insert(req.requestId) self.updatePendingCounts() - self.presentNextIfNeeded() + self.syncCards() + // The "previously paired" trust signal must not come from a stale + // startup snapshot; re-fetch gateway truth for each new request. + Task { @MainActor [weak self] in + await self?.loadPendingRequestsFromGateway() + } } private func handleResolved(_ resolved: PairingResolvedEvent) { - let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue - ? PairingAlertSupport.PairingResolution.approved - : PairingAlertSupport.PairingResolution.rejected - if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId { - self.resolvedByRequestId.insert(resolved.requestId) - self.endActiveAlert() - let decision = resolution.rawValue - self.logger.info( - "device pairing resolved while active requestId=\(resolved.requestId, privacy: .public) " + - "decision=\(decision, privacy: .public)") - return - } + // Discard any in-flight list snapshot taken before this resolution + // so it cannot resurrect the resolved card. + self.listFetchGeneration += 1 self.queue.removeAll { $0.requestId == resolved.requestId } self.updatePendingCounts() - } - - static func alertTitle(for req: PendingRequest) -> String { - self.isMac(req.platform) ? "New Mac wants to connect" : "New device wants to connect" - } - - static func alertSummary(for req: PendingRequest) -> String { - let subject = self.isMac(req.platform) ? "this Mac app" : "this device" - return "Approve \(subject) to control OpenClaw. Only approve if this is yours; you can remove it later in Settings." - } - - static func approveButtonTitle(for req: PendingRequest) -> String { - self.isMac(req.platform) ? "Approve Mac" : "Approve Device" - } - - static func buildAccessoryView(for req: PendingRequest) -> NSView { - let stack = NSStackView() - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 8 - stack.edgeInsets = NSEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) - - stack.addArrangedSubview(self.makeValueRow(label: "Device", value: self.deviceName(for: req))) - if let platform = self.prettyPlatform(req.platform) { - stack.addArrangedSubview(self.makeValueRow(label: "Platform", value: platform)) - } - if let role = self.prettyRole(req.role) { - stack.addArrangedSubview(self.makeValueRow(label: "Role", value: role)) - } - let accessItems = self.friendlyScopeNames(req.scopes) - if !accessItems.isEmpty { - stack.addArrangedSubview(self.makeSectionLabel("Access requested")) - for item in accessItems { - stack.addArrangedSubview(self.makeBullet(item)) - } - } - stack.addArrangedSubview(self.makeDetailLine(req)) - - let fitting = stack.fittingSize - stack.frame = NSRect(x: 0, y: 0, width: 420, height: fitting.height) - return stack - } - - static func deviceName(for req: PendingRequest) -> String { - let trimmedName = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedName, !trimmedName.isEmpty, trimmedName != req.deviceId { - return trimmedName - } - return self.isMac(req.platform) ? "OpenClaw Mac app" : "New device" - } - - static func prettyPlatform(_ raw: String?) -> String? { - let platform = raw?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let platform, !platform.isEmpty else { return nil } - switch platform.lowercased() { - case "macintel", "x86_64-apple-darwin": - return "Mac (Intel)" - case "macarm", "macarm64", "arm64-apple-darwin", "aarch64-apple-darwin": - return "Mac (Apple silicon)" - case "darwin": - return "Mac" - default: - if platform.lowercased().contains("mac") { - return "Mac" - } - return platform - } - } - - static func prettyRole(_ raw: String?) -> String? { - let role = raw?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let role, !role.isEmpty else { return nil } - return role == "operator" ? "Operator" : role - } - - static func friendlyScopeNames(_ scopes: [String]?) -> [String] { - guard let scopes else { return [] } - var seen = Set() - return scopes.compactMap { scope in - let normalized = scope.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty, seen.insert(normalized).inserted else { return nil } - switch normalized { - case "operator.admin": - return "Admin access" - case "operator.read": - return "Read OpenClaw data" - case "operator.write": - return "Send messages and make changes" - case "operator.approvals": - return "Manage approvals" - case "operator.pairing": - return "Pair and repair devices" - case "operator.talk.secrets": - return "Use Talk credentials" - default: - return normalized - } - } - } - - static func shortIdentifier(_ id: String) -> String { - let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.count > 20 else { return trimmed } - return "\(trimmed.prefix(8))...\(trimmed.suffix(7))" - } - - private static func isMac(_ platform: String?) -> Bool { - guard let platform else { return false } - let lower = platform.lowercased() - return lower.contains("mac") || lower.contains("darwin") - } - - private static func makeValueRow(label: String, value: String) -> NSView { - let row = NSStackView() - row.orientation = .horizontal - row.alignment = .firstBaseline - row.spacing = 8 - - let labelField = self.makeLabel("\(label):", font: .systemFont(ofSize: 12, weight: .semibold)) - labelField.textColor = .secondaryLabelColor - labelField.setContentHuggingPriority(.required, for: .horizontal) - let valueField = self.makeLabel(value, font: .systemFont(ofSize: 12, weight: .regular)) - valueField.maximumNumberOfLines = 2 - - row.addArrangedSubview(labelField) - row.addArrangedSubview(valueField) - return row - } - - private static func makeSectionLabel(_ text: String) -> NSTextField { - let label = self.makeLabel(text, font: .systemFont(ofSize: 12, weight: .semibold)) - label.textColor = .secondaryLabelColor - return label - } - - private static func makeBullet(_ text: String) -> NSTextField { - let label = self.makeLabel("• \(text)", font: .systemFont(ofSize: 12, weight: .regular)) - label.maximumNumberOfLines = 2 - return label - } - - private static func makeDetailLine(_ req: PendingRequest) -> NSTextField { - var parts = ["ID \(self.shortIdentifier(req.deviceId))"] - if let remoteIp = req.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines), !remoteIp.isEmpty { - parts.append("IP \(remoteIp.replacingOccurrences(of: "::ffff:", with: ""))") - } - if req.isRepair == true { - parts.append("repair request") - } - let label = self.makeLabel( - parts.joined(separator: " · "), - font: .monospacedSystemFont(ofSize: 11, weight: .regular)) - label.textColor = .tertiaryLabelColor - label.maximumNumberOfLines = 2 - return label - } - - private static func makeLabel(_ text: String, font: NSFont) -> NSTextField { - let label = NSTextField(labelWithString: text) - label.font = font - label.lineBreakMode = .byWordWrapping - label.textColor = .labelColor - return label + self.syncCards() } } diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index bd507defea0..95be923baea 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -390,6 +390,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") } + #if DEBUG + // Screenshot/demo helper: show the pairing panel with sample requests. + if ProcessInfo.processInfo.environment["OPENCLAW_DEBUG_PAIRING_DEMO"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + DebugActions.showPairingPanelDemo() + } + } + #endif // Developer/testing helper: auto-open chat when launched with --chat (or legacy --webchat). if CommandLine.arguments.contains("--chat") || CommandLine.arguments.contains("--webchat") { self.webChatAutoLogger.debug("Auto-opening chat via CLI flag") diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift index 3e9104b1eaf..8471d1e22e4 100644 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -49,18 +49,14 @@ struct MenuContent: View { self.statusLine(label: macNodeStatus.label, color: macNodeStatus.color) } if self.pairingPrompter.pendingCount > 0 { - let repairCount = self.pairingPrompter.pendingRepairCount - let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : "" - self.statusLine( - label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))\(repairSuffix)", - color: .orange) + self.pairingStatusLine( + label: "Pairing approval pending (\(self.pairingPrompter.pendingCount))") } if self.devicePairingPrompter.pendingCount > 0 { let repairCount = self.devicePairingPrompter.pendingRepairCount let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : "" - self.statusLine( - label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)", - color: .orange) + self.pairingStatusLine( + label: "Device pairing pending (\(self.devicePairingPrompter.pendingCount))\(repairSuffix)") } } } @@ -233,6 +229,13 @@ struct MenuContent: View { } label: { Label("Send Test Heartbeat", systemImage: "waveform.path.ecg") } + #if DEBUG + Button { + DebugActions.showPairingPanelDemo() + } label: { + Label("Show Pairing Panel (Demo)", systemImage: "checkmark.shield") + } + #endif if self.state.connectionMode == .remote { Button { Task { @MainActor in @@ -415,6 +418,17 @@ struct MenuContent: View { .padding(.top, 2) } + /// Pending-pairing status lines reopen the approval panel (e.g. after "Not Now"). + private func pairingStatusLine(label: String) -> some View { + Button { + PairingApprovalCenter.shared.showPanel() + } label: { + self.statusLine(label: label, color: .orange) + } + .buttonStyle(.plain) + .help("Show pairing requests") + } + private var activeBinding: Binding { Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 }) } diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift index adfb85d1fea..21a4931380e 100644 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -12,8 +12,8 @@ enum NodePairingReconcilePolicy { static let activeIntervalMs: UInt64 = 15000 static let resyncDelayMs: UInt64 = 250 - static func shouldPoll(pendingCount: Int, isPresenting: Bool) -> Bool { - pendingCount > 0 || isPresenting + static func shouldPoll(pendingCount: Int) -> Bool { + pendingCount > 0 } } @@ -41,13 +41,26 @@ final class NodePairingApprovalPrompter { private var reconcileOnceTask: Task? private var reconcileInFlight = false private var isStopping = false - private var isPresenting = false private var queue: [PendingRequest] = [] var pendingCount: Int = 0 - var pendingRepairCount: Int = 0 - private let alertState = PairingAlertState() - private var remoteResolutionsByRequestId: [String: PairingResolution] = [:] + /// Node ids already paired on the gateway (from the last list fetch); + /// drives the "previously paired" trust signal on cards. + private var pairedNodeIds: Set = [] + /// Requests that arrived via push after the last list fetch; their trust + /// state is unknown until fresh gateway truth applies (stale snapshots + /// must not produce a positive "previously paired" claim). + private var trustUnknownRequestIds: Set = [] private var autoApproveAttempts: Set = [] + /// Requests hidden from the panel while a silent/local auto-approve runs. + private var autoApproveInFlight: Set = [] + /// The gateway broadcasts `node.pair.resolved` before our approve/reject + /// RPC returns. Ids here mark decisions whose RPC is still in flight; + /// resolutions echoed for them are parked in + /// `echoedResolutionsByRequestId` so the awaiting path can report the + /// authoritative outcome exactly once (another operator may win the race + /// with the opposite decision). + private var pendingLocalDecisionRequestIds: Set = [] + private var echoedResolutionsByRequestId: [String: PairingResolution] = [:] private struct PairingList: Codable { let pending: [PendingRequest] @@ -63,14 +76,18 @@ final class NodePairingApprovalPrompter { let remoteIp: String? } - private struct PendingRequest: Codable, Equatable, Identifiable { + struct PendingRequest: Codable, Equatable, Identifiable { let requestId: String let nodeId: String let displayName: String? let platform: String? let version: String? + let coreVersion: String? + let deviceFamily: String? + let modelIdentifier: String? + let caps: [String]? + let commands: [String]? let remoteIp: String? - let isRepair: Bool? let silent: Bool? let ts: Double @@ -79,17 +96,20 @@ final class NodePairingApprovalPrompter { } } - private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent - private typealias PairingResolution = PairingAlertSupport.PairingResolution + private typealias PairingResolvedEvent = PairingPromptSupport.PairingResolvedEvent + private typealias PairingResolution = PairingPromptSupport.PairingResolution func start() { self.reconcileTask?.cancel() self.reconcileTask = nil + PairingApprovalCenter.shared.register(kind: .node) { [weak self] card, decision in + await self?.handleDecision(card: card, decision: decision) + } self.startPushTask() } private func startPushTask() { - PairingAlertSupport.startPairingPushTask( + PairingPromptSupport.startPairingPushTask( task: &self.task, isStopping: &self.isStopping, loadPending: self.loadPendingRequestsFromGateway, @@ -97,23 +117,21 @@ final class NodePairingApprovalPrompter { } func stop() { - self.stopPushTask() + PairingPromptSupport.stopPairingPrompter( + isStopping: &self.isStopping, + task: &self.task, + queue: &self.queue) + PairingApprovalCenter.shared.unregister(kind: .node) self.reconcileTask?.cancel() self.reconcileTask = nil self.reconcileOnceTask?.cancel() self.reconcileOnceTask = nil self.updatePendingCounts() - self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false) self.autoApproveAttempts.removeAll(keepingCapacity: false) - } - - private func stopPushTask() { - PairingAlertSupport.stopPairingPrompter( - isStopping: &self.isStopping, - task: &self.task, - queue: &self.queue, - isPresenting: &self.isPresenting, - state: self.alertState) + self.autoApproveInFlight.removeAll(keepingCapacity: false) + self.pendingLocalDecisionRequestIds.removeAll(keepingCapacity: false) + self.echoedResolutionsByRequestId.removeAll(keepingCapacity: false) + self.trustUnknownRequestIds.removeAll(keepingCapacity: false) } private func loadPendingRequestsFromGateway() async { @@ -121,7 +139,9 @@ final class NodePairingApprovalPrompter { // pending pairing prompts are still shown on launch. var delayMs: UInt64 = 200 for attempt in 1...8 { - if Task.isCancelled { return } + if Task.isCancelled { + return + } do { let data = try await GatewayConnection.shared.request( method: "node.pair.list", @@ -133,7 +153,7 @@ final class NodePairingApprovalPrompter { guard pendingCount > 0 else { return } self.logger.info( "loaded \(pendingCount, privacy: .public) pending node pairing request(s) on startup") - await self.apply(list: list) + self.apply(list: list) return } catch { if attempt == 8 { @@ -150,9 +170,11 @@ final class NodePairingApprovalPrompter { private func reconcileLoop() async { // Reconcile requests periodically so multiple running apps stay in sync - // (e.g. close dialogs + notify if another machine approves/rejects via app or CLI). + // (e.g. close cards + notify if another machine approves/rejects via app or CLI). while !Task.isCancelled { - if self.isStopping { break } + if self.isStopping { + break + } if !self.shouldPoll { self.reconcileTask = nil return @@ -172,8 +194,14 @@ final class NodePairingApprovalPrompter { return try JSONDecoder().decode(PairingList.self, from: data) } - private func apply(list: PairingList) async { - if self.isStopping { return } + private func apply(list: PairingList) { + if self.isStopping { + return + } + + self.pairedNodeIds = Set((list.paired ?? []).map(\.nodeId)) + // This snapshot is authoritative for every pending request in it. + self.trustUnknownRequestIds.removeAll() let pendingById = Dictionary( uniqueKeysWithValues: list.pending.map { ($0.requestId, $0) }) @@ -184,38 +212,27 @@ final class NodePairingApprovalPrompter { } // Detect resolved requests (approved/rejected elsewhere). - let queued = self.queue - for req in queued { - if pendingById[req.requestId] != nil { continue } + for req in self.queue where pendingById[req.requestId] == nil { let resolution = self.inferResolution(for: req, list: list) - - if self.alertState.activeRequestId == req.requestId, self.alertState.activeAlert != nil { - self.remoteResolutionsByRequestId[req.requestId] = resolution - self.logger.info( - """ - pairing request resolved elsewhere; closing dialog \ - requestId=\(req.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.endActiveAlert() - continue - } - self.logger.info( """ pairing request resolved elsewhere requestId=\(req.requestId, privacy: .public) \ resolution=\(resolution.rawValue, privacy: .public) """) self.queue.removeAll { $0 == req } - Task { @MainActor in - await self.notify(resolution: resolution, request: req, via: "remote") + // Same coordination as handleResolved: while our own RPC is in + // flight the awaiting path reports the outcome, not this one. + if self.pendingLocalDecisionRequestIds.contains(req.requestId) { + self.echoedResolutionsByRequestId[req.requestId] = resolution + } else { + Task { @MainActor in + await self.notify(resolution: resolution, request: req, via: "remote") + } } } - if self.queue.isEmpty { - self.isPresenting = false - } - self.presentNextIfNeeded() + self.updatePendingCounts() + self.syncCards() self.updateReconcileLoop() } @@ -224,23 +241,27 @@ final class NodePairingApprovalPrompter { guard let node = paired.first(where: { $0.nodeId == request.nodeId }) else { return .rejected } - if request.isRepair == true, let approvedAtMs = node.approvedAtMs { + // A previously paired node stays in the paired list even when this + // request was rejected; only an approval newer than the request proves approval. + if let approvedAtMs = node.approvedAtMs { return approvedAtMs >= request.ts ? .approved : .rejected } return .approved } - private func endActiveAlert() { - PairingAlertSupport.endActiveAlert(state: self.alertState) - } - private func handle(push: GatewayPush) { switch push { case let .event(evt) where evt.event == "node.pair.requested": guard let payload = evt.payload else { return } do { let req = try GatewayPayloadDecoding.decode(payload, as: PendingRequest.self) + self.trustUnknownRequestIds.insert(req.requestId) self.enqueue(req) + self.syncCards() + self.updateReconcileLoop() + // Refresh the paired list now so the card's "previously + // paired" trust signal reflects current gateway truth. + self.scheduleReconcileOnce(delayMs: 0) } catch { self.logger .error("failed to decode pairing request: \(error.localizedDescription, privacy: .public)") @@ -265,81 +286,118 @@ final class NodePairingApprovalPrompter { } private func enqueue(_ req: PendingRequest) { - if self.queue.contains(where: { $0.requestId == req.requestId }) { return } + if self.queue.contains(where: { $0.requestId == req.requestId }) { + return + } // The gateway keeps at most one live pending request per node; a newer // request supersedes queued ones so missed resolve pushes cannot stack - // stale alerts. The actively presented request stays owned by its alert. - self.queue.removeAll { $0.nodeId == req.nodeId && $0.requestId != self.alertState.activeRequestId } + // stale cards. + self.queue.removeAll { $0.nodeId == req.nodeId } self.queue.append(req) self.updatePendingCounts() - self.presentNextIfNeeded() + self.beginAutoApproveIfEligible(req) + } + + /// Auto-approve runs before the request surfaces in the panel: the app's + /// own local node pairs silently, and `silent` requests are approved after + /// an SSH trust probe. Only failed attempts fall through to the UI. + private func beginAutoApproveIfEligible(_ req: PendingRequest) { + guard !self.autoApproveAttempts.contains(req.requestId) else { return } + guard self.isAutoApproveCandidate(req) else { return } + self.autoApproveInFlight.insert(req.requestId) + Task { @MainActor [weak self] in + guard let self else { return } + let approved = await self.tryAutomaticApproveIfPossible(req) + self.autoApproveInFlight.remove(req.requestId) + if approved { + self.queue.removeAll { $0.requestId == req.requestId } + self.updatePendingCounts() + } + self.syncCards() + self.updateReconcileLoop() + } + } + + private func isAutoApproveCandidate(_ req: PendingRequest) -> Bool { + if req.silent == true { + return true + } + let localNodeId = DeviceIdentityStore.loadOrCreate( + profile: MacNodeModeCoordinator.nodeIdentityProfile).deviceId + return Self.shouldAutoApproveOwnLocalNode( + connectionMode: AppStateStore.shared.connectionMode, + requestNodeId: req.nodeId, + localNodeId: localNodeId) + } + + private func syncCards() { + guard !self.isStopping else { return } + let cards = self.queue + .filter { !self.autoApproveInFlight.contains($0.requestId) } + .map { self.card(for: $0) } + PairingApprovalCenter.shared.sync(kind: .node, cards: cards) + } + + private func card(for req: PendingRequest) -> PairingApprovalCenter.Card { + PairingApprovalCenter.Card( + kind: .node, + requestId: req.requestId, + subjectId: req.nodeId, + displayName: req.displayName, + platform: req.platform, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, + version: req.version, + coreVersion: req.coreVersion, + remoteIp: req.remoteIp, + role: nil, + scopes: [], + caps: req.caps ?? [], + commands: req.commands ?? [], + isRepair: false, + previouslyPaired: self.trustUnknownRequestIds.contains(req.requestId) + ? nil + : self.pairedNodeIds.contains(req.nodeId), + requestedAt: Date(timeIntervalSince1970: req.ts / 1000)) + } + + private func handleDecision(card: PairingApprovalCenter.Card, decision: PairingApprovalCenter.Decision) async { + guard !self.isStopping else { return } + guard let request = self.queue.first(where: { $0.requestId == card.requestId }) else { return } + + self.pendingLocalDecisionRequestIds.insert(request.requestId) + let expected: PairingResolution = decision == .approve ? .approved : .rejected + let rpcOk: Bool = switch decision { + case .approve: + await self.approve(requestId: request.requestId) + case .reject: + await self.reject(requestId: request.requestId) + } + self.pendingLocalDecisionRequestIds.remove(request.requestId) + + if let echoed = self.echoedResolutionsByRequestId.removeValue(forKey: request.requestId) { + // The gateway resolved this request while our RPC was in flight + // (possibly another operator with the opposite decision); report + // the authoritative outcome, not what the user asked for. + let via = rpcOk && echoed == expected ? "local" : "remote" + await self.notify(resolution: echoed, request: request, via: via) + } else if rpcOk { + await self.notify(resolution: expected, request: request, via: "local") + } else { + // RPC failed and nothing resolved it elsewhere: keep the card and + // re-sync with gateway truth instead of claiming an outcome. + self.scheduleReconcileOnce(delayMs: 0) + return + } + + self.queue.removeAll { $0.requestId == request.requestId } + self.updatePendingCounts() + self.syncCards() self.updateReconcileLoop() } - private func presentNextIfNeeded() { - guard !self.isStopping else { return } - guard !self.isPresenting else { return } - guard let next = self.queue.first else { return } - self.isPresenting = true - Task { @MainActor [weak self] in - guard let self else { return } - if await self.tryAutomaticApproveIfPossible(next) { - return - } - self.presentAlert(for: next) - } - } - - private func presentAlert(for req: PendingRequest) { - self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)") - PairingAlertSupport.presentPairingAlert( - request: req, - requestId: req.requestId, - messageText: "Allow node to connect?", - informativeText: Self.describe(req), - buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Node"), - state: self.alertState, - onResponse: self.handleAlertResponse) - } - - private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async { - defer { - if self.queue.first == request { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == request } - } - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - self.updateReconcileLoop() - } - - // Never approve/reject while shutting down (alerts can get dismissed during app termination). - guard !self.isStopping else { return } - - if let resolved = self.remoteResolutionsByRequestId.removeValue(forKey: request.requestId) { - await self.notify(resolution: resolved, request: request, via: "remote") - return - } - - switch response { - case .alertFirstButtonReturn: - _ = await self.approve(requestId: request.requestId) - await self.notify(resolution: .approved, request: request, via: "local") - case .alertSecondButtonReturn: - // Later: leave as pending (CLI can approve/reject). Request will expire on the gateway TTL. - return - case .alertThirdButtonReturn: - await self.reject(requestId: request.requestId) - await self.notify(resolution: .rejected, request: request, via: "local") - default: - return - } - } - private func approve(requestId: String) async -> Bool { - await PairingAlertSupport.approveRequest( + await PairingPromptSupport.approveRequest( requestId: requestId, kind: "node", logger: self.logger) @@ -348,8 +406,8 @@ final class NodePairingApprovalPrompter { } } - private func reject(requestId: String) async { - await PairingAlertSupport.rejectRequest( + private func reject(requestId: String) async -> Bool { + await PairingPromptSupport.rejectRequest( requestId: requestId, kind: "node", logger: self.logger) @@ -358,35 +416,6 @@ final class NodePairingApprovalPrompter { } } - private static func describe(_ req: PendingRequest) -> String { - let name = req.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) - let platform = self.prettyPlatform(req.platform) - let version = req.version?.trimmingCharacters(in: .whitespacesAndNewlines) - let ip = self.prettyIP(req.remoteIp) - - var lines: [String] = [] - lines.append("Name: \(name?.isEmpty == false ? name! : "Unknown")") - lines.append("Node ID: \(req.nodeId)") - if let platform, !platform.isEmpty { lines.append("Platform: \(platform)") } - if let version, !version.isEmpty { lines.append("App: \(version)") } - if let ip, !ip.isEmpty { lines.append("IP: \(ip)") } - if req.isRepair == true { lines.append("Note: Repair request (token will rotate).") } - return lines.joined(separator: "\n") - } - - private static func prettyIP(_ ip: String?) -> String? { - let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let trimmed, !trimmed.isEmpty else { return nil } - return trimmed.replacingOccurrences(of: "::ffff:", with: "") - } - - private static func prettyPlatform(_ platform: String?) -> String? { - let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines) - guard let raw, !raw.isEmpty else { return nil } - if let pretty = PlatformLabelFormatter.pretty(raw) { return pretty } - return raw - } - private func notify(resolution: PairingResolution, request: PendingRequest, via: String) async { let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() @@ -449,6 +478,11 @@ final class NodePairingApprovalPrompter { } private func approveAutomatically(_ req: PendingRequest, via: String, notify: Bool) async -> Bool { + self.pendingLocalDecisionRequestIds.insert(req.requestId) + defer { + self.pendingLocalDecisionRequestIds.remove(req.requestId) + self.echoedResolutionsByRequestId.removeValue(forKey: req.requestId) + } guard await self.approve(requestId: req.requestId) else { self.logger.info("automatic pairing approve failed requestId=\(req.requestId, privacy: .public)") return false @@ -459,16 +493,6 @@ final class NodePairingApprovalPrompter { if notify { await self.notify(resolution: .approved, request: req, via: via) } - if self.queue.first == req { - self.queue.removeFirst() - } else { - self.queue.removeAll { $0 == req } - } - - self.updatePendingCounts() - self.isPresenting = false - self.presentNextIfNeeded() - self.updateReconcileLoop() return true } @@ -552,9 +576,7 @@ final class NodePairingApprovalPrompter { } private var shouldPoll: Bool { - NodePairingReconcilePolicy.shouldPoll( - pendingCount: self.queue.count, - isPresenting: self.isPresenting) + NodePairingReconcilePolicy.shouldPoll(pendingCount: self.queue.count) } private func updateReconcileLoop() { @@ -574,17 +596,20 @@ final class NodePairingApprovalPrompter { private func updatePendingCounts() { // Keep a cheap observable summary for the menu bar status line. self.pendingCount = self.queue.count - self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true }) } private func reconcileOnce(timeoutMs: Double) async { - if self.isStopping { return } - if self.reconcileInFlight { return } + if self.isStopping { + return + } + if self.reconcileInFlight { + return + } self.reconcileInFlight = true defer { self.reconcileInFlight = false } do { let list = try await self.fetchPairingList(timeoutMs: timeoutMs) - await self.apply(list: list) + self.apply(list: list) } catch { // best effort: ignore transient connectivity failures } @@ -605,30 +630,21 @@ final class NodePairingApprovalPrompter { let resolution: PairingResolution = resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected - if self.alertState.activeRequestId == resolved.requestId, self.alertState.activeAlert != nil { - self.remoteResolutionsByRequestId[resolved.requestId] = resolution - self.logger.info( - """ - pairing request resolved elsewhere; closing dialog \ - requestId=\(resolved.requestId, privacy: .public) \ - resolution=\(resolution.rawValue, privacy: .public) - """) - self.endActiveAlert() - return - } - guard let request = self.queue.first(where: { $0.requestId == resolved.requestId }) else { return } self.queue.removeAll { $0.requestId == resolved.requestId } self.updatePendingCounts() - Task { @MainActor in - await self.notify(resolution: resolution, request: request, via: "remote") + self.syncCards() + if self.pendingLocalDecisionRequestIds.contains(resolved.requestId) { + // Our own approve/reject RPC is still in flight; park the + // authoritative outcome for that path to report exactly once. + self.echoedResolutionsByRequestId[resolved.requestId] = resolution + } else { + Task { @MainActor in + await self.notify(resolution: resolution, request: request, via: "remote") + } } - if self.queue.isEmpty { - self.isPresenting = false - } - self.presentNextIfNeeded() self.updateReconcileLoop() } } @@ -648,8 +664,12 @@ extension NodePairingApprovalPrompter { displayName: "Node One", platform: "macos", version: "1.0.0", + coreVersion: "1.0.0", + deviceFamily: "Mac", + modelIdentifier: "MacBookPro18,3", + caps: ["screen"], + commands: ["system.run"], remoteIp: "127.0.0.1", - isRepair: false, silent: true, ts: 1_700_000_000_000) let paired = PairedNode( @@ -661,9 +681,7 @@ extension NodePairingApprovalPrompter { remoteIp: "127.0.0.1") let list = PairingList(pending: [pending], paired: [paired]) - _ = Self.describe(pending) - _ = Self.prettyIP(pending.remoteIp) - _ = Self.prettyPlatform(pending.platform) + _ = prompter.card(for: pending) _ = prompter.inferResolution(for: pending, list: list) prompter.queue = [pending] diff --git a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift deleted file mode 100644 index 6b2e070cbca..00000000000 --- a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift +++ /dev/null @@ -1,307 +0,0 @@ -import AppKit -import OpenClawKit -import OSLog - -final class PairingAlertHostWindow: NSWindow { - override var canBecomeKey: Bool { - true - } - - override var canBecomeMain: Bool { - true - } -} - -@MainActor -final class PairingAlertState { - var activeAlert: NSAlert? - var activeRequestId: String? - var alertHostWindow: NSWindow? -} - -@MainActor -enum PairingAlertSupport { - enum PairingResolution: String { - case approved - case rejected - } - - struct ButtonTitles { - var approve: String = "Approve" - var postpone: String = "Not Now" - var reject: String = "Reject" - - init(approve: String = "Approve", postpone: String = "Not Now", reject: String = "Reject") { - self.approve = approve - self.postpone = postpone - self.reject = reject - } - } - - struct PairingResolvedEvent: Codable { - let requestId: String - let decision: String - let ts: Double - } - - static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) { - guard let alert = activeAlert else { return } - if let parent = alert.window.sheetParent { - parent.endSheet(alert.window, returnCode: .abort) - } - activeAlert = nil - activeRequestId = nil - } - - static func endActiveAlert(state: PairingAlertState) { - self.endActiveAlert(activeAlert: &state.activeAlert, activeRequestId: &state.activeRequestId) - } - - static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow { - if let alertHostWindow { - return alertHostWindow - } - - let window = PairingAlertHostWindow( - contentRect: NSRect(x: 0, y: 0, width: 520, height: 1), - styleMask: [.borderless], - backing: .buffered, - defer: false) - window.title = "" - window.isReleasedWhenClosed = false - window.level = .floating - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.isOpaque = false - window.hasShadow = false - window.backgroundColor = .clear - window.ignoresMouseEvents = true - - alertHostWindow = window - return window - } - - static func configureDefaultPairingAlert( - _ alert: NSAlert, - messageText: String, - informativeText: String, - buttonTitles: ButtonTitles = ButtonTitles(), - accessoryView: NSView? = nil) - { - alert.alertStyle = .informational - alert.messageText = messageText - alert.informativeText = informativeText - alert.accessoryView = accessoryView - alert.addButton(withTitle: buttonTitles.approve) - alert.addButton(withTitle: buttonTitles.postpone) - alert.addButton(withTitle: buttonTitles.reject) - if #available(macOS 11.0, *), alert.buttons.indices.contains(2) { - alert.buttons[2].hasDestructiveAction = true - } - } - - static func beginCenteredSheet( - alert: NSAlert, - hostWindow: NSWindow, - completionHandler: @escaping (NSApplication.ModalResponse) -> Void) - { - let sheetSize = alert.window.frame.size - if let screen = hostWindow.screen ?? NSScreen.main { - let bounds = screen.visibleFrame - let x = bounds.midX - (sheetSize.width / 2) - let sheetOriginY = bounds.midY - (sheetSize.height / 2) - let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height - hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY)) - } else { - hostWindow.center() - } - hostWindow.makeKeyAndOrderFront(nil) - alert.beginSheetModal(for: hostWindow, completionHandler: completionHandler) - } - - static func runPairingPushTask( - bufferingNewest: Int = 200, - loadPending: @escaping @MainActor () async -> Void, - handlePush: @escaping @MainActor (GatewayPush) -> Void) async - { - _ = try? await GatewayConnection.shared.refresh() - await loadPending() - await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush) - } - - static func startPairingPushTask( - task: inout Task?, - isStopping: inout Bool, - bufferingNewest: Int = 200, - loadPending: @escaping @MainActor () async -> Void, - handlePush: @escaping @MainActor (GatewayPush) -> Void) - { - guard task == nil else { return } - isStopping = false - task = Task { - await self.runPairingPushTask( - bufferingNewest: bufferingNewest, - loadPending: loadPending, - handlePush: handlePush) - } - } - - static func beginPairingAlert( - messageText: String, - informativeText: String, - buttonTitles: ButtonTitles = ButtonTitles(), - accessoryView: NSView? = nil, - alertHostWindow: inout NSWindow?, - completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert - { - NSApp.activate(ignoringOtherApps: true) - - let alert = NSAlert() - self.configureDefaultPairingAlert( - alert, - messageText: messageText, - informativeText: informativeText, - buttonTitles: buttonTitles, - accessoryView: accessoryView) - - let hostWindow = self.requireAlertHostWindow(alertHostWindow: &alertHostWindow) - self.beginCenteredSheet(alert: alert, hostWindow: hostWindow) { response in - completion(response, hostWindow) - } - return alert - } - - static func presentPairingAlert( - requestId: String, - messageText: String, - informativeText: String, - buttonTitles: ButtonTitles = ButtonTitles(), - accessoryView: NSView? = nil, - activeAlert: inout NSAlert?, - activeRequestId: inout String?, - alertHostWindow: inout NSWindow?, - completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) - { - activeRequestId = requestId - activeAlert = self.beginPairingAlert( - messageText: messageText, - informativeText: informativeText, - buttonTitles: buttonTitles, - accessoryView: accessoryView, - alertHostWindow: &alertHostWindow, - completion: completion) - } - - static func presentPairingAlert( - request: Request, - requestId: String, - messageText: String, - informativeText: String, - buttonTitles: ButtonTitles = ButtonTitles(), - accessoryView: NSView? = nil, - state: PairingAlertState, - onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void) - { - self.presentPairingAlert( - requestId: requestId, - messageText: messageText, - informativeText: informativeText, - buttonTitles: buttonTitles, - accessoryView: accessoryView, - activeAlert: &state.activeAlert, - activeRequestId: &state.activeRequestId, - alertHostWindow: &state.alertHostWindow, - completion: { response, hostWindow in - Task { @MainActor in - self.clearActivePairingAlert(state: state, hostWindow: hostWindow) - await onResponse(response, request) - } - }) - } - - static func clearActivePairingAlert( - activeAlert: inout NSAlert?, - activeRequestId: inout String?, - hostWindow: NSWindow) - { - activeRequestId = nil - activeAlert = nil - hostWindow.orderOut(nil) - } - - static func clearActivePairingAlert(state: PairingAlertState, hostWindow: NSWindow) { - self.clearActivePairingAlert( - activeAlert: &state.activeAlert, - activeRequestId: &state.activeRequestId, - hostWindow: hostWindow) - } - - static func stopPairingPrompter( - isStopping: inout Bool, - activeAlert: inout NSAlert?, - activeRequestId: inout String?, - task: inout Task?, - queue: inout [some Any], - isPresenting: inout Bool, - alertHostWindow: inout NSWindow?) - { - isStopping = true - self.endActiveAlert(activeAlert: &activeAlert, activeRequestId: &activeRequestId) - task?.cancel() - task = nil - queue.removeAll(keepingCapacity: false) - isPresenting = false - activeRequestId = nil - alertHostWindow?.orderOut(nil) - alertHostWindow?.close() - alertHostWindow = nil - } - - static func stopPairingPrompter( - isStopping: inout Bool, - task: inout Task?, - queue: inout [some Any], - isPresenting: inout Bool, - state: PairingAlertState) - { - self.stopPairingPrompter( - isStopping: &isStopping, - activeAlert: &state.activeAlert, - activeRequestId: &state.activeRequestId, - task: &task, - queue: &queue, - isPresenting: &isPresenting, - alertHostWindow: &state.alertHostWindow) - } - - static func approveRequest( - requestId: String, - kind: String, - logger: Logger, - action: @escaping () async throws -> Void) async -> Bool - { - do { - try await action() - logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)") - return true - } catch { - logger.error("approve failed requestId=\(requestId, privacy: .public)") - logger.error("approve failed: \(error.localizedDescription, privacy: .public)") - return false - } - } - - static func rejectRequest( - requestId: String, - kind: String, - logger: Logger, - action: @escaping () async throws -> Void) async - { - do { - try await action() - logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)") - } catch { - logger.error("reject failed requestId=\(requestId, privacy: .public)") - logger.error("reject failed: \(error.localizedDescription, privacy: .public)") - } - } -} diff --git a/apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift b/apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift new file mode 100644 index 00000000000..64f62040c15 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/PairingApprovalCenter.swift @@ -0,0 +1,289 @@ +import AppKit +import Foundation +import Observation +import SwiftUI + +/// Unified store + window owner for node/device pairing approvals. Both +/// prompters feed request cards here; one floating panel renders them all so +/// simultaneous requests never stack serial dialogs. +@MainActor +@Observable +final class PairingApprovalCenter { + static let shared = PairingApprovalCenter() + + enum Kind: String, CaseIterable { + case node + case device + } + + enum Decision { + case approve + case reject + } + + struct Card: Identifiable, Equatable { + let kind: Kind + let requestId: String + /// nodeId or deviceId. + let subjectId: String + let displayName: String? + let platform: String? + let deviceFamily: String? + let modelIdentifier: String? + let version: String? + let coreVersion: String? + let remoteIp: String? + /// Device pairing operator role. + let role: String? + /// Device pairing operator scopes. + let scopes: [String] + /// Node capability surface. + let caps: [String] + /// Node command surface (drives the elevated-access warning). + let commands: [String] + let isRepair: Bool + /// nil until a fresh gateway list confirms pairing history; a positive + /// trust claim must never come from a stale snapshot. + let previouslyPaired: Bool? + let requestedAt: Date + + var id: String { + self.requestId + } + } + + typealias DecisionHandler = @MainActor (Card, Decision) async -> Void + + private(set) var cards: [Card] = [] + private(set) var decisionsInFlight: Set = [] + private var handlersByKind: [Kind: DecisionHandler] = [:] + private var panel: PairingApprovalPanelController? + /// Request ids visible when the user chose "Not Now"; the panel reopens + /// automatically only when a request they have not seen yet arrives. + private var snoozedRequestIds: Set = [] + /// Request ids the visible panel has already presented. Periodic queue + /// syncs must not re-activate the app; only genuinely new requests may. + private var presentedRequestIds: Set = [] + + func register(kind: Kind, handler: @escaping DecisionHandler) { + self.handlersByKind[kind] = handler + } + + func unregister(kind: Kind) { + self.handlersByKind[kind] = nil + self.sync(kind: kind, cards: []) + } + + /// Replace all cards of one kind (the owning prompter's queue is the + /// source of truth). Cards stay ordered oldest-first across both kinds. + func sync(kind: Kind, cards: [Card]) { + var others = self.cards.filter { $0.kind != kind } + #if DEBUG + others += self.cards.filter { $0.kind == kind && self.demoRequestIds.contains($0.requestId) } + #endif + // requestId tiebreaker: Swift sort is not stable and equal timestamps + // would let cards swap positions between syncs. + self.cards = (others + cards).sorted { + ($0.requestedAt, $0.requestId) < ($1.requestedAt, $1.requestId) + } + self.snoozedRequestIds.formIntersection(self.cards.map(\.id)) + self.updatePanel() + } + + func decide(_ card: Card, _ decision: Decision) { + #if DEBUG + if self.demoRequestIds.contains(card.requestId) { + self.demoRequestIds.remove(card.requestId) + self.cards.removeAll { $0.requestId == card.requestId } + self.updatePanel() + return + } + #endif + guard let handler = self.handlersByKind[card.kind] else { return } + guard self.decisionsInFlight.insert(card.requestId).inserted else { return } + Task { @MainActor in + await handler(card, decision) + self.decisionsInFlight.remove(card.requestId) + } + } + + /// "Not Now": hide the panel without resolving anything. Requests stay + /// pending on the gateway (TTL applies) and in the menu-bar count. + func snooze() { + self.snoozedRequestIds = Set(self.cards.map(\.id)) + self.panel?.hide() + } + + func showPanel() { + guard !self.cards.isEmpty else { return } + self.snoozedRequestIds.removeAll() + self.presentedRequestIds.formUnion(self.cards.map(\.id)) + self.ensurePanel().show() + } + + static func shouldAutoPresent(cardIds: [String], snoozedIds: Set) -> Bool { + !cardIds.isEmpty && !cardIds.allSatisfy { snoozedIds.contains($0) } + } + + private func updatePanel() { + if self.cards.isEmpty { + self.snoozedRequestIds.removeAll() + self.presentedRequestIds.removeAll() + self.panel?.hide() + return + } + let cardIds = self.cards.map(\.id) + guard Self.shouldAutoPresent(cardIds: cardIds, snoozedIds: self.snoozedRequestIds) else { + self.panel?.refreshLayout() + return + } + // Activate (app focus + key panel) only for the first presentation or + // a request not shown yet; periodic reconcile syncs merely refresh, so + // the panel cannot steal focus every poll interval. + let hasNewRequests = !cardIds.allSatisfy { self.presentedRequestIds.contains($0) } + self.snoozedRequestIds.removeAll() + self.presentedRequestIds.formUnion(cardIds) + let panel = self.ensurePanel() + if panel.isPanelVisible, !hasNewRequests { + panel.refreshLayout() + } else { + panel.show() + } + } + + private func ensurePanel() -> PairingApprovalPanelController { + if let panel = self.panel { + return panel + } + let panel = PairingApprovalPanelController(center: self) + self.panel = panel + return panel + } + + #if DEBUG + /// Demo/screenshot hook: decisions on injected cards resolve locally + /// instead of routing to a prompter. + private var demoRequestIds: Set = [] + + func injectDemoCards(_ cards: [Card]) { + self.demoRequestIds.formUnion(cards.map(\.id)) + let existing = Set(self.cards.map(\.id)) + self.cards += cards.filter { !existing.contains($0.id) } + self.snoozedRequestIds.removeAll() + self.updatePanel() + } + #endif +} + +/// Borderless floating panel hosting the SwiftUI approval UI. Replaces the +/// old invisible-host-window + NSAlert sheet machinery. +@MainActor +final class PairingApprovalPanelController { + private final class KeyablePanel: NSPanel { + override var canBecomeKey: Bool { + true + } + + override var canBecomeMain: Bool { + true + } + } + + private let center: PairingApprovalCenter + private var panel: NSPanel? + private var hostingView: NSHostingView? + + static let panelWidth: CGFloat = 460 + + init(center: PairingApprovalCenter) { + self.center = center + } + + var isPanelVisible: Bool { + self.panel?.isVisible == true + } + + func show() { + let panel = self.ensurePanel() + self.applyFittingFrame() + // Approval is a security decision: bring the app forward like the old + // NSAlert flow so the prompt is keyboard-actionable immediately. + NSApp.activate(ignoringOtherApps: true) + panel.makeKeyAndOrderFront(nil) + // No initial focus ring (matches NSAlert); Tab starts keyboard + // navigation, Return approves a single request, Esc snoozes. + panel.makeFirstResponder(nil) + self.refreshLayout() + } + + func hide() { + self.panel?.orderOut(nil) + } + + func refreshLayout() { + // The root view stays installed (it observes the center directly); + // replacing it would reset SwiftUI focus. Re-measure on the next tick + // so pending Observation-driven updates are applied first. + DispatchQueue.main.async { [weak self] in + self?.applyFittingFrame() + } + } + + private func applyFittingFrame() { + guard let panel = self.panel, let host = self.hostingView else { return } + var size = host.fittingSize + // Request metadata sizes the panel; clamp to the screen so the action + // buttons stay reachable (the card list scrolls inside). + if let screen = panel.screen ?? NSScreen.main { + size.height = min(size.height, screen.visibleFrame.height * 0.85) + } + let target = self.centeredFrame(for: size) + if target == panel.frame { + return + } + if panel.isVisible { + panel.setFrame(target, display: true, animate: true) + } else { + panel.setFrame(target, display: true) + } + } + + private func ensurePanel() -> NSPanel { + if let panel = self.panel { + return panel + } + let panel = KeyablePanel( + contentRect: NSRect(x: 0, y: 0, width: Self.panelWidth, height: 200), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false) + panel.isOpaque = false + panel.backgroundColor = .clear + // The SwiftUI view draws its own rounded shadow; the window shadow + // would trace the square window frame and look like a border. + panel.hasShadow = false + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isMovableByWindowBackground = true + panel.isReleasedWhenClosed = false + panel.becomesKeyOnlyIfNeeded = false + + let host = NSHostingView(rootView: PairingApprovalPanelView(center: self.center)) + panel.contentView = host + self.hostingView = host + self.panel = panel + return panel + } + + private func centeredFrame(for size: NSSize) -> NSRect { + guard let screen = self.panel?.screen ?? NSScreen.main else { + return NSRect(origin: .zero, size: size) + } + let bounds = screen.visibleFrame + // Slightly above center, where system alerts sit. + let x = bounds.midX - size.width / 2 + let y = bounds.midY - size.height / 2 + bounds.height * 0.08 + return NSRect(x: x, y: y, width: size.width, height: size.height) + } +} diff --git a/apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift b/apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift new file mode 100644 index 00000000000..2edc22f35a7 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/PairingApprovalPanelView.swift @@ -0,0 +1,570 @@ +import AppKit +import SwiftUI + +/// Floating approval UI listing every pending pairing request as a card. +/// Liquid Glass surface on macOS 26+, material fallback on macOS 15. +struct PairingApprovalPanelView: View { + let center: PairingApprovalCenter + + /// Transparent margin around the glass so the drawn shadow has room; + /// the NSPanel shadow is disabled (it would trace a square window edge). + static let shadowMargin: CGFloat = 32 + + var body: some View { + self.surface + .frame(width: PairingApprovalPanelController.panelWidth) + .compositingGroup() + .shadow(color: .black.opacity(0.28), radius: 22, x: 0, y: 10) + .padding(Self.shadowMargin) + } + + @ViewBuilder + private var surface: some View { + if #available(macOS 26.0, *) { + self.content + .glassEffect(.regular, in: .rect(cornerRadius: 24)) + } else { + self.content + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24)) + } + } + + private var content: some View { + let cards = self.center.cards + return VStack(alignment: .leading, spacing: 12) { + self.header(cards: cards) + // Every request renders and stays actionable; the scroll plus the + // controller's screen-height clamp bound hostile queue sizes. + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 12) { + ForEach(cards) { card in + PairingRequestCardView( + card: card, + isBusy: self.center.decisionsInFlight.contains(card.requestId), + isOnlyRequest: cards.count == 1, + onDecision: { self.center.decide(card, $0) }) + } + } + } + .scrollBounceBehavior(.basedOnSize) + HStack { + Spacer() + Button("Not Now") { self.center.snooze() } + .keyboardShortcut(.cancelAction) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + .padding(18) + } + + private func header(cards: [PairingApprovalCenter.Card]) -> some View { + HStack(spacing: 10) { + Image(systemName: "checkmark.shield") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 1) { + Text(cards.count == 1 ? "Pairing Request" : "Pairing Requests") + .font(.title3.weight(.semibold)) + Text(PairingCardPresentation.headerSummary(for: cards)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + } +} + +struct PairingRequestCardView: View { + let card: PairingApprovalCenter.Card + let isBusy: Bool + let isOnlyRequest: Bool + let onDecision: (PairingApprovalCenter.Decision) -> Void + + @State private var isHoveringDetail = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 12) { + self.icon + VStack(alignment: .leading, spacing: 2) { + Text(PairingCardPresentation.title(for: self.card)) + .font(.headline) + .lineLimit(1) + if let subtitle = PairingCardPresentation.subtitle(for: self.card) { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + } + + self.trustRow + self.accessRows + self.detailRow + + HStack(spacing: 8) { + Spacer() + self.rejectButton + self.approveButton + } + } + .padding(14) + .background(RoundedRectangle(cornerRadius: 16).fill(.quinary)) + .disabled(self.isBusy) + .opacity(self.isBusy ? 0.6 : 1) + } + + private var icon: some View { + Image(systemName: PairingCardPresentation.deviceSymbol(for: self.card)) + .font(.system(size: 20, weight: .medium)) + .frame(width: 42, height: 42) + .background(Circle().fill(.quaternary)) + } + + private var trustRow: some View { + let trust = PairingCardPresentation.trustLine(for: self.card) + return Label(trust.text, systemImage: trust.symbol) + .font(.caption) + .foregroundStyle(self.trustStyle(trust.tone)) + } + + private func trustStyle(_ tone: PairingCardPresentation.TrustTone) -> AnyShapeStyle { + switch tone { + case .caution: AnyShapeStyle(.orange) + case .neutral: AnyShapeStyle(.secondary) + } + } + + @ViewBuilder + private var accessRows: some View { + // Every requested scope/capability renders: hiding one behind a cap + // could conceal what approval grants. The panel scrolls when long. + let rows = PairingCardPresentation.accessRows(for: self.card) + if !rows.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(rows) { row in + Label(row.text, systemImage: row.symbol) + .font(.caption) + .foregroundStyle( + row.isElevated ? AnyShapeStyle(.orange) : AnyShapeStyle(.primary)) + } + } + } + } + + private var detailRow: some View { + HStack(spacing: 4) { + Text(PairingCardPresentation.identityLine(for: self.card)) + .font(.caption.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + // Hover-only copy affordance; stays out of the keyboard focus loop. + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(self.card.subjectId, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .focusable(false) + .help("Copy full ID") + .opacity(self.isHoveringDetail ? 1 : 0) + Spacer() + Text(PairingCardPresentation.metaLine(for: self.card)) + .font(.caption) + .foregroundStyle(.tertiary) + .help(PairingCardPresentation.versionTooltip(for: self.card)) + } + .onHover { self.isHoveringDetail = $0 } + } + + @ViewBuilder + private var approveButton: some View { + let button = Button { + self.onDecision(.approve) + } label: { + Text(self.card.kind == .node ? "Approve Node" : "Approve Device") + .padding(.horizontal, 6) + } + let shortcut: KeyboardShortcut? = self.isOnlyRequest ? .defaultAction : nil + if #available(macOS 26.0, *) { + button + .buttonStyle(.glassProminent) + .buttonBorderShape(.capsule) + .keyboardShortcut(shortcut) + } else { + button + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .keyboardShortcut(shortcut) + } + } + + @ViewBuilder + private var rejectButton: some View { + let button = Button(role: .destructive) { + self.onDecision(.reject) + } label: { + Text("Reject") + .padding(.horizontal, 6) + } + if #available(macOS 26.0, *) { + button + .buttonStyle(.glass) + .buttonBorderShape(.capsule) + } else { + button + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + } + } +} + +/// Pure display mapping for pairing cards; kept UI-framework-free so tests +/// can assert copy and symbols without instantiating views. +enum PairingCardPresentation { + struct AccessRow: Identifiable, Equatable { + /// Derived from the raw grant, not display text: distinct grants can + /// render identically (e.g. caps `voice`/`audio`) and colliding ids + /// would let SwiftUI drop rows from the approval surface. + let id: String + let symbol: String + let text: String + let isElevated: Bool + } + + enum TrustTone: Equatable { + case caution + case neutral + } + + struct TrustLine: Equatable { + let symbol: String + let text: String + let tone: TrustTone + } + + static func headerSummary(for cards: [PairingApprovalCenter.Card]) -> String { + guard cards.count == 1, let card = cards.first else { + return "\(cards.count) devices want to connect to OpenClaw." + } + return card.kind == .node + ? "A node wants to connect to OpenClaw." + : "A device wants to connect to OpenClaw." + } + + static func title(for card: PairingApprovalCenter.Card) -> String { + let name = card.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let name, !name.isEmpty, name != card.subjectId { + return name + } + switch card.kind { + case .node: return "Unnamed node" + case .device: return self.isMac(card.platform) ? "OpenClaw Mac app" : "New device" + } + } + + static func subtitle(for card: PairingApprovalCenter.Card) -> String? { + var parts: [String] = [] + if let platform = self.prettyPlatform(card.platform) { + parts.append(platform) + } + if let model = self.prettyModel(card.modelIdentifier, deviceFamily: card.deviceFamily), + parts.allSatisfy({ $0 != model }) + { + parts.append(model) + } + if card.kind == .device, let role = card.role?.trimmingCharacters(in: .whitespacesAndNewlines), + !role.isEmpty + { + parts.append(role == "operator" ? "Operator" : role) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + static func trustLine(for card: PairingApprovalCenter.Card) -> TrustLine { + if card.isRepair { + return TrustLine( + symbol: "arrow.triangle.2.circlepath", + text: "Repair request — its access token will rotate", + tone: .caution) + } + switch card.previouslyPaired { + case true: + // The requester merely CLAIMS this id (it is not authenticated), + // so an already-paired id is a caution — approval replaces the + // existing peer's token — never a positive trust signal. + return TrustLine( + symbol: "exclamationmark.triangle", + text: "This ID is already paired — approving replaces its access token", + tone: .caution) + case false: + return TrustLine( + symbol: "sparkles", + text: "First connection from this \(card.kind == .node ? "node" : "device")", + tone: .neutral) + case nil: + return TrustLine( + symbol: "clock", + text: "Checking pairing history…", + tone: .neutral) + } + } + + static func accessRows(for card: PairingApprovalCenter.Card) -> [AccessRow] { + switch card.kind { + case .device: + // Admin access is what approval actually grants; surface it first + // and highlighted so it can never hide among ordinary scopes. + let rows = self.friendlyScopes(card.scopes).map { scope in + AccessRow( + id: "scope:\(scope.raw)", + symbol: scope.raw == "operator.admin" ? "exclamationmark.shield" : "key", + text: scope.text, + isElevated: scope.raw == "operator.admin") + } + return rows.filter(\.isElevated) + rows.filter { !$0.isElevated } + case .node: + var rows: [AccessRow] = [] + // Commands in NODE_SYSTEM_RUN_COMMANDS (src/infra/node-commands.ts) + // mean approving grants arbitrary command execution on the node. + let isSystemRun = { (command: String) in + command == "system.run" || command == "system.which" || command.hasPrefix("system.run.") + } + if card.commands.contains(where: isSystemRun) { + rows.append(AccessRow( + id: "system-run", + symbol: "exclamationmark.shield", + text: "Can run system commands", + isElevated: true)) + } + rows.append(contentsOf: self.friendlyCapNames(card.caps).map { + AccessRow(id: "cap:\($0.raw)", symbol: $0.symbol, text: $0.text, isElevated: false) + }) + // Approval persists the whole declared command surface; list the + // remaining commands so none of it is granted invisibly. + var seen = Set() + let otherCommands = card.commands.filter { + !$0.isEmpty && !isSystemRun($0) && seen.insert($0).inserted + } + if !otherCommands.isEmpty { + rows.append(AccessRow( + id: "commands", + symbol: "terminal", + text: "Commands: \(otherCommands.joined(separator: ", "))", + isElevated: false)) + } + return rows + } + } + + /// Left, monospaced: stable identity facts (short id, source address). + static func identityLine(for card: PairingApprovalCenter.Card) -> String { + var parts = ["ID \(self.shortIdentifier(card.subjectId))"] + if let ip = self.prettyIP(card.remoteIp) { + parts.append(ip) + } + return parts.joined(separator: " · ") + } + + /// Right, trailing: app version and request age. + static func metaLine(for card: PairingApprovalCenter.Card, now: Date = Date()) -> String { + var parts: [String] = [] + if let version = card.version?.trimmingCharacters(in: .whitespacesAndNewlines), + !version.isEmpty + { + parts.append("v\(version)") + } + parts.append(self.relativeRequestTime(for: card, now: now)) + return parts.joined(separator: " · ") + } + + static func versionTooltip(for card: PairingApprovalCenter.Card) -> String { + var parts: [String] = [] + if let version = card.version?.trimmingCharacters(in: .whitespacesAndNewlines), + !version.isEmpty + { + parts.append("App \(version)") + } + if let core = card.coreVersion?.trimmingCharacters(in: .whitespacesAndNewlines), + !core.isEmpty + { + parts.append("Core \(core)") + } + return parts.joined(separator: " · ") + } + + static func relativeRequestTime(for card: PairingApprovalCenter.Card, now: Date = Date()) -> String { + let elapsed = now.timeIntervalSince(card.requestedAt) + if elapsed < 60 { + return "just now" + } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: card.requestedAt, relativeTo: now) + } + + static func deviceSymbol(for card: PairingApprovalCenter.Card) -> String { + let model = card.modelIdentifier?.lowercased() ?? "" + if model.hasPrefix("macbook") { + return "macbook" + } + if model.hasPrefix("macmini") { + return "macmini" + } + if model.hasPrefix("macstudio") { + return "macstudio" + } + if model.hasPrefix("macpro") { + return "macpro.gen3" + } + if model.hasPrefix("imac") { + return "desktopcomputer" + } + + let family = (card.deviceFamily ?? "").lowercased() + let platform = (card.platform ?? "").lowercased() + let hints = "\(family) \(platform)" + if hints.contains("iphone") || hints.contains("ios") { + return "iphone" + } + if hints.contains("ipad") { + return "ipad" + } + if hints.contains("android") { + return "smartphone" + } + if hints.contains("mac") || hints.contains("darwin") { + return "laptopcomputer" + } + if hints.contains("linux") || hints.contains("windows") { + return "server.rack" + } + return "network" + } + + static func shortIdentifier(_ id: String) -> String { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 20 else { + return trimmed + } + return "\(trimmed.prefix(8))...\(trimmed.suffix(7))" + } + + static func prettyIP(_ ip: String?) -> String? { + let trimmed = ip?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { + return nil + } + return trimmed.replacingOccurrences(of: "::ffff:", with: "") + } + + static func prettyPlatform(_ raw: String?) -> String? { + let platform = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let platform, !platform.isEmpty else { + return nil + } + // Device pairing sends browser-style tokens (MacIntel); map those + // before the generic "os version" formatter capitalizes them. + switch platform.lowercased() { + case "macintel", "x86_64-apple-darwin": + return "Mac (Intel)" + case "macarm", "macarm64", "arm64-apple-darwin", "aarch64-apple-darwin": + return "Mac (Apple silicon)" + case "darwin": + return "Mac" + default: + if let pretty = PlatformLabelFormatter.pretty(platform) { + return pretty + } + return platform.lowercased().contains("mac") ? "Mac" : platform + } + } + + static func friendlyScopes(_ scopes: [String]) -> [(raw: String, text: String)] { + var seen = Set() + return scopes.compactMap { scope in + let normalized = scope.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty, seen.insert(normalized).inserted else { + return nil + } + switch normalized { + case "operator.admin": + return (normalized, "Admin access") + case "operator.read": + return (normalized, "Read OpenClaw data") + case "operator.write": + return (normalized, "Send messages and make changes") + case "operator.approvals": + return (normalized, "Manage approvals") + case "operator.pairing": + return (normalized, "Pair and repair devices") + case "operator.talk.secrets": + return (normalized, "Use Talk credentials") + default: + return (normalized, normalized) + } + } + } + + static func friendlyCapNames(_ caps: [String]) -> [(raw: String, symbol: String, text: String)] { + var seen = Set() + return caps.compactMap { cap in + let normalized = cap.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty, seen.insert(normalized).inserted else { + return nil + } + switch normalized { + case "screen": + return (normalized, "rectangle.inset.filled.badge.record", "Screen capture") + case "camera": + return (normalized, "camera", "Camera") + case "file": + return (normalized, "folder", "File transfer") + case "location": + return (normalized, "location", "Location") + case "voice", "audio": + return (normalized, "mic", "Microphone and voice") + case "canvas": + return (normalized, "paintbrush", "Canvas display") + default: + return (normalized, "puzzlepiece.extension", self.prettifyRawName(normalized)) + } + } + } + + private static func prettifyRawName(_ raw: String) -> String { + let words = raw.split(whereSeparator: { $0 == "-" || $0 == "_" || $0 == "." }).map(String.init) + guard let first = words.first else { + return raw + } + let capitalized = first.prefix(1).uppercased() + first.dropFirst() + return ([capitalized] + words.dropFirst()).joined(separator: " ") + } + + private static func prettyModel(_ modelIdentifier: String?, deviceFamily: String?) -> String? { + let model = modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + if let model, !model.isEmpty { + return model + } + let family = deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let family, !family.isEmpty else { + return nil + } + return family + } + + private static func isMac(_ platform: String?) -> Bool { + guard let platform else { + return false + } + let lower = platform.lowercased() + return lower.contains("mac") || lower.contains("darwin") + } +} diff --git a/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift b/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift new file mode 100644 index 00000000000..3c14a1df801 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/PairingPromptSupport.swift @@ -0,0 +1,92 @@ +import Foundation +import OpenClawKit +import OSLog + +/// Shared plumbing for the node/device pairing prompters: gateway push +/// subscription lifecycle and approve/reject RPC logging. +@MainActor +enum PairingPromptSupport { + enum PairingResolution: String { + case approved + case rejected + } + + struct PairingResolvedEvent: Codable { + let requestId: String + let decision: String + let ts: Double + } + + static func runPairingPushTask( + bufferingNewest: Int = 200, + loadPending: @escaping @MainActor () async -> Void, + handlePush: @escaping @MainActor (GatewayPush) -> Void) async + { + _ = try? await GatewayConnection.shared.refresh() + await loadPending() + await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush) + } + + static func startPairingPushTask( + task: inout Task?, + isStopping: inout Bool, + bufferingNewest: Int = 200, + loadPending: @escaping @MainActor () async -> Void, + handlePush: @escaping @MainActor (GatewayPush) -> Void) + { + guard task == nil else { return } + isStopping = false + task = Task { + await self.runPairingPushTask( + bufferingNewest: bufferingNewest, + loadPending: loadPending, + handlePush: handlePush) + } + } + + static func stopPairingPrompter( + isStopping: inout Bool, + task: inout Task?, + queue: inout [some Any]) + { + isStopping = true + task?.cancel() + task = nil + queue.removeAll(keepingCapacity: false) + } + + static func approveRequest( + requestId: String, + kind: String, + logger: Logger, + action: @escaping () async throws -> Void) async -> Bool + { + do { + try await action() + logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + logger.error("approve failed requestId=\(requestId, privacy: .public)") + logger.error("approve failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + @discardableResult + static func rejectRequest( + requestId: String, + kind: String, + logger: Logger, + action: @escaping () async throws -> Void) async -> Bool + { + do { + try await action() + logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)") + return true + } catch { + logger.error("reject failed requestId=\(requestId, privacy: .public)") + logger.error("reject failed: \(error.localizedDescription, privacy: .public)") + return false + } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift index b86e9e71139..b42ebf7da70 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift @@ -36,62 +36,6 @@ struct NodePairingApprovalPrompterTests { await NodePairingApprovalPrompter.exerciseForTesting() } - @Test func `pairing alert makes approve the primary action`() { - let alert = NSAlert() - PairingAlertSupport.configureDefaultPairingAlert( - alert, - messageText: "New Mac wants to connect", - informativeText: "Approve this Mac app to control OpenClaw.", - buttonTitles: PairingAlertSupport.ButtonTitles(approve: "Approve Mac")) - - #expect(alert.alertStyle == .informational) - #expect(alert.buttons.map(\.title) == ["Approve Mac", "Not Now", "Reject"]) - if #available(macOS 11.0, *) { - #expect(alert.buttons[2].hasDestructiveAction) - } - } - - @Test func `device pairing copy summarizes Mac requests`() { - let request = DevicePairingApprovalPrompter.PendingRequest( - requestId: "req-1", - deviceId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd", - publicKey: "pub", - displayName: nil, - platform: "MacIntel", - clientId: nil, - clientMode: nil, - role: "operator", - scopes: [ - "operator.admin", - "operator.read", - "operator.write", - "operator.approvals", - "operator.pairing", - ], - remoteIp: "192.0.2.10", - silent: nil, - isRepair: nil, - ts: 1) - - #expect(DevicePairingApprovalPrompter.alertTitle(for: request) == "New Mac wants to connect") - #expect(DevicePairingApprovalPrompter.approveButtonTitle(for: request) == "Approve Mac") - #expect(DevicePairingApprovalPrompter.deviceName(for: request) == "OpenClaw Mac app") - #expect(DevicePairingApprovalPrompter.prettyPlatform(request.platform) == "Mac (Intel)") - #expect(DevicePairingApprovalPrompter.shortIdentifier(request.deviceId) == "4a865684...f4e82bd") - #expect(DevicePairingApprovalPrompter.friendlyScopeNames(request.scopes) == [ - "Admin access", - "Read OpenClaw data", - "Send messages and make changes", - "Manage approvals", - "Pair and repair devices", - ]) - #expect(!DevicePairingApprovalPrompter.alertSummary(for: request).contains(request.deviceId)) - - let accessory = DevicePairingApprovalPrompter.buildAccessoryView(for: request) - #expect(accessory.frame.width >= 380) - #expect(accessory.frame.height > 80) - } - @Test func `a newer device pairing request supersedes queued requests for the same device`() { func request(_ requestId: String, deviceId: String, ts: Double) -> DevicePairingApprovalPrompter .PendingRequest @@ -125,3 +69,172 @@ struct NodePairingApprovalPrompterTests { #expect(DevicePairingApprovalPrompter.coalescedQueue([stale1, other], adding: stale1) == nil) } } + +@MainActor +struct PairingCardPresentationTests { + private func nodeCard( + displayName: String? = "Peter's MacBook Pro", + platform: String? = "macos 26.5", + modelIdentifier: String? = "MacBookPro18,3", + caps: [String] = [], + commands: [String] = [], + previouslyPaired: Bool? = false) -> PairingApprovalCenter.Card + { + PairingApprovalCenter.Card( + kind: .node, + requestId: "req-1", + subjectId: "19cec1c3301a7469d4fd71f5f81339508390dadda91b34aee15faf2849dccdc7", + displayName: displayName, + platform: platform, + deviceFamily: "Mac", + modelIdentifier: modelIdentifier, + version: "2026.6.11", + coreVersion: "2026.6.10", + remoteIp: "::ffff:192.168.1.42", + role: nil, + scopes: [], + caps: caps, + commands: commands, + isRepair: false, + previouslyPaired: previouslyPaired, + requestedAt: Date(timeIntervalSince1970: 1_700_000_000)) + } + + private func deviceCard(scopes: [String], isRepair: Bool = false) -> PairingApprovalCenter.Card { + PairingApprovalCenter.Card( + kind: .device, + requestId: "req-2", + subjectId: "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd", + displayName: nil, + platform: "MacIntel", + deviceFamily: nil, + modelIdentifier: nil, + version: nil, + coreVersion: nil, + remoteIp: "192.0.2.10", + role: "operator", + scopes: scopes, + caps: [], + commands: [], + isRepair: isRepair, + previouslyPaired: false, + requestedAt: Date(timeIntervalSince1970: 1_700_000_000)) + } + + @Test func `card copy summarizes the requesting hardware`() { + let card = self.nodeCard() + #expect(PairingCardPresentation.title(for: card) == "Peter's MacBook Pro") + #expect(PairingCardPresentation.subtitle(for: card) == "macOS 26.5 · MacBookPro18,3") + #expect(PairingCardPresentation.deviceSymbol(for: card) == "macbook") + #expect(PairingCardPresentation.identityLine(for: card) == "ID 19cec1c3...9dccdc7 · 192.168.1.42") + #expect(PairingCardPresentation.versionTooltip(for: card) == "App 2026.6.11 · Core 2026.6.10") + #expect(PairingCardPresentation.headerSummary(for: [card]) == "A node wants to connect to OpenClaw.") + } + + @Test func `device symbols map hardware families`() { + #expect(PairingCardPresentation.deviceSymbol(for: self.deviceCard(scopes: [])) == "laptopcomputer") + let iphone = PairingApprovalCenter.Card( + kind: .device, requestId: "r", subjectId: "d", displayName: nil, + platform: "ios 26", deviceFamily: nil, modelIdentifier: nil, version: nil, + coreVersion: nil, remoteIp: nil, role: nil, scopes: [], caps: [], commands: [], + isRepair: false, previouslyPaired: false, requestedAt: Date(timeIntervalSince1970: 0)) + #expect(PairingCardPresentation.deviceSymbol(for: iphone) == "iphone") + } + + @Test func `node access rows flag command execution as elevated`() { + let card = self.nodeCard(caps: ["screen", "camera"], commands: ["system.run", "system.notify"]) + let rows = PairingCardPresentation.accessRows(for: card) + #expect(rows.first?.isElevated == true) + #expect(rows.first?.text == "Can run system commands") + #expect(rows.map(\.text).contains("Screen capture")) + #expect(rows.map(\.text).contains("Camera")) + + // system.which alone is admin-scope (NODE_SYSTEM_RUN_COMMANDS). + let whichOnly = self.nodeCard(commands: ["system.which"]) + #expect(PairingCardPresentation.accessRows(for: whichOnly).first?.isElevated == true) + + // Non-system commands are still part of the approved surface and + // must render rather than being granted invisibly. + let notifyOnly = self.nodeCard(commands: ["system.notify", "contacts.search"]) + let notifyRows = PairingCardPresentation.accessRows(for: notifyOnly) + #expect(notifyRows.allSatisfy { !$0.isElevated }) + #expect(notifyRows.map(\.text).contains("Commands: system.notify, contacts.search")) + } + + @Test func `every requested access row renders and admin can not hide`() { + // No cap: hiding a row could conceal what approval grants. + let caps = (1...12).map { "cap-\($0)" } + #expect(PairingCardPresentation.accessRows(for: self.nodeCard(caps: caps)).count == caps.count) + + // operator.admin buried behind ordinary scopes surfaces first, elevated. + let sneaky = self.deviceCard(scopes: [ + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + "operator.talk.secrets", + "operator.admin", + ]) + let rows = PairingCardPresentation.accessRows(for: sneaky) + #expect(rows.count == 6) + #expect(rows.first?.text == "Admin access") + #expect(rows.first?.isElevated == true) + } + + @Test func `device scopes render friendly names`() { + let card = self.deviceCard(scopes: [ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ]) + #expect(PairingCardPresentation.accessRows(for: card).map(\.text) == [ + "Admin access", + "Read OpenClaw data", + "Send messages and make changes", + "Manage approvals", + "Pair and repair devices", + ]) + #expect(PairingCardPresentation.title(for: card) == "OpenClaw Mac app") + #expect(PairingCardPresentation.subtitle(for: card) == "Mac (Intel) · Operator") + } + + @Test func `trust line distinguishes first contact repair and reused ids`() { + let fresh = self.nodeCard() + #expect(PairingCardPresentation.trustLine(for: fresh).tone == .neutral) + + // The requester-claimed id is unauthenticated, so an already-paired id + // must render as a caution (token replacement), never positive trust. + let reusedId = self.nodeCard(previouslyPaired: true) + let reusedLine = PairingCardPresentation.trustLine(for: reusedId) + #expect(reusedLine.tone == .caution) + #expect(reusedLine.text.contains("already paired")) + + let repair = self.deviceCard(scopes: [], isRepair: true) + let repairLine = PairingCardPresentation.trustLine(for: repair) + #expect(repairLine.tone == .caution) + #expect(repairLine.text.contains("Repair")) + + // Unverified history (stale snapshot) must stay neutral. + let unknown = self.nodeCard(previouslyPaired: nil) + let unknownLine = PairingCardPresentation.trustLine(for: unknown) + #expect(unknownLine.tone == .neutral) + #expect(!unknownLine.text.contains("already paired")) + } + + @Test func `identifier shortening keeps head and tail`() { + let long = "4a865684dbfa7b7937bd333813476ca88b672c2d02ad08fc52b80d88af4e82bd" + #expect(PairingCardPresentation.shortIdentifier(long) == "4a865684...f4e82bd") + #expect(PairingCardPresentation.shortIdentifier("short-id") == "short-id") + } + + @Test func `panel auto presents only for unseen requests`() { + #expect(!PairingApprovalCenter.shouldAutoPresent(cardIds: [], snoozedIds: [])) + #expect(PairingApprovalCenter.shouldAutoPresent(cardIds: ["a"], snoozedIds: [])) + // Everything on screen was snoozed: stay hidden. + #expect(!PairingApprovalCenter.shouldAutoPresent(cardIds: ["a"], snoozedIds: ["a"])) + // A request the user has not seen yet reopens the panel. + #expect(PairingApprovalCenter.shouldAutoPresent(cardIds: ["a", "b"], snoozedIds: ["a"])) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift index a7d1c30642e..78af7169823 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodePairingReconcilePolicyTests.swift @@ -2,10 +2,9 @@ import Testing @testable import OpenClaw struct NodePairingReconcilePolicyTests { - @Test func `policy polls only when active`() { - #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: false) == false) - #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1, isPresenting: false)) - #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0, isPresenting: true)) + @Test func `policy polls only while requests are pending`() { + #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 0) == false) + #expect(NodePairingReconcilePolicy.shouldPoll(pendingCount: 1)) } @Test func `policy uses slow safety interval`() {