mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: Ollama missing from onboarding when its service is reachable (#118020)
This commit is contained in:
committed by
GitHub
parent
5cd6694349
commit
7f7a709b1d
@@ -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
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export {
|
||||
configureOllamaNonInteractive,
|
||||
ensureOllamaModelPulled,
|
||||
promptAndConfigureOllama,
|
||||
resolveOllamaSetupDefaultBaseUrl,
|
||||
} from "./src/setup.js";
|
||||
export {
|
||||
buildOllamaChatRequest,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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…" });
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user