feat: add native mac dashboard window

This commit is contained in:
Peter Steinberger
2026-05-16 23:40:22 +01:00
parent 21244d9793
commit 5b383af736
52 changed files with 1851 additions and 163 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -81,6 +81,7 @@ let package = Package(
dependencies: [
"OpenClawIPC",
"OpenClaw",
"OpenClawMacCLI",
"OpenClawDiscovery",
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
.product(name: "SwabbleKit", package: "swabble"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
:root { color-scheme: light dark; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: Canvas;
color: CanvasText;
font: -apple-system-body;
}
main {
width: min(520px, calc(100vw - 64px));
line-height: 1.4;
}
h1 {
margin: 0 0 10px;
font: -apple-system-title2;
font-weight: 650;
}
p { margin: 8px 0; color: color-mix(in srgb, CanvasText 72%, transparent); }
code {
display: block;
margin-top: 14px;
padding: 12px;
border-radius: 8px;
background: color-mix(in srgb, CanvasText 8%, transparent);
color: CanvasText;
overflow-wrap: anywhere;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>
</head>
<body>
<main>
<h1>Dashboard unavailable</h1>
<p>\(Self.htmlEscape(message))</p>
<code>\(Self.htmlEscape(url.absoluteString))</code>
</main>
</body>
</html>
"""
}
private static func htmlEscape(_ value: String) -> String {
value
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&#39;")
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<RemotePortTunnel, Error>)?
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

View File

@@ -53,6 +53,8 @@
<string>OpenClaw can share your location when requested by the agent.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>OpenClaw can share your location when requested by the agent.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>OpenClaw uses the local network to connect to your remote OpenClaw gateway over LAN, Tailnet, or SSH.</string>
<key>NSMicrophoneUsageDescription</key>
<string>OpenClaw needs the mic for Voice Wake tests and agent audio capture.</string>
<key>NSSpeechRecognitionUsageDescription</key>

View File

@@ -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 <user@host[:port]> [--local-port <port>]
[--remote-port <port>] [--token <token>] [--password <password>]
[--identity <path>] [--project-root <path>] [--cli-path <path>] [--json]
openclaw-mac configure-remote --direct-url <ws://host:port|wss://host> [--token <token>]
[--password <password>] [--project-root <path>] [--cli-path <path>] [--json]
Options:
--ssh-target <t> SSH target for the remote gateway host.
--direct-url <url> Direct remote gateway URL; skips SSH tunneling.
--local-port <p> Local tunnel port for the mac app/UI. Default: 18789.
--remote-port <p> Gateway port on the remote host. Default: 18789.
--token <token> Remote gateway token.
--password <pw> Remote gateway password.
--identity <path> SSH identity file.
--project-root <p> Remote OpenClaw checkout for CLI commands.
--cli-path <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\"}")
}
}

View File

@@ -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 <local|remote>] [--timeout <ms>] [--probe] [--json]
[--client-id <id>] [--client-mode <mode>] [--display-name <name>]
[--role <role>] [--scopes <a,b,c>]
openclaw-mac configure-remote --ssh-target <user@host[:port]> [--local-port <port>]
[--remote-port <port>] [--token <token>] [--password <password>]
[--identity <path>] [--project-root <path>] [--cli-path <path>] [--json]
openclaw-mac discover [--timeout <ms>] [--json] [--include-local]
openclaw-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`.

View File

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

View File

@@ -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).
</Note>
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

View File

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

View File

@@ -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:<port>` and `http://127.0.0.1:<port>` 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.

View File

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

View File

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

View File

@@ -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). */

View File

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

View File

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

View File

@@ -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);
}
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}