From 80537c1ba408212fdd0baf7123b448e9ff2ecdf8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 20:57:56 +0100 Subject: [PATCH] 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 --- apps/.i18n/native-source.json | 144 +++--- .../Sources/OpenClaw/OnboardingAISetup.swift | 136 +++--- .../OpenClawProtocol/GatewayModels.swift | 12 +- docs/cli/crestodian.md | 4 +- docs/start/onboarding.md | 13 +- extensions/anthropic/openclaw.plugin.json | 2 + extensions/arcee/openclaw.plugin.json | 2 + extensions/byteplus/openclaw.plugin.json | 1 + extensions/cerebras/openclaw.plugin.json | 1 + extensions/chutes/openclaw.plugin.json | 1 + extensions/cohere/openclaw.plugin.json | 1 + extensions/deepinfra/openclaw.plugin.json | 1 + extensions/deepseek/openclaw.plugin.json | 1 + extensions/fireworks/openclaw.plugin.json | 1 + extensions/github-copilot/index.ts | 1 + .../github-copilot/openclaw.plugin.json | 1 + extensions/gmi/openclaw.plugin.json | 1 + extensions/google/openclaw.plugin.json | 1 + extensions/groq/openclaw.plugin.json | 16 + extensions/huggingface/openclaw.plugin.json | 1 + extensions/kilocode/openclaw.plugin.json | 1 + extensions/kimi-coding/openclaw.plugin.json | 1 + extensions/litellm/openclaw.plugin.json | 1 + extensions/lmstudio/openclaw.plugin.json | 1 + extensions/minimax/openclaw.plugin.json | 2 + extensions/mistral/openclaw.plugin.json | 1 + extensions/moonshot/openclaw.plugin.json | 2 + extensions/novita/openclaw.plugin.json | 1 + extensions/nvidia/openclaw.plugin.json | 1 + extensions/ollama/openclaw.plugin.json | 1 + extensions/openai/openclaw.plugin.json | 1 + extensions/opencode-go/openclaw.plugin.json | 1 + extensions/opencode/openclaw.plugin.json | 1 + extensions/openrouter/openclaw.plugin.json | 1 + extensions/qianfan/openclaw.plugin.json | 1 + extensions/qwen/openclaw.plugin.json | 5 + extensions/stepfun/openclaw.plugin.json | 4 + extensions/synthetic/openclaw.plugin.json | 1 + extensions/tencent/openclaw.plugin.json | 2 + extensions/together/openclaw.plugin.json | 1 + extensions/venice/openclaw.plugin.json | 1 + .../vercel-ai-gateway/openclaw.plugin.json | 1 + extensions/volcengine/openclaw.plugin.json | 1 + extensions/xai/openclaw.plugin.json | 1 + extensions/xiaomi/openclaw.plugin.json | 4 + extensions/zai/openclaw.plugin.json | 5 + .../gateway-protocol/src/schema/crestodian.ts | 18 +- src/crestodian/setup-inference.test.ts | 419 ++++++++++++++++- src/crestodian/setup-inference.ts | 441 ++++++++++++++---- src/gateway/server-methods/crestodian.ts | 2 +- .../contracts/registry.contract.test.ts | 1 + src/plugins/manifest-registry.test.ts | 2 + src/plugins/manifest.ts | 4 + src/plugins/provider-api-key-auth.ts | 1 + src/plugins/provider-auth-choice.ts | 109 +++-- src/plugins/provider-auth-choices.test.ts | 27 ++ src/plugins/provider-auth-choices.ts | 7 + src/plugins/types.ts | 2 + 58 files changed, 1145 insertions(+), 272 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 03ce86db17a3..53ed9027869e 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -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": "Couldn’t 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": "Couldn’t 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": "That’s 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": "That’s 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 didn’t 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", diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index 57814a99f62d..67ef9dd39f77 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -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 doesn’t 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: "Couldn’t 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( - "That’s fine — you can connect one with an API key. " + + "That’s 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 { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 893c768e6913..1f5fa4653148 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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 } diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index b332f877ecbc..80622dc05af8 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -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 diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 0dc25e5ddaf4..c702ac81d042 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -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. diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index abe48fc70311..4b79c5548b4f 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -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", diff --git a/extensions/arcee/openclaw.plugin.json b/extensions/arcee/openclaw.plugin.json index 895fbe3ef60f..98268d2b8fba 100644 --- a/extensions/arcee/openclaw.plugin.json +++ b/extensions/arcee/openclaw.plugin.json @@ -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", diff --git a/extensions/byteplus/openclaw.plugin.json b/extensions/byteplus/openclaw.plugin.json index 29869efbe2dd..8cfbff503727 100644 --- a/extensions/byteplus/openclaw.plugin.json +++ b/extensions/byteplus/openclaw.plugin.json @@ -149,6 +149,7 @@ "provider": "byteplus", "method": "api-key", "choiceId": "byteplus-api-key", + "appGuidedSecret": true, "choiceLabel": "BytePlus API key", "groupId": "byteplus", "groupLabel": "BytePlus", diff --git a/extensions/cerebras/openclaw.plugin.json b/extensions/cerebras/openclaw.plugin.json index 2b671b6d6744..f239802836af 100644 --- a/extensions/cerebras/openclaw.plugin.json +++ b/extensions/cerebras/openclaw.plugin.json @@ -98,6 +98,7 @@ "provider": "cerebras", "method": "api-key", "choiceId": "cerebras-api-key", + "appGuidedSecret": true, "choiceLabel": "Cerebras API key", "groupId": "cerebras", "groupLabel": "Cerebras", diff --git a/extensions/chutes/openclaw.plugin.json b/extensions/chutes/openclaw.plugin.json index 94a49d32dcd5..570d47105521 100644 --- a/extensions/chutes/openclaw.plugin.json +++ b/extensions/chutes/openclaw.plugin.json @@ -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", diff --git a/extensions/cohere/openclaw.plugin.json b/extensions/cohere/openclaw.plugin.json index 3eb4ed6a328e..24e71f1d9faa 100644 --- a/extensions/cohere/openclaw.plugin.json +++ b/extensions/cohere/openclaw.plugin.json @@ -51,6 +51,7 @@ "provider": "cohere", "method": "api-key", "choiceId": "cohere-api-key", + "appGuidedSecret": true, "choiceLabel": "Cohere API key", "groupId": "cohere", "groupLabel": "Cohere", diff --git a/extensions/deepinfra/openclaw.plugin.json b/extensions/deepinfra/openclaw.plugin.json index a75e4c00fd4b..b9e150901ae0 100644 --- a/extensions/deepinfra/openclaw.plugin.json +++ b/extensions/deepinfra/openclaw.plugin.json @@ -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", diff --git a/extensions/deepseek/openclaw.plugin.json b/extensions/deepseek/openclaw.plugin.json index 2f82b2b95a9e..d6db099cea13 100644 --- a/extensions/deepseek/openclaw.plugin.json +++ b/extensions/deepseek/openclaw.plugin.json @@ -123,6 +123,7 @@ "provider": "deepseek", "method": "api-key", "choiceId": "deepseek-api-key", + "appGuidedSecret": true, "choiceLabel": "DeepSeek API key", "groupId": "deepseek", "groupLabel": "DeepSeek", diff --git a/extensions/fireworks/openclaw.plugin.json b/extensions/fireworks/openclaw.plugin.json index 02e40fdff974..21bc96cc3c98 100644 --- a/extensions/fireworks/openclaw.plugin.json +++ b/extensions/fireworks/openclaw.plugin.json @@ -18,6 +18,7 @@ "provider": "fireworks", "method": "api-key", "choiceId": "fireworks-api-key", + "appGuidedSecret": true, "choiceLabel": "Fireworks API key", "groupId": "fireworks", "groupLabel": "Fireworks", diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 8315a006b013..6ec07987f20f 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -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), }, diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 57168fa35ac2..19b871f77991 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -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", diff --git a/extensions/gmi/openclaw.plugin.json b/extensions/gmi/openclaw.plugin.json index 416725b257ef..95546efd1a81 100644 --- a/extensions/gmi/openclaw.plugin.json +++ b/extensions/gmi/openclaw.plugin.json @@ -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", diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index 3acc5fa553a0..2f469b05cdaf 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -628,6 +628,7 @@ "provider": "google", "method": "api-key", "choiceId": "gemini-api-key", + "appGuidedSecret": true, "choiceLabel": "Google Gemini API key", "groupId": "google", "groupLabel": "Google", diff --git a/extensions/groq/openclaw.plugin.json b/extensions/groq/openclaw.plugin.json index 71f8b647cacc..c3af5882cfa2 100644 --- a/extensions/groq/openclaw.plugin.json +++ b/extensions/groq/openclaw.plugin.json @@ -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 ", + "cliDescription": "Groq API key" + } + ], "modelCatalog": { "providers": { "groq": { diff --git a/extensions/huggingface/openclaw.plugin.json b/extensions/huggingface/openclaw.plugin.json index 4dc45fda8af9..d15e7b5a05ed 100644 --- a/extensions/huggingface/openclaw.plugin.json +++ b/extensions/huggingface/openclaw.plugin.json @@ -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", diff --git a/extensions/kilocode/openclaw.plugin.json b/extensions/kilocode/openclaw.plugin.json index 613fb2f11bc1..9ebf1e48a26e 100644 --- a/extensions/kilocode/openclaw.plugin.json +++ b/extensions/kilocode/openclaw.plugin.json @@ -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", diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index f2044d8c6d86..d3f2d3db06fb 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -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)", diff --git a/extensions/litellm/openclaw.plugin.json b/extensions/litellm/openclaw.plugin.json index 68b4a5dcbfc6..388512adb18f 100644 --- a/extensions/litellm/openclaw.plugin.json +++ b/extensions/litellm/openclaw.plugin.json @@ -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", diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json index f7082ce0819a..1afcf8302039 100644 --- a/extensions/lmstudio/openclaw.plugin.json +++ b/extensions/lmstudio/openclaw.plugin.json @@ -38,6 +38,7 @@ "provider": "lmstudio", "method": "custom", "choiceId": "lmstudio", + "appGuidedSecret": true, "choiceLabel": "LM Studio", "choiceHint": "Local/self-hosted LM Studio server", "optionKey": "lmstudioApiKey", diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index b96f0b18ed32..4f3289830f06 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -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", diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json index d228c678ea64..1c1c3260cc95 100644 --- a/extensions/mistral/openclaw.plugin.json +++ b/extensions/mistral/openclaw.plugin.json @@ -152,6 +152,7 @@ "provider": "mistral", "method": "api-key", "choiceId": "mistral-api-key", + "appGuidedSecret": true, "choiceLabel": "Mistral API key", "groupId": "mistral", "groupLabel": "Mistral AI", diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index f9ec7eebf0a4..d40d491c86b6 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -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)", diff --git a/extensions/novita/openclaw.plugin.json b/extensions/novita/openclaw.plugin.json index b74faa7658b7..aa0e4f6d8f21 100644 --- a/extensions/novita/openclaw.plugin.json +++ b/extensions/novita/openclaw.plugin.json @@ -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", diff --git a/extensions/nvidia/openclaw.plugin.json b/extensions/nvidia/openclaw.plugin.json index dd12ad43d76f..4b7c5fdffe4f 100644 --- a/extensions/nvidia/openclaw.plugin.json +++ b/extensions/nvidia/openclaw.plugin.json @@ -164,6 +164,7 @@ "provider": "nvidia", "method": "api-key", "choiceId": "nvidia-api-key", + "appGuidedSecret": true, "choiceLabel": "NVIDIA API key", "groupId": "nvidia", "groupLabel": "NVIDIA", diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 202b7f0fa081..2fbb7aa1420d 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -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", diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 2ccefcb718de..500e555fbe6a 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -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, diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json index bb0d90705adc..d1538743dcf8 100644 --- a/extensions/opencode-go/openclaw.plugin.json +++ b/extensions/opencode-go/openclaw.plugin.json @@ -84,6 +84,7 @@ "provider": "opencode-go", "method": "api-key", "choiceId": "opencode-go", + "appGuidedSecret": true, "choiceLabel": "OpenCode Go catalog", "groupId": "opencode", "groupLabel": "OpenCode", diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json index 716153b3b3c2..0097ca14157f 100644 --- a/extensions/opencode/openclaw.plugin.json +++ b/extensions/opencode/openclaw.plugin.json @@ -196,6 +196,7 @@ "provider": "opencode", "method": "api-key", "choiceId": "opencode-zen", + "appGuidedSecret": true, "choiceLabel": "OpenCode Zen catalog", "groupId": "opencode", "groupLabel": "OpenCode", diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index a8558f11690e..0edb634ed8a4 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -49,6 +49,7 @@ "provider": "openrouter", "method": "api-key", "choiceId": "openrouter-api-key", + "appGuidedSecret": true, "choiceLabel": "OpenRouter API key", "groupId": "openrouter", "groupLabel": "OpenRouter", diff --git a/extensions/qianfan/openclaw.plugin.json b/extensions/qianfan/openclaw.plugin.json index 41b2c5256812..0e809755fb66 100644 --- a/extensions/qianfan/openclaw.plugin.json +++ b/extensions/qianfan/openclaw.plugin.json @@ -61,6 +61,7 @@ "provider": "qianfan", "method": "api-key", "choiceId": "qianfan-api-key", + "appGuidedSecret": true, "choiceLabel": "Qianfan API key", "groupId": "qianfan", "groupLabel": "Qianfan", diff --git a/extensions/qwen/openclaw.plugin.json b/extensions/qwen/openclaw.plugin.json index 2ef3e6cbe1ac..c3f3708f5493 100644 --- a/extensions/qwen/openclaw.plugin.json +++ b/extensions/qwen/openclaw.plugin.json @@ -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", diff --git a/extensions/stepfun/openclaw.plugin.json b/extensions/stepfun/openclaw.plugin.json index 9a5d712385b2..1eed475ed6b2 100644 --- a/extensions/stepfun/openclaw.plugin.json +++ b/extensions/stepfun/openclaw.plugin.json @@ -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", diff --git a/extensions/synthetic/openclaw.plugin.json b/extensions/synthetic/openclaw.plugin.json index e78448ed1b59..a94f60b2163c 100644 --- a/extensions/synthetic/openclaw.plugin.json +++ b/extensions/synthetic/openclaw.plugin.json @@ -18,6 +18,7 @@ "provider": "synthetic", "method": "api-key", "choiceId": "synthetic-api-key", + "appGuidedSecret": true, "choiceLabel": "Synthetic API key", "groupId": "synthetic", "groupLabel": "Synthetic", diff --git a/extensions/tencent/openclaw.plugin.json b/extensions/tencent/openclaw.plugin.json index 6f755e819dc9..8d6d02991bda 100644 --- a/extensions/tencent/openclaw.plugin.json +++ b/extensions/tencent/openclaw.plugin.json @@ -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", diff --git a/extensions/together/openclaw.plugin.json b/extensions/together/openclaw.plugin.json index 5f77ac3c4707..253966ec4dbe 100644 --- a/extensions/together/openclaw.plugin.json +++ b/extensions/together/openclaw.plugin.json @@ -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", diff --git a/extensions/venice/openclaw.plugin.json b/extensions/venice/openclaw.plugin.json index e2f27ebb8fde..75ec82c64c1a 100644 --- a/extensions/venice/openclaw.plugin.json +++ b/extensions/venice/openclaw.plugin.json @@ -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", diff --git a/extensions/vercel-ai-gateway/openclaw.plugin.json b/extensions/vercel-ai-gateway/openclaw.plugin.json index 12708083d456..16b522d9d7bc 100644 --- a/extensions/vercel-ai-gateway/openclaw.plugin.json +++ b/extensions/vercel-ai-gateway/openclaw.plugin.json @@ -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", diff --git a/extensions/volcengine/openclaw.plugin.json b/extensions/volcengine/openclaw.plugin.json index 612be5dc816f..9a8a71cf1fbb 100644 --- a/extensions/volcengine/openclaw.plugin.json +++ b/extensions/volcengine/openclaw.plugin.json @@ -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", diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 90652d5802a6..f59e38d216d3 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -84,6 +84,7 @@ "provider": "xai", "method": "api-key", "choiceId": "xai-api-key", + "appGuidedSecret": true, "choiceLabel": "xAI API key", "groupId": "xai", "groupLabel": "xAI (Grok)", diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index 21813c316129..5206942dd870 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -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", diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index ba5a1fd62d4d..a298bd31d774 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -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", diff --git a/packages/gateway-protocol/src/schema/crestodian.ts b/packages/gateway-protocol/src/schema/crestodian.ts index d6d921778441..cf97ec2f83a4 100644 --- a/packages/gateway-protocol/src/schema/crestodian.ts +++ b/packages/gateway-protocol/src/schema/crestodian.ts @@ -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()), }, diff --git a/src/crestodian/setup-inference.test.ts b/src/crestodian/setup-inference.test.ts index 6d43ea2f6c4c..1446df95a0e0 100644 --- a/src/crestodian/setup-inference.test.ts +++ b/src/crestodian/setup-inference.test.ts @@ -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 { 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 ", + 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 ", + 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 ", + 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 () => ({ diff --git a/src/crestodian/setup-inference.ts b/src/crestodian/setup-inference.ts index c2378ed69b45..dd6feaf58d4d 100644 --- a/src/crestodian/setup-inference.ts +++ b/src/crestodian/setup-inference.ts @@ -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 = { - 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; removeTempDir?: (dir: string) => Promise; timeoutMs?: number; }; -export async function detectSetupInference(): Promise { +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(); + 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 { 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 { ...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 { 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, +): 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 { - await updateAuthProfileStoreWithLock({ - agentDir: params.agentDir, +async function persistManualAuthProfiles( + profiles: ProviderAuthResult["profiles"], + agentDir: string, +): Promise { + 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, diff --git a/src/gateway/server-methods/crestodian.ts b/src/gateway/server-methods/crestodian.ts index 40c9f9a934d0..662692954d51 100644 --- a/src/gateway/server-methods/crestodian.ts +++ b/src/gateway/server-methods/crestodian.ts @@ -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", diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 41f1d1adee48..af1df3ebb717 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -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", diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index da7de3450245..e5bca64a8d83 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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, }, ]); }); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 091f90629413..63dffdb72604 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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 } : {}), }); } diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index 1edb77678c1e..f53028d5ccb5 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -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 | undefined; diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 78d501af876b..06a9a9a50ab0 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -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[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; +}): Promise { + 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"); } diff --git a/src/plugins/provider-auth-choices.test.ts b/src/plugins/provider-auth-choices.test.ts index f007a72b5fd5..ff0b2e32af95 100644 --- a/src/plugins/provider-auth-choices.test.ts +++ b/src/plugins/provider-auth-choices.test.ts @@ -322,6 +322,7 @@ describe("provider auth choice manifest helpers", () => { optionKey: "openaiApiKey", cliFlag: "--openai-api-key", cliOption: "--openai-api-key ", + appGuidedSecret: true, }, ], }, @@ -339,6 +340,15 @@ describe("provider auth choice manifest helpers", () => { cliFlag: "--openai-api-key", cliOption: "--openai-api-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 ", + }, ], }, ]); @@ -357,6 +367,7 @@ describe("provider auth choice manifest helpers", () => { optionKey: "openaiApiKey", cliFlag: "--openai-api-key", cliOption: "--openai-api-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, diff --git a/src/plugins/provider-auth-choices.ts b/src/plugins/provider-auth-choices.ts index ae18078014fa..e70a84f74392 100644 --- a/src/plugins/provider-auth-choices.ts +++ b/src/plugins/provider-auth-choices.ts @@ -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> = { @@ -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 && diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 09278f151df6..c5338b676db5 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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. *