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)
This commit is contained in:
Peter Steinberger 2026-07-07 04:11:23 +01:00 committed by GitHub
parent 95f46743e4
commit 21d919deb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 310 additions and 414 deletions

View file

@ -181,8 +181,10 @@ and troubleshooting see the main [FAQ](/help/faq).
<Accordion title="It is stuck on wake up my friend / onboarding will not hatch. What now?">
That screen depends on the Gateway being reachable and authenticated. The TUI also sends
"Wake up, my friend!" automatically on first hatch. If you see that line with **no reply**
and tokens stay at 0, the agent never ran.
"Wake up, my friend!" automatically on first hatch when a model provider is configured. If
you skipped model/auth setup, onboarding shows a "Model auth missing" note and opens the
TUI without sending anything — add a provider with `openclaw configure --section model`.
If you see the wake-up line with **no reply** and tokens stay at 0, the agent never ran.
1. Restart the Gateway:

View file

@ -157,6 +157,10 @@ Plaintext `ws://` is accepted for loopback, private IP literals, `.local`, and T
## Auth and model options
If a provider setup step fails in interactive onboarding (for example a CLI reuse option
without a local sign-in), the wizard shows the error and returns to the provider picker
instead of exiting. Explicit `--auth-choice` runs still fail fast for automation.
<AccordionGroup>
<Accordion title="Anthropic API key">
Uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.

View file

