mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
+10
-8
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user