fix: Ollama missing from onboarding when its service is reachable (#118020)

This commit is contained in:
Michael Christenson II
2026-08-03 19:49:31 -04:00
committed by GitHub
parent 5cd6694349
commit 7f7a709b1d
20 changed files with 756 additions and 349 deletions
+4 -1
View File
@@ -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
+1
View File
@@ -24,6 +24,7 @@ export {
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
resolveOllamaSetupDefaultBaseUrl,
} from "./src/setup.js";
export {
buildOllamaChatRequest,
+46 -8
View File
@@ -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 () => {
+43 -16
View File
@@ -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<boolean> {
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<boolean> {
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) {
+1 -1
View File
@@ -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;
+36 -5
View File
@@ -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,
});
});
});
+31 -8
View File
@@ -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<string>;
};
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<string> | 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<AuthChoiceOrBack> => {
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<PromptAuthChoiceResult> => {
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…" });
+1 -2
View File
@@ -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<typeof import("../agents/auth-profiles.js")>()),
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore,
}));
+19 -5
View File
@@ -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;
+15 -6
View File
@@ -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<typeof import("./auth-choice-prompt.js")>()),
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<typeof import("./onboard-interactive-runner.js")>();
@@ -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 {
+58 -152
View File
@@ -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<typeof runRemoteGatewayInferenceOnboarding>[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<typeof import("./onboard-interactive-runner.js")>();
@@ -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<unknown> => {
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");
});
});
@@ -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<boolean>;
/** Detection is read-only: no model pull, download, login, or config write. */
detect: (ctx: ProviderAppGuidedSetupContext) => Promise<ProviderAppGuidedSetupCandidate | null>;
/** Recheck one detected model and return the config required for a live probe. */
@@ -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"));
});
});
@@ -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<ReadonlySet<string>> {
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)));
}
@@ -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<typeof model, string> | 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,
};
}
+56 -123
View File
@@ -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<typeof model, string> | 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,
};
}
+114
View File
@@ -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();
+11 -2
View File
@@ -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<ResolveManifestProviderAuthChoice>(() => ({
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({
+23 -16
View File
@@ -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<typeof import("../commands/auth-choice.js").prepareAuthChoice>
>;
@@ -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<string> | 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;
}
+4 -4
View File
@@ -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<ApplyAuthChoice>(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({