@ -2487,6 +2487,20 @@ warn_shell_path_missing_dir() {
return 0
fi
# persist_shell_path_prepend may already have written the export line; in
# that case new shells are fine and the user only needs to reload this one.
# RC lines may spell the home dir as $HOME instead of the expanded path.
local dir_home_form="\$HOME${dir#"$HOME"}"
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
if [[ -f "$rc" ]] && { grep -Fq "$dir" "$rc" || grep -Fq "$dir_home_form" "$rc"; }; then
echo ""
ui_info "PATH updated in ${rc}: added ${label} (${dir})"
echo " New terminals pick this up automatically."
echo " For this shell, run: source ${rc}"
return 0
fi
done
echo ""
ui_warn "PATH missing ${label}: ${dir}"
echo " This can make openclaw show as \"command not found\" in new terminals."

View file

@ -68,6 +68,37 @@ function hasProfileForProvider(params: {
});
}
/**
* Resolve the default model ref and whether any usable credentials exist for
* it (auth profiles, provider env keys, or custom provider API keys). Shared
* by the onboarding model check and the finalize hatch gating.
*/
export function resolveDefaultModelAuthStatus(
config: OpenClawConfig,
options?: { agentId?: string; agentDir?: string },
): { provider: string; model: string; hasAuth: boolean } {
const ref = resolveDefaultModelForAgent({
cfg: config,
agentId: options?.agentId,
});
const store = ensureAuthProfileStore(options?.agentDir);
const authProviders = resolveAuthProviderCandidates({
config,
provider: ref.provider,
modelId: ref.model,
agentId: options?.agentId,
});
const acceptedTypes = resolveAcceptedAuthProfileTypes({
config,
provider: ref.provider,
});
const hasAuth =
authProviders.some((provider) => hasProfileForProvider({ store, provider, acceptedTypes })) ||
authProviders.some((provider) => resolveEnvApiKey(provider)) ||
authProviders.some((provider) => hasUsableCustomProviderApiKey(config, provider));
return { provider: ref.provider, model: ref.model, hasAuth };
}
/** Warn when the selected default model is unknown or has no usable credentials. */
export async function warnIfModelConfigLooksOff(
config: OpenClawConfig,
@ -96,21 +127,10 @@ export async function warnIfModelConfigLooksOff(
}
}
const store = ensureAuthProfileStore(options?.agentDir);
const authProviders = resolveAuthProviderCandidates({
config,
provider: ref.provider,
modelId: ref.model,
agentId: options?.agentId,
const { hasAuth } = resolveDefaultModelAuthStatus(config, {
...(options?.agentId ? { agentId: options.agentId } : {}),
...(options?.agentDir ? { agentDir: options.agentDir } : {}),
});
const acceptedTypes = resolveAcceptedAuthProfileTypes({
config,
provider: ref.provider,
});
const hasAuth =
authProviders.some((provider) => hasProfileForProvider({ store, provider, acceptedTypes })) ||
authProviders.some((provider) => resolveEnvApiKey(provider)) ||
authProviders.some((provider) => hasUsableCustomProviderApiKey(config, provider));
if (!hasAuth) {
warnings.push(
`No auth configured for provider "${ref.provider}". The agent may fail until credentials are added. ${buildProviderAuthRecoveryHint(

View file

@ -1,4 +1,7 @@
// Public auth-choice barrel used by onboarding and agent setup commands.
export { applyAuthChoice } from "./auth-choice.apply.js";
export { warnIfModelConfigLooksOff } from "./auth-choice.model-check.js";
export {
resolveDefaultModelAuthStatus,
warnIfModelConfigLooksOff,
} from "./auth-choice.model-check.js";
export { resolvePreferredProviderForAuthChoice } from "../plugins/provider-auth-choice-preference.js";

View file

@ -1,27 +1,9 @@
// Onboard hooks tests cover hook setup status, runtime output, and config mutation behavior.
import { describe, expect, it, vi, beforeEach } from "vitest";
// Onboard hooks tests cover the default internal-hook config mutation behavior.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { HookStatusEntry, HookStatusReport } from "../hooks/hooks-status.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { enableDefaultOnboardingInternalHooks, setupInternalHooks } from "./onboard-hooks.js";
// Mock hook discovery modules
vi.mock("../hooks/hooks-status.js", () => ({
buildWorkspaceHookStatus: vi.fn(),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/mock/workspace"),
resolveDefaultAgentId: vi.fn().mockReturnValue("main"),
}));
import { enableDefaultOnboardingInternalHooks } from "./onboard-hooks.js";
describe("onboard-hooks", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.OPENCLAW_LOCALE;
});
describe("enableDefaultOnboardingInternalHooks", () => {
it("enables only the bundled session-memory entry by default", () => {
const result = enableDefaultOnboardingInternalHooks({});
@ -77,256 +59,4 @@ describe("onboard-hooks", () => {
});
});
});
const createMockPrompter = (multiselectValue: string[]): WizardPrompter => ({
confirm: vi.fn().mockResolvedValue(true),
note: vi.fn().mockResolvedValue(undefined),
intro: vi.fn().mockResolvedValue(undefined),
outro: vi.fn().mockResolvedValue(undefined),
text: vi.fn().mockResolvedValue(""),
select: vi.fn().mockResolvedValue(""),
multiselect: vi.fn().mockResolvedValue(multiselectValue),
progress: vi.fn().mockReturnValue({
stop: vi.fn(),
update: vi.fn(),
}),
});
const createMockRuntime = (): RuntimeEnv => ({
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
});
const createMockHook = (
params: {
name: string;
description: string;
filePath: string;
baseDir: string;
handlerPath: string;
hookKey: string;
emoji: string;
events: string[];
},
eligible: boolean,
) => ({
blockedReason: (eligible
? undefined
: "missing requirements") as HookStatusEntry["blockedReason"],
...params,
unknownEvents: [],
source: "openclaw-bundled" as const,
pluginId: undefined,
homepage: undefined,
always: false,
enabledByConfig: eligible,
requirementsSatisfied: eligible,
loadable: eligible,
managedByPlugin: false,
requirements: {
bins: [],
anyBins: [],
env: [],
config: ["workspace.dir"],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: eligible ? [] : ["workspace.dir"],
os: [],
},
configChecks: [],
install: [],
});
const createMockHookReport = (eligible = true): HookStatusReport => ({
workspaceDir: "/mock/workspace",
managedHooksDir: "/mock/.openclaw/hooks",
hooks: [
createMockHook(
{
name: "session-memory",
description: "Save session context to memory when /new or /reset command is issued",
filePath: "/mock/workspace/hooks/session-memory/HOOK.md",
baseDir: "/mock/workspace/hooks/session-memory",
handlerPath: "/mock/workspace/hooks/session-memory/handler.js",
hookKey: "session-memory",
emoji: "💾",
events: ["command:new", "command:reset"],
},
eligible,
),
createMockHook(
{
name: "command-logger",
description: "Log all command events to a centralized audit file",
filePath: "/mock/workspace/hooks/command-logger/HOOK.md",
baseDir: "/mock/workspace/hooks/command-logger",
handlerPath: "/mock/workspace/hooks/command-logger/handler.js",
hookKey: "command-logger",
emoji: "📝",
events: ["command"],
},
eligible,
),
],
});
async function runSetupInternalHooks(params: {
selected: string[];
cfg?: OpenClawConfig;
eligible?: boolean;
}) {
const { buildWorkspaceHookStatus } = await import("../hooks/hooks-status.js");
vi.mocked(buildWorkspaceHookStatus).mockReturnValue(
createMockHookReport(params.eligible ?? true),
);
const cfg = params.cfg ?? {};
const prompter = createMockPrompter(params.selected);
const runtime = createMockRuntime();
const result = await setupInternalHooks(cfg, runtime, prompter);
return { result, cfg, prompter };
}
describe("setupInternalHooks", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("should enable hooks when user selects them", async () => {
const { result, prompter } = await runSetupInternalHooks({
selected: ["session-memory"],
});
expect(result.hooks?.internal?.enabled).toBe(true);
expect(result.hooks?.internal?.entries).toEqual({
"session-memory": { enabled: true },
});
expect(prompter.note).toHaveBeenCalledTimes(2);
expect(prompter.multiselect).toHaveBeenCalledWith({
message: "Enable hooks?",
options: [
{ value: "__skip__", label: "Skip for now" },
{
value: "session-memory",
label: "💾 session-memory",
hint: "Save session context to memory when /new or /reset command is issued",
},
{
value: "command-logger",
label: "📝 command-logger",
hint: "Log all command events to a centralized audit file",
},
],
});
});
it("localizes built-in hook prompts when OPENCLAW_LOCALE is set", async () => {
process.env.OPENCLAW_LOCALE = "zh-CN";
const { prompter } = await runSetupInternalHooks({
selected: ["__skip__"],
});
expect(prompter.multiselect).toHaveBeenCalledWith(
expect.objectContaining({
message: "启用 hooks",
options: expect.arrayContaining([{ value: "__skip__", label: "暂时跳过" }]),
}),
);
});
it("should not enable hooks when user skips", async () => {
const { result, prompter } = await runSetupInternalHooks({
selected: ["__skip__"],
});
expect(result.hooks?.internal).toBeUndefined();
expect(prompter.note).toHaveBeenCalledTimes(1);
});
it("should handle no eligible hooks", async () => {
const { result, cfg, prompter } = await runSetupInternalHooks({
selected: [],
eligible: false,
});
expect(result).toEqual(cfg);
expect(prompter.multiselect).not.toHaveBeenCalled();
expect(prompter.note).toHaveBeenCalledWith(
"No eligible hooks found. You can configure hooks later in your config.",
"No Hooks Available",
);
});
it("should preserve existing hooks config when enabled", async () => {
const cfg: OpenClawConfig = {
hooks: {
enabled: true,
path: "/webhook",
token: "existing-token",
},
};
const { result } = await runSetupInternalHooks({
selected: ["session-memory"],
cfg,
});
expect(result.hooks?.enabled).toBe(true);
expect(result.hooks?.path).toBe("/webhook");
expect(result.hooks?.token).toBe("existing-token");
expect(result.hooks?.internal?.enabled).toBe(true);
expect(result.hooks?.internal?.entries).toEqual({
"session-memory": { enabled: true },
});
});
it("should preserve existing config when user skips", async () => {
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: "/workspace" } },
};
const { result } = await runSetupInternalHooks({
selected: ["__skip__"],
cfg,
});
expect(result).toEqual(cfg);
expect(result.agents?.defaults?.workspace).toBe("/workspace");
});
it("should show informative notes to user", async () => {
vi.stubEnv("OPENCLAW_CONTAINER_HINT", "");
vi.stubEnv("OPENCLAW_PROFILE", "");
const { prompter } = await runSetupInternalHooks({
selected: ["session-memory"],
});
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
expect(noteCalls).toEqual([
[
[
"Hooks let you automate actions when agent commands are issued.",
"Example: Save session context to memory when you issue /new or /reset.",
"",
"Learn more: https://docs.openclaw.ai/automation/hooks",
].join("\n"),
"Hooks",
],
[
[
"Enabled 1 hook: session-memory",
"",
"You can manage hooks later with:",
" openclaw hooks list",
" openclaw hooks enable <name>",
" openclaw hooks disable <name>",
].join("\n"),
"Hooks Configured",
],
]);
});
});
});

View file

@ -1,11 +1,5 @@
/** Interactive onboarding step for enabling workspace hooks. */
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { formatCliCommand } from "../cli/command-format.js";
/** Onboarding defaults for workspace hooks. */
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildWorkspaceHookStatus } from "../hooks/hooks-status.js";
import type { RuntimeEnv } from "../runtime.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
const DEFAULT_ONBOARDING_INTERNAL_HOOKS = ["session-memory"] as const;
@ -43,81 +37,3 @@ export function enableDefaultOnboardingInternalHooks(cfg: OpenClawConfig): OpenC
},
};
}
/** Prompts for loadable internal hooks and writes selected hook entries. */
export async function setupInternalHooks(
cfg: OpenClawConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<OpenClawConfig> {
await prompter.note(
[
"Hooks let you automate actions when agent commands are issued.",
"Example: Save session context to memory when you issue /new or /reset.",
"",
"Learn more: https://docs.openclaw.ai/automation/hooks",
].join("\n"),
t("wizard.hooks.introTitle"),
);
// Discover hooks through the same status path used by hook commands so setup
// only offers entries that would be loadable after onboarding finishes.
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceHookStatus(workspaceDir, { config: cfg });
// Show every eligible hook so users can opt in during setup.
const eligibleHooks = report.hooks.filter((h) => h.loadable);
if (eligibleHooks.length === 0) {
await prompter.note(t("wizard.hooks.noHooksMessage"), t("wizard.hooks.noHooksTitle"));
return cfg;
}
const toEnable = await prompter.multiselect({
message: t("wizard.hooks.enable"),
options: [
{ value: "__skip__", label: t("common.skipForNow") },
...eligibleHooks.map((hook) => ({
value: hook.name,
label: `${hook.emoji ?? "🔗"} ${hook.name}`,
hint: hook.description,
})),
],
});
const selected = toEnable.filter((name) => name !== "__skip__");
if (selected.length === 0) {
return cfg;
}
// Use entries format so per-hook enablement survives future global defaults.
const entries = { ...cfg.hooks?.internal?.entries };
for (const name of selected) {
entries[name] = { enabled: true };
}
const next: OpenClawConfig = {
...cfg,
hooks: {
...cfg.hooks,
internal: {
enabled: true,
entries,
},
},
};
await prompter.note(
[
`Enabled ${selected.length} hook${selected.length > 1 ? "s" : ""}: ${selected.join(", ")}`,
"",
"You can manage hooks later with:",
` ${formatCliCommand("openclaw hooks list")}`,
` ${formatCliCommand("openclaw hooks enable <name>")}`,
` ${formatCliCommand("openclaw hooks disable <name>")}`,
].join("\n"),
t("wizard.hooks.configuredTitle"),
);
return next;
}

View file

@ -265,7 +265,7 @@ describe("runSearchSetupFlow", () => {
expect(grokOption).toEqual(
expect.objectContaining({
label: "Grok (Search with xAI · API key required)",
label: "Grok (Search with xAI · xAI API key required)",
}),
);
expect(grokOption).not.toHaveProperty("hint");

View file

@ -531,7 +531,11 @@ export async function runSearchSetupFlow(
? t("wizard.search.keyFree")
: providerIsReady(config, entry)
? t("wizard.search.configured")
: t("wizard.search.apiKeyRequired");
: entry.credentialLabel
? // Some providers need a non-key credential (e.g. SearXNG's base
// URL); a generic "API key required" suffix contradicts the hint.
t("wizard.search.credentialRequired", { label: entry.credentialLabel })
: t("wizard.search.apiKeyRequired");
const hint = [normalizeOptionalString(entry.hint), credentialHint].filter(Boolean).join(" · ");
return { value: entry.id, label: formatSearchProviderOptionLabel(entry.label, hint) };
});

View file

@ -110,13 +110,6 @@ export const en = {
serveHint: "Private HTTPS for your tailnet (devices on Tailscale)",
warningTitle: "Tailscale Warning",
},
hooks: {
configuredTitle: "Hooks Configured",
enable: "Enable hooks?",
introTitle: "Hooks",
noHooksMessage: "No eligible hooks found. You can configure hooks later in your config.",
noHooksTitle: "No Hooks Available",
},
completion: {
cacheFailed: "Failed to generate completion cache. Run `{command}` later.",
enable: "Enable {shell} shell completion for {cli}?",
@ -241,6 +234,8 @@ export const en = {
websocketUrl: "Gateway WebSocket URL",
},
setup: {
authChoiceFailedRetry: "Pick another provider or auth method, or choose Skip for now.",
authChoiceFailedTitle: "Provider setup failed",
authChoiceRequired: "auth choice is required",
channelsTitle: "Channels",
configHandling: "Config handling",
@ -913,6 +908,7 @@ export const en = {
},
search: {
apiKeyRequired: "API key required",
credentialRequired: "{label} required",
chooseProvider: "Choose a provider. Some providers need an API key, and some work key-free.",
configured: "configured",
configureLaterHint: "Configure later with openclaw configure --section web",
@ -982,6 +978,10 @@ export const en = {
managedWebSearchSkipped: "Managed web search provider was skipped.",
noBackgroundGatewayExpected:
"Setup was run without Gateway service install, so no background gateway is expected.",
noModelAuth:
'No credentials are configured for provider "{provider}", so chat turns will fail until auth is added.',
noModelAuthNext: "Add a provider any time with {command}.",
noModelAuthTitle: "Model auth missing",
nodeAndroid: "Android app (camera/canvas)",
nodeIos: "iOS app (camera/canvas)",
nodeMac: "macOS app (system + notifications)",

View file

@ -109,13 +109,6 @@ export const zh_CN = {
serveHint: "面向你的 tailnet 设备提供私有 HTTPS",
warningTitle: "Tailscale 警告",
},
hooks: {
configuredTitle: "Hooks 已配置",
enable: "启用 hooks",
introTitle: "Hooks",
noHooksMessage: "没有找到可用 hook。你可以稍后在配置中设置 hooks。",
noHooksTitle: "没有可用 Hooks",
},
completion: {
cacheFailed: "生成 completion 缓存失败。稍后运行 `{command}`。",
enable: "为 {cli} 启用 {shell} shell completion",
@ -238,6 +231,8 @@ export const zh_CN = {
websocketUrl: "Gateway WebSocket URL",
},
setup: {
authChoiceFailedRetry: "请选择其他提供商或认证方式,或选择暂时跳过。",
authChoiceFailedTitle: "提供商设置失败",
authChoiceRequired: "必须选择认证方式",
channelsTitle: "频道",
configHandling: "配置处理",
@ -883,6 +878,7 @@ export const zh_CN = {
},
search: {
apiKeyRequired: "需要 API key",
credentialRequired: "需要 {label}",
chooseProvider: "选择一个提供方。有些提供方需要 API key有些无需 key。",
configured: "已配置",
configureLaterHint: "稍后可用 openclaw configure --section web 配置",
@ -949,6 +945,9 @@ export const zh_CN = {
laterTitle: "稍后",
managedWebSearchSkipped: "已跳过托管 web search provider。",
noBackgroundGatewayExpected: "本次设置未安装 Gateway 服务,因此不会有后台 Gateway。",
noModelAuth: "提供商 “{provider}” 尚未配置凭据,聊天将失败,直到添加认证。",
noModelAuthNext: "随时可以通过 {command} 添加提供商。",
noModelAuthTitle: "缺少模型认证",
nodeAndroid: "Android appcamera/canvas",
nodeIos: "iOS appcamera/canvas",
nodeMac: "macOS appsystem + notifications",

View file

@ -109,13 +109,6 @@ export const zh_TW = {
serveHint: "面向你的 tailnet 裝置提供私有 HTTPS",
warningTitle: "Tailscale 警告",
},
hooks: {
configuredTitle: "Hooks 已設定",
enable: "啟用 hooks",
introTitle: "Hooks",
noHooksMessage: "沒有找到可用 hook。你可以稍後在設定中設定 hooks。",
noHooksTitle: "沒有可用 Hooks",
},
completion: {
cacheFailed: "產生 completion 快取失敗。稍後執行 `{command}`。",
enable: "為 {cli} 啟用 {shell} shell completion",
@ -238,6 +231,8 @@ export const zh_TW = {
websocketUrl: "Gateway WebSocket URL",
},
setup: {
authChoiceFailedRetry: "請選擇其他提供商或認證方式,或選擇暫時跳過。",
authChoiceFailedTitle: "提供商設定失敗",
authChoiceRequired: "必須選擇認證方式",
channelsTitle: "頻道",
configHandling: "設定處理",
@ -884,6 +879,7 @@ export const zh_TW = {
},
search: {
apiKeyRequired: "需要 API key",
credentialRequired: "需要 {label}",
chooseProvider: "選擇一個提供方。有些提供方需要 API key有些無需 key。",
configured: "已設定",
configureLaterHint: "稍後可用 openclaw configure --section web 設定",
@ -950,6 +946,9 @@ export const zh_TW = {
laterTitle: "稍後",
managedWebSearchSkipped: "已略過託管 web search provider。",
noBackgroundGatewayExpected: "本次設定未安裝 Gateway 服務,因此不會有背景 Gateway。",
noModelAuth: "提供商「{provider}」尚未設定憑證,聊天將失敗,直到新增認證。",
noModelAuthNext: "隨時可以透過 {command} 新增提供商。",
noModelAuthTitle: "缺少模型認證",
nodeAndroid: "Android appcamera/canvas",
nodeIos: "iOS appcamera/canvas",
nodeMac: "macOS appsystem + notifications",

View file

@ -28,6 +28,9 @@ const resolveLocalControlUiProbeLinks = vi.hoisted(() =>
);
const setupWizardShellCompletion = vi.hoisted(() => vi.fn(async () => {}));
const healthCommand = vi.hoisted(() => vi.fn(async () => {}));
const resolveDefaultModelAuthStatus = vi.hoisted(() =>
vi.fn(() => ({ provider: "anthropic", model: "claude-opus-4-8", hasAuth: true })),
);
const buildGatewayInstallPlan = vi.hoisted(() =>
vi.fn(async () => ({
programArguments: [],
@ -207,6 +210,13 @@ vi.mock("../tui/tui-launch.js", () => ({
launchTuiCli,
}));
vi.mock("../commands/auth-choice.js", () => ({
applyAuthChoice: vi.fn(),
resolveDefaultModelAuthStatus,
resolvePreferredProviderForAuthChoice: vi.fn(),
warnIfModelConfigLooksOff: vi.fn(),
}));
vi.mock("./setup.secret-input.js", () => ({
resolveSetupSecretInputString,
}));
@ -613,6 +623,49 @@ describe("finalizeSetupWizard", () => {
);
});
it("skips the doomed hatch seed message and warns when model auth is missing", async () => {
vi.spyOn(fs, "access").mockResolvedValueOnce(undefined);
resolveDefaultModelAuthStatus.mockReturnValueOnce({
provider: "openai",
model: "gpt-5.5",
hasAuth: false,
});
const prompter = buildWizardPrompter({
confirm: vi.fn(async () => false),
});
await finalizeSetupWizard({
flow: "quickstart",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: false,
},
baseConfig: {},
nextConfig: {},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime: createRuntime(),
});
expect(launchTuiCli).toHaveBeenCalledWith(expect.objectContaining({ message: undefined }), {});
expectNoteContains(
prompter,
'No credentials are configured for provider "openai"',
"Model auth missing",
);
});
it("does not resend the bootstrap hatch message on setup reruns", async () => {
vi.spyOn(fs, "access").mockResolvedValueOnce(undefined);
const prompter = buildWizardPrompter({

View file

@ -596,7 +596,12 @@ export async function finalizeSetupWizard(
.access(bootstrapPath)
.then(() => true)
.catch(() => false);
const shouldSeedBootstrapHatch = hasBootstrap && options.hadExistingConfig !== true;
// Without model credentials the seeded first message is guaranteed to fail
// with a provider auth error, so hatch quietly and explain instead.
const { resolveDefaultModelAuthStatus } = await import("../commands/auth-choice.js");
const modelAuthStatus = resolveDefaultModelAuthStatus(nextConfig);
const shouldSeedBootstrapHatch =
hasBootstrap && options.hadExistingConfig !== true && modelAuthStatus.hasAuth;
await prompter.note(
[
@ -624,12 +629,23 @@ export async function finalizeSetupWizard(
await prompter.note(
[
t("wizard.finalize.workspaceReady"),
t("wizard.finalize.firstTerminalChat"),
...(shouldSeedBootstrapHatch ? [t("wizard.finalize.firstTerminalChat")] : []),
t("wizard.finalize.editBootstrap"),
].join("\n"),
t("wizard.finalize.hatchYourAgent"),
);
}
if (!modelAuthStatus.hasAuth) {
await prompter.note(
[
t("wizard.finalize.noModelAuth", { provider: modelAuthStatus.provider }),
t("wizard.finalize.noModelAuthNext", {
command: formatCliCommand("openclaw configure --section model"),
}),
].join("\n"),
t("wizard.finalize.noModelAuthTitle"),
);
}
if (gatewayProbe.ok) {
const tokenNotes = [

View file

@ -0,0 +1,112 @@
// Regression tests: provider auth failures re-prompt instead of killing the wizard.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { WizardCancelledError, type WizardPrompter } from "./prompts.js";
import { runSetupModelAuthStep } from "./setup.model-auth.js";
const applyAuthChoice = vi.hoisted(() => vi.fn());
const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn());
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn());
const promptDefaultModel = vi.hoisted(() => vi.fn());
const applyPrimaryModel = vi.hoisted(() => vi.fn((config: unknown) => config));
const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn());
vi.mock("../commands/auth-choice.js", () => ({
applyAuthChoice,
warnIfModelConfigLooksOff,
resolvePreferredProviderForAuthChoice,
}));
vi.mock("../commands/model-picker.js", () => ({
applyPrimaryModel,
promptDefaultModel,
}));
vi.mock("../commands/auth-choice-prompt.js", () => ({
KEEP_CURRENT_AUTH_CHOICE: "__keep_current__",
promptAuthChoiceGrouped,
}));
vi.mock("../agents/auth-profiles.runtime.js", () => ({
ensureAuthProfileStore: vi.fn(() => ({ profiles: {} })),
}));
function createPrompter(): WizardPrompter {
return {
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
select: vi.fn(),
multiselect: vi.fn(),
text: vi.fn(),
confirm: vi.fn(),
progress: vi.fn(() => ({ stop: vi.fn(), update: vi.fn() })),
disableBackNavigation: vi.fn(),
} as unknown as WizardPrompter;
}
function createRuntime(): RuntimeEnv {
return { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv;
}
describe("runSetupModelAuthStep provider failures", () => {
beforeEach(() => {
vi.clearAllMocks();
promptDefaultModel.mockResolvedValue({});
warnIfModelConfigLooksOff.mockResolvedValue(undefined);
});
it("re-prompts after a provider setup error instead of aborting", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("anthropic-cli").mockResolvedValueOnce("skip");
applyAuthChoice.mockRejectedValueOnce(
new Error("Claude CLI is not authenticated on this host."),
);
const prompter = createPrompter();
const result = await runSetupModelAuthStep({
config: {},
opts: {},
prompter,
runtime: createRuntime(),
workspaceDir: "/tmp/workspace",
});
expect(result).toEqual({});
expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("Claude CLI is not authenticated on this host."),
"Provider setup failed",
);
});
it("still fails loudly when the auth choice came from a flag", async () => {
applyAuthChoice.mockRejectedValueOnce(
new Error("Claude CLI is not authenticated on this host."),
);
await expect(
runSetupModelAuthStep({
config: {},
opts: { authChoice: "anthropic-cli" },
prompter: createPrompter(),
runtime: createRuntime(),
workspaceDir: "/tmp/workspace",
}),
).rejects.toThrow("Claude CLI is not authenticated");
});
it("propagates wizard cancellation from provider setup", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("anthropic-cli");
applyAuthChoice.mockRejectedValueOnce(new WizardCancelledError());
await expect(
runSetupModelAuthStep({
config: {},
opts: {},
prompter: createPrompter(),
runtime: createRuntime(),
workspaceDir: "/tmp/workspace",
}),
).rejects.toThrow(WizardCancelledError);
});
});

View file

@ -2,6 +2,7 @@
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";
@ -201,18 +202,33 @@ export async function runSetupModelAuthStep(params: {
{ applyPrimaryModel, promptDefaultModel },
] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]);
prompter.disableBackNavigation?.();
const authResult = await applyAuthChoice({
authChoice,
config: nextConfig,
prompter,
runtime,
setDefaultModel: true,
preserveExistingDefaultModel: true,
opts: {
...opts,
token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined,
},
});
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) {

View file

@ -888,7 +888,8 @@ describe("runSetupWizard", () => {
prompter,
);
expect(replaceConfigFile).toHaveBeenCalledTimes(3);
// Migration write + pre-channels persist + post-channels write + final write.
expect(replaceConfigFile).toHaveBeenCalledTimes(4);
const migrationParams = requireRecord(
getMockCallArg(replaceConfigFile, 0, 0, "migration config replacement"),
"migration config replacement params",
@ -904,7 +905,7 @@ describe("runSetupWizard", () => {
expect(migrationWriteOptions.unsetPaths).toContainEqual(["plugins", "installs"]);
const replaceParams = requireRecord(
getMockCallArg(replaceConfigFile, 2, 0, "config replacement"),
getMockCallArg(replaceConfigFile, 3, 0, "config replacement"),
"config replacement params",
);
expect(requireRecord(replaceParams.nextConfig, "next config").plugins).toBeUndefined();

View file

@ -468,6 +468,13 @@ async function runSetupWizardOnce(
nextConfig = gateway.nextConfig;
const settings = gateway.settings;
// Persist auth + gateway decisions now: channel/search/skills steps can pair
// devices or install plugins, and a crash or cancel there must not force the
// user to redo provider setup from scratch.
nextConfig = await writeSetupConfigFile(nextConfig, {
allowConfigSizeDrop: false,
});
prompter.disableBackNavigation?.();
if (opts.skipChannels ?? opts.skipProviders) {
await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle"));