mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 12:34:47 +00:00
feat: add native mac dashboard window
This commit is contained in:
@@ -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 |
@@ -81,6 +81,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
"OpenClawIPC",
|
||||
"OpenClaw",
|
||||
"OpenClawMacCLI",
|
||||
"OpenClawDiscovery",
|
||||
.product(name: "OpenClawProtocol", package: "OpenClawKit"),
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
120
apps/macos/Sources/OpenClaw/DashboardManager.swift
Normal file
120
apps/macos/Sources/OpenClaw/DashboardManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
21
apps/macos/Sources/OpenClaw/DashboardWindow.swift
Normal file
21
apps/macos/Sources/OpenClaw/DashboardWindow.swift
Normal 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
|
||||
}
|
||||
}
|
||||
343
apps/macos/Sources/OpenClaw/DashboardWindowController.swift
Normal file
343
apps/macos/Sources/OpenClaw/DashboardWindowController.swift
Normal 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: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() ?? ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
437
apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift
Normal file
437
apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift
Normal 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\"}")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")!
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user