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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
import Foundation
enum CLIArgParsingSupport {
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)
}
}

View 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\"}")
}
}

View File

@@ -0,0 +1,308 @@
import Foundation
import OpenClawDiscovery
import OpenClawKit
import OpenClawProtocol
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] = defaultOperatorConnectScopes
var scopesAreExplicit: Bool = false
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 }
opts.scopesAreExplicit = true
},
]
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 = CLIArgParsingSupport.nextValue(args, index: &i) {
handler(&opts, value)
i += 1
continue
}
i += 1
}
return opts
}
}
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,
scopesAreExplicit: opts.scopesAreExplicit,
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 {
return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config)
}
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"])
}
return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config)
}
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 gatewayEndpoint(
fromRawURL raw: String,
opts: ConnectOptions,
mode: String,
config: GatewayConfig) throws -> GatewayEndpoint
{
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: mode, config: config),
password: resolvedPassword(opts: opts, mode: mode, config: config),
mode: mode)
}
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 = TailscaleNetwork.detectTailnetIPv4()
switch normalized {
case "tailnet":
return tailnetIP ?? "127.0.0.1"
default:
return "127.0.0.1"
}
}

View File

@@ -0,0 +1,155 @@
import Foundation
import OpenClawDiscovery
struct DiscoveryOptions {
var timeoutMs: Int = 2000
var json: Bool = false
var includeLocal: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> DiscoveryOptions {
var opts = DiscoveryOptions()
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 "--include-local":
opts.includeLocal = true
case "--timeout":
let next = (i + 1 < args.count) ? args[i + 1] : nil
if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(100, parsed)
i += 1
}
default:
break
}
i += 1
}
return opts
}
}
struct DiscoveryOutput: Encodable {
struct Gateway: Encodable {
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var gatewayPort: Int?
var gatewayTls: Bool
var gatewayDirectReachable: Bool
var cliPath: String?
var stableID: String
var debugID: String
var isLocal: Bool
}
var status: String
var timeoutMs: Int
var includeLocal: Bool
var count: Int
var gateways: [Gateway]
}
func runDiscover(_ args: [String]) async {
let opts = DiscoveryOptions.parse(args)
if opts.help {
print("""
openclaw-mac discover
Usage:
openclaw-mac discover [--timeout <ms>] [--json] [--include-local]
Options:
--timeout <ms> Discovery window in milliseconds (default: 2000)
--json Emit JSON
--include-local Include gateways considered local
-h, --help Show help
""")
return
}
let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let model = await MainActor.run {
GatewayDiscoveryModel(
localDisplayName: displayName,
filterLocalGateways: !opts.includeLocal)
}
await MainActor.run {
model.start()
}
let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000
try? await Task.sleep(nanoseconds: nanos)
let gateways = await MainActor.run { model.gateways }
let status = await MainActor.run { model.statusText }
await MainActor.run {
model.stop()
}
if opts.json {
let payload = DiscoveryOutput(
status: status,
timeoutMs: opts.timeoutMs,
includeLocal: opts.includeLocal,
count: gateways.count,
gateways: gateways.map {
DiscoveryOutput.Gateway(
displayName: $0.displayName,
lanHost: $0.lanHost,
tailnetDns: $0.tailnetDns,
sshPort: $0.sshPort,
gatewayPort: $0.gatewayPort,
gatewayTls: $0.gatewayTls,
gatewayDirectReachable: $0.gatewayDirectReachable,
cliPath: $0.cliPath,
stableID: $0.stableID,
debugID: $0.debugID,
isLocal: $0.isLocal)
})
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8)
{
print(json)
} else {
print("{\"error\":\"failed to encode JSON\"}")
}
return
}
print("Gateway Discovery (macOS NWBrowser)")
print("Status: \(status)")
print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")")
if gateways.isEmpty { return }
for gateway in gateways {
let hosts = [gateway.tailnetDns, gateway.lanHost]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ", ")
print("- \(gateway.displayName)")
print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)")
print(" ssh: \(gateway.sshPort)")
if let port = gateway.gatewayPort {
print(" gatewayPort: \(port)")
}
print(" gatewayTls: \(gateway.gatewayTls)")
print(" gatewayDirectReachable: \(gateway.gatewayDirectReachable)")
if let cliPath = gateway.cliPath {
print(" cliPath: \(cliPath)")
}
print(" isLocal: \(gateway.isLocal)")
print(" stableID: \(gateway.stableID)")
print(" debugID: \(gateway.debugID)")
}
}

