Skip to content

Commit 655b18c

Browse files
authored
fix(macos): dashboard window keeps a dead SSH tunnel port after tunnel restart (#100488)
* fix(macos): follow gateway endpoint changes in the open Dashboard window In remote SSH mode the Dashboard window baked the forwarded tunnel's local port into the WebView URL and injected auth at open time; when RemoteTunnelManager recreated the tunnel on a new ephemeral port the open window reconnected to the dead port forever. DashboardManager now subscribes to GatewayEndpointStore and, while the window is open, re-injects auth for the new origin and reloads the WebView without stealing focus. Dashboard log lines now strip the #token= fragment so endpoint changes never write credentials to unified logs. Fixes #100476 * test(macos): drop stale hasCrestodianSetupAuth assertions OnboardingView.hasCrestodianSetupAuth was removed with the replaced OnboardingView+CrestodianSetup source in #100288, leaving the macOS test target failing to compile on main. Delete the test for the removed internal helper.
1 parent 7dfc499 commit 655b18c

5 files changed

Lines changed: 173 additions & 15 deletions

File tree

apps/macos/Sources/OpenClaw/DashboardManager.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,45 @@ final class DashboardManager {
1010
static let shared = DashboardManager()
1111

1212
private var controller: DashboardWindowController?
13+
private var endpointTask: Task<Void, Never>?
1314
private static let failureURL = URL(string: "about:blank")!
1415

1516
private init() {}
1617

18+
/// The remote SSH tunnel can be recreated on a new ephemeral local port while
19+
/// the dashboard stays open; without following endpoint changes the WebView
20+
/// keeps reconnecting to the dead old port forever (#100476).
21+
private func observeEndpointChanges() {
22+
guard self.endpointTask == nil else { return }
23+
self.endpointTask = Task { [weak self] in
24+
let stream = await GatewayEndpointStore.shared.subscribe()
25+
for await state in stream {
26+
guard let self else { return }
27+
await self.handleEndpointState(state)
28+
}
29+
}
30+
}
31+
32+
func handleEndpointState(_ state: GatewayEndpointState) async {
33+
guard case let .ready(mode, url, token, password) = state else { return }
34+
guard let controller, controller.isWindowOpen else { return }
35+
let config: GatewayConnection.Config = (url, token, password)
36+
let authToken = await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
37+
guard let dashboardURL = try? GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: authToken),
38+
dashboardURL != controller.currentURL
39+
else {
40+
return
41+
}
42+
let auth = DashboardWindowAuth(
43+
gatewayUrl: Self.websocketURLString(for: dashboardURL),
44+
token: authToken,
45+
password: password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
46+
guard auth.hasCredential, controller.isWindowOpen else { return }
47+
dashboardManagerLogger.info(
48+
"dashboard endpoint changed; reloading url=\(dashboardLogString(for: dashboardURL), privacy: .public)")
49+
controller.update(url: dashboardURL, auth: auth)
50+
}
51+
1752
@discardableResult
1853
func showConfiguredWindowIfPossible() -> Bool {
1954
let mode = AppStateStore.shared.connectionMode
@@ -39,6 +74,7 @@ final class DashboardManager {
3974
self.controller = controller
4075
controller.show(url: url, auth: auth)
4176
}
77+
self.observeEndpointChanges()
4278
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
4379
return true
4480
}
@@ -56,15 +92,17 @@ final class DashboardManager {
5692
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
5793

5894
if let controller {
59-
dashboardManagerLogger.info("dashboard reuse window url=\(url.absoluteString, privacy: .public)")
95+
dashboardManagerLogger.info("dashboard reuse window url=\(dashboardLogString(for: url), privacy: .public)")
6096
controller.show(url: url, auth: auth)
97+
self.observeEndpointChanges()
6198
return
6299
}
63100

64-
dashboardManagerLogger.info("dashboard create window url=\(url.absoluteString, privacy: .public)")
101+
dashboardManagerLogger.info("dashboard create window url=\(dashboardLogString(for: url), privacy: .public)")
65102
let controller = DashboardWindowController(url: url, auth: auth)
66103
self.controller = controller
67104
controller.show(url: url, auth: auth)
105+
self.observeEndpointChanges()
68106

69107
// Refresh the cached hello payload without blocking window creation.
70108
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
@@ -77,6 +115,9 @@ final class DashboardManager {
77115
url: Self.failureURL,
78116
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
79117
self.controller = controller
118+
// Keep observing while the failure page is up so a recovered tunnel
119+
// swaps the window back to the live dashboard.
120+
self.observeEndpointChanges()
80121
controller.showFailure(
81122
title: "Dashboard unavailable",
82123
message: message,
@@ -133,3 +174,17 @@ final class DashboardManager {
133174
return nil
134175
}
135176
}
177+
178+
#if DEBUG
179+
extension DashboardManager {
180+
/// Test instances skip `observeEndpointChanges()` so the shared endpoint
181+
/// store cannot race test-driven `handleEndpointState` calls.
182+
static func _testMake() -> DashboardManager {
183+
DashboardManager()
184+
}
185+
186+
func _testSetController(_ controller: DashboardWindowController?) {
187+
self.controller = controller
188+
}
189+
}
190+
#endif

apps/macos/Sources/OpenClaw/DashboardWindow.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,13 @@ struct DashboardWindowAuth: Equatable {
1919
self.password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
2020
}
2121
}
22+
23+
/// Dashboard URLs carry the auth token in the `#token=...` fragment; strip the
24+
/// fragment before logging so credentials never land in unified logs.
25+
func dashboardLogString(for url: URL) -> String {
26+
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
27+
return "<unparseable-url>"
28+
}
29+
components.fragment = nil
30+
return components.url?.absoluteString ?? "<unparseable-url>"
31+
}

apps/macos/Sources/OpenClaw/DashboardWindowController.swift

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ private final class DashboardWindowDragRegionView: NSView {
2121
@MainActor
2222
final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate {
2323
private let webView: WKWebView
24-
private var currentURL: URL
24+
private(set) var currentURL: URL
2525
private var auth: DashboardWindowAuth
2626

2727
init(url: URL, auth: DashboardWindowAuth) {
@@ -81,11 +81,27 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
8181
}
8282

8383
func show(url: URL, auth: DashboardWindowAuth) {
84+
self.update(url: url, auth: auth)
85+
self.show()
86+
}
87+
88+
/// Swap the dashboard to a new gateway endpoint without reordering the window:
89+
/// re-injects the native auth script for the new origin and reloads. Used when
90+
/// the remote tunnel is recreated on a new local port while the window stays
91+
/// open; ordering the window front here would steal focus on background
92+
/// tunnel recreation.
93+
func update(url: URL, auth: DashboardWindowAuth) {
8494
self.currentURL = url
8595
self.auth = auth
8696
self.refreshNativeAuthScript(url: url, auth: auth)
8797
self.load(url)
88-
self.show()
98+
}
99+
100+
/// Miniaturized windows report `isVisible == false` but must still follow
101+
/// endpoint changes so deminiaturizing does not land on a dead port.
102+
var isWindowOpen: Bool {
103+
guard let window else { return false }
104+
return window.isVisible || window.isMiniaturized
89105
}
90106

91107
func show() {
@@ -120,7 +136,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
120136
}
121137

122138
private func load(_ url: URL) {
123-
dashboardWindowLogger.debug("dashboard load \(url.absoluteString, privacy: .public)")
139+
dashboardWindowLogger.debug("dashboard load \(dashboardLogString(for: url), privacy: .public)")
124140
self.webView.load(URLRequest(url: url))
125141
}
126142

@@ -320,7 +336,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
320336
let nsError = error as NSError
321337
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { return }
322338
dashboardWindowLogger.error(
323-
"dashboard load failed url=\(self.currentURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
339+
"dashboard load failed url=\(dashboardLogString(for: self.currentURL), privacy: .public) error=\(error.localizedDescription, privacy: .public)")
324340
let html = Self.failureHTML(
325341
title: "Dashboard unavailable",
326342
message: error.localizedDescription,
@@ -437,3 +453,11 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
437453
.replacingOccurrences(of: "'", with: "&#39;")
438454
}
439455
}
456+
457+
#if DEBUG
458+
extension DashboardWindowController {
459+
var _testUserScripts: [WKUserScript] {
460+
self.webView.configuration.userContentController.userScripts
461+
}
462+
}
463+
#endif

apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ struct DashboardWindowSmokeTests {
3838
#expect(DashboardWindowController.originString(for: url) == "http://[fd12:3456:789a::1]:18789")
3939
}
4040

41+
@Test func `dashboard log string strips token fragment`() throws {
42+
let url = try #require(URL(string: "http://127.0.0.1:18789/control/#token=sekret")) // pragma: allowlist secret
43+
#expect(dashboardLogString(for: url) == "http://127.0.0.1:18789/control/")
44+
}
45+
4146
@Test func `dashboard failure state opens in dashboard window`() throws {
4247
let url = try #require(URL(string: "http://127.0.0.1:18789/control/"))
4348
let controller = DashboardWindowController(
@@ -51,4 +56,77 @@ struct DashboardWindowSmokeTests {
5156
#expect(controller.window?.styleMask.contains(.closable) == true)
5257
controller.closeDashboard()
5358
}
59+
60+
private func makeShownController() throws -> DashboardWindowController {
61+
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=device-token"))
62+
let controller = DashboardWindowController(
63+
url: url,
64+
auth: DashboardWindowAuth(
65+
gatewayUrl: "ws://127.0.0.1:60001/",
66+
token: "device-token",
67+
password: nil))
68+
controller.show()
69+
return controller
70+
}
71+
72+
@Test func `dashboard follows ready endpoint to a new tunnel port`() async throws {
73+
let controller = try self.makeShownController()
74+
defer { controller.closeDashboard() }
75+
let manager = DashboardManager._testMake()
76+
manager._testSetController(controller)
77+
78+
await manager.handleEndpointState(.ready(
79+
mode: .remote,
80+
url: try #require(URL(string: "ws://127.0.0.1:60002")),
81+
token: "device-token",
82+
password: nil))
83+
84+
#expect(controller.currentURL.absoluteString == "http://127.0.0.1:60002/#token=device-token")
85+
let authScripts = controller._testUserScripts
86+
.filter { $0.source.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") }
87+
#expect(authScripts.count == 1)
88+
// JSONSerialization escapes "/" so match on host:port, not the full origin.
89+
#expect(authScripts.first?.source.contains("127.0.0.1:60002") == true)
90+
#expect(authScripts.first?.source.contains("60001") == false)
91+
}
92+
93+
@Test func `dashboard keeps endpoint when ready state matches current URL`() async throws {
94+
let controller = try self.makeShownController()
95+
defer { controller.closeDashboard() }
96+
let manager = DashboardManager._testMake()
97+
manager._testSetController(controller)
98+
let scriptsBefore = controller._testUserScripts
99+
100+
await manager.handleEndpointState(.ready(
101+
mode: .remote,
102+
url: try #require(URL(string: "ws://127.0.0.1:60001")),
103+
token: "device-token",
104+
password: nil))
105+
await manager.handleEndpointState(.connecting(mode: .remote, detail: "Connecting…"))
106+
await manager.handleEndpointState(.unavailable(mode: .remote, reason: "tunnel down"))
107+
108+
#expect(controller.currentURL.absoluteString == "http://127.0.0.1:60001/#token=device-token")
109+
// Identity check: an unchanged endpoint must not re-inject scripts or reload.
110+
#expect(controller._testUserScripts.elementsEqual(scriptsBefore) { $0 === $1 })
111+
}
112+
113+
@Test func `dashboard ignores endpoint changes while window is closed`() async throws {
114+
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=device-token"))
115+
let controller = DashboardWindowController(
116+
url: url,
117+
auth: DashboardWindowAuth(
118+
gatewayUrl: "ws://127.0.0.1:60001/",
119+
token: "device-token",
120+
password: nil))
121+
let manager = DashboardManager._testMake()
122+
manager._testSetController(controller)
123+
124+
await manager.handleEndpointState(.ready(
125+
mode: .remote,
126+
url: try #require(URL(string: "ws://127.0.0.1:60002")),
127+
token: "device-token",
128+
password: nil))
129+
130+
#expect(controller.currentURL == url)
131+
}
54132
}

apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,15 +110,6 @@ struct OnboardingViewSmokeTests {
110110
installing: false))
111111
}
112112

113-
@Test func `Crestodian setup requires a nonempty auth value`() {
114-
#expect(!OnboardingView.hasCrestodianSetupAuth([:]))
115-
#expect(!OnboardingView.hasCrestodianSetupAuth(["token": " "]))
116-
#expect(OnboardingView.hasCrestodianSetupAuth(["mode": "token"]))
117-
#expect(OnboardingView.hasCrestodianSetupAuth([
118-
"token": ["source": "env", "provider": "default", "id": "GATEWAY_TOKEN"],
119-
]))
120-
}
121-
122113
@Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async {
123114
let override = FileManager().temporaryDirectory
124115
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")

0 commit comments

Comments
 (0)