mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
macOS/onboarding: prompt for remote gateway auth tokens (#43100)
Merged via squash.
Prepared head SHA: 00e2ad847b
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
@@ -132,38 +132,17 @@ private let defaultOperatorConnectScopes: [String] = [
|
||||
]
|
||||
|
||||
private enum GatewayConnectErrorCodes {
|
||||
static let authTokenMismatch = "AUTH_TOKEN_MISMATCH"
|
||||
static let authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
|
||||
static let authTokenMissing = "AUTH_TOKEN_MISSING"
|
||||
static let authPasswordMissing = "AUTH_PASSWORD_MISSING"
|
||||
static let authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
|
||||
static let authRateLimited = "AUTH_RATE_LIMITED"
|
||||
static let pairingRequired = "PAIRING_REQUIRED"
|
||||
static let controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
|
||||
static let deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
|
||||
}
|
||||
|
||||
private struct GatewayConnectAuthError: LocalizedError {
|
||||
let message: String
|
||||
let detailCode: String?
|
||||
let canRetryWithDeviceToken: Bool
|
||||
|
||||
var errorDescription: String? { self.message }
|
||||
|
||||
var isNonRecoverable: Bool {
|
||||
switch self.detailCode {
|
||||
case GatewayConnectErrorCodes.authTokenMissing,
|
||||
GatewayConnectErrorCodes.authPasswordMissing,
|
||||
GatewayConnectErrorCodes.authPasswordMismatch,
|
||||
GatewayConnectErrorCodes.authRateLimited,
|
||||
GatewayConnectErrorCodes.pairingRequired,
|
||||
GatewayConnectErrorCodes.controlUiDeviceIdentityRequired,
|
||||
GatewayConnectErrorCodes.deviceIdentityRequired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
|
||||
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
|
||||
static let authTokenMissing = GatewayConnectAuthDetailCode.authTokenMissing.rawValue
|
||||
static let authTokenNotConfigured = GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue
|
||||
static let authPasswordMissing = GatewayConnectAuthDetailCode.authPasswordMissing.rawValue
|
||||
static let authPasswordMismatch = GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue
|
||||
static let authPasswordNotConfigured = GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue
|
||||
static let authRateLimited = GatewayConnectAuthDetailCode.authRateLimited.rawValue
|
||||
static let pairingRequired = GatewayConnectAuthDetailCode.pairingRequired.rawValue
|
||||
static let controlUiDeviceIdentityRequired = GatewayConnectAuthDetailCode.controlUiDeviceIdentityRequired.rawValue
|
||||
static let deviceIdentityRequired = GatewayConnectAuthDetailCode.deviceIdentityRequired.rawValue
|
||||
}
|
||||
|
||||
public actor GatewayChannelActor {
|
||||
@@ -278,8 +257,7 @@ public actor GatewayChannelActor {
|
||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||
self.reconnectPausedForAuthFailure = true
|
||||
self.logger.error(
|
||||
"gateway watchdog reconnect paused for non-recoverable auth failure " +
|
||||
"\(error.localizedDescription, privacy: .public)"
|
||||
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -522,10 +500,12 @@ public actor GatewayChannelActor {
|
||||
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
|
||||
let detailCode = details?["code"]?.value as? String
|
||||
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
|
||||
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
|
||||
throw GatewayConnectAuthError(
|
||||
message: msg,
|
||||
detailCode: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken)
|
||||
detailCodeRaw: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStepRaw: recommendedNextStep)
|
||||
}
|
||||
guard let payload = res.payload else {
|
||||
throw NSError(
|
||||
@@ -710,8 +690,7 @@ public actor GatewayChannelActor {
|
||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||
self.reconnectPausedForAuthFailure = true
|
||||
self.logger.error(
|
||||
"gateway reconnect paused for non-recoverable auth failure " +
|
||||
"\(error.localizedDescription, privacy: .public)"
|
||||
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -743,7 +722,7 @@ public actor GatewayChannelActor {
|
||||
return false
|
||||
}
|
||||
return authError.canRetryWithDeviceToken ||
|
||||
authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch
|
||||
authError.detail == .authTokenMismatch
|
||||
}
|
||||
|
||||
private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
|
||||
@@ -753,7 +732,7 @@ public actor GatewayChannelActor {
|
||||
if authError.isNonRecoverable {
|
||||
return true
|
||||
}
|
||||
if authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch &&
|
||||
if authError.detail == .authTokenMismatch &&
|
||||
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
|
||||
{
|
||||
return true
|
||||
@@ -765,7 +744,7 @@ public actor GatewayChannelActor {
|
||||
guard let authError = error as? GatewayConnectAuthError else {
|
||||
return false
|
||||
}
|
||||
return authError.detailCode == GatewayConnectErrorCodes.authDeviceTokenMismatch
|
||||
return authError.detail == .authDeviceTokenMismatch
|
||||
}
|
||||
|
||||
private func isTrustedDeviceRetryEndpoint() -> Bool {
|
||||
@@ -867,6 +846,9 @@ public actor GatewayChannelActor {
|
||||
|
||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||
private func wrap(_ error: Error, context: String) -> Error {
|
||||
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
|
||||
return error
|
||||
}
|
||||
if let urlError = error as? URLError {
|
||||
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
|
||||
return NSError(
|
||||
@@ -910,8 +892,7 @@ public actor GatewayChannelActor {
|
||||
return (id: id, data: data)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) " +
|
||||
"error=\(error.localizedDescription, privacy: .public)")
|
||||
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,112 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
|
||||
public enum GatewayConnectAuthDetailCode: String, Sendable {
|
||||
case authRequired = "AUTH_REQUIRED"
|
||||
case authUnauthorized = "AUTH_UNAUTHORIZED"
|
||||
case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
|
||||
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
|
||||
case authTokenMissing = "AUTH_TOKEN_MISSING"
|
||||
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
|
||||
case authPasswordMissing = "AUTH_PASSWORD_MISSING"
|
||||
case authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
|
||||
case authPasswordNotConfigured = "AUTH_PASSWORD_NOT_CONFIGURED"
|
||||
case authRateLimited = "AUTH_RATE_LIMITED"
|
||||
case authTailscaleIdentityMissing = "AUTH_TAILSCALE_IDENTITY_MISSING"
|
||||
case authTailscaleProxyMissing = "AUTH_TAILSCALE_PROXY_MISSING"
|
||||
case authTailscaleWhoisFailed = "AUTH_TAILSCALE_WHOIS_FAILED"
|
||||
case authTailscaleIdentityMismatch = "AUTH_TAILSCALE_IDENTITY_MISMATCH"
|
||||
case pairingRequired = "PAIRING_REQUIRED"
|
||||
case controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
|
||||
case deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
|
||||
case deviceAuthInvalid = "DEVICE_AUTH_INVALID"
|
||||
case deviceAuthDeviceIdMismatch = "DEVICE_AUTH_DEVICE_ID_MISMATCH"
|
||||
case deviceAuthSignatureExpired = "DEVICE_AUTH_SIGNATURE_EXPIRED"
|
||||
case deviceAuthNonceRequired = "DEVICE_AUTH_NONCE_REQUIRED"
|
||||
case deviceAuthNonceMismatch = "DEVICE_AUTH_NONCE_MISMATCH"
|
||||
case deviceAuthSignatureInvalid = "DEVICE_AUTH_SIGNATURE_INVALID"
|
||||
case deviceAuthPublicKeyInvalid = "DEVICE_AUTH_PUBLIC_KEY_INVALID"
|
||||
}
|
||||
|
||||
public enum GatewayConnectRecoveryNextStep: String, Sendable {
|
||||
case retryWithDeviceToken = "retry_with_device_token"
|
||||
case updateAuthConfiguration = "update_auth_configuration"
|
||||
case updateAuthCredentials = "update_auth_credentials"
|
||||
case waitThenRetry = "wait_then_retry"
|
||||
case reviewAuthConfiguration = "review_auth_configuration"
|
||||
}
|
||||
|
||||
/// Structured websocket connect-auth rejection surfaced before the channel is usable.
|
||||
public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
||||
public let message: String
|
||||
public let detailCodeRaw: String?
|
||||
public let recommendedNextStepRaw: String?
|
||||
public let canRetryWithDeviceToken: Bool
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
detailCodeRaw: String?,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStepRaw: String? = nil)
|
||||
{
|
||||
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedRecommendedNextStep =
|
||||
recommendedNextStepRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.message = trimmedMessage.isEmpty ? "gateway connect failed" : trimmedMessage
|
||||
self.detailCodeRaw = trimmedDetailCode?.isEmpty == false ? trimmedDetailCode : nil
|
||||
self.canRetryWithDeviceToken = canRetryWithDeviceToken
|
||||
self.recommendedNextStepRaw =
|
||||
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
|
||||
}
|
||||
|
||||
public init(
|
||||
message: String,
|
||||
detailCode: String?,
|
||||
canRetryWithDeviceToken: Bool,
|
||||
recommendedNextStep: String? = nil)
|
||||
{
|
||||
self.init(
|
||||
message: message,
|
||||
detailCodeRaw: detailCode,
|
||||
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||
recommendedNextStepRaw: recommendedNextStep)
|
||||
}
|
||||
|
||||
public var detailCode: String? { self.detailCodeRaw }
|
||||
|
||||
public var recommendedNextStepCode: String? { self.recommendedNextStepRaw }
|
||||
|
||||
public var detail: GatewayConnectAuthDetailCode? {
|
||||
guard let detailCodeRaw else { return nil }
|
||||
return GatewayConnectAuthDetailCode(rawValue: detailCodeRaw)
|
||||
}
|
||||
|
||||
public var recommendedNextStep: GatewayConnectRecoveryNextStep? {
|
||||
guard let recommendedNextStepRaw else { return nil }
|
||||
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
|
||||
}
|
||||
|
||||
public var errorDescription: String? { self.message }
|
||||
|
||||
public var isNonRecoverable: Bool {
|
||||
switch self.detail {
|
||||
case .authTokenMissing,
|
||||
.authTokenNotConfigured,
|
||||
.authPasswordMissing,
|
||||
.authPasswordMismatch,
|
||||
.authPasswordNotConfigured,
|
||||
.authRateLimited,
|
||||
.pairingRequired,
|
||||
.controlUiDeviceIdentityRequired,
|
||||
.deviceIdentityRequired:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
|
||||
public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
public let method: String
|
||||
|
||||
Reference in New Issue
Block a user