View File

@@ -0,0 +1,90 @@
import Foundation
struct RootCommand: Equatable {
var name: String
var args: [String]
}
enum RootCommandAction: Equatable {
case usage
case connect([String])
case configureRemote([String])
case discover([String])
case wizard([String])
case unknown(exitCode: Int32)
}
@main
struct OpenClawMacCLI {
static func main() async {
let args = Array(CommandLine.arguments.dropFirst())
switch resolveRootCommandAction(args) {
case .usage:
printUsage()
case let .connect(commandArgs):
await runConnect(commandArgs)
case let .configureRemote(commandArgs):
runConfigureRemote(commandArgs)
case let .discover(commandArgs):
await runDiscover(commandArgs)
case let .wizard(commandArgs):
await runWizardCommand(commandArgs)
case let .unknown(exitCode):
fputs("openclaw-mac: unknown command\n", stderr)
printUsage()
exit(exitCode)
}
}
}
func parseRootCommand(_ args: [String]) -> RootCommand? {
guard let first = args.first else { return nil }
return RootCommand(name: first, args: Array(args.dropFirst()))
}
func resolveRootCommandAction(_ args: [String]) -> RootCommandAction {
guard let command = parseRootCommand(args) else {
return .usage
}
switch command.name {
case "-h", "--help", "help":
return .usage
case "connect":
return .connect(command.args)
case "configure-remote":
return .configureRemote(command.args)
case "discover":
return .discover(command.args)
case "wizard":
return .wizard(command.args)
default:
return .unknown(exitCode: 1)
}
}
private func printUsage() {
print("""
openclaw-mac
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>]
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 discover [--timeout <ms>] [--json] [--include-local]
openclaw-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Examples:
openclaw-mac connect
openclaw-mac configure-remote --ssh-target user@gateway.local --remote-port 18789
openclaw-mac connect --url ws://127.0.0.1:18789 --json
openclaw-mac discover --timeout 3000 --json
openclaw-mac wizard --mode local
""")
}

View File

@@ -0,0 +1,64 @@
import Foundation
struct GatewayConfig {
var mode: String?
var bind: String?
var port: Int?
var remoteUrl: String?
var remotePort: Int?
var token: String?
var password: String?
var remoteToken: String?
var remotePassword: String?
}
struct GatewayEndpoint {
let url: URL
let token: String?
let password: String?
let mode: String
}
func loadGatewayConfig() -> GatewayConfig {
let home = FileManager().homeDirectoryForCurrentUser
let candidates = [
home.appendingPathComponent(".openclaw/openclaw.json"),
]
let url = candidates.first { FileManager().isReadableFile(atPath: $0.path) } ?? candidates[0]
guard let data = try? Data(contentsOf: url) else { return GatewayConfig() }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return GatewayConfig()
}
var cfg = GatewayConfig()
if let gateway = json["gateway"] as? [String: Any] {
cfg.mode = gateway["mode"] as? String
cfg.bind = gateway["bind"] as? String
cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"])
if let auth = gateway["auth"] as? [String: Any] {
cfg.token = auth["token"] as? String
cfg.password = auth["password"] as? String
}
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
}
}
return cfg
}
func parseInt(_ value: Any?) -> Int? {
switch value {
case let number as Int:
number
case let number as Double:
Int(number)
case let raw as String:
Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
default:
nil
}
}

View File

@@ -0,0 +1,7 @@
let defaultOperatorConnectScopes: [String] = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
]

View File

@@ -0,0 +1,5 @@
import OpenClawKit
import OpenClawProtocol
typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
typealias KitAnyCodable = OpenClawKit.AnyCodable

View File

