Files
openclaw/src/wizard/setup.model-auth.ts
Peter Steinberger 21d919deb8 fix(onboard): keep the wizard alive through provider auth failures and polish standalone install UX (#100632)
Fixes rough edges in the standalone install flow (install.sh -> openclaw onboard), found and verified by running the flow in a clean container and on a clean macOS Tahoe VM:

- Provider auth setup failures (e.g. the preselected "Anthropic Claude CLI" option on a host without a Claude CLI login) no longer kill the whole wizard. The interactive wizard notes the error and returns to the provider picker; explicit --auth-choice automation still fails fast.
- Onboarding config now persists before the channel/search/skills steps, so a crash or cancel during channel pairing no longer loses auth + gateway decisions.
- With model auth skipped, finalize no longer auto-sends the "Wake up, my friend!" message (which always failed with a provider auth error). The hatch seed is gated on usable model credentials and a "Model auth missing" note explains the next step.
- Search provider picker no longer labels non-key credentials (e.g. SearXNG base URL) as "API key required".
- install.sh no longer warns "PATH missing npm global bin dir" with manual fix steps after it already persisted the export line; it reports the PATH was updated and how to reload the current shell.
- Removed the dead interactive hooks onboarding step (setupInternalHooks); quickstart enables default hooks silently.

Verified live per fix in a clean Debian/Node 24 container and on a clean macOS 26.5 Parallels VM (wizard re-prompt, SearXNG label), plus wizard/onboard test suites and tsgo:core.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-07 04:11:23 +01:00

276 lines
9.8 KiB
TypeScript

// Model/auth provider selection step shared by the classic wizard and bootstrap onboarding.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { AuthChoice, OnboardOptions } from "../commands/onboard-types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { RuntimeEnv } from "../runtime.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { t } from "./i18n/index.js";
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
type KeepCurrentAuthChoice =
typeof import("../commands/auth-choice-prompt.js").KEEP_CURRENT_AUTH_CHOICE;
const loadAuthChoiceModule = createLazyRuntimeModule(() => import("../commands/auth-choice.js"));
const loadModelPickerModule = createLazyRuntimeModule(() => import("../commands/model-picker.js"));
function isAuthChoiceSelected(
value: AuthChoice | KeepCurrentAuthChoice,
keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined,
): value is AuthChoice {
return keepCurrentAuthChoice === undefined || value !== keepCurrentAuthChoice;
}
async function resolveAuthChoiceModelSelectionPolicy(params: {
authChoice: string;
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
resolvePreferredProviderForAuthChoice: (params: {
choice: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}) => Promise<string | undefined>;
}): Promise<{
preferredProvider?: string;
promptWhenAuthChoiceProvided: boolean;
allowKeepCurrent: boolean;
}> {
const preferredProvider = await params.resolvePreferredProviderForAuthChoice({
choice: params.authChoice,
config: params.config,
workspaceDir: params.workspaceDir,
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({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
mode: "setup",
});
const resolvedChoice = resolveProviderPluginChoice({
providers,
choice: params.authChoice,
});
const matchedProvider =
resolvedChoice?.provider ??
(() => {
const preferredId = preferredProvider?.trim();
if (!preferredId) {
return undefined;
}
return providers.find(
(provider) => typeof provider.id === "string" && provider.id.trim() === preferredId,
);
})();
const setupPolicy =
resolvedChoice?.wizard?.modelSelection ?? matchedProvider?.wizard?.setup?.modelSelection;
return {
preferredProvider,
promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true,
allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true,
};
}
/**
* Run the provider auth-choice + default-model selection loop. When
* `opts.authChoice` is set the prompt is skipped and the flag drives the flow
* (public onboarding automation contract).
*/
export async function runSetupModelAuthStep(params: {
config: OpenClawConfig;
opts: OnboardOptions;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir: string;
}): Promise<OpenClawConfig> {
const { opts, prompter, runtime, workspaceDir } = params;
let nextConfig = params.config;
const authChoiceFromPrompt = opts.authChoice === undefined;
let authChoice: AuthChoice | KeepCurrentAuthChoice | undefined = opts.authChoice;
let authStore:
| ReturnType<(typeof import("../agents/auth-profiles.runtime.js"))["ensureAuthProfileStore"]>
| undefined;
let promptAuthChoiceGrouped:
| (typeof import("../commands/auth-choice-prompt.js"))["promptAuthChoiceGrouped"]
| undefined;
let keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined;
if (authChoiceFromPrompt) {
const { ensureAuthProfileStore } = await import("../agents/auth-profiles.runtime.js");
const authChoicePromptModule = await import("../commands/auth-choice-prompt.js");
promptAuthChoiceGrouped = authChoicePromptModule.promptAuthChoiceGrouped;
keepCurrentAuthChoice = authChoicePromptModule.KEEP_CURRENT_AUTH_CHOICE;
authStore = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
}
while (true) {
if (authChoiceFromPrompt) {
authChoice = await promptAuthChoiceGrouped!({
prompter,
store: authStore!,
includeSkip: true,
config: nextConfig,
workspaceDir,
allowKeepCurrentProvider: true,
});
}
if (authChoice === undefined) {
throw new WizardCancelledError(t("wizard.setup.authChoiceRequired"));
}
if (!isAuthChoiceSelected(authChoice, keepCurrentAuthChoice)) {
break;
}
if (authChoice === "custom-api-key") {
const { promptCustomApiConfig } = await import("../commands/onboard-custom.js");
const customResult = await promptCustomApiConfig({
prompter,
runtime,
config: nextConfig,
secretInputMode: opts.secretInputMode,
});
nextConfig = customResult.config;
prompter.disableBackNavigation?.();
break;
}
if (authChoice === "skip") {
// Explicit skip should stay cold: do not bootstrap auth/profile machinery
// or run model/auth checks when the caller already chose to skip setup.
if (authChoiceFromPrompt) {
const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule();
const modelSelection = await promptDefaultModel({
config: nextConfig,
prompter,
allowKeep: true,
ignoreAllowlist: true,
includeProviderPluginSetups: false,
loadCatalog: false,
workspaceDir,
runtime,
});
if (modelSelection.config) {
nextConfig = modelSelection.config;
}
if (modelSelection.model) {
nextConfig = applyPrimaryModel(nextConfig, modelSelection.model);
}
const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule();
await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false });
}
break;
}
const [
{ applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff },
{ applyPrimaryModel, promptDefaultModel },
] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]);
prompter.disableBackNavigation?.();
let authResult: Awaited<ReturnType<typeof applyAuthChoice>>;
try {
authResult = await applyAuthChoice({
authChoice,
config: nextConfig,
prompter,
runtime,
setDefaultModel: true,
preserveExistingDefaultModel: true,
opts: {
...opts,
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
},
});
} catch (error) {
// Provider setup failures (missing CLI login, unreachable endpoint, ...)
// must not kill the whole wizard: earlier answers only persist later, so
// re-prompt instead. Explicit --auth-choice callers still fail loudly.
if (error instanceof WizardCancelledError || !authChoiceFromPrompt) {
throw error;
}
await prompter.note(
[formatErrorMessage(error), t("wizard.setup.authChoiceFailedRetry")].join("\n"),
t("wizard.setup.authChoiceFailedTitle"),
);
continue;
}
nextConfig = authResult.config;
if (authResult.retrySelection) {
if (authChoiceFromPrompt) {
continue;
}
break;
}
if (authResult.agentModelOverride) {
nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride);
}
const authChoiceModelSelectionPolicy = await resolveAuthChoiceModelSelectionPolicy({
authChoice,
config: nextConfig,
workspaceDir,
resolvePreferredProviderForAuthChoice,
});
const shouldPromptModelSelection =
authChoiceFromPrompt || authChoiceModelSelectionPolicy?.promptWhenAuthChoiceProvided;
if (shouldPromptModelSelection) {
const modelSelection = await promptDefaultModel({
config: nextConfig,
prompter,
allowKeep: authChoiceModelSelectionPolicy?.allowKeepCurrent ?? true,
ignoreAllowlist: true,
includeProviderPluginSetups: true,
preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider,
browseCatalogOnDemand: true,
workspaceDir,
runtime,
});
if (modelSelection.config) {
nextConfig = modelSelection.config;
}
if (modelSelection.model) {
nextConfig = applyPrimaryModel(nextConfig, modelSelection.model);
}
}
await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false });
break;
}
return nextConfig;
}