Files
openclaw/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift
Peter Steinberger a2c857ea0e fix: model setup detects CLI logins when AI access checks time out (#125114)
* fix(system-agent): detect CLI logins on setup timeout

* style(macos): format setup detection timeout

* fix(macos): bound setup restart reconciliation
2026-08-17 02:27:18 -07:00

1656 lines
72 KiB
Swift

import Foundation
import Observation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
/// Structured "Connect your AI" onboarding step.
///
/// Drives the gateway's `openclaw.setup.detect` / `openclaw.setup.activate`
/// RPCs: detect reusable AI access (CLI logins, provider credentials, and local model
/// servers), live-test candidates in the detected order, and automatically fall
/// through when one fails. Config is only written server-side after a
/// candidate actually answered, so this page can never strand the user with a
/// broken model.
@MainActor
@Observable
final class OnboardingAISetupModel {
static let setupDetectionRequestTimeoutMs = 40000
/// Device-code providers advertise windows up to 15 minutes. Keep transport
/// alive long enough for approval plus the post-login inference probe.
static let providerAuthRequestTimeoutMs: Double = 1_200_000
private(set) var phase: Phase = .idle {
didSet {
// Close-guard: quitting mid-test is confirmable, not silent.
OnboardingController.shared.busyReason = if self.phase == .testing {
"OpenClaw is testing your AI connection."
} else if self.activeAuthOption != nil {
self.isPreparingModel
? "OpenClaw is preparing a local model."
: "OpenClaw is completing provider sign-in."
} else {
nil
}
}
}
private(set) var candidates: [Candidate] = []
private(set) var unavailableCandidates: [UnavailableCandidate] = []
private(set) var manualProviders: [ManualProvider] = []
private(set) var authOptions: [AuthOption] = []
private(set) var recommendedInstalls: [RecommendedInstall] = []
private(set) var detectedPrepareOptions: [PrepareOption]?
private(set) var prepareAvailable = false
private(set) var candidatePresentation: [String: CandidatePresentation] = [:]
private(set) var activeAuthOption: AuthOption?
private(set) var providerWizardKind: ProviderWizardKind?
private(set) var authStep: WizardStep?
private(set) var authError: Failure?
private(set) var authBusy = false {
didSet {
if self.activeAuthOption != nil {
OnboardingController.shared.busyReason = self.isPreparingModel
? "OpenClaw is preparing a local model."
: "OpenClaw is completing provider sign-in."
} else if self.phase != .testing {
OnboardingController.shared.busyReason = nil
}
}
}
var authText = ""
var authSelection = 0
var authConfirmation = true
private(set) var providerCatalogLoaded = false
private(set) var providerCatalogError: String?
private(set) var statuses: [String: CandidateStatus] = [:]
private(set) var selectedKind: String?
private(set) var detectError: Failure?
private(set) var pendingActivationVerification = false
private(set) var waitingForPendingActivationDeadline = false
private(set) var configuredGatewayBlocker: ConfiguredGatewayBlocker?
/// Set once every detected candidate failed; opens the manual key form.
private(set) var exhaustedAutoCandidates = false
var manualProviderID = ""
var manualKey: String = ""
private(set) var manualTesting = false
private(set) var manualError: Failure?
var showManualEntry = false
/// Called when a candidate connects so the page can advance.
var onConnected: (() -> Void)?
/// Called whenever setup enters the read-only wait for an ambiguous
/// activation lease. The view owns the route-bound, coalesced timer.
var onPendingActivationDeadline: ((Date, String) -> Void)?
private let gateway: GatewayConnection
private let defaults: UserDefaults
private let routeIdentityProvider: @MainActor () -> String?
private let connectionModeProvider: @MainActor () -> AppState.ConnectionMode
private var started = false
private var attemptToken = UUID()
@ObservationIgnored private var pendingVerification: PendingVerification?
@ObservationIgnored private var pendingActivationOwner: OnboardingSystemAgentResumeStore.ActivationOwner?
@ObservationIgnored private var completedHandoff: CompletedHandoff?
@ObservationIgnored private var pendingActivationRequiresFreshActivation = false
@ObservationIgnored private var serverLease: GatewayConnection.ServerLease?
@ObservationIgnored private var lastDetectedActivationState: PersistedActivationState?
@ObservationIgnored private var authSessionID: String?
@ObservationIgnored private var authAttemptID = UUID()
/// Only a just-completed provider flow may trust setupComplete without re-probing.
@ObservationIgnored private var providerAuthReconciliationPending = false
init(
gateway: GatewayConnection = .shared,
defaults: UserDefaults = AppDefaults.standard,
routeIdentityProvider: @escaping @MainActor () -> String? = {
OnboardingSystemAgentResumeStore.selectedRouteIdentity()
},
connectionModeProvider: @escaping @MainActor () -> AppState.ConnectionMode = {
AppStateStore.shared.connectionMode
})
{
self.gateway = gateway
self.defaults = defaults
self.routeIdentityProvider = routeIdentityProvider
self.connectionModeProvider = connectionModeProvider
}
func startIfNeeded() {
if self.waitingForPendingActivationDeadline {
self.resetForGatewayChange(clearPendingHandoff: false)
}
guard !self.started else { return }
self.configuredGatewayBlocker = nil
self.started = true
self.phase = .detecting
scheduleDetection()
}
func retryFromScratch() {
// The configured-Gateway preflight has its own read-only retry. Never
// turn an unavailable agents.list response into setup mutation.
guard self.configuredGatewayBlocker == nil else { return }
guard !self.waitingForPendingActivationDeadline else { return }
if self.pendingActivationVerification {
Task { await self.verifyPendingConfiguredInference() }
return
}
self.resetForGatewayChange()
self.started = true
self.phase = .detecting
scheduleDetection()
}
func waitForPendingActivationDeadline() {
guard !self.connected,
self.phase != .testing,
!self.manualTesting,
!self.pendingActivationVerification,
let routeIdentity = routeIdentityProvider(),
let deadline = activePendingActivationDeadline(for: routeIdentity)
else { return }
if !self.waitingForPendingActivationDeadline {
self.resetForGatewayChange(clearPendingHandoff: false)
}
self.beginPendingActivationDeadlineWait(
deadline: deadline,
routeIdentity: routeIdentity)
}
func updateConfiguredGatewayBlockerState(
_ blocker: ConfiguredGatewayBlocker?,
phase: Phase,
detectError: Failure?)
{
self.configuredGatewayBlocker = blocker
self.phase = phase
self.detectError = detectError
}
/// Restore only the pending handoff state. A configured model label is not
/// proof that the ambiguous activation completed or that inference works.
func resumeConfiguredInference(modelRef: String) {
let model = modelRef.trimmingCharacters(in: .whitespacesAndNewlines)
guard !model.isEmpty else { return }
if self.waitingForPendingActivationDeadline {
self.resetForGatewayChange(clearPendingHandoff: false)
}
// Reconnects and page changes can discover the same pending handoff
// repeatedly. Keep the first attempt and let every caller await it.
guard !self.ownsInferenceTransition else { return }
let routeIdentity = self.routeIdentityProvider()
let pendingState = OnboardingSystemAgentResumeStore.pendingState(
for: routeIdentity,
defaults: self.defaults)
let inMemoryOwner = self.pendingActivationOwner
let restoredOwner = OnboardingSystemAgentResumeStore.activationOwner(
for: routeIdentity,
defaults: self.defaults)
let activationOwner = inMemoryOwner ?? restoredOwner
// A completed receipt may resume only after live inference and an exact
// owner check. Other relaunched states must repeat activation because a
// model label alone does not prove which attempt committed it.
let requiresFreshActivation = inMemoryOwner != nil || pendingState != .none
self.resetForGatewayChange(clearPendingHandoff: false)
// resetForGatewayChange retires the async attempt but the route-owned
// durable receipt above must survive into this reconciliation attempt.
self.pendingActivationOwner = activationOwner
self.pendingActivationRequiresFreshActivation = requiresFreshActivation
self.started = true
self.pendingActivationVerification = true
self.phase = .detecting
}
/// Reconcile an ambiguous activation on the same Gateway route. A live turn
/// is necessary, but only a matching durable completion receipt may hand off;
/// otherwise setup repeats a fresh activate round-trip.
@discardableResult
func verifyPendingConfiguredInference() async -> PendingVerificationOutcome {
guard self.pendingActivationVerification,
let context = captureAttemptContext()
else { return .superseded }
if let pendingVerification, pendingVerification.context == context {
let outcome = await pendingVerification.task.value
guard isCurrentAttempt(context), !Task.isCancelled else { return .superseded }
return outcome
}
let task = Task { @MainActor [weak self] in
guard let self else { return PendingVerificationOutcome.superseded }
return await self.performPendingConfiguredInferenceVerification(context: context)
}
pendingVerification = PendingVerification(context: context, task: task)
let outcome = await task.value
if pendingVerification?.context == context {
pendingVerification = nil
}
guard isCurrentAttempt(context), !Task.isCancelled else { return .superseded }
if outcome == .freshSetupAllowed, isCurrentAttempt(context) {
self.resetForGatewayChange(clearPendingHandoff: false)
self.startIfNeeded()
}
return outcome
}
private func performPendingConfiguredInferenceVerification(
context: AttemptContext) async -> PendingVerificationOutcome
{
guard self.pendingActivationVerification, isCurrentAttempt(context), !Task.isCancelled else {
return .superseded
}
self.phase = .detecting
self.detectError = nil
let lease: GatewayConnection.ServerLease
do {
lease = try await self.gateway.acquireServerLease()
} catch {
guard isCurrentAttempt(context), !Task.isCancelled else { return .superseded }
self.phase = .ready
self.detectError = Self.transportFailure(
"The selected Gateway changed before inference could be verified. Try again.")
return self.pendingVerificationFailureOutcome(context: context)
}
guard isCurrentAttempt(context),
!Task.isCancelled,
await self.gateway.isCurrentServerLease(lease)
else { return .superseded }
if let activationOwner = pendingActivationOwner {
if activationOwner.isUnbound {
// Unbound receipts never resume across relaunch or verification
// retry; a fresh activation is the only safe continuation.
self.pendingActivationVerification = false
clearPendingHandoff(ifOwnedBy: context)
return .freshSetupAllowed
}
guard let currentFingerprint = await gateway.activationOwnershipFingerprint(
ifCurrentServerLease: lease)
else {
self.phase = .ready
self.detectError = Self.transportFailure(
"Secure storage is unavailable, so OpenClaw cannot verify which Gateway completed AI setup.")
return .notConnected
}
guard activationOwner.routeFingerprint == currentFingerprint else {
switch OnboardingSystemAgentResumeStore.pendingState(
for: context.routeIdentity,
defaults: self.defaults)
{
case let .activating(deadline), let .verified(deadline):
// Replacement auth cannot verify this owner, but the old
// activation may still mutate the same route. Keep its lease.
self.pendingActivationVerification = false
self.beginPendingActivationDeadlineWait(
deadline: deadline,
routeIdentity: context.routeIdentity)
return .notConnected
case .activationExpired, .completed, .none:
// No live mutation remains to overlap. Retire only this
// owner, then let the replacement credentials start fresh.
OnboardingSystemAgentResumeStore.clear(
ifOwnedBy: context.routeIdentity,
activationOwner: activationOwner,
defaults: self.defaults)
self.pendingActivationVerification = false
self.phase = .ready
self.detectError = Self.transportFailure(
"The Gateway authentication changed while AI setup was finishing. Testing it again.")
return .freshSetupAllowed
}
}
}
do {
let data = try await gateway.request(
method: "openclaw.setup.verify",
params: [:],
timeoutMs: 150_000,
ifCurrentServerLease: lease)
guard await self.gateway.isCurrentServerLease(lease),
isCurrentAttempt(context),
!Task.isCancelled
else { return .superseded }
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
if result.ok, let modelRef = result.modelRef {
let pendingState = OnboardingSystemAgentResumeStore.pendingState(
for: context.routeIdentity,
defaults: self.defaults)
switch pendingState {
case let .activating(deadline), let .verified(deadline):
// This proves inference works, but not that the dropped
// activation stopped mutating. Preserve its deadline.
OnboardingSystemAgentResumeStore.markVerified(
ifOwnedBy: context.routeIdentity,
activationOwner: self.pendingActivationOwner,
defaults: self.defaults)
self.pendingActivationVerification = false
self.detectError = nil
self.beginPendingActivationDeadlineWait(
deadline: deadline,
routeIdentity: context.routeIdentity)
return .notConnected
case .activationExpired, .none:
if self.pendingActivationRequiresFreshActivation {
self.pendingActivationVerification = false
clearPendingHandoff(ifOwnedBy: context)
return .freshSetupAllowed
}
case .completed:
guard let receiptOwner = self.pendingActivationOwner, !receiptOwner.isUnbound
else {
// Ownerless and unbound receipts carry no auth binding,
// so they can belong to replaced credentials on this
// route. Never let one authorize a handoff — repeat a
// fresh activation instead.
self.pendingActivationVerification = false
clearPendingHandoff(ifOwnedBy: context)
return .freshSetupAllowed
}
finishConnected(
kind: "existing-model",
activationOwner: self.pendingActivationOwner,
requireExistingReceipt: true)
if self.connected {
return .connected
}
// The receipt owner changed while verification was in flight.
// Adopt it only for a fresh verification; this result cannot attest it.
self.retainCompletedReceiptForRetry(context: context)
return .notConnected
}
self.acceptVerifiedPendingInference(modelRef: modelRef)
return self.connected ? .connected : .superseded
}
self.phase = .ready
self.detectError = Self.failure(
label: "Configured AI",
status: result.status,
error: result.error)
return self.pendingVerificationFailureOutcome(context: context)
} catch {
guard isCurrentAttempt(context), !Task.isCancelled else { return .superseded }
// A failed read-only verification never proves activation failed.
// Keep the marker and let Try again repeat this same verification.
self.phase = .ready
self.detectError = Self.transportFailure(error.localizedDescription)
return self.pendingVerificationFailureOutcome(context: context)
}
}
private func pendingVerificationFailureOutcome(
context: AttemptContext) -> PendingVerificationOutcome
{
switch OnboardingSystemAgentResumeStore.pendingState(
for: context.routeIdentity,
defaults: self.defaults)
{
case let .activating(deadline), let .verified(deadline):
// The dropped activation may still be writing config or credentials.
// Verification may repeat, but mutation stays blocked until its lease ends.
if let activationOwner = pendingActivationOwner,
!OnboardingSystemAgentResumeStore.isOwned(
by: activationOwner,
for: context.routeIdentity,
defaults: defaults)
{
self.pendingActivationVerification = false
self.beginPendingActivationDeadlineWait(
deadline: deadline,
routeIdentity: context.routeIdentity)
return .notConnected
}
self.pendingActivationVerification = true
return .notConnected
case .completed:
// Completion is durable proof that activation returned success. A
// read-only transport failure cannot authorize replacement setup.
self.retainCompletedReceiptForRetry(context: context)
return .notConnected
case .activationExpired, .none:
self.pendingActivationVerification = false
clearPendingHandoff(ifOwnedBy: context)
return .freshSetupAllowed
}
}
private func retainCompletedReceiptForRetry(context: AttemptContext) {
self.pendingActivationOwner = OnboardingSystemAgentResumeStore.activationOwner(
for: context.routeIdentity,
defaults: self.defaults)
self.pendingActivationRequiresFreshActivation = true
self.pendingActivationVerification = true
}
private func activePendingActivationDeadline(for routeIdentity: String) -> Date? {
switch OnboardingSystemAgentResumeStore.pendingState(
for: routeIdentity,
defaults: self.defaults)
{
case let .activating(deadline), let .verified(deadline):
deadline
case .activationExpired, .completed, .none:
nil
}
}
private func beginPendingActivationDeadlineWait(
deadline: Date,
routeIdentity: String)
{
self.waitingForPendingActivationDeadline = true
self.phase = .detecting
self.onPendingActivationDeadline?(deadline, routeIdentity)
}
private func retainAmbiguousActivation(
ifOwnedBy context: AttemptContext,
activationOwner: OnboardingSystemAgentResumeStore.ActivationOwner,
activationDeadline: Date)
{
guard isCurrentAttempt(context) else { return }
self.pendingActivationVerification = true
switch OnboardingSystemAgentResumeStore.pendingState(
for: context.routeIdentity,
defaults: self.defaults)
{
case let .activating(deadline), let .verified(deadline):
guard OnboardingSystemAgentResumeStore.isOwned(
by: activationOwner,
for: context.routeIdentity,
defaults: self.defaults)
else {
// Another process replaced this lease. Never let our result
// complete or clear the newer activation.
self.pendingActivationVerification = false
self.beginPendingActivationDeadlineWait(
deadline: deadline,
routeIdentity: context.routeIdentity)
return
}
self.beginPendingActivationDeadlineWait(
deadline: deadline,
routeIdentity: context.routeIdentity)
case .none:
// A concurrent read-only probe can clear the marker while the
// dispatched handler is still returning. Restore route ownership
// before probing so failure or relaunch cannot start a duplicate.
OnboardingSystemAgentResumeStore.restorePending(
routeIdentity: context.routeIdentity,
activationOwner: activationOwner,
deadline: activationDeadline,
defaults: self.defaults)
self.beginPendingActivationDeadlineWait(
deadline: Date(),
routeIdentity: context.routeIdentity)
case .activationExpired, .completed:
// The marker no longer blocks mutation, but the dispatched handler
// may still commit. Probe immediately so only observed Gateway
// state can decide when a fresh activation is safe.
self.beginPendingActivationDeadlineWait(
deadline: Date(),
routeIdentity: context.routeIdentity)
}
}
/// Complete a receipt-backed restored handoff after route-bound live inference.
func acceptVerifiedPendingInference(modelRef: String) {
let model = modelRef.trimmingCharacters(in: .whitespacesAndNewlines)
guard self.pendingActivationVerification, !model.isEmpty else { return }
guard self.pendingActivationOwner == nil else { return }
finishConnected(
kind: "existing-model",
activationOwner: self.pendingActivationOwner)
}
/// Clear only the completed receipt created by this setup attempt.
/// A replacement activation on the same route retains its own receipt.
func clearCompletedHandoffIfOwned() {
guard let completedHandoff else { return }
OnboardingSystemAgentResumeStore.clear(
ifOwnedBy: completedHandoff.routeIdentity,
activationOwner: completedHandoff.activationOwner,
defaults: self.defaults)
self.completedHandoff = nil
}
/// Cancel route-bound work and discard results that belong to the previous Gateway.
func resetForGatewayChange(clearPendingHandoff: Bool = true) {
let authSessionToCancel = self.authSessionID
let authServerLease = self.serverLease
if clearPendingHandoff, let routeIdentity = routeIdentityProvider() {
OnboardingSystemAgentResumeStore.clear(
ifOwnedBy: routeIdentity,
activationOwner: self.pendingActivationOwner,
defaults: self.defaults)
}
self.attemptToken = UUID()
self.pendingVerification?.task.cancel()
self.pendingVerification = nil
self.pendingActivationOwner = nil
self.completedHandoff = nil
self.pendingActivationRequiresFreshActivation = false
self.lastDetectedActivationState = nil
self.started = false
self.phase = .idle
self.candidates = []
self.unavailableCandidates = []
self.manualProviders = []
self.authOptions = []
self.recommendedInstalls = []
self.detectedPrepareOptions = nil
self.prepareAvailable = false
self.candidatePresentation = [:]
self.activeAuthOption = nil
self.providerWizardKind = nil
self.authStep = nil
self.authError = nil
self.authBusy = false
self.authText = ""
self.authSessionID = nil
self.authAttemptID = UUID()
self.providerAuthReconciliationPending = false
self.providerCatalogLoaded = false
self.providerCatalogError = nil
self.statuses = [:]
self.selectedKind = nil
self.detectError = nil
self.pendingActivationVerification = false
self.waitingForPendingActivationDeadline = false
self.configuredGatewayBlocker = nil
self.exhaustedAutoCandidates = false
self.serverLease = nil
self.manualProviderID = ""
self.manualKey = ""
self.manualError = nil
self.manualTesting = false
self.showManualEntry = false
if let authSessionToCancel, let authServerLease {
Task {
await self.gateway.cancelWizardSession(authSessionToCancel, on: authServerLease)
}
}
}
}
extension OnboardingAISetupModel {
func detectAndAutoConnect() async {
guard let context = captureAttemptContext() else {
self.failDetectionForMissingRoute()
return
}
await self.detectAndAutoConnect(context: context)
}
private func scheduleDetection(
preparedChoiceID: String? = nil,
preparedProviderLabel: String? = nil)
{
guard let context = captureAttemptContext() else {
self.failDetectionForMissingRoute()
return
}
Task {
await self.detectAndAutoConnect(
context: context,
preparedChoiceID: preparedChoiceID,
preparedProviderLabel: preparedProviderLabel)
}
}
private func detectAndAutoConnect(
context: AttemptContext,
preparedChoiceID: String? = nil,
preparedProviderLabel: String? = nil) async
{
// Gateway awaits can yield to a route reset or cancellation. Revalidate
// before every activation side effect so stale attempts cannot hand off.
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
self.phase = .detecting
self.detectError = nil
self.providerCatalogError = nil
do {
let lease = try await gateway.acquireServerLease()
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
let data = try await gateway.request(
method: "openclaw.setup.detect",
params: [:],
timeoutMs: Double(Self.setupDetectionRequestTimeoutMs),
ifCurrentServerLease: lease)
guard await self.gateway.isCurrentServerLease(lease),
self.isCurrentAttempt(context),
!Task.isCancelled
else { return }
let result = try JSONDecoder().decode(DetectResult.self, from: data)
let prepareAvailable = await self.gateway.supportsServerMethod(
"openclaw.setup.prepare.start",
ifCurrentServerLease: lease) == true
guard await self.gateway.isCurrentServerLease(lease),
self.isCurrentAttempt(context),
!Task.isCancelled
else { return }
self.serverLease = lease
self.prepareAvailable = prepareAvailable
self.lastDetectedActivationState = result.persistedActivationState
let manualProviders = result.manualProviders ?? []
let authOptions = result.authOptions ?? []
self.authOptions = authOptions
self.recommendedInstalls = result.recommendedInstalls ?? []
self.detectedPrepareOptions = result.prepareOptions
self.candidatePresentation = Dictionary(
result.candidates.map { candidate in
(
candidate.kind,
CandidatePresentation(
brandId: candidate.brandId,
icon: candidate.icon,
website: candidate.website))
},
uniquingKeysWith: { current, _ in current })
let providerAuthReconciliationPending = self.providerAuthReconciliationPending
self.providerAuthReconciliationPending = false
if Self.canAcceptProviderAuthReconciliation(
pending: providerAuthReconciliationPending,
setupComplete: result.setupComplete == true,
configuredModel: result.configuredModel)
{
finishConnected(kind: "provider-auth")
return
}
self.candidates = result.candidates.map { detected in
Candidate(
kind: detected.kind,
label: detected.label,
detail: detected.detail,
modelRef: detected.modelRef,
credentials: detected.credentials)
}
self.manualProviders = manualProviders
self.providerCatalogLoaded = result.manualProviders != nil
if result.manualProviders == nil {
self.providerCatalogError = OnboardingAISetupError.providerCatalogUnavailable.localizedDescription
}
self.unavailableCandidates = result.unavailableCandidates ?? []
if !manualProviders.contains(where: { $0.id == self.manualProviderID }) {
self.manualProviderID = manualProviders.first?.id ?? ""
}
for candidate in self.candidates {
self.statuses[candidate.kind] = .untried
}
self.phase = .ready
if let preparedChoiceID {
// Detection kinds encode the provider-auth choice ID, while
// PrepareOption.brandId owns the model-ref namespace.
let preparedKind = Self.providerAutoSetupKind(choiceID: preparedChoiceID)
if let prepared = candidates.first(where: {
$0.kind == preparedKind && $0.credentials != false
}) {
await self.activate(kind: prepared.kind, context: context)
} else {
let label = preparedProviderLabel ?? preparedChoiceID
self.detectError = Self.failure(
label: label,
status: "unavailable",
error: "\(label) did not expose a usable local model. Review setup, then retry.")
self.showManualEntry = !self.manualProviders.isEmpty
}
return
}
if let first = autoCandidateAfter(kind: nil) {
await self.activate(kind: first.kind, context: context)
} else {
self.showManualEntry = !self.manualProviders.isEmpty
}
} catch {
guard self.isCurrentAttempt(context) else { return }
if self.connectionModeProvider() == .remote, let authIssue = RemoteGatewayAuthIssue(error: error) {
self.enterGatewayAuthBlocker(authIssue)
return
}
self.phase = .ready
self.detectError = Self.transportFailure(error.localizedDescription)
self.showManualEntry = self.candidates.isEmpty
}
}
private func captureAttemptContext(
supersededAttemptDeadline: Date? = nil) -> AttemptContext?
{
let identity = self.routeIdentityProvider()?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let identity, !identity.isEmpty else { return nil }
return AttemptContext(
token: self.attemptToken,
routeIdentity: identity,
supersededAttemptDeadline: supersededAttemptDeadline)
}
private func beginAttemptContext(
supersededAttemptDeadline: Date? = nil) -> AttemptContext?
{
self.attemptToken = UUID()
return self.captureAttemptContext(supersededAttemptDeadline: supersededAttemptDeadline)
}
private func isCurrentAttempt(_ context: AttemptContext) -> Bool {
context.token == self.attemptToken &&
self.routeIdentityProvider()?.trimmingCharacters(in: .whitespacesAndNewlines) == context.routeIdentity
}
private func clearPendingHandoff(
ifOwnedBy context: AttemptContext,
activationOwner: OnboardingSystemAgentResumeStore.ActivationOwner? = nil)
{
guard self.isCurrentAttempt(context) else { return }
OnboardingSystemAgentResumeStore.clear(
ifOwnedBy: context.routeIdentity,
activationOwner: activationOwner ?? self.pendingActivationOwner,
defaults: self.defaults)
}
private func failDetectionForMissingRoute() {
self.phase = .ready
self.detectError = Self.transportFailure(
"No Gateway is selected. Select a Gateway, then try again.")
}
/// Candidates the automatic ladder may try: skip definitively logged-out
/// installs and anything already attempted.
private func autoCandidateAfter(kind: String?) -> Candidate? {
let startIndex: Int = if let kind, let index = candidates.firstIndex(where: { $0.kind == kind }) {
index + 1
} else {
0
}
guard startIndex <= self.candidates.count else { return nil }
return self.candidates[startIndex...].first { candidate in
candidate.credentials != false && self.statuses[candidate.kind] == .untried
}
}
func userSelect(kind: String) {
guard self.canSelectCandidate(kind: kind) else { return }
var supersededKind: String?
var supersededAttemptDeadline: Date?
if self.phase == .testing, let selectedKind = self.selectedKind {
// A user pick supersedes auto-testing; the attempt token rejects every late result.
supersededKind = selectedKind
let routeIdentity = self.routeIdentityProvider()
supersededAttemptDeadline = routeIdentity.flatMap {
self.activePendingActivationDeadline(for: $0)
}
?? Date().addingTimeInterval(
Self.activationRequestTimeoutMs(for: selectedKind) / 1000 + 5)
}
guard let context = beginAttemptContext(
supersededAttemptDeadline: supersededAttemptDeadline)
else { return }
// A fresh user-picked attempt owns the verdict; keeping the stale
// "none of the found options worked" card up during its test would
// contradict the visible Testing state.
self.exhaustedAutoCandidates = false
if let supersededKind {
self.statuses[supersededKind] = .untried
self.selectedKind = kind
self.statuses[kind] = .testing
}
Task {
await self.activate(kind: kind, context: context)
}
}
func activate(kind: String) async {
guard !self.pendingActivationVerification else { return }
guard let context = captureAttemptContext() else {
self.statuses[kind] = .failed(Self.transportFailure(
"No Gateway is selected. Select a Gateway, then try again."))
self.phase = .ready
return
}
await self.activate(kind: kind, context: context)
}
private func activate(kind: String, context: AttemptContext) async {
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
guard let candidate = candidates.first(where: { $0.kind == kind })
else {
requireFreshDetection(after: Self.transportFailure(
"The Gateway connection changed. Check for AI accounts again."))
return
}
await self.activate(
kind: kind,
modelRef: candidate.modelRef,
label: candidate.label,
tryNextCandidateOnFailure: true,
context: context)
}
private func activate(
kind: String,
modelRef: String,
label: String,
tryNextCandidateOnFailure: Bool,
context: AttemptContext) async
{
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
guard let lease = serverLease,
await gateway.isCurrentServerLease(lease)
else {
requireFreshDetection(after: Self.transportFailure(
"The Gateway connection changed. Check for AI accounts again."))
return
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
let persistedStateBeforeActivation = self.lastDetectedActivationState
let requestTimeoutMs = Self.activationRequestTimeoutMs(for: kind)
self.selectedKind = kind
self.phase = .testing
self.statuses[kind] = .testing
guard let supportsExactModel = await gateway.supportsServerCapability(
.systemAgentSetupModelRef,
ifCurrentServerLease: lease),
isCurrentAttempt(context),
!Task.isCancelled
else {
requireFreshDetection(after: Self.transportFailure(
"The Gateway connection changed. Check for AI accounts again."))
return
}
let routeFingerprint = await gateway.activationOwnershipFingerprint(
ifCurrentServerLease: lease)
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
let params = Self.activationParams(
kind: kind,
modelRef: modelRef,
supportsExactModel: supportsExactModel)
// Keychain-unavailable degrades to an unbound per-attempt lease instead
// of refusing setup: live matching stays attempt-exact, and relaunch
// repeats activation rather than trusting the receipt, so a broken
// login keychain cannot dead-end onboarding.
let activationOwner = routeFingerprint.map { fingerprint in
OnboardingSystemAgentResumeStore.ActivationOwner(
id: UUID().uuidString,
routeFingerprint: fingerprint)
} ?? .unbound()
self.pendingActivationOwner = activationOwner
self.pendingActivationRequiresFreshActivation = true
let supersededWaitMs = max(
0,
(context.supersededAttemptDeadline?.timeIntervalSinceNow ?? 0) * 1000)
// Activation can persist before the response reaches the app. Cover the
// whole ambiguous window so relaunch can inspect the actual Gateway state.
guard let activationDeadline = OnboardingSystemAgentResumeStore.markPending(
routeIdentity: context.routeIdentity,
activationOwner: activationOwner,
activationTimeoutMs: requestTimeoutMs + supersededWaitMs,
defaults: defaults)
else {
let failure = Self.transportFailure(
"No Gateway is selected. Select a Gateway, then try again.")
self.statuses[kind] = .failed(failure)
self.exposeActivationFailure(failure, whenTerminal: !tryNextCandidateOnFailure)
self.phase = .ready
return
}
guard !Task.isCancelled else {
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
self.phase = .ready
return
}
do {
let data = try await self.requestActivation(
params: params,
timeoutMs: requestTimeoutMs,
serverLease: lease,
context: context)
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
guard await self.gateway.isCurrentServerLease(lease) else {
if result.ok,
OnboardingSystemAgentResumeStore.markCompleted(
ifOwnedBy: context.routeIdentity,
activationOwner: activationOwner,
defaults: self.defaults)
{
self.pendingActivationVerification = true
self.phase = .detecting
_ = await self.verifyPendingConfiguredInference()
} else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
requireFreshDetection(after: Self.transportFailure(
"The Gateway connection changed while AI setup was finishing. Check again."))
}
return
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
if result.ok {
finishConnected(kind: kind, activationOwner: activationOwner)
} else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
let failure = Self.failure(
label: label,
status: result.status,
error: result.error)
self.statuses[kind] = .failed(failure)
if tryNextCandidateOnFailure {
await tryNextAfterFailure(of: kind, context: context)
} else {
self.phase = .ready
self.detectError = failure
self.showManualEntry = !self.manualProviders.isEmpty
}
}
} catch {
guard self.isCurrentAttempt(context) else { return }
// Cancellation, decoding, and transport failures after dispatch are
// ambiguous. Keep the marker; model-label detection is not proof that
// this activation and its credential mutation completed safely.
let failure = Self.transportFailure(error.localizedDescription)
self.statuses[kind] = .failed(failure)
self.exposeActivationFailure(failure, whenTerminal: !tryNextCandidateOnFailure)
if Self.activationFailureIsDefinitive(error) {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
if await self.gateway.isCurrentServerLease(lease) {
self.phase = .ready
} else {
requireFreshDetection(after: failure)
}
} else {
// A managed Gateway can restart after persisting fresh-Mac Codex setup.
// The retired process cannot mutate further, so accept only the same
// route/auth owner, an exact persisted transition, and a fresh live turn.
// Unbound (keychain-unavailable) leases cannot prove ownership;
// reconciliation's fingerprint guard rejects them and setup
// falls through to the deadline probe instead.
if !Task.isCancelled,
await !(self.gateway.isCurrentServerLease(lease)),
await self.reconcileActivationAfterGatewayRestart(
kind: kind,
expectedModel: modelRef,
context: context,
activationOwner: activationOwner,
before: persistedStateBeforeActivation,
originalServerLease: lease)
{
return
}
// Do not start another provider while the request can still commit.
// The route-bound deadline probe decides whether setup may resume.
self.retainAmbiguousActivation(
ifOwnedBy: context,
activationOwner: activationOwner,
activationDeadline: activationDeadline)
}
}
}
private func requestActivation(
params: [String: AnyCodable],
timeoutMs: Double,
serverLease: GatewayConnection.ServerLease,
context: AttemptContext) async throws -> Data
{
var retryDelayMs: UInt64 = 250
while true {
guard self.isCurrentAttempt(context), !Task.isCancelled else {
throw CancellationError()
}
do {
return try await self.gateway.request(
method: "openclaw.setup.activate",
params: params,
timeoutMs: timeoutMs,
ifCurrentServerLease: serverLease)
} catch {
guard let supersededAttemptDeadline = context.supersededAttemptDeadline,
Date() < supersededAttemptDeadline,
Self.activationAdmissionIsBusy(error)
else { throw error }
try await Task.sleep(nanoseconds: retryDelayMs * 1_000_000)
retryDelayMs = min(retryDelayMs * 2, 5000)
}
}
}
private func exposeActivationFailure(_ failure: Failure, whenTerminal: Bool) {
guard whenTerminal else { return }
self.detectError = failure
}
private func reconcileActivationAfterGatewayRestart(
kind: String,
expectedModel: String,
context: AttemptContext,
activationOwner: OnboardingSystemAgentResumeStore.ActivationOwner,
before: PersistedActivationState?,
originalServerLease: GatewayConnection.ServerLease) async -> Bool
{
let deadline = ReconciliationDeadline(timeout: .seconds(45))
var delayMs = 250
while deadline.hasTimeRemaining {
guard self.isCurrentAttempt(context), !Task.isCancelled else { return false }
let leaseTimeoutMs = deadline.remainingMilliseconds(cappedAt: 3000)
guard leaseTimeoutMs > 0 else { return false }
if let replacementLease = try? await gateway.acquireServerLease(
ifSameRouteAs: originalServerLease,
timeoutMs: Double(leaseTimeoutMs)),
await reconcilePersistedActivation(
kind: kind,
expectedModel: expectedModel,
context: context,
activationOwner: activationOwner,
before: before,
serverLease: replacementLease,
deadline: deadline)
{
self.serverLease = replacementLease
return true
}
let sleepMs = deadline.remainingMilliseconds(cappedAt: delayMs)
guard sleepMs > 0 else { return false }
do {
try await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
} catch {
return false
}
delayMs = min(delayMs * 2, 2000)
}
return false
}
private func reconcilePersistedActivation(
kind: String,
expectedModel: String,
context: AttemptContext,
activationOwner: OnboardingSystemAgentResumeStore.ActivationOwner,
before: PersistedActivationState?,
serverLease: GatewayConnection.ServerLease,
deadline: ReconciliationDeadline) async -> Bool
{
let detectTimeoutMs = deadline.remainingMilliseconds(
cappedAt: Self.setupDetectionRequestTimeoutMs)
guard detectTimeoutMs > 0,
self.isCurrentAttempt(context),
!Task.isCancelled,
OnboardingSystemAgentResumeStore.isOwned(
by: activationOwner,
for: context.routeIdentity,
defaults: self.defaults),
await self.gateway.activationOwnershipFingerprint(ifCurrentServerLease: serverLease) ==
activationOwner.routeFingerprint
else { return false }
guard let detectData = try? await gateway.request(
method: "openclaw.setup.detect",
params: [:],
timeoutMs: Double(detectTimeoutMs),
ifCurrentServerLease: serverLease),
await gateway.isCurrentServerLease(serverLease),
isCurrentAttempt(context),
!Task.isCancelled,
let detection = try? JSONDecoder().decode(DetectResult.self, from: detectData),
Self.activationTransitionWasPersisted(
expectedModel: expectedModel,
before: before,
after: detection.persistedActivationState)
else { return false }
let verifyTimeoutMs = deadline.remainingMilliseconds(
cappedAt: Self.setupDetectionRequestTimeoutMs)
guard verifyTimeoutMs > 0 else { return false }
guard let verifyData = try? await gateway.request(
method: "openclaw.setup.verify",
params: [:],
timeoutMs: Double(verifyTimeoutMs),
ifCurrentServerLease: serverLease),
await gateway.isCurrentServerLease(serverLease),
isCurrentAttempt(context),
!Task.isCancelled,
let result = try? JSONDecoder().decode(ActivateResult.self, from: verifyData),
result.ok,
result.modelRef == expectedModel
else { return false }
finishConnected(
kind: kind,
activationOwner: activationOwner)
return self.connected
}
}
extension OnboardingAISetupModel {
func startProviderAuth(_ option: AuthOption) {
self.startProviderWizard(option, kind: .auth)
}
func startProviderPrepare(_ option: PrepareOption) {
self.startProviderWizard(
AuthOption(
id: option.id,
brandId: option.brandId,
label: option.label,
hint: option.hint,
groupLabel: nil,
icon: option.icon,
website: option.website,
kind: "prepare",
featured: false),
kind: .prepare)
}
private func startProviderWizard(_ option: AuthOption, kind: ProviderWizardKind) {
guard !self.isBusy, self.activeAuthOption == nil, let serverLease else { return }
self.activeAuthOption = option
self.providerWizardKind = kind
self.authStep = nil
self.authError = nil
self.authText = ""
self.authBusy = true
self.providerAuthReconciliationPending = false
let token = self.attemptToken
let authAttemptID = UUID()
let authSessionID = UUID().uuidString
self.authAttemptID = authAttemptID
self.authSessionID = authSessionID
Task {
do {
let data = try await self.gateway.request(
method: kind.startMethod,
params: [
"sessionId": AnyCodable(authSessionID),
"authChoice": AnyCodable(option.id),
],
timeoutMs: 600_000,
ifCurrentServerLease: serverLease)
let result = try JSONDecoder().decode(WizardStartResult.self, from: data)
guard token == self.attemptToken, authAttemptID == self.authAttemptID else {
// A route reset can race the start response. Cancel the
// decoded server session so the discarded flow cannot commit.
await self.gateway.cancelWizardSession(result.sessionid, on: serverLease)
return
}
if let cancellationSessionID = Self.providerAuthCancellationSessionID(
requested: authSessionID,
returned: result.sessionid)
{
// The returned id owns the live server session. Cancel that
// session even when the Gateway violated the echo contract.
self.authSessionID = cancellationSessionID
self.cancelProviderAuth()
return
}
if !result.done, result.step == nil, wizardStatusString(result.status) == "running" {
self.advanceProviderAuth(stepID: nil, value: nil)
return
}
self.applyAuthWizardResult(
done: result.done,
step: result.step,
status: wizardStatusString(result.status),
error: result.error,
preparedModelRef: result.preparedmodelref)
} catch {
// The Gateway session survives socket loss; cancel by its known
// id before reporting failure so it cannot persist config later.
let cancellation = await self.gateway.cancelWizardSession(
authSessionID,
on: serverLease)
guard token == self.attemptToken, authAttemptID == self.authAttemptID else { return }
if cancellation != .cancelled,
await self.reconcileProviderAuthAfterUnknownOutcome(
token: token,
before: self.lastDetectedActivationState,
originalServerLease: serverLease)
{
return
}
if cancellation != .unresolved {
self.authSessionID = nil
}
self.authBusy = false
self.authError = Self.transportFailure(error.localizedDescription)
}
}
}
func continueProviderAuth() {
guard let step = authStep else { return }
let value: AnyCodable? = switch wizardStepType(step) {
case "text": AnyCodable(self.authText)
case "select": self.selectedAuthWizardOption?.value
case "confirm": AnyCodable(self.authConfirmation)
default: nil
}
self.advanceProviderAuth(stepID: step.id, value: value)
}
func cancelProviderAuth() {
let sessionID = self.authSessionID
let authServerLease = self.serverLease
guard let sessionID, let authServerLease else {
self.authAttemptID = UUID()
self.providerAuthReconciliationPending = false
self.clearProviderAuth()
return
}
let authAttemptID = self.authAttemptID
let token = self.attemptToken
let activationState = self.lastDetectedActivationState
self.authBusy = true
Task {
let cancellation = await self.gateway.cancelWizardSession(
sessionID,
on: authServerLease)
guard authAttemptID == self.authAttemptID else { return }
if cancellation == .absent,
await self.reconcileProviderAuthAfterUnknownOutcome(
token: token,
before: activationState,
originalServerLease: authServerLease)
{
return
}
if cancellation != .unresolved {
self.authAttemptID = UUID()
self.providerAuthReconciliationPending = false
self.clearProviderAuth()
}
}
}
var authWizardOptions: [WizardOption] {
parseWizardOptions(self.authStep?.options)
}
var selectedAuthWizardOption: WizardOption? {
let options = self.authWizardOptions
guard options.indices.contains(self.authSelection) else { return options.first }
return options[self.authSelection]
}
private func advanceProviderAuth(stepID: String?, value: AnyCodable?) {
guard let sessionID = authSessionID, let serverLease else { return }
self.authBusy = true
self.authError = nil
var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionID)]
if let stepID {
var answer: [String: AnyCodable] = ["stepId": AnyCodable(stepID)]
if let value {
answer["value"] = value
}
params["answer"] = AnyCodable(answer)
}
let token = self.attemptToken
let authAttemptID = self.authAttemptID
Task {
do {
let data = try await self.gateway.request(
method: "wizard.next",
params: params,
timeoutMs: Self.providerAuthRequestTimeoutMs,
ifCurrentServerLease: serverLease)
guard token == self.attemptToken, authAttemptID == self.authAttemptID else { return }
let result = try JSONDecoder().decode(WizardNextResult.self, from: data)
self.applyAuthWizardResult(
done: result.done,
step: result.step,
status: wizardStatusString(result.status),
error: result.error,
preparedModelRef: result.preparedmodelref)
} catch {
let cancellation = await self.gateway.cancelWizardSession(sessionID, on: serverLease)
guard token == self.attemptToken, authAttemptID == self.authAttemptID else { return }
if cancellation != .cancelled,
await self.reconcileProviderAuthAfterUnknownOutcome(
token: token,
before: self.lastDetectedActivationState,
originalServerLease: serverLease)
{
return
}
if cancellation != .unresolved {
self.authSessionID = nil
}
self.authBusy = false
self.authError = Self.transportFailure(error.localizedDescription)
}
}
}
private func applyAuthWizardResult(
done: Bool,
step: WizardStep?,
status: String?,
error: String?,
preparedModelRef: String?)
{
self.authBusy = false
let validationError = !done && status == "running" && error?.isEmpty == false
let preserveEnteredValue = validationError && self.authStep?.id == step?.id
if status == "error" || (done && error != nil) {
// Terminal sessions are removed by the Gateway. Drop the local id
// so Cancel dismisses the preserved, copyable error immediately.
self.authSessionID = nil
self.authStep = nil
self.authError = Self.failure(
label: self.activeAuthOption?.label ?? "Provider login",
status: "unavailable",
error: error)
return
}
if status == "cancelled" {
self.clearProviderAuth()
return
}
if done || status == "done" {
let preparedProvider = self.providerWizardKind == .prepare
? self.activeAuthOption.map { (id: $0.id, label: $0.label) }
: nil
let preparedModel = preparedModelRef?
.trimmingCharacters(in: .whitespacesAndNewlines)
self.providerAuthReconciliationPending = self.providerWizardKind == .auth
self.clearProviderAuth()
if let preparedProvider,
let preparedModel,
!preparedModel.isEmpty
{
guard let context = self.captureAttemptContext() else {
self.failDetectionForMissingRoute()
return
}
let kind = Self.providerAutoSetupKind(choiceID: preparedProvider.id)
self.statuses[kind] = .untried
Task {
await self.activate(
kind: kind,
modelRef: preparedModel,
label: preparedProvider.label,
tryNextCandidateOnFailure: false,
context: context)
}
return
}
self.scheduleDetection(
preparedChoiceID: preparedProvider?.id,
preparedProviderLabel: preparedProvider?.label)
return
}
self.authStep = step
if validationError {
self.authError = Self.failure(
label: self.activeAuthOption?.label ?? "Provider login",
status: "format",
error: error)
}
if !preserveEnteredValue {
self.authText = anyCodableString(step?.initialvalue)
}
self.authConfirmation = anyCodableBool(step?.initialvalue)
let options = parseWizardOptions(step?.options)
self.authSelection = max(0, options.firstIndex {
anyCodableEqual($0.value, step?.initialvalue)
} ?? 0)
// Gateway-executed steps render progress and expose no input control, so
// no user action would ever ask for the next frame. Keep polling; the
// session long-polls until the next update or the terminal result, so a
// download reports live instead of freezing on its first frame.
if let step, wizardStepExecutor(step) == "gateway" {
self.advanceProviderAuth(stepID: nil, value: nil)
}
}
private func reconcileProviderAuthAfterUnknownOutcome(
token: UUID,
before: PersistedActivationState?,
originalServerLease: GatewayConnection.ServerLease) async -> Bool
{
guard let before else { return false }
let lease: GatewayConnection.ServerLease
if await self.gateway.isCurrentServerLease(originalServerLease) {
lease = originalServerLease
} else {
guard let replacement = try? await gateway.acquireServerLease(
ifSameRouteAs: originalServerLease,
timeoutMs: 5000)
else { return false }
lease = replacement
}
guard let data = try? await gateway.request(
method: "openclaw.setup.detect",
params: [:],
timeoutMs: Double(Self.setupDetectionRequestTimeoutMs),
ifCurrentServerLease: lease),
token == attemptToken,
let result = try? JSONDecoder().decode(DetectResult.self, from: data),
let configuredModel = result.configuredModel,
Self.activationTransitionWasPersisted(
expectedModel: configuredModel,
before: before,
after: result.persistedActivationState)
else { return false }
self.serverLease = lease
self.clearProviderAuth()
finishConnected(kind: "provider-auth")
return true
}
private func clearProviderAuth() {
self.activeAuthOption = nil
self.providerWizardKind = nil
self.authSessionID = nil
self.authStep = nil
self.authError = nil
self.authBusy = false
self.authText = ""
}
#if DEBUG
func _test_setProviderAuth(option: AuthOption, sessionID: String) {
self.activeAuthOption = option
self.authSessionID = sessionID
self.authBusy = true
}
func _test_applyAuthWizardResult(
done: Bool,
status: String?,
error: String?,
preparedModelRef: String? = nil)
{
self.applyAuthWizardResult(
done: done,
step: nil,
status: status,
error: error,
preparedModelRef: preparedModelRef)
}
var _test_authSessionID: String? {
self.authSessionID
}
#endif
}
extension OnboardingAISetupModel {
func submitManualKey() {
let key = self.manualKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard let provider = selectedManualProvider, !key.isEmpty, !self.isBusy else { return }
guard let context = beginAttemptContext() else {
self.manualError = Self.transportFailure(
"No Gateway is selected. Select a Gateway, then try again.")
return
}
self.manualError = nil
self.manualTesting = true
Task { await self.submitManualKey(key: key, provider: provider, context: context) }
}
private func submitManualKey(
key: String,
provider: ManualProvider,
context: AttemptContext) async
{
defer {
if self.isCurrentAttempt(context) {
self.manualTesting = false
}
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
guard let lease = serverLease,
await gateway.isCurrentServerLease(lease)
else {
let failure = Self.transportFailure(
"The Gateway connection changed. Check for AI accounts again.")
self.manualError = failure
self.requireFreshDetection(after: failure)
return
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
let routeFingerprint = await gateway.activationOwnershipFingerprint(
ifCurrentServerLease: lease)
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
let requestTimeoutMs = Self.activationRequestTimeoutMs(for: "api-key")
// Same keychain-unavailable degradation as detected candidates: an
// unbound lease keeps the ambiguity window without a resume receipt.
let activationOwner = routeFingerprint.map { fingerprint in
OnboardingSystemAgentResumeStore.ActivationOwner(
id: UUID().uuidString,
routeFingerprint: fingerprint)
} ?? .unbound()
self.pendingActivationOwner = activationOwner
self.pendingActivationRequiresFreshActivation = true
// Manual activation has the same persist-before-response ambiguity as
// detected candidates, so relaunch must inspect exact Gateway truth.
guard let activationDeadline = OnboardingSystemAgentResumeStore.markPending(
routeIdentity: context.routeIdentity,
activationOwner: activationOwner,
activationTimeoutMs: requestTimeoutMs,
defaults: defaults)
else {
self.manualError = Self.transportFailure(
"No Gateway is selected. Select a Gateway, then try again.")
return
}
guard !Task.isCancelled else {
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
return
}
do {
let data = try await gateway.request(
method: "openclaw.setup.activate",
params: [
"kind": AnyCodable("api-key"),
"authChoice": AnyCodable(provider.id),
"apiKey": AnyCodable(key),
],
timeoutMs: requestTimeoutMs,
ifCurrentServerLease: lease)
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
guard await self.gateway.isCurrentServerLease(lease) else {
if result.ok,
OnboardingSystemAgentResumeStore.markCompleted(
ifOwnedBy: context.routeIdentity,
activationOwner: activationOwner,
defaults: self.defaults)
{
self.pendingActivationVerification = true
self.phase = .detecting
_ = await self.verifyPendingConfiguredInference()
} else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
self.requireFreshDetection(after: Self.transportFailure(
"The Gateway connection changed while AI setup was finishing. Check again."))
}
return
}
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
if result.ok {
self.manualKey = ""
self.finishConnected(
kind: "api-key",
activationOwner: activationOwner)
} else {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
self.manualError = Self.failure(
label: provider.label,
status: result.status,
error: result.error)
}
} catch {
guard self.isCurrentAttempt(context) else { return }
// A cancellation after request dispatch is ambiguous; keep the
// pending marker so relaunch reconciles against this exact route.
let failure = Self.transportFailure(error.localizedDescription)
self.manualError = failure
if Self.activationFailureIsDefinitive(error) {
self.pendingActivationVerification = false
self.clearPendingHandoff(ifOwnedBy: context, activationOwner: activationOwner)
if await !(self.gateway.isCurrentServerLease(lease)) {
self.requireFreshDetection(after: failure)
}
} else {
self.retainAmbiguousActivation(
ifOwnedBy: context,
activationOwner: activationOwner,
activationDeadline: activationDeadline)
}
}
}
/// A retired socket invalidates every candidate and provider record learned
/// from that server generation. Preserve the error, but require a fresh
/// detection lease before the user can dispatch another setup mutation.
func requireFreshDetection(after failure: Failure) {
self.resetForGatewayChange()
self.phase = .ready
self.detectError = failure
}
private func finishConnected(
kind: String,
activationOwner: OnboardingSystemAgentResumeStore.ActivationOwner? = nil,
requireExistingReceipt: Bool = false)
{
let routeIdentity = self.routeIdentityProvider()?.trimmingCharacters(in: .whitespacesAndNewlines)
let completedReceipt = OnboardingSystemAgentResumeStore.markCompleted(
ifOwnedBy: routeIdentity,
activationOwner: activationOwner,
defaults: self.defaults)
if activationOwner != nil || requireExistingReceipt {
guard completedReceipt else {
self.pendingActivationVerification = false
self.statuses[kind] = .failed(Self.transportFailure(
"Another AI setup attempt replaced this activation. Waiting for its result."))
self.phase = .ready
return
}
}
self.pendingActivationVerification = false
self.waitingForPendingActivationDeadline = false
self.selectedKind = kind
self.phase = .connected
self.pendingActivationOwner = activationOwner
self.completedHandoff = completedReceipt ? routeIdentity.flatMap { routeIdentity in
routeIdentity.isEmpty ? nil : CompletedHandoff(
routeIdentity: routeIdentity,
activationOwner: activationOwner)
} : nil
self.pendingActivationRequiresFreshActivation = false
self.onConnected?()
}
private func tryNextAfterFailure(of kind: String, context: AttemptContext) async {
guard self.isCurrentAttempt(context), !Task.isCancelled else { return }
if let next = autoCandidateAfter(kind: kind) {
await self.activate(kind: next.kind, context: context)
return
}
self.phase = .ready
self.exhaustedAutoCandidates = true
self.showManualEntry = true
}
}