fix: keep onboarding setup paths cold

This commit is contained in:
Shakker
2026-04-26 10:06:26 +01:00
parent 26b203e573
commit 2f81c5f580
7 changed files with 251 additions and 22 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd. - Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd.
- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { warnIfModelConfigLooksOff } from "./auth-choice.model-check.js";
import { makePrompter } from "./setup/__tests__/test-utils.js";
const loadModelCatalog = vi.hoisted(() => vi.fn());
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog,
}));
const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ version: 1, profiles: {} })));
const listProfilesForProvider = vi.hoisted(() => vi.fn(() => []));
vi.mock("../agents/auth-profiles.js", () => ({
ensureAuthProfileStore,
listProfilesForProvider,
}));
const resolveEnvApiKey = vi.hoisted(() => vi.fn(() => undefined));
const hasUsableCustomProviderApiKey = vi.hoisted(() => vi.fn(() => false));
vi.mock("../agents/model-auth.js", () => ({
resolveEnvApiKey,
hasUsableCustomProviderApiKey,
}));
describe("warnIfModelConfigLooksOff", () => {
beforeEach(() => {
vi.clearAllMocks();
loadModelCatalog.mockResolvedValue([]);
});
it("skips catalog validation when requested while keeping auth checks", async () => {
const note = vi.fn(async () => {});
const prompter = makePrompter({ note });
const config = {
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig;
await warnIfModelConfigLooksOff(config, prompter, { validateCatalog: false });
expect(loadModelCatalog).not.toHaveBeenCalled();
expect(ensureAuthProfileStore).toHaveBeenCalledOnce();
expect(listProfilesForProvider).toHaveBeenCalledWith(
expect.objectContaining({ profiles: {} }),
"openai-codex",
);
expect(note).toHaveBeenCalledWith(
expect.stringContaining('No auth configured for provider "openai-codex"'),
"Model check",
);
});
it("keeps full catalog validation enabled by default", async () => {
const note = vi.fn(async () => {});
const prompter = makePrompter({ note });
const config = {
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig;
await warnIfModelConfigLooksOff(config, prompter);
expect(loadModelCatalog).toHaveBeenCalledWith({
config,
useCache: false,
});
});
});

View File

@@ -9,25 +9,27 @@ import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js";
export async function warnIfModelConfigLooksOff( export async function warnIfModelConfigLooksOff(
config: OpenClawConfig, config: OpenClawConfig,
prompter: WizardPrompter, prompter: WizardPrompter,
options?: { agentId?: string; agentDir?: string }, options?: { agentId?: string; agentDir?: string; validateCatalog?: boolean },
) { ) {
const ref = resolveDefaultModelForAgent({ const ref = resolveDefaultModelForAgent({
cfg: config, cfg: config,
agentId: options?.agentId, agentId: options?.agentId,
}); });
const warnings: string[] = []; const warnings: string[] = [];
const catalog = await loadModelCatalog({ if (options?.validateCatalog !== false) {
config, const catalog = await loadModelCatalog({
useCache: false, config,
}); useCache: false,
if (catalog.length > 0) { });
const known = catalog.some( if (catalog.length > 0) {
(entry) => entry.provider === ref.provider && entry.id === ref.model, const known = catalog.some(
); (entry) => entry.provider === ref.provider && entry.id === ref.model,
if (!known) {
warnings.push(
`Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`,
); );
if (!known) {
warnings.push(
`Model not found: ${ref.provider}/${ref.model}. Update agents.defaults.model or run /models list.`,
);
}
} }
} }

View File

@@ -233,6 +233,87 @@ describe("promptDefaultModel", () => {
); );
}); });
it("keeps current preferred-provider models cold until browsing is requested", async () => {
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
const config = {
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig;
const result = await promptDefaultModel({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
preferredProvider: "openai-codex",
browseCatalogOnDemand: true,
});
expect(result).toEqual({});
expect(loadModelCatalog).not.toHaveBeenCalled();
expect(select.mock.calls[0]?.[0]).toMatchObject({
searchable: false,
initialValue: "__keep__",
});
expect(select.mock.calls[0]?.[0]?.options).toEqual([
expect.objectContaining({ value: "__keep__" }),
expect.objectContaining({ value: "__manual__" }),
expect.objectContaining({ value: "__browse__" }),
]);
});
it("loads the full model catalog when the user chooses to browse", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai-codex",
id: "gpt-5.5",
name: "GPT-5.5",
},
{
provider: "openai-codex",
id: "gpt-5.5-pro",
name: "GPT-5.5 Pro",
},
]);
const select = vi
.fn()
.mockResolvedValueOnce("__browse__")
.mockImplementationOnce(async (params) => {
const option = params.options.find(
(entry: { value: string }) => entry.value === "openai-codex/gpt-5.5-pro",
);
return option?.value ?? params.initialValue;
});
const prompter = makePrompter({ select });
const config = {
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig;
const result = await promptDefaultModel({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
preferredProvider: "openai-codex",
browseCatalogOnDemand: true,
});
expect(result.model).toBe("openai-codex/gpt-5.5-pro");
expect(loadModelCatalog).toHaveBeenCalledOnce();
expect(select).toHaveBeenCalledTimes(2);
expect(select.mock.calls[1]?.[0]?.searchable).toBe(true);
});
it("supports configuring vLLM during setup", async () => { it("supports configuring vLLM during setup", async () => {
loadModelCatalog.mockResolvedValue([ loadModelCatalog.mockResolvedValue([
{ {

View File

@@ -33,6 +33,7 @@ export { applyPrimaryModel } from "../plugins/provider-model-primary.js";
const KEEP_VALUE = "__keep__"; const KEEP_VALUE = "__keep__";
const MANUAL_VALUE = "__manual__"; const MANUAL_VALUE = "__manual__";
const BROWSE_VALUE = "__browse__";
const PROVIDER_FILTER_THRESHOLD = 30; const PROVIDER_FILTER_THRESHOLD = 30;
// Internal router models are valid defaults during auth/setup but not manual API targets. // Internal router models are valid defaults during auth/setup but not manual API targets.
@@ -46,6 +47,7 @@ export type PromptDefaultModelParams = {
includeProviderPluginSetups?: boolean; includeProviderPluginSetups?: boolean;
ignoreAllowlist?: boolean; ignoreAllowlist?: boolean;
loadCatalog?: boolean; loadCatalog?: boolean;
browseCatalogOnDemand?: boolean;
preferredProvider?: string; preferredProvider?: string;
agentDir?: string; agentDir?: string;
workspaceDir?: string; workspaceDir?: string;
@@ -508,20 +510,70 @@ export async function promptDefaultModel(
const includeManual = params.includeManual ?? true; const includeManual = params.includeManual ?? true;
const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false;
const loadCatalog = params.loadCatalog ?? true; const loadCatalog = params.loadCatalog ?? true;
const browseCatalogOnDemand = params.browseCatalogOnDemand ?? false;
const ignoreAllowlist = params.ignoreAllowlist ?? false; const ignoreAllowlist = params.ignoreAllowlist ?? false;
const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); const preferredProviderRaw = normalizeOptionalString(params.preferredProvider);
const preferredProvider = preferredProviderRaw const preferredProvider = preferredProviderRaw
? normalizeProviderId(preferredProviderRaw) ? normalizeProviderId(preferredProviderRaw)
: undefined; : undefined;
const configuredRaw = resolveConfiguredModelRaw(cfg); const configuredRaw = resolveConfiguredModelRaw(cfg);
const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand;
const resolved = resolveConfiguredModelRef({ const resolved = resolveConfiguredModelRef({
cfg, cfg,
defaultProvider: DEFAULT_PROVIDER, defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
allowPluginNormalization: useStaticModelNormalization ? false : undefined,
}); });
const resolvedKey = modelKey(resolved.provider, resolved.model); const resolvedKey = modelKey(resolved.provider, resolved.model);
const configuredKey = configuredRaw ? resolvedKey : ""; const configuredKey = configuredRaw ? resolvedKey : "";
if (
loadCatalog &&
browseCatalogOnDemand &&
preferredProvider &&
allowKeep &&
normalizeProviderId(resolved.provider) === preferredProvider
) {
const options: WizardSelectOption[] = [
{
value: KEEP_VALUE,
label: configuredRaw
? `Keep current (${configuredRaw})`
: `Keep current (default: ${resolvedKey})`,
hint:
configuredRaw && configuredRaw !== resolvedKey ? `resolves to ${resolvedKey}` : undefined,
},
];
if (includeManual) {
options.push({ value: MANUAL_VALUE, label: "Enter model manually" });
}
options.push({
value: BROWSE_VALUE,
label: "Browse all models",
hint: "loads provider catalogs",
});
const selection = await params.prompter.select({
message: params.message ?? "Default model",
options,
initialValue: KEEP_VALUE,
searchable: false,
});
if (selection === KEEP_VALUE) {
return {};
}
if (selection === MANUAL_VALUE) {
return promptManualModel({
prompter: params.prompter,
allowBlank: false,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
if (selection !== BROWSE_VALUE) {
return { model: selection };
}
}
if (!loadCatalog) { if (!loadCatalog) {
const options: WizardSelectOption[] = []; const options: WizardSelectOption[] = [];
if (allowKeep) { if (allowKeep) {

View File

@@ -118,13 +118,18 @@ const readConfigFileSnapshot = vi.hoisted(() =>
legacyIssues: [] as Array<{ path: string; message: string }>, legacyIssues: [] as Array<{ path: string; message: string }>,
})), })),
); );
const createConfigIO = vi.hoisted(() =>
vi.fn(() => ({
readConfigFileSnapshot,
})),
);
const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {}));
const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true));
const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {}));
const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {}));
const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
const buildPluginCompatibilityNotices = vi.hoisted(() => const buildPluginCompatibilitySnapshotNotices = vi.hoisted(() =>
vi.fn((): PluginCompatibilityNotice[] => []), vi.fn((): PluginCompatibilityNotice[] => []),
); );
const formatPluginCompatibilityNotice = vi.hoisted(() => const formatPluginCompatibilityNotice = vi.hoisted(() =>
@@ -185,8 +190,8 @@ vi.mock("../commands/onboard-hooks.js", () => ({
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
DEFAULT_GATEWAY_PORT: 18789, DEFAULT_GATEWAY_PORT: 18789,
createConfigIO,
resolveGatewayPort, resolveGatewayPort,
readConfigFileSnapshot,
writeConfigFile, writeConfigFile,
})); }));
@@ -228,7 +233,7 @@ vi.mock("../infra/control-ui-assets.js", () => ({
})); }));
vi.mock("../plugins/status.js", () => ({ vi.mock("../plugins/status.js", () => ({
buildPluginCompatibilityNotices, buildPluginCompatibilitySnapshotNotices,
formatPluginCompatibilityNotice, formatPluginCompatibilityNotice,
})); }));
@@ -405,6 +410,7 @@ describe("runSetupWizard", () => {
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
const prompter = buildWizardPrompter({ select, multiselect }); const prompter = buildWizardPrompter({ select, multiselect });
const runtime = createRuntime({ throwsOnExit: true }); const runtime = createRuntime({ throwsOnExit: true });
createConfigIO.mockClear();
ensureAuthProfileStore.mockClear(); ensureAuthProfileStore.mockClear();
await runSetupWizard( await runSetupWizard(
@@ -423,6 +429,7 @@ describe("runSetupWizard", () => {
prompter, prompter,
); );
expect(createConfigIO).toHaveBeenCalledWith({ pluginValidation: "skip" });
expect(select).not.toHaveBeenCalled(); expect(select).not.toHaveBeenCalled();
expect(ensureAuthProfileStore).not.toHaveBeenCalled(); expect(ensureAuthProfileStore).not.toHaveBeenCalled();
expect(setupChannels).not.toHaveBeenCalled(); expect(setupChannels).not.toHaveBeenCalled();
@@ -623,6 +630,7 @@ describe("runSetupWizard", () => {
it("prompts for a model during explicit interactive Ollama setup", async () => { it("prompts for a model during explicit interactive Ollama setup", async () => {
promptDefaultModel.mockClear(); promptDefaultModel.mockClear();
warnIfModelConfigLooksOff.mockClear();
resolveProviderPluginChoice.mockReturnValue({ resolveProviderPluginChoice.mockReturnValue({
provider: { provider: {
id: "ollama", id: "ollama",
@@ -671,8 +679,14 @@ describe("runSetupWizard", () => {
expect(promptDefaultModel).toHaveBeenCalledWith( expect(promptDefaultModel).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
allowKeep: false, allowKeep: false,
browseCatalogOnDemand: true,
}), }),
); );
expect(warnIfModelConfigLooksOff).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ validateCatalog: false }),
);
}); });
it("re-prompts for auth when applyAuthChoice requests retry selection", async () => { it("re-prompts for auth when applyAuthChoice requests retry selection", async () => {
@@ -744,7 +758,7 @@ describe("runSetupWizard", () => {
}); });
it("shows plugin compatibility notices for an existing valid config", async () => { it("shows plugin compatibility notices for an existing valid config", async () => {
buildPluginCompatibilityNotices.mockReturnValue([ buildPluginCompatibilitySnapshotNotices.mockReturnValue([
{ {
pluginId: "legacy-plugin", pluginId: "legacy-plugin",
code: "legacy-before-agent-start", code: "legacy-before-agent-start",

View File

@@ -7,12 +7,12 @@ import type {
OnboardOptions, OnboardOptions,
ResetScope, ResetScope,
} from "../commands/onboard-types.js"; } from "../commands/onboard-types.js";
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; import { createConfigIO, resolveGatewayPort, writeConfigFile } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeSecretInputString } from "../config/types.secrets.js"; import { normalizeSecretInputString } from "../config/types.secrets.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { import {
buildPluginCompatibilityNotices, buildPluginCompatibilitySnapshotNotices,
formatPluginCompatibilityNotice, formatPluginCompatibilityNotice,
} from "../plugins/status.js"; } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -61,6 +61,10 @@ async function writeWizardConfigFile(config: OpenClawConfig): Promise<OpenClawCo
return committed.config; return committed.config;
} }
async function readSetupConfigFileSnapshot() {
return await createConfigIO({ pluginValidation: "skip" }).readConfigFileSnapshot();
}
async function resolveAuthChoiceModelSelectionPolicy(params: { async function resolveAuthChoiceModelSelectionPolicy(params: {
authChoice: string; authChoice: string;
config: OpenClawConfig; config: OpenClawConfig;
@@ -146,7 +150,7 @@ export async function runSetupWizard(
await prompter.intro("OpenClaw setup"); await prompter.intro("OpenClaw setup");
await requireRiskAcknowledgement({ opts, prompter }); await requireRiskAcknowledgement({ opts, prompter });
const snapshot = await readConfigFileSnapshot(); const snapshot = await readSetupConfigFileSnapshot();
let baseConfig: OpenClawConfig = snapshot.valid let baseConfig: OpenClawConfig = snapshot.valid
? snapshot.exists ? snapshot.exists
? (snapshot.sourceConfig ?? snapshot.config) ? (snapshot.sourceConfig ?? snapshot.config)
@@ -173,7 +177,7 @@ export async function runSetupWizard(
} }
const compatibilityNotices = snapshot.valid const compatibilityNotices = snapshot.valid
? buildPluginCompatibilityNotices({ config: baseConfig }) ? buildPluginCompatibilitySnapshotNotices({ config: baseConfig })
: []; : [];
if (compatibilityNotices.length > 0) { if (compatibilityNotices.length > 0) {
await prompter.note( await prompter.note(
@@ -570,7 +574,7 @@ export async function runSetupWizard(
} }
const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule();
await warnIfModelConfigLooksOff(nextConfig, prompter); await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false });
} }
break; break;
} }
@@ -617,6 +621,7 @@ export async function runSetupWizard(
ignoreAllowlist: true, ignoreAllowlist: true,
includeProviderPluginSetups: true, includeProviderPluginSetups: true,
preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider,
browseCatalogOnDemand: true,
workspaceDir, workspaceDir,
runtime, runtime,
}); });
@@ -628,7 +633,7 @@ export async function runSetupWizard(
} }
} }
await warnIfModelConfigLooksOff(nextConfig, prompter); await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false });
break; break;
} }