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