feat(macos): load provider catalog during AI onboarding (#101132)

* feat(macos): load onboarding providers from gateway

* test(crestodian): widen setup config mock

* fix(crestodian): satisfy onboarding lint gate

* chore(macos): refresh onboarding localization inventory

* test(plugins): cover guided Copilot secret metadata
This commit is contained in:
Peter Steinberger 2026-07-06 20:57:56 +01:00 committed by GitHub
parent 3accc99b43
commit 80537c1ba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1145 additions and 272 deletions

View file

@ -18361,33 +18361,9 @@
"surface": "apple",
"id": "native.apple.dca2e9174fec56ce"
},
{
"kind": "conditional-branch",
"line": 56,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Claude (Anthropic)",
"surface": "apple",
"id": "native.apple.99c49ed9c135cdcd"
},
{
"kind": "conditional-branch",
"line": 57,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "OpenAI",
"surface": "apple",
"id": "native.apple.5c0a3a27717239d6"
},
{
"kind": "conditional-branch",
"line": 58,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Google Gemini",
"surface": "apple",
"id": "native.apple.f85f71cced8225d6"
},
{
"kind": "ui-call",
"line": 371,
"line": 377,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Looking for AI you already use…",
"surface": "apple",
@ -18395,7 +18371,7 @@
},
{
"kind": "ui-call",
"line": 373,
"line": 379,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Checking for Claude Code, Codex, Gemini, and saved API keys.",
"surface": "apple",
@ -18403,7 +18379,7 @@
},
{
"kind": "ui-named-argument",
"line": 403,
"line": 409,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Couldnt check this Mac for AI accounts",
"surface": "apple",
@ -18411,7 +18387,15 @@
},
{
"kind": "ui-named-argument",
"line": 406,
"line": 420,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Couldnt load the full provider list",
"surface": "apple",
"id": "native.apple.3b56fbfb9200f050"
},
{
"kind": "ui-named-argument",
"line": 423,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Try again",
"surface": "apple",
@ -18419,7 +18403,7 @@
},
{
"kind": "ui-named-argument",
"line": 414,
"line": 431,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "None of the found options worked",
"surface": "apple",
@ -18427,23 +18411,15 @@
},
{
"kind": "ui-named-argument",
"line": 415,
"line": 432,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "The details are listed on each option above. You can fix the login and retry, or connect with an API key below.",
"source": "The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.",
"surface": "apple",
"id": "native.apple.86f29266102f1bf3"
},
{
"kind": "ui-named-argument",
"line": 417,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Check again",
"surface": "apple",
"id": "native.apple.ff87d5ec9e15e581"
"id": "native.apple.9947fd4c5e97875f"
},
{
"kind": "ui-call",
"line": 432,
"line": 449,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Need help? Chat with Crestodian",
"surface": "apple",
@ -18451,7 +18427,7 @@
},
{
"kind": "ui-call",
"line": 445,
"line": 462,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Your AI is ready",
"surface": "apple",
@ -18459,7 +18435,7 @@
},
{
"kind": "ui-call",
"line": 462,
"line": 479,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "No AI accounts found on this Mac",
"surface": "apple",
@ -18467,39 +18443,63 @@
},
{
"kind": "ui-call-concatenated",
"line": 464,
"line": 481,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "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”.",
"source": "Thats fine — you can connect one with an API key or token. If you use Claude Code, Codex, or the Gemini CLI on this Mac, sign in there first and hit “Check again”.",
"surface": "apple",
"id": "native.apple.f87ba9e9eb97d8bb"
"id": "native.apple.08d6423b6ed142a8"
},
{
"kind": "ui-call",
"line": 496,
"line": 513,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Recommended",
"surface": "apple",
"id": "native.apple.f51a11c7f56bdb29"
},
{
"kind": "ui-call",
"line": 585,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Connect with an API key instead…",
"surface": "apple",
"id": "native.apple.361c4a91a6aef57b"
},
{
"kind": "ui-call",
"kind": "ui-named-argument",
"line": 596,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Connect with an API key",
"source": "No key-based providers are available",
"surface": "apple",
"id": "native.apple.99c1034543042eca"
"id": "native.apple.d13850075df8f0f3"
},
{
"kind": "ui-named-argument",
"line": 597,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Enable or install a text-inference provider plugin on this Gateway, then check again.",
"surface": "apple",
"id": "native.apple.0dd754dd6da55727"
},
{
"kind": "ui-named-argument",
"line": 599,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Check again",
"surface": "apple",
"id": "native.apple.ff87d5ec9e15e581"
},
{
"kind": "ui-call",
"line": 599,
"line": 611,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Connect with an API key or token instead…",
"surface": "apple",
"id": "native.apple.459cc3789bc7128d"
},
{
"kind": "ui-call",
"line": 622,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Connect with an API key or token",
"surface": "apple",
"id": "native.apple.8347a2f698a63735"
},
{
"kind": "ui-call",
"line": 625,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Provider",
"surface": "apple",
@ -18507,23 +18507,23 @@
},
{
"kind": "ui-call",
"line": 619,
"line": 633,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "API key or token",
"surface": "apple",
"id": "native.apple.cb3d8a8506943d3a"
},
{
"kind": "ui-call",
"line": 645,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Connect",
"surface": "apple",
"id": "native.apple.41d117fdc784999f"
},
{
"kind": "ui-call-concatenated",
"line": 627,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Create a key at \\(self.model.manualProvider.consoleName), paste it here, and OpenClaw checks it with a real test question.",
"surface": "apple",
"id": "native.apple.3863947506f62f17"
},
{
"kind": "ui-named-argument",
"line": 634,
"line": 658,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "That key didnt work",
"surface": "apple",
@ -18531,7 +18531,7 @@
},
{
"kind": "ui-call",
"line": 651,
"line": 683,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Crestodian — setup helper",
"surface": "apple",
@ -18539,7 +18539,7 @@
},
{
"kind": "ui-call",
"line": 654,
"line": 686,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Done",
"surface": "apple",
@ -18547,7 +18547,7 @@
},
{
"kind": "ui-call",
"line": 695,
"line": 727,
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift",
"source": "Open help…",
"surface": "apple",

View file

@ -42,38 +42,10 @@ final class OnboardingAISetupModel {
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"
}
}
struct ManualProvider: Identifiable, Equatable, Decodable {
let id: String
let label: String
let hint: String?
}
private(set) var phase: Phase = .idle {
@ -86,6 +58,9 @@ final class OnboardingAISetupModel {
}
private(set) var candidates: [Candidate] = []
private(set) var manualProviders: [ManualProvider] = []
private(set) var providerCatalogLoaded = false
private(set) var providerCatalogError: String?
private(set) var statuses: [String: CandidateStatus] = [:]
private(set) var selectedKind: String?
private(set) var connectedModelRef: String?
@ -94,12 +69,16 @@ final class OnboardingAISetupModel {
/// Set once every detected candidate failed; opens the manual key form.
private(set) var exhaustedAutoCandidates = false
var manualProvider: ManualProvider = .anthropic
var manualProviderID = ""
var manualKey: String = ""
private(set) var manualTesting = false
private(set) var manualError: String?
var showManualEntry = false
var selectedManualProvider: ManualProvider? {
self.manualProviders.first { $0.id == self.manualProviderID }
}
var connected: Bool {
self.phase == .connected
}
@ -125,6 +104,7 @@ final class OnboardingAISetupModel {
}
let candidates: [DetectedCandidate]
let manualProviders: [ManualProvider]?
let workspace: String
let configuredModel: String?
let setupComplete: Bool
@ -148,6 +128,9 @@ final class OnboardingAISetupModel {
self.attemptToken = UUID()
self.phase = .idle
self.candidates = []
self.manualProviders = []
self.providerCatalogLoaded = false
self.providerCatalogError = nil
self.statuses = [:]
self.selectedKind = nil
self.detectError = nil
@ -162,6 +145,7 @@ final class OnboardingAISetupModel {
let token = self.attemptToken
self.phase = .detecting
self.detectError = nil
self.providerCatalogError = nil
do {
let data = try await GatewayConnection.shared.request(
method: "crestodian.setup.detect",
@ -170,6 +154,7 @@ final class OnboardingAISetupModel {
retryTransportFailures: true)
guard token == self.attemptToken else { return }
let result = try JSONDecoder().decode(DetectResult.self, from: data)
let manualProviders = result.manualProviders ?? []
self.candidates = result.candidates.map { detected in
Candidate(
kind: detected.kind,
@ -179,6 +164,14 @@ final class OnboardingAISetupModel {
recommended: detected.recommended,
credentials: detected.credentials)
}
self.manualProviders = manualProviders
self.providerCatalogLoaded = result.manualProviders != nil
if result.manualProviders == nil {
self.providerCatalogError = OnboardingAISetupError.providerCatalogUnavailable.localizedDescription
}
if !manualProviders.contains(where: { $0.id == self.manualProviderID }) {
self.manualProviderID = manualProviders.first?.id ?? ""
}
for candidate in self.candidates {
self.statuses[candidate.kind] = .untried
}
@ -188,7 +181,7 @@ final class OnboardingAISetupModel {
// stays one click away while the test runs server-side.
await self.activate(kind: first.kind)
} else {
self.showManualEntry = true
self.showManualEntry = !self.manualProviders.isEmpty
}
} catch {
guard token == self.attemptToken else { return }
@ -258,7 +251,7 @@ final class OnboardingAISetupModel {
func submitManualKey() {
let key = self.manualKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty, !self.manualTesting else { return }
guard let provider = self.selectedManualProvider, !key.isEmpty, !self.manualTesting else { return }
self.manualError = nil
self.manualTesting = true
let token = self.attemptToken
@ -269,7 +262,7 @@ final class OnboardingAISetupModel {
method: "crestodian.setup.activate",
params: [
"kind": AnyCodable("api-key"),
"provider": AnyCodable(self.manualProvider.rawValue),
"authChoice": AnyCodable(provider.id),
"apiKey": AnyCodable(key),
],
timeoutMs: 150_000,
@ -281,7 +274,7 @@ final class OnboardingAISetupModel {
self.finishConnected(kind: "api-key", result: result)
} else {
self.manualError = Self.friendlyFailure(
label: self.manualProvider.title,
label: provider.label,
status: result.status,
error: result.error)
}
@ -333,7 +326,8 @@ final class OnboardingAISetupModel {
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 label = self.candidates.first { $0.kind == self.selectedKind }?.label ??
(self.selectedKind == "api-key" ? self.selectedManualProvider?.label : nil)
let via = label.map { " via \($0)" } ?? ""
if let latency = self.connectedLatencyMs {
let seconds = Double(latency) / 1000
@ -343,6 +337,18 @@ final class OnboardingAISetupModel {
}
}
private enum OnboardingAISetupError: LocalizedError {
case providerCatalogUnavailable
var errorDescription: String? {
switch self {
case .providerCatalogUnavailable:
"The Gateway is running an older OpenClaw version that doesnt provide the " +
"supported provider list. Update OpenClaw on the gateway, then try again."
}
}
}
struct OnboardingAISetupView: View {
@Bindable var model: OnboardingAISetupModel
@State private var showCrestodianChat = false
@ -409,10 +415,21 @@ struct OnboardingAISetupView: View {
}
}
if let providerCatalogError = self.model.providerCatalogError {
OnboardingErrorCard(
title: "Couldnt load the full provider list",
message: providerCatalogError,
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.",
message: "The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.",
docsSlug: "concepts/model-providers",
retryTitle: "Check again")
{
@ -420,7 +437,7 @@ struct OnboardingAISetupView: View {
}
}
if !self.model.connected {
if !self.model.connected, self.model.providerCatalogLoaded {
self.manualSection
}
@ -462,7 +479,7 @@ struct OnboardingAISetupView: View {
Text("No AI accounts found on this Mac")
.font(.headline)
Text(
"Thats fine — you can connect one with an API key. " +
"Thats fine — you can connect one with an API key or token. " +
"If you use Claude Code, Codex, or the Gemini CLI on this Mac, " +
"sign in there first and hit “Check again”.")
.font(.subheadline)
@ -574,7 +591,16 @@ struct OnboardingAISetupView: View {
private var manualSection: some View {
VStack(alignment: .leading, spacing: 10) {
if self.model.candidates.isEmpty || self.model.showManualEntry {
if self.model.manualProviders.isEmpty {
OnboardingErrorCard(
title: "No key-based providers are available",
message: "Enable or install a text-inference provider plugin on this Gateway, then check again.",
docsSlug: "concepts/model-providers",
retryTitle: "Check again")
{
self.model.retryFromScratch()
}
} else if self.model.candidates.isEmpty || self.model.showManualEntry {
self.manualForm
} else {
Button {
@ -582,7 +608,7 @@ struct OnboardingAISetupView: View {
self.model.showManualEntry = true
}
} label: {
Label("Connect with an API key instead…", systemImage: "key")
Label("Connect with an API key or token instead…", systemImage: "key")
.font(.callout)
}
.buttonStyle(.link)
@ -593,18 +619,18 @@ struct OnboardingAISetupView: View {
private var manualForm: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Connect with an API key")
Text("Connect with an API key or token")
.font(.headline)
HStack(spacing: 8) {
Picker("Provider", selection: self.$model.manualProvider) {
ForEach(OnboardingAISetupModel.ManualProvider.allCases) { provider in
Text(provider.title).tag(provider)
Picker("Provider", selection: self.$model.manualProviderID) {
ForEach(self.model.manualProviders) { provider in
Text(provider.label).tag(provider.id)
}
}
.labelsHidden()
.frame(width: 170)
.frame(width: 230)
SecureField(self.model.manualProvider.keyHint, text: self.$model.manualKey)
SecureField("API key or token", text: self.$model.manualKey)
.textFieldStyle(.roundedBorder)
.onSubmit { self.model.submitManualKey() }
@ -624,9 +650,7 @@ struct OnboardingAISetupView: View {
.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.")
Text(self.manualProviderHelp)
.font(.caption)
.foregroundStyle(.secondary)
if let manualError = self.model.manualError {
@ -645,6 +669,14 @@ struct OnboardingAISetupView: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
private var manualProviderHelp: String {
let hint = self.model.selectedManualProvider?.hint?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let hint, !hint.isEmpty else {
return "Paste the key or token here, and OpenClaw checks it with a real test question."
}
return "\(hint). Paste it here, and OpenClaw checks it with a real test question."
}
private var crestodianSheet: some View {
VStack(spacing: 8) {
HStack {

View file

@ -3665,17 +3665,20 @@ public struct CrestodianSetupDetectParams: Codable, Sendable {}
public struct CrestodianSetupDetectResult: Codable, Sendable {
public let candidates: [[String: AnyCodable]]
public let manualproviders: [[String: AnyCodable]]
public let workspace: String
public let configuredmodel: String?
public let setupcomplete: Bool
public init(
candidates: [[String: AnyCodable]],
manualproviders: [[String: AnyCodable]],
workspace: String,
configuredmodel: String?,
setupcomplete: Bool)
{
self.candidates = candidates
self.manualproviders = manualproviders
self.workspace = workspace
self.configuredmodel = configuredmodel
self.setupcomplete = setupcomplete
@ -3683,6 +3686,7 @@ public struct CrestodianSetupDetectResult: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case candidates
case manualproviders = "manualProviders"
case workspace
case configuredmodel = "configuredModel"
case setupcomplete = "setupComplete"
@ -3691,25 +3695,25 @@ public struct CrestodianSetupDetectResult: Codable, Sendable {
public struct CrestodianSetupActivateParams: Codable, Sendable {
public let kind: AnyCodable
public let provider: String?
public let authchoice: String?
public let apikey: String?
public let workspace: String?
public init(
kind: AnyCodable,
provider: String?,
authchoice: String?,
apikey: String?,
workspace: String?)
{
self.kind = kind
self.provider = provider
self.authchoice = authchoice
self.apikey = apikey
self.workspace = workspace
}
private enum CodingKeys: String, CodingKey {
case kind
case provider
case authchoice = "authChoice"
case apikey = "apiKey"
case workspace
}

View file

@ -36,7 +36,7 @@ Interactive Crestodian opens the same TUI shell as `openclaw tui`, with a Cresto
It does not dump secrets or load plugin CLI commands just to start.
Use `status` for the detailed inventory: config path, docs/source paths, local CLI probes, API-key presence, agents, model, and Gateway details.
Use `status` for the detailed inventory: config path, docs/source paths, local CLI probes, key/token presence, agents, model, and Gateway details.
Crestodian uses the same reference discovery as regular agents: in a Git checkout it points at local `docs/` and the source tree; in an npm install it uses bundled docs and links to [https://github.com/openclaw/openclaw](https://github.com/openclaw/openclaw), with guidance to check source when docs are not enough.
@ -119,7 +119,7 @@ When no model is configured, setup picks the first usable backend in this order
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.
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 key/token step populated from the Gateway's active text-inference provider plugins. The selected provider owns its starter model and config, and the credential is verified the same way before it is saved.
## AI conversation

View file

@ -73,12 +73,13 @@ Where does the **Gateway** run?
the next option and shows why the previous one failed. If several options
are found you can switch between them before continuing.
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.
If nothing is found (or nothing works), the manual key/token picker loads the
Gateway's active text-inference provider plugins instead of using a fixed app
list. The selected provider supplies its starter model and config; OpenClaw
verifies the credential with the same live test before storing its auth profile. Next
remains locked until one backend has passed, so the first agent chat cannot
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>

View file

@ -222,6 +222,7 @@
"provider": "anthropic",
"method": "setup-token",
"choiceId": "setup-token",
"appGuidedSecret": true,
"choiceLabel": "Anthropic setup-token",
"choiceHint": "Manual token path",
"assistantPriority": 40,
@ -234,6 +235,7 @@
"provider": "anthropic",
"method": "api-key",
"choiceId": "apiKey",
"appGuidedSecret": true,
"choiceLabel": "Anthropic API key",
"groupId": "anthropic",
"groupLabel": "Anthropic",

View file

@ -18,6 +18,7 @@
"provider": "arcee",
"method": "arcee-platform",
"choiceId": "arceeai-api-key",
"appGuidedSecret": true,
"choiceLabel": "Arcee AI API key",
"choiceHint": "Direct (chat.arcee.ai)",
"groupId": "arcee",
@ -32,6 +33,7 @@
"provider": "arcee",
"method": "openrouter",
"choiceId": "arceeai-openrouter",
"appGuidedSecret": true,
"choiceLabel": "OpenRouter API key",
"choiceHint": "Via OpenRouter (openrouter.ai)",
"groupId": "arcee",

View file

@ -149,6 +149,7 @@
"provider": "byteplus",
"method": "api-key",
"choiceId": "byteplus-api-key",
"appGuidedSecret": true,
"choiceLabel": "BytePlus API key",
"groupId": "byteplus",
"groupLabel": "BytePlus",

View file

@ -98,6 +98,7 @@
"provider": "cerebras",
"method": "api-key",
"choiceId": "cerebras-api-key",
"appGuidedSecret": true,
"choiceLabel": "Cerebras API key",
"groupId": "cerebras",
"groupLabel": "Cerebras",

View file

@ -41,6 +41,7 @@
"provider": "chutes",
"method": "api-key",
"choiceId": "chutes-api-key",
"appGuidedSecret": true,
"choiceLabel": "Chutes API key",
"choiceHint": "Open-source models including Llama, DeepSeek, and more",
"groupId": "chutes",

View file

@ -51,6 +51,7 @@
"provider": "cohere",
"method": "api-key",
"choiceId": "cohere-api-key",
"appGuidedSecret": true,
"choiceLabel": "Cohere API key",
"groupId": "cohere",
"groupLabel": "Cohere",

View file

@ -185,6 +185,7 @@
"provider": "deepinfra",
"method": "api-key",
"choiceId": "deepinfra-api-key",
"appGuidedSecret": true,
"choiceLabel": "DeepInfra API key",
"choiceHint": "Unified API for open source models",
"groupId": "deepinfra",

View file

@ -123,6 +123,7 @@
"provider": "deepseek",
"method": "api-key",
"choiceId": "deepseek-api-key",
"appGuidedSecret": true,
"choiceLabel": "DeepSeek API key",
"groupId": "deepseek",
"groupLabel": "DeepSeek",

View file

@ -18,6 +18,7 @@
"provider": "fireworks",
"method": "api-key",
"choiceId": "fireworks-api-key",
"appGuidedSecret": true,
"choiceLabel": "Fireworks API key",
"groupId": "fireworks",
"groupLabel": "Fireworks",

View file

@ -431,6 +431,7 @@ export default definePluginEntry({
label: "GitHub device login",
hint: "Browser device-code flow",
kind: "device_code",
starterModel: DEFAULT_COPILOT_MODEL,
run: async (ctx) => await runGitHubCopilotAuth(ctx),
runNonInteractive: async (ctx) => await runGitHubCopilotNonInteractiveAuth(ctx),
},

View file

@ -169,6 +169,7 @@
"provider": "github-copilot",
"method": "device",
"choiceId": "github-copilot",
"appGuidedSecret": true,
"choiceLabel": "GitHub Copilot",
"choiceHint": "Device login with your GitHub account",
"groupId": "copilot",

View file

@ -43,6 +43,7 @@
"provider": "gmi",
"method": "api-key",
"choiceId": "gmi-api-key",
"appGuidedSecret": true,
"choiceLabel": "GMI Cloud API key",
"choiceHint": "OpenAI-compatible GMI Cloud endpoint",
"groupId": "gmi",

View file

@ -628,6 +628,7 @@
"provider": "google",
"method": "api-key",
"choiceId": "gemini-api-key",
"appGuidedSecret": true,
"choiceLabel": "Google Gemini API key",
"groupId": "google",
"groupLabel": "Google",

View file

@ -27,6 +27,22 @@
}
]
},
"providerAuthChoices": [
{
"provider": "groq",
"method": "api-key",
"choiceId": "groq-api-key",
"appGuidedSecret": true,
"choiceLabel": "Groq API key",
"choiceHint": "Fast OpenAI-compatible inference",
"groupId": "groq",
"groupLabel": "Groq",
"optionKey": "groqApiKey",
"cliFlag": "--groq-api-key",
"cliOption": "--groq-api-key <key>",
"cliDescription": "Groq API key"
}
],
"modelCatalog": {
"providers": {
"groq": {

View file

@ -26,6 +26,7 @@
"provider": "huggingface",
"method": "api-key",
"choiceId": "huggingface-api-key",
"appGuidedSecret": true,
"choiceLabel": "Hugging Face API key",
"choiceHint": "Inference API (HF token)",
"groupId": "huggingface",

View file

@ -30,6 +30,7 @@
"provider": "kilocode",
"method": "api-key",
"choiceId": "kilocode-api-key",
"appGuidedSecret": true,
"choiceLabel": "Kilo Gateway API key",
"choiceHint": "API key (OpenRouter-compatible)",
"groupId": "kilocode",

View file

@ -54,6 +54,7 @@
"provider": "kimi",
"method": "api-key",
"choiceId": "kimi-code-api-key",
"appGuidedSecret": true,
"choiceLabel": "Kimi Code API key (subscription)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.6)",

View file

@ -18,6 +18,7 @@
"provider": "litellm",
"method": "api-key",
"choiceId": "litellm-api-key",
"appGuidedSecret": true,
"choiceLabel": "LiteLLM API key",
"choiceHint": "Unified gateway for 100+ LLM providers",
"groupId": "litellm",

View file

@ -38,6 +38,7 @@
"provider": "lmstudio",
"method": "custom",
"choiceId": "lmstudio",
"appGuidedSecret": true,
"choiceLabel": "LM Studio",
"choiceHint": "Local/self-hosted LM Studio server",
"optionKey": "lmstudioApiKey",

View file

@ -41,6 +41,7 @@
"provider": "minimax",
"method": "api-global",
"choiceId": "minimax-global-api",
"appGuidedSecret": true,
"deprecatedChoiceIds": ["minimax", "minimax-api", "minimax-cloud", "minimax-api-lightning"],
"choiceLabel": "MiniMax API key (Global)",
"choiceHint": "Global endpoint - api.minimax.io",
@ -66,6 +67,7 @@
"provider": "minimax",
"method": "api-cn",
"choiceId": "minimax-cn-api",
"appGuidedSecret": true,
"deprecatedChoiceIds": ["minimax-api-key-cn"],
"choiceLabel": "MiniMax API key (CN)",
"choiceHint": "CN endpoint - api.minimaxi.com",

View file

@ -152,6 +152,7 @@
"provider": "mistral",
"method": "api-key",
"choiceId": "mistral-api-key",
"appGuidedSecret": true,
"choiceLabel": "Mistral API key",
"groupId": "mistral",
"groupLabel": "Mistral AI",

View file

@ -151,6 +151,7 @@
"provider": "moonshot",
"method": "api-key",
"choiceId": "moonshot-api-key",
"appGuidedSecret": true,
"choiceLabel": "Moonshot API key (.ai)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.6)",
@ -164,6 +165,7 @@
"provider": "moonshot",
"method": "api-key-cn",
"choiceId": "moonshot-api-key-cn",
"appGuidedSecret": true,
"choiceLabel": "Moonshot API key (.cn)",
"groupId": "moonshot",
"groupLabel": "Moonshot AI (Kimi K2.6)",

View file

@ -41,6 +41,7 @@
"provider": "novita",
"method": "api-key",
"choiceId": "novita-api-key",
"appGuidedSecret": true,
"choiceLabel": "NovitaAI API key",
"choiceHint": "OpenAI-compatible NovitaAI endpoint",
"groupId": "novita",

View file

@ -164,6 +164,7 @@
"provider": "nvidia",
"method": "api-key",
"choiceId": "nvidia-api-key",
"appGuidedSecret": true,
"choiceLabel": "NVIDIA API key",
"groupId": "nvidia",
"groupLabel": "NVIDIA",

View file

@ -56,6 +56,7 @@
"provider": "ollama-cloud",
"method": "api-key",
"choiceId": "ollama-cloud",
"appGuidedSecret": true,
"choiceLabel": "Ollama Cloud",
"choiceHint": "Hosted models via ollama.com",
"groupId": "ollama",

View file

@ -309,6 +309,7 @@
"provider": "openai",
"method": "api-key",
"choiceId": "openai-api-key",
"appGuidedSecret": true,
"choiceLabel": "OpenAI API Key",
"choiceHint": "Use your OpenAI API key directly",
"assistantPriority": 5,

View file

@ -84,6 +84,7 @@
"provider": "opencode-go",
"method": "api-key",
"choiceId": "opencode-go",
"appGuidedSecret": true,
"choiceLabel": "OpenCode Go catalog",
"groupId": "opencode",
"groupLabel": "OpenCode",

View file

@ -196,6 +196,7 @@
"provider": "opencode",
"method": "api-key",
"choiceId": "opencode-zen",
"appGuidedSecret": true,
"choiceLabel": "OpenCode Zen catalog",
"groupId": "opencode",
"groupLabel": "OpenCode",

View file

@ -49,6 +49,7 @@
"provider": "openrouter",
"method": "api-key",
"choiceId": "openrouter-api-key",
"appGuidedSecret": true,
"choiceLabel": "OpenRouter API key",
"groupId": "openrouter",
"groupLabel": "OpenRouter",

View file

@ -61,6 +61,7 @@
"provider": "qianfan",
"method": "api-key",
"choiceId": "qianfan-api-key",
"appGuidedSecret": true,
"choiceLabel": "Qianfan API key",
"groupId": "qianfan",
"groupLabel": "Qianfan",

View file

@ -247,6 +247,7 @@
"provider": "qwen",
"method": "standard-api-key-cn",
"choiceId": "qwen-standard-api-key-cn",
"appGuidedSecret": true,
"deprecatedChoiceIds": ["modelstudio-standard-api-key-cn"],
"choiceLabel": "Standard API Key for China (pay-as-you-go)",
"choiceHint": "Endpoint: dashscope.aliyuncs.com",
@ -262,6 +263,7 @@
"provider": "qwen",
"method": "standard-api-key",
"choiceId": "qwen-standard-api-key",
"appGuidedSecret": true,
"deprecatedChoiceIds": ["modelstudio-standard-api-key"],
"choiceLabel": "Standard API Key for Global/Intl (pay-as-you-go)",
"choiceHint": "Endpoint: dashscope-intl.aliyuncs.com",
@ -277,6 +279,7 @@
"provider": "qwen",
"method": "api-key-cn",
"choiceId": "qwen-api-key-cn",
"appGuidedSecret": true,
"deprecatedChoiceIds": ["modelstudio-api-key-cn"],
"choiceLabel": "Coding Plan API Key for China (subscription)",
"choiceHint": "Endpoint: coding.dashscope.aliyuncs.com",
@ -292,6 +295,7 @@
"provider": "qwen",
"method": "api-key",
"choiceId": "qwen-api-key",
"appGuidedSecret": true,
"deprecatedChoiceIds": ["modelstudio-api-key"],
"choiceLabel": "Coding Plan API Key for Global/Intl (subscription)",
"choiceHint": "Endpoint: coding-intl.dashscope.aliyuncs.com",
@ -307,6 +311,7 @@
"provider": "qwen-oauth",
"method": "api-key",
"choiceId": "qwen-oauth",
"appGuidedSecret": true,
"choiceLabel": "Qwen OAuth",
"choiceHint": "Portal token for portal.qwen.ai",
"groupId": "qwen",

View file

@ -86,6 +86,7 @@
"provider": "stepfun",
"method": "standard-api-key-cn",
"choiceId": "stepfun-standard-api-key-cn",
"appGuidedSecret": true,
"choiceLabel": "StepFun Standard API key (China)",
"choiceHint": "Endpoint: api.stepfun.com/v1",
"groupId": "stepfun",
@ -100,6 +101,7 @@
"provider": "stepfun",
"method": "standard-api-key-intl",
"choiceId": "stepfun-standard-api-key-intl",
"appGuidedSecret": true,
"choiceLabel": "StepFun Standard API key (Global/Intl)",
"choiceHint": "Endpoint: api.stepfun.ai/v1",
"groupId": "stepfun",
@ -114,6 +116,7 @@
"provider": "stepfun-plan",
"method": "plan-api-key-cn",
"choiceId": "stepfun-plan-api-key-cn",
"appGuidedSecret": true,
"choiceLabel": "StepFun Step Plan API key (China)",
"choiceHint": "Endpoint: api.stepfun.com/step_plan/v1",
"groupId": "stepfun",
@ -128,6 +131,7 @@
"provider": "stepfun-plan",
"method": "plan-api-key-intl",
"choiceId": "stepfun-plan-api-key-intl",
"appGuidedSecret": true,
"choiceLabel": "StepFun Step Plan API key (Global/Intl)",
"choiceHint": "Endpoint: api.stepfun.ai/step_plan/v1",
"groupId": "stepfun",

View file

@ -18,6 +18,7 @@
"provider": "synthetic",
"method": "api-key",
"choiceId": "synthetic-api-key",
"appGuidedSecret": true,
"choiceLabel": "Synthetic API key",
"groupId": "synthetic",
"groupLabel": "Synthetic",

View file

@ -118,6 +118,7 @@
"provider": "tencent-tokenhub",
"method": "api-key",
"choiceId": "tokenhub-api-key",
"appGuidedSecret": true,
"choiceLabel": "Tencent TokenHub",
"groupId": "tencent",
"groupLabel": "Tencent Cloud",
@ -131,6 +132,7 @@
"provider": "tencent-tokenplan",
"method": "api-key",
"choiceId": "tokenplan-api-key",
"appGuidedSecret": true,
"choiceLabel": "Tencent TokenPlan",
"groupId": "tencent",
"groupLabel": "Tencent Cloud",

View file

@ -25,6 +25,7 @@
"provider": "together",
"method": "api-key",
"choiceId": "together-api-key",
"appGuidedSecret": true,
"choiceLabel": "Together AI API key",
"groupId": "together",
"groupLabel": "Together AI",

View file

@ -13,6 +13,7 @@
"provider": "venice",
"method": "api-key",
"choiceId": "venice-api-key",
"appGuidedSecret": true,
"choiceLabel": "Venice AI API key",
"groupId": "venice",
"groupLabel": "Venice AI",

View file

@ -47,6 +47,7 @@
"provider": "vercel-ai-gateway",
"method": "api-key",
"choiceId": "ai-gateway-api-key",
"appGuidedSecret": true,
"choiceLabel": "Vercel AI Gateway API key",
"groupId": "ai-gateway",
"groupLabel": "Vercel AI Gateway",

View file

@ -203,6 +203,7 @@
"provider": "volcengine",
"method": "api-key",
"choiceId": "volcengine-api-key",
"appGuidedSecret": true,
"choiceLabel": "Volcano Engine API key",
"groupId": "volcengine",
"groupLabel": "Volcano Engine",

View file

@ -84,6 +84,7 @@
"provider": "xai",
"method": "api-key",
"choiceId": "xai-api-key",
"appGuidedSecret": true,
"choiceLabel": "xAI API key",
"groupId": "xai",
"groupLabel": "xAI (Grok)",

View file

@ -140,6 +140,7 @@
"provider": "xiaomi",
"method": "api-key",
"choiceId": "xiaomi-api-key",
"appGuidedSecret": true,
"choiceLabel": "Xiaomi API key (Pay-as-you-go)",
"groupId": "xiaomi",
"groupLabel": "Xiaomi",
@ -153,6 +154,7 @@
"provider": "xiaomi-token-plan",
"method": "token-plan-ams",
"choiceId": "xiaomi-token-plan-ams",
"appGuidedSecret": true,
"choiceLabel": "Xiaomi Token Plan (Europe)",
"choiceHint": "Endpoint preset: token-plan-ams.xiaomimimo.com/v1",
"groupId": "xiaomi",
@ -167,6 +169,7 @@
"provider": "xiaomi-token-plan",
"method": "token-plan-cn",
"choiceId": "xiaomi-token-plan-cn",
"appGuidedSecret": true,
"choiceLabel": "Xiaomi Token Plan (China)",
"choiceHint": "Endpoint preset: token-plan-cn.xiaomimimo.com/v1",
"groupId": "xiaomi",
@ -181,6 +184,7 @@
"provider": "xiaomi-token-plan",
"method": "token-plan-sgp",
"choiceId": "xiaomi-token-plan-sgp",
"appGuidedSecret": true,
"choiceLabel": "Xiaomi Token Plan (Singapore)",
"choiceHint": "Endpoint preset: token-plan-sgp.xiaomimimo.com/v1",
"groupId": "xiaomi",

View file

@ -261,6 +261,7 @@
"provider": "zai",
"method": "api-key",
"choiceId": "zai-api-key",
"appGuidedSecret": true,
"choiceLabel": "Z.AI API key",
"groupId": "zai",
"groupLabel": "Z.AI",
@ -274,6 +275,7 @@
"provider": "zai",
"method": "coding-global",
"choiceId": "zai-coding-global",
"appGuidedSecret": true,
"choiceLabel": "Coding-Plan-Global",
"choiceHint": "GLM Coding Plan Global (api.z.ai)",
"groupId": "zai",
@ -288,6 +290,7 @@
"provider": "zai",
"method": "coding-cn",
"choiceId": "zai-coding-cn",
"appGuidedSecret": true,
"choiceLabel": "Coding-Plan-CN",
"choiceHint": "GLM Coding Plan CN (open.bigmodel.cn)",
"groupId": "zai",
@ -302,6 +305,7 @@
"provider": "zai",
"method": "global",
"choiceId": "zai-global",
"appGuidedSecret": true,
"choiceLabel": "Global",
"choiceHint": "Z.AI Global (api.z.ai)",
"groupId": "zai",
@ -316,6 +320,7 @@
"provider": "zai",
"method": "cn",
"choiceId": "zai-cn",
"appGuidedSecret": true,
"choiceLabel": "CN",
"choiceHint": "Z.AI CN (open.bigmodel.cn)",
"groupId": "zai",

View file

@ -72,6 +72,18 @@ export const CrestodianSetupDetectResultSchema = Type.Object(
{ additionalProperties: false },
),
),
/** Text-inference key/token methods exposed by the Gateway provider registry. */
manualProviders: Type.Array(
Type.Object(
{
/** Opaque provider-auth choice sent back during activation. */
id: NonEmptyString,
label: NonEmptyString,
hint: Type.Optional(Type.String()),
},
{ additionalProperties: false },
),
),
workspace: NonEmptyString,
configuredModel: Type.Optional(Type.String()),
setupComplete: Type.Boolean(),
@ -90,9 +102,9 @@ export const CrestodianSetupActivateParamsSchema = Type.Object(
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. */
/** Manual step only: opaque provider-auth choice returned by detection. */
authChoice: Type.Optional(Type.String()),
/** Manual step only: the pasted API key or token; masked by clients, never echoed. */
apiKey: Type.Optional(Type.String()),
workspace: Type.Optional(Type.String()),
},

View file

@ -2,7 +2,19 @@ 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";
import {
readAuthProfileStoreForTest,
removeOAuthTestTempRoot,
} from "../agents/auth-profiles/oauth-test-utils.js";
import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choices.js";
import type { ProviderPlugin } from "../plugins/types.js";
import {
activateSetupInference,
detectSetupInference,
listSetupInferenceManualProviders,
} from "./setup-inference.js";
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: vi.fn(async () => ({
@ -45,12 +57,82 @@ async function makeTempDir(): Promise<string> {
describe("detectSetupInference", () => {
it("marks the first non-logged-out candidate recommended", async () => {
const detection = await detectSetupInference();
const resolveManifestProviderAuthChoices = vi.fn(() => []);
const detection = await detectSetupInference({ resolveManifestProviderAuthChoices });
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);
expect(resolveManifestProviderAuthChoices).toHaveBeenCalledWith(
expect.objectContaining({ includeWorkspacePlugins: false }),
);
});
it("lists text-inference key and token methods from provider manifests", () => {
const choices: ProviderAuthChoiceMetadata[] = [
{
pluginId: "visuals",
providerId: "visuals",
methodId: "api-key",
choiceId: "visuals-api-key",
choiceLabel: "Visuals API key",
appGuidedSecret: true,
onboardingScopes: ["image-generation"],
},
{
pluginId: "zeta",
providerId: "zeta",
methodId: "oauth",
choiceId: "zeta-oauth",
choiceLabel: "Zeta OAuth",
},
{
pluginId: "zeta",
providerId: "zeta",
methodId: "direct-key",
choiceId: "zeta-api-key",
choiceLabel: "Zeta API key",
choiceHint: "Direct key",
optionKey: "zetaApiKey",
cliOption: "--zeta-api-key <key>",
appGuidedSecret: true,
},
{
pluginId: "alpha",
providerId: "alpha",
methodId: "api-key",
choiceId: "alpha-api-key",
choiceLabel: "Alpha API key",
appGuidedSecret: true,
},
{
pluginId: "github-copilot",
providerId: "github-copilot",
methodId: "device",
choiceId: "github-copilot",
choiceLabel: "GitHub Copilot",
optionKey: "githubCopilotToken",
cliOption: "--github-copilot-token <token>",
appGuidedSecret: true,
},
];
expect(listSetupInferenceManualProviders(choices)).toEqual([
{
id: "alpha-api-key",
label: "Alpha API key",
},
{
id: "github-copilot",
label: "GitHub Copilot",
},
{
id: "zeta-api-key",
label: "Zeta API key",
hint: "Direct key",
},
]);
});
});
@ -132,15 +214,344 @@ describe("activateSetupInference", () => {
it("rejects manual activation without a supported provider", async () => {
const result = await activateSetupInference({
kind: "api-key",
provider: "definitely-not-a-provider",
authChoice: "definitely-not-a-provider",
apiKey: "sk-test",
surface: "gateway",
runtime,
deps: { createTempDir: makeTempDir },
deps: {
createTempDir: makeTempDir,
resolveManifestProviderAuthChoice: () => undefined,
resolvePluginProviders: () => [],
},
});
expect(result).toMatchObject({ ok: false, status: "unavailable" });
});
it.each([
{ name: "API-key", authKind: "api_key" as const, credentialType: "api_key" as const },
{ name: "token", authKind: "token" as const, credentialType: "token" as const },
])(
"uses a provider-owned $name method and persists it after a passing test",
async ({ authKind, credentialType }) => {
const stateDir = await makeTempDir();
const agentDir = path.join(stateDir, "agent");
const runAuth = vi.fn(async (ctx: { opts?: { token?: string } }) => ({
profiles: [
{
profileId: "groq:default",
credential:
credentialType === "api_key"
? { type: "api_key" as const, provider: "groq", key: ctx.opts?.token }
: { type: "token" as const, provider: "groq", token: ctx.opts?.token ?? "" },
},
],
defaultModel: "groq/llama-3.3-70b-versatile",
configPatch: { agents: { defaults: { models: { "groq/llama-3.3-70b-versatile": {} } } } },
}));
const provider: ProviderPlugin = {
id: "groq",
label: "Groq",
pluginId: "groq",
auth: [
{
id: "api-key",
label: "Groq API key",
kind: authKind,
wizard: { choiceId: "groq-api-key" },
run: runAuth as never,
},
],
};
const resolvePluginProviders = vi.fn(() => [provider]);
const enablePluginInConfig = vi.fn((config: OpenClawConfig, pluginId: string) => ({
config: {
...config,
plugins: { entries: { [pluginId]: { enabled: true } } },
},
enabled: true,
}));
const runEmbeddedAgent = vi.fn(async () => ({
meta: { finalAssistantVisibleText: "OK" },
}));
const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] }));
let persistedConfig: OpenClawConfig = {};
const updateConfig = vi.fn(async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => {
persistedConfig = mutator(persistedConfig);
return persistedConfig;
});
try {
const result = await activateSetupInference({
kind: "api-key",
authChoice: "groq-api-key",
apiKey: "test-groq-key",
workspace: "/tmp/openclaw-workspace",
surface: "gateway",
runtime,
deps: {
resolvePluginProviders,
enablePluginInConfig: enablePluginInConfig as never,
resolveManifestProviderAuthChoice: () => ({
pluginId: "groq",
providerId: "groq",
methodId: "api-key",
choiceId: "groq-api-key",
choiceLabel: "Groq API key",
appGuidedSecret: true,
}),
resolveAgentDir: () => agentDir,
runEmbeddedAgent: runEmbeddedAgent as never,
updateConfig: updateConfig as never,
applySetup: applySetup as never,
createTempDir: makeTempDir,
},
});
expect(result).toMatchObject({ ok: true, modelRef: "groq/llama-3.3-70b-versatile" });
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
plugins: { entries: { groq: { enabled: true } } },
}),
onlyPluginIds: ["groq"],
workspaceDir: "/tmp/openclaw-workspace",
}),
);
expect(runAuth).toHaveBeenCalledWith(
expect.objectContaining({
opts: expect.objectContaining({ token: "test-groq-key", tokenProvider: "groq" }),
allowSecretRefPrompt: false,
secretInputMode: "plaintext",
}),
);
expect(runEmbeddedAgent).toHaveBeenCalledWith(
expect.objectContaining({
provider: "groq",
model: "llama-3.3-70b-versatile",
authProfileId: "groq:default",
agentDir: expect.stringContaining("setup-inference-test-"),
}),
);
expect(persistedConfig).toMatchObject({
plugins: { entries: { groq: { enabled: true } } },
auth: { profiles: { "groq:default": { provider: "groq", mode: credentialType } } },
});
expect(readAuthProfileStoreForTest(agentDir).profiles["groq:default"]).toMatchObject(
credentialType === "api_key"
? { type: "api_key", provider: "groq", key: "test-groq-key" }
: { type: "token", provider: "groq", token: "test-groq-key" },
);
} finally {
await removeOAuthTestTempRoot(stateDir);
}
},
);
it.each([
{
name: "uses a provider starter model instead of an unrelated existing default",
existingModel: "openai/gpt-5.2",
starterModel: "github-copilot/claude-sonnet-4.5",
},
{
name: "accepts an unchanged provider-owned dynamic model",
existingModel: "github-copilot/claude-sonnet-4.5",
starterModel: undefined,
},
])("$name without starting interactive login", async ({ existingModel, starterModel }) => {
const stateDir = await makeTempDir();
const agentDir = path.join(stateDir, "agent");
const runInteractive = vi.fn();
const runNonInteractive = vi.fn(
async (ctx: {
agentDir?: string;
opts: { githubCopilotToken?: unknown };
config: OpenClawConfig;
}) => {
const token =
typeof ctx.opts.githubCopilotToken === "string" ? ctx.opts.githubCopilotToken : "";
await upsertAuthProfileWithLock({
profileId: "github-copilot:github",
credential: { type: "token", provider: "github-copilot", token },
agentDir: ctx.agentDir,
});
return {
...ctx.config,
agents: {
...ctx.config.agents,
defaults: {
...ctx.config.agents?.defaults,
model: ctx.config.agents?.defaults?.model ?? {
primary: "github-copilot/claude-sonnet-4.5",
},
},
},
} satisfies OpenClawConfig;
},
);
const provider: ProviderPlugin = {
id: "github-copilot",
label: "GitHub Copilot",
pluginId: "github-copilot",
auth: [
{
id: "device",
label: "GitHub device login",
kind: "device_code",
...(starterModel ? { starterModel } : {}),
run: runInteractive as never,
runNonInteractive: runNonInteractive as never,
},
],
};
const runEmbeddedAgent = vi.fn(async () => ({
meta: { finalAssistantVisibleText: "OK" },
}));
const initialConfig = {
gateway: { port: 18789 },
agents: { defaults: { model: { primary: existingModel } } },
} satisfies OpenClawConfig;
let persistedConfig: OpenClawConfig = {
gateway: { port: 19000 },
agents: { defaults: { model: { primary: existingModel } } },
} satisfies OpenClawConfig;
const updateConfig = vi.fn(async (mutator: (cfg: OpenClawConfig) => OpenClawConfig) => {
persistedConfig = mutator(persistedConfig);
return persistedConfig;
});
try {
const result = await activateSetupInference({
kind: "api-key",
authChoice: "github-copilot",
apiKey: "github-token",
workspace: "/tmp/openclaw-workspace",
surface: "gateway",
runtime,
deps: {
readConfigFileSnapshot: vi.fn(async () => ({
exists: true,
valid: true,
path: "/tmp/openclaw.json",
issues: [],
config: initialConfig,
runtimeConfig: initialConfig,
})) as never,
resolvePluginProviders: () => [provider],
resolveManifestProviderAuthChoice: () => ({
pluginId: "github-copilot",
providerId: "github-copilot",
methodId: "device",
choiceId: "github-copilot",
choiceLabel: "GitHub Copilot",
optionKey: "githubCopilotToken",
cliOption: "--github-copilot-token <token>",
appGuidedSecret: true,
}),
resolveAgentDir: () => agentDir,
runEmbeddedAgent: runEmbeddedAgent as never,
updateConfig: updateConfig as never,
applySetup: vi.fn(async () => ({
configPath: "/tmp/openclaw.json",
lines: ["ok"],
})) as never,
createTempDir: makeTempDir,
},
});
expect(result).toMatchObject({
ok: true,
modelRef: "github-copilot/claude-sonnet-4.5",
});
expect(runInteractive).not.toHaveBeenCalled();
expect(runNonInteractive).toHaveBeenCalledWith(
expect.objectContaining({
opts: expect.objectContaining({ githubCopilotToken: "github-token" }),
}),
);
expect(runEmbeddedAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentDir: expect.stringContaining("setup-inference-test-"),
authProfileId: "github-copilot:github",
provider: "github-copilot",
model: "claude-sonnet-4.5",
}),
);
expect(readAuthProfileStoreForTest(agentDir).profiles["github-copilot:github"]).toMatchObject(
{
type: "token",
provider: "github-copilot",
token: "github-token",
},
);
expect(persistedConfig.gateway?.port).toBe(19000);
expect(persistedConfig.agents?.defaults?.model).toEqual({ primary: existingModel });
} finally {
await removeOAuthTestTempRoot(stateDir);
}
});
it("does not persist a provider key after a failed live test", async () => {
const stateDir = await makeTempDir();
const agentDir = path.join(stateDir, "agent");
const provider: ProviderPlugin = {
id: "groq",
label: "Groq",
pluginId: "groq",
auth: [
{
id: "api-key",
label: "Groq API key",
kind: "api_key",
wizard: { choiceId: "groq-api-key" },
run: async (ctx) => ({
profiles: [
{
profileId: "groq:default",
credential: { type: "api_key", provider: "groq", key: ctx.opts?.token },
},
],
defaultModel: "groq/llama-3.3-70b-versatile",
}),
},
],
};
try {
const result = await activateSetupInference({
kind: "api-key",
authChoice: "groq-api-key",
apiKey: "bad-groq-key",
workspace: "/tmp/openclaw-workspace",
surface: "gateway",
runtime,
deps: {
resolvePluginProviders: () => [provider],
resolveManifestProviderAuthChoice: () => ({
pluginId: "groq",
providerId: "groq",
methodId: "api-key",
choiceId: "groq-api-key",
choiceLabel: "Groq API key",
appGuidedSecret: true,
}),
resolveAgentDir: () => agentDir,
runEmbeddedAgent: vi.fn(async () => {
throw new Error("401 invalid_api_key");
}) as never,
applySetup: vi.fn() as never,
updateConfig: vi.fn() as never,
createTempDir: makeTempDir,
},
});
expect(result).toMatchObject({ ok: false, status: "auth" });
expect(readAuthProfileStoreForTest(agentDir).profiles["groq:default"]).toBeUndefined();
} finally {
await removeOAuthTestTempRoot(stateDir);
}
});
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 () => ({

View file

@ -4,9 +4,9 @@ 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 { normalizeAuthProfileCredential } from "../agents/auth-profiles/credential-normalize.js";
import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.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,
@ -22,7 +22,25 @@ import {
detectInferenceBackends,
type InferenceBackendKind,
} from "../commands/onboard-inference.js";
import { createMergePatch } from "../config/io.write-prepare.js";
import { applyMergePatch } from "../config/merge-patch.js";
import {
normalizeAgentModelRefForConfig,
resolveAgentModelPrimaryValue,
} from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import {
applyProviderPluginAuthMethodResultConfig,
runProviderPluginAuthMethodUnpersisted,
} from "../plugins/provider-auth-choice.js";
import {
resolveManifestProviderAuthChoice,
resolveManifestProviderAuthChoices,
type ProviderAuthChoiceMetadata,
} from "../plugins/provider-auth-choices.js";
import { resolvePluginProviders } from "../plugins/providers.runtime.js";
import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { buildCliPlannerConfig, buildCodexAppServerPlannerConfig } from "./assistant-backends.js";
@ -39,14 +57,6 @@ import { applyCrestodianSetup, createQuickstartNotePrompter } from "./setup-appl
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;
@ -57,8 +67,17 @@ export type SetupInferenceCandidate = {
credentials?: boolean;
};
export type SetupInferenceManualProvider = {
/** Provider-auth choice id sent back to `crestodian.setup.activate`. */
id: string;
label: string;
hint?: string;
};
export type SetupInferenceDetection = {
candidates: SetupInferenceCandidate[];
/** Text-inference key/token methods exposed by installed provider manifests. */
manualProviders: SetupInferenceManualProvider[];
/** Resolved workspace the setup apply would use (display + default). */
workspace: string;
configuredModel?: string;
@ -82,9 +101,9 @@ export type ActivateSetupInferenceResult =
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. */
/** Manual step only: provider-auth choice returned by detection. */
authChoice?: string;
/** Manual step only: the pasted API key or token. Never logged. */
apiKey?: string;
workspace?: string;
surface: "cli" | "gateway";
@ -99,12 +118,64 @@ export type ActivateSetupInferenceDeps = {
applySetup?: typeof applyCrestodianSetup;
ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection;
updateConfig?: typeof import("../commands/models/shared.js").updateConfig;
resolvePluginProviders?: typeof resolvePluginProviders;
resolveManifestProviderAuthChoice?: typeof resolveManifestProviderAuthChoice;
enablePluginInConfig?: typeof enablePluginInConfig;
resolveAgentDir?: typeof resolveAgentDir;
createTempDir?: () => Promise<string>;
removeTempDir?: (dir: string) => Promise<void>;
timeoutMs?: number;
};
export async function detectSetupInference(): Promise<SetupInferenceDetection> {
export type DetectSetupInferenceDeps = {
resolveManifestProviderAuthChoices?: typeof resolveManifestProviderAuthChoices;
};
async function resolveSetupInferenceWorkspace(params: {
configExists: boolean;
configValid: boolean;
}): Promise<{ workspace: string; hasAuthoredSetup: boolean }> {
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig(params);
const { DEFAULT_WORKSPACE } = await import("../commands/onboard-helpers.js");
return {
workspace: resolveUserPath(
authoredConfig?.agents?.defaults?.workspace?.trim() || DEFAULT_WORKSPACE,
),
hasAuthoredSetup,
};
}
function supportsTextInference(scopes?: ProviderAuthChoiceMetadata["onboardingScopes"]): boolean {
return !scopes || scopes.includes("text-inference");
}
function supportsManualSecret(choice: ProviderAuthChoiceMetadata): boolean {
return supportsTextInference(choice.onboardingScopes) && choice.appGuidedSecret === true;
}
export function listSetupInferenceManualProviders(
authChoices: readonly ProviderAuthChoiceMetadata[],
): SetupInferenceManualProvider[] {
const choices = new Map<string, SetupInferenceManualProvider>();
for (const choice of authChoices) {
const id = choice.choiceId.trim();
if (!id || choices.has(id) || !supportsManualSecret(choice)) {
continue;
}
choices.set(id, {
id,
label: choice.choiceLabel,
...(choice.choiceHint?.trim() ? { hint: choice.choiceHint.trim() } : {}),
});
}
return [...choices.values()].toSorted(
(a, b) => a.label.localeCompare(b.label, "en") || a.id.localeCompare(b.id, "en"),
);
}
export async function detectSetupInference(
deps: DetectSetupInferenceDeps = {},
): Promise<SetupInferenceDetection> {
const { readConfigFileSnapshot } = await import("../config/config.js");
const snapshot = await readConfigFileSnapshot();
const cfg = snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
@ -116,17 +187,22 @@ export async function detectSetupInference(): Promise<SetupInferenceDetection> {
...candidate,
recommended: index === recommendedIndex,
}));
const { authoredConfig, hasAuthoredSetup } = await loadAuthoredSetupConfig({
const { workspace, hasAuthoredSetup } = await resolveSetupInferenceWorkspace({
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,
);
const authChoices = (
deps.resolveManifestProviderAuthChoices ?? resolveManifestProviderAuthChoices
)({
config: cfg,
workspaceDir: workspace,
includeUntrustedWorkspacePlugins: false,
includeWorkspacePlugins: false,
}).filter((choice) => enablePluginInConfig(cfg, choice.pluginId).enabled);
return {
candidates,
manualProviders: listSetupInferenceManualProviders(authChoices),
workspace,
...(configuredModel ? { configuredModel } : {}),
setupComplete: hasAuthoredSetup && Boolean(configuredModel),
@ -140,9 +216,15 @@ type SetupInferenceTestPlan = {
modelRef: string;
config: OpenClawConfig;
agentHarnessId?: string;
agentDir?: string;
authProfileId?: string;
/** Model to persist as default on success; undefined keeps the current one. */
persistModelRef?: string;
manualAuth?: {
profiles: ProviderAuthResult["profiles"];
configPatch: unknown;
pluginId?: string;
};
};
type RunResult = {
@ -189,9 +271,14 @@ function mapFailoverReasonToSetupStatus(reason?: string | null): SetupInferenceS
async function buildTestPlan(params: {
kind: InferenceBackendKind | "api-key";
provider?: string;
authChoice?: string;
apiKey?: string;
cfg: OpenClawConfig;
workspaceDir: string;
pluginWorkspaceDir: string;
agentDir: string;
runtime: RuntimeEnv;
deps: ActivateSetupInferenceDeps;
}): Promise<SetupInferenceTestPlan | { error: string }> {
const { kind, cfg, workspaceDir } = params;
switch (kind) {
@ -258,22 +345,120 @@ async function buildTestPlan(params: {
};
}
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) {
const apiKey = params.apiKey?.trim();
if (!apiKey) {
return { error: "Enter an API key or token first." };
}
const authChoice = params.authChoice?.trim();
const choice = authChoice
? (params.deps.resolveManifestProviderAuthChoice ?? resolveManifestProviderAuthChoice)(
authChoice,
{
config: cfg,
workspaceDir: params.pluginWorkspaceDir,
includeUntrustedWorkspacePlugins: false,
includeWorkspacePlugins: false,
},
)
: undefined;
if (!choice || !supportsManualSecret(choice)) {
return { error: "That key-based provider is not available on this Gateway." };
}
const enableResult = (params.deps.enablePluginInConfig ?? enablePluginInConfig)(
cfg,
choice.pluginId,
);
if (!enableResult.enabled) {
return {
error: `Unsupported provider "${params.provider ?? ""}" — expected anthropic, openai, or google.`,
error: `${choice.choiceLabel} is disabled (${enableResult.reason ?? "blocked"}).`,
};
}
const providers = (params.deps.resolvePluginProviders ?? resolvePluginProviders)({
config: enableResult.config,
workspaceDir: params.pluginWorkspaceDir,
mode: "setup",
includeUntrustedWorkspacePlugins: false,
onlyPluginIds: [choice.pluginId],
});
const provider = providers.find(
(candidate) =>
candidate.pluginId === choice.pluginId &&
normalizeProviderId(candidate.id) === normalizeProviderId(choice.providerId),
);
const method = provider?.auth.find((candidate) => candidate.id === choice.methodId);
const resolved = provider && method ? { provider, method } : null;
if (!resolved || !supportsTextInference(resolved.method.wizard?.onboardingScopes)) {
return { error: "That key-based provider is not available on this Gateway." };
}
let result: ProviderAuthResult;
let preparedConfig: OpenClawConfig;
try {
if (resolved.method.kind === "api_key" || resolved.method.kind === "token") {
result = await runProviderPluginAuthMethodUnpersisted({
config: enableResult.config,
runtime: params.runtime,
prompter: createQuickstartNotePrompter(params.runtime),
method: resolved.method,
agentDir: params.agentDir,
workspaceDir,
secretInputMode: "plaintext",
allowSecretRefPrompt: false,
opts: { token: apiKey, tokenProvider: resolved.provider.id },
});
preparedConfig = applyProviderPluginAuthMethodResultConfig({
config: enableResult.config,
result,
});
} else {
const prepared = await runProviderManualSecretMethod({
config: enableResult.config,
baseConfig: cfg,
choice,
method: resolved.method,
apiKey,
agentDir: params.agentDir,
workspaceDir,
});
result = prepared.result;
preparedConfig = prepared.config;
}
} catch {
return {
error: `${resolved.provider.label} could not prepare this credential for app-guided setup.`,
};
}
const modelRef = result.defaultModel
? normalizeAgentModelRefForConfig(result.defaultModel)
: "";
if (!modelRef || result.profiles.length === 0) {
return {
error: `${resolved.provider.label} does not expose a starter model for app-guided setup.`,
};
}
const ref = parseRef(modelRef);
if (!ref.model) {
return {
error: `${resolved.provider.label} returned an invalid starter model.`,
};
}
const matchingProfile =
result.profiles.find(
(profile) =>
normalizeProviderId(profile.credential.provider) === normalizeProviderId(ref.provider),
) ?? result.profiles[0];
return {
runner: "embedded",
...ref,
modelRef,
config: buildCliPlannerConfig(workspaceDir, modelRef),
authProfileId: `${canonical}:manual`,
agentDir: params.agentDir,
config: preparedConfig,
authProfileId: matchingProfile.profileId,
persistModelRef: modelRef,
manualAuth: {
profiles: result.profiles,
configPatch: createMergePatch(enableResult.config, preparedConfig),
...(resolved.provider.pluginId ? { pluginId: resolved.provider.pluginId } : {}),
},
};
}
default:
@ -281,10 +466,87 @@ async function buildTestPlan(params: {
}
}
async function runProviderManualSecretMethod(params: {
config: OpenClawConfig;
baseConfig: OpenClawConfig;
choice: ProviderAuthChoiceMetadata;
method: ProviderAuthMethod;
apiKey: string;
agentDir: string;
workspaceDir: string;
}): Promise<{ result: ProviderAuthResult; config: OpenClawConfig }> {
const optionKey = params.choice.optionKey;
const runNonInteractive = params.method.runNonInteractive;
if (!optionKey || !params.choice.cliOption || !runNonInteractive) {
throw new Error("Provider does not expose app-guided secret setup.");
}
let methodError = "";
const isolatedRuntime: RuntimeEnv = {
log: () => {},
error: (...args) => {
methodError = args.map(String).join(" ");
},
// Provider CLI methods use exit for validation failures. Convert it to a
// request-local failure so app-guided setup can never stop the Gateway.
exit: (code) => {
throw new Error(methodError || `Provider setup exited with code ${code}.`);
},
};
const configured = await runNonInteractive({
authChoice: params.choice.choiceId,
config: params.config,
baseConfig: params.baseConfig,
opts: { [optionKey]: params.apiKey, secretInputMode: "plaintext" },
runtime: isolatedRuntime,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
resolveApiKey: async (input) =>
typeof input.flagValue === "string" && input.flagValue.trim()
? { key: input.flagValue.trim(), source: "flag" }
: null,
toApiKeyCredential: ({ provider, resolved, email, metadata }) => ({
type: "api_key",
provider,
key: resolved.key,
...(email ? { email } : {}),
...(metadata ? { metadata } : {}),
}),
});
if (!configured) {
throw new Error(methodError || "Provider setup did not produce a configuration.");
}
const store = loadPersistedAuthProfileStore(params.agentDir);
const profiles = Object.entries(store?.profiles ?? {}).map(([profileId, credential]) => ({
profileId,
credential,
}));
const previousModel = resolveAgentModelPrimaryValue(params.config.agents?.defaults?.model);
const configuredModel = resolveAgentModelPrimaryValue(configured.agents?.defaults?.model);
const configuredProvider = configuredModel ? parseRef(configuredModel).provider : undefined;
// Dynamic provider setup can rediscover the already-selected model while
// repairing credentials. It is valid only when the provider still owns it.
const configuredModelOwnedByProvider =
configuredProvider !== undefined &&
normalizeProviderId(configuredProvider) === normalizeProviderId(params.choice.providerId);
const defaultModel =
configuredModel && (configuredModel !== previousModel || configuredModelOwnedByProvider)
? configuredModel
: params.method.starterModel;
if (profiles.length === 0 || !defaultModel) {
throw new Error("Provider setup did not produce credentials and a starter model.");
}
return {
result: { profiles, defaultModel },
config: configured,
};
}
/**
* 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.
* default. Manual credentials are tested from a temporary auth store and
* copied into the real agent store only after success, so failures leave no trace.
*/
export async function activateSetupInference(
params: ActivateSetupInferenceParams,
@ -295,35 +557,39 @@ export async function activateSetupInference(
const snapshot = await readSnapshot();
const cfg: OpenClawConfig =
snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
const workspace = params.workspace?.trim()
? resolveUserPath(params.workspace)
: (
await resolveSetupInferenceWorkspace({
configExists: snapshot.exists,
configValid: snapshot.valid,
})
).workspace;
const tempDir = await (
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
)();
const agentDir = (deps.resolveAgentDir ?? resolveAgentDir)(cfg, resolveDefaultAgentId(cfg));
const testAgentDir = path.join(tempDir, "agent");
try {
const plan = await buildTestPlan({
kind: params.kind,
...(params.provider !== undefined ? { provider: params.provider } : {}),
...(params.authChoice !== undefined ? { authChoice: params.authChoice } : {}),
...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}),
cfg,
workspaceDir: tempDir,
pluginWorkspaceDir: workspace,
agentDir: testAgentDir,
runtime: params.runtime,
deps,
});
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) {
if (plan.manualAuth) {
const staged = await persistManualAuthProfiles(plan.manualAuth.profiles, testAgentDir);
if (!staged) {
return {
ok: false,
status: "unknown",
@ -334,9 +600,6 @@ export async function activateSetupInference(
const test = await runSetupInferenceTest({ plan, tempDir, deps });
if (!test.ok) {
if (stagedProfile) {
await rollbackManualApiKeyProfile({ ...stagedProfile, agentDir });
}
return test;
}
@ -357,27 +620,27 @@ export async function activateSetupInference(
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) {
if (plan.manualAuth) {
const manualAuth = plan.manualAuth;
const persisted = await persistManualAuthProfiles(manualAuth.profiles, agentDir);
if (!persisted) {
return {
ok: false,
status: "unknown",
error: "Could not update the auth profile store; try again in a moment.",
};
}
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" }),
);
await updateConfig((current) => applyManualAuthConfig(current, manualAuth));
}
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,
workspace,
...(plan.persistModelRef ? { model: plan.persistModelRef } : {}),
surface: params.surface,
runtime: params.runtime,
@ -390,52 +653,36 @@ export async function activateSetupInference(
}
}
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;
function applyManualAuthConfig(
config: OpenClawConfig,
manualAuth: NonNullable<SetupInferenceTestPlan["manualAuth"]>,
): OpenClawConfig {
let enabledConfig = config;
if (manualAuth.pluginId) {
const enableResult = enablePluginInConfig(config, manualAuth.pluginId);
if (!enableResult.enabled) {
throw new Error(`Provider plugin ${manualAuth.pluginId} is ${enableResult.reason}.`);
}
enabledConfig = enableResult.config;
}
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 } : {}) };
return applyMergePatch(enabledConfig, manualAuth.configPatch) as OpenClawConfig;
}
async function rollbackManualApiKeyProfile(params: {
profileId: string;
prior?: AuthProfileCredential;
agentDir: string;
}): Promise<void> {
await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
async function persistManualAuthProfiles(
profiles: ProviderAuthResult["profiles"],
agentDir: string,
): Promise<boolean> {
const updated = await updateAuthProfileStoreWithLock({
agentDir,
saveOptions: { filterExternalAuthProfiles: false, syncExternalCli: false },
updater: (store) => {
if (params.prior) {
store.profiles[params.profileId] = params.prior;
} else {
delete store.profiles[params.profileId];
for (const profile of profiles) {
store.profiles[profile.profileId] = normalizeAuthProfileCredential(profile.credential);
}
return true;
},
});
return updated !== null;
}
async function runSetupInferenceTest(params: {
@ -462,6 +709,7 @@ async function runSetupInferenceTest(params: {
trigger: "manual",
sessionFile,
workspaceDir: tempDir,
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
config: plan.config,
prompt: SETUP_INFERENCE_TEST_PROMPT,
provider: plan.provider,
@ -482,6 +730,7 @@ async function runSetupInferenceTest(params: {
trigger: "manual",
sessionFile,
workspaceDir: tempDir,
...(plan.agentDir ? { agentDir: plan.agentDir } : {}),
config: plan.config,
prompt: SETUP_INFERENCE_TEST_PROMPT,
provider: plan.provider,

View file

@ -90,7 +90,7 @@ export const crestodianHandlers: GatewayRequestHandlers = {
};
const result = await activateSetupInference({
kind: params.kind,
...(params.provider !== undefined ? { provider: params.provider } : {}),
...(params.authChoice !== undefined ? { authChoice: params.authChoice } : {}),
...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}),
...(params.workspace !== undefined ? { workspace: params.workspace } : {}),
surface: "gateway",

View file

@ -175,6 +175,7 @@ describe("plugin contract registry", () => {
{
provider: "github-copilot",
method: "device",
appGuidedSecret: true,
choiceId: "github-copilot",
choiceLabel: "GitHub Copilot",
choiceHint: "Device login with your GitHub account",

View file

@ -877,6 +877,7 @@ describe("loadPluginManifestRegistry", () => {
choiceLabel: "OpenAI API key",
assistantPriority: 10,
assistantVisibility: "visible",
appGuidedSecret: true,
},
],
configSchema: { type: "object" },
@ -944,6 +945,7 @@ describe("loadPluginManifestRegistry", () => {
choiceLabel: "OpenAI API key",
assistantPriority: 10,
assistantVisibility: "visible",
appGuidedSecret: true,
},
]);
});

View file

@ -525,6 +525,8 @@ export type PluginManifestProviderAuthChoice = {
cliFlag?: string;
cliOption?: string;
cliDescription?: string;
/** One pasted secret plus provider defaults is sufficient for app-guided setup. */
appGuidedSecret?: boolean;
/**
* Interactive onboarding surfaces where this auth choice should appear.
* Defaults to `["text-inference"]` when omitted.
@ -1536,6 +1538,7 @@ function normalizeProviderAuthChoices(
const cliFlag = normalizeOptionalString(entry.cliFlag) ?? "";
const cliOption = normalizeOptionalString(entry.cliOption) ?? "";
const cliDescription = normalizeOptionalString(entry.cliDescription) ?? "";
const appGuidedSecret = entry.appGuidedSecret === true;
const onboardingScopes = normalizeTrimmedStringList(entry.onboardingScopes).filter(
(scope): scope is PluginManifestOnboardingScope =>
scope === "text-inference" || scope === "image-generation" || scope === "music-generation",
@ -1557,6 +1560,7 @@ function normalizeProviderAuthChoices(
...(cliFlag ? { cliFlag } : {}),
...(cliOption ? { cliOption } : {}),
...(cliDescription ? { cliDescription } : {}),
...(appGuidedSecret ? { appGuidedSecret: true } : {}),
...(onboardingScopes.length > 0 ? { onboardingScopes } : {}),
});
}

View file

@ -100,6 +100,7 @@ export function createProviderApiKeyAuthMethod(
label: params.label,
hint: params.hint,
kind: "api_key",
starterModel: params.defaultModel,
wizard: params.wizard,
run: async (ctx) => {
const opts = ctx.opts as Record<string, unknown> | undefined;

View file

@ -29,7 +29,12 @@ import {
import { applyAuthProfileConfig } from "./provider-auth-helpers.js";
import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js";
import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js";
import type {
ProviderAuthMethod,
ProviderAuthOptionBag,
ProviderAuthResult,
ProviderPlugin,
} from "./types.js";
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
@ -255,6 +260,67 @@ export const testing = {
},
} as const;
export async function runProviderPluginAuthMethodUnpersisted(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
runtime: RuntimeEnv;
prompter: WizardPrompter;
method: ProviderAuthMethod;
agentDir: string;
workspaceDir: string;
secretInputMode?: ProviderAuthOptionBag["secretInputMode"];
allowSecretRefPrompt?: boolean;
opts?: Partial<ProviderAuthOptionBag>;
}): Promise<ProviderAuthResult> {
return await params.method.run({
config: params.config,
env: params.env,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
opts: params.opts,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
isRemote: isRemoteEnvironment(),
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
});
}
export function applyProviderPluginAuthMethodResultConfig(params: {
config: OpenClawConfig;
result: ProviderAuthResult;
}): OpenClawConfig {
const { result } = params;
let nextConfig = params.config;
if (result.configPatch) {
nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch, {
replaceDefaultModels: result.replaceDefaultModels,
});
}
for (const profile of result.profiles) {
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
...("displayName" in profile.credential && profile.credential.displayName
? { displayName: profile.credential.displayName }
: {}),
});
}
return nextConfig;
}
export async function runProviderPluginAuthMethod(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@ -275,53 +341,32 @@ export async function runProviderPluginAuthMethod(params: {
params.workspaceDir ??
resolveAgentWorkspaceDir(params.config, agentId) ??
resolveDefaultAgentWorkspaceDir();
const result = await params.method.run({
const result = await runProviderPluginAuthMethodUnpersisted({
config: params.config,
env: params.env,
runtime: params.runtime,
prompter: params.prompter,
method: params.method,
agentDir,
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
opts: params.opts,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
isRemote: isRemoteEnvironment(),
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
opts: params.opts,
});
let nextConfig = params.config;
if (result.configPatch) {
nextConfig = applyProviderAuthConfigPatch(nextConfig, result.configPatch, {
replaceDefaultModels: result.replaceDefaultModels,
});
}
for (const profile of result.profiles) {
await upsertAuthProfileWithLockOrThrow({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
...("displayName" in profile.credential && profile.credential.displayName
? { displayName: profile.credential.displayName }
: {}),
});
}
const nextConfig = applyProviderPluginAuthMethodResultConfig({
config: params.config,
result,
});
if (params.emitNotes !== false && result.notes && result.notes.length > 0) {
await params.prompter.note(result.notes.join("\n"), "Provider notes");
}

View file

@ -322,6 +322,7 @@ describe("provider auth choice manifest helpers", () => {
optionKey: "openaiApiKey",
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
appGuidedSecret: true,
},
],
},
@ -339,6 +340,15 @@ describe("provider auth choice manifest helpers", () => {
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
},
{
provider: "evil-openai",
method: "api-key",
choiceId: "evil-openai-api-key",
choiceLabel: "Evil OpenAI API key",
optionKey: "evilOpenaiApiKey",
cliFlag: "--evil-openai-api-key",
cliOption: "--evil-openai-api-key <key>",
},
],
},
]);
@ -357,6 +367,7 @@ describe("provider auth choice manifest helpers", () => {
optionKey: "openaiApiKey",
cliFlag: "--openai-api-key",
cliOption: "--openai-api-key <key>",
appGuidedSecret: true,
},
]);
expect(
@ -364,6 +375,22 @@ describe("provider auth choice manifest helpers", () => {
includeUntrustedWorkspacePlugins: false,
})?.providerId,
).toBe("openai");
const enabledWorkspaceConfig = {
plugins: { entries: { "evil-openai-hijack": { enabled: true } } },
};
expect(
resolveManifestProviderAuthChoices({
config: enabledWorkspaceConfig,
includeUntrustedWorkspacePlugins: false,
}).map((choice) => choice.choiceId),
).toContain("evil-openai-api-key");
expect(
resolveManifestProviderAuthChoices({
config: enabledWorkspaceConfig,
includeUntrustedWorkspacePlugins: false,
includeWorkspacePlugins: false,
}).map((choice) => choice.choiceId),
).not.toContain("evil-openai-api-key");
expect(
resolveManifestProviderOnboardAuthFlags({
includeUntrustedWorkspacePlugins: false,

View file

@ -29,6 +29,7 @@ export type ProviderAuthChoiceMetadata = {
cliFlag?: string;
cliOption?: string;
cliDescription?: string;
appGuidedSecret?: boolean;
onboardingScopes?: ("text-inference" | "image-generation" | "music-generation")[];
};
@ -53,6 +54,7 @@ type ManifestProviderAuthChoiceParams = {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
includeUntrustedWorkspacePlugins?: boolean;
includeWorkspacePlugins?: boolean;
};
const PROVIDER_AUTH_CHOICE_ORIGIN_PRIORITY: Readonly<Record<PluginOrigin, number>> = {
@ -105,6 +107,7 @@ function toProviderAuthChoiceCandidate(params: {
...(choice.cliFlag ? { cliFlag: choice.cliFlag } : {}),
...(choice.cliOption ? { cliOption: choice.cliOption } : {}),
...(choice.cliDescription ? { cliDescription: choice.cliDescription } : {}),
...(choice.appGuidedSecret ? { appGuidedSecret: true } : {}),
...(choice.onboardingScopes ? { onboardingScopes: choice.onboardingScopes } : {}),
};
}
@ -186,6 +189,7 @@ function resolveManifestProviderAuthChoiceCandidates(params?: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
includeUntrustedWorkspacePlugins?: boolean;
includeWorkspacePlugins?: boolean;
}): ProviderAuthChoiceCandidate[] {
const metadataSnapshot = loadManifestMetadataSnapshot({
config: params?.config ?? {},
@ -195,6 +199,9 @@ function resolveManifestProviderAuthChoiceCandidates(params?: {
const registry = metadataSnapshot.manifestRegistry;
const normalizedConfig = normalizePluginsConfig(params?.config?.plugins);
return registry.plugins.flatMap((plugin) => {
if (plugin.origin === "workspace" && params?.includeWorkspacePlugins === false) {
return [];
}
if (
plugin.origin === "workspace" &&
params?.includeUntrustedWorkspacePlugins === false &&

View file

@ -415,6 +415,8 @@ export type ProviderAuthMethod = {
label: string;
hint?: string;
kind: ProviderAuthKind;
/** Provider-owned model used to validate app-guided secret setup. */
starterModel?: string;
/**
* Optional wizard/onboarding metadata for this specific auth method.
*