diff --git a/CHANGELOG.md b/CHANGELOG.md index 366a271e9ca..5c23a6fba83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Security/audit: add `security.audit.suppressions` for intentionally accepted audit findings, keeping suppressed matches out of the active summary while preserving them in JSON output with an active suppression notice. (#76949) Thanks @100menotu001. - Agents/subagents: label delegated task and subagent completion handoffs as ready for parent review, and tell requester agents to review/verify results before calling them done. (#78985) Thanks @100menotu001. - Control UI: show provider quota usage in the Overview card and Chat header, and recover stale Chat in-progress state after missed terminal events. (#82647) +- Mac app remote setup can now be preconfigured from `openclaw-mac configure-remote`, skips onboarding when config is already complete, supports direct LAN/Tailnet gateway URLs, allows private same-origin Control UI loads, and owns the SSH tunnel process when SSH is selected. - Providers/xAI: add xAI Grok OAuth login for SuperGrok subscribers, letting `xai/*` models and xAI media/tool providers authenticate without `XAI_API_KEY`. - CLI/cron: add `openclaw cron run --wait` with timeout and poll interval controls, plus exact `cron.runs --run-id` filtering so automation can block on one queued manual run. (#81929) Thanks @ificator. - Maintainer tooling: route Crabbox skill defaults through the repo brokered AWS config, leaving Blacksmith Testbox as an explicit opt-in instead of the broad-proof default. @@ -87,6 +88,7 @@ Docs: https://docs.openclaw.ai - GitHub Copilot: route device-login requests through the plugin SSRF guard with a GitHub-only policy. - Group/channel replies: keep message-tool-preferred final replies private when the agent misses the message tool, and log suppressed payload metadata in the gateway debug log for quieter diagnosis. - Gateway/WebChat: route image attachments through a configured vision-capable `imageModel` plan before inlining images, and carry that image-model fallback chain through runtime retries. (#82524) Thanks @frankekn. +- macOS app: open the Dashboard in a native WebKit window with standard macOS traffic-light controls, keep the Dock icon visible by default, and reuse the app's connected gateway auth for automatic Control UI login. - WebChat: show progress while manual `/compact` is running by streaming a session operation event to subscribed Control UI clients. Fixes #82407. Thanks @Conan-Scott. - Codex app-server: limit canonical OpenAI Codex app-server attribution rewrites to local transcript and trajectory records, leaving runtime/tool routing on the selected OpenAI model metadata so OpenAI API-key backup profiles keep their billing path. - Codex app-server: hide native tool-search control tools from dynamic tool exposure while preserving the message tool. diff --git a/apps/macos/Icon.icon/Assets/openclaw-mac.png b/apps/macos/Icon.icon/Assets/openclaw-mac.png index 1ebd257d93f..9bb52017115 100644 Binary files a/apps/macos/Icon.icon/Assets/openclaw-mac.png and b/apps/macos/Icon.icon/Assets/openclaw-mac.png differ diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 36690db4208..1dd3995d6e7 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -81,6 +81,7 @@ let package = Package( dependencies: [ "OpenClawIPC", "OpenClaw", + "OpenClawMacCLI", "OpenClawDiscovery", .product(name: "OpenClawProtocol", package: "OpenClawKit"), .product(name: "SwabbleKit", package: "swabble"), diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 6ab59ae1a15..e971ac3f3fb 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -318,7 +318,12 @@ final class AppState { self.iconAnimationsEnabled = true UserDefaults.standard.set(true, forKey: iconAnimationsEnabledKey) } - self.showDockIcon = UserDefaults.standard.bool(forKey: showDockIconKey) + if let storedShowDockIcon = UserDefaults.standard.object(forKey: showDockIconKey) as? Bool { + self.showDockIcon = storedShowDockIcon + } else { + self.showDockIcon = true + UserDefaults.standard.set(true, forKey: showDockIconKey) + } self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? "" self.voiceWakeMicName = UserDefaults.standard.string(forKey: voiceWakeMicNameKey) ?? "" self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier @@ -365,8 +370,16 @@ final class AppState { self.remoteTransport = configRemoteTransport self.connectionMode = resolvedConnectionMode + let configRemote = (configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any] + let configRemoteTarget = (configRemote?["sshTarget"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" if resolvedConnectionMode == .remote, + !configRemoteTarget.isEmpty, + storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + self.remoteTarget = configRemoteTarget + } else if resolvedConnectionMode == .remote, configRemoteTransport != .direct, storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let host = AppState.remoteHost(from: configRemoteUrl), @@ -380,9 +393,11 @@ final class AppState { self.remoteToken = configRemoteToken.textFieldValue self.remoteTokenDirty = false self.remoteTokenUnsupported = configRemoteToken.isUnsupportedNonString - self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" - self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" - self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" + self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey)?.nonEmpty + ?? configRemote?["sshIdentity"] as? String + ?? "" + self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey)?.nonEmpty ?? "" + self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey)?.nonEmpty ?? "" self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true let execDefaults = ExecApprovalsStore.resolveDefaults() self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask) diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index 718a303fc7a..4e0a2eb5012 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -426,10 +426,15 @@ enum CommandResolver { { let root = configRoot ?? OpenClawConfigFile.loadDict() let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode - let target = defaults.string(forKey: remoteTargetKey) ?? "" - let identity = defaults.string(forKey: remoteIdentityKey) ?? "" - let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? "" - let cliPath = defaults.string(forKey: remoteCliPathKey) ?? "" + let remote = (root["gateway"] as? [String: Any])?["remote"] as? [String: Any] + let target = defaults.string(forKey: remoteTargetKey)?.nonEmpty + ?? remote?["sshTarget"] as? String + ?? "" + let identity = defaults.string(forKey: remoteIdentityKey)?.nonEmpty + ?? remote?["sshIdentity"] as? String + ?? "" + let projectRoot = defaults.string(forKey: remoteProjectRootKey)?.nonEmpty ?? "" + let cliPath = defaults.string(forKey: remoteCliPathKey)?.nonEmpty ?? "" return RemoteSettings( mode: mode, target: self.sanitizedTarget(target), diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index 607aab47940..730470b422a 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -240,6 +240,12 @@ final class ControlChannel { case .timedOut: return "Gateway request timed out; check gateway on localhost:\(port)." case .notConnectedToInternet: + if Self.isLikelyLocalNetworkPermissionBlock() { + return """ + macOS is blocking OpenClaw Local Network access. + Allow OpenClaw in System Settings → Privacy & Security → Local Network, then relaunch the app. + """ + } return "No network connectivity; cannot reach gateway." default: break @@ -257,6 +263,21 @@ final class ControlChannel { return "Gateway error: \(trimmed)" } + private static func isLikelyLocalNetworkPermissionBlock() -> Bool { + let root = OpenClawConfigFile.loadDict() + guard ConnectionModeResolver.resolve(root: root).mode == .remote, + GatewayRemoteConfig.resolveTransport(root: root) == .direct, + let url = GatewayRemoteConfig.resolveGatewayUrl(root: root), + url.scheme?.lowercased() == "ws", + let host = url.host, + GatewayRemoteConfig.isTrustedPlaintextRemoteHost(host), + !LoopbackHost.isLoopbackHost(host) + else { + return false + } + return true + } + private func scheduleRecovery(reason: String) { let now = Date() if let last = self.lastRecoveryAt, now.timeIntervalSince(last) < 10 { return } diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift new file mode 100644 index 00000000000..752e5b39368 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -0,0 +1,120 @@ +import AppKit +import Foundation +import OpenClawKit +import OSLog + +private let dashboardManagerLogger = Logger(subsystem: "ai.openclaw", category: "DashboardManager") + +@MainActor +final class DashboardManager { + static let shared = DashboardManager() + + private var controller: DashboardWindowController? + + private init() {} + + @discardableResult + func showConfiguredWindowIfPossible() -> Bool { + let mode = AppStateStore.shared.connectionMode + guard let config = self.immediateDashboardConfig(mode: mode), + let url = try? GatewayEndpointStore.dashboardURL( + for: config, + mode: mode, + authToken: config.token) + else { + return false + } + let auth = DashboardWindowAuth( + gatewayUrl: Self.websocketURLString(for: url), + token: config.token, + password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + guard auth.hasCredential else { + return false + } + if let controller { + controller.show(url: url, auth: auth) + } else { + let controller = DashboardWindowController(url: url, auth: auth) + self.controller = controller + controller.show(url: url, auth: auth) + } + Task { _ = try? await ControlChannel.shared.health(timeout: 3) } + return true + } + + func show() async throws { + let mode = AppStateStore.shared.connectionMode + dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)") + let config = try await self.dashboardConfig(mode: mode) + dashboardManagerLogger.info("dashboard config url=\(config.url.absoluteString, privacy: .public)") + let token = await GatewayConnection.shared.controlUiAutoAuthToken(config: config) + let url = try GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: token) + let auth = DashboardWindowAuth( + gatewayUrl: Self.websocketURLString(for: url), + token: token, + password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) + + if let controller { + dashboardManagerLogger.info("dashboard reuse window url=\(url.absoluteString, privacy: .public)") + controller.show(url: url, auth: auth) + return + } + + dashboardManagerLogger.info("dashboard create window url=\(url.absoluteString, privacy: .public)") + let controller = DashboardWindowController(url: url, auth: auth) + self.controller = controller + controller.show(url: url, auth: auth) + + // Refresh the cached hello payload without blocking window creation. + Task { _ = try? await ControlChannel.shared.health(timeout: 3) } + } + + func close() { + self.controller?.closeDashboard() + } + + private static func websocketURLString(for dashboardURL: URL) -> String { + guard var components = URLComponents(url: dashboardURL, resolvingAgainstBaseURL: false) else { + return dashboardURL.absoluteString + } + switch components.scheme?.lowercased() { + case "https": + components.scheme = "wss" + default: + components.scheme = "ws" + } + components.queryItems = nil + components.fragment = nil + return components.url?.absoluteString ?? dashboardURL.absoluteString + } + + private func dashboardConfig(mode: AppState.ConnectionMode) async throws -> GatewayConnection.Config { + if let config = self.immediateDashboardConfig(mode: mode) { + return config + } + + return try await Task.detached(priority: .userInitiated) { + await GatewayEndpointStore.shared.refresh() + return try await GatewayEndpointStore.shared.requireConfig() + }.value + } + + private func immediateDashboardConfig(mode: AppState.ConnectionMode) -> GatewayConnection.Config? { + let root = OpenClawConfigFile.loadDict() + if mode == .remote, + GatewayRemoteConfig.resolveTransport(root: root) == .direct, + let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) + { + return ( + url, + GatewayRemoteConfig.resolveTokenString(root: root), + GatewayRemoteConfig.resolvePasswordString(root: root)) + } + + if mode == .local { + return GatewayEndpointStore.localConfig() + } + + return nil + } +} diff --git a/apps/macos/Sources/OpenClaw/DashboardWindow.swift b/apps/macos/Sources/OpenClaw/DashboardWindow.swift new file mode 100644 index 00000000000..9fabcee88c2 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardWindow.swift @@ -0,0 +1,21 @@ +import AppKit +import Foundation +import OSLog + +let dashboardWindowLogger = Logger(subsystem: "ai.openclaw", category: "DashboardWindow") + +enum DashboardWindowLayout { + static let windowSize = NSSize(width: 1240, height: 860) + static let windowMinSize = NSSize(width: 900, height: 620) +} + +struct DashboardWindowAuth: Equatable { + var gatewayUrl: String? + var token: String? + var password: String? + + var hasCredential: Bool { + self.token?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || + self.password?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } +} diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift new file mode 100644 index 00000000000..f27f37ce53f --- /dev/null +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -0,0 +1,343 @@ +import AppKit +import Foundation +import WebKit + +private final class DashboardWindowContentView: NSView { + override var mouseDownCanMoveWindow: Bool { true } +} + +private final class DashboardWindowDragRegionView: NSView { + override var mouseDownCanMoveWindow: Bool { true } + + override func mouseDown(with event: NSEvent) { + self.window?.performDrag(with: event) + } +} + +@MainActor +final class DashboardWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate { + private let webView: WKWebView + private var currentURL: URL + private var auth: DashboardWindowAuth + + init(url: URL, auth: DashboardWindowAuth) { + self.currentURL = url + self.auth = auth + + let config = WKWebViewConfiguration() + config.preferences.isElementFullscreenEnabled = true + config.preferences.setValue(true, forKey: "developerExtrasEnabled") + config.userContentController = WKUserContentController() + Self.installNativeChromeScript(into: config.userContentController) + Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth) + + self.webView = WKWebView( + frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize), + configuration: config) + self.webView.setValue(true, forKey: "drawsBackground") + + let window = Self.makeWindow(contentView: self.webView) + super.init(window: window) + + self.webView.navigationDelegate = self + self.window?.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + func show(url: URL, auth: DashboardWindowAuth) { + self.currentURL = url + self.auth = auth + self.refreshNativeAuthScript(url: url, auth: auth) + self.load(url) + self.show() + } + + func show() { + if let window { + let frame = window.frame + if frame.width < DashboardWindowLayout.windowMinSize.width || + frame.height < DashboardWindowLayout.windowMinSize.height + { + window.setFrame(WindowPlacement.centeredFrame(size: DashboardWindowLayout.windowSize), display: false) + } + } + self.showWindow(nil) + self.window?.makeKeyAndOrderFront(nil) + self.window?.makeFirstResponder(self.webView) + self.window?.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + } + + func closeDashboard() { + self.window?.performClose(nil) + } + + private func load(_ url: URL) { + dashboardWindowLogger.debug("dashboard load \(url.absoluteString, privacy: .public)") + self.webView.load(URLRequest(url: url)) + } + + private func refreshNativeAuthScript(url: URL, auth: DashboardWindowAuth) { + let controller = self.webView.configuration.userContentController + controller.removeAllUserScripts() + Self.installNativeChromeScript(into: controller) + Self.installNativeAuthScript(into: controller, url: url, auth: auth) + } + + private static func makeWindow(contentView: NSView) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false) + let container = DashboardWindowContentView(frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize)) + contentView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(contentView) + let topDragRegion = DashboardWindowDragRegionView() + topDragRegion.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(topDragRegion) + let topRightDragRegion = DashboardWindowDragRegionView() + topRightDragRegion.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(topRightDragRegion) + let sidebarDragRegion = DashboardWindowDragRegionView() + sidebarDragRegion.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(sidebarDragRegion) + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + contentView.topAnchor.constraint(equalTo: container.topAnchor), + contentView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + topDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78), + topDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -380), + topDragRegion.topAnchor.constraint(equalTo: container.topAnchor), + topDragRegion.heightAnchor.constraint(equalToConstant: 28), + topRightDragRegion.leadingAnchor.constraint(equalTo: topDragRegion.trailingAnchor), + topRightDragRegion.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8), + topRightDragRegion.topAnchor.constraint(equalTo: container.topAnchor), + topRightDragRegion.heightAnchor.constraint(equalToConstant: 6), + sidebarDragRegion.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 78), + sidebarDragRegion.topAnchor.constraint(equalTo: container.topAnchor), + sidebarDragRegion.widthAnchor.constraint(equalToConstant: 176), + sidebarDragRegion.heightAnchor.constraint(equalToConstant: 46), + ]) + window.title = "OpenClaw" + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.hasShadow = true + window.backgroundColor = .windowBackgroundColor + window.isOpaque = true + let viewController = NSViewController() + viewController.view = container + window.contentViewController = viewController + window.center() + window.minSize = DashboardWindowLayout.windowMinSize + WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize) + return window + } + + private static func installNativeChromeScript(into userContentController: WKUserContentController) { + let css = """ + html.openclaw-native-macos { + --openclaw-native-titlebar-height: 50px; + } + @media (min-width: 700px) { + html.openclaw-native-macos .sidebar-shell { + padding-top: max(14px, var(--openclaw-native-titlebar-height)) !important; + } + html.openclaw-native-macos .sidebar-shell__header { + padding-left: 10px !important; + padding-right: 8px !important; + } + } + """ + let script = """ + (() => { + try { + if (document.getElementById("openclaw-native-macos-chrome")) return; + const style = document.createElement("style"); + style.id = "openclaw-native-macos-chrome"; + style.textContent = \(Self.jsStringLiteral(css)); + document.documentElement.classList.add("openclaw-native-macos"); + document.head.appendChild(style); + } catch {} + })(); + """ + userContentController.addUserScript( + WKUserScript(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) + } + + private static func installNativeAuthScript( + into userContentController: WKUserContentController, + url: URL, + auth: DashboardWindowAuth) + { + guard auth.hasCredential else { return } + let allowedOrigin = self.originString(for: url) + let allowedPath = self.allowedPath(for: url) + let payload: [String: Any?] = [ + "gatewayUrl": auth.gatewayUrl, + "token": auth.token, + "password": auth.password, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload.compactMapValues { $0 }), + let json = String(data: data, encoding: .utf8) + else { + return + } + let script = """ + (() => { + try { + const allowedOrigin = \(Self.jsStringLiteral(allowedOrigin)); + const allowedPath = \(Self.jsStringLiteral(allowedPath)); + if (location.origin !== allowedOrigin) return; + if (allowedPath !== "/" && !location.pathname.startsWith(allowedPath)) return; + Object.defineProperty(window, "__OPENCLAW_NATIVE_CONTROL_AUTH__", { + value: \(json), + configurable: true, + }); + } catch {} + })(); + """ + userContentController.addUserScript( + WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + } + + static func originString(for url: URL) -> String { + guard let scheme = url.scheme, let host = url.host else { return "" } + let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host + var out = "\(scheme)://\(hostPart)" + if let port = url.port { + out += ":\(port)" + } + return out + } + + private static func allowedPath(for url: URL) -> String { + let path = url.path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return "/" } + return path.hasSuffix("/") ? path : path + "/" + } + + private static func jsStringLiteral(_ value: String) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let raw = String(data: data, encoding: .utf8), + raw.hasPrefix("["), + raw.hasSuffix("]") + else { + return "\"\"" + } + return String(raw.dropFirst().dropLast()) + } + + func webView( + _: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) + { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + if Self.shouldAllowNavigation(to: url, dashboardURL: self.currentURL) { + decisionHandler(.allow) + return + } + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + } + + func webView(_: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + self.showLoadFailure(error) + } + + func webView(_: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + self.showLoadFailure(error) + } + + static func shouldAllowNavigation(to url: URL, dashboardURL: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return true } + if scheme == "about" || scheme == "blob" || scheme == "data" { return true } + guard scheme == "http" || scheme == "https" else { return false } + return url.scheme?.lowercased() == dashboardURL.scheme?.lowercased() && + url.host?.lowercased() == dashboardURL.host?.lowercased() && + url.port == dashboardURL.port + } + + func windowWillClose(_: Notification) { + self.webView.stopLoading() + } + + private func showLoadFailure(_ error: Error) { + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled { return } + dashboardWindowLogger.error( + "dashboard load failed url=\(self.currentURL.absoluteString, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + let html = Self.failureHTML(url: self.currentURL, message: error.localizedDescription) + self.webView.loadHTMLString(html, baseURL: nil) + } + + private static func failureHTML(url: URL, message: String) -> String { + """ + + + + + + + +
+

Dashboard unavailable

+

\(Self.htmlEscape(message))

+ \(Self.htmlEscape(url.absoluteString)) +
+ + + """ + } + + private static func htmlEscape(_ value: String) -> String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } +} diff --git a/apps/macos/Sources/OpenClaw/DeepLinks.swift b/apps/macos/Sources/OpenClaw/DeepLinks.swift index d11d4d524c3..4b05a672447 100644 --- a/apps/macos/Sources/OpenClaw/DeepLinks.swift +++ b/apps/macos/Sources/OpenClaw/DeepLinks.swift @@ -69,6 +69,8 @@ final class DeepLinkHandler { await self.handleAgent(link: link, originalURL: url) case .gateway: break + case .dashboard: + await self.openDashboard() } } @@ -178,6 +180,14 @@ final class DeepLinkHandler { // MARK: - UI + private func openDashboard() async { + do { + try await DashboardManager.shared.show() + } catch { + self.presentAlert(title: "Dashboard unavailable", message: error.localizedDescription) + } + } + private func confirm(title: String, message: String) -> Bool { let alert = NSAlert() alert.messageText = title diff --git a/apps/macos/Sources/OpenClaw/DockIconManager.swift b/apps/macos/Sources/OpenClaw/DockIconManager.swift index 98201393b75..946b4b3a31e 100644 --- a/apps/macos/Sources/OpenClaw/DockIconManager.swift +++ b/apps/macos/Sources/OpenClaw/DockIconManager.swift @@ -28,7 +28,7 @@ final class DockIconManager: NSObject, @unchecked Sendable { return } - let userWantsDockHidden = !UserDefaults.standard.bool(forKey: showDockIconKey) + let userWantsDockHidden = (UserDefaults.standard.object(forKey: showDockIconKey) as? Bool) == false let visibleWindows = NSApp?.windows.filter { window in window.isVisible && window.frame.width > 1 && diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index f08b04944b4..e15a4a8e440 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -317,6 +317,28 @@ actor GatewayConnection { return trimmed.isEmpty ? nil : trimmed } + func controlUiAutoAuthToken(config: Config) async -> String? { + if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), + !token.isEmpty + { + return token + } + if let deviceToken = self.lastSnapshot?.auth["deviceToken"]?.value as? String { + let trimmed = deviceToken.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + let identity = DeviceIdentityStore.loadOrCreate() + if let entry = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") { + let trimmed = entry.token.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + } + private func sessionDefaultString(_ defaults: [String: OpenClawProtocol.AnyCodable]?, key: String) -> String { let raw = defaults?[key]?.value as? String return (raw ?? "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift index 2d923a5ea9e..29f09d3934d 100644 --- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift +++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift @@ -667,7 +667,8 @@ extension GatewayEndpointStore { static func dashboardURL( for config: GatewayConnection.Config, mode: AppState.ConnectionMode, - localBasePath: String? = nil) throws -> URL + localBasePath: String? = nil, + authToken: String? = nil) throws -> URL { guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else { throw NSError(domain: "Dashboard", code: 1, userInfo: [ @@ -694,7 +695,8 @@ extension GatewayEndpointStore { } var fragmentItems: [URLQueryItem] = [] - if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines), + let tokenCandidate = authToken ?? config.token + if let token = tokenCandidate?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { fragmentItems.append(URLQueryItem(name: "token", value: token)) diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift index 629651e34e7..32ab874d039 100644 --- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift +++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift @@ -1,5 +1,8 @@ import Foundation import OpenClawKit +#if canImport(Darwin) +import Darwin +#endif enum GatewayRemoteConfig { enum TokenValue: Equatable { @@ -69,6 +72,17 @@ enum GatewayRemoteConfig { } } + static func resolvePasswordString(root: [String: Any]) -> String? { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any], + let raw = remote["password"] as? String + else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + static func resolveTLSFingerprint(root: [String: Any]) -> String? { guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], @@ -85,6 +99,27 @@ enum GatewayRemoteConfig { return self.normalizeGatewayUrl(raw) } + static func resolveRemotePort(root: [String: Any]) -> Int? { + guard let gateway = root["gateway"] as? [String: Any], + let remote = gateway["remote"] as? [String: Any] + else { + return nil + } + let value = remote["remotePort"] + let port: Int? = switch value { + case let raw as Int: + raw + case let raw as NSNumber: + raw.intValue + case let raw as String: + Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } + guard let port, port > 0, port <= 65535 else { return nil } + return port + } + static func normalizeGatewayUrlString(_ raw: String) -> String? { self.normalizeGatewayUrl(raw)?.absoluteString } @@ -96,7 +131,10 @@ enum GatewayRemoteConfig { guard scheme == "ws" || scheme == "wss" else { return nil } let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !host.isEmpty else { return nil } - if scheme == "ws", !LoopbackHost.isLoopbackHost(host) { + if scheme == "ws", + !LoopbackHost.isLoopbackHost(host), + !self.isTrustedPlaintextRemoteHost(host) + { return nil } if scheme == "ws", url.port == nil { @@ -109,6 +147,59 @@ enum GatewayRemoteConfig { return url } + static func isTrustedPlaintextRemoteHost(_ host: String) -> Bool { + let lower = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !lower.isEmpty else { return false } + if lower == "localhost" || lower.hasSuffix(".local") || lower.hasSuffix(".ts.net") { + return true + } + if self.isPrivateIPv6Literal(lower) { + return true + } + guard let parts = self.ipv4Parts(lower) else { return false } + switch (parts[0], parts[1]) { + case (10, _), (192, 168), (169, 254): + return true + case (172, 16...31): + return true + case (100, 64...127): + return true + default: + return false + } + } + + private static func ipv4Parts(_ value: String) -> [Int]? { + let labels = value.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count == 4 else { return nil } + var parts: [Int] = [] + parts.reserveCapacity(4) + for label in labels { + guard !label.isEmpty, + label.allSatisfy(\.isNumber), + let part = Int(label), + part >= 0, + part <= 255 + else { + return nil + } + parts.append(part) + } + return parts + } + + private static func isPrivateIPv6Literal(_ value: String) -> Bool { + #if canImport(Darwin) + var addr = in6_addr() + guard value.withCString({ inet_pton(AF_INET6, $0, &addr) }) == 1 else { + return false + } + return value.hasPrefix("fc") || value.hasPrefix("fd") || value.hasPrefix("fe80:") + #else + return false + #endif + } + static func defaultPort(for url: URL) -> Int? { if let port = url.port { return port } let scheme = url.scheme?.lowercased() ?? "" diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 90f2ffff4b4..17ab82f603b 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -44,7 +44,7 @@ struct GeneralSettings: View { SettingsToggleRow( title: "Show Dock icon", - subtitle: "Keep OpenClaw visible in the Dock instead of menu-bar-only mode.", + subtitle: "Keep OpenClaw visible in the Dock. When off, windows still show the Dock icon while open.", binding: self.$state.showDockIcon) SettingsToggleRow( @@ -285,7 +285,7 @@ struct GeneralSettings: View { disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } Text( - "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.") + "Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 6e29f8a0f52..acc2e2def99 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -143,7 +143,7 @@ struct OpenClawApp: App { handler.translatesAutoresizingMaskIntoConstraints = false handler.onLeftClick = { [self] in HoverHUDController.shared.dismiss(reason: "statusItemClick") - self.toggleWebChatPanel() + self.openDashboardWindow() } handler.onRightClick = { [self] in HoverHUDController.shared.dismiss(reason: "statusItemRightClick") @@ -167,14 +167,24 @@ struct OpenClawApp: App { } @MainActor - private func toggleWebChatPanel() { + private func openDashboardWindow() { HoverHUDController.shared.setSuppressed(true) self.isMenuPresented = false + if DashboardManager.shared.showConfiguredWindowIfPossible() { + return + } Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.togglePanel( - sessionKey: sessionKey, - anchorProvider: { [self] in self.statusButtonScreenFrame() }) + if DashboardManager.shared.showConfiguredWindowIfPossible() { + return + } + do { + try await DashboardManager.shared.show() + } catch { + let alert = NSAlert() + alert.messageText = "Dashboard unavailable" + alert.informativeText = error.localizedDescription + alert.runModal() + } } } @@ -283,6 +293,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { WebChatManager.shared.show(sessionKey: sessionKey) } } + if CommandLine.arguments.contains("--dashboard") { + self.webChatAutoLogger.info("Auto-opening dashboard via CLI flag") + Task { @MainActor in + if DashboardManager.shared.showConfiguredWindowIfPossible() { + return + } + do { + try await DashboardManager.shared.show() + } catch { + let alert = NSAlert() + alert.messageText = "Dashboard unavailable" + alert.informativeText = error.localizedDescription + alert.runModal() + } + } + } } func applicationWillTerminate(_ notification: Notification) { @@ -294,6 +320,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { MacNodeModeCoordinator.shared.stop() TerminationSignalWatcher.shared.stop() VoiceWakeGlobalSettingsSync.shared.stop() + DashboardManager.shared.close() WebChatManager.shared.close() WebChatManager.shared.resetTunnels() Task { await RemoteTunnelManager.shared.stopAll() } @@ -303,6 +330,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor private func scheduleFirstRunOnboardingIfNeeded() { + if AppStateStore.shared.connectionMode != .unconfigured { + OnboardingController.markComplete() + return + } let seenVersion = UserDefaults.standard.integer(forKey: onboardingVersionKey) let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen guard shouldShow else { return } diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift index c2a48746435..7e0de41b17a 100644 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -111,11 +111,7 @@ struct MenuContent: View { self.voiceWakeMicMenu } Divider() - Button { - Task { @MainActor in - await self.openDashboard() - } - } label: { + Link(destination: URL(string: "openclaw://dashboard")!) { Label("Open Dashboard", systemImage: "gauge") } Button { @@ -342,20 +338,6 @@ struct MenuContent: View { } } - @MainActor - private func openDashboard() async { - do { - let config = try await GatewayEndpointStore.shared.requireConfig() - let url = try GatewayEndpointStore.dashboardURL(for: config, mode: self.state.connectionMode) - NSWorkspace.shared.open(url) - } catch { - let alert = NSAlert() - alert.messageText = "Dashboard unavailable" - alert.informativeText = error.localizedDescription - alert.runModal() - } - } - private var macNodeStatus: (label: String, color: Color)? { guard self.state.connectionMode != .unconfigured else { return nil } guard case .connected = self.controlChannel.state else { return nil } diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index ca183d35311..b7ca48a840c 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -21,12 +21,16 @@ final class OnboardingController { static let shared = OnboardingController() private var window: NSWindow? + static func markComplete() { + UserDefaults.standard.set(true, forKey: onboardingSeenKey) + UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + AppStateStore.shared.onboardingSeen = true + } + func show() { if ProcessInfo.processInfo.isNixMode { // Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply. - UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") - UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) - AppStateStore.shared.onboardingSeen = true + Self.markComplete() return } if let window { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index 23b051cbc99..9c55111512f 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -54,8 +54,7 @@ extension OnboardingView { } func finish() { - UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") - UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + OnboardingController.markComplete() OnboardingController.shared.close() } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 45f9b45bdef..3fbf2201247 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -321,7 +321,7 @@ extension OnboardingView { return "Select a nearby gateway or open Advanced to enter a gateway URL." } if GatewayRemoteConfig.normalizeGatewayUrl(trimmedUrl) == nil { - return "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)." + return "Gateway URL must use wss:// for public hosts; ws:// is allowed for localhost, LAN, or Tailnet hosts." } return nil case .ssh: diff --git a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift index d2e13ca8586..745adb689ae 100644 --- a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift +++ b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift @@ -1,5 +1,6 @@ import Foundation import Network +import OpenClawKit import OSLog #if canImport(Darwin) import Darwin @@ -8,7 +9,7 @@ import Darwin /// Port forwarding tunnel for remote mode. /// /// Uses `ssh -N -L` to forward the remote gateway ports to localhost. -final class RemotePortTunnel { +final class RemotePortTunnel: @unchecked Sendable { private static let logger = Logger(subsystem: "ai.openclaw", category: "remote.tunnel") let process: Process @@ -56,9 +57,8 @@ final class RemotePortTunnel { preferred: preferredLocalPort, allowRandom: allowRandomLocalPort) let sshHost = parsed.host.trimmingCharacters(in: .whitespacesAndNewlines) - let remotePortOverride = - allowRemoteUrlOverride && remotePort == GatewayEnvironment.gatewayPort() - ? Self.resolveRemotePortOverride(for: sshHost) + let remotePortOverride = allowRemoteUrlOverride + ? Self.resolveRemotePortOverride(defaultRemotePort: remotePort, for: sshHost) : nil let resolvedRemotePort = remotePortOverride ?? remotePort if let override = remotePortOverride { @@ -76,6 +76,7 @@ final class RemotePortTunnel { "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-o", "TCPKeepAlive=yes", + "-n", "-N", "-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)", ] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions @@ -113,13 +114,7 @@ final class RemotePortTunnel { try process.run() - // If ssh exits immediately (e.g. local port already in use), surface stderr and ensure we stop monitoring. - try? await Task.sleep(nanoseconds: 150_000_000) // 150ms - if !process.isRunning { - let stderr = Self.drainStderr(stderrHandle) - let msg = stderr.isEmpty ? "ssh tunnel exited immediately" : "ssh tunnel failed: \(stderr)" - throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) - } + try await Self.waitForListener(process: process, localPort: localPort, stderrHandle: stderrHandle) // Track tunnel so we can clean up stale listeners on restart. Task { @@ -133,8 +128,40 @@ final class RemotePortTunnel { return RemotePortTunnel(process: process, localPort: localPort, stderrHandle: stderrHandle) } - private static func resolveRemotePortOverride(for sshHost: String) -> Int? { + private static func waitForListener( + process: Process, + localPort: UInt16, + stderrHandle: FileHandle) async throws + { + let deadline = Date().addingTimeInterval(6) + repeat { + if !process.isRunning { + let stderr = Self.drainStderr(stderrHandle) + let msg = stderr.isEmpty ? "ssh tunnel exited before listening" : "ssh tunnel failed: \(stderr)" + throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) + } + if await PortGuardian.shared.isListening(port: Int(localPort), pid: process.processIdentifier) { + return + } + do { + try await Task.sleep(nanoseconds: 100_000_000) + } catch { + process.terminate() + throw error + } + } while Date() < deadline + + process.terminate() + let stderr = Self.drainStderr(stderrHandle) + let msg = stderr.isEmpty ? "ssh tunnel did not open local port \(localPort)" : "ssh tunnel failed: \(stderr)" + throw NSError(domain: "RemotePortTunnel", code: 4, userInfo: [NSLocalizedDescriptionKey: msg]) + } + + private static func resolveRemotePortOverride(defaultRemotePort: Int, for sshHost: String) -> Int? { let root = OpenClawConfigFile.loadDict() + if let port = GatewayRemoteConfig.resolveRemotePort(root: root) { + return port + } guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], let urlRaw = remote["url"] as? String @@ -150,6 +177,9 @@ final class RemotePortTunnel { else { return nil } + if LoopbackHost.isLoopbackHost(host) { + return port == defaultRemotePort ? nil : port + } guard let sshKey = OpenClawConfigFile.canonicalHostForComparison(sshHost), let urlKey = OpenClawConfigFile.canonicalHostForComparison(host) else { @@ -299,8 +329,13 @@ final class RemotePortTunnel { self.portIsFree(port) } + static func _testResolveRemotePortOverride(defaultRemotePort: Int, sshHost: String) -> Int? { + self.resolveRemotePortOverride(defaultRemotePort: defaultRemotePort, for: sshHost) + } + static func _testDrainStderr(_ handle: FileHandle) -> String { self.drainStderr(handle) } + #endif } diff --git a/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift b/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift index e8f0da6f091..9a3e8b91ed0 100644 --- a/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift +++ b/apps/macos/Sources/OpenClaw/RemoteTunnelManager.swift @@ -7,6 +7,7 @@ actor RemoteTunnelManager { private let logger = Logger(subsystem: "ai.openclaw", category: "remote-tunnel") private var controlTunnel: RemotePortTunnel? + private var createInFlight: (token: UUID, task: Task)? private var restartInFlight = false private var lastRestartAt: Date? private let restartBackoffSeconds: TimeInterval = 2.0 @@ -31,18 +32,6 @@ actor RemoteTunnelManager { tunnel.terminate() self.controlTunnel = nil } - // If a previous OpenClaw run already has an SSH listener on the expected port (common after restarts), - // reuse it instead of spawning new ssh processes that immediately fail with "Address already in use". - let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) - if let desc = await PortGuardian.shared.describe(port: Int(desiredPort)), - self.isSshProcess(desc) - { - self.logger.info( - "reusing existing SSH tunnel listener " + - "localPort=\(desiredPort, privacy: .public) " + - "pid=\(desc.pid, privacy: .public)") - return desiredPort - } return nil } @@ -63,32 +52,52 @@ actor RemoteTunnelManager { "identitySet=\(identitySet, privacy: .public)") if let local = await self.controlTunnelPortIfRunning() { return local } + if let create = self.createInFlight { + self.logger.info("control tunnel create in flight; joining") + let tunnel = try await create.task.value + return try await self.installCreatedTunnel(tunnel, token: create.token, fallbackPort: UInt16(GatewayEnvironment.gatewayPort())) + } await self.waitForRestartBackoffIfNeeded() let desiredPort = UInt16(GatewayEnvironment.gatewayPort()) - let tunnel = try await RemotePortTunnel.create( - remotePort: GatewayEnvironment.gatewayPort(), - preferredLocalPort: desiredPort, - allowRandomLocalPort: false) + let token = UUID() + let task = Task { + try await RemotePortTunnel.create( + remotePort: GatewayEnvironment.gatewayPort(), + preferredLocalPort: desiredPort, + allowRandomLocalPort: true) + } + self.createInFlight = (token: token, task: task) + let tunnel: RemotePortTunnel + do { + tunnel = try await task.value + } catch { + if self.createInFlight?.token == token { + self.createInFlight = nil + } + throw error + } + return try await self.installCreatedTunnel(tunnel, token: token, fallbackPort: desiredPort) + } + + private func installCreatedTunnel(_ tunnel: RemotePortTunnel, token: UUID, fallbackPort: UInt16) async throws -> UInt16 { + if self.createInFlight?.token == token { + self.createInFlight = nil + } self.controlTunnel = tunnel self.endRestart() - let resolvedPort = tunnel.localPort ?? desiredPort + let resolvedPort = tunnel.localPort ?? fallbackPort self.logger.info("ssh tunnel ready localPort=\(resolvedPort, privacy: .public)") - return tunnel.localPort ?? desiredPort + return resolvedPort } func stopAll() { + self.createInFlight?.task.cancel() + self.createInFlight = nil self.controlTunnel?.terminate() self.controlTunnel = nil } - private func isSshProcess(_ desc: PortGuardian.Descriptor) -> Bool { - let cmd = desc.command.lowercased() - if cmd.contains("ssh") { return true } - if let path = desc.executablePath?.lowercased(), path.contains("/ssh") { return true } - return false - } - private func beginRestart() async { guard !self.restartInFlight else { return } self.restartInFlight = true diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 4a4733b30d8..08030232c53 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -53,6 +53,8 @@ OpenClaw can share your location when requested by the agent. NSLocationAlwaysAndWhenInUseUsageDescription OpenClaw can share your location when requested by the agent. + NSLocalNetworkUsageDescription + OpenClaw uses the local network to connect to your remote OpenClaw gateway over LAN, Tailnet, or SSH. NSMicrophoneUsageDescription OpenClaw needs the mic for Voice Wake tests and agent audio capture. NSSpeechRecognitionUsageDescription diff --git a/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns b/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns index f317728e1c9..28e1f90c47f 100644 Binary files a/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns and b/apps/macos/Sources/OpenClaw/Resources/OpenClaw.icns differ diff --git a/apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift new file mode 100644 index 00000000000..844b46e2a1d --- /dev/null +++ b/apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift @@ -0,0 +1,437 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#endif + +private let appDefaultsSuites = ["ai.openclaw.mac", "ai.openclaw.mac.debug"] +private let appOnboardingVersion = 7 + +struct ConfigureRemoteOptions { + var sshTarget: String? + var directUrl: String? + var localPort: Int = 18789 + var remotePort: Int = 18789 + var token: String? + var password: String? + var identity: String? + var projectRoot: String? + var cliPath: String? + var json = false + var help = false + + static func parse(_ args: [String]) throws -> ConfigureRemoteOptions { + var opts = ConfigureRemoteOptions() + var i = 0 + while i < args.count { + let arg = args[i] + switch arg { + case "-h", "--help": + opts.help = true + case "--json": + opts.json = true + case "--ssh-target": + opts.sshTarget = CLIArgParsingSupport.nextValue(args, index: &i) + case "--direct-url": + opts.directUrl = CLIArgParsingSupport.nextValue(args, index: &i) + case "--local-port": + opts.localPort = try parsePortFlag(args, index: &i, flag: arg) + case "--remote-port": + opts.remotePort = try parsePortFlag(args, index: &i, flag: arg) + case "--token": + opts.token = CLIArgParsingSupport.nextValue(args, index: &i) + case "--password": + opts.password = CLIArgParsingSupport.nextValue(args, index: &i) + case "--identity": + opts.identity = CLIArgParsingSupport.nextValue(args, index: &i) + case "--project-root": + opts.projectRoot = CLIArgParsingSupport.nextValue(args, index: &i) + case "--cli-path": + opts.cliPath = CLIArgParsingSupport.nextValue(args, index: &i) + default: + break + } + i += 1 + } + return opts + } +} + +struct ConfigureRemoteOutput: Encodable { + var status: String + var configPath: String + var mode: String + var transport: String + var sshTarget: String? + var localUrl: String? + var remoteUrl: String + var remotePort: Int + var onboardingSkipped: Bool +} + +func runConfigureRemote(_ args: [String]) { + do { + let opts = try ConfigureRemoteOptions.parse(args) + if opts.help { + print(""" + openclaw-mac configure-remote + + Usage: + openclaw-mac configure-remote --ssh-target [--local-port ] + [--remote-port ] [--token ] [--password ] + [--identity ] [--project-root ] [--cli-path ] [--json] + openclaw-mac configure-remote --direct-url [--token ] + [--password ] [--project-root ] [--cli-path ] [--json] + + Options: + --ssh-target SSH target for the remote gateway host. + --direct-url Direct remote gateway URL; skips SSH tunneling. + --local-port

Local tunnel port for the mac app/UI. Default: 18789. + --remote-port

Gateway port on the remote host. Default: 18789. + --token Remote gateway token. + --password Remote gateway password. + --identity SSH identity file. + --project-root

Remote OpenClaw checkout for CLI commands. + --cli-path Remote openclaw executable or entrypoint. + --json Emit JSON. + -h, --help Show help. + """) + return + } + let output = try configureRemote(opts) + printConfigureRemoteOutput(output, json: opts.json) + } catch { + if args.contains("--json") { + printJSONError(error.localizedDescription) + } else { + fputs("configure-remote: \(error.localizedDescription)\n", stderr) + } + exit(1) + } +} + +@discardableResult +func configureRemote(_ opts: ConfigureRemoteOptions) throws -> ConfigureRemoteOutput { + if let directUrlRaw = opts.directUrl?.trimmingCharacters(in: .whitespacesAndNewlines), + !directUrlRaw.isEmpty + { + return try configureDirectRemote(opts, directUrlRaw: directUrlRaw) + } + return try configureSSHRemote(opts) +} + +private func configureSSHRemote(_ opts: ConfigureRemoteOptions) throws -> ConfigureRemoteOutput { + let target = opts.sshTarget?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard isValidSSHTarget(target) else { + throw NSError( + domain: "ConfigureRemote", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "SSH target must look like user@host[:port]"]) + } + + let configURL = openClawConfigURL() + var root = try loadConfigRoot(from: configURL) + var gateway = root["gateway"] as? [String: Any] ?? [:] + var remote = gateway["remote"] as? [String: Any] ?? [:] + let localURL = "ws://127.0.0.1:\(opts.localPort)" + + gateway["mode"] = "remote" + gateway["port"] = opts.localPort + remote["transport"] = "ssh" + remote["url"] = localURL + remote["remotePort"] = opts.remotePort + remote["sshTarget"] = target + updateStringIfProvided(&remote, key: "sshIdentity", value: opts.identity) + updateStringIfProvided(&remote, key: "token", value: opts.token) + updateStringIfProvided(&remote, key: "password", value: opts.password) + gateway["remote"] = remote + root["gateway"] = gateway + + try saveConfigRoot(root, to: configURL) + writeAppDefaults(opts: opts, target: target) + + return ConfigureRemoteOutput( + status: "ok", + configPath: configURL.path, + mode: "remote", + transport: "ssh", + sshTarget: target, + localUrl: localURL, + remoteUrl: localURL, + remotePort: opts.remotePort, + onboardingSkipped: true) +} + +private func configureDirectRemote(_ opts: ConfigureRemoteOptions, directUrlRaw: String) throws -> ConfigureRemoteOutput { + guard let directURL = normalizeDirectURL(directUrlRaw) else { + throw NSError( + domain: "ConfigureRemote", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Direct URL must be ws:// for private/Tailscale hosts or wss:// for remote hosts"]) + } + + let configURL = openClawConfigURL() + var root = try loadConfigRoot(from: configURL) + var gateway = root["gateway"] as? [String: Any] ?? [:] + var remote = gateway["remote"] as? [String: Any] ?? [:] + + gateway["mode"] = "remote" + remote["transport"] = "direct" + remote["url"] = directURL.absoluteString + remote.removeValue(forKey: "remotePort") + remote.removeValue(forKey: "sshTarget") + remote.removeValue(forKey: "sshIdentity") + updateStringIfProvided(&remote, key: "token", value: opts.token) + updateStringIfProvided(&remote, key: "password", value: opts.password) + gateway["remote"] = remote + root["gateway"] = gateway + + try saveConfigRoot(root, to: configURL) + writeAppDefaults(opts: opts, target: "") + + return ConfigureRemoteOutput( + status: "ok", + configPath: configURL.path, + mode: "remote", + transport: "direct", + sshTarget: nil, + localUrl: nil, + remoteUrl: directURL.absoluteString, + remotePort: defaultPort(for: directURL) ?? opts.remotePort, + onboardingSkipped: true) +} + +private func openClawConfigURL() -> URL { + if let raw = ProcessInfo.processInfo.environment["OPENCLAW_CONFIG_PATH"], + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return URL(fileURLWithPath: NSString(string: raw).expandingTildeInPath) + } + return FileManager().homeDirectoryForCurrentUser.appendingPathComponent(".openclaw/openclaw.json") +} + +private func loadConfigRoot(from url: URL) throws -> [String: Any] { + guard FileManager().isReadableFile(atPath: url.path) else { return [:] } + let data = try Data(contentsOf: url) + return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] +} + +private func saveConfigRoot(_ root: [String: Any], to url: URL) throws { + try FileManager().createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: [.atomic]) +} + +private func writeAppDefaults(opts: ConfigureRemoteOptions, target: String) { + for suite in appDefaultsSuites { + guard let defaults = UserDefaults(suiteName: suite) else { continue } + defaults.set("remote", forKey: "openclaw.connectionMode") + setDefaultString(defaults, key: "openclaw.remoteTarget", value: target) + defaults.set(true, forKey: "openclaw.onboardingSeen") + defaults.set(appOnboardingVersion, forKey: "openclaw.onboardingVersion") + setDefaultStringIfProvided(defaults, key: "openclaw.remoteIdentity", value: opts.identity) + setDefaultStringIfProvided(defaults, key: "openclaw.remoteProjectRoot", value: opts.projectRoot) + setDefaultStringIfProvided(defaults, key: "openclaw.remoteCliPath", value: opts.cliPath) + defaults.synchronize() + } +} + +private func normalizeDirectURL(_ raw: String) -> URL? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } + let scheme = url.scheme?.lowercased() ?? "" + guard scheme == "ws" || scheme == "wss" else { return nil } + let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !host.isEmpty else { return nil } + if scheme == "ws", + !isLoopbackHost(host), + !isTrustedPlaintextRemoteHost(host) + { + return nil + } + if scheme == "ws", url.port == nil { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url + } + components.port = 18789 + return components.url + } + return url +} + +private func defaultPort(for url: URL) -> Int? { + if let port = url.port { return port } + switch url.scheme?.lowercased() { + case "wss": + return 443 + case "ws": + return 18789 + default: + return nil + } +} + +private func isLoopbackHost(_ host: String) -> Bool { + let lower = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lower == "localhost" || lower == "127.0.0.1" || lower == "::1" +} + +private func isTrustedPlaintextRemoteHost(_ host: String) -> Bool { + let lower = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !lower.isEmpty else { return false } + if lower.hasSuffix(".local") || lower.hasSuffix(".ts.net") { + return true + } + if isPrivateIPv6Literal(lower) { + return true + } + guard let parts = ipv4Parts(lower) else { return false } + switch (parts[0], parts[1]) { + case (10, _), (192, 168), (169, 254): + return true + case (172, 16...31), (100, 64...127): + return true + default: + return false + } +} + +private func ipv4Parts(_ value: String) -> [Int]? { + let labels = value.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count == 4 else { return nil } + var parts: [Int] = [] + parts.reserveCapacity(4) + for label in labels { + guard !label.isEmpty, + label.allSatisfy(\.isNumber), + let part = Int(label), + part >= 0, + part <= 255 + else { + return nil + } + parts.append(part) + } + return parts +} + +private func isPrivateIPv6Literal(_ value: String) -> Bool { + #if canImport(Darwin) + var addr = in6_addr() + guard value.withCString({ inet_pton(AF_INET6, $0, &addr) }) == 1 else { + return false + } + return value.hasPrefix("fc") || value.hasPrefix("fd") || value.hasPrefix("fe80:") + #else + return false + #endif +} + +private func setDefaultStringIfProvided(_ defaults: UserDefaults, key: String, value: String?) { + guard let value else { return } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + defaults.removeObject(forKey: key) + } else { + defaults.set(trimmed, forKey: key) + } +} + +private func setDefaultString(_ defaults: UserDefaults, key: String, value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + defaults.removeObject(forKey: key) + } else { + defaults.set(trimmed, forKey: key) + } +} + +private func updateStringIfProvided(_ dictionary: inout [String: Any], key: String, value: String?) { + guard let value else { return } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + dictionary.removeValue(forKey: key) + } else { + dictionary[key] = trimmed + } +} + +private func parsePort(_ raw: String) -> Int? { + let port = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + guard let port, port > 0, port <= 65535 else { return nil } + return port +} + +private func parsePortFlag(_ args: [String], index: inout Int, flag: String) throws -> Int { + guard let value = CLIArgParsingSupport.nextValue(args, index: &index), + let port = parsePort(value) + else { + throw NSError( + domain: "ConfigureRemote", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "\(flag) must be an integer from 1 to 65535"]) + } + return port +} + +private func isValidSSHTarget(_ raw: String) -> Bool { + if raw.isEmpty || raw.hasPrefix("-") { return false } + if raw.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil { + return false + } + let targetParts = raw.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + let hostPort: String + if targetParts.count == 2 { + guard !targetParts[0].isEmpty, !targetParts[1].isEmpty else { return false } + hostPort = String(targetParts[1]) + } else { + hostPort = raw + } + guard !hostPort.isEmpty else { return false } + guard !hostPort.hasPrefix(":") else { return false } + if let colon = hostPort.lastIndex(of: ":"), colon != hostPort.startIndex { + let portRaw = hostPort[hostPort.index(after: colon)...] + return parsePort(String(portRaw)) != nil + } + return true +} + +private func printConfigureRemoteOutput(_ output: ConfigureRemoteOutput, json: Bool) { + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output), + let text = String(data: data, encoding: .utf8) + { + print(text) + } + return + } + print("OpenClaw macOS Remote Config") + print("Status: \(output.status)") + print("Config: \(output.configPath)") + print("Mode: \(output.mode)") + print("Transport: \(output.transport)") + if let sshTarget = output.sshTarget { + print("SSH target: \(sshTarget)") + } + if let localUrl = output.localUrl { + print("Local URL: \(localUrl)") + } + print("Remote URL: \(output.remoteUrl)") + print("Remote port: \(output.remotePort)") + print("Onboarding: skipped") +} + +private func printJSONError(_ message: String) { + let payload = [ + "status": "error", + "error": message, + ] + if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) + { + print(text) + } else { + print("{\"status\":\"error\"}") + } +} diff --git a/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift b/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift index 6cb4880cf91..425a7f0f6cc 100644 --- a/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift +++ b/apps/macos/Sources/OpenClawMacCLI/EntryPoint.swift @@ -17,6 +17,8 @@ struct OpenClawMacCLI { printUsage() case "connect": await runConnect(command?.args ?? []) + case "configure-remote": + runConfigureRemote(command?.args ?? []) case "discover": await runDiscover(command?.args ?? []) case "wizard": @@ -43,12 +45,16 @@ private func printUsage() { [--mode ] [--timeout ] [--probe] [--json] [--client-id ] [--client-mode ] [--display-name ] [--role ] [--scopes ] + openclaw-mac configure-remote --ssh-target [--local-port ] + [--remote-port ] [--token ] [--password ] + [--identity ] [--project-root ] [--cli-path ] [--json] openclaw-mac discover [--timeout ] [--json] [--include-local] openclaw-mac wizard [--url ] [--token ] [--password ] [--mode ] [--workspace ] [--json] Examples: openclaw-mac connect + openclaw-mac configure-remote --ssh-target user@gateway.local --remote-port 18789 openclaw-mac connect --url ws://127.0.0.1:18789 --json openclaw-mac discover --timeout 3000 --json openclaw-mac wizard --mode local diff --git a/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift b/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift index c3c963b2531..bfe24cd9116 100644 --- a/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift +++ b/apps/macos/Sources/OpenClawMacCLI/GatewayConfig.swift @@ -5,6 +5,7 @@ struct GatewayConfig { var bind: String? var port: Int? var remoteUrl: String? + var remotePort: Int? var token: String? var password: String? var remoteToken: String? @@ -41,6 +42,7 @@ func loadGatewayConfig() -> GatewayConfig { } if let remote = gateway["remote"] as? [String: Any] { cfg.remoteUrl = remote["url"] as? String + cfg.remotePort = remote["remotePort"] as? Int ?? parseInt(remote["remotePort"]) cfg.remoteToken = remote["token"] as? String cfg.remotePassword = remote["password"] as? String } diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 7cf471eadb7..98d50e2a2ca 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -179,6 +179,28 @@ import Testing } } + @Test func `empty remote defaults fall back to config remote values`() { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) + defaults.set(" ", forKey: remoteTargetKey) + defaults.set("", forKey: remoteIdentityKey) + + let settings = CommandResolver.connectionSettings( + defaults: defaults, + configRoot: [ + "gateway": [ + "mode": "remote", + "remote": [ + "sshTarget": "alice@gateway.local", + "sshIdentity": "/tmp/config-id", + ], + ], + ]) + + #expect(settings.target == "alice@gateway.local") + #expect(settings.identity == "/tmp/config-id") + } + @Test func `rejects unsafe SSH targets`() { #expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil) #expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil) @@ -208,4 +230,23 @@ import Testing #expect(cmd[1] == "daemon") } } + + @Test func `remote settings fall back to config ssh target`() { + let defaults = self.makeDefaults() + let settings = CommandResolver.connectionSettings( + defaults: defaults, + configRoot: [ + "gateway": [ + "mode": "remote", + "remote": [ + "sshTarget": "alice@gateway.example:2222", + "sshIdentity": "/tmp/id_ed25519", + ], + ], + ]) + + #expect(settings.mode == .remote) + #expect(settings.target == "alice@gateway.example:2222") + #expect(settings.identity == "/tmp/id_ed25519") + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigureRemoteCommandTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigureRemoteCommandTests.swift new file mode 100644 index 00000000000..d245995bd2e --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ConfigureRemoteCommandTests.swift @@ -0,0 +1,199 @@ +import Foundation +import Testing +@testable import OpenClawMacCLI + +@Suite(.serialized) +struct ConfigureRemoteCommandTests { + @Test @MainActor func `configure remote writes ssh config and app defaults`() async throws { + let configURL = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-configure-remote-\(UUID().uuidString).json") + defer { try? FileManager().removeItem(at: configURL) } + + let defaultSuites = ["ai.openclaw.mac", "ai.openclaw.mac.debug"] + let keys = [ + "openclaw.connectionMode", + "openclaw.remoteTarget", + "openclaw.onboardingSeen", + "openclaw.onboardingVersion", + "openclaw.remoteCliPath", + ] + let defaultsBySuite = defaultSuites.compactMap { suite in + UserDefaults(suiteName: suite).map { (suite, $0) } + } + let previousDefaults = Dictionary(uniqueKeysWithValues: defaultsBySuite.map { suite, defaults in + (suite, Dictionary(uniqueKeysWithValues: keys.map { ($0, defaults.object(forKey: $0)) })) + }) + defer { + for (suite, defaults) in defaultsBySuite { + for (key, value) in previousDefaults[suite] ?? [:] { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + } + + try await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) { + let output = try configureRemote(.init( + sshTarget: "alice@gateway.example", + localPort: 19089, + remotePort: 18789, + token: "test-token", // pragma: allowlist secret + password: nil, + identity: nil, + projectRoot: nil, + cliPath: "/opt/homebrew/bin/openclaw")) + + #expect(output.status == "ok") + #expect(output.localUrl == "ws://127.0.0.1:19089") + #expect(output.remotePort == 18789) + + let data = try Data(contentsOf: configURL) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let gateway = try #require(root["gateway"] as? [String: Any]) + let remote = try #require(gateway["remote"] as? [String: Any]) + #expect(gateway["mode"] as? String == "remote") + #expect(gateway["port"] as? Int == 19089) + #expect(remote["transport"] as? String == "ssh") + #expect(remote["url"] as? String == "ws://127.0.0.1:19089") + #expect(remote["remotePort"] as? Int == 18789) + #expect(remote["sshTarget"] as? String == "alice@gateway.example") + #expect(remote["token"] as? String == "test-token") // pragma: allowlist secret + + for (_, defaults) in defaultsBySuite { + #expect(defaults.string(forKey: "openclaw.connectionMode") == "remote") + #expect(defaults.string(forKey: "openclaw.remoteTarget") == "alice@gateway.example") + #expect(defaults.bool(forKey: "openclaw.onboardingSeen") == true) + #expect(defaults.string(forKey: "openclaw.remoteCliPath") == "/opt/homebrew/bin/openclaw") + } + } + } + + @Test @MainActor func `configure remote preserves existing optional credentials when flags omitted`() async throws { + let configURL = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-configure-remote-preserve-\(UUID().uuidString).json") + defer { try? FileManager().removeItem(at: configURL) } + + let initial: [String: Any] = [ + "gateway": [ + "remote": [ + "token": "keep-token", // pragma: allowlist secret + "sshIdentity": "/tmp/id", + ], + ], + ] + let initialData = try JSONSerialization.data(withJSONObject: initial, options: [.prettyPrinted]) + try FileManager().createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try initialData.write(to: configURL) + + try await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) { + try configureRemote(.init(sshTarget: "alice@gateway.example")) + + let data = try Data(contentsOf: configURL) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let gateway = try #require(root["gateway"] as? [String: Any]) + let remote = try #require(gateway["remote"] as? [String: Any]) + #expect(remote["token"] as? String == "keep-token") // pragma: allowlist secret + #expect(remote["sshIdentity"] as? String == "/tmp/id") + } + } + + @Test func `configure remote rejects invalid explicit ports`() throws { + #expect(throws: Error.self) { + _ = try ConfigureRemoteOptions.parse(["--ssh-target", "alice@gateway.example", "--remote-port", "99999"]) + } + #expect(throws: Error.self) { + _ = try ConfigureRemoteOptions.parse(["--ssh-target", "alice@gateway.example", "--local-port", "nope"]) + } + } + + @Test func `configure remote rejects ssh targets without a host`() throws { + #expect(throws: Error.self) { + try configureRemote(.init(sshTarget: "user@")) + } + #expect(throws: Error.self) { + try configureRemote(.init(sshTarget: "alice@:2222")) + } + } + + @Test @MainActor func `configure remote can write direct private url`() async throws { + let configURL = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-configure-direct-\(UUID().uuidString).json") + defer { try? FileManager().removeItem(at: configURL) } + + let initial: [String: Any] = [ + "gateway": [ + "port": 19089, + ], + ] + let initialData = try JSONSerialization.data(withJSONObject: initial, options: [.prettyPrinted]) + try FileManager().createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try initialData.write(to: configURL) + + let defaultSuites = ["ai.openclaw.mac", "ai.openclaw.mac.debug"] + let keys = [ + "openclaw.connectionMode", + "openclaw.remoteTarget", + "openclaw.onboardingSeen", + "openclaw.onboardingVersion", + "openclaw.remoteCliPath", + ] + let defaultsBySuite = defaultSuites.compactMap { suite in + UserDefaults(suiteName: suite).map { (suite, $0) } + } + let previousDefaults = Dictionary(uniqueKeysWithValues: defaultsBySuite.map { suite, defaults in + (suite, Dictionary(uniqueKeysWithValues: keys.map { ($0, defaults.object(forKey: $0)) })) + }) + defer { + for (suite, defaults) in defaultsBySuite { + for (key, value) in previousDefaults[suite] ?? [:] { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + } + + try await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) { + let output = try configureRemote(.init( + directUrl: "ws://192.168.0.202:18789", + token: "test-token")) // pragma: allowlist secret + + #expect(output.transport == "direct") + #expect(output.remoteUrl == "ws://192.168.0.202:18789") + #expect(output.localUrl == nil) + #expect(output.sshTarget == nil) + + let data = try Data(contentsOf: configURL) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let gateway = try #require(root["gateway"] as? [String: Any]) + let remote = try #require(gateway["remote"] as? [String: Any]) + #expect(gateway["mode"] as? String == "remote") + #expect(gateway["port"] as? Int == 19089) + #expect(remote["transport"] as? String == "direct") + #expect(remote["url"] as? String == "ws://192.168.0.202:18789") + #expect(remote["remotePort"] == nil) + #expect(remote["sshTarget"] == nil) + #expect(remote["token"] as? String == "test-token") // pragma: allowlist secret + } + } + + @Test @MainActor func `configure remote rejects plaintext public prefix bypass`() async throws { + let configURL = FileManager().temporaryDirectory + .appendingPathComponent("openclaw-configure-direct-reject-\(UUID().uuidString).json") + defer { try? FileManager().removeItem(at: configURL) } + + _ = await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configURL.path]) { + #expect(throws: Error.self) { + try configureRemote(.init(directUrl: "ws://fd-example.com:18789")) + } + #expect(throws: Error.self) { + try configureRemote(.init(directUrl: "ws://192.168.0.202.attacker.example:18789")) + } + } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift new file mode 100644 index 00000000000..7d0b546b1a0 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/DashboardWindowSmokeTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct DashboardWindowSmokeTests { + @Test func `dashboard window controller shows and closes`() throws { + let url = try #require(URL(string: "http://127.0.0.1:18789/control/#token=device-token")) + let controller = DashboardWindowController( + url: url, + auth: DashboardWindowAuth( + gatewayUrl: "ws://127.0.0.1:18789/control/", + token: "device-token", + password: nil)) + controller.show() + #expect(controller.window?.styleMask.contains(.titled) == true) + #expect(controller.window?.styleMask.contains(.closable) == true) + #expect(controller.window?.contentViewController != nil) + #expect(controller.window?.standardWindowButton(.closeButton) != nil) + #expect((controller.window?.frame.width ?? 0) >= DashboardWindowLayout.windowMinSize.width) + #expect((controller.window?.frame.height ?? 0) >= DashboardWindowLayout.windowMinSize.height) + controller.closeDashboard() + } + + @Test func `dashboard navigation stays on same endpoint`() throws { + let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/")) + #expect(DashboardWindowController.shouldAllowNavigation( + to: try #require(URL(string: "http://127.0.0.1:18789/control/chat")), + dashboardURL: dashboard)) + #expect(!DashboardWindowController.shouldAllowNavigation( + to: try #require(URL(string: "https://docs.openclaw.ai/")), + dashboardURL: dashboard)) + } + + @Test func `dashboard origin brackets ipv6 literals`() throws { + let url = try #require(URL(string: "http://[fd12:3456:789a::1]:18789/control/")) + #expect(DashboardWindowController.originString(for: url) == "http://[fd12:3456:789a::1]:18789") + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index e28091591c3..8ed20610e7a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -76,6 +76,18 @@ struct GatewayEndpointStoreTests { #expect(token == "remote-token") } + @Test func `remote password resolver trims remote config password`() { + let root: [String: Any] = [ + "gateway": [ + "remote": [ + "password": " remote-pass ", + ], + ], + ] + + #expect(GatewayRemoteConfig.resolvePasswordString(root: root) == "remote-pass") + } + @Test func `resolve gateway password falls back to launchd`() { let snapshot = self.makeLaunchAgentSnapshot( env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"], @@ -272,17 +284,52 @@ struct GatewayEndpointStoreTests { #expect(url.query == nil) } + @Test func `dashboard URL can use native auth token override`() throws { + let config: GatewayConnection.Config = try ( + url: #require(URL(string: "ws://127.0.0.1:18789")), + token: nil, + password: "sekret") // pragma: allowlist secret + + let url = try GatewayEndpointStore.dashboardURL( + for: config, + mode: .local, + localBasePath: "/control", + authToken: "device-token") + #expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=device-token") + #expect(url.query == nil) + } + @Test func `normalize gateway url adds default port for loopback ws`() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1") #expect(url?.port == 18789) #expect(url?.absoluteString == "ws://127.0.0.1:18789") } - @Test func `normalize gateway url rejects non loopback ws`() { + @Test func `normalize gateway url accepts private network ws`() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://192.168.0.202:18789") + #expect(url?.absoluteString == "ws://192.168.0.202:18789") + } + + @Test func `normalize gateway url accepts tailnet ws`() { + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://100.123.224.76:18789") + #expect(url?.absoluteString == "ws://100.123.224.76:18789") + } + + @Test func `normalize gateway url rejects public host ws`() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789") #expect(url == nil) } + @Test func `normalize gateway url rejects private ipv4 suffix host bypasses`() { + #expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://192.168.0.202.attacker.example:18789") == nil) + #expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://100.123.224.76.attacker.example:18789") == nil) + } + + @Test func `normalize gateway url rejects ipv6 prefix hostname bypasses`() { + #expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://fcorp.example:18789") == nil) + #expect(GatewayRemoteConfig.normalizeGatewayUrl("ws://fd-example.com:18789") == nil) + } + @Test func `normalize gateway url rejects prefix bypass loopback host`() { let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example") #expect(url == nil) diff --git a/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift b/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift index 34298b1a713..7f0d29bf904 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RemotePortTunnelTests.swift @@ -70,5 +70,41 @@ struct RemotePortTunnelTests { } #expect(free == true) } + + @Test @MainActor func `remote port override prefers explicit remote port`() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "ws://127.0.0.1:19089", + "remotePort": 18789, + ], + ], + ]) + + #expect(RemotePortTunnel._testResolveRemotePortOverride( + defaultRemotePort: 19089, + sshHost: "gateway.example") == 18789) + } + } + + @Test @MainActor func `remote port override can read loopback url port`() async { + let configPath = TestIsolation.tempConfigPath() + await TestIsolation.withIsolatedState(env: ["OPENCLAW_CONFIG_PATH": configPath]) { + OpenClawConfigFile.saveDict([ + "gateway": [ + "remote": [ + "url": "ws://127.0.0.1:18789", + ], + ], + ]) + + #expect(RemotePortTunnel._testResolveRemotePortOverride( + defaultRemotePort: 19089, + sshHost: "gateway.example") == 18789) + } + } + } #endif diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 8e2dbddd5da..56b19b947b8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -3,6 +3,7 @@ import Foundation public enum DeepLinkRoute: Sendable, Equatable { case agent(AgentDeepLink) case gateway(GatewayConnectDeepLink) + case dashboard } public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { @@ -266,6 +267,9 @@ public enum DeepLinkParser { token: query["token"], password: query["password"])) + case "dashboard": + return .dashboard + default: return nil } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index e9e4a699a6f..73b36f1b56c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -11,6 +11,11 @@ private func setupCode(from payload: String) -> String { } @Suite struct DeepLinksSecurityTests { + @Test func dashboardDeepLinkParses() { + let url = URL(string: "openclaw://dashboard")! + #expect(DeepLinkParser.parse(url) == .dashboard) + } + @Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() { let url = URL( string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! diff --git a/docs/cli/node.md b/docs/cli/node.md index e176caaa75f..1f846d276b2 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -74,10 +74,12 @@ Options: - In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules. - Node host auth resolution only honors `OPENCLAW_GATEWAY_*` env vars. -For a node connecting to a non-loopback `ws://` Gateway on a trusted private -network, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`. Without it, node startup -fails closed and asks you to use `wss://`, an SSH tunnel, or Tailscale. -This is a process-environment opt-in, not an `openclaw.json` config key. +For a node connecting to a plaintext `ws://` Gateway, loopback, private IP +literals, `.local`, and Tailnet `*.ts.net` hosts are accepted. For other +trusted private-DNS names, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; without +it, node startup fails closed and asks you to use `wss://`, an SSH tunnel, or +Tailscale. This is a process-environment opt-in, not an `openclaw.json` config +key. `openclaw node install` persists it into the supervised node service when it is present in the install command environment. diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 512d48b59ba..d561578bd26 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -47,10 +47,9 @@ openclaw onboard --mode remote --remote-url wss://gateway-host:18789 `--modern` starts the Crestodian conversational onboarding preview. Without `--modern`, `openclaw onboard` keeps the classic onboarding flow. -For plaintext private-network `ws://` targets (trusted networks only), set +Plaintext `ws://` is accepted for loopback, private IP literals, `.local`, and +Tailnet `*.ts.net` gateway URLs. For other trusted private-DNS names, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment. -There is no `openclaw.json` equivalent for this client-side transport -break-glass. ## Locale diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 6b58a5fd029..10b1e01e562 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -487,7 +487,7 @@ See [Inferred commitments](/concepts/commitments). // dangerouslyDisableDeviceAuth: false, }, remote: { - url: "ws://gateway.tailnet:18789", + url: "ws://127.0.0.1:18789", transport: "ssh", // ssh | direct token: "your-token", // password: "your-password", @@ -545,16 +545,11 @@ See [Inferred commitments](/concepts/commitments). checks `tailscale funnel status` before re-applying Serve at startup and skips it if an externally configured Funnel route already covers the gateway port. Default `false`. -- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins. +- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required for public non-loopback browser origins. Private same-origin LAN/Tailnet UI loads from loopback, RFC1918/link-local, `.local`, `.ts.net`, or Tailscale CGNAT hosts are accepted without enabling Host-header fallback. - `controlUi.chatMessageMaxWidth`: optional max-width for grouped Control UI chat messages. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. -- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. -- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side process-environment - break-glass override that allows plaintext `ws://` to trusted private-network - IPs; default remains loopback-only for plaintext. There is no `openclaw.json` - equivalent, and browser private-network config such as - `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` does not affect Gateway - WebSocket clients. +- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `wss://` for public hosts; plaintext `ws://` is accepted only for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts. +- `remote.remotePort`: gateway port on the remote SSH host. Defaults to `18789`; use this when the local tunnel port differs from the remote gateway port. - `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves. - `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build. - `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`. diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index cd0328c9f97..a13f00477eb 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -1,19 +1,19 @@ --- -summary: "Remote access using SSH tunnels (Gateway WS) and tailnets" +summary: "Remote access using Gateway WS, SSH tunnels, and tailnets" read_when: - Running or troubleshooting remote gateway setups title: "Remote access" --- -This repo supports "remote over SSH" by keeping a single Gateway (the master) running on a dedicated host (desktop/server) and connecting clients to it. +This repo supports remote gateway access by keeping a single Gateway (the master) running on a dedicated host (desktop/server) and connecting clients to it. -- For **operators (you / the macOS app)**: SSH tunneling is the universal fallback. +- For **operators (you / the macOS app)**: direct LAN/Tailnet WebSocket is simplest when the gateway is reachable; SSH tunneling is the universal fallback. - For **nodes (iOS/Android and future devices)**: connect to the Gateway **WebSocket** (LAN/tailnet or SSH tunnel as needed). ## The core idea -- The Gateway WebSocket binds to **loopback** on your configured port (defaults to 18789). -- For remote use, you forward that loopback port over SSH (or use a tailnet/VPN and tunnel less). +- The Gateway WebSocket usually binds to **loopback** on your configured port (defaults to 18789). +- For remote use, expose it through Tailscale Serve or a trusted LAN/Tailnet bind, or forward the loopback port over SSH. ## Common VPN and tailnet setups @@ -24,6 +24,7 @@ Think of the **Gateway host** as where the agent lives. It owns sessions, auth p Run the Gateway on a persistent host (VPS or home server) and reach it via **Tailscale** or SSH. - **Best UX:** keep `gateway.bind: "loopback"` and use **Tailscale Serve** for the Control UI. +- **Trusted LAN/Tailnet:** bind the gateway to a private interface and connect directly with `gateway.remote.transport: "direct"`. - **Fallback:** keep loopback plus SSH tunnel from any machine that needs access. - **Examples:** [exe.dev](/install/exe-dev) (easy VM) or [Hetzner](/install/hetzner) (production VPS). @@ -33,8 +34,8 @@ Ideal when your laptop sleeps often but you want the agent always-on. The laptop does **not** run the agent. It connects remotely: -- Use the macOS app's **Remote over SSH** mode (Settings → General → OpenClaw runs). -- The app opens and manages the tunnel, so WebChat and health checks just work. +- Use the macOS app's remote mode (Settings → General → OpenClaw runs). +- The app connects directly when the gateway is reachable on LAN/Tailnet, or opens and manages an SSH tunnel when you choose SSH. Runbook: [macOS remote access](/platforms/mac/remote). @@ -103,6 +104,23 @@ You can persist a remote target so CLI commands use it by default: When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and open the SSH tunnel first. In the macOS app's SSH tunnel transport, discovered gateway hostnames belong in `gateway.remote.sshTarget`; `gateway.remote.url` remains the local tunnel URL. +If those ports differ, set `gateway.remote.remotePort` to the gateway port on +the SSH host. + +For a gateway already reachable on a trusted LAN or Tailnet, use direct mode: + +```json5 +{ + gateway: { + mode: "remote", + remote: { + transport: "direct", + url: "ws://192.168.0.202:18789", + token: "your-token", + }, + }, +} +``` ## Credential precedence @@ -122,14 +140,15 @@ Gateway credential resolution follows one shared contract across call/probe/stat - Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode. - Gateway env overrides use `OPENCLAW_GATEWAY_*` only. -## Chat UI over SSH +## Chat UI remote access WebChat no longer uses a separate HTTP port. The SwiftUI chat UI connects directly to the Gateway WebSocket. - Forward `18789` over SSH (see above), then connect clients to `ws://127.0.0.1:18789`. -- On macOS, prefer the app's "Remote over SSH" mode, which manages the tunnel automatically. +- For LAN/Tailnet direct mode, connect clients to the configured private `ws://` or secure `wss://` URL. +- On macOS, prefer the app's remote mode, which manages the selected transport automatically. -## macOS app Remote over SSH +## macOS app remote mode The macOS menu bar app can drive the same setup end-to-end (remote status checks, WebChat, and Voice Wake forwarding). @@ -140,10 +159,7 @@ Runbook: [macOS remote access](/platforms/mac/remote). Short version: **keep the Gateway loopback-only** unless you're sure you need a bind. - **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure). -- Plaintext `ws://` is loopback-only by default. For trusted private networks, - set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as - break-glass. There is no `openclaw.json` equivalent; this must be process - environment for the client making the WebSocket connection. +- Plaintext `ws://` is accepted for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts. Public remote hosts must use `wss://`. - **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use gateway auth: token, password, or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. - `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves. - Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index a13b65f6442..d6aa2133e78 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -876,10 +876,11 @@ Doctor can generate one for you: `openclaw doctor --generate-gateway-token`. `gateway.remote.token` and `gateway.remote.password` are client credential sources. They do **not** protect local WS access by themselves. Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. If `gateway.auth.token` or `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. -Plaintext `ws://` is loopback-only by default. For trusted private-network -paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as -break-glass. This is intentionally process environment only, not an -`openclaw.json` config key. +Plaintext `ws://` is accepted for loopback, private IP literals, `.local`, and +Tailnet `*.ts.net` gateway URLs. For other trusted private-DNS names, set +`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass. +This is intentionally process environment only, not an `openclaw.json` config +key. Mobile pairing and Android manual or scanned gateway routes are stricter: cleartext is accepted for loopback, but private-LAN, link-local, `.local`, and dotless hostnames must use TLS unless you explicitly opt into the trusted diff --git a/docs/platforms/mac/remote.md b/docs/platforms/mac/remote.md index fb6a95fe6e5..a9a448d6711 100644 --- a/docs/platforms/mac/remote.md +++ b/docs/platforms/mac/remote.md @@ -1,17 +1,17 @@ --- -summary: "macOS app flow for controlling a remote OpenClaw gateway over SSH" +summary: "macOS app flow for controlling a remote OpenClaw gateway" read_when: - Setting up or debugging remote mac control title: "Remote control" --- -This flow lets the macOS app act as a full remote control for an OpenClaw gateway running on another host (desktop/server). It's the app's **Remote over SSH** (remote run) feature. All features-health checks, Voice Wake forwarding, and Web Chat-reuse the same remote SSH configuration from _Settings → General_. +This flow lets the macOS app act as a full remote control for an OpenClaw gateway running on another host (desktop/server). The app can connect directly to trusted LAN/Tailnet gateway URLs or manage an SSH tunnel when the remote gateway is loopback-only. Health checks, Voice Wake forwarding, and Web Chat reuse the same remote configuration from _Settings → General_. ## Modes - **Local (this Mac)**: Everything runs on the laptop. No SSH involved. - **Remote over SSH (default)**: OpenClaw commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key and a local port-forward. -- **Remote direct (ws/wss)**: No SSH tunnel. The mac app connects to the gateway URL directly (for example, via Tailscale Serve or a public HTTPS reverse proxy). +- **Remote direct (ws/wss)**: No SSH tunnel. The mac app connects to the gateway URL directly (for example, via LAN, Tailscale, Tailscale Serve, or a public HTTPS reverse proxy). ## Remote transports @@ -24,6 +24,8 @@ In SSH tunnel mode, discovered LAN/tailnet hostnames are saved as `gateway.remote.sshTarget`. The app keeps `gateway.remote.url` on the local tunnel endpoint, for example `ws://127.0.0.1:18789`, so CLI, Web Chat, and the local node-host service all use the same safe loopback transport. +If the local tunnel port differs from the remote gateway port, set +`gateway.remote.remotePort` to the port on the remote host. Browser automation in remote mode is owned by the CLI node host, not by the native macOS app node. The app starts the installed node host service when @@ -36,12 +38,33 @@ node. 1. Install Node + pnpm and build/install the OpenClaw CLI (`pnpm install && pnpm build && pnpm link --global`). 2. Ensure `openclaw` is on PATH for non-interactive shells (symlink into `/usr/local/bin` or `/opt/homebrew/bin` if needed). -3. Open SSH with key auth. We recommend **Tailscale** IPs for stable reachability off-LAN. +3. For SSH transport only: open SSH with key auth. We recommend **Tailscale** IPs for stable reachability off-LAN. ## macOS app setup +To preconfigure the app without the welcome flow: + +```bash +openclaw-mac configure-remote \ + --ssh-target user@gateway.local \ + --local-port 18789 \ + --remote-port 18789 \ + --token "$OPENCLAW_GATEWAY_TOKEN" +``` + +For a gateway already reachable on a trusted LAN or Tailnet, skip SSH entirely: + +```bash +openclaw-mac configure-remote \ + --direct-url ws://192.168.0.202:18789 \ + --token "$OPENCLAW_GATEWAY_TOKEN" +``` + +This writes the remote config, marks onboarding complete, and lets the app own +the selected transport when it starts. + 1. Open _Settings → General_. -2. Under **OpenClaw runs**, pick **Remote over SSH** and set: +2. Under **OpenClaw runs**, pick **Remote** and set: - **Transport**: **SSH tunnel** or **Direct (ws/wss)**. - **SSH target**: `user@host` (optional `:port`). - If the gateway is on the same LAN and advertises Bonjour, pick it from the discovered list to auto-fill this field. @@ -50,7 +73,7 @@ node. - **Project root** (advanced): remote checkout path used for commands. - **CLI path** (advanced): optional path to a runnable `openclaw` entrypoint/binary (auto-filled when advertised). 3. Hit **Test remote**. Success indicates the remote `openclaw status --json` runs correctly. Failures usually mean PATH/CLI issues; exit 127 means the CLI isn't found remotely. -4. Health checks and Web Chat will now run through this SSH tunnel automatically. +4. Health checks and Web Chat will now run through the selected transport automatically. ## Web Chat @@ -65,7 +88,7 @@ node. ## Security notes -- Prefer loopback binds on the remote host and connect via SSH or Tailscale. +- Prefer loopback binds on the remote host and connect via SSH, Tailscale Serve, or a trusted Tailnet/LAN direct URL. - SSH tunneling uses strict host-key checking; trust the host key first so it exists in `~/.ssh/known_hosts`. - If you bind the Gateway to a non-loopback interface, require valid Gateway auth: token, password, or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. - See [Security](/gateway/security) and [Tailscale](/gateway/tailscale). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 73293eb1148..67ae9da10bb 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -473,7 +473,7 @@ The Control UI is static files; the WebSocket target is configurable and can be - When `gatewayUrl` is set, the UI does not fall back to config or environment credentials. Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. - - Non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` explicitly (full origins). This includes remote dev setups. + - Public non-loopback Control UI deployments must set `gateway.controlUi.allowedOrigins` explicitly (full origins). Private same-origin LAN/Tailnet loads from loopback, RFC1918/link-local, `.local`, `.ts.net`, or Tailscale CGNAT hosts are accepted without enabling Host-header fallback. - Gateway startup may seed local origins such as `http://localhost:` and `http://127.0.0.1:` from the effective runtime bind and port, but remote browser origins still need explicit entries. - Do not use `gateway.controlUi.allowedOrigins: ["*"]` except for tightly controlled local testing. It means allow any browser origin, not "match whatever host I am using." - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode, but it is a dangerous security mode. diff --git a/docs/web/index.md b/docs/web/index.md index 0de235d3409..72b67454d53 100644 --- a/docs/web/index.md +++ b/docs/web/index.md @@ -110,8 +110,9 @@ Open: `https://` dashboard URLs and `wss://` WebSocket URLs. - In identity-bearing modes such as Tailscale Serve or `trusted-proxy`, the WebSocket auth check is satisfied from request headers instead. -- For non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins` - explicitly (full origins). Without it, gateway startup is refused by default. +- For public non-loopback Control UI deployments, set `gateway.controlUi.allowedOrigins` + explicitly (full origins). Private same-origin LAN/Tailnet loads are accepted for loopback, + RFC1918/link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts. - `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true` enables Host-header origin fallback mode, but is a dangerous security downgrade. - With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth diff --git a/src/commands/onboard-remote.test.ts b/src/commands/onboard-remote.test.ts index b7947690305..1130e3fef9a 100644 --- a/src/commands/onboard-remote.test.ts +++ b/src/commands/onboard-remote.test.ts @@ -277,13 +277,13 @@ describe("promptRemoteGatewayConfig", () => { ); }); - it("validates insecure ws:// remote URLs and allows only loopback ws:// by default", async () => { + it("validates insecure ws:// remote URLs and allows trusted private ws:// by default", async () => { const text: WizardPrompter["text"] = vi.fn(async (params) => { if (params.message === "Gateway WebSocket URL") { // ws:// to public IPs is rejected expect(params.validate?.("ws://203.0.113.10:18789")).toBe(INSECURE_WS_URL_MESSAGE); - // ws:// to private IPs remains blocked by default - expect(params.validate?.("ws://10.0.0.8:18789")).toBe(INSECURE_WS_URL_MESSAGE); + // ws:// to trusted LAN/Tailnet endpoints is accepted. + expect(params.validate?.("ws://10.0.0.8:18789")).toBeUndefined(); expect(params.validate?.("ws://127.0.0.1:18789")).toBeUndefined(); expect(params.validate?.("wss://remote.example.com:18789")).toBeUndefined(); return "wss://remote.example.com:18789"; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 7f3fff9adc6..ce1bd18efae 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -226,6 +226,8 @@ export type GatewayRemoteConfig = { url?: string; /** Transport for macOS remote connections (ssh tunnel or direct WS). */ transport?: "ssh" | "direct"; + /** Gateway port on the remote SSH host. Defaults to 18789. */ + remotePort?: number; /** Token for remote auth (when the gateway requires token auth). */ token?: SecretInput; /** Password for remote auth (when the gateway requires password auth). */ diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 828475f43e4..b10dfd5654b 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -862,8 +862,7 @@ describe("buildGatewayConnectionDetails", () => { } }); - it("allows ws:// private remote URLs only when OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { - process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + it("allows ws:// private remote URLs for trusted LAN and Tailnet configs", () => { getRuntimeConfig.mockReturnValue({ gateway: { mode: "remote", diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 435d72038b3..58bb5bb6ade 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -496,8 +496,7 @@ describe("GatewayClient security checks", () => { client.stop(); }); - it("allows ws:// to private addresses only with OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", () => { - process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS = "1"; + it("allows ws:// to private addresses for trusted LAN and Tailnet configs", () => { const onConnectError = vi.fn(); const client = new GatewayClient({ url: "ws://192.168.1.100:18789", diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 6f30830771c..f4a5c37e728 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -760,17 +760,19 @@ describe("isSecureWebSocketUrl", () => { { input: "ws://localhost:18789", expected: true }, { input: "ws://[::1]:18789", expected: true }, { input: "ws://127.0.0.42:18789", expected: true }, - // ws:// private/public remote addresses rejected by default - { input: "ws://10.0.0.5:18789", expected: false }, - { input: "ws://10.42.1.100:18789", expected: false }, - { input: "ws://172.16.0.1:18789", expected: false }, - { input: "ws://172.31.255.254:18789", expected: false }, - { input: "ws://192.168.1.100:18789", expected: false }, - { input: "ws://169.254.10.20:18789", expected: false }, - { input: "ws://100.64.0.1:18789", expected: false }, - { input: "ws://[fc00::1]:18789", expected: false }, - { input: "ws://[fd12:3456:789a::1]:18789", expected: false }, - { input: "ws://[fe80::1]:18789", expected: false }, + // ws:// trusted LAN/Tailnet endpoints accepted + { input: "ws://10.0.0.5:18789", expected: true }, + { input: "ws://10.42.1.100:18789", expected: true }, + { input: "ws://172.16.0.1:18789", expected: true }, + { input: "ws://172.31.255.254:18789", expected: true }, + { input: "ws://192.168.1.100:18789", expected: true }, + { input: "ws://169.254.10.20:18789", expected: true }, + { input: "ws://100.64.0.1:18789", expected: true }, + { input: "ws://[fc00::1]:18789", expected: true }, + { input: "ws://[fd12:3456:789a::1]:18789", expected: true }, + { input: "ws://[fe80::1]:18789", expected: true }, + { input: "ws://gateway.local:18789", expected: true }, + { input: "ws://machine.tail123.ts.net:18789", expected: true }, { input: "ws://[::]:18789", expected: false }, { input: "ws://[ff02::1]:18789", expected: false }, // ws:// public addresses rejected @@ -789,20 +791,11 @@ describe("isSecureWebSocketUrl", () => { expect(isSecureWebSocketUrl(input), input).toBe(expected); }); - it("allows private ws:// only when opt-in is enabled", () => { - const allowedWhenOptedIn = [ - "ws://10.0.0.5:18789", - "http://10.0.0.5:18789", - "ws://172.16.0.1:18789", - "ws://192.168.1.100:18789", - "ws://100.64.0.1:18789", - "ws://169.254.10.20:18789", - "ws://[fc00::1]:18789", - "ws://[fe80::1]:18789", - "ws://gateway.private.example:18789", - ]; + it("allows arbitrary private-dns ws:// hostnames only when opt-in is enabled", () => { + const allowedWhenOptedIn = ["ws://gateway.private.example:18789"]; for (const input of allowedWhenOptedIn) { + expect(isSecureWebSocketUrl(input), input).toBe(false); expect(isSecureWebSocketUrl(input, { allowPrivateWs: true }), input).toBe(true); } }); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index a2b3356fa68..2a92a548dc1 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -476,8 +476,8 @@ function parseHostForAddressChecks( * * Returns true if the URL is secure for transmitting data: * - wss:// (TLS) is always secure - * - ws:// is secure only for loopback addresses by default - * - optional break-glass: private ws:// can be enabled for trusted networks + * - ws:// is secure for loopback, private IP literals, .local, and Tailnet hosts + * - optional break-glass: other private-DNS ws:// hostnames can be enabled for trusted networks * * All other ws:// URLs are considered insecure because both credentials * AND chat/conversation data would be exposed to network interception. @@ -509,11 +509,15 @@ export function isSecureWebSocketUrl( return false; } - // Default policy stays strict: loopback-only plaintext ws://. + // Default policy allows local/Tailnet endpoints that cannot be given public TLS + // without extra operator setup. Public DNS hostnames still require wss://. if (isLoopbackHost(parsed.hostname)) { return true; } - // Optional break-glass for trusted private-network overlays. + if (isTrustedPlaintextWebSocketHost(parsed.hostname)) { + return true; + } + // Optional break-glass for trusted private-DNS overlays. if (opts?.allowPrivateWs) { if (isPrivateOrLoopbackHost(parsed.hostname)) { return true; @@ -528,3 +532,11 @@ export function isSecureWebSocketUrl( } return false; } + +function isTrustedPlaintextWebSocketHost(hostname: string): boolean { + if (isPrivateOrLoopbackHost(hostname)) { + return true; + } + const normalized = normalizeLowercaseStringOrEmpty(hostname).replace(/\.+$/, ""); + return normalized.endsWith(".local") || normalized.endsWith(".ts.net"); +} diff --git a/src/gateway/origin-check.test.ts b/src/gateway/origin-check.test.ts index 2bdec288fd6..65c610b5870 100644 --- a/src/gateway/origin-check.test.ts +++ b/src/gateway/origin-check.test.ts @@ -20,6 +20,30 @@ describe("checkBrowserOrigin", () => { }, expected: { ok: false as const, reason: "origin not allowed" }, }, + { + name: "accepts same-origin private LAN host without dangerous fallback", + input: { + requestHost: "192.168.0.202:18789", + origin: "http://192.168.0.202:18789", + }, + expected: { ok: true as const, matchedBy: "private-same-origin" as const }, + }, + { + name: "accepts same-origin tailnet host without dangerous fallback", + input: { + requestHost: "peters-mac-studio-1.example.ts.net:18789", + origin: "http://peters-mac-studio-1.example.ts.net:18789", + }, + expected: { ok: true as const, matchedBy: "private-same-origin" as const }, + }, + { + name: "rejects same-origin public host without dangerous fallback", + input: { + requestHost: "attacker.example.com:18789", + origin: "http://attacker.example.com:18789", + }, + expected: { ok: false as const, reason: "origin not allowed" }, + }, { name: "accepts local loopback mismatches for local clients", input: { diff --git a/src/gateway/origin-check.ts b/src/gateway/origin-check.ts index 90c2053d5e9..7522b9f9468 100644 --- a/src/gateway/origin-check.ts +++ b/src/gateway/origin-check.ts @@ -1,13 +1,15 @@ +import net from "node:net"; +import { isPrivateOrLoopbackIpAddress } from "../shared/net/ip.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "../shared/string-coerce.js"; -import { isLoopbackHost, normalizeHostHeader } from "./net.js"; +import { isLoopbackHost, normalizeHostHeader, resolveHostName } from "./net.js"; type OriginCheckResult = | { ok: true; - matchedBy: "allowlist" | "host-header-fallback" | "local-loopback"; + matchedBy: "allowlist" | "host-header-fallback" | "private-same-origin" | "local-loopback"; } | { ok: false; reason: string }; @@ -59,6 +61,9 @@ export function checkBrowserOrigin(params: { ) { return { ok: true, matchedBy: "host-header-fallback" }; } + if (requestHost && parsedOrigin.host === requestHost && isTrustedSameOriginHost(requestHost)) { + return { ok: true, matchedBy: "private-same-origin" }; + } // Dev fallback only for genuinely local socket clients, not Host-header claims. if (params.isLocalClient && isLoopbackHost(parsedOrigin.hostname)) { @@ -67,3 +72,17 @@ export function checkBrowserOrigin(params: { return { ok: false, reason: "origin not allowed" }; } + +function isTrustedSameOriginHost(hostHeader: string): boolean { + const hostname = resolveHostName(hostHeader); + if (!hostname) { + return false; + } + if (isLoopbackHost(hostname)) { + return true; + } + if (net.isIP(hostname) !== 0) { + return isPrivateOrLoopbackIpAddress(hostname); + } + return hostname.endsWith(".local") || hostname.endsWith(".ts.net"); +} diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 4981efeaae9..121b4905a01 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -60,6 +60,7 @@ type SettingsHost = { logsAtBottom: boolean; eventLog: unknown[]; eventLogBuffer: unknown[]; + password?: string; basePath: string; themeMedia: MediaQueryList | null; themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; @@ -158,6 +159,7 @@ const createHost = (tab: Tab): SettingsHost => ({ logsAtBottom: false, eventLog: [], eventLogBuffer: [], + password: "", basePath: "", themeMedia: null, themeMediaHandler: null, @@ -428,6 +430,37 @@ describe("applySettingsFromUrl", () => { expect(window.location.hash).toBe(""); }); + it("hydrates native Mac app auth before the first connection", () => { + setTestWindowUrl("https://control.example/ui/chat"); + ( + window as unknown as { + __OPENCLAW_NATIVE_CONTROL_AUTH__?: { + gatewayUrl?: string; + token?: string; + password?: string; + }; + } + ).__OPENCLAW_NATIVE_CONTROL_AUTH__ = { + gatewayUrl: "wss://control.example/ui/", + token: "device-token", + password: "shared-password", + }; + const host = createHost("chat"); + + applySettingsFromUrl(host); + + expect(host.settings.gatewayUrl).toBe("wss://control.example/ui/"); + expect(host.settings.token).toBe("device-token"); + expect(host.password).toBe("shared-password"); + expect( + ( + window as unknown as { + __OPENCLAW_NATIVE_CONTROL_AUTH__?: unknown; + } + ).__OPENCLAW_NATIVE_CONTROL_AUTH__, + ).toBeUndefined(); + }); + it("resets stale persisted session selection to main when a token is supplied without a session", () => { setTestWindowUrl("https://control.example/chat#token=test-token"); const host = createHost("chat"); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index a2d048aad84..f43f8578f02 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -198,7 +198,45 @@ function applySessionSelection(host: SettingsHost, session: string) { /** Set to true when the token is read from a query string (?token=) instead of a URL fragment. */ export let warnQueryToken = false; +declare global { + interface Window { + __OPENCLAW_NATIVE_CONTROL_AUTH__?: { + gatewayUrl?: string | null; + token?: string | null; + password?: string | null; + }; + } +} + +function applyNativeControlAuth(host: SettingsHost) { + const nativeAuth = window.__OPENCLAW_NATIVE_CONTROL_AUTH__; + if (!nativeAuth) { + return; + } + try { + delete window.__OPENCLAW_NATIVE_CONTROL_AUTH__; + } catch { + window.__OPENCLAW_NATIVE_CONTROL_AUTH__ = undefined; + } + + const gatewayUrl = normalizeOptionalString(nativeAuth.gatewayUrl); + const token = normalizeOptionalString(nativeAuth.token); + const password = normalizeOptionalString(nativeAuth.password); + const nextSettings = { + ...host.settings, + ...(gatewayUrl ? { gatewayUrl } : {}), + ...(token ? { token } : {}), + }; + if (gatewayUrl || (token && token !== host.settings.token)) { + applySettings(host, nextSettings); + } + if (password && password !== host.password) { + host.password = password; + } +} + export function applySettingsFromUrl(host: SettingsHost) { + applyNativeControlAuth(host); if (!window.location.search && !window.location.hash) { return; }