From 21d919deb801939d185e548cb70268b05045ebf9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 04:11:23 +0100 Subject: [PATCH] fix(onboard): keep the wizard alive through provider auth failures and polish standalone install UX (#100632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/help/faq-first-run.md | 6 +- docs/start/wizard-cli-reference.md | 4 + scripts/install.sh | 14 ++ src/commands/auth-choice.model-check.ts | 48 +++-- src/commands/auth-choice.ts | 5 +- src/commands/onboard-hooks.test.ts | 276 +----------------------- src/commands/onboard-hooks.ts | 86 +------- src/flows/search-setup.test.ts | 2 +- src/flows/search-setup.ts | 6 +- src/wizard/i18n/locales/en.ts | 14 +- src/wizard/i18n/locales/zh-CN.ts | 13 +- src/wizard/i18n/locales/zh-TW.ts | 13 +- src/wizard/setup.finalize.test.ts | 53 +++++ src/wizard/setup.finalize.ts | 20 +- src/wizard/setup.model-auth.test.ts | 112 ++++++++++ src/wizard/setup.model-auth.ts | 40 ++-- src/wizard/setup.test.ts | 5 +- src/wizard/setup.ts | 7 + 18 files changed, 310 insertions(+), 414 deletions(-) create mode 100644 src/wizard/setup.model-auth.test.ts diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index 87991f3af03..b81d39deaf1 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -181,8 +181,10 @@ and troubleshooting see the main [FAQ](/help/faq). 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: diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 8db31ff6ad7..90ae9bd083b 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -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. + Uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use. diff --git a/scripts/install.sh b/scripts/install.sh index 4a83e495dbd..84e2930f62d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -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." diff --git a/src/commands/auth-choice.model-check.ts b/src/commands/auth-choice.model-check.ts index 05f9d416325..b18d77ffd0a 100644 --- a/src/commands/auth-choice.model-check.ts +++ b/src/commands/auth-choice.model-check.ts @@ -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( diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 247c287afba..6e8bff20ea8 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -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"; diff --git a/src/commands/onboard-hooks.test.ts b/src/commands/onboard-hooks.test.ts index 5f3de3f7c22..b7d2582a6b1 100644 --- a/src/commands/onboard-hooks.test.ts +++ b/src/commands/onboard-hooks.test.ts @@ -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).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 ", - " openclaw hooks disable ", - ].join("\n"), - "Hooks Configured", - ], - ]); - }); - }); }); diff --git a/src/commands/onboard-hooks.ts b/src/commands/onboard-hooks.ts index da09952589b..c4dff16fd54 100644 --- a/src/commands/onboard-hooks.ts +++ b/src/commands/onboard-hooks.ts @@ -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 { - 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 ")}`, - ` ${formatCliCommand("openclaw hooks disable ")}`, - ].join("\n"), - t("wizard.hooks.configuredTitle"), - ); - - return next; -} diff --git a/src/flows/search-setup.test.ts b/src/flows/search-setup.test.ts index 88e066cf895..62e07f57920 100644 --- a/src/flows/search-setup.test.ts +++ b/src/flows/search-setup.test.ts @@ -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"); diff --git a/src/flows/search-setup.ts b/src/flows/search-setup.ts index b49a0c5477b..0873c2aaa48 100644 --- a/src/flows/search-setup.ts +++ b/src/flows/search-setup.ts @@ -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) }; }); diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index a1defb290a6..eb77cfa8718 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -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)", diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index 20125f2309b..79cba0bd555 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -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 app(camera/canvas)", nodeIos: "iOS app(camera/canvas)", nodeMac: "macOS app(system + notifications)", diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index d3c6b67073b..a463a7e9713 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -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 app(camera/canvas)", nodeIos: "iOS app(camera/canvas)", nodeMac: "macOS app(system + notifications)", diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 64c6483f040..c2975559123 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -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({ diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 7a43fb192aa..6a684157934 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -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 = [ diff --git a/src/wizard/setup.model-auth.test.ts b/src/wizard/setup.model-auth.test.ts new file mode 100644 index 00000000000..a721c698a27 --- /dev/null +++ b/src/wizard/setup.model-auth.test.ts @@ -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); + }); +}); diff --git a/src/wizard/setup.model-auth.ts b/src/wizard/setup.model-auth.ts index cca68099002..5e62f483bda 100644 --- a/src/wizard/setup.model-auth.ts +++ b/src/wizard/setup.model-auth.ts @@ -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>; + 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) { diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index dc1e852a817..34d5fd33ea3 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -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(); diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 4ff70c599d6..8229102c250 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -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"));