From 7f7a709b1d0e573cd92f89ae9a1ca1a3f55c87a6 Mon Sep 17 00:00:00 2001 From: Michael Christenson II Date: Mon, 3 Aug 2026 19:49:31 -0400 Subject: [PATCH] fix: Ollama missing from onboarding when its service is reachable (#118020) --- docs/plugins/manifest.md | 5 +- extensions/ollama/api.ts | 1 + extensions/ollama/index.test.ts | 54 ++++- extensions/ollama/index.ts | 59 +++-- extensions/ollama/src/setup.ts | 2 +- src/commands/auth-choice-prompt.test.ts | 41 +++- src/commands/auth-choice-prompt.ts | 39 +++- src/commands/auth-choice.model-check.test.ts | 3 +- src/commands/onboard-guided-manual.ts | 24 +- src/commands/onboard-guided.custodian.test.ts | 21 +- src/commands/onboard-guided.test.ts | 210 +++++------------- src/plugins/provider-authentication.types.ts | 5 + .../provider-setup-availability.test.ts | 86 +++++++ src/plugins/provider-setup-availability.ts | 85 +++++++ .../setup-inference-plan-provider-auth.ts | 117 ++++++++++ src/system-agent/setup-inference-plan.ts | 179 +++++---------- src/system-agent/setup-inference.test.ts | 114 ++++++++++ src/wizard/setup.model-auth.test.ts | 13 +- src/wizard/setup.model-auth.ts | 39 ++-- src/wizard/setup.test.ts | 8 +- 20 files changed, 756 insertions(+), 349 deletions(-) create mode 100644 src/plugins/provider-setup-availability.test.ts create mode 100644 src/plugins/provider-setup-availability.ts create mode 100644 src/system-agent/setup-inference-plan-provider-auth.ts diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 6802aaa4e783..fde15f573a4b 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -415,7 +415,10 @@ When `appGuidedDiscovery` is true, the matching provider auth method must expose `appGuidedSetup.detect` and `appGuidedSetup.prepare`. Detection must be read-only: no login, model pull, download, or config write. Preparation rechecks the exact selected model and returns a config proposal; OpenClaw live-tests that -proposal in isolation and commits it only after success. +proposal in isolation and commits it only after success. A provider can also +expose `appGuidedSetup.detectAvailability` to mark its setup choice as detected +when the local service is reachable but no model qualifies for automatic setup. +The availability probe is also read-only. ## commandAliases reference diff --git a/extensions/ollama/api.ts b/extensions/ollama/api.ts index 8e7625b9fd95..765966755c3e 100644 --- a/extensions/ollama/api.ts +++ b/extensions/ollama/api.ts @@ -24,6 +24,7 @@ export { configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, + resolveOllamaSetupDefaultBaseUrl, } from "./src/setup.js"; export { buildOllamaChatRequest, diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 63b0ada459af..9ceb83958fff 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -65,6 +65,10 @@ vi.mock("./api.js", () => ({ fetchOllamaModels: fetchOllamaModelsMock, resolveOllamaApiBase: (baseUrl?: string) => (baseUrl ?? "http://127.0.0.1:11434").replace(/\/+$/, "").replace(/\/v1$/i, ""), + resolveOllamaSetupDefaultBaseUrl: (env: NodeJS.ProcessEnv = process.env) => + ["1", "true", "yes", "on"].includes(env.OPENCLAW_DOCKER_SETUP?.trim().toLowerCase() ?? "") + ? "http://host.docker.internal:11434" + : "http://127.0.0.1:11434", buildOllamaProvider: buildOllamaProviderMock, queryOllamaModelShowInfo: queryOllamaModelShowInfoMock, buildOllamaModelDefinition: buildOllamaModelDefinitionMock, @@ -591,6 +595,37 @@ describe("ollama plugin", () => { expect(ensureOllamaModelPulledMock).not.toHaveBeenCalled(); }); + it("detects a reachable Ollama service without requiring a suitable model", async () => { + const provider = registerProvider(); + fetchOllamaModelsMock.mockResolvedValue({ reachable: true, models: [] }); + + await expect( + provider.auth[0].appGuidedSetup?.detectAvailability?.({ config: {}, env: {} }), + ).resolves.toBe(true); + expect(fetchOllamaModelsMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {}); + }); + + it("does not mark an unreachable Ollama service as available", async () => { + const provider = registerProvider(); + fetchOllamaModelsMock.mockResolvedValue({ reachable: false, models: [] }); + + await expect( + provider.auth[0].appGuidedSetup?.detectAvailability?.({ config: {}, env: {} }), + ).resolves.toBe(false); + }); + + it("uses the Docker host default for availability detection during Docker setup", async () => { + const provider = registerProvider(); + fetchOllamaModelsMock.mockResolvedValue({ reachable: true, models: [] }); + + await provider.auth[0].appGuidedSetup?.detectAvailability?.({ + config: {}, + env: { OPENCLAW_DOCKER_SETUP: "1" }, + }); + + expect(fetchOllamaModelsMock).toHaveBeenCalledWith("http://host.docker.internal:11434", {}); + }); + it("does not auto-detect installed models that are not loaded", async () => { const provider = registerProvider(); fetchLoadedOllamaModelNamesMock.mockResolvedValue({ reachable: true, models: [] }); @@ -792,17 +827,20 @@ describe("ollama plugin", () => { it("honors the Ollama discovery opt-out during app-guided detection", async () => { const provider = registerProvider(); + const context = { + config: { + plugins: { entries: { ollama: { config: { discovery: { enabled: false } } } } }, + }, + env: {}, + }; - await expect( - provider.auth[0].appGuidedSetup?.detect({ - config: { - plugins: { entries: { ollama: { config: { discovery: { enabled: false } } } } }, - }, - env: {}, - }), - ).resolves.toBeNull(); + await expect(provider.auth[0].appGuidedSetup?.detect(context)).resolves.toBeNull(); + await expect(provider.auth[0].appGuidedSetup?.detectAvailability?.(context)).resolves.toBe( + false, + ); expect(fetchLoadedOllamaModelNamesMock).not.toHaveBeenCalled(); expect(buildOllamaProviderMock).not.toHaveBeenCalled(); + expect(fetchOllamaModelsMock).not.toHaveBeenCalled(); }); it("pulls the model the user actually selected", async () => { diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 89c79d4d09ea..6b3ae54ca4b3 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -42,6 +42,7 @@ import { promptAndConfigureOllama, queryOllamaModelShowInfo, resolveOllamaApiBase, + resolveOllamaSetupDefaultBaseUrl, } from "./api.js"; import { resolveThinkingProfile as resolveOllamaThinkingProfile } from "./provider-policy-api.js"; import { @@ -50,7 +51,6 @@ import { OLLAMA_CLOUD_PROVIDER_ID, OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, - OLLAMA_DOCKER_HOST_BASE_URL, OLLAMA_GLM52_CLOUD_MODEL_ID, } from "./src/defaults.js"; import { @@ -127,12 +127,7 @@ async function validateOllamaNonInteractive( ): Promise { const configuredBaseUrl = typeof ctx.opts.customBaseUrl === "string" ? ctx.opts.customBaseUrl.trim() : undefined; - const dockerSetup = ["1", "true", "yes", "on"].includes( - process.env.OPENCLAW_DOCKER_SETUP?.trim().toLowerCase() ?? "", - ); - const baseUrl = resolveOllamaApiBase( - configuredBaseUrl || (dockerSetup ? OLLAMA_DOCKER_HOST_BASE_URL : OLLAMA_DEFAULT_BASE_URL), - ); + const baseUrl = resolveOllamaApiBase(configuredBaseUrl || resolveOllamaSetupDefaultBaseUrl()); // Reset must only inspect existing models: pulling or storing credentials // here could mutate a healthy installation before its reset is approved. const discovery = await fetchOllamaModels(baseUrl); @@ -228,7 +223,7 @@ function orderAppGuidedOllamaModels(models: ModelDefinitionConfig[]): ModelDefin return ordered; } -async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) { +async function resolveAppGuidedOllamaConnection(ctx: ProviderAppGuidedSetupContext) { const pluginConfig = resolvePluginConfigObject(ctx.config, OLLAMA_PROVIDER_ID) as | OllamaPluginConfig | undefined; @@ -241,16 +236,47 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) }); const accessValue = await resolveAppGuidedOllamaApiKey(ctx, existing); const discoveryAccess = accessValue ? { apiKey: accessValue } : {}; - const baseUrl = resolveOllamaApiBase(readProviderBaseUrl(existing)); + return { + existing, + accessValue, + discoveryAccess, + baseUrl: resolveOllamaApiBase( + readProviderBaseUrl(existing) ?? resolveOllamaSetupDefaultBaseUrl(ctx.env), + ), + }; +} + +async function detectAppGuidedOllamaAvailability( + ctx: ProviderAppGuidedSetupContext, +): Promise { + const connection = await resolveAppGuidedOllamaConnection(ctx); + if (!connection) { + return false; + } + const result = await fetchOllamaModels(connection.baseUrl, { + ...connection.discoveryAccess, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); + return result.reachable; +} + +async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) { + const connection = await resolveAppGuidedOllamaConnection(ctx); + if (!connection) { + return null; + } // App-guided setup must not turn an installed-but-idle model into a surprise // memory allocation. Only /api/ps owns the currently resident model set. - const loaded = await fetchLoadedOllamaModelNames(baseUrl, discoveryAccess); + const loaded = await fetchLoadedOllamaModelNames(connection.baseUrl, { + ...connection.discoveryAccess, + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); if (!loaded.reachable || loaded.models.length === 0) { return null; } - const provider = await buildOllamaProvider(baseUrl, { + const provider = await buildOllamaProvider(connection.baseUrl, { quiet: true, - ...discoveryAccess, + ...connection.discoveryAccess, }); const toolModels = provider.models?.filter( @@ -265,7 +291,7 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) const showInfo = await queryOllamaModelShowInfo( provider.baseUrl, candidate.id, - accessValue ? { apiKey: accessValue } : undefined, + connection.accessValue ? { apiKey: connection.accessValue } : undefined, ); const contextWindow = showInfo.contextWindow; if ( @@ -290,16 +316,16 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) ...provider, models: provider.models?.map((candidate) => (candidate.id === model.id ? model : candidate)), }); - let ownerValue = existing?.apiKey; + let ownerValue = connection.existing?.apiKey; if (ownerValue === undefined) { - if (accessValue) { + if (connection.accessValue) { ownerValue = "OLLAMA_API_KEY"; } else { ownerValue = OLLAMA_DEFAULT_API_KEY; } } return { - existing, + existing: connection.existing, provider: preparedProvider, model, ownerValue, @@ -879,6 +905,7 @@ export default definePluginEntry({ hint: "Connect to an Ollama server and select a cloud or local model", kind: "custom", appGuidedSetup: { + detectAvailability: detectAppGuidedOllamaAvailability, detect: async (ctx) => { const discovered = await discoverAppGuidedOllamaModel(ctx); if (!discovered) { diff --git a/extensions/ollama/src/setup.ts b/extensions/ollama/src/setup.ts index b23228fb9b4a..659a2abdc94b 100644 --- a/extensions/ollama/src/setup.ts +++ b/extensions/ollama/src/setup.ts @@ -73,7 +73,7 @@ function isTruthyEnvValue(value: string | undefined): boolean { return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? ""); } -function resolveOllamaSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string { +export function resolveOllamaSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string { return isTruthyEnvValue(env.OPENCLAW_DOCKER_SETUP) ? OLLAMA_DOCKER_HOST_BASE_URL : OLLAMA_DEFAULT_BASE_URL; diff --git a/src/commands/auth-choice-prompt.test.ts b/src/commands/auth-choice-prompt.test.ts index 5e349da7355a..30ddc7156462 100644 --- a/src/commands/auth-choice-prompt.test.ts +++ b/src/commands/auth-choice-prompt.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import type { WizardPrompter, WizardSelectParams } from "../wizard/prompts.js"; import type { AuthChoiceGroup } from "./auth-choice-options.static.js"; -import { KEEP_CURRENT_AUTH_CHOICE, promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; +import { isKeepCurrentAuthChoice, promptAuthChoiceGrouped } from "./auth-choice-prompt.js"; const buildAuthChoiceGroups = vi.hoisted(() => vi.fn()); const compareAuthChoiceGroups = vi.hoisted(() => @@ -86,6 +86,11 @@ describe("promptAuthChoiceGrouped", () => { .mockImplementation((a: AuthChoiceGroup, b: AuthChoiceGroup) => a.label.localeCompare(b.label), ); + isFeaturedAuthChoiceGroup + .mockReset() + .mockImplementation((group: AuthChoiceGroup) => + ["openai", "anthropic", "xai", "google", "openrouter"].includes(group.value), + ); }); it("marks the configured provider and offers keep current config first", async () => { @@ -116,7 +121,7 @@ describe("promptAuthChoiceGrouped", () => { } if (params.message === "OpenAI auth method") { methodOptions = params.options; - return KEEP_CURRENT_AUTH_CHOICE; + return "__keep-current"; } throw new Error(`unexpected prompt ${params.message}`); }); @@ -137,19 +142,19 @@ describe("promptAuthChoiceGrouped", () => { }, }); - expect(result).toBe(KEEP_CURRENT_AUTH_CHOICE); + expect(isKeepCurrentAuthChoice(result)).toBe(true); expect(providerOptions).toContainEqual({ value: "openai", label: "OpenAI (currently configured)", hint: undefined, }); expect(methodOptions[0]).toEqual({ - value: KEEP_CURRENT_AUTH_CHOICE, + value: "__keep-current", label: "Keep current config", hint: "Keep openai/gpt-5.5", }); expect(methodOptions.map((option) => option.value)).toEqual([ - KEEP_CURRENT_AUTH_CHOICE, + "__keep-current", "openai", "openai-api-key", "__back", @@ -391,4 +396,30 @@ describe("promptAuthChoiceGrouped", () => { expect(messages).toEqual(["Model/auth provider", "Use which detected AI?"]); expect(result).toBe("candidate:codex-cli"); }); + + it("marks a detected provider in the provider picker", async () => { + const ollama = authChoiceGroup("ollama", "Ollama", [["ollama", "Ollama"]]); + buildAuthChoiceGroups.mockReturnValue({ + groups: [ollama], + skipOption: { value: "skip", label: "Skip for now" }, + }); + let providerOptions: Array<{ value: unknown; label: string }> = []; + const prompter = createPromptHarness(async (params) => { + providerOptions = params.options; + return "skip"; + }); + + await promptAuthChoiceGrouped({ + prompter, + store: EMPTY_STORE, + includeSkip: true, + detectedProviderIds: new Set(["ollama"]), + }); + + expect(providerOptions).toContainEqual({ + value: "ollama", + label: "Ollama (detected)", + hint: undefined, + }); + }); }); diff --git a/src/commands/auth-choice-prompt.ts b/src/commands/auth-choice-prompt.ts index 74cf5203c8d1..7bd2c6c37e00 100644 --- a/src/commands/auth-choice-prompt.ts +++ b/src/commands/auth-choice-prompt.ts @@ -15,7 +15,7 @@ import type { AuthChoice } from "./onboard-types.js"; const BACK_VALUE = "__back"; const MORE_VALUE = "__more"; -export const KEEP_CURRENT_AUTH_CHOICE = "__keep-current"; +const KEEP_CURRENT_AUTH_CHOICE = "__keep-current"; type KeepCurrentAuthChoice = typeof KEEP_CURRENT_AUTH_CHOICE; type PromptAuthChoiceResult = AuthChoice | KeepCurrentAuthChoice; @@ -31,8 +31,13 @@ type PromptAuthChoiceGroupedParams = { workspaceDir?: string; env?: NodeJS.ProcessEnv; allowKeepCurrentProvider?: boolean; + detectedProviderIds?: ReadonlySet; }; +export function isKeepCurrentAuthChoice(value: unknown): value is KeepCurrentAuthChoice { + return value === KEEP_CURRENT_AUTH_CHOICE; +} + function resolveConfiguredModelRef(config?: OpenClawConfig): string | undefined { return resolveAgentModelPrimaryValue(config?.agents?.defaults?.model); } @@ -58,11 +63,19 @@ function groupMatchesProvider(group: AuthChoiceGroup, provider: string | undefin function groupToOption( group: AuthChoiceGroup, configuredProvider: string | undefined, + detectedProviderIds: ReadonlySet | undefined, ): WizardSelectOption { const configured = groupMatchesProvider(group, configuredProvider); + const detected = [...(detectedProviderIds ?? [])].some((provider) => + groupMatchesProvider(group, provider), + ); + const statuses = [ + ...(detected ? ["detected"] : []), + ...(configured ? ["currently configured"] : []), + ]; return { value: group.value, - label: configured ? `${group.label} (currently configured)` : group.label, + label: statuses.length > 0 ? `${group.label} (${statuses.join(", ")})` : group.label, hint: group.hint, }; } @@ -88,14 +101,24 @@ export async function promptAuthChoiceGrouped( ); const availableGroups = [...availableBuiltInGroups, ...additionalGroups]; const groupById = new Map(availableGroups.map((group) => [group.value, group] as const)); + const isDetectedGroup = (group: AuthChoiceGroup) => + [...(params.detectedProviderIds ?? [])].some((provider) => + groupMatchesProvider(group, provider), + ); + const detectedBuiltInGroups = availableBuiltInGroups + .filter(isDetectedGroup) + .toSorted(compareAuthChoiceGroups); // Caller-supplied groups carry pre-vetted context such as detected onboarding routes. - // Keep them ahead of the generic catalog instead of demoting them under More. + // Keep them and reachable local providers ahead of the generic catalog. const featuredGroups = [ ...additionalGroups, - ...availableBuiltInGroups.filter(isFeaturedAuthChoiceGroup).toSorted(compareAuthChoiceGroups), + ...detectedBuiltInGroups, + ...availableBuiltInGroups + .filter((group) => !isDetectedGroup(group) && isFeaturedAuthChoiceGroup(group)) + .toSorted(compareAuthChoiceGroups), ]; const moreGroups = availableBuiltInGroups - .filter((group) => !isFeaturedAuthChoiceGroup(group)) + .filter((group) => !isDetectedGroup(group) && !isFeaturedAuthChoiceGroup(group)) .toSorted(compareAuthChoiceGroups); const configuredModelRef = resolveConfiguredModelRef(params.config); const configuredProvider = params.allowKeepCurrentProvider @@ -126,7 +149,7 @@ export async function promptAuthChoiceGrouped( const pickFromMore = async (): Promise => { while (true) { const options: WizardSelectOption[] = moreGroups.map((group) => - groupToOption(group, configuredProvider), + groupToOption(group, configuredProvider, params.detectedProviderIds), ); options.push({ value: BACK_VALUE, label: "Back" }); const selection = await params.prompter.select({ @@ -154,7 +177,7 @@ export async function promptAuthChoiceGrouped( const runFlat = async (): Promise => { while (true) { const flatOptions: WizardSelectOption[] = moreGroups.map((group) => - groupToOption(group, configuredProvider), + groupToOption(group, configuredProvider, params.detectedProviderIds), ); if (skipOption) { flatOptions.push({ value: skipOption.value, label: skipOption.label }); @@ -189,7 +212,7 @@ export async function promptAuthChoiceGrouped( while (true) { const topTier: WizardSelectOption[] = featuredGroups.map((group) => - groupToOption(group, configuredProvider), + groupToOption(group, configuredProvider, params.detectedProviderIds), ); if (moreGroups.length > 0) { topTier.push({ value: MORE_VALUE, label: "More…" }); diff --git a/src/commands/auth-choice.model-check.test.ts b/src/commands/auth-choice.model-check.test.ts index d82983429799..371511354902 100644 --- a/src/commands/auth-choice.model-check.test.ts +++ b/src/commands/auth-choice.model-check.test.ts @@ -43,8 +43,7 @@ vi.mock("../agents/openai-model-routes.js", async (importOriginal) => { }); const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1, profiles: {} }))); -vi.mock("../agents/auth-profiles.js", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("../agents/auth-profiles.js", () => ({ ensureAuthProfileStore, })); diff --git a/src/commands/onboard-guided-manual.ts b/src/commands/onboard-guided-manual.ts index 7d7578b5426b..79bab2dac936 100644 --- a/src/commands/onboard-guided-manual.ts +++ b/src/commands/onboard-guided-manual.ts @@ -103,6 +103,7 @@ export async function runManualStage(params: { const allowedChoices = new Set([ ...params.detection.manualProviders.map((provider) => provider.id), ...params.detection.authOptions.map((option) => option.id), + ...(params.detection.prepareOptions ?? []).map((option) => option.id), ]); const detectedOptions = params.detection.candidates.map((candidate) => ({ value: `candidate:${candidate.kind}`, @@ -134,11 +135,20 @@ export async function runManualStage(params: { }, ] : []; - const [{ ensureAuthProfileStore }, { promptAuthChoiceGrouped }] = await Promise.all([ + const [ + { ensureAuthProfileStore }, + { detectAvailableSetupProviderIds }, + { promptAuthChoiceGrouped }, + ] = await Promise.all([ import("../agents/auth-profiles.runtime.js"), + import("../plugins/provider-setup-availability.js"), import("./auth-choice-prompt.js"), ]); const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }); + const detectedProviderIds = await detectAvailableSetupProviderIds({ + config: params.config, + workspaceDir: params.workspace, + }); while (true) { const choice = await promptAuthChoiceGrouped({ prompter: params.prompter, @@ -149,6 +159,7 @@ export async function runManualStage(params: { additionalGroups, config: params.config, workspaceDir: params.workspace, + detectedProviderIds, }); if (choice === "skip") { @@ -184,12 +195,15 @@ export async function runManualStage(params: { continue; } - const authOption = params.detection.authOptions.find((item) => item.id === choice); - if (authOption) { + const providerAuthOption = [ + ...params.detection.authOptions, + ...(params.detection.prepareOptions ?? []), + ].find((item) => item.id === choice); + if (providerAuthOption) { const result = await withConsoleSubsystemsSuppressed(() => params.activate({ kind: "provider-auth", - authChoice: authOption.id, + authChoice: providerAuthOption.id, workspace: params.workspace, surface: "cli", runtime: params.runtime, @@ -201,7 +215,7 @@ export async function runManualStage(params: { } await noteActivationFailure({ prompter: params.prompter, - label: authOption.label, + label: providerAuthOption.label, result, }); continue; diff --git a/src/commands/onboard-guided.custodian.test.ts b/src/commands/onboard-guided.custodian.test.ts index 28eb13055240..4d3849b0f402 100644 --- a/src/commands/onboard-guided.custodian.test.ts +++ b/src/commands/onboard-guided.custodian.test.ts @@ -14,15 +14,18 @@ const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn()); const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1 as const, profiles: {} })), ); +const detectAvailableSetupProviderIds = vi.hoisted(() => vi.fn()); vi.mock("../../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState })); -vi.mock("./auth-choice-prompt.js", async (importActual) => ({ - ...(await importActual()), +vi.mock("./auth-choice-prompt.js", () => ({ promptAuthChoiceGrouped, })); vi.mock("../agents/auth-profiles.runtime.js", () => ({ ensureAuthProfileStore })); +vi.mock("../plugins/provider-setup-availability.js", () => ({ + detectAvailableSetupProviderIds, +})); vi.mock("./onboard-interactive-runner.js", async (importActual) => { const actual = await importActual(); @@ -213,6 +216,7 @@ function setupDeps(params: { persistRiskAcknowledgement?: GuidedOnboardingDeps["persistRiskAcknowledgement"]; runSetupMemoryImportStep?: GuidedOnboardingDeps["runSetupMemoryImportStep"]; runAppRecommendations?: GuidedOnboardingDeps["runAppRecommendations"]; + runBrowserHandoff?: GuidedOnboardingDeps["runBrowserHandoff"]; applySetup?: GuidedOnboardingDeps["applySetup"]; handoffMode?: GuidedOnboardingDeps["handoffMode"]; }) { @@ -255,11 +259,14 @@ function setupDeps(params: { runAppRecommendations: params.runAppRecommendations ?? vi.fn(async ({ config }) => ({ config, commitResult: vi.fn() })), - runBrowserHandoff: vi.fn(async () => ({ - handedOff: false as const, - reason: "gateway-unreachable" as const, - })), + runBrowserHandoff: + params.runBrowserHandoff ?? + (vi.fn(async () => ({ + handedOff: false as const, + reason: "timeout" as const, + })) as GuidedOnboardingDeps["runBrowserHandoff"]), runSystemAgentChat, + platform: "linux", ...(params.handoffMode ? { handoffMode: params.handoffMode } : {}), } satisfies GuidedOnboardingDeps; } @@ -282,6 +289,8 @@ describe("runGuidedOnboarding custodian flow", () => { restoreTerminalState.mockClear(); promptAuthChoiceGrouped.mockReset(); ensureAuthProfileStore.mockClear(); + detectAvailableSetupProviderIds.mockReset(); + detectAvailableSetupProviderIds.mockResolvedValue(new Set()); readConfigFileSnapshot.mockReset(); readConfigFileSnapshot.mockImplementation(async () => { return { diff --git a/src/commands/onboard-guided.test.ts b/src/commands/onboard-guided.test.ts index dc48e6cf71e4..bbb80eee3436 100644 --- a/src/commands/onboard-guided.test.ts +++ b/src/commands/onboard-guided.test.ts @@ -2,7 +2,6 @@ import fs from "node:fs"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { CallGatewayCliOptions } from "../gateway/call.js"; import { createSuiteLogPathTracker } from "../logging/log-test-helpers.js"; import { flushLogger, resetLogger, setLoggerOverride } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; @@ -11,17 +10,13 @@ import type { RuntimeEnv } from "../runtime.js"; import type { LocalOnboardingState } from "../state/local-onboarding-state.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { runGuidedOnboarding, type GuidedOnboardingDeps } from "./onboard-guided.js"; -import { runRemoteGatewayInferenceOnboarding } from "./onboard-remote-gateway.js"; - -type RemoteGatewayInferenceOnboardingDeps = NonNullable< - Parameters[2] ->; const restoreTerminalState = vi.hoisted(() => vi.fn()); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn()); const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1 as const, profiles: {} })), ); +const detectAvailableSetupProviderIds = vi.hoisted(() => vi.fn()); vi.mock("../../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState })); @@ -31,6 +26,9 @@ vi.mock("./auth-choice-prompt.js", async (importActual) => ({ })); vi.mock("../agents/auth-profiles.runtime.js", () => ({ ensureAuthProfileStore })); +vi.mock("../plugins/provider-setup-availability.js", () => ({ + detectAvailableSetupProviderIds, +})); vi.mock("./onboard-interactive-runner.js", async (importActual) => { const actual = await importActual(); @@ -268,6 +266,8 @@ describe("runGuidedOnboarding", () => { restoreTerminalState.mockClear(); promptAuthChoiceGrouped.mockReset(); ensureAuthProfileStore.mockClear(); + detectAvailableSetupProviderIds.mockReset(); + detectAvailableSetupProviderIds.mockResolvedValue(new Set(["ollama"])); readConfigFileSnapshot.mockReset().mockImplementation(async () => ({ exists: localOnboarding.persisted.config !== undefined, valid: true, @@ -777,6 +777,54 @@ describe("runGuidedOnboarding", () => { expect(text).not.toHaveBeenCalled(); }); + it("routes detected local provider setup through its provider-owned flow", async () => { + promptAuthChoiceGrouped.mockResolvedValueOnce("ollama"); + const prompter = createWizardPrompter(); + const activate = vi.fn(async () => ({ + ok: true as const, + modelRef: "ollama/qwen3.5:4b", + latencyMs: 500, + lines: ["Default model: ollama/qwen3.5:4b"], + })) as GuidedOnboardingDeps["activate"]; + const deps = setupDeps({ + prompter, + detect: vi.fn(async () => + detection({ + candidates: [], + prepareOptions: [ + { + id: "ollama", + brandId: "ollama", + label: "Ollama", + actionLabel: "Choose connection", + }, + ], + }), + ), + activate, + }); + const runtime = makeRuntime(); + + await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps); + + expect(promptAuthChoiceGrouped).toHaveBeenCalledWith( + expect.objectContaining({ + allowedChoices: new Set(["ollama"]), + detectedProviderIds: new Set(["ollama"]), + }), + ); + expect(activate).toHaveBeenCalledWith({ + kind: "provider-auth", + authChoice: "ollama", + workspace: "/tmp/work", + surface: "cli", + runtime, + prompter, + onCommitStarted: expect.any(Function), + }); + expect(prompter.text).not.toHaveBeenCalled(); + }); + it("lets the grouped provider picker skip without opening AI chat", async () => { promptAuthChoiceGrouped.mockResolvedValueOnce("skip"); const prompter = createWizardPrompter(); @@ -793,7 +841,10 @@ describe("runGuidedOnboarding", () => { await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps); expect(promptAuthChoiceGrouped).toHaveBeenCalledWith( - expect.objectContaining({ includeSkip: true }), + expect.objectContaining({ + includeSkip: true, + detectedProviderIds: new Set(["ollama"]), + }), ); expect(deps.activate).not.toHaveBeenCalled(); expect(deps.runSystemAgentChat).not.toHaveBeenCalled(); @@ -943,149 +994,4 @@ describe("runGuidedOnboarding", () => { expect(deps.detect).not.toHaveBeenCalled(); expect(deps.activate).not.toHaveBeenCalled(); }); - - it("converges remote inference before remote OpenClaw without mutating local config", async () => { - const localConfig = { - wizard: { securityAcknowledgedAt: "2026-07-11T00:00:00.000Z" }, - agents: { - defaults: { - workspace: "/client/workspace", - model: { primary: "openai/local-only" }, - }, - }, - gateway: { - mode: "remote", - remote: { url: "wss://configured.example/ws", token: "configured-token" }, - }, - } satisfies OpenClawConfig; - const localConfigBefore = structuredClone(localConfig); - readConfigFileSnapshot.mockResolvedValueOnce({ - exists: true, - valid: true, - path: "/tmp/openclaw.json", - issues: [], - config: localConfig, - }); - - const order: string[] = []; - const remoteConfig: { modelRef?: string } = {}; - const gatewayCallMock = vi.fn(async (options: CallGatewayCliOptions): Promise => { - expect(options.url).toBe("wss://selected.example/ws"); - expect(options.token).toBe("selected-token"); - expect(options.tlsFingerprint).toBe("sha256:selected"); - expect(options.ignoreEnvUrlOverride).toBe(true); - expect(options.config?.gateway?.remote?.url).toBe("wss://selected.example/ws"); - order.push(options.method); - if (options.method === "openclaw.setup.detect") { - return { - candidates: [ - { - kind: "claude-cli", - label: "Claude Code", - detail: "logged in", - modelRef: "claude-cli/opus", - recommended: true, - credentials: true, - }, - { - kind: "codex-cli", - label: "Codex", - detail: "logged in", - modelRef: "openai/gpt-5.5", - recommended: false, - credentials: true, - }, - ], - unavailableCandidates: [], - manualProviders: [], - authOptions: [], - recommendedInstalls: [], - workspace: "/gateway/workspace", - setupComplete: false, - }; - } - if (options.method === "openclaw.setup.activate") { - expect(options.params).toEqual({ - kind: "claude-cli", - modelRef: "claude-cli/opus", - workspace: "/gateway/workspace", - }); - remoteConfig.modelRef = "claude-cli/opus"; - return { - ok: true, - modelRef: remoteConfig.modelRef, - latencyMs: 250, - lines: ["Default model: claude-cli/opus"], - }; - } - if (options.method === "openclaw.setup.verify") { - expect(remoteConfig.modelRef).toBe("claude-cli/opus"); - return { ok: true, modelRef: remoteConfig.modelRef, latencyMs: 100 }; - } - if (options.method === "openclaw.chat") { - expect(remoteConfig.modelRef).toBe("claude-cli/opus"); - expect(options.params).toEqual({ - sessionId: expect.any(String), - welcomeVariant: "onboarding", - }); - return { - sessionId: (options.params as { sessionId: string }).sessionId, - reply: "Inference is ready. I can configure the rest.", - action: "open-agent", - }; - } - throw new Error(`unexpected Gateway method ${options.method}`); - }); - const runTui = vi.fn(async (options: unknown) => { - order.push("tui"); - expect(options).toEqual({ - config: expect.objectContaining({ - gateway: expect.objectContaining({ - remote: expect.objectContaining({ url: "wss://selected.example/ws" }), - }), - }), - deliver: false, - boundGateway: { - url: "wss://selected.example/ws", - token: "selected-token", - tlsFingerprint: "sha256:selected", - }, - }); - return { exitReason: "exit" as const }; - }); - const text = vi.fn(async () => "unexpected"); - const prompter = createWizardPrompter({ text }); - const runtime = makeRuntime(); - - await runRemoteGatewayInferenceOnboarding( - { - config: localConfig, - gatewayUrl: "wss://selected.example/ws", - token: "selected-token", - tlsFingerprint: "sha256:selected", - }, - runtime, - { - callGateway: gatewayCallMock as unknown as NonNullable< - RemoteGatewayInferenceOnboardingDeps["callGateway"] - >, - createPrompter: () => prompter, - runTui, - }, - ); - - expect(order).toEqual([ - "openclaw.setup.detect", - "openclaw.setup.activate", - "openclaw.setup.verify", - "openclaw.chat", - "tui", - ]); - expect(remoteConfig.modelRef).toBe("claude-cli/opus"); - expect(localConfig).toEqual(localConfigBefore); - expect(text).not.toHaveBeenCalled(); - expect( - JSON.stringify([prompter.note, prompter.outro, runtime.log, runtime.error]), - ).not.toContain("selected-token"); - }); }); diff --git a/src/plugins/provider-authentication.types.ts b/src/plugins/provider-authentication.types.ts index 7b7538e619d1..e8795ba2cc29 100644 --- a/src/plugins/provider-authentication.types.ts +++ b/src/plugins/provider-authentication.types.ts @@ -134,6 +134,11 @@ export type ProviderAppGuidedSetupCandidate = { }; export type ProviderAppGuidedSetup = { + /** + * Report whether the provider's local service is reachable, even when no + * model is suitable for automatic activation. This probe must be read-only. + */ + detectAvailability?: (ctx: ProviderAppGuidedSetupContext) => Promise; /** Detection is read-only: no model pull, download, login, or config write. */ detect: (ctx: ProviderAppGuidedSetupContext) => Promise; /** Recheck one detected model and return the config required for a live probe. */ diff --git a/src/plugins/provider-setup-availability.test.ts b/src/plugins/provider-setup-availability.test.ts new file mode 100644 index 000000000000..68fd8eb39c79 --- /dev/null +++ b/src/plugins/provider-setup-availability.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { detectAvailableSetupProviderIds } from "./provider-setup-availability.js"; + +const resolveManifestProviderAuthChoices = vi.hoisted(() => vi.fn()); +const enablePluginInConfig = vi.hoisted(() => vi.fn()); +const resolvePluginProviders = vi.hoisted(() => vi.fn()); +const debug = vi.hoisted(() => vi.fn()); + +vi.mock("./provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoices, +})); + +vi.mock("./enable.js", () => ({ + enablePluginInConfig, +})); + +vi.mock("./providers.runtime.js", () => ({ + resolvePluginProviders, +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ debug }), +})); + +describe("detectAvailableSetupProviderIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "ollama", + providerId: "ollama", + methodId: "local", + choiceId: "ollama", + choiceLabel: "Ollama", + appGuidedDiscovery: true, + }, + ]); + enablePluginInConfig.mockImplementation((config: unknown) => ({ + config, + enabled: true, + pluginId: "ollama", + })); + }); + + it("returns the provider id when its read-only availability probe succeeds", async () => { + const detectAvailability = vi.fn(async () => true); + resolvePluginProviders.mockReturnValue([ + { + pluginId: "ollama", + id: "ollama", + auth: [{ id: "local", appGuidedSetup: { detectAvailability } }], + }, + ]); + + await expect(detectAvailableSetupProviderIds({ config: {} })).resolves.toEqual( + new Set(["ollama"]), + ); + expect(detectAvailability).toHaveBeenCalledWith({ + config: {}, + env: process.env, + workspaceDir: undefined, + }); + }); + + it("treats failed availability probes as an intentional non-match", async () => { + resolvePluginProviders.mockReturnValue([ + { + pluginId: "ollama", + id: "ollama", + auth: [ + { + id: "local", + appGuidedSetup: { + detectAvailability: vi.fn(async () => { + throw new Error("offline"); + }), + }, + }, + ], + }, + ]); + + await expect(detectAvailableSetupProviderIds({ config: {} })).resolves.toEqual(new Set()); + expect(debug).toHaveBeenCalledWith(expect.stringContaining("offline")); + }); +}); diff --git a/src/plugins/provider-setup-availability.ts b/src/plugins/provider-setup-availability.ts new file mode 100644 index 000000000000..28d2e09655de --- /dev/null +++ b/src/plugins/provider-setup-availability.ts @@ -0,0 +1,85 @@ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { enablePluginInConfig } from "./enable.js"; +import { + type ProviderAuthChoiceMetadata, + resolveManifestProviderAuthChoices, +} from "./provider-auth-choices.js"; +import { resolvePluginProviders } from "./providers.runtime.js"; + +const log = createSubsystemLogger("plugins/provider-setup-availability"); + +function supportsTextInference(choice: ProviderAuthChoiceMetadata): boolean { + return !choice.onboardingScopes || choice.onboardingScopes.includes("text-inference"); +} + +/** Detect reachable provider-owned services for the classic setup picker. */ +export async function detectAvailableSetupProviderIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): Promise> { + const env = params.env ?? process.env; + const choices = resolveManifestProviderAuthChoices({ + config: params.config, + workspaceDir: params.workspaceDir, + env, + includeUntrustedWorkspacePlugins: false, + }).filter( + (choice) => + choice.appGuidedDiscovery === true && + choice.assistantVisibility !== "manual-only" && + supportsTextInference(choice), + ); + let discoveryConfig = params.config; + const enabledChoices = choices.filter((choice) => { + const enabled = enablePluginInConfig(discoveryConfig, choice.pluginId); + discoveryConfig = enabled.config; + return enabled.enabled; + }); + if (enabledChoices.length === 0) { + return new Set(); + } + + const providers = resolvePluginProviders({ + config: discoveryConfig, + workspaceDir: params.workspaceDir, + env, + mode: "setup", + includeUntrustedWorkspacePlugins: false, + onlyPluginIds: uniqueStrings(enabledChoices.map((choice) => choice.pluginId)), + }); + const detected = await Promise.all( + enabledChoices.map(async (choice) => { + const provider = providers.find( + (candidate) => + candidate.pluginId === choice.pluginId && + normalizeProviderId(candidate.id) === normalizeProviderId(choice.providerId), + ); + const method = provider?.auth.find( + (candidate) => normalizeProviderId(candidate.id) === normalizeProviderId(choice.methodId), + ); + if (!method?.appGuidedSetup?.detectAvailability) { + return undefined; + } + try { + return (await method.appGuidedSetup.detectAvailability({ + config: discoveryConfig, + env, + workspaceDir: params.workspaceDir, + })) + ? choice.providerId + : undefined; + } catch (error) { + log.debug( + `Provider availability detection failed for ${choice.choiceId}: ${formatErrorMessage(error)}`, + ); + return undefined; + } + }), + ); + return new Set(detected.filter((providerId): providerId is string => Boolean(providerId))); +} diff --git a/src/system-agent/setup-inference-plan-provider-auth.ts b/src/system-agent/setup-inference-plan-provider-auth.ts new file mode 100644 index 000000000000..d50821a28284 --- /dev/null +++ b/src/system-agent/setup-inference-plan-provider-auth.ts @@ -0,0 +1,117 @@ +import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; +import { normalizeProviderId } from "../agents/model-selection.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choices.js"; +import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { parseRef } from "./setup-inference-plan-helpers.js"; + +export 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 existingPrimary = resolveAgentModelPrimaryValue(params.config.agents?.defaults?.model); + const existingProvider = existingPrimary ? parseRef(existingPrimary).provider : undefined; + let providerSetupConfig = params.config; + if ( + existingProvider && + normalizeProviderId(existingProvider) !== normalizeProviderId(params.choice.providerId) + ) { + const agents = params.config.agents; + const defaults = agents?.defaults; + const model = defaults?.model; + if (defaults && model !== undefined) { + const { model: _model, ...defaultsWithoutModel } = defaults; + let modelWithoutPrimary: Exclude | undefined; + if (typeof model === "object" && model !== null) { + const { primary: _primary, ...remainingModelConfig } = model; + modelWithoutPrimary = remainingModelConfig; + } + // App-guided setup needs this provider's verified candidate, not an + // unrelated current default. The original config remains the merge base. + providerSetupConfig = { + ...params.config, + agents: { + ...agents, + defaults: + modelWithoutPrimary && Object.keys(modelWithoutPrimary).length > 0 + ? { ...defaultsWithoutModel, model: modelWithoutPrimary } + : defaultsWithoutModel, + }, + }; + } + } + + const configured = await runNonInteractive({ + authChoice: params.choice.choiceId, + config: providerSetupConfig, + 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, + }; +} diff --git a/src/system-agent/setup-inference-plan.ts b/src/system-agent/setup-inference-plan.ts index 8746901411d6..2accebb55366 100644 --- a/src/system-agent/setup-inference-plan.ts +++ b/src/system-agent/setup-inference-plan.ts @@ -1,5 +1,4 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadPersistedAuthProfileStore } from "../agents/auth-profiles/persisted.js"; import type { CodexCliApiKeyCredential } from "../agents/cli-credentials.js"; import { CliExecutionAuthProfileError } from "../agents/cli-execution-auth.js"; import { normalizeProviderId } from "../agents/model-selection.js"; @@ -11,10 +10,7 @@ import { OPENAI_API_DEFAULT_MODEL_REF, } from "../commands/onboard-inference.js"; import { createMergePatch } from "../config/merge-patch.js"; -import { - normalizeAgentModelRefForConfig, - resolveAgentModelPrimaryValue, -} from "../config/model-input.js"; +import { normalizeAgentModelRefForConfig } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { enablePluginInConfig } from "../plugins/enable.js"; @@ -22,12 +18,9 @@ import { applyProviderPluginAuthMethodResultConfig, runProviderPluginAuthMethodUnpersisted, } from "../plugins/provider-auth-choice.js"; -import { - type ProviderAuthChoiceMetadata, - resolveManifestProviderAuthChoice, -} from "../plugins/provider-auth-choices.js"; +import { resolveManifestProviderAuthChoice } from "../plugins/provider-auth-choices.js"; import { resolvePluginProviders } from "../plugins/providers.runtime.js"; -import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js"; +import type { ProviderAuthResult } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { resolveSystemAgentConfiguredRouteFromConfig } from "./inference-route.js"; @@ -52,6 +45,7 @@ import { prepareManualAuthForActivation, projectManualInferenceConfig, } from "./setup-inference-plan-helpers.js"; +import { runProviderManualSecretMethod } from "./setup-inference-plan-provider-auth.js"; export async function buildTestPlan(params: { kind: SetupInferenceKind | "api-key" | "provider-auth"; @@ -398,11 +392,13 @@ export async function buildTestPlan(params: { !choice || !supportsSetupTextInference(choice.onboardingScopes) || (!interactive && !supportsSetupManualSecret(choice)) || - (interactive && (choice.assistantVisibility === "manual-only" || !choice.appGuidedAuth)) + (interactive && + (choice.assistantVisibility === "manual-only" || + (!choice.appGuidedAuth && choice.appGuidedDiscovery !== true))) ) { return { error: interactive - ? "That provider login is not available on this Gateway." + ? "That provider setup is not available on this Gateway." : "That key-based provider is not available on this Gateway.", }; } @@ -436,11 +432,14 @@ export async function buildTestPlan(params: { if ( !resolved || !supportsSetupTextInference(resolved.method.wizard?.onboardingScopes) || - (interactive && resolved.method.kind !== "oauth" && resolved.method.kind !== "device_code") + (interactive && + choice.appGuidedDiscovery !== true && + resolved.method.kind !== "oauth" && + resolved.method.kind !== "device_code") ) { return { error: interactive - ? "That provider login is not available on this Gateway." + ? "That provider setup is not available on this Gateway." : "That key-based provider is not available on this Gateway.", }; } @@ -470,6 +469,49 @@ export async function buildTestPlan(params: { config: enableResult.config, result, }); + if (choice.appGuidedDiscovery === true) { + const guidedSetup = resolved.method.appGuidedSetup; + if (!guidedSetup) { + return { error: "That provider setup is not available on this Gateway." }; + } + const candidate = await guidedSetup.detect({ + config: preparedConfig, + env: process.env, + workspaceDir: params.pluginWorkspaceDir, + ...(params.signal ? { signal: params.signal } : {}), + }); + if (!candidate) { + return { + error: `${resolved.provider.label} setup completed, but no compatible model was found. Add a compatible model and try again.`, + }; + } + const prepared = await guidedSetup.prepare({ + config: preparedConfig, + env: process.env, + workspaceDir: params.pluginWorkspaceDir, + modelRef: candidate.modelRef, + ...(params.signal ? { signal: params.signal } : {}), + }); + const preparedModelRef = prepared?.defaultModel + ? normalizeAgentModelRefForConfig(prepared.defaultModel) + : ""; + if (!prepared || preparedModelRef !== candidate.modelRef) { + return { + error: `${resolved.provider.label} could not prepare its detected model. Try setup again.`, + }; + } + preparedConfig = applyProviderPluginAuthMethodResultConfig({ + config: preparedConfig, + result: prepared, + }); + const profiles = new Map( + [...result.profiles, ...prepared.profiles].map((profile) => [ + profile.profileId, + profile, + ]), + ); + result = { ...prepared, profiles: [...profiles.values()] }; + } } else if (resolved.method.kind === "api_key" || resolved.method.kind === "token") { result = await runProviderPluginAuthMethodUnpersisted({ config: enableResult.config, @@ -563,112 +605,3 @@ export async function buildTestPlan(params: { return { error: `Unknown inference choice "${kind}".` }; } } - -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 existingPrimary = resolveAgentModelPrimaryValue(params.config.agents?.defaults?.model); - const existingProvider = existingPrimary ? parseRef(existingPrimary).provider : undefined; - let providerSetupConfig = params.config; - if ( - existingProvider && - normalizeProviderId(existingProvider) !== normalizeProviderId(params.choice.providerId) - ) { - const agents = params.config.agents; - const defaults = agents?.defaults; - const model = defaults?.model; - if (defaults && model !== undefined) { - const { model: _model, ...defaultsWithoutModel } = defaults; - let modelWithoutPrimary: Exclude | undefined; - if (typeof model === "object" && model !== null) { - const { primary: _primary, ...remainingModelConfig } = model; - modelWithoutPrimary = remainingModelConfig; - } - // App-guided setup needs this provider's verified candidate, not an - // unrelated current default. The original config remains the merge base. - providerSetupConfig = { - ...params.config, - agents: { - ...agents, - defaults: - modelWithoutPrimary && Object.keys(modelWithoutPrimary).length > 0 - ? { ...defaultsWithoutModel, model: modelWithoutPrimary } - : defaultsWithoutModel, - }, - }; - } - } - - const configured = await runNonInteractive({ - authChoice: params.choice.choiceId, - config: providerSetupConfig, - 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, - }; -} diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 5b03252c6f94..90b57cbfa065 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -2636,6 +2636,120 @@ describe("activateSetupInference", () => { } }); + it("runs provider-owned local setup from an app-guided discovery choice", async () => { + const { stateDir, initialConfig } = await createMainAgentFixture(); + const runAuth = vi.fn(async () => ({ + profiles: [ + { + profileId: "ollama:default", + credential: { + type: "api_key" as const, + provider: "ollama", + key: "ollama-local", + }, + }, + ], + configPatch: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama" as const, + apiKey: "ollama-local", + models: [], + }, + }, + }, + }, + })); + const detect = vi.fn(async () => ({ + modelRef: "ollama/qwen3.5:4b", + detail: "qwen3.5:4b at http://127.0.0.1:11434", + })); + const prepare = vi.fn(async () => ({ + profiles: [], + defaultModel: "ollama/qwen3.5:4b", + configPatch: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama" as const, + apiKey: "ollama-local", + models: [], + }, + }, + }, + }, + })); + const provider: ProviderPlugin = { + id: "ollama", + label: "Ollama", + pluginId: "ollama", + auth: [ + { + id: "local", + label: "Ollama", + kind: "custom", + run: runAuth, + appGuidedSetup: { detect, prepare }, + }, + ], + }; + const runEmbeddedAgent = vi.fn( + async (params: SuccessfulRunParams & { authProfileId?: string }) => + successfulRun("ollama", "qwen3.5:4b", params), + ); + const configHarness = createConfigTransformHarness(initialConfig); + + try { + const result = await activateSetupInference({ + kind: "provider-auth", + authChoice: "ollama", + workspace: "/tmp/openclaw-workspace", + prompter: { note: vi.fn(async () => {}) } as never, + deps: { + readConfigFileSnapshot: mockConfigSnapshot(initialConfig, { + includeMetadata: true, + }), + resolvePluginProviders: () => [provider], + resolveManifestProviderAuthChoice: () => ({ + pluginId: "ollama", + providerId: "ollama", + methodId: "local", + choiceId: "ollama", + choiceLabel: "Ollama", + appGuidedDiscovery: true, + }), + runEmbeddedAgent: runEmbeddedAgent as never, + transformConfigWithPendingPluginInstalls: configHarness.transform as never, + }, + }); + + expect(result).toMatchObject({ ok: true, modelRef: "ollama/qwen3.5:4b" }); + expect(runAuth).toHaveBeenCalledOnce(); + expect(detect).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + models: { + providers: { + ollama: expect.objectContaining({ + baseUrl: "http://127.0.0.1:11434", + apiKey: "ollama-local", + }), + }, + }, + }), + }), + ); + expect(prepare).toHaveBeenCalledWith( + expect.objectContaining({ modelRef: "ollama/qwen3.5:4b" }), + ); + } finally { + await removeOAuthTestTempRoot(stateDir); + } + }); + it("does not probe or persist an interactive login after session cancellation", async () => { const runAuth = vi.fn(async () => ({ profiles: [], defaultModel: "openai/gpt-5.5" })); const runEmbeddedAgent = vi.fn(); diff --git a/src/wizard/setup.model-auth.test.ts b/src/wizard/setup.model-auth.test.ts index 0155cf93a610..e32ea0dbf824 100644 --- a/src/wizard/setup.model-auth.test.ts +++ b/src/wizard/setup.model-auth.test.ts @@ -17,6 +17,7 @@ const promptDefaultModel = vi.hoisted(() => vi.fn()); const applyPrimaryModel = vi.hoisted(() => vi.fn((config: unknown) => config)); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn()); const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); +const detectAvailableSetupProviderIds = vi.hoisted(() => vi.fn()); const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => ({ pluginId: "anthropic", @@ -43,7 +44,7 @@ vi.mock("../commands/model-picker.js", () => ({ })); vi.mock("../commands/auth-choice-prompt.js", () => ({ - KEEP_CURRENT_AUTH_CHOICE: "__keep_current__", + isKeepCurrentAuthChoice: (value: unknown) => value === "__keep-current", promptAuthChoiceGrouped, })); @@ -51,6 +52,10 @@ vi.mock("../agents/auth-profiles.runtime.js", () => ({ ensureAuthProfileStore, })); +vi.mock("../plugins/provider-setup-availability.js", () => ({ + detectAvailableSetupProviderIds, +})); + vi.mock("../plugins/provider-auth-choices.js", () => ({ resolveManifestProviderAuthChoice, })); @@ -97,6 +102,7 @@ describe("runSetupModelAuthStep", () => { vi.clearAllMocks(); promptDefaultModel.mockResolvedValue({}); warnIfModelConfigLooksOff.mockResolvedValue(undefined); + detectAvailableSetupProviderIds.mockResolvedValue(new Set(["ollama"])); }); it("targets the configured default agent for auth and model setup", async () => { @@ -120,7 +126,10 @@ describe("runSetupModelAuthStep", () => { readOnly: true, }); expect(promptAuthChoiceGrouped).toHaveBeenCalledWith( - expect.objectContaining({ workspaceDir: "/tmp/ops-workspace" }), + expect.objectContaining({ + workspaceDir: "/tmp/ops-workspace", + detectedProviderIds: new Set(["ollama"]), + }), ); expect(applyAuthChoice).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/wizard/setup.model-auth.ts b/src/wizard/setup.model-auth.ts index fdac0391d8c5..04dfa23ed711 100644 --- a/src/wizard/setup.model-auth.ts +++ b/src/wizard/setup.model-auth.ts @@ -12,8 +12,6 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { t } from "./i18n/index.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; -type KeepCurrentAuthChoice = - typeof import("../commands/auth-choice-prompt.js").KEEP_CURRENT_AUTH_CHOICE; type PreparedAuthChoiceResult = Awaited< ReturnType >; @@ -28,13 +26,6 @@ const loadAuthChoiceModule = createLazyRuntimeModule(() => import("../commands/a const loadModelPickerModule = createLazyRuntimeModule(() => import("../commands/model-picker.js")); -function isAuthChoiceSelected( - value: AuthChoice | KeepCurrentAuthChoice, - keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined, -): value is AuthChoice { - return keepCurrentAuthChoice === undefined || value !== keepCurrentAuthChoice; -} - async function resolveAuthChoiceModelSelectionPolicy(params: { authChoice: string; config: OpenClawConfig; @@ -143,24 +134,39 @@ export async function runSetupModelAuthStep(params: { let persistAuthProfiles: PreparedAuthChoiceResult["persistAuthProfiles"] = params.stagedCandidate?.persistAuthProfiles ?? (async () => {}); const authChoiceFromPrompt = opts.authChoice === undefined; - let authChoice: AuthChoice | KeepCurrentAuthChoice | undefined = opts.authChoice; + let authChoice: AuthChoice | undefined = opts.authChoice; let authStore: | ReturnType<(typeof import("../agents/auth-profiles.runtime.js"))["ensureAuthProfileStore"]> | undefined; let promptAuthChoiceGrouped: | (typeof import("../commands/auth-choice-prompt.js"))["promptAuthChoiceGrouped"] | undefined; - let keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined; + let isKeepCurrentAuthChoice: + | (typeof import("../commands/auth-choice-prompt.js"))["isKeepCurrentAuthChoice"] + | undefined; + let detectedProviderIds: ReadonlySet | undefined; if (authChoiceFromPrompt) { - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.runtime.js"); - const authChoicePromptModule = await import("../commands/auth-choice-prompt.js"); - promptAuthChoiceGrouped = authChoicePromptModule.promptAuthChoiceGrouped; - keepCurrentAuthChoice = authChoicePromptModule.KEEP_CURRENT_AUTH_CHOICE; + const [ + { ensureAuthProfileStore }, + { promptAuthChoiceGrouped: promptAuthChoice, isKeepCurrentAuthChoice: isKeepCurrentChoice }, + { detectAvailableSetupProviderIds }, + ] = await Promise.all([ + import("../agents/auth-profiles.runtime.js"), + import("../commands/auth-choice-prompt.js"), + import("../plugins/provider-setup-availability.js"), + ]); + promptAuthChoiceGrouped = promptAuthChoice; + isKeepCurrentAuthChoice = isKeepCurrentChoice; const target = resolveOnboardingAgentTarget(nextConfig); authStore = ensureAuthProfileStore(params.agentDir ?? target.agentDir, { allowKeychainPrompt: false, readOnly: true, }); + detectedProviderIds = await detectAvailableSetupProviderIds({ + config: nextConfig, + workspaceDir: target.workspaceDir, + env, + }); } while (true) { if (authChoiceFromPrompt) { @@ -172,12 +178,13 @@ export async function runSetupModelAuthStep(params: { config: nextConfig, workspaceDir: target.workspaceDir, allowKeepCurrentProvider: true, + detectedProviderIds, }); } if (authChoice === undefined) { throw new WizardCancelledError(t("wizard.setup.authChoiceRequired")); } - if (!isAuthChoiceSelected(authChoice, keepCurrentAuthChoice)) { + if (isKeepCurrentAuthChoice?.(authChoice)) { break; } diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 32831c891ff5..146d94e847c6 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -36,7 +36,6 @@ type RunSetupMigrationImport = typeof import("./setup.migration-import.js").runS type RunSearchSetupFlow = typeof import("../flows/search-setup.js").runSearchSetupFlow; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); -const keepCurrentAuthChoice = vi.hoisted(() => "__keep-current" as const); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })), @@ -352,7 +351,7 @@ vi.mock("../agents/auth-profiles.runtime.js", () => ({ })); vi.mock("../commands/auth-choice-prompt.js", () => ({ - KEEP_CURRENT_AUTH_CHOICE: keepCurrentAuthChoice, + isKeepCurrentAuthChoice: (value: unknown) => value === "__keep-current", promptAuthChoiceGrouped, })); @@ -365,6 +364,7 @@ vi.mock("../commands/auth-choice.js", () => ({ vi.mock("../plugins/provider-auth-choices.js", () => ({ resolveManifestProviderAuthChoice, + resolveManifestProviderAuthChoices: () => [], })); vi.mock("../plugins/setup-registry.js", () => ({ @@ -1586,7 +1586,7 @@ describe("runSetupWizard", () => { warnings: [], legacyIssues: [], }); - promptAuthChoiceGrouped.mockResolvedValueOnce(keepCurrentAuthChoice); + promptAuthChoiceGrouped.mockResolvedValueOnce("__keep-current"); const workspaceDir = await makeCaseDir("keep-provider-config-"); const prompter = buildWizardPrompter(); const runtime = createRuntime(); @@ -2815,7 +2815,7 @@ describe("runSetupWizard", () => { }); promptAuthChoiceGrouped .mockResolvedValueOnce("demo-provider") - .mockResolvedValueOnce(keepCurrentAuthChoice); + .mockResolvedValueOnce("__keep-current"); verifySetupInferenceConfig .mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" }) .mockResolvedValueOnce({