@@ -0,0 +1,560 @@
import Darwin
import Foundation
import OpenClawKit
import OpenClawProtocol
struct WizardCliOptions {
var url: String?
var token: String?
var password: String?
var mode: String = "local"
var workspace: String?
var json: Bool = false
var help: Bool = false
static func parse(_ args: [String]) -> WizardCliOptions {
var opts = WizardCliOptions()
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 "--url":
opts.url = CLIArgParsingSupport.nextValue(args, index: &i)
case "--token":
opts.token = CLIArgParsingSupport.nextValue(args, index: &i)
case "--password":
opts.password = CLIArgParsingSupport.nextValue(args, index: &i)
case "--mode":
if let value = CLIArgParsingSupport.nextValue(args, index: &i) {
opts.mode = value
}
case "--workspace":
opts.workspace = CLIArgParsingSupport.nextValue(args, index: &i)
default:
break
}
i += 1
}
return opts
}
}
enum WizardCliError: Error, CustomStringConvertible {
case invalidUrl(String)
case missingRemoteUrl
case gatewayError(String)
case decodeError(String)
case cancelled
var description: String {
switch self {
case let .invalidUrl(raw): "Invalid URL: \(raw)"
case .missingRemoteUrl: "gateway.remote.url is missing"
case let .gatewayError(msg): msg
case let .decodeError(msg): msg
case .cancelled: "Wizard cancelled"
}
}
}
func runWizardCommand(_ args: [String]) async {
let opts = WizardCliOptions.parse(args)
if opts.help {
print("""
openclaw-mac wizard
Usage:
openclaw-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> Wizard workspace override
--json Print raw wizard responses
-h, --help Show help
""")
return
}
let config = loadGatewayConfig()
do {
guard isatty(STDIN_FILENO) != 0 else {
throw WizardCliError.gatewayError("Wizard requires an interactive TTY.")
}
let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config)
let client = GatewayWizardClient(
url: endpoint.url,
token: endpoint.token,
password: endpoint.password,
json: opts.json)
try await client.connect()
defer { Task { await client.close() } }
try await runWizard(client: client, opts: opts)
} catch {
fputs("wizard: \(error)\n", stderr)
exit(1)
}
}
private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint {
if let raw = opts.url, !raw.isEmpty {
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config),
mode: (config.mode ?? "local").lowercased())
}
let mode = (config.mode ?? "local").lowercased()
if mode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
throw WizardCliError.missingRemoteUrl
}
guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) }
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config),
mode: mode)
}
let port = config.port ?? 18789
let host = "127.0.0.1"
guard let url = URL(string: "ws://\(host):\(port)") else {
throw WizardCliError.invalidUrl("ws://\(host):\(port)")
}
return GatewayEndpoint(
url: url,
token: resolvedToken(opts: opts, config: config),
password: resolvedPassword(opts: opts, config: config),
mode: mode)
}
private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? {
if let token = opts.token, !token.isEmpty { return token }
if (config.mode ?? "local").lowercased() == "remote" {
return config.remoteToken
}
return config.token
}
private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) -> String? {
if let password = opts.password, !password.isEmpty { return password }
if (config.mode ?? "local").lowercased() == "remote" {
return config.remotePassword
}
return config.password
}
actor GatewayWizardClient {
private enum ConnectChallengeError: Error {
case timeout
}
private let url: URL
private let token: String?
private let password: String?
private let json: Bool
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let session = URLSession(configuration: .default)
private let connectChallengeTimeoutSeconds: Double = 0.75
private var task: URLSessionWebSocketTask?
init(url: URL, token: String?, password: String?, json: Bool) {
self.url = url
self.token = token
self.password = password
self.json = json
}
func connect() async throws {
let socket = self.session.webSocketTask(with: self.url)
socket.maximumMessageSize = 16 * 1024 * 1024
socket.resume()
self.task = socket
try await self.sendConnect()
}
func close() {
self.task?.cancel(with: .goingAway, reason: nil)
self.task = nil
}
func request(method: String, params: [String: ProtoAnyCodable]?) async throws -> ResponseFrame {
guard let task = self.task else {
throw WizardCliError.gatewayError("gateway not connected")
}
let id = UUID().uuidString
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: params.map { ProtoAnyCodable($0) })
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
while true {
let message = try await task.receive()
let frame = try decodeFrame(message)
if case let .res(res) = frame, res.id == id {
if res.ok == false {
let msg = res.error?.message ?? "gateway error"
throw WizardCliError.gatewayError(msg)
}
return res
}
}
}
func decodePayload<T: Decodable>(_ response: ResponseFrame, as _: T.Type) throws -> T {
guard let payload = response.payload else {
throw WizardCliError.decodeError("missing payload")
}
let data = try self.encoder.encode(payload)
return try self.decoder.decode(T.self, from: data)
}
private func decodeFrame(_ message: URLSessionWebSocketTask.Message) throws -> GatewayFrame {
let data: Data? = switch message {
case let .data(data): data
case let .string(text): text.data(using: .utf8)
@unknown default: nil
}
guard let data else {
throw WizardCliError.decodeError("empty gateway response")
}
return try self.decoder.decode(GatewayFrame.self, from: data)
}
private func sendConnect() async throws {
guard let task = self.task else {
throw WizardCliError.gatewayError("gateway not connected")
}
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let clientId = "openclaw-macos"
let clientMode = "ui"
let role = "operator"
// Explicit scopes; gateway no longer defaults empty scopes to admin.
let scopes = defaultOperatorConnectScopes
let client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"),
"version": ProtoAnyCodable("dev"),
"platform": ProtoAnyCodable(platform),
"deviceFamily": ProtoAnyCodable("Mac"),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(UUID().uuidString),
]
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_MIN_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([String]()),
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
]
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let payload = GatewayDeviceAuthPayload.buildV3(
deviceId: identity.deviceId,
clientId: clientId,
clientMode: clientMode,
role: role,
scopes: scopes,
signedAtMs: signedAtMs,
token: self.token,
nonce: connectNonce,
platform: platform,
deviceFamily: "Mac")
if let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
payload: payload,
identity: identity,
signedAtMs: signedAtMs,
nonce: connectNonce)
{
params["device"] = ProtoAnyCodable(device)
}
let reqId = UUID().uuidString
let frame = RequestFrame(
type: "req",
id: reqId,
method: "connect",
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
while true {
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
if case let .res(res) = frameResponse, res.id == reqId {
if res.ok == false {
let msg = res.error?.message ?? "gateway connect failed"
throw WizardCliError.gatewayError(msg)
}
_ = try self.decodePayload(res, as: HelloOk.self)
return
}
}
}
private func waitForConnectChallenge() async throws -> String {
guard let task = self.task else { throw ConnectChallengeError.timeout }
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
let nonce = GatewayConnectChallengeSupport.nonce(from: payload)
{
return nonce
}
}
})
}
}
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
var params: [String: ProtoAnyCodable] = [:]
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if mode == "local" || mode == "remote" {
params["mode"] = ProtoAnyCodable(mode)
}
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
params["workspace"] = ProtoAnyCodable(workspace)
}
let startResponse = try await client.request(method: "wizard.start", params: params)
let startResult = try await client.decodePayload(startResponse, as: WizardStartResult.self)
if opts.json {
dumpResult(startResponse)
}
let sessionId = startResult.sessionid
var nextResult = WizardNextResult(
done: startResult.done,
step: startResult.step,
status: startResult.status,
error: startResult.error)
do {
while true {
let status = wizardStatusString(nextResult.status) ?? (nextResult.done ? "done" : "running")
if status == "cancelled" {
print("Wizard cancelled.")
return
}
if status == "error" || (nextResult.done && nextResult.error != nil) {
throw WizardCliError.gatewayError(nextResult.error ?? "wizard error")
}
if status == "done" || nextResult.done {
print("Wizard complete.")
return
}
if let error = nextResult.error, !opts.json {
fputs("wizard: \(error)\n", stderr)
}
if let step = nextResult.step {
let answer = try promptAnswer(for: step)
var answerPayload: [String: ProtoAnyCodable] = [
"stepId": ProtoAnyCodable(step.id),
]
if !(answer is NSNull) {
answerPayload["value"] = ProtoAnyCodable(answer)
}
let response = try await client.request(
method: "wizard.next",
params: [
"sessionId": ProtoAnyCodable(sessionId),
"answer": ProtoAnyCodable(answerPayload),
])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
dumpResult(response)
}
} else {
let response = try await client.request(
method: "wizard.next",
params: ["sessionId": ProtoAnyCodable(sessionId)])
nextResult = try await client.decodePayload(response, as: WizardNextResult.self)
if opts.json {
dumpResult(response)
}
}
}
} catch WizardCliError.cancelled {
_ = try? await client.request(
method: "wizard.cancel",
params: ["sessionId": ProtoAnyCodable(sessionId)])
throw WizardCliError.cancelled
}
}
private func dumpResult(_ response: ResponseFrame) {
guard let payload = response.payload else {
print("{\"error\":\"missing payload\"}")
return
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(payload), let text = String(data: data, encoding: .utf8) {
print(text)
}
}
private func promptAnswer(for step: WizardStep) throws -> Any {
let type = wizardStepType(step)
if let title = step.title, !title.isEmpty {
print("\n\(title)")
}
if let message = step.message, !message.isEmpty {
print(message)
}
switch type {
case "note":
_ = try readLineWithPrompt("Continue? (enter)")
return NSNull()
case "progress":
_ = try readLineWithPrompt("Continue? (enter)")
return NSNull()
case "action":
_ = try readLineWithPrompt("Run? (enter)")
return true
case "text":
let initial = anyCodableString(step.initialvalue)
let prompt = step.placeholder ?? "Value"
if step.sensitive == true {
let sensitivePrompt = initial.isEmpty ? prompt : "\(prompt) (leave blank to keep existing)"
let value = try readSensitiveLineWithPrompt(sensitivePrompt)
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? initial : trimmed
}
let value = try readLineWithPrompt("\(prompt)\(initial.isEmpty ? "" : " [\(initial)]")")
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? initial : trimmed
case "confirm":
let initial = anyCodableBool(step.initialvalue)
let value = try readLineWithPrompt("Confirm? (y/n) [\(initial ? "y" : "n")]")
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.isEmpty { return initial }
return trimmed == "y" || trimmed == "yes" || trimmed == "true"
case "select":
return try promptSelect(step)
case "multiselect":
return try promptMultiSelect(step)
default:
_ = try readLineWithPrompt("Continue? (enter)")
return NSNull()
}
}
private func promptSelect(_ step: WizardStep) throws -> Any {
let options = parseWizardOptions(step.options)
guard !options.isEmpty else { return NSNull() }
for (idx, option) in options.enumerated() {
let hint = option.hint?.isEmpty == false ? "\(option.hint!)" : ""
print(" [\(idx + 1)] \(option.label)\(hint)")
}
let initialIndex = options.firstIndex(where: { anyCodableEqual($0.value, step.initialvalue) })
let defaultLabel = initialIndex.map { " [\($0 + 1)]" } ?? ""
while true {
let input = try readLineWithPrompt("Select one\(defaultLabel)")
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty, let initialIndex {
return options[initialIndex].value?.value ?? options[initialIndex].label
}
if trimmed.lowercased() == "q" { throw WizardCliError.cancelled }
if let number = Int(trimmed), (1...options.count).contains(number) {
let option = options[number - 1]
return option.value?.value ?? option.label
}
print("Invalid selection.")
}
}
private func promptMultiSelect(_ step: WizardStep) throws -> [Any] {
let options = parseWizardOptions(step.options)
guard !options.isEmpty else { return [] }
for (idx, option) in options.enumerated() {
let hint = option.hint?.isEmpty == false ? "\(option.hint!)" : ""
print(" [\(idx + 1)] \(option.label)\(hint)")
}
let initialValues = anyCodableArray(step.initialvalue)
let initialIndices = options.enumerated().compactMap { index, option in
initialValues.contains { anyCodableEqual($0, option.value) } ? index + 1 : nil
}
let defaultLabel = initialIndices.isEmpty ? "" : " [\(initialIndices.map(String.init).joined(separator: ","))]"
while true {
let input = try readLineWithPrompt("Select (comma-separated)\(defaultLabel)")
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return initialIndices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label }
}
if trimmed.lowercased() == "q" { throw WizardCliError.cancelled }
let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let indices = parts.compactMap { Int($0) }.filter { (1...options.count).contains($0) }
if indices.isEmpty {
print("Invalid selection.")
continue
}
return indices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label }
}
}
private func readLineWithPrompt(_ prompt: String) throws -> String {
print("\(prompt): ", terminator: "")
guard let line = readLine() else {
throw WizardCliError.cancelled
}
return line
}
private func readSensitiveLineWithPrompt(_ prompt: String) throws -> String {
print("\(prompt): ", terminator: "")
fflush(stdout)
var original = termios()
guard tcgetattr(STDIN_FILENO, &original) == 0 else {
throw WizardCliError.gatewayError("Could not configure hidden terminal input.")
}
var hidden = original
hidden.c_lflag &= ~tcflag_t(ECHO)
guard tcsetattr(STDIN_FILENO, TCSANOW, &hidden) == 0 else {
throw WizardCliError.gatewayError("Could not configure hidden terminal input.")
}
defer {
_ = tcsetattr(STDIN_FILENO, TCSANOW, &original)
print("")
}
guard let line = readLine() else {
throw WizardCliError.cancelled
}
return line
}