mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
95f46743e4
commit
21d919deb8
18 changed files with 310 additions and 414 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
112
src/wizard/setup.model-auth.test.ts
Normal file
112
src/wizard/setup.model-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue