Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
489
apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift
Normal file
489
apps/macos/Sources/OpenClawMacCLI/ConfigureRemoteCommand.swift
Normal file
@@ -0,0 +1,489 @@
|
||||
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 sshHostKeyPolicy: String?
|
||||
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 "--ssh-host-key-policy":
|
||||
opts.sshHostKeyPolicy = try parseSSHHostKeyPolicyFlag(args, index: &i)
|
||||
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 sshHostKeyPolicy: String?
|
||||
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>] [--ssh-host-key-policy <strict|openssh>]
|
||||
[--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.
|
||||
--ssh-host-key-policy <strict|openssh>
|
||||
Require a trusted host key (default), or explicitly use SSH config policy.
|
||||
--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,
|
||||
defaultsSuites: [String] = appDefaultsSuites) throws -> ConfigureRemoteOutput
|
||||
{
|
||||
if let directUrlRaw = opts.directUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!directUrlRaw.isEmpty
|
||||
{
|
||||
return try configureDirectRemote(opts, directUrlRaw: directUrlRaw, defaultsSuites: defaultsSuites)
|
||||
}
|
||||
return try configureSSHRemote(opts, defaultsSuites: defaultsSuites)
|
||||
}
|
||||
|
||||
private func configureSSHRemote(
|
||||
_ opts: ConfigureRemoteOptions,
|
||||
defaultsSuites: [String]) 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)"
|
||||
let existingTarget = (remote["sshTarget"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
gateway["mode"] = "remote"
|
||||
gateway["port"] = opts.localPort
|
||||
remote["transport"] = "ssh"
|
||||
remote["url"] = localURL
|
||||
remote["remotePort"] = opts.remotePort
|
||||
remote["sshTarget"] = target
|
||||
let requestedHostKeyPolicy = opts.sshHostKeyPolicy.map { normalizedSSHHostKeyPolicy($0) ?? "strict" }
|
||||
let existingHostKeyPolicy = existingTarget == target
|
||||
? normalizedSSHHostKeyPolicy(remote["sshHostKeyPolicy"] as? String)
|
||||
: nil
|
||||
let sshHostKeyPolicy = requestedHostKeyPolicy
|
||||
?? existingHostKeyPolicy
|
||||
?? "strict"
|
||||
remote["sshHostKeyPolicy"] = sshHostKeyPolicy
|
||||
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, suites: defaultsSuites)
|
||||
|
||||
return ConfigureRemoteOutput(
|
||||
status: "ok",
|
||||
configPath: configURL.path,
|
||||
mode: "remote",
|
||||
transport: "ssh",
|
||||
sshTarget: target,
|
||||
localUrl: localURL,
|
||||
remoteUrl: localURL,
|
||||
remotePort: opts.remotePort,
|
||||
sshHostKeyPolicy: sshHostKeyPolicy,
|
||||
onboardingSkipped: true)
|
||||
}
|
||||
|
||||
private func configureDirectRemote(
|
||||
_ opts: ConfigureRemoteOptions,
|
||||
directUrlRaw: String,
|
||||
defaultsSuites: [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")
|
||||
remote.removeValue(forKey: "sshHostKeyPolicy")
|
||||
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: "", suites: defaultsSuites)
|
||||
|
||||
return ConfigureRemoteOutput(
|
||||
status: "ok",
|
||||
configPath: configURL.path,
|
||||
mode: "remote",
|
||||
transport: "direct",
|
||||
sshTarget: nil,
|
||||
localUrl: nil,
|
||||
remoteUrl: directURL.absoluteString,
|
||||
remotePort: defaultPort(for: directURL) ?? opts.remotePort,
|
||||
sshHostKeyPolicy: nil,
|
||||
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, suites: [String]) {
|
||||
for suite in suites {
|
||||
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 parseSSHHostKeyPolicyFlag(_ args: [String], index: inout Int) throws -> String {
|
||||
let value = CLIArgParsingSupport.nextValue(args, index: &index)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard let value, value == "strict" || value == "openssh" else {
|
||||
throw NSError(
|
||||
domain: "ConfigureRemote",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "--ssh-host-key-policy must be strict or openssh"])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func normalizedSSHHostKeyPolicy(_ raw: String?) -> String? {
|
||||
raw == "strict" || raw == "openssh" ? raw : nil
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
if let sshHostKeyPolicy = output.sshHostKeyPolicy {
|
||||
print("SSH host-key policy: \(sshHostKeyPolicy)")
|
||||
}
|
||||
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\"}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user