fix: keep onboarding model prompts scoped

This commit is contained in:
Shakker
2026-04-26 09:44:12 +01:00
parent c74fb78194
commit 26b203e573
7 changed files with 346 additions and 17 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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

@@ -133,7 +133,8 @@ export async function promptAuthConfig(
prompter,
allowKeep: true,
ignoreAllowlist: true,
includeProviderPluginSetups: true,
includeProviderPluginSetups: false,
loadCatalog: false,
preferredProvider,
workspaceDir: resolveDefaultAgentWorkspaceDir(),
runtime,

View File

@@ -360,6 +360,40 @@ describe("promptDefaultModel", () => {
expect.arrayContaining([expect.objectContaining({ value: "legacy-entry" })]),
);
});
it("keeps skip-auth model selection cold when catalog loading is disabled", async () => {
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
const config = {
agents: {
defaults: {
model: "openai/gpt-5.5",
},
},
} as OpenClawConfig;
const result = await promptDefaultModel({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
includeProviderPluginSetups: true,
loadCatalog: false,
agentDir: "/tmp/openclaw-agent",
runtime: {} as never,
});
expect(result).toEqual({});
expect(loadModelCatalog).not.toHaveBeenCalled();
expect(resolveProviderModelPickerEntries).not.toHaveBeenCalled();
expect(providerModelPickerContributionRuntime.resolve).not.toHaveBeenCalled();
expect(select.mock.calls[0]?.[0]?.options).toEqual([
expect.objectContaining({ value: "__keep__" }),
expect.objectContaining({ value: "__manual__" }),
expect.objectContaining({ value: "openai/gpt-5.5" }),
]);
});
});
describe("promptModelAllowlist", () => {
@@ -607,6 +641,63 @@ describe("promptModelAllowlist", () => {
scopeKeys: ["openai/gpt-5.5", "openai/gpt-5.4"],
});
});
it("uses configured provider-scoped seeds without loading the full catalog", async () => {
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
const prompter = makePrompter({ multiselect });
const config = {
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig;
const result = await promptModelAllowlist({
config,
prompter,
preferredProvider: "openai-codex",
});
expect(loadModelCatalog).not.toHaveBeenCalled();
expect(multiselect.mock.calls[0]?.[0]?.options).toEqual([
expect.objectContaining({ value: "openai-codex/gpt-5.5" }),
]);
expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]);
expect(result).toEqual({
models: ["openai-codex/gpt-5.5"],
scopeKeys: ["openai-codex/gpt-5.5"],
});
});
it("uses explicit allowed model keys without loading the full catalog", async () => {
const multiselect = createSelectAllMultiselect();
const prompter = makePrompter({ multiselect });
const config = {
agents: {
defaults: {
model: "openai-codex/gpt-5.5",
},
},
} as OpenClawConfig;
const result = await promptModelAllowlist({
config,
prompter,
allowedKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"],
preferredProvider: "openai-codex",
});
expect(loadModelCatalog).not.toHaveBeenCalled();
expect(
multiselect.mock.calls[0]?.[0]?.options.map((option: { value: string }) => option.value),
).toEqual(["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"]);
expect(multiselect.mock.calls[0]?.[0]?.initialValues).toEqual(["openai-codex/gpt-5.5"]);
expect(result).toEqual({
models: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"],
scopeKeys: ["openai-codex/gpt-5.5", "openai-codex/gpt-5.4"],
});
});
});
describe("runtime model picker visibility", () => {

View File

@@ -45,6 +45,7 @@ export type PromptDefaultModelParams = {
includeManual?: boolean;
includeProviderPluginSetups?: boolean;
ignoreAllowlist?: boolean;
loadCatalog?: boolean;
preferredProvider?: string;
agentDir?: string;
workspaceDir?: string;
@@ -229,6 +230,45 @@ function addModelSelectOption(params: {
params.seen.add(key);
}
function splitModelKey(key: string): { provider: string; id: string } | undefined {
const slashIndex = key.indexOf("/");
if (slashIndex <= 0 || slashIndex >= key.length - 1) {
return undefined;
}
return {
provider: key.slice(0, slashIndex),
id: key.slice(slashIndex + 1),
};
}
function addModelKeySelectOption(params: {
key: string;
options: WizardSelectOption[];
seen: Set<string>;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
hasAuth: (provider: string) => boolean;
fallbackHint: string;
}) {
const entry = splitModelKey(params.key);
if (!entry) {
return;
}
const before = params.seen.size;
addModelSelectOption({
entry,
options: params.options,
seen: params.seen,
aliasIndex: params.aliasIndex,
hasAuth: params.hasAuth,
});
if (params.seen.size > before) {
const option = params.options.at(-1);
if (option && !option.hint) {
option.hint = params.fallbackHint;
}
}
}
function createPreferredProviderMatcher(params: {
preferredProvider: string;
cfg: OpenClawConfig;
@@ -467,6 +507,7 @@ export async function promptDefaultModel(
const allowKeep = params.allowKeep ?? true;
const includeManual = params.includeManual ?? true;
const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false;
const loadCatalog = params.loadCatalog ?? true;
const ignoreAllowlist = params.ignoreAllowlist ?? false;
const preferredProviderRaw = normalizeOptionalString(params.preferredProvider);
const preferredProvider = preferredProviderRaw
@@ -481,10 +522,58 @@ export async function promptDefaultModel(
const resolvedKey = modelKey(resolved.provider, resolved.model);
const configuredKey = configuredRaw ? resolvedKey : "";
if (!loadCatalog) {
const options: WizardSelectOption[] = [];
if (allowKeep) {
options.push({
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" });
}
if (configuredKey && !options.some((option) => option.value === configuredKey)) {
options.push({
value: configuredKey,
label: configuredKey,
hint: "current",
});
}
if (options.length === 0) {
return promptManualModel({
prompter: params.prompter,
allowBlank: allowKeep,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
const selection = await params.prompter.select({
message: params.message ?? "Default model",
options,
initialValue: allowKeep ? KEEP_VALUE : configuredKey || MANUAL_VALUE,
searchable: false,
});
if (selection === KEEP_VALUE) {
return {};
}
if (selection === MANUAL_VALUE) {
return promptManualModel({
prompter: params.prompter,
allowBlank: false,
initialValue: configuredRaw || resolvedKey || undefined,
});
}
return { model: selection };
}
const catalogProgress = params.prompter.progress("Loading available models");
let catalog: Awaited<ReturnType<typeof loadModelCatalog>>;
try {
catalog = await loadModelCatalog({ config: cfg, useCache: false });
catalog = await loadModelCatalog({ config: cfg });
} finally {
catalogProgress.stop();
}
@@ -650,6 +739,7 @@ export async function promptModelAllowlist(params: {
}): Promise<PromptModelAllowlistResult> {
const cfg = params.config;
const existingKeys = resolveConfiguredModelKeys(cfg);
const configuredRaw = resolveConfiguredModelRaw(cfg);
const allowedKeys = normalizeModelKeys(params.allowedKeys ?? []);
const allowedKeySet = allowedKeys.length > 0 ? new Set(allowedKeys) : null;
const preferredProviderRaw = normalizeOptionalString(params.preferredProvider);
@@ -685,11 +775,71 @@ export async function promptModelAllowlist(params: {
...fallbackKeys,
...(params.initialSelections ?? []),
]);
const hasRealSeed =
existingKeys.length > 0 ||
fallbackKeys.length > 0 ||
(params.initialSelections?.length ?? 0) > 0 ||
configuredRaw.length > 0;
const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir });
const matchesPreferredProvider = preferredProvider
? createPreferredProviderMatcher({
preferredProvider,
cfg,
})
: undefined;
const scopedFastKeys =
allowedKeys.length > 0
? allowedKeys
: preferredProvider && hasRealSeed
? initialSeeds.filter((key) => {
const entry = splitModelKey(key);
return entry ? matchesPreferredProvider?.(entry.provider) === true : false;
})
: [];
if (scopedFastKeys.length > 0) {
const scopeKeys = allowedKeys.length > 0 ? allowedKeys : scopedFastKeys;
const scopeKeySet = new Set(scopeKeys);
const initialKeys = normalizeModelKeys(initialSeeds.filter((key) => scopeKeySet.has(key)));
const options: WizardSelectOption[] = [];
const seen = new Set<string>();
for (const key of scopeKeys) {
addModelKeySelectOption({
key,
options,
seen,
aliasIndex,
hasAuth,
fallbackHint: allowedKeys.length > 0 ? "allowed" : "configured",
});
}
if (options.length === 0) {
return {};
}
const selection = await params.prompter.multiselect({
message: params.message ?? "Models in /model picker (multi-select)",
options,
initialValues: initialKeys.length > 0 ? initialKeys : undefined,
searchable: true,
});
const selected = normalizeModelKeys(selection);
if (selected.length > 0) {
return { models: selected, scopeKeys };
}
const confirmScopedClear = await params.prompter.confirm({
message: "Remove these provider models from the /model picker?",
initialValue: false,
});
if (!confirmScopedClear) {
return {};
}
return { models: [], scopeKeys };
}
const allowlistProgress = params.prompter.progress("Loading available models");
let catalog: Awaited<ReturnType<typeof loadModelCatalog>>;
try {
catalog = await loadModelCatalog({ config: cfg, useCache: false });
catalog = await loadModelCatalog({ config: cfg });
} finally {
allowlistProgress.stop();
}
@@ -713,14 +863,6 @@ export async function promptModelAllowlist(params: {
return { models: normalizeModelKeys(parsed) };
}
const hasAuth = createProviderAuthChecker({ cfg, agentDir: params.agentDir });
const matchesPreferredProvider = preferredProvider
? createPreferredProviderMatcher({
preferredProvider,
cfg,
})
: undefined;
const options: WizardSelectOption[] = [];
const seen = new Set<string>();
const allowedCatalog = (

View File

@@ -1766,6 +1766,8 @@ describe("provider-runtime", () => {
cache: false,
}),
);
expect(resolveCatalogHookProviderPluginIdsMock).toHaveBeenCalledTimes(1);
expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1);
});
it("does not stack-overflow when provider hook resolution reenters the same plugin load", () => {

View File

@@ -14,9 +14,8 @@ import { sanitizeForLog } from "../terminal/ansi.js";
import { resolvePluginDiscoveryProvidersRuntime } from "./provider-discovery.runtime.js";
import {
__testing as providerHookRuntimeTesting,
clearProviderRuntimeHookCache,
clearProviderRuntimeHookCache as clearProviderHookRuntimeCache,
prepareProviderExtraParams,
resetProviderRuntimeHookCacheForTest,
resolveProviderAuthProfileId,
resolveProviderExtraParamsForTransport,
resolveProviderFollowupFallbackRoute,
@@ -34,6 +33,7 @@ import {
resolveExternalAuthProfileProviderPluginIds,
resolveOwningPluginIdsForProvider,
} from "./providers.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js";
import type {
@@ -86,6 +86,14 @@ import type {
const log = createSubsystemLogger("plugins/provider-runtime");
const warnedExternalAuthFallbackPluginIds = new Set<string>();
let catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
let catalogHookProviderIdCacheWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map<string, string[]>
>();
let catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
@@ -132,13 +140,95 @@ function resetCatalogHookProvidersCacheForTest(): void {
catalogHookProvidersCache = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
}
function clearCatalogHookProviderIdCache(): void {
catalogHookProviderIdCacheWithoutConfig = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>
>();
}
function resolveCatalogHookProviderIdCacheBucket(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Map<string, string[]> {
if (!params.config) {
let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket);
}
return bucket;
}
let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config);
if (!envBuckets) {
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, string[]>>();
catalogHookProviderIdCacheByConfig.set(params.config, envBuckets);
}
let bucket = envBuckets.get(params.env);
if (!bucket) {
bucket = new Map<string, string[]>();
envBuckets.set(params.env, bucket);
}
return bucket;
}
function buildCatalogHookProviderIdCacheKey(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string {
const { roots } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
env: params.env,
});
return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}`;
}
function resolveCachedCatalogHookProviderPluginIds(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string[] {
const env = params.env ?? process.env;
const bucket = resolveCatalogHookProviderIdCacheBucket({
config: params.config,
env,
});
const key = buildCatalogHookProviderIdCacheKey({
config: params.config,
workspaceDir: params.workspaceDir,
env,
});
const cached = bucket.get(key);
if (cached) {
return cached;
}
const resolved = resolveCatalogHookProviderPluginIds({
config: params.config,
workspaceDir: params.workspaceDir,
env,
});
bucket.set(key, resolved);
return resolved;
}
export function clearProviderRuntimeHookCache(): void {
resetCatalogHookProvidersCacheForTest();
clearCatalogHookProviderIdCache();
clearProviderHookRuntimeCache();
}
export function resetProviderRuntimeHookCacheForTest(): void {
clearProviderRuntimeHookCache();
}
export {
clearProviderRuntimeHookCache,
prepareProviderExtraParams,
resolveProviderAuthProfileId,
resolveProviderExtraParamsForTransport,
resolveProviderFollowupFallbackRoute,
resetProviderRuntimeHookCacheForTest,
resolveProviderRuntimePlugin,
wrapProviderStreamFn,
};
@@ -147,6 +237,7 @@ export const __testing = {
...providerHookRuntimeTesting,
resetExternalAuthFallbackWarningCacheForTest,
resetCatalogHookProvidersCacheForTest,
resetProviderRuntimeHookCacheForTest,
} as const;
function resolveProviderPluginsForCatalogHooks(params: {
@@ -169,7 +260,7 @@ function resolveProviderPluginsForCatalogHooks(params: {
if (cached) {
return cached;
}
const onlyPluginIds = resolveCatalogHookProviderPluginIds({
const onlyPluginIds = resolveCachedCatalogHookProviderPluginIds({
config: params.config,
workspaceDir,
env,

View File

@@ -557,7 +557,8 @@ export async function runSetupWizard(
prompter,
allowKeep: true,
ignoreAllowlist: true,
includeProviderPluginSetups: true,
includeProviderPluginSetups: false,
loadCatalog: false,
workspaceDir,
runtime,
});