diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 03ce86db17a..53ed9027869 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 57814a99f62..67ef9dd39f7 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 893c768e691..1f5fa465314 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 b332f877ecb..80622dc05af 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 0dc25e5ddaf..c702ac81d04 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 abe48fc7031..4b79c5548b4 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 895fbe3ef60..98268d2b8fb 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 29869efbe2d..8cfbff50372 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 2b671b6d674..f239802836a 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 94a49d32dcd..570d4710552 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 3eb4ed6a328..24e71f1d9fa 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 a75e4c00fd4..b9e150901ae 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 2f82b2b95a9..d6db099cea1 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 02e40fdff97..21bc96cc3c9 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 8315a006b01..6ec07987f20 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 57168fa35ac..19b871f7799 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 416725b257e..95546efd1a8 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 3acc5fa553a..2f469b05cda 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 71f8b647cac..c3af5882cfa 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 4dc45fda8af..d15e7b5a05e 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 613fb2f11bc..9ebf1e48a26 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 f2044d8c6d8..d3f2d3db06f 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 68b4a5dcbfc..388512adb18 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 f7082ce0819..1afcf830203 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 b96f0b18ed3..4f3289830f0 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 d228c678ea6..1c1c3260cc9 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 f9ec7eebf0a..d40d491c86b 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 b74faa7658b..aa0e4f6d8f2 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 dd12ad43d76..4b7c5fdffe4 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 202b7f0fa08..2fbb7aa1420 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 2ccefcb718d..500e555fbe6 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 bb0d90705ad..d1538743dcf 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 716153b3b3c..0097ca14157 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 a8558f11690..0edb634ed8a 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 41b2c525681..0e809755fb6 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 2ef3e6cbe1a..c3f3708f549 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 9a5d712385b..1eed475ed6b 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 e78448ed1b5..a94f60b2163 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 6f755e819dc..8d6d02991bd 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 5f77ac3c470..253966ec4db 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 e2f27ebb8fd..75ec82c64c1 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 12708083d45..16b522d9d7b 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 612be5dc816..9a8a71cf1fb 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 90652d5802a..f59e38d216d 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 21813c31612..5206942dd87 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 ba5a1fd62d4..a298bd31d77 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 d6d92177844..cf97ec2f83a 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 6d43ea2f6c4..1446df95a0e 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 c2378ed69b4..dd6feaf58d4 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 40c9f9a934d..662692954d5 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 41f1d1adee4..af1df3ebb71 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 da7de345024..e5bca64a8d8 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 091f9062941..63dffdb7260 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 1edb77678c1..f53028d5ccb 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 78d501af876..06a9a9a50ab 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 f007a72b5fd..ff0b2e32af9 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 ae18078014f..e70a84f7439 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 09278f151df..c5338b676db 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. *