fix: restore grouped AI auth options in guided onboarding (#107038)

Restore the grouped provider/auth picker for fresh guided onboarding, keep secondary providers under More, expose OAuth and device-code methods, and bound the setup probe session identifier so ChatGPT login is not rejected by the prompt cache key limit.
This commit is contained in:
Jason (Json)
2026-07-13 22:12:19 -06:00
committed by GitHub
parent 689edddd34
commit f72e936f26
11 changed files with 481 additions and 112 deletions
+10 -8
View File
@@ -65,14 +65,16 @@ environment variables, and supported local CLIs, then tests the recommended
candidate with a real completion. If that candidate fails, onboarding shows
the reason and automatically tries the next usable candidate.
If automatic detection is exhausted, choose another detected candidate or enter
a provider API key in a masked prompt. A manual key is tested through the same
live completion path. Guided onboarding
does not offer Crestodian or a skip-AI exit before a candidate passes. OpenClaw
persists only the verified model route and its credential after the test
succeeds; a failed candidate does not replace the configured model or save the
attempted credential. Workspace and Gateway setup remain unchanged until
Crestodian starts.
If automatic detection is exhausted, the provider picker shows OpenAI,
Anthropic, xAI (Grok), Google, and OpenRouter first. Choose **More…** for every
other supported provider, grouped by provider; regions, plans, and auth methods
then appear in a second menu. Supported browser or device sign-in and masked
API-key or token methods use the same live completion path. OpenClaw persists
only the verified model route and its credential after the test succeeds; a
failed candidate does not replace the configured model or save the attempted
credential. Choose **Skip for now** to exit without starting Crestodian and
rerun `openclaw onboard` when you are ready. Workspace and Gateway setup remain
unchanged until Crestodian starts.
In guided mode, `--workspace <dir>` supplies Crestodian's proposed workspace
and the isolated inference context. It is not persisted until you approve the
+11 -7
View File
@@ -31,7 +31,8 @@ you the most control.
The guided inference phase establishes only:
1. **Model provider and auth** — detected access or a verified API key
1. **Model provider and auth** — detected access or a verified provider sign-in,
API key, or token
2. **Verified inference** — a real completion on the default agent's effective
model
@@ -54,12 +55,15 @@ openclaw onboard
```
The guided flow detects existing AI access, live-tests candidates in order,
falls through on failure, and offers masked manual key entry. It saves the
model and credential only after a passing completion, then starts Crestodian
to configure the workspace, Gateway, channels, agents, plugins, and other
optional features. There is no pre-inference Crestodian, skip-AI path, or
in-flow classic handoff. Exit and run `openclaw onboard --classic` when you
want the classic wizard instead.
and falls through on failure. If detection is exhausted, it shows OpenAI,
Anthropic, xAI (Grok), Google, and OpenRouter first. **More…** contains the
remaining providers in provider groups, with regions, plans, and supported
browser, device, API-key, or token methods in a second menu. It saves the model
and credential only after a passing completion, then starts Crestodian to
configure the workspace, Gateway, channels, agents, plugins, and other optional
features. **Skip for now** exits without starting Crestodian. There is no
in-flow classic handoff; exit and run `openclaw onboard --classic` when you want
the classic wizard instead.
After inference passes, Crestodian can hand channel setup to a masked terminal
wizard. It does not open guided or classic provider setup; exit Crestodian and
+13 -12
View File
@@ -20,17 +20,16 @@ from [Windows Hub](/platforms/windows).
Guided onboarding establishes inference first. It detects available AI access,
requires a real completion, and only then starts [Crestodian](/cli/crestodian)
to configure the rest of OpenClaw. There is no pre-inference Crestodian or
skip-AI path in the guided flow.
to configure the rest of OpenClaw. Choosing **Skip for now** exits onboarding
without starting Crestodian.
The classic wizard remains available for provider sign-in, remote Gateway
The classic wizard remains available for custom providers, remote Gateway
setup, channel pairing, daemon controls, skills, and imports. Run it explicitly
with `openclaw onboard --classic`; the guided inference candidate screen does
not delegate into it. After inference passes, Crestodian can use `open channel
wizard for <channel>` to hand channel setup that needs secrets to a masked
terminal wizard. To change the model provider or its authentication, exit
Crestodian and run `openclaw onboard`; Crestodian does not open guided or
classic provider flows.
with `openclaw onboard --classic`; the guided inference picker does not delegate
into it. After inference passes, Crestodian can use `open channel wizard for
<channel>` to hand channel setup that needs secrets to a masked terminal wizard.
To change the model provider or its authentication, exit Crestodian and run
`openclaw onboard`; Crestodian does not open guided or classic provider flows.
<Info>
Fastest first chat: finish guided setup, run `openclaw dashboard`, and chat in
@@ -78,9 +77,11 @@ Plain `openclaw onboard` follows this path:
AI CLIs.
3. Test the first detected candidate with a real completion. On failure, show the
reason and continue to the next usable candidate.
4. If detection is exhausted, retry a detected candidate or enter a provider
API key in a masked prompt. Guided onboarding
does not offer Crestodian or a skip-AI exit before inference works.
4. If detection is exhausted, choose OpenAI, Anthropic, xAI (Grok), Google, or
OpenRouter, or choose **More…** for the remaining providers. Each provider's
regions, plans, and supported browser, device, API-key, or token methods
appear in a second menu and are tested with the same real completion.
Choose **Skip for now** to exit without starting Crestodian.
5. Persist only the verified model route and any credential/plugin state it
requires. Workspace and Gateway settings remain untouched.
6. Start Crestodian with the verified model so it can configure the workspace,
+50
View File
@@ -6,6 +6,7 @@ import {
buildAuthChoiceGroups,
buildAuthChoiceOptions,
formatAuthChoiceChoicesForCli,
isFeaturedAuthChoiceGroup,
} from "./auth-choice-options.js";
import { formatStaticAuthChoiceChoicesForCli } from "./auth-choice-options.static.js";
@@ -524,6 +525,16 @@ describe("buildAuthChoiceOptions", () => {
groupId: "openrouter",
groupLabel: "OpenRouter",
},
{
pluginId: "meta",
providerId: "meta",
methodId: "api-key",
choiceId: "meta-api-key",
choiceLabel: "Meta API key",
groupId: "meta",
groupLabel: "Meta",
onboardingFeatured: true,
},
]);
const { groups } = buildAuthChoiceGroups({
@@ -540,6 +551,14 @@ describe("buildAuthChoiceOptions", () => {
"BytePlus",
"Custom Provider",
"LiteLLM",
"Meta",
]);
expect(groups.filter(isFeaturedAuthChoiceGroup).map((group) => group.label)).toEqual([
"OpenAI",
"Anthropic",
"xAI (Grok)",
"Google",
"OpenRouter",
]);
});
@@ -634,6 +653,37 @@ describe("buildAuthChoiceOptions", () => {
expect(openAIGroup.options[0]?.onboardingFeatured).toBe(true);
});
it("includes manual-only methods when the grouped CLI picker requests them", () => {
resolveProviderWizardOptions.mockReturnValue([
{
value: "openai-device-code",
label: "ChatGPT Device Pairing",
groupId: "openai",
groupLabel: "OpenAI",
assistantPriority: -10,
assistantVisibility: "manual-only",
},
{
value: "openai-api-key",
label: "OpenAI API Key",
groupId: "openai",
groupLabel: "OpenAI",
assistantPriority: 5,
},
]);
const { groups } = buildAuthChoiceGroups({
store: EMPTY_STORE,
includeSkip: false,
assistantVisibleOnly: false,
});
expect(requireChoiceGroup(groups, "openai").options.map((option) => option.value)).toEqual([
"openai-device-code",
"openai-api-key",
]);
});
it("groups OpenCode Zen and Go under one OpenCode entry", () => {
resolveManifestProviderAuthChoices.mockReturnValue([
{
+8 -2
View File
@@ -23,6 +23,11 @@ const FEATURED_AUTH_GROUP_ORDER = new Map<string, number>([
["openrouter", 4],
]);
/** Keep the first-tier provider list stable; every other group belongs under More. */
export function isFeaturedAuthChoiceGroup(group: AuthChoiceGroup): boolean {
return FEATURED_AUTH_GROUP_ORDER.has(group.value);
}
function compareAssistantOptions(a: AuthChoiceOption, b: AuthChoiceOption): number {
const priorityA = a.assistantPriority ?? 0;
const priorityB = b.assistantPriority ?? 0;
@@ -132,10 +137,11 @@ export function buildAuthChoiceOptions(params: {
return options;
}
/** Build grouped assistant-visible auth choices for the onboarding prompt. */
/** Build grouped auth choices, filtering manual-only methods by default. */
export function buildAuthChoiceGroups(params: {
store: AuthProfileStore;
includeSkip: boolean;
assistantVisibleOnly?: boolean;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
@@ -146,7 +152,7 @@ export function buildAuthChoiceGroups(params: {
const options = buildAuthChoiceOptions({
...params,
includeSkip: false,
assistantVisibleOnly: true,
assistantVisibleOnly: params.assistantVisibleOnly ?? true,
});
const groupsById = new Map<AuthChoiceGroupId, AuthChoiceGroup>();
+140 -1
View File
@@ -9,10 +9,16 @@ const buildAuthChoiceGroups = vi.hoisted(() => vi.fn());
const compareAuthChoiceGroups = vi.hoisted(() =>
vi.fn((a: AuthChoiceGroup, b: AuthChoiceGroup) => a.label.localeCompare(b.label)),
);
const isFeaturedAuthChoiceGroup = vi.hoisted(() =>
vi.fn((group: AuthChoiceGroup) =>
["openai", "anthropic", "xai", "google", "openrouter"].includes(group.value),
),
);
vi.mock("./auth-choice-options.js", () => ({
buildAuthChoiceGroups,
compareAuthChoiceGroups,
isFeaturedAuthChoiceGroup,
}));
const EMPTY_STORE: AuthProfileStore = { version: 1, profiles: {} };
@@ -55,10 +61,31 @@ function openAIGroup(options?: Partial<AuthChoiceGroup>): AuthChoiceGroup {
};
}
function authChoiceGroup(
value: string,
label: string,
methods: Array<readonly [value: string, label: string]>,
featured = false,
): AuthChoiceGroup {
return {
value,
label,
options: methods.map(([methodValue, methodLabel], index) => ({
value: methodValue,
label: methodLabel,
...(featured && index === 0 ? { onboardingFeatured: true } : {}),
})),
};
}
describe("promptAuthChoiceGrouped", () => {
beforeEach(() => {
buildAuthChoiceGroups.mockReset();
compareAuthChoiceGroups.mockClear();
compareAuthChoiceGroups
.mockReset()
.mockImplementation((a: AuthChoiceGroup, b: AuthChoiceGroup) =>
a.label.localeCompare(b.label),
);
});
it("marks the configured provider and offers keep current config first", async () => {
@@ -176,4 +203,116 @@ describe("promptAuthChoiceGrouped", () => {
"__back",
]);
});
it("filters guided choices while keeping featured providers and grouped methods", async () => {
const featuredOrder = new Map([
["openai", 0],
["anthropic", 1],
["xai", 2],
["google", 3],
["openrouter", 4],
]);
compareAuthChoiceGroups.mockImplementation((a, b) => {
const priorityA = featuredOrder.get(a.value) ?? Number.POSITIVE_INFINITY;
const priorityB = featuredOrder.get(b.value) ?? Number.POSITIVE_INFINITY;
return priorityA - priorityB || a.label.localeCompare(b.label);
});
buildAuthChoiceGroups.mockReturnValue({
groups: [
authChoiceGroup("minimax", "MiniMax", [
["minimax-global-oauth", "MiniMax OAuth (Global)"],
["minimax-global-api", "MiniMax API key (Global)"],
["minimax-cn-oauth", "MiniMax OAuth (CN)"],
["minimax-cn-api", "MiniMax API key (CN)"],
["minimax-legacy", "Legacy MiniMax login"],
]),
authChoiceGroup("opencode", "OpenCode", [
["opencode-zen", "OpenCode Zen catalog"],
["opencode-go", "OpenCode Go catalog"],
]),
authChoiceGroup("meta", "Meta", [["meta-api-key", "Meta API key"]], true),
authChoiceGroup("xiaomi", "Xiaomi", [
["xiaomi-api-key", "Xiaomi API key"],
["xiaomi-token-plan-cn", "Xiaomi Token Plan (CN)"],
]),
openAIGroup(),
authChoiceGroup(
"openrouter",
"OpenRouter",
[["openrouter-oauth", "OpenRouter OAuth"]],
true,
),
authChoiceGroup("google", "Google", [["google-gemini-cli", "Gemini CLI OAuth"]], true),
authChoiceGroup("xai", "xAI (Grok)", [["xai-oauth", "xAI OAuth"]], true),
authChoiceGroup("anthropic", "Anthropic", [["apiKey", "Anthropic API key"]], true),
],
skipOption: { value: "skip", label: "Skip for now" },
});
let providerOptions: Array<{ value: unknown; label: string }> = [];
let moreProviderOptions: Array<{ value: unknown; label: string }> = [];
let minimaxOptions: Array<{ value: unknown; label: string }> = [];
const prompter = createPromptHarness(async (params) => {
if (params.message === "Model/auth provider" && !providerOptions.length) {
providerOptions = params.options;
return "__more";
}
if (params.message === "Model/auth provider") {
moreProviderOptions = params.options;
return "minimax";
}
if (params.message === "MiniMax auth method") {
minimaxOptions = params.options;
return "minimax-cn-api";
}
throw new Error(`unexpected prompt ${params.message}`);
});
const result = await promptAuthChoiceGrouped({
prompter,
store: EMPTY_STORE,
includeSkip: true,
allowedChoices: new Set([
"openai",
"openai-api-key",
"apiKey",
"xai-oauth",
"google-gemini-cli",
"openrouter-oauth",
"minimax-global-oauth",
"minimax-global-api",
"minimax-cn-oauth",
"minimax-cn-api",
"opencode-zen",
"opencode-go",
"xiaomi-api-key",
"xiaomi-token-plan-cn",
"meta-api-key",
]),
});
expect(providerOptions.map((option) => option.value)).toEqual([
"openai",
"anthropic",
"xai",
"google",
"openrouter",
"__more",
"skip",
]);
expect(moreProviderOptions.map((option) => option.value)).toEqual([
"meta",
"minimax",
"opencode",
"xiaomi",
"__back",
]);
expect(minimaxOptions.map((option) => option.value)).toEqual([
"minimax-global-oauth",
"minimax-global-api",
"minimax-cn-oauth",
"minimax-cn-api",
"__back",
]);
expect(result).toBe("minimax-cn-api");
});
});
+26 -9
View File
@@ -5,7 +5,11 @@ import type { AuthProfileStore } from "../agents/auth-profiles/types.js";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
import { buildAuthChoiceGroups, compareAuthChoiceGroups } from "./auth-choice-options.js";
import {
buildAuthChoiceGroups,
compareAuthChoiceGroups,
isFeaturedAuthChoiceGroup,
} from "./auth-choice-options.js";
import type { AuthChoiceGroup } from "./auth-choice-options.static.js";
import type { AuthChoice } from "./onboard-types.js";
@@ -20,16 +24,15 @@ type PromptAuthChoiceGroupedParams = {
prompter: WizardPrompter;
store: AuthProfileStore;
includeSkip: boolean;
assistantVisibleOnly?: boolean;
allowedChoices?: ReadonlySet<string>;
additionalGroups?: readonly AuthChoiceGroup[];
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
allowKeepCurrentProvider?: boolean;
};
function isGroupFeatured(group: AuthChoiceGroup): boolean {
return group.options.some((option) => option.onboardingFeatured);
}
function resolveConfiguredModelRef(config?: OpenClawConfig): string | undefined {
return resolveAgentModelPrimaryValue(config?.agents?.defaults?.model);
}
@@ -73,10 +76,22 @@ export async function promptAuthChoiceGrouped(
params: PromptAuthChoiceGroupedParams,
): Promise<PromptAuthChoiceResult> {
const { groups, skipOption } = buildAuthChoiceGroups(params);
const availableGroups = groups.filter((group) => group.options.length > 0);
const filteredGroups = params.allowedChoices
? groups.map((group) => ({
...group,
options: group.options.filter((option) => params.allowedChoices?.has(option.value)),
}))
: groups;
const availableGroups = [...filteredGroups, ...(params.additionalGroups ?? [])].filter(
(group) => group.options.length > 0,
);
const groupById = new Map(availableGroups.map((group) => [group.value, group] as const));
const featuredGroups = availableGroups.filter(isGroupFeatured).toSorted(compareAuthChoiceGroups);
const moreGroups = [...availableGroups].toSorted(compareAuthChoiceGroups);
const featuredGroups = availableGroups
.filter(isFeaturedAuthChoiceGroup)
.toSorted(compareAuthChoiceGroups);
const moreGroups = availableGroups
.filter((group) => !isFeaturedAuthChoiceGroup(group))
.toSorted(compareAuthChoiceGroups);
const configuredModelRef = resolveConfiguredModelRef(params.config);
const configuredProvider = params.allowKeepCurrentProvider
? resolveConfiguredProvider(params.config)
@@ -171,7 +186,9 @@ export async function promptAuthChoiceGrouped(
const topTier: WizardSelectOption[] = featuredGroups.map((group) =>
groupToOption(group, configuredProvider),
);
topTier.push({ value: MORE_VALUE, label: "More…" });
if (moreGroups.length > 0) {
topTier.push({ value: MORE_VALUE, label: "More…" });
}
if (skipOption) {
topTier.push({ value: skipOption.value, label: skipOption.label });
}
+121 -33
View File
@@ -8,7 +8,7 @@ import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter, WizardSelectParams } from "../wizard/prompts.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { runGuidedOnboarding, type GuidedOnboardingDeps } from "./onboard-guided.js";
import { runRemoteGatewayInferenceOnboarding } from "./onboard-remote-gateway.js";
@@ -17,9 +17,20 @@ type RemoteGatewayInferenceOnboardingDeps = NonNullable<
>;
const restoreTerminalState = vi.hoisted(() => vi.fn());
const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn());
const ensureAuthProfileStore = vi.hoisted(() =>
vi.fn(() => ({ version: 1 as const, profiles: {} })),
);
vi.mock("../../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState }));
vi.mock("./auth-choice-prompt.js", async (importActual) => ({
...(await importActual<typeof import("./auth-choice-prompt.js")>()),
promptAuthChoiceGrouped,
}));
vi.mock("../agents/auth-profiles.runtime.js", () => ({ ensureAuthProfileStore }));
vi.mock("./onboard-interactive-runner.js", async (importActual) => {
const actual = await importActual<typeof import("./onboard-interactive-runner.js")>();
return { ...actual, hasInteractiveOnboardingTty: () => true };
@@ -118,6 +129,8 @@ describe("runGuidedOnboarding", () => {
beforeEach(() => {
restoreTerminalState.mockClear();
promptAuthChoiceGrouped.mockReset();
ensureAuthProfileStore.mockClear();
readConfigFileSnapshot.mockReset();
readConfigFileSnapshot.mockResolvedValue({
exists: false,
@@ -275,15 +288,8 @@ describe("runGuidedOnboarding", () => {
it("never replaces a configured model by fallthrough when its check fails", async () => {
const existingModel = existingModelCandidate();
const select = vi.fn(async (params: WizardSelectParams) => {
expect(params.options.map((option) => option.value)).toEqual([
"candidate:existing-model",
"candidate:claude-cli",
]);
return "candidate:existing-model";
}) as unknown as WizardPrompter["select"];
promptAuthChoiceGrouped.mockResolvedValueOnce("candidate:existing-model");
const prompter = createWizardPrompter({
select,
confirm: vi.fn(async () => false),
});
const activate = vi
@@ -324,7 +330,7 @@ describe("runGuidedOnboarding", () => {
]);
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
expect(notes).toContain("kept unchanged");
expect(select).toHaveBeenCalledOnce();
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
expect(deps.runCrestodianChat).toHaveBeenCalledOnce();
});
@@ -368,17 +374,8 @@ describe("runGuidedOnboarding", () => {
});
it("offers an auto-attempted transient failure for manual retry", async () => {
const select = vi.fn(async (params: WizardSelectParams) => {
expect(params.options).toContainEqual(
expect.objectContaining({
value: "candidate:claude-cli",
label: "Retry Claude Code (logged in)",
}),
);
return "candidate:claude-cli";
}) as unknown as WizardPrompter["select"];
promptAuthChoiceGrouped.mockResolvedValueOnce("candidate:claude-cli");
const prompter = createWizardPrompter({
select,
confirm: vi.fn(async () => false),
});
const activate = vi
@@ -395,25 +392,35 @@ describe("runGuidedOnboarding", () => {
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(activate).toHaveBeenCalledTimes(2);
expect(select).toHaveBeenCalledOnce();
expect(promptAuthChoiceGrouped).toHaveBeenCalledWith(
expect.objectContaining({
additionalGroups: [
expect.objectContaining({
options: [
expect.objectContaining({
value: "candidate:claude-cli",
label: "Retry Claude Code (logged in)",
}),
],
}),
],
}),
);
expect(deps.runCrestodianChat).toHaveBeenCalledWith("/tmp/work", expect.anything(), true);
});
it("accepts and verifies a manual provider key without displaying it", async () => {
const enteredValue = "synthetic-value";
promptAuthChoiceGrouped.mockResolvedValueOnce("apiKey");
const text = vi.fn().mockResolvedValueOnce(enteredValue);
const select = vi.fn(
async () => "manual:openai-api-key",
) as unknown as WizardPrompter["select"];
const detect = vi.fn(async () =>
detection({
candidates: [],
manualProviders: [{ id: "openai-api-key", label: "OpenAI", hint: "API key" }],
manualProviders: [{ id: "apiKey", label: "Anthropic", hint: "API key" }],
}),
);
const prompter = createWizardPrompter({
text: text as WizardPrompter["text"],
select,
confirm: vi.fn(async () => false),
});
const activate = vi.fn(async () => ({
@@ -431,10 +438,13 @@ describe("runGuidedOnboarding", () => {
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
expect(promptAuthChoiceGrouped).toHaveBeenCalledWith(
expect.objectContaining({ allowedChoices: new Set(["apiKey"]) }),
);
expect(activate).toHaveBeenCalledWith(
expect.objectContaining({
kind: "api-key",
authChoice: "openai-api-key",
authChoice: "apiKey",
apiKey: enteredValue,
}),
);
@@ -446,6 +456,88 @@ describe("runGuidedOnboarding", () => {
expect(JSON.stringify([runtime.log, runtime.error])).not.toContain(enteredValue);
});
it("offers detected OAuth methods through the grouped provider picker", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("openai");
const text = vi.fn(async () => "unexpected");
const select = vi.fn(async () => "unexpected") as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({ text, select });
const activate = vi.fn(async () => ({
ok: true as const,
modelRef: "openai/gpt-5.5",
latencyMs: 500,
lines: ["Default model: openai/gpt-5.5"],
})) as GuidedOnboardingDeps["activate"];
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [],
manualProviders: [],
authOptions: [
{
id: "openai",
label: "ChatGPT Login",
hint: "Sign in with ChatGPT",
groupLabel: "OpenAI",
kind: "oauth",
featured: true,
},
],
}),
),
activate,
});
const runtime = makeRuntime();
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
expect(promptAuthChoiceGrouped).toHaveBeenCalledWith(
expect.objectContaining({
prompter,
includeSkip: true,
assistantVisibleOnly: false,
workspaceDir: "/tmp/work",
allowedChoices: new Set(["openai"]),
}),
);
expect(activate).toHaveBeenCalledWith({
kind: "provider-auth",
authChoice: "openai",
workspace: "/tmp/work",
surface: "cli",
runtime,
prompter,
});
expect(text).not.toHaveBeenCalled();
expect(select).not.toHaveBeenCalled();
});
it("lets the grouped provider picker skip without opening AI chat", async () => {
promptAuthChoiceGrouped.mockResolvedValueOnce("skip");
const prompter = createWizardPrompter();
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [],
manualProviders: [{ id: "openai-api-key", label: "OpenAI API Key" }],
}),
),
});
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, makeRuntime(), deps);
expect(promptAuthChoiceGrouped).toHaveBeenCalledWith(
expect.objectContaining({ includeSkip: true }),
);
expect(deps.activate).not.toHaveBeenCalled();
expect(deps.runCrestodianChat).not.toHaveBeenCalled();
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("Add AI later"),
"Next steps",
);
});
it("fails closed without opening an empty inference selector", async () => {
const select = vi.fn() as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({ select });
@@ -468,14 +560,10 @@ describe("runGuidedOnboarding", () => {
});
it("keeps Crestodian unavailable until a manual key passes", async () => {
promptAuthChoiceGrouped.mockResolvedValue("openai-api-key");
const text = vi.fn().mockResolvedValueOnce("bad-key").mockResolvedValueOnce("good-key");
const select = vi.fn(async (params: WizardSelectParams) => {
expect(params.options.map((option) => option.value)).toEqual(["manual:openai-api-key"]);
return "manual:openai-api-key";
}) as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({
text: text as WizardPrompter["text"],
select,
confirm: vi.fn(async () => false),
});
const runCrestodianChat = vi.fn(async () => {});
@@ -507,7 +595,7 @@ describe("runGuidedOnboarding", () => {
await runGuidedOnboarding({ acceptRisk: true, workspace: "/tmp/work" }, runtime, deps);
expect(activate.mock.calls.map(([call]) => call.apiKey)).toEqual(["bad-key", "good-key"]);
expect(select).toHaveBeenCalledTimes(2);
expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2);
expect(runCrestodianChat).toHaveBeenCalledOnce();
});
+90 -34
View File
@@ -1,5 +1,6 @@
import { formatCliCommand } from "../cli/command-format.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
// Guided onboarding: detect AI access, live-test it, then persist only a working route.
import type {
ActivateSetupInferenceResult,
@@ -13,6 +14,7 @@ import { resolveUserPath, shortenHomePath } from "../utils.js";
import { t } from "../wizard/i18n/index.js";
import { WizardCancelledError, type WizardPrompter } from "../wizard/prompts.js";
import { requireRiskAcknowledgement } from "../wizard/setup.shared.js";
import type { AuthChoiceGroup } from "./auth-choice-options.static.js";
import {
hasInteractiveOnboardingTty,
runInteractiveOnboarding,
@@ -127,43 +129,68 @@ async function tryCandidate(params: {
async function runManualStage(params: {
detection: SetupInferenceDetection;
autoAttemptedKinds: ReadonlySet<SetupInferenceCandidate["kind"]>;
config: OpenClawConfig;
workspace: string;
runtime: RuntimeEnv;
prompter: WizardPrompter;
activate: ActivateSetupInference;
}): Promise<string[]> {
const options = [
...params.detection.candidates.map((candidate) => ({
value: `candidate:${candidate.kind}`,
label: t(
params.autoAttemptedKinds.has(candidate.kind)
? "wizard.guided.retryCandidate"
: "wizard.guided.tryCandidate",
{
label: candidate.label,
detail: candidate.detail,
},
),
})),
...params.detection.manualProviders.map((provider) => ({
value: `manual:${provider.id}`,
label: t("wizard.guided.enterApiKey", { label: provider.label }),
...(provider.hint ? { hint: provider.hint } : {}),
})),
];
if (options.length === 0) {
}): Promise<string[] | null> {
const allowedChoices = new Set([
...params.detection.manualProviders.map((provider) => provider.id),
...params.detection.authOptions.map((option) => option.id),
]);
const detectedOptions = params.detection.candidates.map((candidate) => ({
value: `candidate:${candidate.kind}`,
label: t(
params.autoAttemptedKinds.has(candidate.kind)
? "wizard.guided.retryCandidate"
: "wizard.guided.tryCandidate",
{
label: candidate.label,
detail: candidate.detail,
},
),
}));
if (detectedOptions.length === 0 && allowedChoices.size === 0) {
await params.prompter.note(
t("wizard.guided.noInferenceOptions"),
t("wizard.guided.aiAccessTitle"),
);
throw new WizardCancelledError("no inference setup options");
}
const additionalGroups: AuthChoiceGroup[] = detectedOptions.length
? [
{
value: "detected-ai",
label: t("wizard.guided.detectedTitle"),
options: detectedOptions,
},
]
: [];
const [{ ensureAuthProfileStore }, { promptAuthChoiceGrouped }] = await Promise.all([
import("../agents/auth-profiles.runtime.js"),
import("./auth-choice-prompt.js"),
]);
const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
while (true) {
const choice = await params.prompter.select({
message: t("wizard.guided.manualChoice"),
options,
const choice = await promptAuthChoiceGrouped({
prompter: params.prompter,
store,
includeSkip: true,
assistantVisibleOnly: false,
allowedChoices,
additionalGroups,
config: params.config,
workspaceDir: params.workspace,
});
if (choice === "skip") {
await params.prompter.note(
t("wizard.guided.nextStepsWithoutAi", { workspace: params.workspace }),
t("wizard.guided.nextStepsTitle"),
);
return null;
}
if (choice.startsWith("candidate:")) {
const kind = choice.slice("candidate:".length);
const candidate = params.detection.candidates.find((item) => item.kind === kind);
@@ -183,8 +210,30 @@ async function runManualStage(params: {
continue;
}
const providerId = choice.slice("manual:".length);
const provider = params.detection.manualProviders.find((item) => item.id === providerId);
const authOption = params.detection.authOptions.find((item) => item.id === choice);
if (authOption) {
const result = await withConsoleSubsystemsSuppressed(() =>
params.activate({
kind: "provider-auth",
authChoice: authOption.id,
workspace: params.workspace,
surface: "cli",
runtime: params.runtime,
prompter: params.prompter,
}),
);
if (result.ok) {
return activationLines(result);
}
await noteActivationFailure({
prompter: params.prompter,
label: authOption.label,
result,
});
continue;
}
const provider = params.detection.manualProviders.find((item) => item.id === choice);
if (!provider) {
continue;
}
@@ -308,14 +357,21 @@ async function runGuidedOnboardingFlow(
break;
}
}
resultLines ??= await runManualStage({
detection,
autoAttemptedKinds,
workspace,
runtime,
prompter,
activate,
});
if (!resultLines) {
const manualResult = await runManualStage({
detection,
autoAttemptedKinds,
config: existingConfig,
workspace,
runtime,
prompter,
activate,
});
if (!manualResult) {
return null;
}
resultLines = manualResult;
}
await prompter.note(resultLines.join("\n"), t("wizard.guided.appliedTitle"));
return { workspace };
+9 -3
View File
@@ -1438,9 +1438,11 @@ describe("activateSetupInference", () => {
expect(transformConfig).not.toHaveBeenCalled();
});
it("treats an empty model reply as a failure", async () => {
it("treats an empty model reply as a failure with bounded probe identifiers", async () => {
const transformConfig = vi.fn();
const runEmbeddedAgent = vi.fn(async () => ({ payloads: [] }));
const runEmbeddedAgent = vi.fn(async (_params: { runId?: string; sessionId?: string }) => ({
payloads: [],
}));
const result = await activateSetupInference({
kind: "anthropic-api-key",
surface: "gateway",
@@ -1455,11 +1457,15 @@ describe("activateSetupInference", () => {
expect(runEmbeddedAgent).toHaveBeenCalledWith(
expect.objectContaining({
runId: expect.stringMatching(/^probe-setup-inference-/),
sessionId: expect.stringMatching(/^probe-setup-inference-.*-session$/),
sessionId: expect.stringMatching(/^probe-setup-inference-/),
sessionKey: expect.stringMatching(/^temp:setup-inference:probe-setup-inference-/),
lane: "session:probe-setup-inference:anthropic",
}),
);
const probeCall = runEmbeddedAgent.mock.calls[0]?.[0];
expect(probeCall).toBeDefined();
expect(probeCall?.sessionId).toBe(probeCall?.runId);
expect(probeCall?.sessionId).toHaveLength(58);
expect(transformConfig).not.toHaveBeenCalled();
});
+3 -3
View File
@@ -2799,10 +2799,10 @@ async function runSetupInferenceTest(params: {
}
> {
const { plan, tempDir, deps, authProfileStateMode, requireExecutionOwner } = params;
// Keep these probe prefixes aligned with logging/subsystem.ts and process/command-queue.ts
// so expected setup failures stay off the interactive TTY.
// Keep probe prefixes aligned with the logging filters; provider transports can also use the
// session id as cache affinity, so this ephemeral id must stay under OpenAI's 64-character cap.
const runId = `probe-setup-inference-${randomUUID()}`;
const sessionId = `${runId}-session`;
const sessionId = runId;
const sessionFile = path.join(tempDir, "session.jsonl");
const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS;
const started = Date.now();