mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
223 lines
8.5 KiB
Swift
223 lines
8.5 KiB
Swift
import Foundation
|
|
import OpenClawIPC
|
|
import OpenClawKit
|
|
|
|
enum RemoteGatewayAuthIssue: Equatable {
|
|
case tokenRequired
|
|
case tokenMismatch
|
|
case gatewayTokenNotConfigured
|
|
case passwordRequired
|
|
case pairingRequired
|
|
|
|
init?(error: Error) {
|
|
guard let authError = error as? GatewayConnectAuthError else {
|
|
return nil
|
|
}
|
|
switch authError.detail {
|
|
case .authTokenMissing:
|
|
self = .tokenRequired
|
|
case .authTokenMismatch:
|
|
self = .tokenMismatch
|
|
case .authTokenNotConfigured:
|
|
self = .gatewayTokenNotConfigured
|
|
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
|
|
self = .passwordRequired
|
|
case .pairingRequired:
|
|
self = .pairingRequired
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var showsTokenField: Bool {
|
|
switch self {
|
|
case .tokenRequired, .tokenMismatch:
|
|
true
|
|
case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired:
|
|
false
|
|
}
|
|
}
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .tokenRequired:
|
|
"This gateway requires an auth token"
|
|
case .tokenMismatch:
|
|
"That token did not match the gateway"
|
|
case .gatewayTokenNotConfigured:
|
|
"This gateway host needs token setup"
|
|
case .passwordRequired:
|
|
"This gateway is using unsupported auth"
|
|
case .pairingRequired:
|
|
"This device needs pairing approval"
|
|
}
|
|
}
|
|
|
|
var body: String {
|
|
switch self {
|
|
case .tokenRequired:
|
|
"Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`."
|
|
case .tokenMismatch:
|
|
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
|
|
case .gatewayTokenNotConfigured:
|
|
"This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway."
|
|
case .passwordRequired:
|
|
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
|
|
case .pairingRequired:
|
|
"Approve this device from an already-paired OpenClaw client. In your OpenClaw chat, run `/pair approve`, then click **Check connection** again."
|
|
}
|
|
}
|
|
|
|
var footnote: String? {
|
|
switch self {
|
|
case .tokenRequired, .gatewayTokenNotConfigured:
|
|
"No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`."
|
|
case .pairingRequired:
|
|
"If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`."
|
|
case .tokenMismatch, .passwordRequired:
|
|
nil
|
|
}
|
|
}
|
|
|
|
var statusMessage: String {
|
|
switch self {
|
|
case .tokenRequired:
|
|
"This gateway requires an auth token from the gateway host."
|
|
case .tokenMismatch:
|
|
"Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
|
|
case .gatewayTokenNotConfigured:
|
|
"This gateway has token auth enabled, but no gateway.auth.token is configured on the host."
|
|
case .passwordRequired:
|
|
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
|
|
case .pairingRequired:
|
|
"Pairing required. In an already-paired OpenClaw client, run /pair approve, then check the connection again."
|
|
}
|
|
}
|
|
}
|
|
|
|
enum RemoteGatewayProbeResult: Equatable {
|
|
case ready(RemoteGatewayProbeSuccess)
|
|
case authIssue(RemoteGatewayAuthIssue)
|
|
case failed(String)
|
|
}
|
|
|
|
struct RemoteGatewayProbeSuccess: Equatable {
|
|
let authSource: GatewayAuthSource?
|
|
|
|
var title: String {
|
|
switch self.authSource {
|
|
case .some(.deviceToken):
|
|
"Connected via paired device"
|
|
case .some(.sharedToken):
|
|
"Connected with gateway token"
|
|
case .some(.password):
|
|
"Connected with password"
|
|
case .some(GatewayAuthSource.none), nil:
|
|
"Remote gateway ready"
|
|
}
|
|
}
|
|
|
|
var detail: String? {
|
|
switch self.authSource {
|
|
case .some(.deviceToken):
|
|
"This Mac used a stored device token. New or unpaired devices may still need the gateway token."
|
|
case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
|
|
nil
|
|
}
|
|
}
|
|
}
|
|
|
|
enum RemoteGatewayProbe {
|
|
@MainActor
|
|
static func run() async -> RemoteGatewayProbeResult {
|
|
AppStateStore.shared.syncGatewayConfigNow()
|
|
let settings = CommandResolver.connectionSettings()
|
|
let transport = AppStateStore.shared.remoteTransport
|
|
|
|
if transport == .direct {
|
|
let trimmedUrl = AppStateStore.shared.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedUrl.isEmpty else {
|
|
return .failed("Set a gateway URL first")
|
|
}
|
|
guard self.isValidWsUrl(trimmedUrl) else {
|
|
return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
|
|
}
|
|
} else {
|
|
let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedTarget.isEmpty else {
|
|
return .failed("Set an SSH target first")
|
|
}
|
|
if let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) {
|
|
return .failed(validationMessage)
|
|
}
|
|
guard let sshCommand = self.sshCheckCommand(target: settings.target, identity: settings.identity) else {
|
|
return .failed("SSH target is invalid")
|
|
}
|
|
|
|
let sshResult = await ShellExecutor.run(
|
|
command: sshCommand,
|
|
cwd: nil,
|
|
env: nil,
|
|
timeout: 8)
|
|
guard sshResult.ok else {
|
|
return .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
|
}
|
|
}
|
|
|
|
do {
|
|
_ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10_000)
|
|
let authSource = await GatewayConnection.shared.authSource()
|
|
return .ready(RemoteGatewayProbeSuccess(authSource: authSource))
|
|
} catch {
|
|
if let authIssue = RemoteGatewayAuthIssue(error: error) {
|
|
return .authIssue(authIssue)
|
|
}
|
|
return .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private static func isValidWsUrl(_ raw: String) -> Bool {
|
|
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
|
|
}
|
|
|
|
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
|
|
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
|
|
let options = [
|
|
"-o", "BatchMode=yes",
|
|
"-o", "ConnectTimeout=5",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "UpdateHostKeys=yes",
|
|
]
|
|
let args = CommandResolver.sshArguments(
|
|
target: parsed,
|
|
identity: identity,
|
|
options: options,
|
|
remoteCommand: ["echo", "ok"])
|
|
return ["/usr/bin/ssh"] + args
|
|
}
|
|
|
|
private static func formatSSHFailure(_ response: Response, target: String) -> String {
|
|
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
|
|
let trimmed = payload?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.split(whereSeparator: \.isNewline)
|
|
.joined(separator: " ")
|
|
if let trimmed,
|
|
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
|
{
|
|
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
|
return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again."
|
|
}
|
|
if let trimmed, !trimmed.isEmpty {
|
|
if let message = response.message, message.hasPrefix("exit ") {
|
|
return "SSH check failed: \(trimmed) (\(message))"
|
|
}
|
|
return "SSH check failed: \(trimmed)"
|
|
}
|
|
if let message = response.message {
|
|
return "SSH check failed (\(message))"
|
|
}
|
|
return "SSH check failed"
|
|
}
|
|
}
|