Spaces:
Paused
Paused
| import OpenClawKit | |
| import OpenClawProtocol | |
| import Foundation | |
| #if canImport(Darwin) | |
| import Darwin | |
| #endif | |
| struct ConnectOptions { | |
| var url: String? | |
| var token: String? | |
| var password: String? | |
| var mode: String? | |
| var timeoutMs: Int = 15000 | |
| var json: Bool = false | |
| var probe: Bool = false | |
| var clientId: String = "openclaw-macos" | |
| var clientMode: String = "ui" | |
| var displayName: String? | |
| var role: String = "operator" | |
| var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] | |
| var help: Bool = false | |
| static func parse(_ args: [String]) -> ConnectOptions { | |
| var opts = ConnectOptions() | |
| let flagHandlers: [String: (inout ConnectOptions) -> Void] = [ | |
| "-h": { $0.help = true }, | |
| "--help": { $0.help = true }, | |
| "--json": { $0.json = true }, | |
| "--probe": { $0.probe = true }, | |
| ] | |
| let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [ | |
| "--url": { $0.url = $1 }, | |
| "--token": { $0.token = $1 }, | |
| "--password": { $0.password = $1 }, | |
| "--mode": { $0.mode = $1 }, | |
| "--timeout": { opts, raw in | |
| if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) { | |
| opts.timeoutMs = max(250, parsed) | |
| } | |
| }, | |
| "--client-id": { $0.clientId = $1 }, | |
| "--client-mode": { $0.clientMode = $1 }, | |
| "--display-name": { $0.displayName = $1 }, | |
| "--role": { $0.role = $1 }, | |
| "--scopes": { opts, raw in | |
| opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | |
| .filter { !$0.isEmpty } | |
| }, | |
| ] | |
| var i = 0 | |
| while i < args.count { | |
| let arg = args[i] | |
| if let handler = flagHandlers[arg] { | |
| handler(&opts) | |
| i += 1 | |
| continue | |
| } | |
| if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) { | |
| handler(&opts, value) | |
| i += 1 | |
| continue | |
| } | |
| i += 1 | |
| } | |
| return opts | |
| } | |
| private static func nextValue(_ args: [String], index: inout Int) -> String? { | |
| guard index + 1 < args.count else { return nil } | |
| index += 1 | |
| return args[index].trimmingCharacters(in: .whitespacesAndNewlines) | |
| } | |
| } | |
| struct ConnectOutput: Encodable { | |
| var status: String | |
| var url: String | |
| var mode: String | |
| var role: String | |
| var clientId: String | |
| var clientMode: String | |
| var scopes: [String] | |
| var snapshot: HelloOk? | |
| var health: ProtoAnyCodable? | |
| var error: String? | |
| } | |
| actor SnapshotStore { | |
| private var value: HelloOk? | |
| func set(_ snapshot: HelloOk) { | |
| self.value = snapshot | |
| } | |
| func get() -> HelloOk? { | |
| self.value | |
| } | |
| } | |
| func runConnect(_ args: [String]) async { | |
| let opts = ConnectOptions.parse(args) | |
| if opts.help { | |
| print(""" | |
| openclaw-mac connect | |
| Usage: | |
| openclaw-mac connect [--url <ws://host:port>] [--token <token>] [--password <password>] | |
| [--mode <local|remote>] [--timeout <ms>] [--probe] [--json] | |
| [--client-id <id>] [--client-mode <mode>] [--display-name <name>] | |
| [--role <role>] [--scopes <a,b,c>] | |
| Options: | |
| --url <url> Gateway WebSocket URL (overrides config) | |
| --token <token> Gateway token (if required) | |
| --password <pw> Gateway password (if required) | |
| --mode <mode> Resolve from config: local|remote (default: config or local) | |
| --timeout <ms> Request timeout (default: 15000) | |
| --probe Force a fresh health probe | |
| --json Emit JSON | |
| --client-id <id> Override client id (default: openclaw-macos) | |
| --client-mode <m> Override client mode (default: ui) | |
| --display-name <n> Override display name | |
| --role <role> Override role (default: operator) | |
| --scopes <a,b,c> Override scopes list | |
| -h, --help Show help | |
| """) | |
| return | |
| } | |
| let config = loadGatewayConfig() | |
| do { | |
| let endpoint = try resolveGatewayEndpoint(opts: opts, config: config) | |
| let displayName = opts.displayName ?? Host.current().localizedName ?? "OpenClaw macOS Debug CLI" | |
| let connectOptions = GatewayConnectOptions( | |
| role: opts.role, | |
| scopes: opts.scopes, | |
| caps: [], | |
| commands: [], | |
| permissions: [:], | |
| clientId: opts.clientId, | |
| clientMode: opts.clientMode, | |
| clientDisplayName: displayName) | |
| let snapshotStore = SnapshotStore() | |
| let channel = GatewayChannelActor( | |
| url: endpoint.url, | |
| token: endpoint.token, | |
| password: endpoint.password, | |
| pushHandler: { push in | |
| if case let .snapshot(ok) = push { | |
| await snapshotStore.set(ok) | |
| } | |
| }, | |
| connectOptions: connectOptions) | |
| let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil | |
| let data = try await channel.request( | |
| method: "health", | |
| params: params, | |
| timeoutMs: Double(opts.timeoutMs)) | |
| let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data) | |
| let snapshot = await snapshotStore.get() | |
| await channel.shutdown() | |
| let output = ConnectOutput( | |
| status: "ok", | |
| url: endpoint.url.absoluteString, | |
| mode: endpoint.mode, | |
| role: opts.role, | |
| clientId: opts.clientId, | |
| clientMode: opts.clientMode, | |
| scopes: opts.scopes, | |
| snapshot: snapshot, | |
| health: health, | |
| error: nil) | |
| printConnectOutput(output, json: opts.json) | |
| } catch { | |
| let endpoint = bestEffortEndpoint(opts: opts, config: config) | |
| let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased() | |
| let output = ConnectOutput( | |
| status: "error", | |
| url: endpoint?.url.absoluteString ?? "unknown", | |
| mode: endpoint?.mode ?? fallbackMode, | |
| role: opts.role, | |
| clientId: opts.clientId, | |
| clientMode: opts.clientMode, | |
| scopes: opts.scopes, | |
| snapshot: nil, | |
| health: nil, | |
| error: error.localizedDescription) | |
| printConnectOutput(output, json: opts.json) | |
| exit(1) | |
| } | |
| } | |
| private func printConnectOutput(_ output: ConnectOutput, 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) | |
| } else { | |
| print("{\"error\":\"failed to encode JSON\"}") | |
| } | |
| return | |
| } | |
| print("OpenClaw macOS Gateway Connect") | |
| print("Status: \(output.status)") | |
| print("URL: \(output.url)") | |
| print("Mode: \(output.mode)") | |
| print("Client: \(output.clientId) (\(output.clientMode))") | |
| print("Role: \(output.role)") | |
| print("Scopes: \(output.scopes.joined(separator: ", "))") | |
| if let snapshot = output.snapshot { | |
| print("Protocol: \(snapshot._protocol)") | |
| if let version = snapshot.server["version"]?.value as? String { | |
| print("Server: \(version)") | |
| } | |
| } | |
| if let health = output.health, | |
| let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool | |
| { | |
| print("Health: \(ok ? "ok" : "error")") | |
| } else if output.health != nil { | |
| print("Health: received") | |
| } | |
| if let error = output.error { | |
| print("Error: \(error)") | |
| } | |
| } | |
| private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint { | |
| let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased() | |
| if let raw = opts.url, !raw.isEmpty { | |
| guard let url = URL(string: raw) else { | |
| throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) | |
| } | |
| return GatewayEndpoint( | |
| url: url, | |
| token: resolvedToken(opts: opts, mode: resolvedMode, config: config), | |
| password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), | |
| mode: resolvedMode) | |
| } | |
| if resolvedMode == "remote" { | |
| guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), | |
| !raw.isEmpty | |
| else { | |
| throw NSError( | |
| domain: "Gateway", | |
| code: 1, | |
| userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) | |
| } | |
| guard let url = URL(string: raw) else { | |
| throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) | |
| } | |
| return GatewayEndpoint( | |
| url: url, | |
| token: resolvedToken(opts: opts, mode: resolvedMode, config: config), | |
| password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), | |
| mode: resolvedMode) | |
| } | |
| let port = config.port ?? 18789 | |
| let host = resolveLocalHost(bind: config.bind) | |
| guard let url = URL(string: "ws://\(host):\(port)") else { | |
| throw NSError( | |
| domain: "Gateway", | |
| code: 1, | |
| userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) | |
| } | |
| return GatewayEndpoint( | |
| url: url, | |
| token: resolvedToken(opts: opts, mode: resolvedMode, config: config), | |
| password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), | |
| mode: resolvedMode) | |
| } | |
| private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { | |
| try? resolveGatewayEndpoint(opts: opts, config: config) | |
| } | |
| private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { | |
| if let token = opts.token, !token.isEmpty { return token } | |
| if mode == "remote" { | |
| return config.remoteToken | |
| } | |
| return config.token | |
| } | |
| private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { | |
| if let password = opts.password, !password.isEmpty { return password } | |
| if mode == "remote" { | |
| return config.remotePassword | |
| } | |
| return config.password | |
| } | |
| private func resolveLocalHost(bind: String?) -> String { | |
| let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | |
| let tailnetIP = detectTailnetIPv4() | |
| switch normalized { | |
| case "tailnet": | |
| return tailnetIP ?? "127.0.0.1" | |
| default: | |
| return "127.0.0.1" | |
| } | |
| } | |
| private func detectTailnetIPv4() -> String? { | |
| var addrList: UnsafeMutablePointer<ifaddrs>? | |
| guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } | |
| defer { freeifaddrs(addrList) } | |
| for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { | |
| let flags = Int32(ptr.pointee.ifa_flags) | |
| let isUp = (flags & IFF_UP) != 0 | |
| let isLoopback = (flags & IFF_LOOPBACK) != 0 | |
| let family = ptr.pointee.ifa_addr.pointee.sa_family | |
| if !isUp || isLoopback || family != UInt8(AF_INET) { continue } | |
| var addr = ptr.pointee.ifa_addr.pointee | |
| var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) | |
| let result = getnameinfo( | |
| &addr, | |
| socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), | |
| &buffer, | |
| socklen_t(buffer.count), | |
| nil, | |
| 0, | |
| NI_NUMERICHOST) | |
| guard result == 0 else { continue } | |
| let len = buffer.prefix { $0 != 0 } | |
| let bytes = len.map { UInt8(bitPattern: $0) } | |
| guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } | |
| if isTailnetIPv4(ip) { return ip } | |
| } | |
| return nil | |
| } | |
| private func isTailnetIPv4(_ address: String) -> Bool { | |
| let parts = address.split(separator: ".") | |
| guard parts.count == 4 else { return false } | |
| let octets = parts.compactMap { Int($0) } | |
| guard octets.count == 4 else { return false } | |
| let a = octets[0] | |
| let b = octets[1] | |
| return a == 100 && b >= 64 && b <= 127 | |
| } | |