Skip to content

Commit eecd758

Browse files
authored
fix(macos): repair stale gateway tls pins (#75038)
Merged via squash. Prepared head SHA: 35196f8 Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman
1 parent 29d3b65 commit eecd758

13 files changed

Lines changed: 447 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ Docs: https://docs.openclaw.ai
335335
- Providers/GitHub Copilot: support the GUI/RPC wizard device-code auth flow so onboarding from non-TTY clients (gateway RPC bridge, GUI wizards) completes instead of returning empty profiles. Dangerous-state handling now distinguishes `access_denied` and `expired_token` from transport errors. (#73290) Thanks @indierawk2k2.
336336
- Installer/Linux: warn before switching an unwritable npm global prefix to `~/.npm-global`, then tell users to run future global updates with `npm i -g openclaw@latest` without `sudo` so npm keeps using the redirected user prefix. Fixes #44365; carries forward #50479. Thanks @Sayeem3051.
337337
- Gateway/plugins: enable the native `require()` fast path on Windows for bundled plugin modules so plugin loading uses `require()` instead of Jiti's transform pipeline, reducing startup from ~39s to ~2s on typical 6-plugin setups. Fixes #68656. (#74173) Thanks @galiniliev.
338+
- macOS app: detect stale Gateway TLS certificate pins, automatically repair trusted Tailscale Serve rotations, and surface paired-but-disconnected Mac companion nodes so partial Gateway connections no longer look healthy. Thanks @guti.
338339

339340
## 2026.4.27
340341

apps/macos/Sources/OpenClaw/MenuContentView.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AppKit
22
import AVFoundation
33
import Foundation
44
import Observation
5+
import OpenClawKit
56
import SwiftUI
67

78
/// Menu contents for the OpenClaw menu bar extra.
@@ -14,6 +15,7 @@ struct MenuContent: View {
1415
private let heartbeatStore = HeartbeatStore.shared
1516
private let controlChannel = ControlChannel.shared
1617
private let activityStore = WorkActivityStore.shared
18+
private let nodesStore = NodesStore.shared
1719
@Bindable private var pairingPrompter = NodePairingApprovalPrompter.shared
1820
@Bindable private var devicePairingPrompter = DevicePairingApprovalPrompter.shared
1921
@Environment(\.openSettings) private var openSettings
@@ -44,6 +46,9 @@ struct MenuContent: View {
4446
VStack(alignment: .leading, spacing: 2) {
4547
Text(self.connectionLabel)
4648
self.statusLine(label: self.healthStatus.label, color: self.healthStatus.color)
49+
if let macNodeStatus = self.macNodeStatus {
50+
self.statusLine(label: macNodeStatus.label, color: macNodeStatus.color)
51+
}
4752
if self.pairingPrompter.pendingCount > 0 {
4853
let repairCount = self.pairingPrompter.pendingRepairCount
4954
let repairSuffix = repairCount > 0 ? " · \(repairCount) repair" : ""
@@ -351,6 +356,31 @@ struct MenuContent: View {
351356
}
352357
}
353358

359+
private var macNodeStatus: (label: String, color: Color)? {
360+
guard self.state.connectionMode != .unconfigured else { return nil }
361+
guard case .connected = self.controlChannel.state else { return nil }
362+
363+
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
364+
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
365+
guard entry.isConnected else {
366+
return ("Mac capabilities offline", .orange)
367+
}
368+
let commands = Set(entry.commands ?? [])
369+
let missingRequiredCommands = [
370+
OpenClawSystemCommand.notify.rawValue,
371+
OpenClawSystemCommand.run.rawValue,
372+
OpenClawSystemCommand.which.rawValue,
373+
].filter { !commands.contains($0) }
374+
if !missingRequiredCommands.isEmpty {
375+
return ("Mac capabilities incomplete", .orange)
376+
}
377+
return nil
378+
}
379+
380+
guard !self.nodesStore.isLoading, !self.nodesStore.nodes.isEmpty else { return nil }
381+
return ("Mac capabilities offline", .orange)
382+
}
383+
354384
private var healthStatus: (label: String, color: Color) {
355385
if let activity = self.activityStore.current {
356386
let color: Color = activity.role == .main ? .accentColor : .gray

apps/macos/Sources/OpenClaw/MenuSessionsInjector.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,7 @@ extension MenuSessionsInjector {
11561156
}
11571157

11581158
private func sortedNodeEntries() -> [NodeInfo] {
1159-
let entries = self.nodesStore.nodes.filter(\.isConnected)
1159+
let entries = self.nodesStore.nodes.filter { $0.isConnected || $0.isPaired }
11601160
return entries.sorted { lhs, rhs in
11611161
if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
11621162
if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
@@ -1239,5 +1239,9 @@ extension MenuSessionsInjector {
12391239
func testingFindNodesInsertIndex(in menu: NSMenu) -> Int? {
12401240
self.findNodesInsertIndex(in: menu)
12411241
}
1242+
1243+
func testingSortedNodeEntries() -> [NodeInfo] {
1244+
self.sortedNodeEntries()
1245+
}
12421246
}
12431247
#endif

apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ final class MacNodeModeCoordinator {
1010
private var task: Task<Void, Never>?
1111
private let runtime = MacNodeRuntime()
1212
private let session = GatewayNodeSession()
13+
private var autoRepairedTLSFingerprintsByStoreKey: [String: String] = [:]
1314

1415
func start() {
1516
guard self.task == nil else { return }
@@ -58,8 +59,10 @@ final class MacNodeModeCoordinator {
5859
try? await Task.sleep(nanoseconds: 200_000_000)
5960
}
6061

62+
var attemptedURL: URL?
6163
do {
6264
let config = try await GatewayEndpointStore.shared.requireConfig()
65+
attemptedURL = config.url
6366
let caps = self.currentCaps()
6467
let commands = self.currentCommands(caps: caps)
6568
let permissions = await self.currentPermissions()
@@ -109,6 +112,10 @@ final class MacNodeModeCoordinator {
109112
retryDelay = 1_000_000_000
110113
try? await Task.sleep(nanoseconds: 1_000_000_000)
111114
} catch {
115+
if await self.autoRepairStaleTLSPinIfNeeded(error: error, url: attemptedURL) {
116+
retryDelay = 1_000_000_000
117+
continue
118+
}
112119
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
113120
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
114121
retryDelay = min(retryDelay * 2, 10_000_000_000)
@@ -188,11 +195,49 @@ final class MacNodeModeCoordinator {
188195
Self.resolvedCommands(caps: caps)
189196
}
190197

198+
nonisolated static func tlsPinStoreKey(for url: URL) -> String {
199+
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "gateway"
200+
let port = url.port ?? 443
201+
return "\(host):\(port)"
202+
}
203+
204+
nonisolated static func shouldAutoRepairStaleTLSPin(url: URL, failure: GatewayTLSValidationFailure) -> Bool {
205+
guard failure.kind == .pinMismatch else { return false }
206+
guard url.scheme?.lowercased() == "wss" else { return false }
207+
guard failure.storeKey == nil || failure.storeKey == self.tlsPinStoreKey(for: url) else { return false }
208+
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !host.isEmpty
209+
else { return false }
210+
211+
if LoopbackHost.isLoopback(host) {
212+
return failure.systemTrustOk
213+
}
214+
215+
// Tailscale Serve uses publicly trusted, rotating certificates for *.ts.net names.
216+
// A stale legacy leaf pin should not leave the companion app half-connected forever.
217+
if host == "ts.net" || host.hasSuffix(".ts.net") {
218+
return failure.systemTrustOk
219+
}
220+
221+
return false
222+
}
223+
224+
private func autoRepairStaleTLSPinIfNeeded(error: Error, url: URL?) async -> Bool {
225+
guard let tlsError = error as? GatewayTLSValidationError, let url else { return false }
226+
guard Self.shouldAutoRepairStaleTLSPin(url: url, failure: tlsError.failure) else { return false }
227+
let storeKey = tlsError.failure.storeKey ?? Self.tlsPinStoreKey(for: url)
228+
guard let observedFingerprint = tlsError.failure.observedFingerprint else { return false }
229+
guard self.autoRepairedTLSFingerprintsByStoreKey[storeKey] != observedFingerprint else { return false }
230+
231+
guard GatewayTLSStore.replaceFingerprint(observedFingerprint, stableID: storeKey) else { return false }
232+
self.autoRepairedTLSFingerprintsByStoreKey[storeKey] = observedFingerprint
233+
self.logger.info("replaced stale gateway TLS pin storeKey=\(storeKey, privacy: .public)")
234+
await self.session.disconnect()
235+
return true
236+
}
237+
191238
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
192239
guard url.scheme?.lowercased() == "wss" else { return nil }
193-
let host = url.host ?? "gateway"
194-
let port = url.port ?? 443
195-
let stableID = "\(host):\(port)"
240+
let stableID = Self.tlsPinStoreKey(for: url)
196241
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
197242
let params = GatewayTLSParams(
198243
required: true,

apps/macos/Sources/OpenClaw/NodesMenu.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ struct NodeMenuEntryFormatter {
4444
}
4545

4646
static func roleText(_ entry: NodeInfo) -> String {
47-
if entry.isConnected { return "connected" }
48-
if self.isGateway(entry) { return "disconnected" }
49-
if entry.isPaired { return "paired" }
50-
return "unpaired"
47+
if self.isGateway(entry) {
48+
return entry.isConnected ? "connected" : "disconnected"
49+
}
50+
let pairing = entry.isPaired ? "paired" : "unpaired"
51+
let connection = entry.isConnected ? "connected" : "disconnected"
52+
return "\(pairing) · \(connection)"
5153
}
5254

5355
static func detailLeft(_ entry: NodeInfo) -> String {

apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ import Testing
44
@testable import OpenClaw
55

66
struct GatewayChannelConnectTests {
7+
private final class TLSFailureSession: WebSocketSessioning, GatewayTLSFailureProviding, @unchecked Sendable {
8+
private var failure: GatewayTLSValidationFailure?
9+
10+
init(failure: GatewayTLSValidationFailure) {
11+
self.failure = failure
12+
}
13+
14+
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
15+
_ = url
16+
let task = GatewayTestWebSocketTask(receiveHook: { _, receiveIndex in
17+
if receiveIndex == 0 {
18+
return .data(GatewayWebSocketTestSupport.connectChallengeData())
19+
}
20+
throw URLError(.userCancelledAuthentication)
21+
})
22+
return WebSocketTaskBox(task: task)
23+
}
24+
25+
func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
26+
defer { self.failure = nil }
27+
return self.failure
28+
}
29+
}
30+
731
private enum FakeResponse {
832
case helloOk(delayMs: Int)
933
case invalid(delayMs: Int)
@@ -109,4 +133,28 @@ struct GatewayChannelConnectTests {
109133
Issue.record("unexpected error: \(error)")
110134
}
111135
}
136+
137+
@Test func `connect maps user cancelled authentication with cached TLS failure`() async throws {
138+
let failure = GatewayTLSValidationFailure(
139+
kind: .pinMismatch,
140+
host: "gateway.example.ts.net",
141+
storeKey: "gateway.example.ts.net:443",
142+
expectedFingerprint: "old",
143+
observedFingerprint: "new",
144+
systemTrustOk: true)
145+
let session = TLSFailureSession(failure: failure)
146+
let channel = try GatewayChannelActor(
147+
url: #require(URL(string: "wss://gateway.example.ts.net")),
148+
token: nil,
149+
session: WebSocketSessionBox(session: session))
150+
151+
do {
152+
try await channel.connect()
153+
Issue.record("expected GatewayTLSValidationError")
154+
} catch let error as GatewayTLSValidationError {
155+
#expect(error.failure == failure)
156+
} catch {
157+
Issue.record("unexpected error: \(error)")
158+
}
159+
}
112160
}

apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,61 @@ struct MacNodeModeCoordinatorTests {
2929
#expect(caps.contains(OpenClawCapability.browser.rawValue))
3030
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
3131
}
32+
33+
@Test func `tls pin store key uses default wss port`() throws {
34+
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
35+
#expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443")
36+
}
37+
38+
@Test func `auto repairs trusted tailscale serve pin mismatch`() throws {
39+
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
40+
let failure = GatewayTLSValidationFailure(
41+
kind: .pinMismatch,
42+
host: "gateway.example.ts.net",
43+
storeKey: "gateway.example.ts.net:443",
44+
expectedFingerprint: "old",
45+
observedFingerprint: "new",
46+
systemTrustOk: true)
47+
48+
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
49+
}
50+
51+
@Test func `does not auto repair untrusted remote pin mismatch`() throws {
52+
let url = try #require(URL(string: "wss://gateway.example.com"))
53+
let failure = GatewayTLSValidationFailure(
54+
kind: .pinMismatch,
55+
host: "gateway.example.com",
56+
storeKey: "gateway.example.com:443",
57+
expectedFingerprint: "old",
58+
observedFingerprint: "new",
59+
systemTrustOk: true)
60+
61+
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
62+
}
63+
64+
@Test func `auto repairs trusted loopback pin mismatch`() throws {
65+
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
66+
let failure = GatewayTLSValidationFailure(
67+
kind: .pinMismatch,
68+
host: "127.0.0.1",
69+
storeKey: "127.0.0.1:18789",
70+
expectedFingerprint: "old",
71+
observedFingerprint: "new",
72+
systemTrustOk: true)
73+
74+
#expect(MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
75+
}
76+
77+
@Test func `does not auto repair untrusted loopback pin mismatch`() throws {
78+
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
79+
let failure = GatewayTLSValidationFailure(
80+
kind: .pinMismatch,
81+
host: "127.0.0.1",
82+
storeKey: "127.0.0.1:18789",
83+
expectedFingerprint: "old",
84+
observedFingerprint: "new",
85+
systemTrustOk: false)
86+
87+
#expect(!MacNodeModeCoordinator.shouldAutoRepairStaleTLSPin(url: url, failure: failure))
88+
}
3289
}

apps/macos/Tests/OpenClawIPCTests/MenuSessionsInjectorTests.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,50 @@ struct MenuSessionsInjectorTests {
165165
#expect(usageCostItem?.submenu != nil)
166166
#expect(usageCostItem?.submenu?.delegate == nil)
167167
}
168+
169+
@Test func `node status text distinguishes paired disconnected nodes`() {
170+
let pairedDisconnected = Self.node(id: "paired", paired: true, connected: false)
171+
let unpairedDisconnected = Self.node(id: "unpaired", paired: false, connected: false)
172+
let connected = Self.node(id: "connected", paired: true, connected: true)
173+
174+
#expect(NodeMenuEntryFormatter.roleText(pairedDisconnected) == "paired · disconnected")
175+
#expect(NodeMenuEntryFormatter.roleText(unpairedDisconnected) == "unpaired · disconnected")
176+
#expect(NodeMenuEntryFormatter.roleText(connected) == "paired · connected")
177+
}
178+
179+
@Test func `sorted node entries include paired disconnected nodes`() {
180+
let injector = MenuSessionsInjector()
181+
defer { NodesStore.shared.nodes = [] }
182+
NodesStore.shared.nodes = [
183+
Self.node(id: "ignored", paired: false, connected: false, displayName: "Ignored"),
184+
Self.node(id: "paired", paired: true, connected: false, displayName: "MacBook"),
185+
Self.node(id: "connected", paired: true, connected: true, displayName: "iPhone"),
186+
]
187+
188+
let entries = injector.testingSortedNodeEntries()
189+
#expect(entries.map(\.nodeId) == ["connected", "paired"])
190+
}
191+
192+
private static func node(
193+
id: String,
194+
paired: Bool,
195+
connected: Bool,
196+
displayName: String? = nil) -> NodeInfo
197+
{
198+
NodeInfo(
199+
nodeId: id,
200+
displayName: displayName ?? id,
201+
platform: "macOS 26.3.1",
202+
version: nil,
203+
coreVersion: nil,
204+
uiVersion: nil,
205+
deviceFamily: "Mac",
206+
modelIdentifier: nil,
207+
remoteIp: nil,
208+
caps: nil,
209+
commands: nil,
210+
permissions: nil,
211+
paired: paired,
212+
connected: connected)
213+
}
168214
}

apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1010,10 +1010,13 @@ public actor GatewayChannelActor {
10101010

10111011
/// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
10121012
private func wrap(_ error: Error, context: String) -> Error {
1013-
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
1013+
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError || error is GatewayTLSValidationError {
10141014
return error
10151015
}
10161016
if let urlError = error as? URLError {
1017+
if let failure = (self.session as? GatewayTLSFailureProviding)?.consumeLastTLSFailure() {
1018+
return GatewayTLSValidationError(failure: failure, context: context)
1019+
}
10171020
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
10181021
return NSError(
10191022
domain: URLError.errorDomain,

0 commit comments

Comments
 (0)