feat: verify AI access during macOS onboarding before the first chat (#100288)

* feat(crestodian): add live-tested structured inference setup (detect/activate gateway RPCs)

* feat(macos): redesign onboarding around a verified Connect-your-AI step

* docs: describe the verified AI onboarding step and gemini setup ladder entry

* chore(macos): drop replaced OnboardingView+CrestodianSetup source

* fix(macos): keep the AI-detect error card from pairing with an unproven empty-state claim

* chore(protocol): regenerate Swift gateway models for crestodian.setup methods

* test(crestodian): give setup-inference mocks explicit params for test-types lane

* chore(i18n): sync native app string inventory for onboarding redesign

* chore(i18n): sync native app string inventory for onboarding redesign
This commit is contained in:
Peter Steinberger
2026-07-05 06:59:30 -07:00
committed by GitHub
parent a5fdc07121
commit 2bc50d0656
31 changed files with 2713 additions and 486 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
import SwiftUI
/// Settings pane hosting the Crestodian setup/repair chat.
///
/// Crestodian answers even when no model is configured (deterministic engine
/// on the gateway), so this pane is the "always works" place to fix config,
/// switch models, connect channels, or run doctor in plain language.
struct CrestodianSettings: View {
let isActive: Bool
@State private var chat = CrestodianOnboardingChatModel(
welcomeVariant: nil,
sessionPrefix: "mac-settings-crestodian")
var body: some View {
VStack(alignment: .leading, spacing: 20) {
SettingsPageHeader(
title: "Crestodian",
subtitle: "Your setup helper. It can check status, fix config, switch models, " +
"and connect channels — even when the agent itself is not working.")
SettingsCardGroup("Chat") {
CrestodianOnboardingChatView(model: self.chat)
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity)
}
.frame(maxHeight: .infinity)
Text("Tip: try “status”, “doctor”, “set default model …”, or “connect telegram”.")
.font(.caption)
.foregroundStyle(.secondary)
}
.settingsDetailContent()
.task(id: self.isActive) {
guard self.isActive else { return }
self.chat.onAgentHandoff = {
AppNavigationActions.openChat()
}
await self.chat.startIfNeeded()
}
}
}
+69 -17
View File
@@ -17,9 +17,13 @@ enum RemoteOnboardingProbeState: Equatable {
}
@MainActor
final class OnboardingController {
final class OnboardingController: NSObject, NSWindowDelegate {
static let shared = OnboardingController()
private var window: NSWindow?
/// Human description of work in flight ("Installing the Gateway").
/// While set, closing the window asks for confirmation instead of quitting
/// setup mid-operation.
var busyReason: String?
static func markComplete() {
UserDefaults.standard.set(true, forKey: onboardingSeenKey)
@@ -47,6 +51,7 @@ final class OnboardingController {
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.isMovableByWindowBackground = true
window.delegate = self
window.center()
DockIconManager.shared.temporarilyShowDock()
window.makeKeyAndOrderFront(nil)
@@ -55,6 +60,7 @@ final class OnboardingController {
}
func close() {
self.busyReason = nil
self.window?.close()
self.window = nil
}
@@ -67,12 +73,39 @@ final class OnboardingController {
self.close()
self.show()
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
guard let busyReason else { return true }
let alert = NSAlert()
alert.messageText = "Setup is still working"
alert.informativeText =
"\(busyReason)\n\nYou can keep this window open until it finishes, " +
"or quit setup and pick it up again later from the menu bar."
alert.alertStyle = .warning
alert.addButton(withTitle: "Continue Setup")
alert.addButton(withTitle: "Quit Setup")
let response = alert.runModal()
return response == .alertSecondButtonReturn
}
func windowWillClose(_ notification: Notification) {
guard let closing = notification.object as? NSWindow, closing === self.window else { return }
self.busyReason = nil
self.window = nil
}
}
struct OnboardingView: View {
enum CLIInstallPhase {
case idle
case installing
case startingService
}
@State var currentPage = 0
@State var isRequesting = false
@State var installingCLI = false
@State var cliInstallPhase: CLIInstallPhase = .idle
@State var cliStatus: String?
@State var copied = false
@State var monitoringPermissions = false
@@ -87,6 +120,7 @@ struct OnboardingView: View {
@State var needsBootstrap = false
@State var didAutoKickoff = false
@State var showAdvancedConnection = false
@State var showRemoteChoices = false
@State var preferredGatewayID: String?
@State var remoteProbeState: RemoteOnboardingProbeState = .idle
@State var remoteAuthIssue: RemoteGatewayAuthIssue?
@@ -95,7 +129,7 @@ struct OnboardingView: View {
@State var onboardingChatModel: OpenClawChatViewModel
@State var onboardingSkillsModel = SkillsSettingsModel()
@State var crestodianChat = CrestodianOnboardingChatModel()
@State var crestodianSetupComplete = false
@State var aiSetup = OnboardingAISetupModel()
@State var didLoadOnboardingSkills = false
@State var localGatewayProbe: LocalGatewayProbe?
@State var defaultsToLocalGateway: Bool
@@ -106,15 +140,32 @@ struct OnboardingView: View {
static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content
let pageWidth: CGFloat = Self.windowWidth
// Sized so the permissions page fits all capabilities without scrolling:
// 145 (icon header) + 535 + ~60 (nav bar) stays inside windowHeight 752.
let contentHeight: CGFloat = 535
let connectionPageIndex = 1
let cliPageIndex = 2
let crestodianPageIndex = 3
let aiPageIndex = 3
let onboardingChatPageIndex = 8
let permissionsPageIndex = 5
/// Chat-like pages shrink the mascot so the conversation gets the room.
var usesCompactHero: Bool {
[self.aiPageIndex, self.onboardingChatPageIndex].contains(self.activePageIndex)
}
var heroFrameHeight: CGFloat {
self.usesCompactHero ? 78 : 145
}
var heroSize: CGFloat {
self.usesCompactHero ? 64 : 130
}
/// Sized so the permissions page fits all capabilities without scrolling:
/// heroFrameHeight + contentHeight + ~72 (nav bar) fills windowHeight 752.
var contentHeight: CGFloat {
Self.windowHeight - self.heroFrameHeight - 72
}
static func pageOrder(
for mode: AppState.ConnectionMode,
showOnboardingChat: Bool,
@@ -123,8 +174,9 @@ struct OnboardingView: View {
switch mode {
case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional.
return showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
// but the AI check runs against the remote gateway so a broken
// remote model surfaces here, not in the first chat.
return showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
case .unconfigured:
return showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
case .local:
@@ -171,18 +223,18 @@ struct OnboardingView: View {
self.activePageIndex == self.cliPageIndex && !self.cliInstalled
}
/// Local onboarding must not finish with nothing configured: the Crestodian
/// page blocks Next until setup authored the config (the conversation's
/// "yes"), mirroring the old wizard-completion gate. "Configure later" on
/// the connection page remains the explicit skip path.
var isCrestodianBlocking: Bool {
self.activePageIndex == self.crestodianPageIndex &&
self.state.connectionMode == .local &&
!self.crestodianSetupComplete
/// Onboarding must not finish without working inference: the AI page
/// blocks Next until a candidate passed its live test (config is authored
/// server-side on that success). "Configure later" on the connection page
/// remains the explicit skip path.
var isAISetupBlocking: Bool {
self.activePageIndex == self.aiPageIndex &&
self.state.connectionMode != .unconfigured &&
!self.aiSetup.connected
}
var canAdvance: Bool {
!self.isCLIBlocking && !self.isCrestodianBlocking
!self.isCLIBlocking && !self.isAISetupBlocking
}
struct LocalGatewayProbe: Equatable {
@@ -0,0 +1,713 @@
import Foundation
import Observation
import OpenClawIPC
import SwiftUI
/// Structured "Connect your AI" onboarding step.
///
/// Drives the gateway's `crestodian.setup.detect` / `crestodian.setup.activate`
/// RPCs: detect reusable AI access (Claude Code, Codex, Gemini logins, API
/// keys), live-test the best candidate, and automatically fall through to the
/// next one when a test 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 {
struct Candidate: Identifiable, Equatable {
let kind: String
let label: String
let detail: String
let modelRef: String
let recommended: Bool
let credentials: Bool?
var id: String {
self.kind
}
}
enum CandidateStatus: Equatable {
case untried
case testing
case failed(message: String)
case connected
}
enum Phase: Equatable {
case idle
case detecting
case ready
case testing
case connected
}
enum ManualProvider: String, CaseIterable, Identifiable {
case anthropic
case openai
case google
var id: String {
self.rawValue
}
var title: String {
switch self {
case .anthropic: "Claude (Anthropic)"
case .openai: "OpenAI"
case .google: "Google Gemini"
}
}
var keyHint: String {
switch self {
case .anthropic: "sk-ant-…"
case .openai: "sk-…"
case .google: "AIza…"
}
}
var consoleName: String {
switch self {
case .anthropic: "console.anthropic.com"
case .openai: "platform.openai.com"
case .google: "aistudio.google.com"
}
}
}
private(set) var phase: Phase = .idle {
didSet {
// Close-guard: quitting mid-test is confirmable, not silent.
OnboardingController.shared.busyReason = self.phase == .testing
? "OpenClaw is testing your AI connection."
: nil
}
}
private(set) var candidates: [Candidate] = []
private(set) var statuses: [String: CandidateStatus] = [:]
private(set) var selectedKind: String?
private(set) var connectedModelRef: String?
private(set) var connectedLatencyMs: Int?
private(set) var detectError: String?
/// Set once every detected candidate failed; opens the manual key form.
private(set) var exhaustedAutoCandidates = false
var manualProvider: ManualProvider = .anthropic
var manualKey: String = ""
private(set) var manualTesting = false
private(set) var manualError: String?
var showManualEntry = false
var connected: Bool {
self.phase == .connected
}
var isBusy: Bool {
self.phase == .detecting || self.phase == .testing || self.manualTesting
}
/// Called when a candidate connects so the page can advance.
var onConnected: (() -> Void)?
private var started = false
private var attemptToken = UUID()
private struct DetectResult: Decodable {
struct DetectedCandidate: Decodable {
let kind: String
let label: String
let detail: String
let modelRef: String
let recommended: Bool
let credentials: Bool?
}
let candidates: [DetectedCandidate]
let workspace: String
let configuredModel: String?
let setupComplete: Bool
}
private struct ActivateResult: Decodable {
let ok: Bool
let modelRef: String?
let latencyMs: Double?
let status: String?
let error: String?
}
func startIfNeeded() {
guard !self.started else { return }
self.started = true
Task { await self.detectAndAutoConnect() }
}
func retryFromScratch() {
self.attemptToken = UUID()
self.phase = .idle
self.candidates = []
self.statuses = [:]
self.selectedKind = nil
self.detectError = nil
self.exhaustedAutoCandidates = false
self.manualError = nil
self.manualTesting = false
self.showManualEntry = false
Task { await self.detectAndAutoConnect() }
}
func detectAndAutoConnect() async {
let token = self.attemptToken
self.phase = .detecting
self.detectError = nil
do {
let data = try await GatewayConnection.shared.request(
method: "crestodian.setup.detect",
params: [:],
timeoutMs: 20000,
retryTransportFailures: true)
guard token == self.attemptToken else { return }
let result = try JSONDecoder().decode(DetectResult.self, from: data)
self.candidates = result.candidates.map { detected in
Candidate(
kind: detected.kind,
label: detected.label,
detail: detected.detail,
modelRef: detected.modelRef,
recommended: detected.recommended,
credentials: detected.credentials)
}
for candidate in self.candidates {
self.statuses[candidate.kind] = .untried
}
self.phase = .ready
if let first = self.autoCandidateAfter(kind: nil) {
// Best candidate found: connect without asking. Switching later
// stays one click away while the test runs server-side.
await self.activate(kind: first.kind)
} else {
self.showManualEntry = true
}
} catch {
guard token == self.attemptToken else { return }
self.phase = .ready
self.detectError = Self.friendlyTransportError(error.localizedDescription)
self.showManualEntry = self.candidates.isEmpty
}
}
/// Transport/protocol failures deserve plain language, not RPC codes.
static func friendlyTransportError(_ raw: String) -> String {
if raw.localizedCaseInsensitiveContains("unknown method") {
return "The Gateway is running an older OpenClaw version that doesnt support " +
"app-guided setup. Update OpenClaw on the gateway, then try again."
}
return raw
}
/// 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 = self.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.isBusy else { return }
guard self.statuses[kind] != .connected else { return }
Task { await self.activate(kind: kind) }
}
func activate(kind: String) async {
let token = self.attemptToken
self.selectedKind = kind
self.phase = .testing
self.statuses[kind] = .testing
do {
let data = try await GatewayConnection.shared.request(
method: "crestodian.setup.activate",
params: ["kind": AnyCodable(kind)],
timeoutMs: 150_000,
retryTransportFailures: false)
guard token == self.attemptToken else { return }
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
if result.ok {
self.finishConnected(kind: kind, result: result)
} else {
self.statuses[kind] = .failed(message: Self.friendlyFailure(
label: self.candidates.first { $0.kind == kind }?.label ?? kind,
status: result.status,
error: result.error))
await self.tryNextAfterFailure(of: kind)
}
} catch {
guard token == self.attemptToken else { return }
self.statuses[kind] = .failed(message: Self.friendlyTransportError(error.localizedDescription))
await self.tryNextAfterFailure(of: kind)
}
}
func submitManualKey() {
let key = self.manualKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !self.manualTesting else { return }
self.manualError = nil
self.manualTesting = true
let token = self.attemptToken
Task {
defer { self.manualTesting = false }
do {
let data = try await GatewayConnection.shared.request(
method: "crestodian.setup.activate",
params: [
"kind": AnyCodable("api-key"),
"provider": AnyCodable(self.manualProvider.rawValue),
"apiKey": AnyCodable(key),
],
timeoutMs: 150_000,
retryTransportFailures: false)
guard token == self.attemptToken else { return }
let result = try JSONDecoder().decode(ActivateResult.self, from: data)
if result.ok {
self.manualKey = ""
self.finishConnected(kind: "api-key", result: result)
} else {
self.manualError = Self.friendlyFailure(
label: self.manualProvider.title,
status: result.status,
error: result.error)
}
} catch {
guard token == self.attemptToken else { return }
self.manualError = error.localizedDescription
}
}
}
private func finishConnected(kind: String, result: ActivateResult) {
self.statuses[kind] = .connected
self.selectedKind = kind
self.connectedModelRef = result.modelRef
self.connectedLatencyMs = result.latencyMs.map { Int($0.rounded()) }
self.phase = .connected
self.onConnected?()
}
private func tryNextAfterFailure(of kind: String) async {
if let next = self.autoCandidateAfter(kind: kind) {
await self.activate(kind: next.kind)
return
}
self.phase = .ready
self.exhaustedAutoCandidates = true
self.showManualEntry = true
}
/// One friendly sentence per failure bucket; raw detail stays available
/// underneath so support/docs can work with it.
static func friendlyFailure(label: String, status: String?, error: String?) -> String {
let detail = error?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
switch status {
case "auth":
return "\(label) is installed, but the login didnt work. Sign in again, then retry."
case "billing":
return "\(label) responded, but the account has a billing problem."
case "rate_limit":
return "\(label) is temporarily rate-limited. Try again in a moment."
case "timeout":
return "\(label) didnt answer in time."
case "format", "unavailable":
return detail.isEmpty ? "\(label) couldnt complete the test." : detail
default:
return detail.isEmpty ? "\(label) couldnt complete the test." : detail
}
}
var connectedSummary: String {
guard let modelRef = self.connectedModelRef else { return "Your AI is connected." }
let label = self.candidates.first { $0.kind == self.selectedKind }?.label
let via = label.map { " via \($0)" } ?? ""
if let latency = self.connectedLatencyMs {
let seconds = Double(latency) / 1000
return "\(modelRef)\(via) — replied in \(String(format: "%.1f", seconds))s"
}
return "\(modelRef)\(via)"
}
}
struct OnboardingAISetupView: View {
@Bindable var model: OnboardingAISetupModel
@State private var showCrestodianChat = false
var crestodianChat: CrestodianOnboardingChatModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
switch self.model.phase {
case .idle, .detecting:
self.detectingView
default:
self.resultsView
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.sheet(isPresented: self.$showCrestodianChat) {
self.crestodianSheet
}
}
private var detectingView: some View {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
VStack(alignment: .leading, spacing: 2) {
Text("Looking for AI you already use…")
.font(.callout.weight(.semibold))
Text("Checking for Claude Code, Codex, Gemini, and saved API keys.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.vertical, 18)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var resultsView: some View {
if self.model.connected {
self.connectedBanner
}
if !self.model.candidates.isEmpty {
VStack(spacing: 8) {
ForEach(self.model.candidates) { candidate in
self.candidateRow(candidate)
}
}
} else if self.model.phase != .connected, self.model.detectError == nil {
// A failed detect must not claim "nothing found" the error card
// below owns that state and the claim would be unproven.
self.noCandidatesIntro
}
if let detectError = self.model.detectError {
OnboardingErrorCard(
title: "Couldnt check this Mac for AI accounts",
message: detectError,
docsSlug: "start/onboarding",
retryTitle: "Try again")
{
self.model.retryFromScratch()
}
}
if self.model.exhaustedAutoCandidates, !self.model.connected {
OnboardingErrorCard(
title: "None of the found options worked",
message: "The details are listed on each option above. You can fix the login and retry, or connect with an API key below.",
docsSlug: "concepts/model-providers",
retryTitle: "Check again")
{
self.model.retryFromScratch()
}
}
if !self.model.connected {
self.manualSection
}
HStack {
Spacer(minLength: 0)
Button {
self.showCrestodianChat = true
} label: {
Label("Need help? Chat with Crestodian", systemImage: "questionmark.bubble")
.font(.caption)
}
.buttonStyle(.link)
}
}
private var connectedBanner: some View {
HStack(alignment: .center, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Your AI is ready")
.font(.headline)
Text(self.model.connectedSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.green.opacity(0.12)))
}
private var noCandidatesIntro: some View {
VStack(alignment: .leading, spacing: 6) {
Text("No AI accounts found on this Mac")
.font(.headline)
Text(
"Thats fine — you can connect one with an API key. " +
"If you use Claude Code, Codex, or the Gemini CLI on this Mac, " +
"sign in there first and hit “Check again”.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Check again") {
self.model.retryFromScratch()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.vertical, 4)
}
private func candidateRow(_ candidate: OnboardingAISetupModel.Candidate) -> some View {
let status = self.model.statuses[candidate.kind] ?? .untried
let selected = self.model.selectedKind == candidate.kind
return Button {
self.model.userSelect(kind: candidate.kind)
} label: {
HStack(alignment: .center, spacing: 12) {
Image(systemName: Self.symbol(for: candidate.kind))
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(candidate.label)
.font(.callout.weight(.semibold))
if candidate.recommended, status != .connected {
Text("Recommended")
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor.opacity(0.16)))
.foregroundStyle(Color.accentColor)
}
}
Text(self.subtitle(for: candidate, status: status))
.font(.caption)
.foregroundStyle(self.subtitleStyle(for: status))
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
self.trailingIndicator(status: status, selected: selected)
}
.openClawSelectableRowChrome(selected: selected && status != .failed(message: ""))
}
.buttonStyle(.plain)
.disabled(self.model.isBusy || self.model.connected)
}
private func subtitle(
for candidate: OnboardingAISetupModel.Candidate,
status: OnboardingAISetupModel.CandidateStatus) -> String
{
switch status {
case .testing:
"Testing — asking \(candidate.modelRef) for a quick reply…"
case let .failed(message):
message
case .connected:
self.model.connectedSummary
case .untried:
"\(candidate.modelRef) · \(candidate.detail)"
}
}
private func subtitleStyle(
for status: OnboardingAISetupModel.CandidateStatus) -> Color
{
if case .failed = status {
return .orange
}
return .secondary
}
@ViewBuilder
private func trailingIndicator(
status: OnboardingAISetupModel.CandidateStatus,
selected: Bool) -> some View
{
switch status {
case .testing:
ProgressView()
.controlSize(.small)
case .connected:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
case .untried:
SelectionStateIndicator(selected: selected)
}
}
private static func symbol(for kind: String) -> String {
switch kind {
case "claude-cli": "sparkle"
case "codex-cli": "chevron.left.forwardslash.chevron.right"
case "gemini-cli": "diamond"
case "existing-model": "checkmark.seal"
default: "key.fill"
}
}
private var manualSection: some View {
VStack(alignment: .leading, spacing: 10) {
if self.model.candidates.isEmpty || self.model.showManualEntry {
self.manualForm
} else {
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.model.showManualEntry = true
}
} label: {
Label("Connect with an API key instead…", systemImage: "key")
.font(.callout)
}
.buttonStyle(.link)
.disabled(self.model.isBusy)
}
}
}
private var manualForm: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Connect with an API key")
.font(.headline)
HStack(spacing: 8) {
Picker("Provider", selection: self.$model.manualProvider) {
ForEach(OnboardingAISetupModel.ManualProvider.allCases) { provider in
Text(provider.title).tag(provider)
}
}
.labelsHidden()
.frame(width: 170)
SecureField(self.model.manualProvider.keyHint, text: self.$model.manualKey)
.textFieldStyle(.roundedBorder)
.onSubmit { self.model.submitManualKey() }
Button {
self.model.submitManualKey()
} label: {
if self.model.manualTesting {
ProgressView()
.controlSize(.small)
.frame(minWidth: 74)
} else {
Text("Connect")
.frame(minWidth: 74)
}
}
.buttonStyle(.borderedProminent)
.disabled(self.model.manualTesting ||
self.model.manualKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text(
"Create a key at \(self.model.manualProvider.consoleName), paste it here, " +
"and OpenClaw checks it with a real test question.")
.font(.caption)
.foregroundStyle(.secondary)
if let manualError = self.model.manualError {
OnboardingErrorCard(
title: "That key didnt work",
message: manualError,
docsSlug: "concepts/model-providers",
retryTitle: nil,
retry: nil)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
private var crestodianSheet: some View {
VStack(spacing: 8) {
HStack {
Label("Crestodian — setup helper", systemImage: "lifepreserver")
.font(.headline)
Spacer(minLength: 0)
Button("Done") {
self.showCrestodianChat = false
}
}
.padding([.top, .horizontal], 14)
CrestodianOnboardingChatView(model: self.crestodianChat)
.task { await self.crestodianChat.startIfNeeded() }
}
.frame(width: 520, height: 480)
}
}
/// Friendly error presentation with a consistent docs escape hatch.
/// Every onboarding failure points at a docs.openclaw.ai page so people are
/// never stuck staring at a raw error string.
struct OnboardingErrorCard: View {
let title: String
let message: String
let docsSlug: String
var retryTitle: String?
var retry: (() -> Void)?
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.callout.weight(.semibold))
Text(self.message)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 14) {
if let retryTitle = self.retryTitle, let retry = self.retry {
Button(retryTitle, action: retry)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
Button("Open help…") {
if let url = URL(string: "https://docs.openclaw.ai/\(self.docsSlug)") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.link)
.font(.caption)
}
.padding(.top, 2)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.orange.opacity(0.10)))
}
}
@@ -30,9 +30,17 @@ final class CrestodianOnboardingChatModel {
/// Called after every assistant reply (setup may have applied config).
var onReplyReceived: (() -> Void)?
private let sessionId = "mac-onboarding-\(UUID().uuidString)"
private let sessionId: String
/// "onboarding" seeds the first-run setup proposal; nil gets the
/// status/repair greeting (used by Settings Crestodian).
private let welcomeVariant: String?
private var started = false
init(welcomeVariant: String? = "onboarding", sessionPrefix: String = "mac-onboarding") {
self.welcomeVariant = welcomeVariant
self.sessionId = "\(sessionPrefix)-\(UUID().uuidString)"
}
private struct ChatResult: Decodable {
let sessionId: String
let reply: String
@@ -67,8 +75,10 @@ final class CrestodianOnboardingChatModel {
do {
var params: [String: AnyCodable] = [
"sessionId": AnyCodable(self.sessionId),
"welcomeVariant": AnyCodable("onboarding"),
]
if let welcomeVariant = self.welcomeVariant {
params["welcomeVariant"] = AnyCodable(welcomeVariant)
}
if let message {
params["message"] = AnyCodable(message)
}
@@ -0,0 +1,52 @@
import SwiftUI
extension OnboardingView {
/// Structured AI setup: detect what's already on this machine, test the
/// best option live, fall through automatically, offer an API-key form
/// when nothing works. Crestodian chat stays one click away for help.
func aiSetupPage() -> some View {
VStack(spacing: 12) {
Text("Connect your AI")
.font(.largeTitle.weight(.semibold))
Text(self.aiSetupSubtitle)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 540)
.fixedSize(horizontal: false, vertical: true)
ScrollView {
OnboardingAISetupView(model: self.aiSetup, crestodianChat: self.crestodianChat)
.padding(.vertical, 4)
.padding(.trailing, 12)
}
.scrollIndicators(.automatic)
}
.padding(.horizontal, 28)
.frame(width: self.pageWidth, height: self.contentHeight, alignment: .top)
}
private var aiSetupSubtitle: String {
if self.aiSetup.connected {
return "All good — your assistant has a working AI connection."
}
return "OpenClaw needs an AI account to think. " +
"It reuses what you already have — nothing new to sign up for if " +
"Claude Code, Codex, or an API key is on this Mac."
}
func maybeStartAISetup(for pageIndex: Int) {
guard pageIndex == self.aiPageIndex else { return }
// Local mode reaches this page only after the CLI/gateway install page,
// so the gateway is up before the first RPC.
guard self.state.connectionMode != .local || self.cliInstalled else { return }
if self.aiSetup.onConnected == nil {
self.aiSetup.onConnected = { [self] in
// Setup authored the workspace (BOOTSTRAP.md); re-check so the
// Meet-your-agent page joins the flow.
self.refreshBootstrapStatus()
}
}
self.aiSetup.startIfNeeded()
}
}
@@ -10,6 +10,7 @@ extension OnboardingView {
self.state.connectionMode = .local
self.preferredGatewayID = nil
self.showAdvancedConnection = false
self.showRemoteChoices = false
GatewayDiscoveryPreferences.setPreferredStableID(nil)
}
@@ -18,6 +19,7 @@ extension OnboardingView {
self.state.connectionMode = .unconfigured
self.preferredGatewayID = nil
self.showAdvancedConnection = false
self.showRemoteChoices = false
GatewayDiscoveryPreferences.setPreferredStableID(nil)
}
@@ -64,6 +66,11 @@ extension OnboardingView {
func finish() {
OnboardingController.markComplete()
OnboardingController.shared.close()
// Land people in the real conversation, not on an empty desktop: the
// agent chat is the product, and it is verified working by now.
if self.state.connectionMode != .unconfigured {
AppNavigationActions.openChat()
}
}
func copyToPasteboard(_ text: String) {
@@ -1,92 +0,0 @@
import SwiftUI
extension OnboardingView {
/// Conversational setup: the user talks to Crestodian over the gateway and
/// it configures everything (AI detection, config, workspace). No wizard.
func crestodianSetupPage() -> some View {
VStack(spacing: 12) {
Text("Talk to Crestodian")
.font(.largeTitle.weight(.semibold))
Text(
"Crestodian is OpenClaw's setup custodian. It finds AI access you already have — " +
"a Claude Code or Codex login, or API keys — and sets everything up when you say yes. " +
"Just tell it what you want.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 540)
.fixedSize(horizontal: false, vertical: true)
self.onboardingGlassCard(padding: 4) {
CrestodianOnboardingChatView(model: self.crestodianChat)
.frame(maxHeight: .infinity)
}
.frame(maxHeight: .infinity)
}
.padding(.horizontal, 28)
.frame(width: self.pageWidth, height: self.contentHeight, alignment: .top)
}
func maybeStartCrestodianChat(for pageIndex: Int) {
self.refreshCrestodianSetupComplete()
guard pageIndex == self.crestodianPageIndex else { return }
// Local mode reaches this page only after the CLI/gateway install page,
// so the gateway is up before the first RPC.
guard self.state.connectionMode != .local || self.cliInstalled else { return }
if self.crestodianChat.onAgentHandoff == nil {
self.crestodianChat.onAgentHandoff = { [self] in
// "talk to agent": refresh workspace state so the agent chat
// page appears, then advance.
self.refreshBootstrapStatus()
self.refreshCrestodianSetupComplete()
self.handleNext()
}
}
if self.crestodianChat.onReplyReceived == nil {
self.crestodianChat.onReplyReceived = { [self] in
// Setup applies mid-conversation; re-check so Next unlocks.
self.refreshCrestodianSetupComplete()
}
}
Task { await self.crestodianChat.startIfNeeded() }
}
/// Setup is complete once the config carries authored wizard/gateway-auth
/// state the same signal the old step wizard used to skip itself.
func refreshCrestodianSetupComplete() {
let root = OpenClawConfigFile.loadDict()
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
self.crestodianSetupComplete = true
return
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
Self.hasCrestodianSetupAuth(auth)
{
self.crestodianSetupComplete = true
return
}
self.crestodianSetupComplete = false
}
static func hasCrestodianSetupAuth(_ auth: [String: Any]) -> Bool {
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
return ["token", "password"].contains { key in
if let value = auth[key] as? String {
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
guard let ref = auth[key] as? [String: Any], ref.count == 3,
let source = ref["source"] as? String,
["env", "file", "exec"].contains(source),
let provider = ref["provider"] as? String,
!provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let id = ref["id"] as? String
else { return false }
return !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
}
@@ -4,9 +4,11 @@ import SwiftUI
extension OnboardingView {
var body: some View {
VStack(spacing: 0) {
GlowingOpenClawIcon(size: 130)
.offset(y: 10)
.frame(height: 145)
// Chat-heavy pages shrink the mascot so the content gets the room.
GlowingOpenClawIcon(size: self.heroSize)
.offset(y: self.usesCompactHero ? 4 : 10)
.frame(height: self.heroFrameHeight)
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: self.usesCompactHero)
GeometryReader { _ in
HStack(spacing: 0) {
@@ -23,6 +25,7 @@ extension OnboardingView {
.clipped()
}
.frame(height: self.contentHeight)
.animation(.spring(response: 0.45, dampingFraction: 0.85), value: self.usesCompactHero)
Spacer(minLength: 0)
self.navigationBar
@@ -87,7 +90,7 @@ extension OnboardingView {
var navigationBar: some View {
let connectionLockIndex = pageOrder.firstIndex(of: connectionPageIndex)
let cliLockIndex = pageOrder.firstIndex(of: cliPageIndex)
let crestodianLockIndex = pageOrder.firstIndex(of: crestodianPageIndex)
let aiLockIndex = pageOrder.firstIndex(of: aiPageIndex)
return HStack(spacing: 20) {
ZStack(alignment: .leading) {
Button(action: {}, label: {
@@ -105,7 +108,7 @@ extension OnboardingView {
.buttonStyle(.plain)
.foregroundColor(.secondary)
.opacity(0.8)
.disabled(self.installingCLI)
.disabled(self.installingCLI || self.aiSetup.isBusy)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
}
@@ -115,18 +118,19 @@ extension OnboardingView {
HStack(spacing: 8) {
ForEach(0..<self.pageCount, id: \.self) { index in
let isInstallLocked = self.installingCLI && index != self.currentPage
let isInstallLocked = (self.installingCLI || self.aiSetup.isBusy) &&
index != self.currentPage
let isConnectionLocked = self.isConnectionSelectionBlocking &&
index > (connectionLockIndex ?? 0)
let isCLILocked = cliLockIndex != nil && !self.cliInstalled && index > (cliLockIndex ?? 0)
// Dots must honor the same setup gate as Next: no jumping
// past the Crestodian page before setup authored the config.
let isCrestodianLocked = crestodianLockIndex != nil &&
self.state.connectionMode == .local &&
!self.crestodianSetupComplete &&
index > (crestodianLockIndex ?? 0)
// past the AI page before a candidate passed its live test.
let isAILocked = aiLockIndex != nil &&
self.state.connectionMode != .unconfigured &&
!self.aiSetup.connected &&
index > (aiLockIndex ?? 0)
let isLocked = isInstallLocked || isConnectionLocked || isCLILocked ||
isCrestodianLocked
isAILocked
Button {
withAnimation { self.currentPage = index }
} label: {
@@ -43,9 +43,9 @@ extension OnboardingView {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
self.maybeInstallCLI(for: pageIndex)
self.maybeStartCrestodianChat(for: pageIndex)
// Crestodian setup creates the workspace (BOOTSTRAP.md); re-check so
// the Meet-your-agent page joins the flow once setup ran.
self.maybeStartAISetup(for: pageIndex)
// AI setup creates the workspace (BOOTSTRAP.md); re-check so the
// Meet-your-agent page joins the flow once setup ran.
self.refreshBootstrapStatus()
maybeKickoffOnboardingChat(for: pageIndex)
}
@@ -77,6 +77,8 @@ extension OnboardingView {
guard self.onboardingVisible, !installingCLI else { return }
installingCLI = true
OnboardingController.shared.setWindowCloseEnabled(false)
// Cmd-W bypasses the disabled close button; the delegate asks first.
OnboardingController.shared.busyReason = "OpenClaw is installing the Gateway service."
Task { @MainActor in await self.runCLIInstall() }
}
@@ -91,9 +93,12 @@ extension OnboardingView {
}
func runCLIInstall() async {
self.cliInstallPhase = .installing
defer {
self.installingCLI = false
self.cliInstallPhase = .idle
OnboardingController.shared.setWindowCloseEnabled(true)
OnboardingController.shared.busyReason = nil
}
let installed = await CLIInstaller.install { message in
self.cliStatus = message
@@ -101,6 +106,9 @@ extension OnboardingView {
guard installed else { return }
cliInstallLocation = CLIInstaller.managedExecutableLocation()
cliStatus = "Starting OpenClaw Gateway…"
// The step checklist shows one spinner at a time: install first,
// then the service start.
self.cliInstallPhase = .startingService
switch await CLIInstaller.activateLocalGateway() {
case .ready:
cliStatus = "OpenClaw Gateway is ready."
@@ -16,7 +16,7 @@ extension OnboardingView {
case 2:
self.cliPage()
case 3:
self.crestodianSetupPage()
self.aiSetupPage()
case 5:
self.permissionsPage()
case 8:
@@ -31,15 +31,20 @@ extension OnboardingView {
func welcomePage() -> some View {
self.onboardingPage {
VStack(spacing: 18) {
Text("Welcome to OpenClaw")
.font(.largeTitle.weight(.semibold))
VStack(spacing: 8) {
Text("Welcome to OpenClaw")
.font(.largeTitle.weight(.semibold))
Text("Your personal AI assistant, living on your own Mac.")
.font(.title3)
.foregroundStyle(.secondary)
}
Text(
"OpenClaw is your personal AI assistant. It can answer questions, work with files and apps, " +
"and connect to services like WhatsApp and Telegram through a Gateway you control.")
"It answers questions, works with your files and apps, and can chat with you " +
"on WhatsApp or Telegram. Setup takes about two minutes.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 560)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 14, padding: 16) {
@@ -48,12 +53,12 @@ extension OnboardingView {
subtitle: "Give your assistant tasks and let it help across your Mac.",
systemImage: "sparkles")
self.featureRow(
title: "Connect the tools you use",
subtitle: "Bring conversations and services together in one assistant.",
systemImage: "point.3.connected.trianglepath.dotted")
title: "Chat wherever you like",
subtitle: "This app, WhatsApp, Telegram, Discord, Slack — your choice.",
systemImage: "bubble.left.and.bubble.right.fill")
self.featureRow(
title: "Stay in control",
subtitle: "Choose where the Gateway runs and which permissions OpenClaw receives.",
subtitle: "Everything runs where you decide, with permissions you grant.",
systemImage: "hand.raised.fill")
}
.frame(maxWidth: 520)
@@ -75,48 +80,72 @@ extension OnboardingView {
func connectionPage() -> some View {
self.onboardingPage {
Text("Where should OpenClaw run?")
Text("Where should your assistant live?")
.font(.largeTitle.weight(.semibold))
Text(
"For the simplest setup, run the Gateway on this Mac. " +
"OpenClaw installs it and keeps it running for you.")
"Most people pick this Mac — OpenClaw installs everything and keeps it " +
"running in the background. You can change this anytime in Settings.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(2)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 12, padding: 14) {
VStack(alignment: .leading, spacing: 10) {
self.connectionChoiceButton(
title: "This Mac",
title: "On this Mac",
badge: "Recommended",
subtitle: self.localGatewaySubtitle,
systemImage: "laptopcomputer",
selected: self.selectedConnectionMode == .local)
{
self.selectLocalGateway()
}
Divider().padding(.vertical, 4)
self.gatewayDiscoverySection()
if self.shouldShowRemoteConnectionSection {
Divider().padding(.vertical, 4)
self.remoteConnectionSection()
}
self.connectionChoiceButton(
title: "Configure later",
subtitle: "Dont start the Gateway yet.",
selected: self.selectedConnectionMode == .unconfigured)
title: "On another computer",
badge: nil,
subtitle: self.remoteChoiceSubtitle,
systemImage: "network",
selected: self.selectedConnectionMode == .remote)
{
self.selectUnconfiguredGateway()
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.showRemoteChoices.toggle()
}
}
self.advancedConnectionSection()
if self.showRemoteChoices || self.selectedConnectionMode == .remote {
self.gatewayDiscoverySection()
if self.shouldShowRemoteConnectionSection {
Divider().padding(.vertical, 4)
self.remoteConnectionSection()
}
self.advancedConnectionSection()
}
}
}
HStack {
Spacer(minLength: 0)
Button("Set up later") {
self.selectUnconfiguredGateway()
}
.buttonStyle(.link)
.font(.callout)
.foregroundStyle(self.selectedConnectionMode == .unconfigured ? Color.accentColor : .secondary)
.help("Skip Gateway setup for now; pick Local or Remote later in Settings → General.")
Spacer(minLength: 0)
}
if self.selectedConnectionMode == .unconfigured {
Text("OK — OpenClaw wont start anything yet. Pick Local or Remote later in Settings → General.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
}
.onChange(of: self.state.connectionMode) { _, newValue in
guard Self.shouldResetRemoteProbeFeedback(
@@ -138,7 +167,7 @@ extension OnboardingView {
private var localGatewaySubtitle: String {
guard let probe = self.localGatewayProbe else {
return "Recommended — installs and starts automatically."
return "Private to this computer. Installs and starts automatically."
}
let base = probe.expected
? "Existing gateway detected"
@@ -147,41 +176,46 @@ extension OnboardingView {
return "\(base)\(command). Will attach."
}
private var remoteChoiceSubtitle: String {
let count = self.gatewayDiscovery.gateways.count
if count > 0 {
return count == 1
? "1 gateway found on your network — click to choose it."
: "\(count) gateways found on your network — click to choose one."
}
return "For advanced setups — use a gateway that runs elsewhere."
}
@ViewBuilder
private func gatewayDiscoverySection() -> some View {
HStack(spacing: 8) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.caption)
.foregroundStyle(.secondary)
Text(self.gatewayDiscovery.statusText)
.font(.caption)
.foregroundStyle(.secondary)
if self.gatewayDiscovery.gateways.isEmpty {
ProgressView().controlSize(.small)
Button("Refresh") {
// Quiet by design: discovery runs in the background and must not make
// the page read as "loading" no spinner, just a status line.
if self.gatewayDiscovery.gateways.isEmpty {
HStack(spacing: 8) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.caption)
.foregroundStyle(.tertiary)
Text("No gateways found on your network yet.")
.font(.caption)
.foregroundStyle(.secondary)
Button("Look again") {
self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0)
}
.buttonStyle(.link)
.help("Retry remote discovery (Tailscale DNS-SD + Serve probe).")
}
Spacer(minLength: 0)
}
if self.gatewayDiscovery.gateways.isEmpty {
Text("Searching for nearby gateways…")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
.help("Retry discovery (Bonjour + Tailscale DNS-SD).")
Spacer(minLength: 0)
}
.padding(.leading, 4)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("Nearby gateways")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in
self.connectionChoiceButton(
title: gateway.displayName,
badge: nil,
subtitle: self.gatewaySubtitle(for: gateway),
systemImage: "desktopcomputer",
monospacedSubtitle: true,
selected: self.isSelectedGateway(gateway))
{
self.selectRemoteGateway(gateway)
@@ -561,7 +595,10 @@ extension OnboardingView {
func connectionChoiceButton(
title: String,
badge: String? = nil,
subtitle: String?,
systemImage: String? = nil,
monospacedSubtitle: Bool = false,
selected: Bool,
action: @escaping () -> Void) -> some View
{
@@ -570,18 +607,35 @@ extension OnboardingView {
action()
}
} label: {
HStack(alignment: .center, spacing: 10) {
HStack(alignment: .center, spacing: 12) {
if let systemImage {
Image(systemName: systemImage)
.font(.title3.weight(.semibold))
.foregroundStyle(selected ? Color.accentColor : Color.secondary)
.frame(width: 26)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
HStack(spacing: 6) {
Text(title)
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let badge {
Text(badge)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor.opacity(0.16)))
.foregroundStyle(Color.accentColor)
}
}
if let subtitle {
Text(subtitle)
.font(.caption.monospaced())
.font(monospacedSubtitle ? .caption.monospaced() : .caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.lineLimit(2)
.truncationMode(.middle)
.multilineTextAlignment(.leading)
}
}
Spacer(minLength: 0)
@@ -631,62 +685,124 @@ extension OnboardingView {
func cliPage() -> some View {
self.onboardingPage {
Text("Setting up OpenClaw")
Text("Getting things ready")
.font(.largeTitle.weight(.semibold))
Text("OpenClaw is installing the local Gateway and its managed runtime.")
Text(
"OpenClaw is setting up its background service on this Mac. " +
"This usually takes under a minute — no Terminal, no administrator password.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard(spacing: 10) {
if !self.cliStatusKnown {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Checking existing setup…")
.font(.headline)
}
} else if self.installingCLI {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Installing the Gateway…")
.font(.headline)
}
} else if self.cliInstalled, let loc = self.cliInstallLocation {
Label("Gateway installed", systemImage: "checkmark.circle.fill")
.font(.headline)
.foregroundStyle(.green)
Text(loc)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
} else {
HStack {
Button("Retry setup", action: self.startCLIInstall)
.buttonStyle(.borderedProminent)
Button("Check again") {
Task { await self.refreshCLIStatus() }
}
.buttonStyle(.bordered)
}
}
self.onboardingCard(spacing: 14, padding: 16) {
self.installStepRow(
title: "Install OpenClaw",
detail: self.cliInstalled
? (self.cliInstallLocation ?? "Installed")
: "A private copy inside your user folder.",
state: self.installStepStateForInstall,
monospacedDetail: self.cliInstalled && self.cliInstallLocation != nil)
self.installStepRow(
title: "Start the background service",
detail: "Runs quietly and starts again after a restart.",
state: self.installStepStateForService)
self.installStepRow(
title: "Ready for the next step",
detail: "Once the service answers, youll connect your AI.",
state: self.cliInstalled ? .done : .pending)
if let cliStatus {
if self.installFailed {
OnboardingErrorCard(
title: "The Gateway didnt start",
message: self.cliStatus ?? "The installer did not finish.",
docsSlug: "platforms/mac/bundled-gateway",
retryTitle: "Try again")
{
self.startCLIInstall()
}
} else if let cliStatus, !self.cliInstalled {
Text(cliStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Uses a private user-space install. No Terminal, administrator access, or Homebrew required.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
private var installFailed: Bool {
self.cliStatusKnown && !self.installingCLI && !self.cliInstalled
}
/// Exactly one spinner at a time: the install row finishes before the
/// service row starts, mirroring the actual runCLIInstall phases.
private var installStepStateForInstall: InstallStepState {
if self.cliInstalled { return .done }
if self.installingCLI {
return self.cliInstallPhase == .startingService ? .done : .running
}
if self.installFailed { return .failed }
return .running // status probe still deciding
}
private var installStepStateForService: InstallStepState {
if self.cliInstalled { return .done }
if self.installingCLI {
return self.cliInstallPhase == .startingService ? .running : .pending
}
if self.installFailed { return .failed }
return .pending
}
enum InstallStepState {
case pending
case running
case done
case failed
}
private func installStepRow(
title: String,
detail: String,
state: InstallStepState,
monospacedDetail: Bool = false) -> some View
{
HStack(alignment: .top, spacing: 12) {
Group {
switch state {
case .pending:
Image(systemName: "circle.dotted")
.foregroundStyle(.tertiary)
case .running:
ProgressView()
.controlSize(.small)
case .done:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
}
}
.font(.title3)
.frame(width: 26, height: 22)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(state == .pending ? Color.secondary : Color.primary)
Text(detail)
.font(monospacedDetail ? .caption.monospaced() : .caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(2)
.truncationMode(.middle)
}
Spacer(minLength: 0)
}
}
func workspacePage() -> some View {
self.onboardingPage {
Text("Agent workspace")
@@ -778,14 +894,13 @@ extension OnboardingView {
}
func onboardingChatPage() -> some View {
VStack(spacing: 16) {
VStack(spacing: 12) {
Text("Meet your agent")
.font(.largeTitle.weight(.semibold))
Text(
"This is a dedicated onboarding chat. Your agent will introduce itself, " +
"learn who you are, and help you connect Discord, Slack, Telegram, WhatsApp, " +
"or another channel if you want.")
.font(.body)
"Your agent introduces itself, picks a name with you, and helps you " +
"connect WhatsApp, Telegram, or another channel — just chat.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
@@ -803,8 +918,14 @@ extension OnboardingView {
func readyPage() -> some View {
self.onboardingPage {
Text("All set")
Text("Youre all set!")
.font(.largeTitle.weight(.semibold))
if self.state.connectionMode != .unconfigured {
Text("Finish opens the chat — say hi to your new agent.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
self.onboardingCard {
if self.state.connectionMode == .unconfigured {
self.featureRow(
@@ -40,7 +40,7 @@ extension OnboardingView {
view.state.connectionMode = .local
_ = view.welcomePage()
_ = view.connectionPage()
_ = view.crestodianSetupPage()
_ = view.aiSetupPage()
_ = view.permissionsPage()
_ = view.cliPage()
_ = view.workspacePage()
@@ -177,6 +177,8 @@ struct SettingsRootView: View {
showOnboarding: { DebugActions.restartOnboarding() }))
case .voiceWake:
AnyView(VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake))
case .crestodian:
AnyView(CrestodianSettings(isActive: self.selectedTab == tab))
case .channels:
AnyView(ChannelsSettings(isActive: self.selectedTab == tab))
case .skills:
@@ -239,7 +241,7 @@ private struct SettingsTabGroup: Identifiable {
static func defaultGroups(showDebug: Bool) -> [SettingsTabGroup] {
var groups = [
SettingsTabGroup(title: "Basics", tabs: [.general, .connection, .permissions, .voiceWake]),
SettingsTabGroup(title: "Basics", tabs: [.general, .connection, .permissions, .voiceWake, .crestodian]),
SettingsTabGroup(title: "Automation", tabs: [.channels, .skills, .cron, .execApprovals]),
SettingsTabGroup(title: "Data", tabs: [.sessions, .instances]),
SettingsTabGroup(title: "Advanced", tabs: [.config]),
@@ -255,7 +257,7 @@ private struct SettingsTabGroup: Identifiable {
}
enum SettingsTab: CaseIterable, Identifiable, Hashable {
case general, connection, permissions, voiceWake, channels, skills, cron
case general, connection, permissions, voiceWake, crestodian, channels, skills, cron
case execApprovals, sessions, instances, config, debug, about
static let windowWidth: CGFloat = 1120
static let windowHeight: CGFloat = 790
@@ -270,6 +272,7 @@ enum SettingsTab: CaseIterable, Identifiable, Hashable {
case .connection: "Connection"
case .permissions: "Permissions"
case .voiceWake: "Voice & Talk"
case .crestodian: "Crestodian"
case .channels: "Channels"
case .skills: "Skills"
case .cron: "Cron Jobs"
@@ -288,6 +291,7 @@ enum SettingsTab: CaseIterable, Identifiable, Hashable {
case .connection: "point.3.connected.trianglepath.dotted"
case .permissions: "lock.shield"
case .voiceWake: "waveform.circle"
case .crestodian: "lifepreserver"
case .channels: "link"
case .skills: "sparkles"
case .cron: "calendar.badge.clock"
@@ -3177,6 +3177,94 @@ public struct CrestodianChatResult: Codable, Sendable {
}
}
public struct CrestodianSetupDetectParams: Codable, Sendable {}
public struct CrestodianSetupDetectResult: Codable, Sendable {
public let candidates: [[String: AnyCodable]]
public let workspace: String
public let configuredmodel: String?
public let setupcomplete: Bool
public init(
candidates: [[String: AnyCodable]],
workspace: String,
configuredmodel: String?,
setupcomplete: Bool)
{
self.candidates = candidates
self.workspace = workspace
self.configuredmodel = configuredmodel
self.setupcomplete = setupcomplete
}
private enum CodingKeys: String, CodingKey {
case candidates
case workspace
case configuredmodel = "configuredModel"
case setupcomplete = "setupComplete"
}
}
public struct CrestodianSetupActivateParams: Codable, Sendable {
public let kind: AnyCodable
public let provider: String?
public let apikey: String?
public let workspace: String?
public init(
kind: AnyCodable,
provider: String?,
apikey: String?,
workspace: String?)
{
self.kind = kind
self.provider = provider
self.apikey = apikey
self.workspace = workspace
}
private enum CodingKeys: String, CodingKey {
case kind
case provider
case apikey = "apiKey"
case workspace
}
}
public struct CrestodianSetupActivateResult: Codable, Sendable {
public let ok: Bool
public let modelref: String?
public let latencyms: Double?
public let lines: [String]?
public let status: AnyCodable?
public let error: String?
public init(
ok: Bool,
modelref: String?,
latencyms: Double?,
lines: [String]?,
status: AnyCodable?,
error: String?)
{
self.ok = ok
self.modelref = modelref
self.latencyms = latencyms
self.lines = lines
self.status = status
self.error = error
}
private enum CodingKeys: String, CodingKey {
case ok
case modelref = "modelRef"
case latencyms = "latencyMs"
case lines
case status
case error
}
}
public struct WizardStartParams: Codable, Sendable {
public let mode: AnyCodable?
public let workspace: String?
+4 -1
View File
@@ -113,8 +113,11 @@ When no model is configured, setup picks the first usable backend in this order
3. `ANTHROPIC_API_KEY` -> `anthropic/claude-opus-4-8`
4. Claude Code CLI -> `claude-cli/claude-opus-4-8`
5. Codex -> `openai/gpt-5.5` through the Codex app-server harness
6. Gemini CLI -> `google-gemini-cli/gemini-3.1-pro-preview`
If none are available, setup still writes the default workspace and leaves the model unset. Install or log into Codex/Claude Code, or expose `OPENAI_API_KEY`/`ANTHROPIC_API_KEY`, then run setup again.
If none are available, setup still writes the default workspace and leaves the model unset. Install or log into Codex/Claude Code/Gemini CLI, or expose `OPENAI_API_KEY`/`ANTHROPIC_API_KEY`, then run setup again.
The macOS app drives the same ladder through the `crestodian.setup.detect` and `crestodian.setup.activate` gateway methods: detect lists every reusable backend it finds, activate live-tests one candidate (a real "reply with OK" completion) and only persists the model, workspace, and gateway defaults after the test passes. A failing candidate never changes config; the app automatically walks down the ladder and finally offers a manual API-key step (Anthropic, OpenAI, or Google) that is verified the same way before it is saved.
## Model-assisted planner
+6 -4
View File
@@ -51,10 +51,12 @@ CLI command docs: [`openclaw onboard`](/cli/onboard)
## macOS app onboarding
Open the OpenClaw app. For local setup, the first-run flow starts the Gateway,
then opens a Crestodian conversation that detects existing AI access, proposes
the workspace and config, and applies the plan after approval. Sensitive
credentials use masked input. Remote setup connects to an already-configured
Gateway instead.
detects existing AI access (Claude Code, Codex, Gemini CLI, or API keys),
live-tests the best option, and saves it only after a real reply — falling
back automatically and offering a verified manual API-key step when nothing is
found. Sensitive credentials use masked input. Remote setup connects to an
already-configured Gateway instead, and the same AI check runs against that
Gateway.
Full reference: [Onboarding (macOS App)](/start/onboarding)
+18 -12
View File
@@ -7,9 +7,9 @@ title: "Onboarding (macOS app)"
sidebarTitle: "Onboarding: macOS App"
---
The macOS app's first-run flow: pick where the Gateway runs, complete local
setup through a Crestodian conversation, grant permissions, and hand off to
the agent's own bootstrap ritual.
The macOS app's first-run flow: pick where the Gateway runs, connect a
verified AI backend, grant permissions, and hand off to the agent's own
bootstrap ritual.
For CLI onboarding and a comparison of both paths, see [Onboarding Overview](/start/onboarding-overview).
<Steps>
@@ -65,16 +65,22 @@ Where does the **Gateway** run?
preferring npm first. Node remains the recommended runtime for the Gateway
itself. Existing compatible installations are reused.
</Step>
<Step title="Talk to Crestodian">
Local setup opens a dedicated conversation with Crestodian after the Gateway
is ready. Crestodian detects an existing Claude Code or Codex login and
supported API keys, proposes the workspace and configuration, then waits for
approval before writing anything. Next remains locked until the conversation
has authored setup state. Credential prompts use masked input; after an
ambiguous transport failure, restart the setup conversation instead of
replaying the previous turn.
<Step title="Connect your AI">
Once the Gateway is ready, onboarding looks for AI access you already have:
a Claude Code, Codex, or Gemini CLI login, or `OPENAI_API_KEY` /
`ANTHROPIC_API_KEY`. The best option is tested with a real completion and
only saved after it answers; when a test fails the app automatically tries
the next option and shows why the previous one failed. If several options
are found you can switch between them before continuing.
Remote and Configure Later flows skip this local setup conversation.
If nothing is found (or nothing works), a manual step accepts an API key for
Anthropic, OpenAI, or Google, verifies it the same way, and stores it as an
auth profile. Next remains locked until one backend has passed its live test,
so the first agent chat can never start without working inference. The
Crestodian chat stays available from this page (and later under
Settings → Crestodian) for help in plain language.
Configure Later skips this step.
</Step>
<Step title="Permissions">
+22
View File
@@ -509,6 +509,14 @@ import {
CrestodianChatParamsSchema,
type CrestodianChatResult,
CrestodianChatResultSchema,
type CrestodianSetupDetectParams,
CrestodianSetupDetectParamsSchema,
type CrestodianSetupDetectResult,
CrestodianSetupDetectResultSchema,
type CrestodianSetupActivateParams,
CrestodianSetupActivateParamsSchema,
type CrestodianSetupActivateResult,
CrestodianSetupActivateResultSchema,
type WizardCancelParams,
WizardCancelParamsSchema,
type WizardNextParams,
@@ -756,6 +764,12 @@ export const validateConfigSchemaLookupResult = lazyCompile<ConfigSchemaLookupRe
export const validateCrestodianChatParams = lazyCompile<CrestodianChatParams>(
CrestodianChatParamsSchema,
);
export const validateCrestodianSetupDetectParams = lazyCompile<CrestodianSetupDetectParams>(
CrestodianSetupDetectParamsSchema,
);
export const validateCrestodianSetupActivateParams = lazyCompile<CrestodianSetupActivateParams>(
CrestodianSetupActivateParamsSchema,
);
export const validateWizardStartParams = lazyCompile<WizardStartParams>(WizardStartParamsSchema);
export const validateWizardNextParams = lazyCompile<WizardNextParams>(WizardNextParamsSchema);
export const validateWizardCancelParams = lazyCompile<WizardCancelParams>(WizardCancelParamsSchema);
@@ -1142,6 +1156,10 @@ export {
UpdateStatusParamsSchema,
CrestodianChatParamsSchema,
CrestodianChatResultSchema,
CrestodianSetupDetectParamsSchema,
CrestodianSetupDetectResultSchema,
CrestodianSetupActivateParamsSchema,
CrestodianSetupActivateResultSchema,
WizardStartParamsSchema,
WizardNextParamsSchema,
WizardCancelParamsSchema,
@@ -1317,6 +1335,10 @@ export type {
ConfigSchemaResponse,
CrestodianChatParams,
CrestodianChatResult,
CrestodianSetupDetectParams,
CrestodianSetupDetectResult,
CrestodianSetupActivateParams,
CrestodianSetupActivateResult,
WizardStartParams,
WizardNextParams,
WizardCancelParams,
@@ -37,3 +37,90 @@ export const CrestodianChatResultSchema = Type.Object(
},
{ additionalProperties: false },
);
/**
* Structured first-run inference setup for GUI clients: detect reusable AI
* access (CLI logins, env keys, existing config), then activate one choice.
* Activation live-tests the candidate and persists it only on success, so a
* client can walk the ladder candidate-by-candidate without ever leaving a
* broken default model behind.
*/
export const CrestodianSetupDetectParamsSchema = Type.Object({}, { additionalProperties: false });
const SetupInferenceKind = Type.Union([
Type.Literal("existing-model"),
Type.Literal("openai-api-key"),
Type.Literal("anthropic-api-key"),
Type.Literal("claude-cli"),
Type.Literal("codex-cli"),
Type.Literal("gemini-cli"),
]);
export const CrestodianSetupDetectResultSchema = Type.Object(
{
candidates: Type.Array(
Type.Object(
{
kind: SetupInferenceKind,
label: NonEmptyString,
detail: Type.String(),
modelRef: NonEmptyString,
recommended: Type.Boolean(),
/** true: verified; false: definitively logged out; absent: unknown. */
credentials: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
),
),
workspace: NonEmptyString,
configuredModel: Type.Optional(Type.String()),
setupComplete: Type.Boolean(),
},
{ additionalProperties: false },
);
export const CrestodianSetupActivateParamsSchema = Type.Object(
{
kind: Type.Union([
Type.Literal("existing-model"),
Type.Literal("openai-api-key"),
Type.Literal("anthropic-api-key"),
Type.Literal("claude-cli"),
Type.Literal("codex-cli"),
Type.Literal("gemini-cli"),
Type.Literal("api-key"),
]),
/** Manual step only: provider the pasted key belongs to (anthropic/openai/google). */
provider: Type.Optional(Type.String()),
/** Manual step only: the pasted API key; masked by clients, never echoed. */
apiKey: Type.Optional(Type.String()),
workspace: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const CrestodianSetupActivateResultSchema = Type.Object(
{
ok: Type.Boolean(),
/** Present on success: the model ref that answered the live test. */
modelRef: Type.Optional(Type.String()),
latencyMs: Type.Optional(Type.Number()),
/** Human-readable setup summary lines (workspace, model, gateway). */
lines: Type.Optional(Type.Array(Type.String())),
/** Present on failure: coarse bucket for client copy + docs links. */
status: Type.Optional(
Type.Union([
Type.Literal("ok"),
Type.Literal("auth"),
Type.Literal("rate_limit"),
Type.Literal("billing"),
Type.Literal("timeout"),
Type.Literal("format"),
Type.Literal("unavailable"),
Type.Literal("unknown"),
]),
),
error: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
@@ -141,7 +141,14 @@ import {
UpdateStatusParamsSchema,
UpdateRunParamsSchema,
} from "./config.js";
import { CrestodianChatParamsSchema, CrestodianChatResultSchema } from "./crestodian.js";
import {
CrestodianChatParamsSchema,
CrestodianChatResultSchema,
CrestodianSetupActivateParamsSchema,
CrestodianSetupActivateResultSchema,
CrestodianSetupDetectParamsSchema,
CrestodianSetupDetectResultSchema,
} from "./crestodian.js";
import {
CronAddParamsSchema,
CronGetParamsSchema,
@@ -448,6 +455,10 @@ export const ProtocolSchemas = {
ConfigSchemaLookupResult: ConfigSchemaLookupResultSchema,
CrestodianChatParams: CrestodianChatParamsSchema,
CrestodianChatResult: CrestodianChatResultSchema,
CrestodianSetupDetectParams: CrestodianSetupDetectParamsSchema,
CrestodianSetupDetectResult: CrestodianSetupDetectResultSchema,
CrestodianSetupActivateParams: CrestodianSetupActivateParamsSchema,
CrestodianSetupActivateResult: CrestodianSetupActivateResultSchema,
WizardStartParams: WizardStartParamsSchema,
WizardNextParams: WizardNextParamsSchema,
WizardCancelParams: WizardCancelParamsSchema,
@@ -119,6 +119,10 @@ export type UpdateStatusParams = SchemaType<"UpdateStatusParams">;
/** Crestodian chat payloads exchanged by clients and the gateway. */
export type CrestodianChatParams = SchemaType<"CrestodianChatParams">;
export type CrestodianChatResult = SchemaType<"CrestodianChatResult">;
export type CrestodianSetupDetectParams = SchemaType<"CrestodianSetupDetectParams">;
export type CrestodianSetupDetectResult = SchemaType<"CrestodianSetupDetectResult">;
export type CrestodianSetupActivateParams = SchemaType<"CrestodianSetupActivateParams">;
export type CrestodianSetupActivateResult = SchemaType<"CrestodianSetupActivateResult">;
/** Wizard setup flow payloads exchanged by CLI, UI, and gateway. */
export type WizardStartParams = SchemaType<"WizardStartParams">;
+26 -3
View File
@@ -1,6 +1,7 @@
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readGeminiCliCredentialsCached,
} from "../agents/cli-credentials.js";
// Inference backend detection shared by onboarding bootstrap and Crestodian setup.
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
@@ -18,13 +19,15 @@ export const OPENAI_API_DEFAULT_MODEL_REF = `${DEFAULT_PROVIDER}/${DEFAULT_MODEL
export const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-8";
export const CLAUDE_CLI_DEFAULT_MODEL_REF = "claude-cli/claude-opus-4-8";
export const CODEX_APP_SERVER_DEFAULT_MODEL_REF = OPENAI_API_DEFAULT_MODEL_REF;
export const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3.1-pro-preview";
export type InferenceBackendKind =
| "existing-model"
| "openai-api-key"
| "anthropic-api-key"
| "claude-cli"
| "codex-cli";
| "codex-cli"
| "gemini-cli";
export type InferenceBackendCandidate = {
kind: InferenceBackendKind;
@@ -44,6 +47,7 @@ export type DetectInferenceBackendsDeps = {
probeLocalCommand?: typeof probeLocalCommand;
readClaudeCliCredentials?: () => { type: string } | null;
readCodexCliCredentials?: () => { type: string } | null;
readGeminiCliCredentials?: () => { type: string } | null;
};
export type DetectInferenceBackendsOptions = {
@@ -98,6 +102,9 @@ export async function detectInferenceBackends(
const readCodex =
options.deps?.readCodexCliCredentials ??
(() => readCodexCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 }));
const readGemini =
options.deps?.readGeminiCliCredentials ??
(() => readGeminiCliCredentialsCached({ ttlMs: 60_000 }));
const candidates: InferenceBackendCandidate[] = [];
const existingModel = resolveAgentModelPrimaryValue(options.config?.agents?.defaults?.model);
@@ -129,7 +136,11 @@ export async function detectInferenceBackends(
});
}
const [claudeProbe, codexProbe] = await Promise.all([probe("claude"), probe("codex")]);
const [claudeProbe, codexProbe, geminiProbe] = await Promise.all([
probe("claude"),
probe("codex"),
probe("gemini"),
]);
const cliCandidates: InferenceBackendCandidate[] = [];
if (claudeProbe.found) {
const credentials = detectCliCredentialState({
@@ -159,8 +170,20 @@ export async function detectInferenceBackends(
...(credentials === undefined ? {} : { credentials }),
});
}
if (geminiProbe.found) {
// Gemini CLI stores its OAuth login in a plain file on every platform (no
// keychain), so a missing credential file is a definitive logout signal.
const credentials = readGemini() !== null;
cliCandidates.push({
kind: "gemini-cli",
modelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
label: "Gemini CLI",
detail: describeCliDetail(credentials),
credentials,
});
}
// Stable partition: logged-out installs sink, ladder order preserved inside
// each partition (claude before codex per the documented ladder).
// each partition (claude before codex before gemini per the documented ladder).
candidates.push(
...cliCandidates.filter((candidate) => candidate.credentials !== false),
...cliCandidates.filter((candidate) => candidate.credentials === false),
+4 -2
View File
@@ -53,7 +53,8 @@ export function selectCrestodianLocalPlannerBackends(
return backends;
}
function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClawConfig {
/** Minimal run config for a CLI-harness model scoped to one workspace. */
export function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClawConfig {
return {
agents: {
defaults: {
@@ -64,7 +65,8 @@ function buildCliPlannerConfig(workspaceDir: string, modelRef: string): OpenClaw
};
}
function buildCodexAppServerPlannerConfig(workspaceDir: string): OpenClawConfig {
/** Run config for the Codex app-server harness (exec must be allowed to spawn it). */
export function buildCodexAppServerPlannerConfig(workspaceDir: string): OpenClawConfig {
return {
agents: {
defaults: {
+28 -13
View File
@@ -11,16 +11,20 @@ import { formatCrestodianOnboardingWelcome } from "./overview.js";
* On an already-configured install the welcome becomes the channels/handoff
* guide instead of re-proposing setup.
*/
export async function buildOnboardingWelcome(params: {
engine: CrestodianChatEngine;
workspace?: string;
}): Promise<string> {
const overview = await params.engine.loadOverview();
// "Configured" must match the app onboarding gate (wizard metadata or
// gateway auth), not just a model: a model-only config would otherwise get
// the ready-guide welcome while the gate stays locked, stranding the page.
/**
* "Configured" must match the app onboarding gate (wizard metadata or gateway
* auth), not just a model: a model-only config would otherwise get the
* ready-guide welcome while the gate stays locked, stranding the page.
*/
export async function loadAuthoredSetupConfig(params: {
configExists: boolean;
configValid: boolean;
}): Promise<{
authoredConfig?: import("../config/types.openclaw.js").OpenClawConfig;
hasAuthoredSetup: boolean;
}> {
const authoredConfig = await (async () => {
if (!overview.config.exists || !overview.config.valid) {
if (!params.configExists || !params.configValid) {
return undefined;
}
try {
@@ -38,10 +42,21 @@ export async function buildOnboardingWelcome(params: {
normalizeSecretInputString(auth?.token) !== undefined ||
isSecretRef(auth?.password) ||
normalizeSecretInputString(auth?.password) !== undefined;
const hasAuthoredSetup =
(authoredConfig?.wizard && Object.keys(authoredConfig.wizard).length > 0) ||
hasAuthMode ||
hasAuthSecret;
const hasWizardMetadata =
authoredConfig?.wizard !== undefined && Object.keys(authoredConfig.wizard).length > 0;
const hasAuthoredSetup = hasWizardMetadata || hasAuthMode || hasAuthSecret;
return { ...(authoredConfig ? { authoredConfig } : {}), hasAuthoredSetup };
}
export async function buildOnboardingWelcome(params: {
engine: CrestodianChatEngine;
workspace?: string;
}): Promise<string> {
const overview = await params.engine.loadOverview();
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig({
configExists: overview.config.exists,
configValid: overview.config.valid,
});
if (hasAuthoredSetup && overview.defaultModel) {
const welcome = formatCrestodianOnboardingWelcome(overview);
params.engine.noteAssistantMessage(welcome);
+1
View File
@@ -150,6 +150,7 @@ const INFERENCE_SOURCE_LABELS: Record<InferenceBackendKind, string> = {
"anthropic-api-key": "ANTHROPIC_API_KEY",
"claude-cli": "Claude Code CLI",
"codex-cli": "Codex app-server",
"gemini-cli": "Gemini CLI",
};
/**
+1 -1
View File
@@ -25,7 +25,7 @@ export type CrestodianSetupApplyResult = {
};
/** Prompter for quickstart-only flows: notes go to the log, prompts fail loud. */
function createQuickstartNotePrompter(runtime: RuntimeEnv): WizardPrompter {
export function createQuickstartNotePrompter(runtime: RuntimeEnv): WizardPrompter {
const unexpected = (kind: string) => {
throw new Error(`crestodian setup hit an interactive ${kind} prompt; quickstart must not ask`);
};
+173
View File
@@ -0,0 +1,173 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { activateSetupInference, detectSetupInference } from "./setup-inference.js";
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: vi.fn(async () => ({
exists: false,
valid: false,
path: "/tmp/openclaw.json",
issues: [],
config: {},
})),
}));
vi.mock("../commands/onboard-inference.js", async (importActual) => {
const actual = await importActual<typeof import("../commands/onboard-inference.js")>();
return {
...actual,
detectInferenceBackends: vi.fn(async () => [
{
kind: "claude-cli",
modelRef: "claude-cli/claude-opus-4-8",
label: "Claude Code",
detail: "logged in",
credentials: true,
},
{
kind: "codex-cli",
modelRef: "openai/gpt-5.5",
label: "Codex",
detail: "installed, not logged in",
credentials: false,
},
]),
};
});
const runtime = { log: () => {}, error: () => {}, exit: () => {} } as never;
async function makeTempDir(): Promise<string> {
return await fs.mkdtemp(path.join(os.tmpdir(), "setup-inference-test-"));
}
describe("detectSetupInference", () => {
it("marks the first non-logged-out candidate recommended", async () => {
const detection = await detectSetupInference();
expect(detection.candidates).toHaveLength(2);
expect(detection.candidates[0]).toMatchObject({ kind: "claude-cli", recommended: true });
expect(detection.candidates[1]).toMatchObject({ kind: "codex-cli", recommended: false });
expect(detection.setupComplete).toBe(false);
expect(detection.workspace.length).toBeGreaterThan(0);
});
});
describe("activateSetupInference", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("persists setup only after the live test succeeds", async () => {
const applySetup = vi.fn(async (_params: unknown) => ({
configPath: "/tmp/openclaw.json",
lines: ["ok"],
}));
const runCliAgent = vi.fn(async (_params: unknown) => ({
meta: { finalAssistantVisibleText: "OK" },
}));
const result = await activateSetupInference({
kind: "claude-cli",
surface: "gateway",
runtime,
deps: {
runCliAgent: runCliAgent as never,
applySetup: applySetup as never,
createTempDir: makeTempDir,
},
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.modelRef).toBe("claude-cli/claude-opus-4-8");
expect(result.lines).toEqual(["ok"]);
}
expect(runCliAgent).toHaveBeenCalledOnce();
expect(applySetup).toHaveBeenCalledOnce();
expect(applySetup.mock.calls[0]?.[0]).toMatchObject({
model: "claude-cli/claude-opus-4-8",
surface: "gateway",
});
});
it("does not touch config when the live test fails", async () => {
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] }));
const runCliAgent = vi.fn(async () => {
throw new Error("401 invalid_api_key");
});
const result = await activateSetupInference({
kind: "claude-cli",
surface: "gateway",
runtime,
deps: {
runCliAgent: runCliAgent as never,
applySetup: applySetup as never,
createTempDir: makeTempDir,
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("invalid_api_key");
}
expect(applySetup).not.toHaveBeenCalled();
});
it("treats an empty model reply as a failure", async () => {
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] }));
const runEmbeddedAgent = vi.fn(async () => ({ payloads: [] }));
const result = await activateSetupInference({
kind: "anthropic-api-key",
surface: "gateway",
runtime,
deps: {
runEmbeddedAgent: runEmbeddedAgent as never,
applySetup: applySetup as never,
createTempDir: makeTempDir,
},
});
expect(result).toMatchObject({ ok: false, status: "format" });
expect(applySetup).not.toHaveBeenCalled();
});
it("rejects manual activation without a supported provider", async () => {
const result = await activateSetupInference({
kind: "api-key",
provider: "definitely-not-a-provider",
apiKey: "sk-test",
surface: "gateway",
runtime,
deps: { createTempDir: makeTempDir },
});
expect(result).toMatchObject({ ok: false, status: "unavailable" });
});
it("runs the codex plugin ensure step only after a passing test", async () => {
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] }));
const ensureCodex = vi.fn(async () => ({
cfg: {},
required: false,
installed: false,
}));
const runEmbeddedAgent = vi.fn(async (_params: unknown) => ({
meta: { finalAssistantVisibleText: "OK" },
}));
const result = await activateSetupInference({
kind: "codex-cli",
surface: "gateway",
runtime,
deps: {
runEmbeddedAgent: runEmbeddedAgent as never,
applySetup: applySetup as never,
ensureCodexRuntimePlugin: ensureCodex as never,
createTempDir: makeTempDir,
},
});
expect(result.ok).toBe(true);
expect(ensureCodex).toHaveBeenCalledOnce();
// Harness selection: codex tests run embedded with the codex harness.
expect(runEmbeddedAgent.mock.calls[0]?.[0]).toMatchObject({
agentHarnessId: "codex",
provider: "openai",
});
});
});
+526
View File
@@ -0,0 +1,526 @@
// First-run inference activation: detect candidates, live-test, persist only on success.
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
import type { AuthProfileCredential } from "../agents/auth-profiles/types.js";
import { describeFailoverError } from "../agents/failover-error.js";
import {
isCliProvider,
normalizeProviderId,
resolveDefaultModelForAgent,
} from "../agents/model-selection.js";
import {
ANTHROPIC_API_DEFAULT_MODEL_REF,
CLAUDE_CLI_DEFAULT_MODEL_REF,
CODEX_APP_SERVER_DEFAULT_MODEL_REF,
GEMINI_CLI_DEFAULT_MODEL_REF,
OPENAI_API_DEFAULT_MODEL_REF,
detectInferenceBackends,
type InferenceBackendKind,
} from "../commands/onboard-inference.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { buildCliPlannerConfig, buildCodexAppServerPlannerConfig } from "./assistant-backends.js";
import { loadAuthoredSetupConfig } from "./onboarding-welcome.js";
import { applyCrestodianSetup, createQuickstartNotePrompter } from "./setup-apply.js";
/**
* Inference is the one required onboarding step (docs/cli/crestodian.md
* "Setup bootstrap"). This module gives structured clients (macOS app) the
* same ladder the conversation uses, with one hard guarantee: a candidate is
* persisted as the default model only after a real completion round-trips.
* A failing candidate must never leave config pointing at a broken model.
*/
export const SETUP_INFERENCE_TEST_TIMEOUT_MS = 90_000;
const SETUP_INFERENCE_TEST_PROMPT = "Reply with the single word OK. Do not use tools.";
const SETUP_INFERENCE_TEST_MAX_TOKENS = 32;
const GOOGLE_API_DEFAULT_MODEL_REF = "google/gemini-3.1-pro-preview";
/** Providers accepted for the manual API-key step, mapped to a starter model. */
const MANUAL_API_KEY_MODEL_REFS: Record<string, string> = {
anthropic: ANTHROPIC_API_DEFAULT_MODEL_REF,
openai: OPENAI_API_DEFAULT_MODEL_REF,
google: GOOGLE_API_DEFAULT_MODEL_REF,
};
export type SetupInferenceCandidate = {
kind: InferenceBackendKind;
label: string;
detail: string;
modelRef: string;
recommended: boolean;
credentials?: boolean;
};
export type SetupInferenceDetection = {
candidates: SetupInferenceCandidate[];
/** Resolved workspace the setup apply would use (display + default). */
workspace: string;
configuredModel?: string;
/** Config already carries authored setup and a default model. */
setupComplete: boolean;
};
export type SetupInferenceStatus =
| "ok"
| "auth"
| "rate_limit"
| "billing"
| "timeout"
| "format"
| "unavailable"
| "unknown";
export type ActivateSetupInferenceResult =
| { ok: true; modelRef: string; latencyMs: number; lines: string[] }
| { ok: false; status: SetupInferenceStatus; error: string };
export type ActivateSetupInferenceParams = {
kind: InferenceBackendKind | "api-key";
/** Manual step only: provider the pasted API key belongs to. */
provider?: string;
/** Manual step only: the pasted API key. Never logged. */
apiKey?: string;
workspace?: string;
surface: "cli" | "gateway";
runtime: RuntimeEnv;
deps?: ActivateSetupInferenceDeps;
};
export type ActivateSetupInferenceDeps = {
readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot;
runEmbeddedAgent?: typeof import("../agents/embedded-agent.js").runEmbeddedAgent;
runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent;
applySetup?: typeof applyCrestodianSetup;
ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection;
updateConfig?: typeof import("../commands/models/shared.js").updateConfig;
createTempDir?: () => Promise<string>;
removeTempDir?: (dir: string) => Promise<void>;
timeoutMs?: number;
};
export async function detectSetupInference(): Promise<SetupInferenceDetection> {
const { readConfigFileSnapshot } = await import("../config/config.js");
const snapshot = await readConfigFileSnapshot();
const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
const raw = await detectInferenceBackends({ config: cfg });
// Recommended = the first candidate setup itself would bootstrap with; a
// definitively logged-out CLI never gets the badge.
const recommendedIndex = raw.findIndex((candidate) => candidate.credentials !== false);
const candidates = raw.map((candidate, index) => ({
...candidate,
recommended: index === recommendedIndex,
}));
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig({
configExists: snapshot.exists,
configValid: snapshot.valid,
});
const configuredModel = raw.find((candidate) => candidate.kind === "existing-model")?.modelRef;
const { DEFAULT_WORKSPACE } = await import("../commands/onboard-helpers.js");
const workspace = resolveUserPath(
authoredConfig?.agents?.defaults?.workspace?.trim() || DEFAULT_WORKSPACE,
);
return {
candidates,
workspace,
...(configuredModel ? { configuredModel } : {}),
setupComplete: hasAuthoredSetup && Boolean(configuredModel),
};
}
type SetupInferenceTestPlan = {
runner: "cli" | "embedded";
provider: string;
model: string;
modelRef: string;
config: OpenClawConfig;
agentHarnessId?: string;
authProfileId?: string;
/** Model to persist as default on success; undefined keeps the current one. */
persistModelRef?: string;
};
type RunResult = {
payloads?: Array<{ text?: string }>;
meta?: { finalAssistantVisibleText?: string; finalAssistantRawText?: string };
};
function extractRunText(result: RunResult): string | undefined {
return (
result.meta?.finalAssistantVisibleText ??
result.meta?.finalAssistantRawText ??
result.payloads
?.map((payload) => payload.text?.trim())
.filter(Boolean)
.join("\n")
);
}
function parseRef(modelRef: string): { provider: string; model: string } {
const slash = modelRef.indexOf("/");
return slash === -1
? { provider: modelRef, model: "" }
: { provider: modelRef.slice(0, slash), model: modelRef.slice(slash + 1) };
}
function mapFailoverReasonToSetupStatus(reason?: string | null): SetupInferenceStatus {
if (reason === "auth" || reason === "auth_permanent") {
return "auth";
}
if (reason === "rate_limit" || reason === "overloaded") {
return "rate_limit";
}
if (reason === "billing") {
return "billing";
}
if (reason === "timeout") {
return "timeout";
}
if (reason === "format" || reason === "model_not_found") {
return "format";
}
return "unknown";
}
async function buildTestPlan(params: {
kind: InferenceBackendKind | "api-key";
provider?: string;
cfg: OpenClawConfig;
workspaceDir: string;
}): Promise<SetupInferenceTestPlan | { error: string }> {
const { kind, cfg, workspaceDir } = params;
switch (kind) {
case "existing-model": {
const ref = resolveDefaultModelForAgent({ cfg, agentId: resolveDefaultAgentId(cfg) });
const modelRef = `${ref.provider}/${ref.model}`;
return {
runner: isCliProvider(ref.provider, cfg) ? "cli" : "embedded",
provider: ref.provider,
model: ref.model,
modelRef,
config: cfg,
};
}
case "claude-cli": {
const ref = parseRef(CLAUDE_CLI_DEFAULT_MODEL_REF);
return {
runner: "cli",
...ref,
modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
config: buildCliPlannerConfig(workspaceDir, CLAUDE_CLI_DEFAULT_MODEL_REF),
persistModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
};
}
case "gemini-cli": {
const ref = parseRef(GEMINI_CLI_DEFAULT_MODEL_REF);
return {
runner: "cli",
...ref,
modelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
config: buildCliPlannerConfig(workspaceDir, GEMINI_CLI_DEFAULT_MODEL_REF),
persistModelRef: GEMINI_CLI_DEFAULT_MODEL_REF,
};
}
case "codex-cli": {
const ref = parseRef(CODEX_APP_SERVER_DEFAULT_MODEL_REF);
return {
runner: "embedded",
...ref,
modelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF,
config: buildCodexAppServerPlannerConfig(workspaceDir),
agentHarnessId: "codex",
persistModelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF,
};
}
case "openai-api-key": {
const ref = parseRef(OPENAI_API_DEFAULT_MODEL_REF);
return {
runner: "embedded",
...ref,
modelRef: OPENAI_API_DEFAULT_MODEL_REF,
config: buildCliPlannerConfig(workspaceDir, OPENAI_API_DEFAULT_MODEL_REF),
persistModelRef: OPENAI_API_DEFAULT_MODEL_REF,
};
}
case "anthropic-api-key": {
const ref = parseRef(ANTHROPIC_API_DEFAULT_MODEL_REF);
return {
runner: "embedded",
...ref,
modelRef: ANTHROPIC_API_DEFAULT_MODEL_REF,
config: buildCliPlannerConfig(workspaceDir, ANTHROPIC_API_DEFAULT_MODEL_REF),
persistModelRef: ANTHROPIC_API_DEFAULT_MODEL_REF,
};
}
case "api-key": {
const provider = normalizeProviderId(params.provider ?? "");
const canonical = provider === "codex" || provider === "openai-codex" ? "openai" : provider;
const modelRef = MANUAL_API_KEY_MODEL_REFS[canonical];
if (!modelRef) {
return {
error: `Unsupported provider "${params.provider ?? ""}" — expected anthropic, openai, or google.`,
};
}
const ref = parseRef(modelRef);
return {
runner: "embedded",
...ref,
modelRef,
config: buildCliPlannerConfig(workspaceDir, modelRef),
authProfileId: `${canonical}:manual`,
persistModelRef: modelRef,
};
}
default:
return { error: `Unknown inference choice "${String(kind)}".` };
}
}
/**
* Test one candidate with a real completion, then persist it as the setup
* default. Manual API keys are staged into the auth store for the test and
* rolled back when the test fails, so a bad key leaves no trace.
*/
export async function activateSetupInference(
params: ActivateSetupInferenceParams,
): Promise<ActivateSetupInferenceResult> {
const deps = params.deps ?? {};
const readSnapshot =
deps.readConfigFileSnapshot ?? (await import("../config/config.js")).readConfigFileSnapshot;
const snapshot = await readSnapshot();
const cfg: OpenClawConfig =
snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
const tempDir = await (
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
)();
try {
const plan = await buildTestPlan({
kind: params.kind,
...(params.provider !== undefined ? { provider: params.provider } : {}),
cfg,
workspaceDir: tempDir,
});
if ("error" in plan) {
return { ok: false, status: "unavailable", error: plan.error };
}
const agentDir = resolveAgentDir(cfg, resolveDefaultAgentId(cfg));
let stagedProfile: { profileId: string; prior?: AuthProfileCredential } | null = null;
if (plan.authProfileId) {
const apiKey = params.apiKey?.trim();
if (!apiKey) {
return { ok: false, status: "unavailable", error: "Enter an API key first." };
}
stagedProfile = await stageManualApiKeyProfile({
profileId: plan.authProfileId,
provider: plan.provider,
apiKey,
agentDir,
});
if (!stagedProfile) {
return {
ok: false,
status: "unknown",
error: "Could not update the auth profile store; try again in a moment.",
};
}
}
const test = await runSetupInferenceTest({ plan, tempDir, deps });
if (!test.ok) {
if (stagedProfile) {
await rollbackManualApiKeyProfile({ ...stagedProfile, agentDir });
}
return test;
}
// Test passed — persist. Codex routes openai/* through the Codex plugin,
// so make sure it is installed/enabled before the model ref lands in config.
if (params.kind === "codex-cli") {
const ensureCodex =
deps.ensureCodexRuntimePlugin ??
(await import("../commands/codex-runtime-plugin-install.js"))
.ensureCodexRuntimePluginForModelSelection;
const ensured = await ensureCodex({
cfg,
model: plan.modelRef,
prompter: createQuickstartNotePrompter(params.runtime),
runtime: params.runtime,
workspaceDir: tempDir,
});
if (ensured.required) {
const updateConfig =
deps.updateConfig ?? (await import("../commands/models/shared.js")).updateConfig;
const { enablePluginInConfig } = await import("../plugins/enable.js");
await updateConfig((current) => enablePluginInConfig(current, "codex").config);
}
}
if (stagedProfile && plan.authProfileId) {
const updateConfig =
deps.updateConfig ?? (await import("../commands/models/shared.js")).updateConfig;
const { applyAuthProfileConfig } = await import("../plugins/provider-auth-helpers.js");
const profileId = plan.authProfileId;
const provider = plan.provider;
await updateConfig((current) =>
applyAuthProfileConfig(current, { profileId, provider, mode: "api_key" }),
);
}
const applySetup = deps.applySetup ?? applyCrestodianSetup;
const detection = params.workspace?.trim()
? { workspace: resolveUserPath(params.workspace) }
: { workspace: (await detectSetupInference()).workspace };
const applied = await applySetup({
workspace: detection.workspace,
...(plan.persistModelRef ? { model: plan.persistModelRef } : {}),
surface: params.surface,
runtime: params.runtime,
});
return { ok: true, modelRef: plan.modelRef, latencyMs: test.latencyMs, lines: applied.lines };
} finally {
await (deps.removeTempDir ?? ((dir: string) => fs.rm(dir, { recursive: true, force: true })))(
tempDir,
);
}
}
async function stageManualApiKeyProfile(params: {
profileId: string;
provider: string;
apiKey: string;
agentDir: string;
}): Promise<{ profileId: string; prior?: AuthProfileCredential } | null> {
let prior: AuthProfileCredential | undefined;
const updated = await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
updater: (store) => {
prior = store.profiles[params.profileId];
return false;
},
});
if (updated === null) {
return null;
}
const upserted = await upsertAuthProfileWithLock({
profileId: params.profileId,
credential: { type: "api_key", provider: params.provider, key: params.apiKey },
agentDir: params.agentDir,
});
if (upserted === null) {
return null;
}
return { profileId: params.profileId, ...(prior ? { prior } : {}) };
}
async function rollbackManualApiKeyProfile(params: {
profileId: string;
prior?: AuthProfileCredential;
agentDir: string;
}): Promise<void> {
await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
updater: (store) => {
if (params.prior) {
store.profiles[params.profileId] = params.prior;
} else {
delete store.profiles[params.profileId];
}
return true;
},
});
}
async function runSetupInferenceTest(params: {
plan: SetupInferenceTestPlan;
tempDir: string;
deps: ActivateSetupInferenceDeps;
}): Promise<
{ ok: true; latencyMs: number } | { ok: false; status: SetupInferenceStatus; error: string }
> {
const { plan, tempDir, deps } = params;
const runId = `setup-inference-${randomUUID()}`;
const sessionId = `${runId}-session`;
const sessionFile = path.join(tempDir, "session.jsonl");
const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS;
const started = Date.now();
try {
let result: RunResult;
if (plan.runner === "cli") {
const runCli = deps.runCliAgent ?? (await import("../agents/cli-runner.js")).runCliAgent;
result = (await runCli({
sessionId,
sessionKey: `temp:setup-inference:${runId}`,
agentId: "crestodian",
trigger: "manual",
sessionFile,
workspaceDir: tempDir,
config: plan.config,
prompt: SETUP_INFERENCE_TEST_PROMPT,
provider: plan.provider,
model: plan.model,
timeoutMs,
runId,
messageChannel: "crestodian",
messageProvider: "crestodian",
cleanupCliLiveSessionOnRunEnd: true,
})) as RunResult;
} else {
const runEmbedded =
deps.runEmbeddedAgent ?? (await import("../agents/embedded-agent.js")).runEmbeddedAgent;
result = (await runEmbedded({
sessionId,
sessionKey: `temp:setup-inference:${runId}`,
agentId: "crestodian",
trigger: "manual",
sessionFile,
workspaceDir: tempDir,
config: plan.config,
prompt: SETUP_INFERENCE_TEST_PROMPT,
provider: plan.provider,
model: plan.model,
...(plan.authProfileId
? { authProfileId: plan.authProfileId, authProfileIdSource: "user" as const }
: {}),
...(plan.agentHarnessId
? { agentHarnessId: plan.agentHarnessId, cleanupBundleMcpOnRunEnd: true }
: {}),
timeoutMs,
runId,
lane: `setup-inference:${plan.provider}`,
thinkLevel: "off",
reasoningLevel: "off",
verboseLevel: "off",
streamParams: { maxTokens: SETUP_INFERENCE_TEST_MAX_TOKENS },
disableTools: true,
modelRun: true,
messageChannel: "crestodian",
messageProvider: "crestodian",
})) as RunResult;
}
const text = extractRunText(result)?.trim();
if (!text) {
return {
ok: false,
status: "format",
error: "The model started but did not send a reply. Try again or pick another option.",
};
}
return { ok: true, latencyMs: Date.now() - started };
} catch (error) {
const described = describeFailoverError(error);
const { redactSecrets } = await import("../commands/status-all/format.js");
return {
ok: false,
status: mapFailoverReasonToSetupStatus(described.reason),
error: redactSecrets(described.message),
};
}
}
+2
View File
@@ -67,6 +67,8 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "plugins.uiDescriptors", scope: "operator.read" },
{ name: "plugins.sessionAction", scope: "dynamic" },
{ name: "crestodian.chat", scope: "operator.admin" },
{ name: "crestodian.setup.detect", scope: "operator.admin" },
{ name: "crestodian.setup.activate", scope: "operator.admin" },
{ name: "wizard.start", scope: "operator.admin" },
{ name: "wizard.next", scope: "operator.admin" },
{ name: "wizard.cancel", scope: "operator.admin" },
+1 -1
View File
@@ -435,7 +435,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
loadHandlers: loadWizardHandlers,
}),
...createLazyCoreHandlers({
methods: ["crestodian.chat"],
methods: ["crestodian.chat", "crestodian.setup.detect", "crestodian.setup.activate"],
loadHandlers: loadCrestodianHandlers,
}),
...createLazyCoreHandlers({
+56 -1
View File
@@ -1,8 +1,13 @@
// Crestodian gateway methods host the setup/repair conversation for clients.
import { validateCrestodianChatParams } from "../../../packages/gateway-protocol/src/index.js";
import {
validateCrestodianChatParams,
validateCrestodianSetupActivateParams,
validateCrestodianSetupDetectParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { CrestodianChatEngine } from "../../crestodian/chat-engine.js";
import { buildOnboardingWelcome } from "../../crestodian/onboarding-welcome.js";
import { formatCrestodianStartupMessage } from "../../crestodian/overview.js";
import { defaultRuntime } from "../../runtime.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -43,6 +48,56 @@ async function evictOldestSession(sessions: Map<string, CrestodianChatSession>):
}
export const crestodianHandlers: GatewayRequestHandlers = {
/** Structured onboarding: list reusable AI access on this host. */
"crestodian.setup.detect": async ({ params, respond }) => {
if (
!assertValidParams(
params,
validateCrestodianSetupDetectParams,
"crestodian.setup.detect",
respond,
)
) {
return;
}
const { detectSetupInference } = await import("../../crestodian/setup-inference.js");
respond(true, await detectSetupInference(), undefined);
},
/**
* Structured onboarding: live-test one candidate and persist it on success.
* Serialized per gateway process implicitly by the app driving one attempt
* at a time; a failed attempt never mutates config (see setup-inference.ts).
*/
"crestodian.setup.activate": async ({ params, respond }) => {
if (
!assertValidParams(
params,
validateCrestodianSetupActivateParams,
"crestodian.setup.activate",
respond,
)
) {
return;
}
const { activateSetupInference } = await import("../../crestodian/setup-inference.js");
const runtime = {
...defaultRuntime,
// Setup runs inside the gateway process; a failing sub-step must reject
// the RPC, never exit the daemon.
exit: (code: number | undefined): never => {
throw new Error(`setup step exited with code ${String(code)}`);
},
};
const result = await activateSetupInference({
kind: params.kind,
...(params.provider !== undefined ? { provider: params.provider } : {}),
...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}),
...(params.workspace !== undefined ? { workspace: params.workspace } : {}),
surface: "gateway",
runtime,
});
respond(true, result, undefined);
},
"crestodian.chat": async ({ params, respond, context }) => {
if (!assertValidParams(params, validateCrestodianChatParams, "crestodian.chat", respond)) {
return;