diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index b51c6697edc9..148e4f7a0262 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -3,6 +3,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { applyAuthChoiceLoadedPluginProvider, + prepareAuthChoiceLoadedPluginProvider, runProviderPluginAuthMethod, } from "../plugins/provider-auth-choice.js"; import type { ProviderPlugin } from "../plugins/types.js"; @@ -233,6 +234,52 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { })); }); + it("stages provider profiles until the caller commits them", async () => { + const provider = buildProvider(); + resolvePluginProviders.mockReturnValue([provider]); + resolveProviderPluginChoice.mockReturnValue({ + provider, + method: expectDefined(provider.auth[0], "provider.auth[0] test invariant"), + }); + + const prepared = await prepareAuthChoiceLoadedPluginProvider(buildParams()); + + expect(prepared?.authProfiles).toEqual([ + { + profileId: LOCAL_PROFILE_ID, + credential: { + type: "api_key", + provider: LOCAL_PROVIDER_ID, + key: LOCAL_API_KEY, + }, + }, + ]); + expect(upsertAuthProfile).not.toHaveBeenCalled(); + + await prepared?.persistAuthProfiles([ + { + profileId: LOCAL_PROFILE_ID, + credential: { + type: "api_key", + provider: LOCAL_PROVIDER_ID, + key: "test-key", + }, + }, + ]); + await prepared?.persistAuthProfiles(); + + expect(upsertAuthProfile).toHaveBeenCalledOnce(); + expect(upsertAuthProfile).toHaveBeenCalledWith({ + profileId: LOCAL_PROFILE_ID, + credential: { + type: "api_key", + provider: LOCAL_PROVIDER_ID, + key: "test-key", + }, + agentDir: "/tmp/agent", + }); + }); + it("returns an agent model override when default model application is deferred", async () => { const provider = buildProvider(); resolvePluginProviders.mockReturnValue([provider]); diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index 18d06308af10..26c242667848 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -1,7 +1,11 @@ // Applies an onboarding auth choice through provider setup flows and legacy normalization. import { formatCliCommand } from "../cli/command-format.js"; -import { applyAuthChoiceLoadedPluginProvider } from "../plugins/provider-auth-choice.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.types.js"; +import { prepareAuthChoiceLoadedPluginProvider } from "../plugins/provider-auth-choice.js"; +import type { + ApplyAuthChoiceParams, + ApplyAuthChoiceResult, + PreparedAuthChoiceResult, +} from "./auth-choice.apply.types.js"; import type { AuthChoice } from "./onboard-types.js"; async function normalizeLegacyChoice( @@ -71,10 +75,10 @@ async function formatDeprecatedProviderChoiceError( return `Auth choice ${JSON.stringify(authChoice)} is no longer supported. Use ${JSON.stringify(externalDeprecatedChoice.choiceId)} instead, or run ${formatCliCommand("openclaw onboard")} to choose interactively.`; } -/** Apply a selected auth choice, returning the mutated config or retry/model override signals. */ -export async function applyAuthChoice( +/** Prepare a selected auth choice without writing its returned provider profiles. */ +export async function prepareAuthChoice( params: ApplyAuthChoiceParams, -): Promise { +): Promise { const normalizedAuthChoice = (await normalizeLegacyChoice(params.authChoice, { config: params.config, @@ -88,7 +92,7 @@ export async function applyAuthChoice( normalizedProviderAuthChoice === params.authChoice ? params : { ...params, authChoice: normalizedProviderAuthChoice }; - const result = await applyAuthChoiceLoadedPluginProvider(normalizedParams); + const result = await prepareAuthChoiceLoadedPluginProvider(normalizedParams); if (result) { return result; } @@ -119,5 +123,22 @@ export async function applyAuthChoice( ); } - return { config: normalizedParams.config }; + return { + config: normalizedParams.config, + authProfiles: [], + persistAuthProfiles: async () => {}, + }; +} + +/** Apply a selected auth choice, returning the mutated config or retry/model override signals. */ +export async function applyAuthChoice( + params: ApplyAuthChoiceParams, +): Promise { + const prepared = await prepareAuthChoice(params); + await prepared.persistAuthProfiles(); + return { + config: prepared.config, + ...(prepared.agentModelOverride ? { agentModelOverride: prepared.agentModelOverride } : {}), + ...(prepared.retrySelection ? { retrySelection: true } : {}), + }; } diff --git a/src/commands/auth-choice.apply.types.ts b/src/commands/auth-choice.apply.types.ts index 78400e83be0f..265d8e6f93c1 100644 --- a/src/commands/auth-choice.apply.types.ts +++ b/src/commands/auth-choice.apply.types.ts @@ -1,5 +1,6 @@ // Shared types for applying auth-choice selections during onboarding and agent setup. import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderAuthResult } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; @@ -22,3 +23,8 @@ export type ApplyAuthChoiceResult = { agentModelOverride?: string; retrySelection?: boolean; }; + +export type PreparedAuthChoiceResult = ApplyAuthChoiceResult & { + authProfiles: ProviderAuthResult["profiles"]; + persistAuthProfiles: (profiles?: ProviderAuthResult["profiles"]) => Promise; +}; diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 72a8068634d4..c12ac5839ff3 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -1,5 +1,5 @@ // Public auth-choice barrel used by onboarding and agent setup commands. -export { applyAuthChoice } from "./auth-choice.apply.js"; +export { applyAuthChoice, prepareAuthChoice } from "./auth-choice.apply.js"; export { resolveDefaultModelCatalogFacts, resolveDefaultModelAuthStatus, diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index 22539280a267..cc6e5934e5f6 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -56,6 +56,21 @@ type ApplyProviderAuthChoiceResult = { retrySelection?: boolean; }; +type PreparedApplyProviderAuthChoiceResult = ApplyProviderAuthChoiceResult & { + authProfiles: ProviderAuthResult["profiles"]; + persistAuthProfiles: (profiles?: ProviderAuthResult["profiles"]) => Promise; +}; + +function preparedWithoutAuthProfiles( + result: ApplyProviderAuthChoiceResult, +): PreparedApplyProviderAuthChoiceResult { + return { + ...result, + authProfiles: [], + persistAuthProfiles: async () => {}, + }; +} + function formatModelRefForDisplay(modelRef: string, provider: ProviderPlugin): string { if (!provider.preserveLiteralProviderPrefix) { return modelRef; @@ -366,9 +381,75 @@ export async function runProviderPluginAuthMethod(params: { }; } -export async function applyAuthChoiceLoadedPluginProvider( +async function prepareProviderPluginAuthMethod( + params: Parameters[0], +): Promise<{ + config: OpenClawConfig; + defaultModel?: string; + authProfiles: ProviderAuthResult["profiles"]; + persistAuthProfiles: (profiles?: ProviderAuthResult["profiles"]) => Promise; +}> { + const agentId = params.agentId ?? resolveDefaultAgentId(params.config); + const agentDir = params.agentDir ?? resolveAgentDir(params.config, agentId); + const workspaceDir = + params.workspaceDir ?? + resolveAgentWorkspaceDir(params.config, agentId) ?? + resolveDefaultAgentWorkspaceDir(); + const result = await runProviderPluginAuthMethodUnpersisted({ + config: params.config, + env: params.env, + runtime: params.runtime, + prompter: params.prompter, + method: params.method, + agentDir, + workspaceDir, + ...(params.signal ? { signal: params.signal } : {}), + ...(params.isRemote !== undefined ? { isRemote: params.isRemote } : {}), + secretInputMode: params.secretInputMode, + allowSecretRefPrompt: params.allowSecretRefPrompt, + opts: params.opts, + }); + + if (params.emitNotes !== false && result.notes && result.notes.length > 0) { + await params.prompter.note(result.notes.join("\n"), "Provider notes"); + } + + const nextConfig = applyProviderPluginAuthMethodResultConfig({ + config: params.config, + result, + }); + const defaultModel = result.defaultModel + ? normalizeAgentModelRefForConfig(result.defaultModel) + : undefined; + + let profilesPersisted = false; + const persistAuthProfiles = async (profiles = result.profiles) => { + if (profilesPersisted) { + return; + } + await params.beforePersistentEffect?.(); + for (const profile of profiles) { + const { profileId, credential } = profile; + await upsertAuthProfileWithLockOrThrow({ + profileId, + credential, + agentDir, + }); + } + profilesPersisted = true; + }; + + return { + config: nextConfig, + ...(defaultModel ? { defaultModel } : {}), + authProfiles: result.profiles, + persistAuthProfiles, + }; +} + +export async function prepareAuthChoiceLoadedPluginProvider( params: ApplyProviderAuthChoiceParams, -): Promise { +): Promise { const agentId = params.agentId ?? resolveDefaultAgentId(params.config); const workspaceDir = params.workspaceDir ?? @@ -407,7 +488,7 @@ export async function applyAuthChoiceLoadedPluginProvider( `${safeLabel} plugin is disabled (${enableResult.reason ?? "blocked"}).`, safeLabel, ); - return { config: nextConfig }; + return preparedWithoutAuthProfiles({ config: nextConfig }); } enabledConfig = enableResult.config; } @@ -466,7 +547,7 @@ export async function applyAuthChoiceLoadedPluginProvider( workspaceDir, }); if (!installResult.installed) { - return { config: installResult.cfg, retrySelection: true }; + return preparedWithoutAuthProfiles({ config: installResult.cfg, retrySelection: true }); } nextConfig = installResult.cfg; providers = resolveScopedRuntimeProviders(nextConfig); @@ -476,14 +557,16 @@ export async function applyAuthChoiceLoadedPluginProvider( }); } if (!resolved) { - return nextConfig === params.config ? null : { config: nextConfig, retrySelection: true }; + return nextConfig === params.config + ? null + : preparedWithoutAuthProfiles({ config: nextConfig, retrySelection: true }); } if (nextConfig === params.config && enabledConfig !== params.config) { nextConfig = enabledConfig; } const configBeforeProviderAuth = nextConfig; - const applied = await runProviderPluginAuthMethod({ + const applied = await prepareProviderPluginAuthMethod({ config: nextConfig, env: params.env, runtime: params.runtime, @@ -527,13 +610,37 @@ export async function applyAuthChoiceLoadedPluginProvider( }); }, }); - return { config: nextConfig }; + return { + config: nextConfig, + authProfiles: applied.authProfiles, + persistAuthProfiles: applied.persistAuthProfiles, + }; } nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); agentModelOverride = selectedModel; } - return { config: nextConfig, agentModelOverride }; + return { + config: nextConfig, + agentModelOverride, + authProfiles: applied.authProfiles, + persistAuthProfiles: applied.persistAuthProfiles, + }; +} + +export async function applyAuthChoiceLoadedPluginProvider( + params: ApplyProviderAuthChoiceParams, +): Promise { + const prepared = await prepareAuthChoiceLoadedPluginProvider(params); + if (!prepared) { + return null; + } + await prepared.persistAuthProfiles(); + return { + config: prepared.config, + ...(prepared.agentModelOverride ? { agentModelOverride: prepared.agentModelOverride } : {}), + ...(prepared.retrySelection ? { retrySelection: true } : {}), + }; } async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise { const updated = await upsertAuthProfileWithLock(params); diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index aa585c4efebc..25429c90aa0e 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -5364,6 +5364,230 @@ describe("verifySetupInference", () => { expect(runEmbeddedAgent).toHaveBeenCalledOnce(); }); + it("returns a refreshed staged profile without changing the configured agent store", async () => { + const stateDir = await makeTempDir(); + const agentDir = path.join(stateDir, "configured-agent"); + const profileId = "openai:default"; + await upsertAuthProfileWithLock({ + profileId, + credential: { + type: "oauth", + provider: "openai", + access: "dummy", + refresh: "dummy", + expires: Date.now() + 3_600_000, + }, + agentDir, + }); + const runEmbeddedAgent = vi.fn( + async (params: { + agentDir?: string; + onSuccessfulAuthBinding?: (binding: AgentExecutionAuthBinding) => void; + }) => { + expect(params.agentDir).toBeDefined(); + expect(params.agentDir).not.toBe(agentDir); + expect(readAuthProfileStoreForTest(params.agentDir!).profiles[profileId]).toEqual({ + type: "oauth", + provider: "openai", + access: "fake", + refresh: "fake", + expires: expect.any(Number), + }); + await updateAuthProfileStoreWithLock({ + agentDir: params.agentDir!, + updater: ({ profiles }) => { + profiles[profileId] = { + type: "oauth", + provider: "openai", + access: "sample", + refresh: "sample", + expires: Date.now() + 7_200_000, + }; + return true; + }, + }); + return successfulRun("openai", "gpt-5.5", { + authProfileId: profileId, + onSuccessfulAuthBinding: params.onSuccessfulAuthBinding, + }); + }, + ); + + try { + const result = await verifySetupInferenceConfig({ + config: { + auth: { + profiles: { [profileId]: { provider: "openai", mode: "oauth" } }, + order: { openai: [profileId] }, + }, + agents: { + list: [ + { + id: "main", + default: true, + agentDir, + model: { primary: `openai/gpt-5.5@${profileId}` }, + }, + ], + }, + }, + authProfiles: [ + { + profileId, + credential: { + type: "oauth", + provider: "openai", + access: "fake", + refresh: "fake", + expires: Date.now() + 3_600_000, + }, + }, + ], + runtime, + deps: { + runEmbeddedAgent: runEmbeddedAgent as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ + ok: true, + modelRef: "openai/gpt-5.5", + authProfiles: [ + { + profileId, + credential: { + type: "oauth", + provider: "openai", + access: "sample", + refresh: "sample", + expires: expect.any(Number), + }, + }, + ], + }); + expect(readAuthProfileStoreForTest(agentDir).profiles[profileId]).toEqual({ + type: "oauth", + provider: "openai", + access: "dummy", + refresh: "dummy", + expires: expect.any(Number), + }); + } finally { + await removeOAuthTestTempRoot(stateDir); + } + }); + + it("returns a refreshed staged profile when the live inference test fails", async () => { + const profileId = "openai:default"; + const runEmbeddedAgent = vi.fn(async (params: { agentDir?: string }) => { + expect(params.agentDir).toBeDefined(); + await updateAuthProfileStoreWithLock({ + agentDir: params.agentDir!, + updater: ({ profiles }) => { + profiles[profileId] = { + type: "oauth", + provider: "openai", + access: "sample", + refresh: "sample", + expires: Date.now() + 7_200_000, + }; + return true; + }, + }); + throw new Error("request timed out"); + }); + + const result = await verifySetupInferenceConfig({ + config: { + auth: { + profiles: { [profileId]: { provider: "openai", mode: "oauth" } }, + order: { openai: [profileId] }, + }, + agents: { defaults: { model: `openai/gpt-5.5@${profileId}` } }, + }, + authProfiles: [ + { + profileId, + credential: { + type: "oauth", + provider: "openai", + access: "fake", + refresh: "fake", + expires: Date.now() + 3_600_000, + }, + }, + ], + runtime, + deps: { + runEmbeddedAgent: runEmbeddedAgent as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ + ok: false, + status: "timeout", + authProfiles: [ + { + profileId, + credential: { + type: "oauth", + provider: "openai", + access: "sample", + refresh: "sample", + expires: expect.any(Number), + }, + }, + ], + }); + }); + + it("rejects a staged credential that differs from the configured profile pin", async () => { + const runEmbeddedAgent = vi.fn(); + const result = await verifySetupInferenceConfig({ + config: { + auth: { + profiles: { "openai:old": { provider: "openai", mode: "api_key" } }, + order: { openai: ["openai:old"] }, + }, + agents: { defaults: { model: "openai/gpt-5.5@openai:old" } }, + }, + authProfiles: [ + { + profileId: "openai:new", + credential: { + type: "api_key", + provider: "openai", + key: "test-new-key", + }, + }, + ], + runtime, + deps: { + loadAuthProfileStoreForRuntime: vi.fn(() => ({ + version: 1, + profiles: { + "openai:old": { + type: "api_key", + provider: "openai", + key: "test-old-key", + }, + }, + })) as never, + runEmbeddedAgent: runEmbeddedAgent as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ + ok: false, + status: "auth", + error: "The staged credential does not match the configured auth profile.", + }); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + }); + it("rejects a configured route that changes during its live check", async () => { const initialConfig = { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, diff --git a/src/system-agent/setup-inference.ts b/src/system-agent/setup-inference.ts index debd5686e7c1..1d4f7f107718 100644 --- a/src/system-agent/setup-inference.ts +++ b/src/system-agent/setup-inference.ts @@ -194,8 +194,18 @@ class SetupInferenceActivationUnavailableError extends Error { } export type VerifySetupInferenceResult = - | { ok: true; modelRef: string; latencyMs: number } - | { ok: false; status: SetupInferenceFailureStatus; error: string }; + | { + ok: true; + modelRef: string; + latencyMs: number; + authProfiles?: ProviderAuthResult["profiles"]; + } + | { + ok: false; + status: SetupInferenceFailureStatus; + error: string; + authProfiles?: ProviderAuthResult["profiles"]; + }; export type CompleteSetupInferenceResult = | { ok: true; modelRef: string; latencyMs: number; text: string } @@ -2568,6 +2578,8 @@ export async function resolvePersistentApplyInference(params: { /** Live-test a staged default-agent route before any caller persists it. */ export async function verifySetupInferenceConfig(params: { config: OpenClawConfig; + /** Candidate profiles staged in the isolated probe store, never the real agent store. */ + authProfiles?: ProviderAuthResult["profiles"]; agentId?: string; runtime: RuntimeEnv; timeoutMs?: number; @@ -2597,7 +2609,7 @@ export async function verifySetupInferenceConfig(params: { deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-"))) )(); try { - const plan = await buildTestPlan({ + const builtPlan = await buildTestPlan({ kind: "existing-model", cfg, sourceCfg: cfg, @@ -2608,9 +2620,80 @@ export async function verifySetupInferenceConfig(params: { routeAgentId, deps, }); - if ("error" in plan) { - return { ok: false, status: "unavailable", error: plan.error }; + if ("error" in builtPlan) { + return { ok: false, status: "unavailable", error: builtPlan.error }; } + let plan: SetupInferenceTestPlan = builtPlan; + if (params.authProfiles && params.authProfiles.length > 0) { + const selectedProfile = plan.authProfileId + ? params.authProfiles.find((profile) => profile.profileId === plan.authProfileId) + : params.authProfiles.find( + (profile) => + normalizeProviderId(profile.credential.provider) === + normalizeProviderId(plan.provider), + ); + if (!selectedProfile) { + return { + ok: false, + status: "auth", + error: plan.authProfileId + ? "The staged credential does not match the configured auth profile." + : "The staged credential does not belong to the configured inference provider.", + }; + } + const stagedAgentDir = path.join(tempDir, "agent"); + const staged = await persistManualAuthProfiles({ + profiles: params.authProfiles, + agentDir: stagedAgentDir, + deps, + }); + if (staged.status !== "persisted") { + return { + ok: false, + status: "unknown", + error: + "Could not stage the credential for its live inference test; try again in a moment.", + }; + } + plan = { + ...plan, + agentDir: stagedAgentDir, + authProfileId: selectedProfile.profileId, + }; + } + const readStagedAuthProfiles = (): ProviderAuthResult["profiles"] | undefined => { + if (!params.authProfiles || params.authProfiles.length === 0) { + return undefined; + } + const loadStore = deps.loadAuthProfileStoreForRuntime ?? loadAuthProfileStoreForRuntime; + const { profiles } = loadStore(plan.agentDir, { + readOnly: true, + allowKeychainPrompt: false, + config: plan.config, + externalCliProviderIds: [plan.provider], + }); + return params.authProfiles.map((profile) => { + const credential = profiles[profile.profileId]; + if (!credential) { + throw new Error("staged profile missing after verification"); + } + return { profileId: profile.profileId, credential }; + }); + }; + const retainStagedAuthProfiles = () => { + try { + return { ok: true as const, authProfiles: readStagedAuthProfiles() }; + } catch { + return { + ok: false as const, + result: { + ok: false as const, + status: "unknown" as const, + error: "Could not retain the credential after its live inference test.", + }, + }; + } + }; const requiresExecutionOwner = params.requireExecutionOwner === true || params.onVerifiedExecution !== undefined; let configuredRoute: @@ -2651,6 +2734,11 @@ export async function verifySetupInferenceConfig(params: { authProfileStateMode: "read-only", requireExecutionOwner: requiresExecutionOwner, }); + let retained = retainStagedAuthProfiles(); + if (!retained.ok) { + return retained.result; + } + let authProfiles = retained.authProfiles; if (test.ok) { const verifiedProfileId = test.auth.authProfileId; if (plan.authProfileId && verifiedProfileId !== plan.authProfileId) { @@ -2658,6 +2746,7 @@ export async function verifySetupInferenceConfig(params: { ok: false, status: "auth", error: `The inference run used profile "${verifiedProfileId ?? "unknown"}" instead of the configured profile "${plan.authProfileId}".`, + ...(authProfiles ? { authProfiles } : {}), }; } if (params.onVerifiedExecution && !plan.authProfileId && verifiedProfileId) { @@ -2671,10 +2760,16 @@ export async function verifySetupInferenceConfig(params: { authProfileStateMode: "read-only", requireExecutionOwner: true, }); + retained = retainStagedAuthProfiles(); + if (!retained.ok) { + return retained.result; + } + authProfiles = retained.authProfiles; if (!test.ok) { return { ...test, error: await redactSetupInferenceError(test.error), + ...(authProfiles ? { authProfiles } : {}), }; } if (test.auth.authProfileId !== verifiedProfileId) { @@ -2682,6 +2777,7 @@ export async function verifySetupInferenceConfig(params: { ok: false, status: "auth", error: "The selected inference credential changed during its locked verification.", + ...(authProfiles ? { authProfiles } : {}), }; } } @@ -2705,14 +2801,21 @@ export async function verifySetupInferenceConfig(params: { status: "auth", error: "The verified inference owner changed before validation completed. Retry the inference check.", + ...(authProfiles ? { authProfiles } : {}), }; } } - return { ok: true, latencyMs: test.latencyMs, modelRef: plan.modelRef }; + return { + ok: true, + latencyMs: test.latencyMs, + modelRef: plan.modelRef, + ...(authProfiles ? { authProfiles } : {}), + }; } return { ...test, error: await redactSetupInferenceError(test.error), + ...(authProfiles ? { authProfiles } : {}), }; } finally { await cleanupSetupInferenceTempDir({ tempDir, deps, runtime: params.runtime }); diff --git a/src/wizard/setup.model-auth.test.ts b/src/wizard/setup.model-auth.test.ts index a721c698a27c..02451662bfe2 100644 --- a/src/wizard/setup.model-auth.test.ts +++ b/src/wizard/setup.model-auth.test.ts @@ -13,6 +13,7 @@ const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn()); vi.mock("../commands/auth-choice.js", () => ({ applyAuthChoice, + prepareAuthChoice: applyAuthChoice, warnIfModelConfigLooksOff, resolvePreferredProviderForAuthChoice, })); @@ -71,7 +72,11 @@ describe("runSetupModelAuthStep provider failures", () => { workspaceDir: "/tmp/workspace", }); - expect(result).toEqual({}); + expect(result).toEqual({ + config: {}, + authProfiles: [], + persistAuthProfiles: expect.any(Function), + }); expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2); expect(prompter.note).toHaveBeenCalledWith( expect.stringContaining("Claude CLI is not authenticated on this host."), diff --git a/src/wizard/setup.model-auth.ts b/src/wizard/setup.model-auth.ts index 5e62f483bda2..a3a6a7a7e98e 100644 --- a/src/wizard/setup.model-auth.ts +++ b/src/wizard/setup.model-auth.ts @@ -10,6 +10,15 @@ import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; type KeepCurrentAuthChoice = typeof import("../commands/auth-choice-prompt.js").KEEP_CURRENT_AUTH_CHOICE; +type PreparedAuthChoiceResult = Awaited< + ReturnType +>; + +export type SetupModelAuthCandidate = { + config: OpenClawConfig; + authProfiles: PreparedAuthChoiceResult["authProfiles"]; + persistAuthProfiles: PreparedAuthChoiceResult["persistAuthProfiles"]; +}; const loadAuthChoiceModule = createLazyRuntimeModule(() => import("../commands/auth-choice.js")); @@ -114,13 +123,19 @@ async function resolveAuthChoiceModelSelectionPolicy(params: { */ export async function runSetupModelAuthStep(params: { config: OpenClawConfig; + stagedCandidate?: SetupModelAuthCandidate; opts: OnboardOptions; prompter: WizardPrompter; runtime: RuntimeEnv; workspaceDir: string; -}): Promise { +}): Promise { const { opts, prompter, runtime, workspaceDir } = params; - let nextConfig = params.config; + let nextConfig = params.stagedCandidate?.config ?? params.config; + let replacementBaseConfig = params.config; + let authProfiles: PreparedAuthChoiceResult["authProfiles"] = + params.stagedCandidate?.authProfiles ?? []; + let persistAuthProfiles: PreparedAuthChoiceResult["persistAuthProfiles"] = + params.stagedCandidate?.persistAuthProfiles ?? (async () => {}); const authChoiceFromPrompt = opts.authChoice === undefined; let authChoice: AuthChoice | KeepCurrentAuthChoice | undefined = opts.authChoice; let authStore: @@ -157,6 +172,11 @@ export async function runSetupModelAuthStep(params: { break; } + // A new auth choice replaces the rejected candidate instead of layering onto it. + nextConfig = replacementBaseConfig; + authProfiles = []; + persistAuthProfiles = async () => {}; + if (authChoice === "custom-api-key") { const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); const customResult = await promptCustomApiConfig({ @@ -198,13 +218,13 @@ export async function runSetupModelAuthStep(params: { } const [ - { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, + { prepareAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, { applyPrimaryModel, promptDefaultModel }, ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); prompter.disableBackNavigation?.(); - let authResult: Awaited>; + let authResult: PreparedAuthChoiceResult; try { - authResult = await applyAuthChoice({ + authResult = await prepareAuthChoice({ authChoice, config: nextConfig, prompter, @@ -230,8 +250,11 @@ export async function runSetupModelAuthStep(params: { continue; } nextConfig = authResult.config; + authProfiles = authResult.authProfiles; + persistAuthProfiles = authResult.persistAuthProfiles; if (authResult.retrySelection) { if (authChoiceFromPrompt) { + replacementBaseConfig = authResult.config; continue; } break; @@ -271,5 +294,5 @@ export async function runSetupModelAuthStep(params: { await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); break; } - return nextConfig; + return { config: nextConfig, authProfiles, persistAuthProfiles }; } diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index 16c2983a222c..7149d4ae6b81 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -5,8 +5,15 @@ import path from "node:path"; import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createWizardPrompter as buildWizardPrompter } from "../../test/helpers/wizard-prompter.js"; +import { + readAuthProfileStoreForTest, + removeOAuthTestTempRoot, +} from "../agents/auth-profiles/oauth-test-utils.js"; +import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginCompatibilityNotice } from "../plugins/status.js"; +import type { ProviderAuthResult } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; import { runSetupWizard } from "./setup.js"; @@ -21,6 +28,9 @@ 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; +type PrepareAuthChoice = typeof import("../commands/auth-choice.js").prepareAuthChoice; +type VerifySetupInferenceConfig = + typeof import("../system-agent/setup-inference.js").verifySetupInferenceConfig; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); const keepCurrentAuthChoice = vi.hoisted(() => "__keep-current" as const); @@ -28,6 +38,13 @@ const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config })), ); +const prepareAuthChoice = vi.hoisted(() => + vi.fn(async (args) => ({ + ...(await applyAuthChoice(args)), + authProfiles: [], + persistAuthProfiles: async () => {}, + })), +); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider")); const resolveManifestProviderAuthChoice = vi.hoisted(() => vi.fn(() => undefined), @@ -111,10 +128,12 @@ const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => [])); const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => [])); const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {})); const runSetupMemoryImportStep = vi.hoisted(() => vi.fn(async () => {})); -const verifySetupInference = vi.hoisted(() => - vi.fn<() => Promise>( - async () => ({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 250 }), - ), +const verifySetupInferenceConfig = vi.hoisted(() => + vi.fn(async () => ({ + ok: true, + modelRef: "openai/gpt-5.5", + latencyMs: 250, + })), ); const setupChannels = vi.hoisted(() => @@ -185,6 +204,70 @@ function getWizardNoteCalls(note: WizardPrompter["note"]) { return (note as unknown as { mock: { calls: unknown[][] } }).mock.calls; } +function modelConfigWithApiKey(apiKey: string): OpenClawConfig { + return { + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + auth: { + profiles: { "openai:default": { provider: "openai", mode: "api_key" } }, + order: { openai: ["openai:default"] }, + }, + models: { + providers: { + openai: { + apiKey, + baseUrl: "https://api.openai.com/v1", + models: [], + }, + }, + }, + }; +} + +function stagedOpenAiProfile(apiKey: string) { + return { + profileId: "openai:default", + credential: { type: "api_key" as const, provider: "openai", key: apiKey }, + }; +} + +function prepareMockAuthProfilesIn( + agentDir: string, +): Array { + const persistCalls: Array = []; + prepareAuthChoice.mockImplementation(async (args) => { + const result = await applyAuthChoice(args); + const apiKey = result.config.models?.providers?.openai?.apiKey; + if (typeof apiKey !== "string") { + return { + ...result, + authProfiles: [], + persistAuthProfiles: async () => {}, + }; + } + const profile = stagedOpenAiProfile(apiKey); + return { + ...result, + authProfiles: [profile], + persistAuthProfiles: async (profiles) => { + persistCalls.push(profiles); + for (const candidate of profiles ?? [profile]) { + const updated = await upsertAuthProfileWithLock({ ...candidate, agentDir }); + if (!updated) { + throw new Error("test auth profile write failed"); + } + } + }, + }; + }); + return persistCalls; +} + +function persistedWizardConfigs(): OpenClawConfig[] { + return (replaceConfigFile.mock.calls as unknown[][]).map( + ([params]) => (params as { nextConfig: OpenClawConfig }).nextConfig, + ); +} + function requireRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`expected ${label} to be an object`); @@ -257,6 +340,7 @@ vi.mock("../commands/auth-choice-prompt.js", () => ({ vi.mock("../commands/auth-choice.js", () => ({ applyAuthChoice, + prepareAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff, })); @@ -303,7 +387,7 @@ vi.mock("./setup.memory-import.js", () => ({ })); vi.mock("../system-agent/setup-inference.js", () => ({ - verifySetupInference, + verifySetupInferenceConfig, })); vi.mock("../config/config.js", () => ({ @@ -423,6 +507,12 @@ describe("runSetupWizard", () => { promptAuthChoiceGrouped.mockResolvedValue("skip"); applyAuthChoice.mockReset(); applyAuthChoice.mockImplementation(async (args) => ({ config: args.config })); + prepareAuthChoice.mockReset(); + prepareAuthChoice.mockImplementation(async (args) => ({ + ...(await applyAuthChoice(args)), + authProfiles: [], + persistAuthProfiles: async () => {}, + })); setupChannels.mockReset(); setupChannels.mockImplementation(async (cfg) => cfg); setupSkills.mockReset(); @@ -474,8 +564,8 @@ describe("runSetupWizard", () => { warnIfModelConfigLooksOff.mockResolvedValue(undefined); buildPluginCompatibilitySnapshotNotices.mockReset(); buildPluginCompatibilitySnapshotNotices.mockReturnValue([]); - verifySetupInference.mockReset(); - verifySetupInference.mockResolvedValue({ + verifySetupInferenceConfig.mockReset(); + verifySetupInferenceConfig.mockResolvedValue({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 250, @@ -2002,14 +2092,14 @@ describe("runSetupWizard", () => { expect(confirm).toHaveBeenCalledWith( expect.objectContaining({ message: "Test AI access now with a live completion?" }), ); - expect(verifySetupInference).toHaveBeenCalledOnce(); + expect(verifySetupInferenceConfig).toHaveBeenCalledOnce(); }); it("continues classic setup when live AI verification fails", async () => { applyAuthChoice.mockResolvedValueOnce({ config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, }); - verifySetupInference.mockResolvedValueOnce({ + verifySetupInferenceConfig.mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired", @@ -2038,39 +2128,220 @@ describe("runSetupWizard", () => { expect(select).toHaveBeenCalledWith( expect.objectContaining({ message: "How would you like to continue?" }), ); - expect(verifySetupInference).toHaveBeenCalledOnce(); + expect(verifySetupInferenceConfig).toHaveBeenCalledOnce(); }); - it("re-enters model/auth setup once and re-verifies after a failed AI check", async () => { - applyAuthChoice.mockResolvedValue({ - config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, - }); + it("keeps failed model/auth fixes in the verification loop without persisting them", async () => { + const stateDir = await makeCaseDir("failed-auth-profile-retry-"); + const agentDir = path.join(stateDir, "agent"); + prepareMockAuthProfilesIn(agentDir); + applyAuthChoice + .mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-original-key"), + }) + .mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-retry-invalid-key"), + }) + .mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-retry-still-invalid-key"), + }); promptAuthChoiceGrouped.mockResolvedValue("demo-provider"); - verifySetupInference + verifySetupInferenceConfig .mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" }) - .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 300 }); + .mockResolvedValueOnce({ ok: false, status: "auth", error: "key rejected" }) + .mockResolvedValueOnce({ ok: false, status: "auth", error: "key still rejected" }); + const select = vi + .fn() + .mockResolvedValueOnce("fix") + .mockResolvedValueOnce("fix") + .mockResolvedValueOnce("continue") as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select }); + + try { + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ); + + expect(applyAuthChoice).toHaveBeenCalledTimes(3); + expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2); + expect(verifySetupInferenceConfig).toHaveBeenCalledTimes(3); + const thirdVerification = getMockCallArg( + verifySetupInferenceConfig, + 2, + 0, + "third verification", + ) as Parameters[0]; + expect(thirdVerification.config.models?.providers?.openai?.apiKey).toBe( + "test-retry-still-invalid-key", + ); + const secondRetry = getMockCallArg( + applyAuthChoice, + 2, + 0, + "second retry auth choice", + ) as Parameters[0]; + expect(secondRetry.config.models?.providers?.openai?.apiKey).toBe("test-original-key"); + expect(select).toHaveBeenCalledTimes(3); + expect(thirdVerification.authProfiles).toEqual([ + stagedOpenAiProfile("test-retry-still-invalid-key"), + ]); + expect( + persistedWizardConfigs().some( + (config) => + config.models?.providers?.openai?.apiKey === "test-retry-invalid-key" || + config.models?.providers?.openai?.apiKey === "test-retry-still-invalid-key", + ), + ).toBe(false); + expect(readAuthProfileStoreForTest(agentDir).profiles["openai:default"]).toEqual( + stagedOpenAiProfile("test-original-key").credential, + ); + } finally { + await removeOAuthTestTempRoot(stateDir); + } + }); + + it("persists a model/auth fix after its live verification succeeds", async () => { + const stateDir = await makeCaseDir("successful-auth-profile-retry-"); + const agentDir = path.join(stateDir, "agent"); + const persistCalls = prepareMockAuthProfilesIn(agentDir); + applyAuthChoice + .mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-original-key"), + }) + .mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-retry-valid-key"), + }); + promptAuthChoiceGrouped.mockResolvedValue("demo-provider"); + verifySetupInferenceConfig + .mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" }) + .mockResolvedValueOnce({ + ok: true, + modelRef: "openai/gpt-5.5", + latencyMs: 300, + authProfiles: [stagedOpenAiProfile("test-retry-valid-key")], + }); const select = vi.fn(async () => "fix") as unknown as WizardPrompter["select"]; const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select }); - await runSetupWizard( - { - acceptRisk: true, - flow: "quickstart", - authChoice: "demo-provider", - installDaemon: false, - skipChannels: true, - skipSkills: true, - skipSearch: true, - skipHealth: true, - skipUi: true, - }, - createRuntime(), - prompter, - ); + try { + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ); - expect(applyAuthChoice).toHaveBeenCalledTimes(2); - expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce(); - expect(verifySetupInference).toHaveBeenCalledTimes(2); + expect(applyAuthChoice).toHaveBeenCalledTimes(2); + expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce(); + expect(verifySetupInferenceConfig).toHaveBeenCalledTimes(2); + const retryVerification = getMockCallArg( + verifySetupInferenceConfig, + 1, + 0, + "retry verification", + ) as Parameters[0]; + expect(retryVerification.config.models?.providers?.openai?.apiKey).toBe( + "test-retry-valid-key", + ); + expect(retryVerification.authProfiles).toEqual([stagedOpenAiProfile("test-retry-valid-key")]); + expect( + persistedWizardConfigs().some( + (config) => config.models?.providers?.openai?.apiKey === "test-retry-valid-key", + ), + ).toBe(true); + expect(readAuthProfileStoreForTest(agentDir).profiles["openai:default"]).toEqual( + stagedOpenAiProfile("test-retry-valid-key").credential, + ); + expect(persistCalls).toEqual([undefined, [stagedOpenAiProfile("test-retry-valid-key")]]); + } finally { + await removeOAuthTestTempRoot(stateDir); + } + }); + + it("retains a staged retry credential when a later Fix keeps the current auth", async () => { + const stateDir = await makeCaseDir("kept-auth-profile-retry-"); + const agentDir = path.join(stateDir, "agent"); + prepareMockAuthProfilesIn(agentDir); + applyAuthChoice + .mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-original-key"), + }) + .mockResolvedValueOnce({ + config: modelConfigWithApiKey("test-staged-key"), + }); + promptAuthChoiceGrouped + .mockResolvedValueOnce("demo-provider") + .mockResolvedValueOnce(keepCurrentAuthChoice); + verifySetupInferenceConfig + .mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" }) + .mockResolvedValueOnce({ + ok: false, + status: "timeout", + error: "request timed out", + authProfiles: [stagedOpenAiProfile("test-refreshed-key")], + }) + .mockResolvedValueOnce({ + ok: true, + modelRef: "openai/gpt-5.5", + latencyMs: 300, + }); + const select = vi.fn(async () => "fix") as unknown as WizardPrompter["select"]; + const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select }); + + try { + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + authChoice: "demo-provider", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + createRuntime(), + prompter, + ); + + expect(applyAuthChoice).toHaveBeenCalledTimes(2); + expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2); + expect(verifySetupInferenceConfig).toHaveBeenCalledTimes(3); + const finalVerification = getMockCallArg( + verifySetupInferenceConfig, + 2, + 0, + "final verification", + ) as Parameters[0]; + expect(finalVerification.authProfiles).toEqual([stagedOpenAiProfile("test-refreshed-key")]); + expect(readAuthProfileStoreForTest(agentDir).profiles["openai:default"]).toEqual( + stagedOpenAiProfile("test-staged-key").credential, + ); + } finally { + await removeOAuthTestTempRoot(stateDir); + } }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 72c561cdf048..32fd044f0524 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -23,7 +23,7 @@ import { listSetupMigrationOptions, runSetupMigrationImport, } from "./setup.migration-import.js"; -import { runSetupModelAuthStep } from "./setup.model-auth.js"; +import { runSetupModelAuthStep, type SetupModelAuthCandidate } from "./setup.model-auth.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import { readSetupConfigFileSnapshot, @@ -62,11 +62,15 @@ async function offerLiveModelVerification(params: { return { config: params.config, verified: false }; } - const { verifySetupInference } = await import("../system-agent/setup-inference.js"); - const verify = async () => { + const { verifySetupInferenceConfig } = await import("../system-agent/setup-inference.js"); + const verify = async (candidate: SetupModelAuthCandidate) => { const progress = params.prompter.progress(t("wizard.setup.testAiProgress")); const result = await withConsoleSubsystemsSuppressed(() => - verifySetupInference({ runtime: params.runtime }), + verifySetupInferenceConfig({ + config: candidate.config, + runtime: params.runtime, + authProfiles: candidate.authProfiles, + }), ); progress.stop(); if (result.ok) { @@ -83,31 +87,47 @@ async function offerLiveModelVerification(params: { return result; }; - const firstResult = await verify(); - if (firstResult.ok) { - return { config: params.config, verified: true }; - } - const action = await params.prompter.select({ - message: t("wizard.setup.testAiFailureChoice"), - options: [ - { value: "fix", label: t("wizard.setup.testAiFix") }, - { value: "continue", label: t("wizard.setup.testAiContinue") }, - ], - }); - if (action === "continue") { - return { config: params.config, verified: false }; - } - - const fixedConfig = await runSetupModelAuthStep({ + let candidate: SetupModelAuthCandidate = { config: params.config, - opts: { ...params.opts, authChoice: undefined }, - prompter: params.prompter, - runtime: params.runtime, - workspaceDir: params.workspaceDir, - }); - const persistedConfig = await params.writeConfig(fixedConfig); - const retryResult = await verify(); - return { config: persistedConfig, verified: retryResult.ok }; + authProfiles: [], + persistAuthProfiles: async () => {}, + }; + let shouldPersistCandidate = false; + while (true) { + const result = await verify(candidate); + if (result.ok) { + if (!shouldPersistCandidate) { + return { config: params.config, verified: true }; + } + await candidate.persistAuthProfiles(result.authProfiles); + const config = await params.writeConfig(candidate.config); + return { config, verified: true }; + } + if (result.authProfiles) { + candidate.authProfiles = result.authProfiles; + } + const action = await params.prompter.select({ + message: t("wizard.setup.testAiFailureChoice"), + options: [ + { value: "fix", label: t("wizard.setup.testAiFix") }, + { value: "continue", label: t("wizard.setup.testAiContinue") }, + ], + }); + if (action === "continue") { + return { config: params.config, verified: false }; + } + + // Attempts N>1 share the same gate and staged credentials until the user replaces them. + candidate = await runSetupModelAuthStep({ + config: params.config, + stagedCandidate: candidate, + opts: { ...params.opts, authChoice: undefined }, + prompter: params.prompter, + runtime: params.runtime, + workspaceDir: params.workspaceDir, + }); + shouldPersistCandidate = true; + } } function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean { @@ -549,13 +569,15 @@ async function runSetupWizardOnce( } if (!keepExistingModelConfig) { - nextConfig = await runSetupModelAuthStep({ + const modelAuth = await runSetupModelAuthStep({ config: nextConfig, opts, prompter, runtime, workspaceDir, }); + await modelAuth.persistAuthProfiles(); + nextConfig = modelAuth.config; } const { configureGatewayForSetup } = await import("./setup.gateway-config.js");