From 26b203e573e395dca519d4fda821eed1c85a89c1 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 09:44:12 +0100 Subject: [PATCH 01/19] fix: keep onboarding model prompts scoped --- CHANGELOG.md | 1 + src/commands/configure.gateway-auth.ts | 3 +- src/commands/model-picker.test.ts | 91 ++++++++++++++ src/flows/model-picker.ts | 162 +++++++++++++++++++++++-- src/plugins/provider-runtime.test.ts | 2 + src/plugins/provider-runtime.ts | 101 ++++++++++++++- src/wizard/setup.ts | 3 +- 7 files changed, 346 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094bc06d8b5..f28860de015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index a507356f80a..ff0f5407f14 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -133,7 +133,8 @@ export async function promptAuthConfig( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, preferredProvider, workspaceDir: resolveDefaultAgentWorkspaceDir(), runtime, diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 93f63f95a16..70407fa1d92 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -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", () => { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index bc3e7d7274e..28885a97186 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -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; + aliasIndex: ReturnType; + 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>; 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 { 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(); + 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>; 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(); const allowedCatalog = ( diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 9f996af4c54..86d2db022ea 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -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", () => { diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 87180cf72c3..4c1ed4b5d4e 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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(); let catalogHookProvidersCache = new WeakMap>(); +let catalogHookProviderIdCacheWithoutConfig = new WeakMap< + NodeJS.ProcessEnv, + Map +>(); +let catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> +>(); function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); @@ -132,13 +140,95 @@ function resetCatalogHookProvidersCacheForTest(): void { catalogHookProvidersCache = new WeakMap>(); } +function clearCatalogHookProviderIdCache(): void { + catalogHookProviderIdCacheWithoutConfig = new WeakMap>(); + catalogHookProviderIdCacheByConfig = new WeakMap< + OpenClawConfig, + WeakMap> + >(); +} + +function resolveCatalogHookProviderIdCacheBucket(params: { + config?: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): Map { + if (!params.config) { + let bucket = catalogHookProviderIdCacheWithoutConfig.get(params.env); + if (!bucket) { + bucket = new Map(); + catalogHookProviderIdCacheWithoutConfig.set(params.env, bucket); + } + return bucket; + } + + let envBuckets = catalogHookProviderIdCacheByConfig.get(params.config); + if (!envBuckets) { + envBuckets = new WeakMap>(); + catalogHookProviderIdCacheByConfig.set(params.config, envBuckets); + } + let bucket = envBuckets.get(params.env); + if (!bucket) { + bucket = new Map(); + 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, diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 8058a539de9..fb280ae3c45 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -557,7 +557,8 @@ export async function runSetupWizard( prompter, allowKeep: true, ignoreAllowlist: true, - includeProviderPluginSetups: true, + includeProviderPluginSetups: false, + loadCatalog: false, workspaceDir, runtime, }); From 2f81c5f5803db19cadbf56be7659a0712ea985ce Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 10:06:26 +0100 Subject: [PATCH 02/19] fix: keep onboarding setup paths cold --- CHANGELOG.md | 1 + src/commands/auth-choice.model-check.test.ts | 74 ++++++++++++++++++ src/commands/auth-choice.model-check.ts | 26 ++++--- src/commands/model-picker.test.ts | 81 ++++++++++++++++++++ src/flows/model-picker.ts | 52 +++++++++++++ src/wizard/setup.test.ts | 22 +++++- src/wizard/setup.ts | 17 ++-- 7 files changed, 251 insertions(+), 22 deletions(-) create mode 100644 src/commands/auth-choice.model-check.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f28860de015..de5fa94a078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### 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. - 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. diff --git a/src/commands/auth-choice.model-check.test.ts b/src/commands/auth-choice.model-check.test.ts new file mode 100644 index 00000000000..b6e61286b98 --- /dev/null +++ b/src/commands/auth-choice.model-check.test.ts @@ -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, + }); + }); +}); diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index 0cced85226f..8624f3547af 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -9,25 +9,27 @@ import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js"; export async function warnIfModelConfigLooksOff( config: OpenClawConfig, prompter: WizardPrompter, - options?: { agentId?: string; agentDir?: string }, + options?: { agentId?: string; agentDir?: string; validateCatalog?: boolean }, ) { const ref = resolveDefaultModelForAgent({ cfg: config, agentId: options?.agentId, }); const warnings: string[] = []; - const catalog = await loadModelCatalog({ - config, - useCache: false, - }); - if (catalog.length > 0) { - 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 (options?.validateCatalog !== false) { + const catalog = await loadModelCatalog({ + config, + useCache: false, + }); + if (catalog.length > 0) { + 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.`, + ); + } } } diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 70407fa1d92..c6e7da673bf 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -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 () => { loadModelCatalog.mockResolvedValue([ { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 28885a97186..6adfed8c619 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -33,6 +33,7 @@ export { applyPrimaryModel } from "../plugins/provider-model-primary.js"; const KEEP_VALUE = "__keep__"; const MANUAL_VALUE = "__manual__"; +const BROWSE_VALUE = "__browse__"; const PROVIDER_FILTER_THRESHOLD = 30; // Internal router models are valid defaults during auth/setup but not manual API targets. @@ -46,6 +47,7 @@ export type PromptDefaultModelParams = { includeProviderPluginSetups?: boolean; ignoreAllowlist?: boolean; loadCatalog?: boolean; + browseCatalogOnDemand?: boolean; preferredProvider?: string; agentDir?: string; workspaceDir?: string; @@ -508,20 +510,70 @@ export async function promptDefaultModel( const includeManual = params.includeManual ?? true; const includeProviderPluginSetups = params.includeProviderPluginSetups ?? false; const loadCatalog = params.loadCatalog ?? true; + const browseCatalogOnDemand = params.browseCatalogOnDemand ?? false; const ignoreAllowlist = params.ignoreAllowlist ?? false; const preferredProviderRaw = normalizeOptionalString(params.preferredProvider); const preferredProvider = preferredProviderRaw ? normalizeProviderId(preferredProviderRaw) : undefined; const configuredRaw = resolveConfiguredModelRaw(cfg); + const useStaticModelNormalization = !loadCatalog || browseCatalogOnDemand; const resolved = resolveConfiguredModelRef({ cfg, defaultProvider: DEFAULT_PROVIDER, defaultModel: DEFAULT_MODEL, + allowPluginNormalization: useStaticModelNormalization ? false : undefined, }); const resolvedKey = modelKey(resolved.provider, resolved.model); 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) { const options: WizardSelectOption[] = []; if (allowKeep) { diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 088aedee456..8a178912854 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -118,13 +118,18 @@ const readConfigFileSnapshot = vi.hoisted(() => legacyIssues: [] as Array<{ path: string; message: string }>, })), ); +const createConfigIO = vi.hoisted(() => + vi.fn(() => ({ + readConfigFileSnapshot, + })), +); const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); const isSystemdUserServiceAvailable = vi.hoisted(() => vi.fn(async () => true)); const ensureControlUiAssetsBuilt = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); const runTui = vi.hoisted(() => vi.fn(async (_options: unknown) => {})); const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {})); const probeGatewayReachable = vi.hoisted(() => vi.fn(async () => ({ ok: true }))); -const buildPluginCompatibilityNotices = vi.hoisted(() => +const buildPluginCompatibilitySnapshotNotices = vi.hoisted(() => vi.fn((): PluginCompatibilityNotice[] => []), ); const formatPluginCompatibilityNotice = vi.hoisted(() => @@ -185,8 +190,8 @@ vi.mock("../commands/onboard-hooks.js", () => ({ vi.mock("../config/config.js", () => ({ DEFAULT_GATEWAY_PORT: 18789, + createConfigIO, resolveGatewayPort, - readConfigFileSnapshot, writeConfigFile, })); @@ -228,7 +233,7 @@ vi.mock("../infra/control-ui-assets.js", () => ({ })); vi.mock("../plugins/status.js", () => ({ - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, })); @@ -405,6 +410,7 @@ describe("runSetupWizard", () => { const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); const prompter = buildWizardPrompter({ select, multiselect }); const runtime = createRuntime({ throwsOnExit: true }); + createConfigIO.mockClear(); ensureAuthProfileStore.mockClear(); await runSetupWizard( @@ -423,6 +429,7 @@ describe("runSetupWizard", () => { prompter, ); + expect(createConfigIO).toHaveBeenCalledWith({ pluginValidation: "skip" }); expect(select).not.toHaveBeenCalled(); expect(ensureAuthProfileStore).not.toHaveBeenCalled(); expect(setupChannels).not.toHaveBeenCalled(); @@ -623,6 +630,7 @@ describe("runSetupWizard", () => { it("prompts for a model during explicit interactive Ollama setup", async () => { promptDefaultModel.mockClear(); + warnIfModelConfigLooksOff.mockClear(); resolveProviderPluginChoice.mockReturnValue({ provider: { id: "ollama", @@ -671,8 +679,14 @@ describe("runSetupWizard", () => { expect(promptDefaultModel).toHaveBeenCalledWith( expect.objectContaining({ 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 () => { @@ -744,7 +758,7 @@ describe("runSetupWizard", () => { }); it("shows plugin compatibility notices for an existing valid config", async () => { - buildPluginCompatibilityNotices.mockReturnValue([ + buildPluginCompatibilitySnapshotNotices.mockReturnValue([ { pluginId: "legacy-plugin", code: "legacy-before-agent-start", diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index fb280ae3c45..624600b7717 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -7,12 +7,12 @@ import type { OnboardOptions, ResetScope, } 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 { normalizeSecretInputString } from "../config/types.secrets.js"; import { formatErrorMessage } from "../infra/errors.js"; import { - buildPluginCompatibilityNotices, + buildPluginCompatibilitySnapshotNotices, formatPluginCompatibilityNotice, } from "../plugins/status.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -61,6 +61,10 @@ async function writeWizardConfigFile(config: OpenClawConfig): Promise 0) { await prompter.note( @@ -570,7 +574,7 @@ export async function runSetupWizard( } const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); } break; } @@ -617,6 +621,7 @@ export async function runSetupWizard( ignoreAllowlist: true, includeProviderPluginSetups: true, preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, + browseCatalogOnDemand: true, workspaceDir, runtime, }); @@ -628,7 +633,7 @@ export async function runSetupWizard( } } - await warnIfModelConfigLooksOff(nextConfig, prompter); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); break; } From 3fffa781644843f90b71fddeb9b2f6550b7bca5b Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 10:42:10 +0100 Subject: [PATCH 03/19] fix: scope provider auth runtime loading --- src/plugins/provider-auth-choice.ts | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 36d554f8eb9..75dcde3b831 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -17,6 +17,10 @@ import { pickAuthMethod, resolveProviderMatch, } from "./provider-auth-choice-helpers.js"; +import { + resolveManifestProviderAuthChoice, + type ProviderAuthChoiceMetadata, +} from "./provider-auth-choices.js"; import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; @@ -154,6 +158,20 @@ async function loadPluginProviderRuntime() { return await providerAuthChoiceDeps.loadPluginProviderRuntime(); } +function resolveManifestAuthChoiceScope(params: { + authChoice: string; + config: OpenClawConfig; + workspaceDir: string; + env?: NodeJS.ProcessEnv; +}): ProviderAuthChoiceMetadata | undefined { + return resolveManifestProviderAuthChoice(params.authChoice, { + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeUntrustedWorkspacePlugins: false, + }); +} + export const __testing = { resetDepsForTest(): void { providerAuthChoiceDeps = defaultProviderAuthChoiceDeps; @@ -258,6 +276,12 @@ export async function applyAuthChoiceLoadedPluginProvider( let enabledConfig = params.config; const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = await loadPluginProviderRuntime(); + const manifestAuthChoice = resolveManifestAuthChoiceScope({ + authChoice: params.authChoice, + config: nextConfig, + workspaceDir, + env: params.env, + }); const installCatalogEntry = resolveProviderInstallCatalogEntry(params.authChoice, { config: nextConfig, workspaceDir, @@ -282,6 +306,12 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, env: params.env, mode: "setup", + ...(manifestAuthChoice + ? { + onlyPluginIds: [manifestAuthChoice.pluginId], + providerRefs: [manifestAuthChoice.providerId], + } + : {}), }); let resolved = resolveProviderPluginChoice({ providers, @@ -313,6 +343,12 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, env: params.env, mode: "setup", + ...(manifestAuthChoice + ? { + onlyPluginIds: [manifestAuthChoice.pluginId], + providerRefs: [manifestAuthChoice.providerId], + } + : {}), }); resolved = resolveProviderPluginChoice({ providers, From 44183de706c883d270eaf6b0bd2f3dd2b7bdde3e Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:08 +0100 Subject: [PATCH 04/19] fix: use setup providers for auth choices --- src/plugins/provider-auth-choice.runtime.ts | 8 +++ src/plugins/provider-auth-choice.ts | 72 +++++++++++++-------- src/plugins/provider-wizard.ts | 12 +++- src/plugins/setup-registry.ts | 17 +++++ 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/plugins/provider-auth-choice.runtime.ts b/src/plugins/provider-auth-choice.runtime.ts index 3660ac189ae..1faf872efbe 100644 --- a/src/plugins/provider-auth-choice.runtime.ts +++ b/src/plugins/provider-auth-choice.runtime.ts @@ -3,12 +3,14 @@ import { runProviderModelSelectedHook as runProviderModelSelectedHookImpl, } from "./provider-wizard.js"; import { resolvePluginProviders as resolvePluginProvidersImpl } from "./providers.runtime.js"; +import { resolvePluginSetupProvider as resolvePluginSetupProviderImpl } from "./setup-registry.js"; type ResolveProviderPluginChoice = typeof import("./provider-wizard.js").resolveProviderPluginChoice; type RunProviderModelSelectedHook = typeof import("./provider-wizard.js").runProviderModelSelectedHook; type ResolvePluginProviders = typeof import("./providers.runtime.js").resolvePluginProviders; +type ResolvePluginSetupProvider = typeof import("./setup-registry.js").resolvePluginSetupProvider; export function resolveProviderPluginChoice( ...args: Parameters @@ -27,3 +29,9 @@ export function resolvePluginProviders( ): ReturnType { return resolvePluginProvidersImpl(...args); } + +export function resolvePluginSetupProvider( + ...args: Parameters +): ReturnType { + return resolvePluginSetupProviderImpl(...args); +} diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 75dcde3b831..30d3e7cbb8e 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -25,7 +25,7 @@ import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; import { isRemoteEnvironment, openUrl } from "./setup-browser.js"; -import type { ProviderAuthMethod, ProviderAuthOptionBag } from "./types.js"; +import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js"; export type ApplyProviderAuthChoiceParams = { authChoice: string; @@ -172,6 +172,10 @@ function resolveManifestAuthChoiceScope(params: { }); } +function withProviderPluginId(provider: ProviderPlugin, pluginId: string): ProviderPlugin { + return provider.pluginId === pluginId ? provider : { ...provider, pluginId }; +} + export const __testing = { resetDepsForTest(): void { providerAuthChoiceDeps = defaultProviderAuthChoiceDeps; @@ -274,8 +278,12 @@ export async function applyAuthChoiceLoadedPluginProvider( resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); let nextConfig = params.config; let enabledConfig = params.config; - const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = - await loadPluginProviderRuntime(); + const { + resolvePluginProviders, + resolvePluginSetupProvider, + resolveProviderPluginChoice, + runProviderModelSelectedHook, + } = await loadPluginProviderRuntime(); const manifestAuthChoice = resolveManifestAuthChoiceScope({ authChoice: params.authChoice, config: nextConfig, @@ -301,22 +309,43 @@ export async function applyAuthChoiceLoadedPluginProvider( enabledConfig = enableResult.config; } - let providers = resolvePluginProviders({ - config: enabledConfig, - workspaceDir, - env: params.env, - mode: "setup", - ...(manifestAuthChoice - ? { - onlyPluginIds: [manifestAuthChoice.pluginId], - providerRefs: [manifestAuthChoice.providerId], - } - : {}), - }); + const resolveScopedRuntimeProviders = (config: OpenClawConfig): ProviderPlugin[] => + resolvePluginProviders({ + config, + workspaceDir, + env: params.env, + mode: "setup", + ...(manifestAuthChoice + ? { + onlyPluginIds: [manifestAuthChoice.pluginId], + providerRefs: [manifestAuthChoice.providerId], + } + : {}), + }); + + const setupProvider = manifestAuthChoice + ? resolvePluginSetupProvider({ + provider: manifestAuthChoice.providerId, + config: enabledConfig, + workspaceDir, + env: params.env, + pluginIds: [manifestAuthChoice.pluginId], + }) + : undefined; + let providers = setupProvider + ? [withProviderPluginId(setupProvider, manifestAuthChoice!.pluginId)] + : resolveScopedRuntimeProviders(enabledConfig); let resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, }); + if (!resolved && setupProvider) { + providers = resolveScopedRuntimeProviders(enabledConfig); + resolved = resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + }); + } if (!resolved && installCatalogEntry) { const [{ ensureOnboardingPluginInstalled }, { clearPluginDiscoveryCache }] = await Promise.all([ import("../commands/onboarding-plugin-install.js"), @@ -338,18 +367,7 @@ export async function applyAuthChoiceLoadedPluginProvider( } nextConfig = installResult.cfg; clearPluginDiscoveryCache(); - providers = resolvePluginProviders({ - config: nextConfig, - workspaceDir, - env: params.env, - mode: "setup", - ...(manifestAuthChoice - ? { - onlyPluginIds: [manifestAuthChoice.pluginId], - providerRefs: [manifestAuthChoice.providerId], - } - : {}), - }); + providers = resolveScopedRuntimeProviders(nextConfig); resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, diff --git a/src/plugins/provider-wizard.ts b/src/plugins/provider-wizard.ts index 49fd178014a..76ac80a5826 100644 --- a/src/plugins/provider-wizard.ts +++ b/src/plugins/provider-wizard.ts @@ -7,6 +7,7 @@ import { } from "../shared/string-coerce.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { resolvePluginProviders } from "./providers.runtime.js"; +import { resolvePluginSetupProvider } from "./setup-registry.js"; import type { ProviderAuthMethod, ProviderPlugin, @@ -293,12 +294,19 @@ export async function runProviderModelSelectedHook(params: { return; } - const providers = resolveProviderWizardProviders({ + const setupProvider = resolvePluginSetupProvider({ + provider: selectedProviderId, config: params.config, workspaceDir: params.workspaceDir, env: params.env, }); - const provider = providers.find((entry) => normalizeProviderId(entry.id) === selectedProviderId); + const provider = + setupProvider ?? + resolveProviderWizardProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }).find((entry) => normalizeProviderId(entry.id) === selectedProviderId); if (!provider?.onModelSelected) { return; } diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index d405e5621ae..ac9ed08ff32 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -153,6 +153,7 @@ function setCachedSetupValue(cache: Map, key: string, value: T): v } function buildSetupRegistryCacheKey(params: { + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; @@ -160,18 +161,22 @@ function buildSetupRegistryCacheKey(params: { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, env: params.env, + loadPaths: params.config?.plugins?.load?.paths, }); return JSON.stringify({ roots, loadPaths, + hasConfig: Boolean(params.config), pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null, }); } function buildSetupProviderCacheKey(params: { provider: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): string { return JSON.stringify({ provider: normalizeProviderId(params.provider), @@ -181,6 +186,7 @@ function buildSetupProviderCacheKey(params: { function buildSetupCliBackendCacheKey(params: { backend: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): string { @@ -493,12 +499,14 @@ function pushSetupDescriptorDriftDiagnostics(params: { } export function resolvePluginSetupRegistry(params?: { + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; pluginIds?: readonly string[]; }): PluginSetupRegistry { const env = params?.env ?? process.env; const cacheKey = buildSetupRegistryCacheKey({ + config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, @@ -532,6 +540,7 @@ export function resolvePluginSetupRegistry(params?: { const cliBackendKeys = new Set(); const manifestRegistry = loadSetupManifestRegistry({ + config: params?.config, workspaceDir: params?.workspaceDir, env, pluginIds: params?.pluginIds, @@ -628,8 +637,10 @@ export function resolvePluginSetupRegistry(params?: { export function resolvePluginSetupProvider(params: { provider: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): ProviderPlugin | undefined { const cacheKey = buildSetupProviderCacheKey(params); const cached = getCachedSetupValue(setupProviderCache, cacheKey); @@ -640,8 +651,10 @@ export function resolvePluginSetupProvider(params: { const env = params.env ?? process.env; const normalizedProvider = normalizeProviderId(params.provider); const manifestRegistry = loadSetupManifestRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, + pluginIds: params.pluginIds, }); const record = findUniqueSetupManifestOwner({ registry: manifestRegistry, @@ -697,6 +710,7 @@ export function resolvePluginSetupProvider(params: { export function resolvePluginSetupCliBackend(params: { backend: string; + config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; }): SetupCliBackendEntry | undefined { @@ -713,6 +727,7 @@ export function resolvePluginSetupCliBackend(params: { // plugin setup module. This avoids booting every setup-api just to find one // backend owner. const manifestRegistry = loadSetupManifestRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, }); @@ -786,6 +801,7 @@ export function runPluginSetupConfigMigrations(params: { } for (const entry of resolvePluginSetupRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env: params.env, pluginIds, @@ -812,6 +828,7 @@ export function resolvePluginSetupAutoEnableReasons(params: { const seen = new Set(); for (const entry of resolvePluginSetupRegistry({ + config: params.config, workspaceDir: params.workspaceDir, env, pluginIds: params.pluginIds, From b11dbb49f9ffbfab7988e4586b2fded0a8ef0a6a Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:23 +0100 Subject: [PATCH 05/19] refactor: keep openai setup auth lightweight --- extensions/openai/setup-api.ts | 100 +++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/extensions/openai/setup-api.ts b/extensions/openai/setup-api.ts index 4d41fff3771..0993f07f54c 100644 --- a/extensions/openai/setup-api.ts +++ b/extensions/openai/setup-api.ts @@ -1,11 +1,111 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderAuthContext, ProviderAuthResult } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { + OPENAI_API_KEY_LABEL, + OPENAI_API_KEY_WIZARD_GROUP, + OPENAI_CODEX_DEVICE_PAIRING_HINT, + OPENAI_CODEX_DEVICE_PAIRING_LABEL, + OPENAI_CODEX_LOGIN_HINT, + OPENAI_CODEX_LOGIN_LABEL, + OPENAI_CODEX_WIZARD_GROUP, +} from "./auth-choice-copy.js"; import { buildOpenAICodexCliBackend } from "./cli-backend.js"; +async function runOpenAIProviderAuthMethod( + methodId: string, + ctx: ProviderAuthContext, +): Promise { + const { buildOpenAIProvider } = await import("./openai-provider.js"); + const method = buildOpenAIProvider().auth.find((entry) => entry.id === methodId); + if (!method) { + return { profiles: [] }; + } + return method.run(ctx); +} + +async function runOpenAICodexProviderAuthMethod( + methodId: string, + ctx: ProviderAuthContext, +): Promise { + const { buildOpenAICodexProviderPlugin } = await import("./openai-codex-provider.js"); + const method = buildOpenAICodexProviderPlugin().auth.find((entry) => entry.id === methodId); + if (!method) { + return { profiles: [] }; + } + return method.run(ctx); +} + +function buildOpenAISetupProvider(): ProviderPlugin { + const apiKeyMethod = { + id: "api-key", + label: OPENAI_API_KEY_LABEL, + hint: "Use your OpenAI API key directly", + kind: "api_key", + wizard: { + choiceId: "openai-api-key", + choiceLabel: OPENAI_API_KEY_LABEL, + ...OPENAI_API_KEY_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAIProviderAuthMethod("api-key", ctx), + } satisfies ProviderAuthMethod; + + return { + id: "openai", + label: "OpenAI", + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [apiKeyMethod], + }; +} + +function buildOpenAICodexSetupProvider(): ProviderPlugin { + const oauthMethod = { + id: "oauth", + label: OPENAI_CODEX_LOGIN_LABEL, + hint: OPENAI_CODEX_LOGIN_HINT, + kind: "oauth", + wizard: { + choiceId: "openai-codex", + choiceLabel: OPENAI_CODEX_LOGIN_LABEL, + choiceHint: OPENAI_CODEX_LOGIN_HINT, + assistantPriority: -30, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAICodexProviderAuthMethod("oauth", ctx), + } satisfies ProviderAuthMethod; + + const deviceCodeMethod = { + id: "device-code", + label: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + hint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + kind: "device_code", + wizard: { + choiceId: "openai-codex-device-code", + choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL, + choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT, + assistantPriority: -10, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAICodexProviderAuthMethod("device-code", ctx), + } satisfies ProviderAuthMethod; + + return { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [oauthMethod, deviceCodeMethod], + }; +} + export default definePluginEntry({ id: "openai", name: "OpenAI Setup", description: "Lightweight OpenAI setup hooks", register(api) { + api.registerProvider(buildOpenAISetupProvider()); + api.registerProvider(buildOpenAICodexSetupProvider()); api.registerCliBackend(buildOpenAICodexCliBackend()); }, }); From edcb2326a1a8a5ff94796517f582ba7fa639c569 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:36 +0100 Subject: [PATCH 06/19] test: cover setup provider auth selection --- .../auth-choice.apply.api-providers.test.ts | 2 + .../auth-choice.apply.plugin-provider.test.ts | 41 +++++++++++++++++++ src/commands/auth-choice.test.ts | 1 + .../contracts/auth-choice.contract.test.ts | 2 + src/wizard/setup.test.ts | 1 + 5 files changed, 47 insertions(+) diff --git a/src/commands/auth-choice.apply.api-providers.test.ts b/src/commands/auth-choice.apply.api-providers.test.ts index a0f2ee9fea1..59557ea1d63 100644 --- a/src/commands/auth-choice.apply.api-providers.test.ts +++ b/src/commands/auth-choice.apply.api-providers.test.ts @@ -5,9 +5,11 @@ import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api- const resolvePluginProviders = vi.hoisted(() => vi.fn(), ); +const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined)); vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders, + resolvePluginSetupProvider, })); function createProvider(params: { diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index d4a87d82442..963b0dbd3a3 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -14,16 +14,25 @@ type EnsureOnboardingPluginInstalled = typeof import("../commands/onboarding-plugin-install.js").ensureOnboardingPluginInstalled; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); +const resolvePluginSetupProvider = vi.hoisted(() => + vi.fn<() => ProviderPlugin | undefined>(() => undefined), +); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), ); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders, + resolvePluginSetupProvider, resolveProviderPluginChoice, runProviderModelSelectedHook, })); +const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined)); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); + const upsertAuthProfile = vi.hoisted(() => vi.fn()); vi.mock("../agents/auth-profiles.js", () => ({ upsertAuthProfile, @@ -172,6 +181,8 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { beforeEach(() => { vi.clearAllMocks(); applyAuthProfileConfig.mockImplementation((config) => config); + resolveManifestProviderAuthChoice.mockReturnValue(undefined); + resolvePluginSetupProvider.mockReturnValue(undefined); resolveProviderInstallCatalogEntry.mockReturnValue(undefined); ensureOnboardingPluginInstalled.mockImplementation(async ({ cfg, entry }) => ({ cfg, @@ -320,6 +331,36 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }); }); + it("uses manifest-owned setup providers without loading the broad provider runtime", async () => { + const provider = buildProvider(); + resolveManifestProviderAuthChoice.mockReturnValue({ + pluginId: "local-provider-plugin", + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + choiceId: LOCAL_PROVIDER_ID, + choiceLabel: LOCAL_PROVIDER_LABEL, + }); + resolvePluginSetupProvider.mockReturnValue(provider); + resolveProviderPluginChoice.mockReturnValue({ + provider, + method: provider.auth[0], + }); + + const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); + + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: LOCAL_DEFAULT_MODEL, + }); + expect(resolvePluginSetupProvider).toHaveBeenCalledWith({ + provider: LOCAL_PROVIDER_ID, + config: {}, + workspaceDir: "/tmp/workspace", + env: undefined, + pluginIds: ["local-provider-plugin"], + }); + expect(resolvePluginProviders).not.toHaveBeenCalled(); + }); + it("installs a missing provider plugin and retries setup resolution", async () => { const provider = buildProvider(); resolveProviderInstallCatalogEntry.mockReturnValue(buildLocalProviderInstallCatalogEntry()); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 99cf5f99878..d6043e21f73 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -624,6 +624,7 @@ describe("applyAuthChoice", () => { providerAuthChoiceTesting.setDepsForTest({ loadPluginProviderRuntime: async () => ({ resolvePluginProviders, + resolvePluginSetupProvider: () => undefined, resolveProviderPluginChoice, runProviderModelSelectedHook, }), diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index 89aa39c94dc..84bb4f0a477 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -11,6 +11,7 @@ type ResolveProviderPluginChoice = type RunProviderModelSelectedHook = typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook; const resolvePluginProvidersMock = vi.hoisted(() => vi.fn(() => [])); +const resolvePluginSetupProviderMock = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn()); const runProviderModelSelectedHookMock = vi.hoisted(() => vi.fn(async () => {}), @@ -19,6 +20,7 @@ const runAuthMethodMock = vi.hoisted(() => vi.fn(async () => ({ profiles: [] })) vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders: resolvePluginProvidersMock, + resolvePluginSetupProvider: resolvePluginSetupProviderMock, resolveProviderPluginChoice: resolveProviderPluginChoiceMock, runProviderModelSelectedHook: runProviderModelSelectedHookMock, })); diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 8a178912854..5794796a530 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -169,6 +169,7 @@ vi.mock("../commands/auth-choice.js", () => ({ vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolveProviderPluginChoice, resolvePluginProviders: resolvePluginProvidersRuntime, + resolvePluginSetupProvider: vi.fn(() => undefined), })); vi.mock("../commands/model-picker.js", () => ({ From cd3b87112267c4de00592166aef8c2e0c3c00d67 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:02:59 +0100 Subject: [PATCH 07/19] docs: note faster onboarding auth setup --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de5fa94a078..d7b92cefa14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai ### 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/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. 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. - 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. From 3fe07189324828fc2ee9311a61b66c4c3df712f7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:17:09 +0100 Subject: [PATCH 08/19] fix: keep post-auth model policy cold --- CHANGELOG.md | 1 + src/wizard/setup.test.ts | 66 +++++++++++++++++++++++++++++++++++++++- src/wizard/setup.ts | 30 ++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b92cefa14..18b5e8c8406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - 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/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd. +- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. 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. - 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. diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 5794796a530..bb0f08c6829 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -23,6 +23,8 @@ const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })), ); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider")); +const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined)); +const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined)); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn(() => null), ); @@ -166,10 +168,17 @@ vi.mock("../commands/auth-choice.js", () => ({ warnIfModelConfigLooksOff, })); +vi.mock("../plugins/provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoice, +})); + +vi.mock("../plugins/setup-registry.js", () => ({ + resolvePluginSetupProvider, +})); + vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolveProviderPluginChoice, resolvePluginProviders: resolvePluginProvidersRuntime, - resolvePluginSetupProvider: vi.fn(() => undefined), })); vi.mock("../commands/model-picker.js", () => ({ @@ -960,4 +969,59 @@ describe("runSetupWizard", () => { ), ).toBe(true); }); + + it("uses manifest setup metadata for post-auth model policy without loading provider runtime", async () => { + promptDefaultModel.mockClear(); + resolvePluginProvidersRuntime.mockClear(); + resolveManifestProviderAuthChoice.mockReturnValue({ + pluginId: "openai", + providerId: "openai-codex", + methodId: "oauth", + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex Browser Login", + }); + resolvePluginSetupProvider.mockReturnValue({ + id: "openai-codex", + label: "OpenAI Codex", + auth: [ + { + id: "oauth", + label: "OpenAI Codex Browser Login", + kind: "oauth", + wizard: { + modelSelection: { + allowKeepCurrent: false, + }, + }, + run: vi.fn(async () => ({ profiles: [] })), + }, + ], + }); + promptAuthChoiceGrouped.mockResolvedValueOnce("openai-codex"); + const prompter = buildWizardPrompter({}); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + installDaemon: false, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expect(resolvePluginSetupProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai-codex", + pluginIds: ["openai"], + }), + ); + expect(resolvePluginProvidersRuntime).not.toHaveBeenCalled(); + expect(promptDefaultModel).toHaveBeenCalledWith(expect.objectContaining({ allowKeep: false })); + }); }); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 624600b7717..48761819632 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -1,3 +1,4 @@ +import { normalizeProviderId } from "../agents/provider-id.js"; import { formatCliCommand } from "../cli/command-format.js"; import { commitConfigWriteWithPendingPluginInstalls } from "../cli/plugins-install-record-commit.js"; import type { @@ -88,6 +89,35 @@ async function resolveAuthChoiceModelSelectionPolicy(params: { env: params.env, }); + const [{ resolveManifestProviderAuthChoice }, { resolvePluginSetupProvider }] = await Promise.all( + [import("../plugins/provider-auth-choices.js"), import("../plugins/setup-registry.js")], + ); + const manifestChoice = resolveManifestProviderAuthChoice(params.authChoice, { + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeUntrustedWorkspacePlugins: false, + }); + if (manifestChoice) { + const setupProvider = resolvePluginSetupProvider({ + provider: manifestChoice.providerId, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + pluginIds: [manifestChoice.pluginId], + }); + const setupMethod = setupProvider?.auth.find( + (method) => normalizeProviderId(method.id) === normalizeProviderId(manifestChoice.methodId), + ); + const setupPolicy = + setupMethod?.wizard?.modelSelection ?? setupProvider?.wizard?.setup?.modelSelection; + return { + preferredProvider, + promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true, + allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true, + }; + } + const { resolvePluginProviders, resolveProviderPluginChoice } = await import("../plugins/provider-auth-choice.runtime.js"); const providers = resolvePluginProviders({ From 8344fae38720f415bffa09aa6ba18b4d942188d5 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 26 Apr 2026 11:35:04 +0100 Subject: [PATCH 09/19] fix: preserve provider-scoped model options --- src/commands/configure.gateway-auth.ts | 1 + src/commands/model-picker.test.ts | 1 + src/flows/model-picker.ts | 8 +++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index ff0f5407f14..5047a0e4342 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -177,6 +177,7 @@ export async function promptAuthConfig( initialSelections: modelAllowlist?.initialSelections, message: modelAllowlist?.message, preferredProvider, + loadCatalog: false, }); if (allowlistSelection.models) { next = applyModelFallbacksFromSelection(next, allowlistSelection.models, { diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index c6e7da673bf..e654864f876 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -738,6 +738,7 @@ describe("promptModelAllowlist", () => { config, prompter, preferredProvider: "openai-codex", + loadCatalog: false, }); expect(loadModelCatalog).not.toHaveBeenCalled(); diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 6adfed8c619..2301de4e9ff 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -788,6 +788,7 @@ export async function promptModelAllowlist(params: { allowedKeys?: string[]; initialSelections?: string[]; preferredProvider?: string; + loadCatalog?: boolean; }): Promise { const cfg = params.config; const existingKeys = resolveConfiguredModelKeys(cfg); @@ -839,11 +840,12 @@ export async function promptModelAllowlist(params: { cfg, }) : undefined; + const loadCatalog = params.loadCatalog ?? true; const scopedFastKeys = allowedKeys.length > 0 ? allowedKeys - : preferredProvider && hasRealSeed + : !loadCatalog && preferredProvider && hasRealSeed ? initialSeeds.filter((key) => { const entry = splitModelKey(key); return entry ? matchesPreferredProvider?.(entry.provider) === true : false; @@ -888,6 +890,10 @@ export async function promptModelAllowlist(params: { return { models: [], scopeKeys }; } + if (!loadCatalog) { + return {}; + } + const allowlistProgress = params.prompter.progress("Loading available models"); let catalog: Awaited>; try { From cd8187d7cecd88e899e41f332432c587fe424455 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:38:20 +0100 Subject: [PATCH 10/19] test(parallels): harden smoke agent model setup --- docs/help/testing.md | 4 ++ scripts/e2e/parallels-linux-smoke.sh | 49 ++++++++++++++++++---- scripts/e2e/parallels-macos-smoke.sh | 33 +++++++++++++-- scripts/e2e/parallels-npm-update-smoke.sh | 27 +++++++++--- scripts/e2e/parallels-windows-smoke.sh | 39 +++++++++++++++-- test/scripts/parallels-smoke-model.test.ts | 43 +++++++++++++++++++ 6 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 test/scripts/parallels-smoke-model.test.ts diff --git a/docs/help/testing.md b/docs/help/testing.md index c7b012fa725..800255e9489 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -172,6 +172,10 @@ runs the same lanes before release approval. - Use `--platform macos`, `--platform windows`, or `--platform linux` while iterating on one guest. Use `--json` for the summary artifact path and per-lane status. + - The OpenAI lane uses `openai/gpt-5.5` for the live agent-turn proof by + default. Pass `--model ` or set + `OPENCLAW_PARALLELS_OPENAI_MODEL` when deliberately validating another + OpenAI model. - Wrap long local runs in a host timeout so Parallels transport stalls cannot consume the rest of the testing window: diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index 2d6837c26c4..4b0712a24be 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -13,6 +13,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 INSTALL_URL="https://openclaw.ai/install.sh" HOST_PORT="18427" HOST_PORT_EXPLICIT=0 @@ -103,6 +104,8 @@ Options: --mode --provider Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -142,6 +145,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -200,19 +208,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -764,13 +772,38 @@ verify_gateway_status() { return 1 } +prepare_agent_workspace() { + guest_exec /bin/sh -lc 'set -eu +workspace="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" +mkdir -p "$workspace/.openclaw" +cat > "$workspace/IDENTITY.md" <<'"'"'IDENTITY_EOF'"'"' +# Identity + +- Name: OpenClaw +- Purpose: Parallels Linux smoke test assistant. +IDENTITY_EOF +cat > "$workspace/.openclaw/workspace-state.json" <<'"'"'STATE_EOF'"'"' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +STATE_EOF +rm -f "$workspace/BOOTSTRAP.md"' +} + verify_local_turn() { guest_exec openclaw models set "$MODEL_ID" - guest_exec /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" openclaw agent \ - --local \ - --agent main \ - --message ping \ - --json + guest_exec openclaw config set agents.defaults.skipBootstrap true --strict-json + prepare_agent_workspace + guest_exec /bin/sh -lc "$(cat < Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -184,6 +187,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -258,19 +266,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -1474,11 +1482,28 @@ show_gateway_status_compat() { verify_turn() { guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" models set "$MODEL_ID" + guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" config set agents.defaults.skipBootstrap true --strict-json guest_current_user_sh "$(cat < "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' +# Identity + +- Name: OpenClaw +- Purpose: Parallels macOS smoke test assistant. +IDENTITY_EOF +cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +STATE_EOF +rm -f "\$workspace/BOOTSTRAP.md" exec /usr/bin/env $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") \ $(shell_quote "$GUEST_NODE_BIN") $(shell_quote "$GUEST_OPENCLAW_ENTRY") agent \ --agent main \ + --session-id parallels-macos-smoke \ --message $(shell_quote "Reply with exact ASCII text OK only.") \ --json EOF diff --git a/scripts/e2e/parallels-npm-update-smoke.sh b/scripts/e2e/parallels-npm-update-smoke.sh index 64566b87d4f..7dfb84e3335 100755 --- a/scripts/e2e/parallels-npm-update-smoke.sh +++ b/scripts/e2e/parallels-npm-update-smoke.sh @@ -13,6 +13,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 PYTHON_BIN="${PYTHON_BIN:-}" PACKAGE_SPEC="" UPDATE_TARGET="" @@ -120,6 +121,8 @@ Options: Default: all --provider Provider auth/model lane. Default: openai + --model Override the model used for agent-turn smoke checks. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -149,6 +152,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -206,19 +214,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -1104,7 +1112,8 @@ cat > "\$workspace/.openclaw/workspace-state.json" <<'STATE_EOF' } STATE_EOF rm -f "\$workspace/BOOTSTRAP.md" -/opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json /opt/homebrew/bin/openclaw agent --agent main --session-id "parallels-npm-update-macos-transport-recovery-$expected_needle" --message "Reply with exact ASCII text OK only." --json EOF macos_desktop_user_exec /bin/bash "$script_path" @@ -1235,7 +1244,8 @@ if (-not \$gatewayReady) { \$providerBytes = [Convert]::FromBase64String('$provider_key_b64') \$providerValue = [Text.Encoding]::UTF8.GetString(\$providerBytes) Set-Item -Path ('Env:' + '$API_KEY_ENV') -Value \$providerValue -& \$openclaw models set '$MODEL_ID' + & \$openclaw models set '$MODEL_ID' + & \$openclaw config set agents.defaults.skipBootstrap true --strict-json \$workspace = \$env:OPENCLAW_WORKSPACE_DIR if (-not \$workspace) { \$workspace = Join-Path \$env:USERPROFILE '.openclaw\\workspace' @@ -1692,7 +1702,8 @@ if [ -n "$expected_needle" ]; then esac fi /opt/homebrew/bin/openclaw update status --json -/opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw models set "$MODEL_ID" + /opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json # Same-guest npm upgrades can leave launchd holding the old gateway process or # module graph briefly; wait for a fresh RPC-ready restart before the agent turn. # Fresh npm installs may not have a launchd service yet, so fall back to the @@ -1826,6 +1837,7 @@ if [ -n "$expected_needle" ]; then fi openclaw update status --json openclaw models set "$MODEL_ID" +openclaw config set agents.defaults.skipBootstrap true --strict-json workspace="\${OPENCLAW_WORKSPACE_DIR:-\$HOME/.openclaw/workspace}" mkdir -p "\$workspace/.openclaw" cat > "\$workspace/IDENTITY.md" <<'IDENTITY_EOF' @@ -1911,6 +1923,7 @@ if platform_enabled macos; then bash "$ROOT_DIR/scripts/e2e/parallels-macos-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/macos-fresh.log" 2>&1 & @@ -1922,6 +1935,7 @@ if platform_enabled windows; then bash "$ROOT_DIR/scripts/e2e/parallels-windows-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/windows-fresh.log" 2>&1 & @@ -1933,6 +1947,7 @@ if platform_enabled linux; then bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \ --mode fresh \ --provider "$PROVIDER" \ + --model "$MODEL_ID" \ --api-key-env "$API_KEY_ENV" \ --target-package-spec "$PACKAGE_SPEC" \ --json >"$RUN_DIR/linux-fresh.log" 2>&1 & diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index a7799cd3e86..63088e20709 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -12,6 +12,7 @@ API_KEY_ENV="" AUTH_CHOICE="" AUTH_KEY_FLAG="" MODEL_ID="" +MODEL_ID_EXPLICIT=0 INSTALL_URL="https://openclaw.ai/install.ps1" HOST_PORT="18426" HOST_PORT_EXPLICIT=0 @@ -138,6 +139,8 @@ Options: --mode --provider Provider auth/model lane. Default: openai + --model Override the model used for the agent-turn smoke. + Default: openai/gpt-5.5 for the OpenAI lane --api-key-env Host env var name for provider API key. Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic --openai-api-key-env Alias for --api-key-env (backward compatible) @@ -183,6 +186,11 @@ while [[ $# -gt 0 ]]; do PROVIDER="$2" shift 2 ;; + --model) + MODEL_ID="$2" + MODEL_ID_EXPLICIT=1 + shift 2 + ;; --api-key-env|--openai-api-key-env) API_KEY_ENV="$2" shift 2 @@ -249,19 +257,19 @@ case "$PROVIDER" in openai) AUTH_CHOICE="openai-api-key" AUTH_KEY_FLAG="openai-api-key" - MODEL_ID="openai/gpt-5.5" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY" ;; anthropic) AUTH_CHOICE="apiKey" AUTH_KEY_FLAG="anthropic-api-key" - MODEL_ID="anthropic/claude-sonnet-4-6" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_ANTHROPIC_MODEL:-anthropic/claude-sonnet-4-6}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="ANTHROPIC_API_KEY" ;; minimax) AUTH_CHOICE="minimax-global-api" AUTH_KEY_FLAG="minimax-api-key" - MODEL_ID="minimax/MiniMax-M2.7" + [[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_MINIMAX_MODEL:-minimax/MiniMax-M2.7}" [[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="MINIMAX_API_KEY" ;; *) @@ -2367,8 +2375,31 @@ show_gateway_status_compat() { verify_turn() { guest_run_openclaw "" "" models set "$MODEL_ID" + guest_run_openclaw "" "" config set agents.defaults.skipBootstrap true --strict-json + guest_powershell "$(cat <<'EOF' +$workspace = $env:OPENCLAW_WORKSPACE_DIR +if (-not $workspace) { + $workspace = Join-Path $env:USERPROFILE '.openclaw\workspace' +} +$stateDir = Join-Path $workspace '.openclaw' +New-Item -ItemType Directory -Path $stateDir -Force | Out-Null +@' +# Identity + +- Name: OpenClaw +- Purpose: Parallels Windows smoke test assistant. +'@ | Set-Content -Path (Join-Path $workspace 'IDENTITY.md') -Encoding UTF8 +@' +{ + "version": 1, + "setupCompletedAt": "2026-01-01T00:00:00.000Z" +} +'@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8 +Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue +EOF +)" guest_run_openclaw "$API_KEY_ENV" "$API_KEY_VALUE" \ - agent --agent main --message "Reply with exact ASCII text OK only." --json + agent --agent main --session-id parallels-windows-smoke --message "Reply with exact ASCII text OK only." --json } capture_latest_ref_failure() { diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts new file mode 100644 index 00000000000..ca176796ad2 --- /dev/null +++ b/test/scripts/parallels-smoke-model.test.ts @@ -0,0 +1,43 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const OS_SCRIPT_PATHS = [ + "scripts/e2e/parallels-linux-smoke.sh", + "scripts/e2e/parallels-macos-smoke.sh", + "scripts/e2e/parallels-windows-smoke.sh", +]; +const NPM_UPDATE_SCRIPT_PATH = "scripts/e2e/parallels-npm-update-smoke.sh"; + +describe("Parallels smoke model selection", () => { + it("keeps the OpenAI smoke lane on the stable direct API model by default", () => { + for (const scriptPath of [...OS_SCRIPT_PATHS, NPM_UPDATE_SCRIPT_PATH]) { + const script = readFileSync(scriptPath, "utf8"); + + expect(script, scriptPath).toContain( + 'MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"', + ); + expect(script, scriptPath).toContain("--model "); + expect(script, scriptPath).toContain("MODEL_ID_EXPLICIT=1"); + } + }); + + it("seeds agent workspace state before OS smoke agent turns", () => { + for (const scriptPath of OS_SCRIPT_PATHS) { + const script = readFileSync(scriptPath, "utf8"); + + expect(script, scriptPath).toContain("workspace-state.json"); + expect(script, scriptPath).toContain("IDENTITY.md"); + expect(script, scriptPath).toContain("BOOTSTRAP.md"); + expect(script, scriptPath).toContain("--session-id parallels-"); + expect(script, scriptPath).toContain("agents.defaults.skipBootstrap true --strict-json"); + } + }); + + it("passes aggregate model overrides into each OS fresh lane", () => { + const script = readFileSync(NPM_UPDATE_SCRIPT_PATH, "utf8"); + + expect(script).toMatch(/parallels-macos-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + expect(script).toMatch(/parallels-windows-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + expect(script).toMatch(/parallels-linux-smoke\.sh"[\s\S]*?--model "\$MODEL_ID"/); + }); +}); From 6a00be5f90ed41b9496033af09b9292d22483b3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:38:36 +0100 Subject: [PATCH 11/19] fix(update): complete channel switch follow-up work --- .../openclaw-live-and-e2e-checks-reusable.yml | 5 + docs/help/testing.md | 3 +- package.json | 1 + scripts/e2e/Dockerfile | 2 +- scripts/e2e/update-channel-switch-docker.sh | 165 ++++++++++++++++++ scripts/test-docker-all.mjs | 8 + src/cli/update-cli.test.ts | 67 ++++--- src/cli/update-cli/update-command.ts | 123 +++++-------- src/docker-build-cache.test.ts | 2 +- 9 files changed, 270 insertions(+), 106 deletions(-) create mode 100755 scripts/e2e/update-channel-switch-docker.sh diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 5a1e6e7415c..cbe4ae1a639 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -430,6 +430,11 @@ jobs: command: pnpm test:docker:doctor-switch timeout_minutes: 60 release_path: true + - suite_id: docker-update-channel-switch + label: Update Channel Switch Docker E2E + command: pnpm test:docker:update-channel-switch + timeout_minutes: 60 + release_path: true - suite_id: docker-session-runtime-context label: Session Runtime Context Docker E2E command: pnpm test:docker:session-runtime-context diff --git a/docs/help/testing.md b/docs/help/testing.md index 800255e9489..33b8728efb6 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -607,7 +607,7 @@ These Docker runners split into two buckets: `OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Override those env vars when you explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the live Docker lanes. It also builds one shared `scripts/e2e/Dockerfile` image via `test:docker:e2e-build` and reuses it for the E2E container smoke runners that exercise the built app. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=6`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=8`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker. -- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. +- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: @@ -619,6 +619,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or - Open WebUI live smoke: `pnpm test:docker:openwebui` (script: `scripts/e2e/openwebui-docker.sh`) - Onboarding wizard (TTY, full scaffolding): `pnpm test:docker:onboard` (script: `scripts/e2e/onboard-docker.sh`) - Npm tarball onboarding/channel/agent smoke: `pnpm test:docker:npm-onboard-channel-agent` installs the packed OpenClaw tarball globally in Docker, configures OpenAI via env-ref onboarding plus Telegram by default, verifies doctor repairs activated plugin runtime deps, and runs one mocked OpenAI agent turn. Reuse a prebuilt tarball with `OPENCLAW_NPM_ONBOARD_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host rebuild with `OPENCLAW_NPM_ONBOARD_HOST_BUILD=0`, or switch channel with `OPENCLAW_NPM_ONBOARD_CHANNEL=discord`. +- Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status. - Session runtime context smoke: `pnpm test:docker:session-runtime-context` verifies hidden runtime context transcript persistence plus doctor repair of affected duplicated prompt-rewrite branches. - Bun global install smoke: `bash scripts/e2e/bun-global-install-smoke.sh` packs the current tree, installs it with `bun install -g` in an isolated home, and verifies `openclaw infer image providers --json` returns bundled image providers instead of hanging. Reuse a prebuilt tarball with `OPENCLAW_BUN_GLOBAL_SMOKE_PACKAGE_TGZ=/path/to/openclaw-*.tgz`, skip the host build with `OPENCLAW_BUN_GLOBAL_SMOKE_HOST_BUILD=0`, or copy `dist/` from a built Docker image with `OPENCLAW_BUN_GLOBAL_SMOKE_DIST_IMAGE=openclaw-dockerfile-smoke:local`. - Installer Docker smoke: `bash scripts/test-install-sh-docker.sh` shares one npm cache across its root, update, and direct-npm containers. Update smoke defaults to npm `latest` as the stable baseline before upgrading to the candidate tarball. Non-root installer checks keep an isolated npm cache so root-owned cache entries do not mask user-local install behavior. Set `OPENCLAW_INSTALL_SMOKE_NPM_CACHE_DIR=/path/to/cache` to reuse the root/update/direct-npm cache across local reruns. diff --git a/package.json b/package.json index 9a098cb9e2f..ba3f9def3e5 100644 --- a/package.json +++ b/package.json @@ -1542,6 +1542,7 @@ "test:docker:plugins": "bash scripts/e2e/plugins-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:session-runtime-context": "bash scripts/e2e/session-runtime-context-docker.sh", + "test:docker:update-channel-switch": "bash scripts/e2e/update-channel-switch-docker.sh", "test:e2e": "node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts", "test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts extensions/openshell/src/backend.e2e.test.ts", "test:extension": "node scripts/test-extension.mjs", diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 036e2c6ead4..91bbcffcd1b 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -40,7 +40,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/ FROM deps AS build -COPY --chown=appuser:appuser tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts openclaw.mjs ./ +COPY --chown=appuser:appuser .oxlintrc.json tsconfig.json tsconfig.plugin-sdk.dts.json tsconfig.oxlint*.json tsdown.config.ts vitest.config.ts openclaw.mjs ./ COPY --chown=appuser:appuser src ./src COPY --chown=appuser:appuser test ./test COPY --chown=appuser:appuser scripts ./scripts diff --git a/scripts/e2e/update-channel-switch-docker.sh b/scripts/e2e/update-channel-switch-docker.sh new file mode 100755 index 00000000000..203c211db4e --- /dev/null +++ b/scripts/e2e/update-channel-switch-docker.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-update-channel-switch-e2e" OPENCLAW_UPDATE_CHANNEL_SWITCH_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_UPDATE_CHANNEL_SWITCH_E2E_SKIP_BUILD:-0}" + +docker_e2e_build_or_reuse "$IMAGE_NAME" update-channel-switch "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" + +echo "Running update channel switch E2E..." +docker run --rm \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_SKIP_CHANNELS=1 \ + -e OPENCLAW_SKIP_PROVIDERS=1 \ + "$IMAGE_NAME" \ + bash -lc 'set -euo pipefail + +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export npm_config_prefix=/tmp/npm-prefix +export NPM_CONFIG_PREFIX=/tmp/npm-prefix +export PNPM_HOME=/tmp/pnpm-home +export PATH="/tmp/npm-prefix/bin:/tmp/pnpm-home:$PATH" +export CI=true +export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 +export OPENCLAW_NO_ONBOARD=1 +export OPENCLAW_NO_PROMPT=1 + +cat > /app/.gitignore <<'"'"'GITIGNORE'"'"' +node_modules +**/node_modules/ +dist +dist-runtime +.turbo +coverage +GITIGNORE + +node --import tsx scripts/write-package-dist-inventory.ts + +git config --global user.email "docker-e2e@openclaw.local" +git config --global user.name "OpenClaw Docker E2E" +git config --global gc.auto 0 +git -C /app init -q +git -C /app config gc.auto 0 +git -C /app add -A +git -C /app commit -qm "test fixture" +fixture_sha="$(git -C /app rev-parse HEAD)" + +pkg_tgz="$(npm pack --ignore-scripts --silent --pack-destination /tmp /app | tail -n 1 | tr -d "\r")" +pkg_tgz_path="/tmp/$pkg_tgz" +if [ ! -f "$pkg_tgz_path" ]; then + echo "npm pack failed (expected $pkg_tgz_path)" + exit 1 +fi + +npm install -g --prefix /tmp/npm-prefix --omit=optional "$pkg_tgz_path" + +home_dir="$(mktemp -d /tmp/openclaw-update-channel-switch-home.XXXXXX)" +export HOME="$home_dir" +mkdir -p "$HOME/.openclaw" +cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"' +{ + "update": { + "channel": "stable" + }, + "plugins": {} +} +JSON + +export OPENCLAW_GIT_DIR=/app +export OPENCLAW_UPDATE_DEV_TARGET_REF="$fixture_sha" + +echo "==> package -> git dev channel" +set +e +dev_json="$(openclaw update --channel dev --yes --json --no-restart)" +dev_status=$? +set -e +printf "%s\n" "$dev_json" +if [ "$dev_status" -ne 0 ]; then + exit "$dev_status" +fi +DEV_JSON="$dev_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.DEV_JSON); +if (payload.status !== "ok") { + throw new Error(`expected dev update status ok, got ${payload.status}`); +} +if (payload.mode !== "git") { + throw new Error(`expected dev update mode git, got ${payload.mode}`); +} +if (payload.postUpdate?.plugins?.status !== "ok") { + throw new Error(`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`); +} +NODE + +node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); +const path = require("node:path"); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +if (config.update?.channel !== "dev") { + throw new Error(`expected persisted update.channel dev, got ${JSON.stringify(config.update?.channel)}`); +} +NODE + +status_json="$(openclaw update status --json)" +printf "%s\n" "$status_json" +STATUS_JSON="$status_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STATUS_JSON); +if (payload.update?.installKind !== "git") { + throw new Error(`expected git install after dev switch, got ${payload.update?.installKind}`); +} +if (payload.channel?.value !== "dev" || payload.channel?.source !== "config") { + throw new Error(`expected dev config channel after dev switch, got ${JSON.stringify(payload.channel)}`); +} +NODE + +echo "==> git -> package stable channel" +set +e +stable_json="$(openclaw update --channel stable --tag "$pkg_tgz_path" --yes --json --no-restart)" +stable_status=$? +set -e +printf "%s\n" "$stable_json" +if [ "$stable_status" -ne 0 ]; then + exit "$stable_status" +fi +STABLE_JSON="$stable_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STABLE_JSON); +if (payload.status !== "ok") { + throw new Error(`expected stable update status ok, got ${payload.status}`); +} +if (!["npm", "pnpm", "bun"].includes(payload.mode)) { + throw new Error(`expected package-manager mode after stable switch, got ${payload.mode}`); +} +if (payload.postUpdate?.plugins?.status !== "ok") { + throw new Error(`expected plugin post-update ok, got ${JSON.stringify(payload.postUpdate?.plugins)}`); +} +NODE + +node - <<'"'"'NODE'"'"' +const fs = require("node:fs"); +const path = require("node:path"); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = JSON.parse(fs.readFileSync(configPath, "utf8")); +if (config.update?.channel !== "stable") { + throw new Error(`expected persisted update.channel stable, got ${JSON.stringify(config.update?.channel)}`); +} +NODE + +status_json="$(openclaw update status --json)" +printf "%s\n" "$status_json" +STATUS_JSON="$status_json" node - <<'"'"'NODE'"'"' +const payload = JSON.parse(process.env.STATUS_JSON); +if (payload.update?.installKind !== "package") { + throw new Error(`expected package install after stable switch, got ${payload.update?.installKind}`); +} +if (payload.channel?.value !== "stable" || payload.channel?.source !== "config") { + throw new Error(`expected stable config channel after stable switch, got ${JSON.stringify(payload.channel)}`); +} +NODE + +echo "OK" +' diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index a0df9dd0f29..10aa26964a9 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -246,6 +246,14 @@ const lanes = [ npmLane("doctor-switch", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:doctor-switch", { weight: 3, }), + npmLane( + "update-channel-switch", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:update-channel-switch", + { + timeoutMs: 30 * 60 * 1000, + weight: 3, + }, + ), lane("plugins", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugins", { resources: ["npm", "service"], weight: 6, diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a52affc945c..e60bbd18a07 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1693,41 +1693,68 @@ describe("update-cli", () => { expect(syncConfig?.plugins?.entries).toBeUndefined(); }); - it("skips plugin sync in the old process after switching from package to git", async () => { + it("persists channel and runs post-update work after switching from package to git", async () => { const tempDir = createCaseDir("openclaw-update"); + const gitRoot = path.join(tempDir, "..", "openclaw"); const completionCacheSpy = vi .spyOn(updateCliShared, "tryWriteCompletionCache") .mockResolvedValue(undefined); mockPackageInstallStatus(tempDir); + vi.mocked(readConfigFileSnapshot).mockResolvedValue({ + ...baseSnapshot, + parsed: { update: { channel: "stable" } }, + resolved: { update: { channel: "stable" } } as OpenClawConfig, + sourceConfig: { update: { channel: "stable" } } as OpenClawConfig, + runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig, + config: { update: { channel: "stable" } } as OpenClawConfig, + }); vi.mocked(runGatewayUpdate).mockResolvedValue( makeOkUpdateResult({ mode: "git", - root: path.join(tempDir, "..", "openclaw"), + root: gitRoot, after: { version: "2026.4.10" }, }), ); - serviceLoaded.mockResolvedValue(true); - syncPluginsForUpdateChannel.mockRejectedValue( - new Error("Config validation failed: old host version"), + syncPluginsForUpdateChannel.mockImplementation(async ({ config }) => ({ + changed: false, + config, + summary: { + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + })); + updateNpmInstalledPlugins.mockImplementation(async ({ config }) => ({ + changed: false, + config, + outcomes: [], + })); + + await updateCommand({ channel: "dev", yes: true, restart: false }); + + const persistedConfig = vi.mocked(replaceConfigFile).mock.calls[0]?.[0]?.nextConfig; + expect(persistedConfig?.update?.channel).toBe("dev"); + expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "dev", + config: expect.objectContaining({ + update: expect.objectContaining({ channel: "dev" }), + }), + workspaceDir: gitRoot, + }), ); - - await updateCommand({ channel: "dev", yes: true }); - - expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled(); - expect(replaceConfigFile).not.toHaveBeenCalled(); - expect(completionCacheSpy).not.toHaveBeenCalled(); + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + update: expect.objectContaining({ channel: "dev" }), + }), + }), + ); + expect(completionCacheSpy).toHaveBeenCalledWith(gitRoot, false); expect(runRestartScript).not.toHaveBeenCalled(); expect(runDaemonRestart).not.toHaveBeenCalled(); - expect(defaultRuntime.exit).toHaveBeenCalledWith(0); expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); - expect( - vi - .mocked(defaultRuntime.log) - .mock.calls.map((call) => String(call[0])) - .join("\n"), - ).toContain( - "Switched from a package install to a git checkout. Skipping remaining post-update work in the old CLI process; rerun follow-up commands from the new git install if needed.", - ); }); it("explains why git updates cannot run with edited files", async () => { vi.mocked(defaultRuntime.log).mockClear(); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index d2250ddc2b8..78c243d9c9c 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1339,54 +1339,30 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - if (switchToGit && result.status === "ok" && result.mode === "git") { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Switched from a package install to a git checkout. Skipping remaining post-update work in the old CLI process; rerun follow-up commands from the new git install if needed.", - ), - ); - } else { - defaultRuntime.writeJson(result); - } - defaultRuntime.exit(0); - return; - } - let postUpdateConfigSnapshot = configSnapshot; if (requestedChannel && configSnapshot.valid && requestedChannel !== storedChannel) { - if (switchToGit) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - `Skipped persisting update.channel=${requestedChannel} in the pre-update CLI process after switching to a git install.`, - ), - ); - } - } else { - const next = { - ...configSnapshot.sourceConfig, - update: { - ...configSnapshot.sourceConfig.update, - channel: requestedChannel, - }, - }; - await replaceConfigFile({ - nextConfig: next, - baseHash: configSnapshot.hash, - }); - postUpdateConfigSnapshot = { - ...configSnapshot, - hash: undefined, - parsed: next, - sourceConfig: asResolvedSourceConfig(next), - resolved: asResolvedSourceConfig(next), - runtimeConfig: asRuntimeConfig(next), - config: asRuntimeConfig(next), - }; - if (!opts.json) { - defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); - } + const next = { + ...configSnapshot.sourceConfig, + update: { + ...configSnapshot.sourceConfig.update, + channel: requestedChannel, + }, + }; + await replaceConfigFile({ + nextConfig: next, + baseHash: configSnapshot.hash, + }); + postUpdateConfigSnapshot = { + ...configSnapshot, + hash: undefined, + parsed: next, + sourceConfig: asResolvedSourceConfig(next), + resolved: asResolvedSourceConfig(next), + runtimeConfig: asRuntimeConfig(next), + config: asRuntimeConfig(next), + }; + if (!opts.json) { + defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`)); } } @@ -1409,16 +1385,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { postCorePluginUpdate = freshProcessResult.pluginUpdate; } - const deferOldProcessPostUpdateWork = switchToGit && result.mode === "git"; - if (deferOldProcessPostUpdateWork) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Skipped plugin update sync in the pre-update CLI process after switching to a git install.", - ), - ); - } - } else if (!pluginsUpdatedInFreshProcess) { + if (!pluginsUpdatedInFreshProcess) { postCorePluginUpdate = await runPostCorePluginUpdate({ root: postUpdateRoot, channel, @@ -1468,34 +1435,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } } - if (deferOldProcessPostUpdateWork) { - if (!opts.json) { - defaultRuntime.log( - theme.muted( - "Skipped completion/restart follow-ups in the pre-update CLI process after switching to a git install.", - ), - ); - } - } else { - await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json)); - await tryInstallShellCompletion({ - jsonMode: Boolean(opts.json), - skipPrompt: Boolean(opts.yes), - }); + await tryWriteCompletionCache(postUpdateRoot, Boolean(opts.json)); + await tryInstallShellCompletion({ + jsonMode: Boolean(opts.json), + skipPrompt: Boolean(opts.yes), + }); - const restartOk = await maybeRestartService({ - shouldRestart, - result: resultWithPostUpdate, - opts, - refreshServiceEnv: refreshGatewayServiceEnv, - gatewayPort, - restartScriptPath, - invocationCwd, - }); - if (!restartOk) { - defaultRuntime.exit(1); - return; - } + const restartOk = await maybeRestartService({ + shouldRestart, + result: resultWithPostUpdate, + opts, + refreshServiceEnv: refreshGatewayServiceEnv, + gatewayPort, + restartScriptPath, + invocationCwd, + }); + if (!restartOk) { + defaultRuntime.exit(1); + return; } if (!opts.json) { diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 3cfc5b01d10..9854c135f9a 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -116,7 +116,7 @@ describe("docker build cache layout", () => { /^COPY(?:\s+--chown=\S+)?\s+scripts\/postinstall-bundled-plugins\.mjs scripts\/preinstall-package-manager-warning\.mjs scripts\/npm-runner\.mjs scripts\/windows-cmd-helpers\.mjs \.\/scripts\/$/m, ); expectPatternAfterInstall( - /^COPY(?:\s+--chown=\S+)?\s+tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsdown\.config\.ts vitest\.config\.ts openclaw\.mjs \.\/$/m, + /^COPY(?:\s+--chown=\S+)?\s+\.oxlintrc\.json tsconfig\.json tsconfig\.plugin-sdk\.dts\.json tsconfig\.oxlint\*\.json tsdown\.config\.ts vitest\.config\.ts openclaw\.mjs \.\/$/m, ); expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+src \.\/src$/m); expectPatternAfterInstall(/^COPY(?:\s+--chown=\S+)?\s+test \.\/test$/m); From 06fe67d7192a3b67f22ad7a8cf11a0e89758c1b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:41:08 +0100 Subject: [PATCH 12/19] test: type setup provider mocks (cherry picked from commit ea9da71f031679b23cb17468f00219c870685ff5) --- .../auth-choice.apply.plugin-provider.test.ts | 10 ++++++++-- src/wizard/setup.test.ts | 12 ++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index 963b0dbd3a3..5082fff2237 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -12,10 +12,14 @@ type ResolveProviderInstallCatalogEntry = typeof import("../plugins/provider-install-catalog.js").resolveProviderInstallCatalogEntry; type EnsureOnboardingPluginInstalled = typeof import("../commands/onboarding-plugin-install.js").ensureOnboardingPluginInstalled; +type ResolveManifestProviderAuthChoice = + typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoice; +type ResolvePluginSetupProvider = + typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginSetupProvider; const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); const resolvePluginSetupProvider = vi.hoisted(() => - vi.fn<() => ProviderPlugin | undefined>(() => undefined), + vi.fn(() => undefined), ); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), @@ -28,7 +32,9 @@ vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ runProviderModelSelectedHook, })); -const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined)); +const resolveManifestProviderAuthChoice = vi.hoisted(() => + vi.fn(() => undefined), +); vi.mock("../plugins/provider-auth-choices.js", () => ({ resolveManifestProviderAuthChoice, })); diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index bb0f08c6829..65b9c58c26f 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -14,6 +14,10 @@ type ResolveProviderPluginChoice = typeof import("../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice; type ResolvePluginProvidersRuntime = typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginProviders; +type ResolvePluginSetupProvider = + typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginSetupProvider; +type ResolveManifestProviderAuthChoice = + typeof import("../plugins/provider-auth-choices.js").resolveManifestProviderAuthChoice; type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDefaultModel; type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoice; @@ -23,8 +27,12 @@ const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })), ); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider")); -const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined)); -const resolvePluginSetupProvider = vi.hoisted(() => vi.fn(() => undefined)); +const resolveManifestProviderAuthChoice = vi.hoisted(() => + vi.fn(() => undefined), +); +const resolvePluginSetupProvider = vi.hoisted(() => + vi.fn(() => undefined), +); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn(() => null), ); From ab0d0f677b1d443b0e6aa7941087d950f40321ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 11:45:22 +0100 Subject: [PATCH 13/19] fix(ui): remove ineffective dynamic imports (cherry picked from commit b4ff9472063bbdf52ae818c5fa3a8f842609eded) --- ui/src/ui/chat/session-controls.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/chat/session-controls.ts b/ui/src/ui/chat/session-controls.ts index a1d3062b049..2cd5179d3b1 100644 --- a/ui/src/ui/chat/session-controls.ts +++ b/ui/src/ui/chat/session-controls.ts @@ -6,6 +6,8 @@ import { resolveChatModelOverrideValue, resolveChatModelSelectState, } from "../chat-model-select-state.ts"; +import { refreshVisibleToolsEffectiveForCurrentSession } from "../controllers/agents.ts"; +import { loadSessions } from "../controllers/sessions.ts"; import { pushUniqueTrimmedSelectOption } from "../select-options.ts"; import { parseAgentSessionKey } from "../session-key.ts"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts"; @@ -70,7 +72,6 @@ export function renderChatSessionSelect( } async function refreshSessionOptions(state: AppViewState) { - const { loadSessions } = await import("../controllers/sessions.ts"); await loadSessions(state as unknown as Parameters[0], { activeMinutes: 0, limit: 0, @@ -80,8 +81,6 @@ async function refreshSessionOptions(state: AppViewState) { } async function refreshVisibleToolsEffectiveForCurrentSessionLazy(state: AppViewState) { - const { refreshVisibleToolsEffectiveForCurrentSession } = - await import("../controllers/agents.ts"); return refreshVisibleToolsEffectiveForCurrentSession(state); } From 23710167cd19ece313c924a14cda00d6972acad6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:10 -0700 Subject: [PATCH 14/19] fix(cli): lazy load model commands --- src/cli/capability-cli.test.ts | 4 ++++ src/cli/capability-cli.ts | 2 +- src/cli/models-cli.test.ts | 6 ++++++ src/cli/models-cli.ts | 4 +++- src/cli/program/routed-command-definitions.ts | 21 ++++++++++++------- src/cli/program/routes.test.ts | 6 ++++++ 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 5e2e1e5c33b..24cfbb27ad3 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -170,6 +170,10 @@ vi.mock("../commands/models/list.js", () => ({ modelsStatusCommand: mocks.modelsStatusCommand as typeof import("../commands/models/list.js").modelsStatusCommand, })); +vi.mock("../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: + mocks.modelsStatusCommand as typeof import("../commands/models/list.status-command.js").modelsStatusCommand, +})); vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway as typeof import("../gateway/call.js").callGateway, diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index 8eb73b1d42c..a5ba86618b7 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -13,7 +13,6 @@ import { import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { modelsStatusCommand } from "../commands/models/list.js"; import { loadConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -683,6 +682,7 @@ async function buildModelProviders() { async function runModelAuthStatus() { const captured: string[] = []; + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: true }, { diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 30f3f2fc03f..6b46baa3f1a 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -49,6 +49,12 @@ vi.mock("../commands/models/list.js", () => ({ modelsListCommand: mocks.noopAsync, modelsStatusCommand: mocks.modelsStatusCommand, })); +vi.mock("../commands/models/list.list-command.js", () => ({ + modelsListCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: mocks.modelsStatusCommand, +})); vi.mock("../commands/models/auth.js", () => ({ modelsAuthAddCommand: mocks.modelsAuthAddCommand, modelsAuthLoginCommand: mocks.modelsAuthLoginCommand, diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 0773a55bb54..a769aae2113 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { modelsListCommand, modelsStatusCommand } from "../commands/models/list.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; @@ -35,6 +34,7 @@ export function registerModelsCli(program: Command) { .option("--plain", "Plain line output", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsListCommand } = await import("../commands/models/list.list-command.js"); await modelsListCommand(opts, defaultRuntime); }); }); @@ -71,6 +71,7 @@ export function registerModelsCli(program: Command) { const agent = resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); await runModelsCommand(async () => { + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: Boolean(opts.json), @@ -270,6 +271,7 @@ export function registerModelsCli(program: Command) { models.action(async (opts) => { await runModelsCommand(async () => { + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: Boolean(opts?.statusJson), diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts index 8557039ac19..09873d6cdd3 100644 --- a/src/cli/program/routed-command-definitions.ts +++ b/src/cli/program/routed-command-definitions.ts @@ -17,7 +17,8 @@ type RouteArgParser = (argv: string[]) => TArgs | null; type ParsedRouteArgs> = Exclude, null>; type ConfigCliModule = typeof import("../config-cli.js"); -type ModelsListModule = typeof import("../../commands/models/list.js"); +type ModelsListCommandModule = typeof import("../../commands/models/list.list-command.js"); +type ModelsStatusCommandModule = typeof import("../../commands/models/list.status-command.js"); export type RoutedCommandDefinition> = { parseArgs: TParse; @@ -36,16 +37,22 @@ function defineRoutedCommand>( } let configCliPromise: Promise | undefined; -let modelsListPromise: Promise | undefined; +let modelsListCommandPromise: Promise | undefined; +let modelsStatusCommandPromise: Promise | undefined; function loadConfigCli(): Promise { configCliPromise ??= import("../config-cli.js"); return configCliPromise; } -function loadModelsList(): Promise { - modelsListPromise ??= import("../../commands/models/list.js"); - return modelsListPromise; +function loadModelsListCommand(): Promise { + modelsListCommandPromise ??= import("../../commands/models/list.list-command.js"); + return modelsListCommandPromise; +} + +function loadModelsStatusCommand(): Promise { + modelsStatusCommandPromise ??= import("../../commands/models/list.status-command.js"); + return modelsStatusCommandPromise; } export const routedCommandDefinitions = { @@ -114,14 +121,14 @@ export const routedCommandDefinitions = { "models-list": defineRoutedCommand({ parseArgs: parseModelsListRouteArgs, runParsedArgs: async (args) => { - const { modelsListCommand } = await loadModelsList(); + const { modelsListCommand } = await loadModelsListCommand(); await modelsListCommand(args, defaultRuntime); }, }), "models-status": defineRoutedCommand({ parseArgs: parseModelsStatusRouteArgs, runParsedArgs: async (args) => { - const { modelsStatusCommand } = await loadModelsList(); + const { modelsStatusCommand } = await loadModelsStatusCommand(); await modelsStatusCommand(args, defaultRuntime); }, }), diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 631d564f865..468458762b3 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -23,6 +23,12 @@ vi.mock("../../commands/models/list.js", () => ({ modelsListCommand: modelsListCommandMock, modelsStatusCommand: modelsStatusCommandMock, })); +vi.mock("../../commands/models/list.list-command.js", () => ({ + modelsListCommand: modelsListCommandMock, +})); +vi.mock("../../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: modelsStatusCommandMock, +})); vi.mock("../daemon-cli/status.js", () => ({ runDaemonStatus: runDaemonStatusMock, From 8740ca7dee334f66216755422ee9c10b95637cde Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:28 -0700 Subject: [PATCH 15/19] fix(models): avoid registry for configured list --- .../list.list-command.forward-compat.test.ts | 56 ++--- src/commands/models/list.list-command.ts | 90 ++++++-- src/commands/models/list.registry-load.ts | 3 +- src/commands/models/list.registry.ts | 17 +- src/commands/models/list.row-sources.ts | 27 +-- src/commands/models/list.rows.test.ts | 8 +- src/commands/models/list.rows.ts | 210 ++++++++++++++---- 7 files changed, 292 insertions(+), 119 deletions(-) diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 0e539e0689c..55c12fa3317 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -147,6 +147,7 @@ function installModelsListCommandForwardCompatMocks() { vi.doMock("./list.provider-catalog.js", () => ({ hasProviderStaticCatalogForFilter: mocks.hasProviderStaticCatalogForFilter, + loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, })); vi.doMock("./list.manifest-catalog.js", () => ({ @@ -190,14 +191,27 @@ function installModelsListCommandForwardCompatMocks() { }, })); - vi.doMock("./list.runtime.js", () => ({ - ensureOpenClawModelsJson: mocks.ensureOpenClawModelsJson, - ensureAuthProfileStore: mocks.ensureAuthProfileStore, + vi.doMock("../../agents/auth-profiles/store.js", () => ({ + loadAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStore, + })); + + vi.doMock("../../agents/agent-paths.js", () => ({ resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, + })); + + vi.doMock("../../agents/auth-profiles/profile-list.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, + })); + + vi.doMock("../../agents/model-catalog.js", () => ({ loadModelCatalog: mocks.loadModelCatalog, - loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, + })); + + vi.doMock("../../agents/pi-embedded-runner/model.js", () => ({ resolveModelWithRegistry: mocks.resolveModelWithRegistry, + })); + + vi.doMock("../../agents/model-auth.js", () => ({ resolveEnvApiKey: vi.fn().mockReturnValue(undefined), resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), @@ -232,7 +246,7 @@ async function buildAllOpenAiCodexRows(opts: { supplementCatalog?: boolean } = { ), filter: { provider: "openai-codex" }, }; - const seenKeys = listRowsModule.appendDiscoveredRows({ + const seenKeys = await listRowsModule.appendDiscoveredRows({ rows: rows as never, models: loaded.models as never, modelRegistry: loaded.registry as never, @@ -256,17 +270,14 @@ beforeEach(() => { describe("modelsListCommand forward-compat", () => { describe("configured rows", () => { - it("passes provider filters into registry loading before row assembly", async () => { + it("keeps configured provider filters on the registry-free row path", async () => { const runtime = createRuntime(); await modelsListCommand({ json: true, provider: "moonshot" }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith( - mocks.resolvedConfig, - expect.objectContaining({ - providerFilter: "moonshot", - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.printModelTable).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("No models found."); }); it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => { @@ -345,17 +356,12 @@ describe("modelsListCommand forward-compat", () => { expect(codexPro?.tags).not.toContain("missing"); }); - it("loads model registry without source config persistence input", async () => { + it("does not load the model registry for configured-mode listing", async () => { const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith( - mocks.resolvedConfig, - expect.not.objectContaining({ - sourceConfig: expect.anything(), - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); }); it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { @@ -568,19 +574,15 @@ describe("modelsListCommand forward-compat", () => { ]); }); - it("keeps the registry path for provider filters without static catalog coverage", async () => { + it("does not fall back to the registry for provider filters without catalog coverage", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false); const runtime = createRuntime(); await modelsListCommand({ all: true, provider: "openrouter", json: true }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith( - mocks.resolvedConfig, - expect.objectContaining({ - providerFilter: "openrouter", - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("No models found."); }); it("includes provider-owned supplemental catalog rows with provider filters", async () => { @@ -748,7 +750,7 @@ describe("modelsListCommand forward-compat", () => { it("suppresses direct openai gpt-5.3-codex-spark rows in --all output", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); const rows: unknown[] = []; - listRowsModule.appendDiscoveredRows({ + await listRowsModule.appendDiscoveredRows({ rows: rows as never, models: [ { diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index be3f411c0d5..52cba5f94e5 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -5,13 +5,6 @@ import type { RuntimeEnv } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveConfiguredEntries } from "./list.configured.js"; import { formatErrorWithStack } from "./list.errors.js"; -import { hasProviderStaticCatalogForFilter } from "./list.provider-catalog.js"; -import { loadConfiguredListModelRegistry, loadListModelRegistry } from "./list.registry-load.js"; -import { - appendAllModelRowSources, - appendConfiguredModelRowSources, - modelRowSourcesRequireRegistry, -} from "./list.row-sources.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; import { loadModelsConfigWithSource } from "./load-config.js"; @@ -19,6 +12,45 @@ import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; +type RegistryLoadModule = typeof import("./list.registry-load.js"); +type RowSourcesModule = typeof import("./list.row-sources.js"); +type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); + +let registryLoadModulePromise: Promise | undefined; +let rowSourcesModulePromise: Promise | undefined; +let providerCatalogModulePromise: Promise | undefined; + +function loadRegistryLoadModule(): Promise { + registryLoadModulePromise ??= import("./list.registry-load.js"); + return registryLoadModulePromise; +} + +function loadRowSourcesModule(): Promise { + rowSourcesModulePromise ??= import("./list.row-sources.js"); + return rowSourcesModulePromise; +} + +function loadProviderCatalogModule(): Promise { + providerCatalogModulePromise ??= import("./list.provider-catalog.js"); + return providerCatalogModulePromise; +} + +function modelRowSourcesRequireRegistry(params: { + all?: boolean; + providerFilter?: string; + useManifestCatalogFastPath: boolean; + useProviderCatalogFastPath: boolean; + useProviderIndexCatalogFastPath: boolean; +}): boolean { + if (!params.all) { + return false; + } + if (params.providerFilter) { + return false; + } + return true; +} + export async function modelsListCommand( opts: { all?: boolean; @@ -48,12 +80,16 @@ export async function modelsListCommand( if (providerFilter === null) { return; } - const { ensureAuthProfileStore, resolveOpenClawAgentDir } = await import("./list.runtime.js"); + const [{ loadAuthProfileStoreWithoutExternalProfiles }, { resolveOpenClawAgentDir }] = + await Promise.all([ + import("../../agents/auth-profiles/store.js"), + import("../../agents/agent-paths.js"), + ]); const { resolvedConfig: cfg } = await loadModelsConfigWithSource({ commandName: "models list", runtime, }); - const authStore = ensureAuthProfileStore(); + const authStore = loadAuthProfileStoreWithoutExternalProfiles(); const agentDir = resolveOpenClawAgentDir(); let modelRegistry: ModelRegistry | undefined; @@ -69,16 +105,24 @@ export async function modelsListCommand( manifestCatalogRows = loadStaticManifestCatalogRowsForList({ cfg, providerFilter }); } const useManifestCatalogFastPath = manifestCatalogRows.length > 0; - const useProviderCatalogFastPath = - !useManifestCatalogFastPath && opts.all && providerFilter - ? await hasProviderStaticCatalogForFilter({ cfg, providerFilter }) - : false; - if (!useManifestCatalogFastPath && !useProviderCatalogFastPath && opts.all && providerFilter) { + if (!useManifestCatalogFastPath && opts.all && providerFilter) { const { loadProviderIndexCatalogRowsForList } = await import("./list.provider-index-catalog.js"); providerIndexCatalogRows = loadProviderIndexCatalogRowsForList({ cfg, providerFilter }); } const useProviderIndexCatalogFastPath = providerIndexCatalogRows.length > 0; + const useProviderCatalogFastPath = await (async () => { + if ( + useManifestCatalogFastPath || + useProviderIndexCatalogFastPath || + !opts.all || + !providerFilter + ) { + return false; + } + const { hasProviderStaticCatalogForFilter } = await loadProviderCatalogModule(); + return hasProviderStaticCatalogForFilter({ cfg, providerFilter }); + })(); const shouldLoadRegistry = modelRowSourcesRequireRegistry({ all: opts.all, providerFilter, @@ -87,6 +131,7 @@ export async function modelsListCommand( useProviderIndexCatalogFastPath, }); const loadRegistryState = async () => { + const { loadListModelRegistry } = await loadRegistryLoadModule(); const loaded = await loadListModelRegistry(cfg, { providerFilter }); modelRegistry = loaded.registry; discoveredKeys = loaded.discoveredKeys; @@ -96,7 +141,8 @@ export async function modelsListCommand( try { if (shouldLoadRegistry) { await loadRegistryState(); - } else if (!opts.all) { + } else if (!opts.all && opts.local) { + const { loadConfiguredListModelRegistry } = await loadRegistryLoadModule(); const loaded = loadConfiguredListModelRegistry(cfg, entries, { providerFilter }); modelRegistry = loaded.registry; discoveredKeys = loaded.discoveredKeys; @@ -123,6 +169,7 @@ export async function modelsListCommand( const rows: ModelRow[] = []; if (opts.all) { + const { appendAllModelRowSources } = await loadRowSourcesModule(); let rowContext = buildRowContext( useManifestCatalogFastPath || useProviderCatalogFastPath || useProviderIndexCatalogFastPath, ); @@ -158,17 +205,12 @@ export async function modelsListCommand( }); } } else { - const registry = modelRegistry; - if (!registry) { - runtime.error("Model registry unavailable."); - process.exitCode = 1; - return; - } - appendConfiguredModelRowSources({ + const { appendConfiguredModelRowSources } = await loadRowSourcesModule(); + await appendConfiguredModelRowSources({ rows, entries, - modelRegistry: registry, - context: buildRowContext(false), + modelRegistry, + context: buildRowContext(!modelRegistry), }); } diff --git a/src/commands/models/list.registry-load.ts b/src/commands/models/list.registry-load.ts index e4114be6a04..d7e683c3c38 100644 --- a/src/commands/models/list.registry-load.ts +++ b/src/commands/models/list.registry-load.ts @@ -1,9 +1,10 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; +import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadModelRegistry } from "./list.registry.js"; -import { discoverAuthStorage, discoverModels, resolveOpenClawAgentDir } from "./list.runtime.js"; import type { ConfiguredEntry } from "./list.types.js"; import { modelKey } from "./shared.js"; diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 29b4e67ddd5..972b8ab43ac 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -1,7 +1,15 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; +import { listProfilesForProvider } from "../../agents/auth-profiles/profile-list.js"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { + hasUsableCustomProviderApiKey, + resolveAwsSdkEnvVarName, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; +import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js"; import { @@ -10,15 +18,6 @@ import { shouldFallbackToAuthHeuristics, } from "./list.errors.js"; import { toModelRow as toModelRowBase } from "./list.model-row.js"; -import { - discoverAuthStorage, - discoverModels, - hasUsableCustomProviderApiKey, - listProfilesForProvider, - resolveAwsSdkEnvVarName, - resolveEnvApiKey, - resolveOpenClawAgentDir, -} from "./list.runtime.js"; import type { ModelRow } from "./list.types.js"; import { modelKey } from "./shared.js"; diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index bb868a5706d..ec5dc6363f0 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -37,12 +37,7 @@ export function modelRowSourcesRequireRegistry(params: { if (!params.all) { return false; } - if ( - params.providerFilter && - (params.useManifestCatalogFastPath || - params.useProviderCatalogFastPath || - params.useProviderIndexCatalogFastPath) - ) { + if (params.providerFilter) { return false; } return true; @@ -58,14 +53,14 @@ export async function appendAllModelRowSources( params.useProviderIndexCatalogFastPath) ) { let seenKeys = new Set(); - appendConfiguredProviderRows({ + await appendConfiguredProviderRows({ rows: params.rows, context: params.context, seenKeys, }); let catalogRows = 0; if (params.useManifestCatalogFastPath) { - catalogRows = appendManifestCatalogRows({ + catalogRows = await appendManifestCatalogRows({ rows: params.rows, context: params.context, seenKeys, @@ -81,7 +76,7 @@ export async function appendAllModelRowSources( }); } if (catalogRows === 0 && params.useProviderIndexCatalogFastPath) { - catalogRows = appendModelCatalogRows({ + catalogRows = await appendModelCatalogRows({ rows: params.rows, context: params.context, seenKeys, @@ -92,7 +87,7 @@ export async function appendAllModelRowSources( if (!params.modelRegistry) { return { requiresRegistryFallback: true }; } - appendDiscoveredRows({ + await appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry.getAll(), modelRegistry: params.modelRegistry, @@ -102,14 +97,14 @@ export async function appendAllModelRowSources( return { requiresRegistryFallback: false }; } - const seenKeys = appendDiscoveredRows({ + const seenKeys = await appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry?.getAll() ?? [], modelRegistry: params.modelRegistry, context: params.context, }); - appendConfiguredProviderRows({ + await appendConfiguredProviderRows({ rows: params.rows, context: params.context, seenKeys, @@ -133,11 +128,11 @@ export async function appendAllModelRowSources( return { requiresRegistryFallback: false }; } -export function appendConfiguredModelRowSources(params: { +export async function appendConfiguredModelRowSources(params: { rows: ModelRow[]; entries: ConfiguredEntry[]; - modelRegistry: ModelRegistry; + modelRegistry?: ModelRegistry; context: RowBuilderContext; -}): void { - appendConfiguredRows(params); +}): Promise { + await appendConfiguredRows(params); } diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index e2f77819e47..663596dc9f6 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -23,9 +23,15 @@ vi.mock("../../agents/model-suppression.js", () => ({ shouldSuppressBuiltInModel: mocks.shouldSuppressBuiltInModel, })); -vi.mock("./list.runtime.js", () => ({ +vi.mock("./list.provider-catalog.js", () => ({ loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, +})); + +vi.mock("../../agents/auth-profiles/profile-list.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, +})); + +vi.mock("../../agents/model-auth.js", () => ({ resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), resolveEnvApiKey: vi.fn().mockReturnValue(null), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 18d6a0097a5..df6b9bd5dc5 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -2,22 +2,27 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { + hasUsableCustomProviderApiKey, + resolveAwsSdkEnvVarName, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; import type { ListRowModel } from "./list.model-row.js"; -import { toModelRow } from "./list.registry.js"; -import { - loadModelCatalog, - loadProviderCatalogModelsForList, - resolveModelWithRegistry, -} from "./list.runtime.js"; +import { toModelRow } from "./list.model-row.js"; import type { ConfiguredEntry, ModelRow } from "./list.types.js"; import { isLocalBaseUrl, modelKey } from "./shared.js"; type ConfiguredByKey = Map; +type ModelCatalogModule = typeof import("../../agents/model-catalog.js"); +type ModelResolverModule = typeof import("../../agents/pi-embedded-runner/model.js"); +type ProfileListModule = typeof import("../../agents/auth-profiles/profile-list.js"); +type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); +type SyntheticAuthModule = typeof import("../../plugins/synthetic-auth.runtime.js"); type RowFilter = { provider?: string; @@ -35,6 +40,37 @@ export type RowBuilderContext = { skipRuntimeModelSuppression?: boolean; }; +let modelCatalogModulePromise: Promise | undefined; +let modelResolverModulePromise: Promise | undefined; +let profileListModulePromise: Promise | undefined; +let providerCatalogModulePromise: Promise | undefined; +let syntheticAuthModulePromise: Promise | undefined; + +function loadModelCatalogModule(): Promise { + modelCatalogModulePromise ??= import("../../agents/model-catalog.js"); + return modelCatalogModulePromise; +} + +function loadModelResolverModule(): Promise { + modelResolverModulePromise ??= import("../../agents/pi-embedded-runner/model.js"); + return modelResolverModulePromise; +} + +function loadProfileListModule(): Promise { + profileListModulePromise ??= import("../../agents/auth-profiles/profile-list.js"); + return profileListModulePromise; +} + +function loadProviderCatalogModule(): Promise { + providerCatalogModulePromise ??= import("./list.provider-catalog.js"); + return providerCatalogModulePromise; +} + +function loadSyntheticAuthModule(): Promise { + syntheticAuthModulePromise ??= import("../../plugins/synthetic-auth.runtime.js"); + return syntheticAuthModulePromise; +} + function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) { if (filter.provider && normalizeProviderId(model.provider) !== filter.provider) { return false; @@ -45,13 +81,44 @@ function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl? return true; } -function buildRow(params: { +async function hasAuthForProvider(params: { + provider: string; + cfg: OpenClawConfig; + authStore: AuthProfileStore; +}): Promise { + const { listProfilesForProvider } = await loadProfileListModule(); + if (listProfilesForProvider(params.authStore, params.provider).length > 0) { + return true; + } + if (params.provider === "amazon-bedrock" && resolveAwsSdkEnvVarName()) { + return true; + } + if (resolveEnvApiKey(params.provider)) { + return true; + } + if (hasUsableCustomProviderApiKey(params.cfg, params.provider)) { + return true; + } + const { resolveRuntimeSyntheticAuthProviderRefs } = await loadSyntheticAuthModule(); + return resolveRuntimeSyntheticAuthProviderRefs().includes(params.provider); +} + +async function buildRow(params: { model: ListRowModel; key: string; context: RowBuilderContext; allowProviderAvailabilityFallback?: boolean; -}): ModelRow { +}): Promise { const configured = params.context.configuredByKey.get(params.key); + const shouldResolveProviderAuth = + params.context.availableKeys === undefined || params.allowProviderAvailabilityFallback === true; + const hasProviderAuth = shouldResolveProviderAuth + ? await hasAuthForProvider({ + provider: params.model.provider, + cfg: params.context.cfg, + authStore: params.context.authStore, + }) + : false; return toModelRow({ model: params.model, key: params.key, @@ -61,6 +128,7 @@ function buildRow(params: { cfg: params.context.cfg, authStore: params.context.authStore, allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback ?? false, + hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined, }); } @@ -79,14 +147,14 @@ function shouldSuppressListModel(params: { }); } -function appendVisibleRow(params: { +async function appendVisibleRow(params: { rows: ModelRow[]; model: ListRowModel; key: string; context: RowBuilderContext; seenKeys?: Set; allowProviderAvailabilityFallback?: boolean; -}): boolean { +}): Promise { if (params.seenKeys?.has(params.key)) { return false; } @@ -97,7 +165,7 @@ function appendVisibleRow(params: { return false; } params.rows.push( - buildRow({ + await buildRow({ model: params.model, key: params.key, context: params.context, @@ -153,13 +221,49 @@ function shouldListConfiguredProviderModel(params: { return params.providerConfig.api !== undefined || params.model.api !== undefined; } -export function appendDiscoveredRows(params: { +function findConfiguredProviderModel(params: { + cfg: OpenClawConfig; + provider: string; + modelId: string; +}): ListRowModel | undefined { + const providerConfig = params.cfg.models?.providers?.[params.provider]; + const configuredModel = providerConfig?.models?.find((model) => model.id === params.modelId); + if (!providerConfig || !configuredModel) { + return undefined; + } + return toConfiguredProviderListModel({ + provider: params.provider, + providerConfig, + model: configuredModel, + }); +} + +function toFallbackConfiguredListModel(entry: ConfiguredEntry, cfg: OpenClawConfig): ListRowModel { + return ( + findConfiguredProviderModel({ + cfg, + provider: entry.ref.provider, + modelId: entry.ref.model, + }) ?? { + provider: entry.ref.provider, + id: entry.ref.model, + name: entry.ref.model, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_TOKENS, + } + ); +} + +export async function appendDiscoveredRows(params: { rows: ModelRow[]; models: Model[]; modelRegistry?: ModelRegistry; context: RowBuilderContext; -}): Set { +}): Promise> { const seenKeys = new Set(); + const modelResolver = params.modelRegistry + ? (await loadModelResolverModule()).resolveModelWithRegistry + : undefined; const sorted = [...params.models].toSorted((a, b) => { const providerCompare = a.provider.localeCompare(b.provider); if (providerCompare !== 0) { @@ -170,20 +274,21 @@ export function appendDiscoveredRows(params: { for (const model of sorted) { const key = modelKey(model.provider, model.id); - const resolvedModel = params.modelRegistry - ? resolveModelWithRegistry({ - provider: model.provider, - modelId: model.id, - modelRegistry: params.modelRegistry, - cfg: params.context.cfg, - agentDir: params.context.agentDir, - }) - : undefined; + const resolvedModel = + params.modelRegistry && modelResolver + ? modelResolver({ + provider: model.provider, + modelId: model.id, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + agentDir: params.context.agentDir, + }) + : undefined; const rowModel = resolvedModel && modelKey(resolvedModel.provider, resolvedModel.id) === key ? resolvedModel : model; - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model: rowModel, key, @@ -195,11 +300,11 @@ export function appendDiscoveredRows(params: { return seenKeys; } -export function appendConfiguredProviderRows(params: { +export async function appendConfiguredProviderRows(params: { rows: ModelRow[]; context: RowBuilderContext; seenKeys: Set; -}): void { +}): Promise { for (const [provider, providerConfig] of Object.entries( params.context.cfg.models?.providers ?? {}, )) { @@ -213,7 +318,7 @@ export function appendConfiguredProviderRows(params: { providerConfig, model: configuredModel, }); - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -225,17 +330,17 @@ export function appendConfiguredProviderRows(params: { } } -export function appendModelCatalogRows(params: { +export async function appendModelCatalogRows(params: { rows: ModelRow[]; context: RowBuilderContext; seenKeys: Set; catalogRows: readonly NormalizedModelCatalogRow[]; -}): number { +}): Promise { let appended = 0; for (const catalogRow of params.catalogRows) { const key = modelKey(catalogRow.provider, catalogRow.id); if ( - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model: toManifestCatalogListModel(catalogRow), key, @@ -255,7 +360,7 @@ export function appendManifestCatalogRows(params: { context: RowBuilderContext; seenKeys: Set; manifestRows: readonly NormalizedModelCatalogRow[]; -}): number { +}): Promise { return appendModelCatalogRows({ ...params, catalogRows: params.manifestRows, @@ -268,6 +373,10 @@ export async function appendCatalogSupplementRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { + const [{ loadModelCatalog }, { resolveModelWithRegistry }] = await Promise.all([ + loadModelCatalogModule(), + loadModelResolverModule(), + ]); const catalog = await loadModelCatalog({ config: params.context.cfg, readOnly: true }); for (const entry of catalog) { if ( @@ -286,7 +395,7 @@ export async function appendCatalogSupplementRows(params: { if (!model) { continue; } - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -314,6 +423,7 @@ export async function appendProviderCatalogRows(params: { staticOnly?: boolean; }): Promise { let appended = 0; + const { loadProviderCatalogModelsForList } = await loadProviderCatalogModule(); for (const model of await loadProviderCatalogModelsForList({ cfg: params.context.cfg, agentDir: params.context.agentDir, @@ -322,7 +432,7 @@ export async function appendProviderCatalogRows(params: { })) { const key = modelKey(model.provider, model.id); if ( - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -337,12 +447,15 @@ export async function appendProviderCatalogRows(params: { return appended; } -export function appendConfiguredRows(params: { +export async function appendConfiguredRows(params: { rows: ModelRow[]; entries: ConfiguredEntry[]; - modelRegistry: ModelRegistry; + modelRegistry?: ModelRegistry; context: RowBuilderContext; -}) { +}): Promise { + const resolveModelWithRegistry = params.modelRegistry + ? (await loadModelResolverModule()).resolveModelWithRegistry + : undefined; for (const entry of params.entries) { if ( params.context.filter.provider && @@ -350,12 +463,15 @@ export function appendConfiguredRows(params: { ) { continue; } - const model = resolveModelWithRegistry({ - provider: entry.ref.provider, - modelId: entry.ref.model, - modelRegistry: params.modelRegistry, - cfg: params.context.cfg, - }); + const model = + params.modelRegistry && resolveModelWithRegistry + ? resolveModelWithRegistry({ + provider: entry.ref.provider, + modelId: entry.ref.model, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + }) + : toFallbackConfiguredListModel(entry, params.context.cfg); if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) { continue; } @@ -365,6 +481,17 @@ export function appendConfiguredRows(params: { if (model && shouldSuppressListModel({ model, context: params.context })) { continue; } + const shouldResolveProviderAuth = + model && + (params.context.availableKeys === undefined || + !params.context.discoveredKeys.has(modelKey(model.provider, model.id))); + const hasProviderAuth = shouldResolveProviderAuth + ? await hasAuthForProvider({ + provider: model.provider, + cfg: params.context.cfg, + authStore: params.context.authStore, + }) + : false; params.rows.push( toModelRow({ model, @@ -377,6 +504,7 @@ export function appendConfiguredRows(params: { allowProviderAvailabilityFallback: model ? !params.context.discoveredKeys.has(modelKey(model.provider, model.id)) : false, + hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined, }), ); } From aec1bfa0bbc82b1e123225f4026b57d913dea4cf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:48 -0700 Subject: [PATCH 16/19] fix(models): keep cold catalog lookup registry indexed --- .../models/list.provider-catalog.test.ts | 15 ++-- src/commands/models/list.provider-catalog.ts | 9 ++- src/plugins/synthetic-auth.runtime.test.ts | 72 +++++++++++-------- src/plugins/synthetic-auth.runtime.ts | 10 +-- 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index e599126a945..9752770dfa0 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -6,7 +6,7 @@ import { } from "./list.provider-catalog.js"; const providerDiscoveryMocks = vi.hoisted(() => ({ - loadPluginRegistrySnapshot: vi.fn(), + loadPluginRegistrySnapshotWithMetadata: vi.fn(), resolvePluginContributionOwners: vi.fn(), resolveProviderOwners: vi.fn(), resolveBundledProviderCompatPluginIds: vi.fn(), @@ -17,7 +17,8 @@ const providerDiscoveryMocks = vi.hoisted(() => ({ vi.mock("../../plugins/plugin-registry.js", () => ({ loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }), - loadPluginRegistrySnapshot: providerDiscoveryMocks.loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata: + providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata, resolvePluginContributionOwners: providerDiscoveryMocks.resolvePluginContributionOwners, resolveProviderOwners: providerDiscoveryMocks.resolveProviderOwners, })); @@ -115,8 +116,11 @@ const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider]; describe("loadProviderCatalogModelsForList", () => { beforeEach(() => { vi.clearAllMocks(); - providerDiscoveryMocks.loadPluginRegistrySnapshot.mockReturnValue({ - plugins: [], + providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { + plugins: [], + }, diagnostics: [], }); providerDiscoveryMocks.resolveProviderOwners.mockImplementation( @@ -197,9 +201,10 @@ describe("loadProviderCatalogModelsForList", () => { }), ).resolves.toEqual(["moonshot"]); - expect(providerDiscoveryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({ + expect(providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ config: baseParams.cfg, env: baseParams.env, + cache: true, }); expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); }); diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 26b33b272f8..14491a15beb 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { - loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata, resolvePluginContributionOwners, resolveProviderOwners, type PluginRegistrySnapshot, @@ -70,10 +70,15 @@ function resolveInstalledIndexPluginIdsForProviderFilter(params: { env?: NodeJS.ProcessEnv; providerFilter: string; }): string[] | undefined { - const index = loadPluginRegistrySnapshot({ + const snapshot = loadPluginRegistrySnapshotWithMetadata({ config: params.cfg, env: params.env, + cache: true, }); + if (snapshot.source !== "persisted" && snapshot.source !== "provided") { + return []; + } + const index = snapshot.snapshot; const pluginIds = [ ...collectMatchingContributionOwners(index, "providers", params.providerFilter, params.cfg), ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, params.cfg), diff --git a/src/plugins/synthetic-auth.runtime.test.ts b/src/plugins/synthetic-auth.runtime.test.ts index 0c3ecd81e61..1d51d37b8a3 100644 --- a/src/plugins/synthetic-auth.runtime.test.ts +++ b/src/plugins/synthetic-auth.runtime.test.ts @@ -2,26 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const getPluginRegistryState = vi.hoisted(() => vi.fn()); const pluginRegistryMocks = vi.hoisted(() => ({ - loadPluginManifestRegistryForInstalledIndex: vi.fn(), - loadPluginRegistrySnapshot: vi.fn((_params?: unknown) => ({ plugins: [] })), + loadPluginRegistrySnapshotWithMetadata: vi.fn((_params?: unknown) => ({ + source: "persisted", + snapshot: { plugins: [] }, + diagnostics: [], + })), })); vi.mock("./runtime-state.js", () => ({ getPluginRegistryState, })); -vi.mock("./manifest-registry-installed.js", () => ({ - loadPluginManifestRegistryForInstalledIndex: - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex, -})); - vi.mock("./plugin-registry.js", () => ({ - loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot, - loadPluginManifestRegistryForPluginRegistry: () => - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex({ - index: pluginRegistryMocks.loadPluginRegistrySnapshot({ cache: true }), - includeDisabled: true, - }), + loadPluginRegistrySnapshotWithMetadata: + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata, })); import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtime.js"; @@ -29,19 +23,24 @@ import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtim describe("synthetic auth runtime refs", () => { beforeEach(() => { getPluginRegistryState.mockReset(); - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex - .mockReset() - .mockReturnValue({ plugins: [] }); - pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset().mockReturnValue({ plugins: [] }); + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset().mockReturnValue({ + source: "persisted", + snapshot: { plugins: [] }, + diagnostics: [], + }); }); - it("uses manifest-owned synthetic auth refs before the runtime registry exists", () => { - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ - plugins: [ - { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, - { syntheticAuthRefs: ["remote-provider"] }, - { syntheticAuthRefs: [] }, - ], + it("uses persisted registry synthetic auth refs before the runtime registry exists", () => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { + plugins: [ + { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, + { syntheticAuthRefs: ["remote-provider"] }, + { syntheticAuthRefs: [] }, + ], + }, + diagnostics: [], }); expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([ @@ -49,13 +48,27 @@ describe("synthetic auth runtime refs", () => { "local-cli", "remote-provider", ]); - expect(pluginRegistryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({ cache: true }); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith({ - index: expect.anything(), - includeDisabled: true, + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ + cache: true, }); }); + it("does not derive the registry just to resolve synthetic auth refs", () => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "derived", + snapshot: { + plugins: [ + { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, + { syntheticAuthRefs: ["remote-provider"] }, + { syntheticAuthRefs: [] }, + ], + }, + diagnostics: [], + }); + + expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([]); + }); + it("prefers the active runtime registry when plugins are already loaded", () => { getPluginRegistryState.mockReturnValue({ activeRegistry: { @@ -84,7 +97,6 @@ describe("synthetic auth runtime refs", () => { }); expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual(["runtime-provider", "runtime-cli"]); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); - expect(pluginRegistryMocks.loadPluginRegistrySnapshot).not.toHaveBeenCalled(); + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/synthetic-auth.runtime.ts b/src/plugins/synthetic-auth.runtime.ts index efed10f1de3..b8d6b4b1c46 100644 --- a/src/plugins/synthetic-auth.runtime.ts +++ b/src/plugins/synthetic-auth.runtime.ts @@ -1,5 +1,5 @@ import { normalizeProviderId } from "../agents/provider-id.js"; -import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js"; import { getPluginRegistryState } from "./runtime-state.js"; function uniqueProviderRefs(values: readonly string[]): string[] { @@ -18,10 +18,12 @@ function uniqueProviderRefs(values: readonly string[]): string[] { } function resolveManifestSyntheticAuthProviderRefs(): string[] { + const result = loadPluginRegistrySnapshotWithMetadata({ cache: true }); + if (result.source !== "persisted" && result.source !== "provided") { + return []; + } return uniqueProviderRefs( - loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true }).plugins.flatMap( - (plugin) => plugin.syntheticAuthRefs ?? [], - ), + result.snapshot.plugins.flatMap((plugin) => plugin.syntheticAuthRefs ?? []), ); } From e76bac5d147c47fc522ddabc3cd26d023092629e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:25:04 -0700 Subject: [PATCH 17/19] fix(cli): lazy load plugin maintenance paths --- src/cli/plugins-cli.ts | 90 ++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index ffa833639c4..c6a20d1a2f9 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -5,51 +5,14 @@ import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { enablePluginInConfig } from "../plugins/enable.js"; -import { - loadInstalledPluginIndexInstallRecords, - removePluginInstallRecordFromRecords, - withoutPluginInstallRecords, - withPluginInstallRecords, -} from "../plugins/installed-plugin-index-records.js"; -import { listMarketplacePlugins } from "../plugins/marketplace.js"; -import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; -import { - buildAllPluginInspectReports, - buildPluginDiagnosticsReport, - buildPluginCompatibilityNotices, - buildPluginInspectReport, - buildPluginRegistrySnapshotReport, - formatPluginCompatibilityNotice, -} from "../plugins/status.js"; import type { PluginLogger } from "../plugins/types.js"; -import { - applyPluginUninstallDirectoryRemoval, - formatUninstallActionLabels, - formatUninstallSlotResetPreview, - planPluginUninstall, - resolveUninstallChannelConfigKeys, - UNINSTALL_ACTION_LABELS, -} from "../plugins/uninstall.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; -import { - applySlotSelectionForPlugin, - createPluginInstallLogger, - logSlotWarnings, -} from "./plugins-command-helpers.js"; -import { setPluginEnabledInConfig } from "./plugins-config.js"; -import { runPluginInstallCommand } from "./plugins-install-command.js"; -import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js"; import { formatPluginLine } from "./plugins-list-format.js"; -import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js"; -import { resolvePluginUninstallId } from "./plugins-uninstall-selection.js"; -import { runPluginUpdateCommand } from "./plugins-update-command.js"; -import { promptYesNo } from "./prompt.js"; export type PluginsListOptions = { json?: boolean; @@ -182,7 +145,8 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .option("--enabled", "Only show enabled plugins", false) .option("--verbose", "Show detailed entries", false) - .action((opts: PluginsListOptions) => { + .action(async (opts: PluginsListOptions) => { + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); const cfg = loadConfig(); const report = buildPluginRegistrySnapshotReport({ config: cfg, @@ -290,6 +254,14 @@ export function registerPluginsCli(program: Command) { .option("--all", "Inspect all plugins") .option("--json", "Print JSON") .action(async (id: string | undefined, opts: PluginInspectOptions) => { + const { + buildAllPluginInspectReports, + buildPluginDiagnosticsReport, + buildPluginInspectReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); + const { loadInstalledPluginIndexInstallRecords } = + await import("../plugins/installed-plugin-index-records.js"); const cfg = loadConfig(); const installRecords = await loadInstalledPluginIndexInstallRecords(); const report = buildPluginDiagnosticsReport({ @@ -523,6 +495,11 @@ export function registerPluginsCli(program: Command) { .description("Enable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { + const { enablePluginInConfig } = await import("../plugins/enable.js"); + const { applySlotSelectionForPlugin, logSlotWarnings } = + await import("./plugins-command-helpers.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const enableResult = enablePluginInConfig(cfg, id); @@ -557,6 +534,9 @@ export function registerPluginsCli(program: Command) { .description("Disable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { + const { setPluginEnabledInConfig } = await import("./plugins-config.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const next = setPluginEnabledInConfig(cfg, id, false); @@ -583,6 +563,27 @@ export function registerPluginsCli(program: Command) { .option("--force", "Skip confirmation prompt", false) .option("--dry-run", "Show what would be removed without making changes", false) .action(async (id: string, opts: PluginUninstallOptions) => { + const { + loadInstalledPluginIndexInstallRecords, + removePluginInstallRecordFromRecords, + withoutPluginInstallRecords, + withPluginInstallRecords, + } = await import("../plugins/installed-plugin-index-records.js"); + const { buildPluginDiagnosticsReport } = await import("../plugins/status.js"); + const { + formatUninstallActionLabels, + formatUninstallSlotResetPreview, + resolveUninstallChannelConfigKeys, + resolveUninstallDirectoryTarget, + UNINSTALL_ACTION_LABELS, + uninstallPlugin, + } = await import("../plugins/uninstall.js"); + const { commitPluginInstallRecordsWithConfig } = + await import("./plugins-install-record-commit.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); + const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js"); + const { promptYesNo } = await import("./prompt.js"); const snapshot = await readConfigFileSnapshot(); const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const installRecords = await loadInstalledPluginIndexInstallRecords(); @@ -744,6 +745,7 @@ export function registerPluginsCli(program: Command) { marketplace?: string; }, ) => { + const { runPluginInstallCommand } = await import("./plugins-install-command.js"); await runPluginInstallCommand({ raw, opts }); }, ); @@ -760,6 +762,7 @@ export function registerPluginsCli(program: Command) { false, ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { + const { runPluginUpdateCommand } = await import("./plugins-update-command.js"); await runPluginUpdateCommand({ id, opts }); }); @@ -769,6 +772,8 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .option("--refresh", "Rebuild the persisted registry from current plugin manifests", false) .action(async (opts: PluginRegistryOptions) => { + const { inspectPluginRegistry, refreshPluginRegistry } = + await import("../plugins/plugin-registry.js"); const cfg = loadConfig(); if (opts.refresh) { @@ -825,7 +830,12 @@ export function registerPluginsCli(program: Command) { plugins .command("doctor") .description("Report plugin load issues") - .action(() => { + .action(async () => { + const { + buildPluginCompatibilityNotices, + buildPluginDiagnosticsReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); const report = buildPluginDiagnosticsReport(); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); @@ -880,6 +890,8 @@ export function registerPluginsCli(program: Command) { .argument("", "Local marketplace path/repo or git/GitHub source") .option("--json", "Print JSON") .action(async (source: string, opts: PluginMarketplaceListOptions) => { + const { listMarketplacePlugins } = await import("../plugins/marketplace.js"); + const { createPluginInstallLogger } = await import("./plugins-command-helpers.js"); const result = await listMarketplacePlugins({ marketplace: source, logger: createPluginInstallLogger(), From be388084c2522ea269e91aa38494e51c7a66d1b2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:27:17 -0700 Subject: [PATCH 18/19] test(cli): cover lazy plugin inspect mocks --- src/cli/plugins-cli-test-helpers.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a6a45cf9b4d..4b96322d54b 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -56,6 +56,7 @@ export const loadPluginManifestRegistry: UnknownMock = vi.fn(); export const buildPluginSnapshotReport: UnknownMock = vi.fn(); export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn(); export const buildPluginInspectReport: UnknownMock = vi.fn(); +export const buildAllPluginInspectReports: UnknownMock = vi.fn(); export const buildPluginDiagnosticsReport: UnknownMock = vi.fn(); export const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); export const inspectPluginRegistry: AsyncUnknownMock = vi.fn(); @@ -248,6 +249,16 @@ vi.mock("../plugins/status.js", () => ({ buildPluginInspectReport, ...args, )) as (typeof import("../plugins/status.js"))["buildPluginInspectReport"], + buildAllPluginInspectReports: (( + ...args: Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]>, + ReturnType<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]> + >( + buildAllPluginInspectReports, + ...args, + )) as (typeof import("../plugins/status.js"))["buildAllPluginInspectReports"], buildPluginDiagnosticsReport: (( ...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginDiagnosticsReport"]> ) => From 5411f9d2179a569dab6b79134b2088000e9404db Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:45:26 -0700 Subject: [PATCH 19/19] test(models): stabilize provider index list mocks --- .../list.list-command.forward-compat.test.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 55c12fa3317..5d836302c6b 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -63,6 +63,7 @@ const mocks = vi.hoisted(() => { loadModelCatalog: vi.fn(), loadProviderCatalogModelsForList: vi.fn(), loadStaticManifestCatalogRowsForList: vi.fn(), + loadProviderIndexCatalogRowsForList: vi.fn(), hasProviderStaticCatalogForFilter: vi.fn(), resolveConfiguredEntries: vi.fn(), printModelTable: vi.fn(), @@ -91,6 +92,7 @@ function resetMocks() { mocks.loadModelCatalog.mockResolvedValue([]); mocks.loadProviderCatalogModelsForList.mockResolvedValue([]); mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]); + mocks.loadProviderIndexCatalogRowsForList.mockReturnValue([]); mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false); mocks.resolveConfiguredEntries.mockReturnValue({ entries: [ @@ -154,6 +156,10 @@ function installModelsListCommandForwardCompatMocks() { loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, })); + vi.doMock("./list.provider-index-catalog.js", () => ({ + loadProviderIndexCatalogRowsForList: mocks.loadProviderIndexCatalogRowsForList, + })); + vi.doMock("./list.registry-load.js", () => ({ loadListModelRegistry: async ( cfg: unknown, @@ -309,7 +315,6 @@ describe("modelsListCommand forward-compat", () => { }, ], }); - mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_MINI_MODEL }); const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); @@ -338,7 +343,6 @@ describe("modelsListCommand forward-compat", () => { }, ], }); - mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_PRO_MODEL }); const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); @@ -422,11 +426,6 @@ describe("modelsListCommand forward-compat", () => { it("does not require the all-model registry result for configured-mode listing", async () => { const previousExitCode = process.exitCode; process.exitCode = undefined; - mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [], - availableKeys: new Set(), - registry: undefined, - }); const runtime = createRuntime(); let observedExitCode: number | undefined; @@ -439,6 +438,7 @@ describe("modelsListCommand forward-compat", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(observedExitCode).toBeUndefined(); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); expect(mocks.printModelTable).toHaveBeenCalled(); }); }); @@ -515,12 +515,27 @@ describe("modelsListCommand forward-compat", () => { it("uses provider index preview rows when an installable provider is not installed", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); - mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false); + mocks.loadProviderIndexCatalogRowsForList.mockReturnValueOnce([ + { + provider: "moonshot", + id: "kimi-k2.6", + ref: "moonshot/kimi-k2.6", + mergeKey: "moonshot::kimi-k2.6", + name: "Kimi K2.6", + source: "provider-index", + input: ["text", "image"], + reasoning: false, + status: "available", + baseUrl: "https://api.moonshot.ai/v1", + contextWindow: 262_144, + }, + ]); const runtime = createRuntime(); await modelsListCommand({ all: true, provider: "moonshot", json: true }, runtime as never); expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.hasProviderStaticCatalogForFilter).not.toHaveBeenCalled(); expect(mocks.loadProviderCatalogModelsForList).not.toHaveBeenCalled(); expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([ expect.objectContaining({ @@ -587,6 +602,7 @@ describe("modelsListCommand forward-compat", () => { it("includes provider-owned supplemental catalog rows with provider filters", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [], availableKeys: new Set(["opencode-go/deepseek-v4-pro"]), @@ -681,6 +697,7 @@ describe("modelsListCommand forward-compat", () => { it("uses provider runtime metadata for discovered codex gpt-5.5 rows", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [ { @@ -798,6 +815,7 @@ describe("modelsListCommand forward-compat", () => { describe("provider filter canonicalization", () => { it("matches alias-valued discovered providers against canonical provider filters", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [ {