feat(mac): unified Liquid Glass pairing approval panel (#102601)

* feat(mac): unified Liquid Glass pairing approval panel

Replace the serial node/device pairing NSAlerts with one floating
approval panel. Both prompters feed request cards into a shared
PairingApprovalCenter; each card shows a hardware icon, platform and
model, app/core version, source IP, a copyable short id, request age,
a trust line (first connection / already-paired-id caution / repair
token rotation), and a friendly access summary with an elevated
warning for system.run-class commands. Requests resolved elsewhere
disappear live; Not Now snoozes without resolving (gateway TTL
applies) and the menu-bar pending line reopens the panel. Liquid
Glass surface on macOS 26+, material fallback on macOS 15. Deletes
the NSAlert + invisible-host-window machinery, dead node isRepair
handling, and text-only describe() body.

Closes #102535

* fix(mac): satisfy swiftformat conditionalAssignment and resync native i18n inventory
This commit is contained in:
Peter Steinberger 2026-07-09 10:31:42 +01:00 committed by GitHub
parent 6433b82723
commit 4cc92009ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1652 additions and 943 deletions

View file

@ -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,

View file

@ -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 {

View file

@ -13,12 +13,17 @@ final class DevicePairingApprovalPrompter {
private let logger = Logger(subsystem: "ai.openclaw", category: "device-pairing")
private var task: Task<Void, Never>?
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<String> = []
/// Device ids already paired on the gateway (from the last list fetch);
/// drives the "previously paired" trust signal on cards.
private var pairedDeviceIds: Set<String> = []
/// 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<String> = []
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<String>()
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()
}
}

View file

@ -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")

View file

@ -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<Bool> {
Binding(get: { !self.state.isPaused }, set: { self.state.isPaused = !$0 })
}

View file

@ -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<Void, Never>?
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<String> = []
/// 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<String> = []
private var autoApproveAttempts: Set<String> = []
/// Requests hidden from the panel while a silent/local auto-approve runs.
private var autoApproveInFlight: Set<String> = []
/// 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<String> = []
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]

View file

@ -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<Void, Never>?,
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: 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<Void, Never>?,
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<Void, Never>?,
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)")
}
}
}

View file

@ -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<String> = []
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<String> = []
/// 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<String> = []
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<String>) -> 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<String> = []
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<PairingApprovalPanelView>?
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)
}
}

View file

@ -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<String>()
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<String>()
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<String>()
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")
}
}

View file

@ -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<Void, Never>?,
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<Void, Never>?,
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
}
}
}

View file

@ -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"]))
}
}

View file

@ -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`() {