fix(onboard): keep invalid wizard retries unpersisted (#111558)

* fix(onboard): validate wizard retries before persist

* fix(onboard): stage retry auth profiles until verified

* test(onboard): clarify staged credential fixtures

* refactor(onboard): preserve immediate provider auth path

* fix(onboard): retain staged auth across retries

* fix(onboard): isolate rejected auth repairs

* fix(onboard): retain verified credential refreshes

* test(onboard): mark credential fixtures synthetic

* test(onboard): use canonical secret placeholders

* refactor(onboard): destructure staged profile stores

* fix(onboard): retain rotated staged credentials

* test(onboard): use valid timeout status

* refactor(onboard): keep staged auth helper private
This commit is contained in:
Peter Steinberger
2026-07-19 19:47:21 -07:00
committed by GitHub
parent b07985ef45
commit ddf57c083f
11 changed files with 921 additions and 92 deletions
@@ -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]);
+28 -7
View File
@@ -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<ApplyAuthChoiceResult> {
): Promise<PreparedAuthChoiceResult> {
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<ApplyAuthChoiceResult> {
const prepared = await prepareAuthChoice(params);
await prepared.persistAuthProfiles();
return {
config: prepared.config,
...(prepared.agentModelOverride ? { agentModelOverride: prepared.agentModelOverride } : {}),
...(prepared.retrySelection ? { retrySelection: true } : {}),
};
}
+6
View File
@@ -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<void>;
};
+1 -1
View File
@@ -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,
+115 -8
View File
@@ -56,6 +56,21 @@ type ApplyProviderAuthChoiceResult = {
retrySelection?: boolean;
};
type PreparedApplyProviderAuthChoiceResult = ApplyProviderAuthChoiceResult & {
authProfiles: ProviderAuthResult["profiles"];
persistAuthProfiles: (profiles?: ProviderAuthResult["profiles"]) => Promise<void>;
};
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<typeof runProviderPluginAuthMethod>[0],
): Promise<{
config: OpenClawConfig;
defaultModel?: string;
authProfiles: ProviderAuthResult["profiles"];
persistAuthProfiles: (profiles?: ProviderAuthResult["profiles"]) => Promise<void>;
}> {
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<ApplyProviderAuthChoiceResult | null> {
): Promise<PreparedApplyProviderAuthChoiceResult | null> {
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<ApplyProviderAuthChoiceResult | null> {
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<void> {
const updated = await upsertAuthProfileWithLock(params);
+224
View File
@@ -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" } } },
+109 -6
View File
@@ -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 });
+6 -1
View File
@@ -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."),
+29 -6
View File
@@ -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<typeof import("../commands/auth-choice.js").prepareAuthChoice>
>;
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<OpenClawConfig> {
}): Promise<SetupModelAuthCandidate> {
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<ReturnType<typeof applyAuthChoice>>;
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 };
}
+305 -34
View File
@@ -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<ApplyAuthChoice>(async (args) => ({ config: args.config })),
);
const prepareAuthChoice = vi.hoisted(() =>
vi.fn<PrepareAuthChoice>(async (args) => ({
...(await applyAuthChoice(args)),
authProfiles: [],
persistAuthProfiles: async () => {},
})),
);
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider"));
const resolveManifestProviderAuthChoice = vi.hoisted(() =>
vi.fn<ResolveManifestProviderAuthChoice>(() => 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<import("../system-agent/setup-inference.js").VerifySetupInferenceResult>>(
async () => ({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 250 }),
),
const verifySetupInferenceConfig = vi.hoisted(() =>
vi.fn<VerifySetupInferenceConfig>(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<ProviderAuthResult["profiles"] | undefined> {
const persistCalls: Array<ProviderAuthResult["profiles"] | undefined> = [];
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<string, unknown> {
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<VerifySetupInferenceConfig>[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<ApplyAuthChoice>[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<VerifySetupInferenceConfig>[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<VerifySetupInferenceConfig>[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. */
+51 -29
View File
@@ -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");