fix(onboard): validate reset preflight (#111348)

This commit is contained in:
Peter Steinberger
2026-07-19 02:42:49 -07:00
committed by GitHub
parent a868d34804
commit d4ed08994d
8 changed files with 1000 additions and 35 deletions

View File

@@ -1272,6 +1272,78 @@ describe("anthropic provider replay hooks", () => {
}
});
it("preflights non-interactive setup-token input without writing credentials", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth?.validateNonInteractive) {
throw new Error("expected setup-token reset preflight");
}
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const valid = await setupTokenAuth.validateNonInteractive({
authChoice: "setup-token",
config: {},
baseConfig: {},
opts: { token: ANTHROPIC_SETUP_TOKEN },
runtime,
resolveApiKey: vi.fn(async () => null),
});
expect(valid).toBe(true);
expect(runtime.error).not.toHaveBeenCalled();
});
it("rejects setup-token ref storage during non-interactive preflight", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth?.validateNonInteractive) {
throw new Error("expected setup-token reset preflight");
}
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const valid = await setupTokenAuth.validateNonInteractive({
authChoice: "setup-token",
config: {},
baseConfig: {},
opts: {
token: ANTHROPIC_SETUP_TOKEN,
secretInputMode: "ref", // pragma: allowlist secret
},
runtime,
resolveApiKey: vi.fn(async () => null),
});
expect(valid).toBe(false);
expect(runtime.error).toHaveBeenCalledWith(
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("rejects invalid setup-token expiry during non-interactive preflight", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
if (!setupTokenAuth?.validateNonInteractive) {
throw new Error("expected setup-token reset preflight");
}
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const valid = await setupTokenAuth.validateNonInteractive({
authChoice: "setup-token",
config: {},
baseConfig: {},
opts: { token: ANTHROPIC_SETUP_TOKEN, tokenExpiresIn: "nope" },
runtime,
resolveApiKey: vi.fn(async () => null),
});
expect(valid).toBe(false);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("Invalid --token-expires-in"),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("omits setup-token expiry when duration overflows the Date range", async () => {
vi.useFakeTimers();
vi.setSystemTime(8_640_000_000_000_000);

View File

@@ -7,6 +7,7 @@ import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-run
import type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderAuthMethod,
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderNormalizeResolvedModelContext,
@@ -60,6 +61,10 @@ import { registerClaudeSessionDiscovery } from "./session-catalog-registration.j
import { wrapAnthropicProviderStream } from "./stream-wrappers.js";
import { fetchAnthropicUsage, resolveAnthropicUsageAuth } from "./usage.js";
type ProviderAuthMethodNonInteractiveValidationContext = Parameters<
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
>[0];
const PROVIDER_ID = "anthropic";
// Anthropic-native error descriptors stay with the Anthropic provider hook.
@@ -194,9 +199,16 @@ async function runAnthropicSetupTokenAuth(ctx: ProviderAuthContext): Promise<Pro
};
}
async function runAnthropicSetupTokenNonInteractive(
ctx: ProviderAuthMethodNonInteractiveContext,
): Promise<ProviderAuthConfig | null> {
function validateAnthropicSetupTokenNonInteractive(
ctx: ProviderAuthMethodNonInteractiveValidationContext,
): string | null {
if (ctx.opts.secretInputMode === "ref") {
ctx.runtime.error(
"Anthropic setup-token input cannot be stored with --secret-input-mode ref. Use --secret-input-mode plaintext.",
);
ctx.runtime.exit(1);
return null;
}
const rawToken =
typeof ctx.opts.token === "string" ? normalizeAnthropicSetupTokenInput(ctx.opts.token) : "";
const tokenError = validateAnthropicSetupToken(rawToken);
@@ -209,6 +221,25 @@ async function runAnthropicSetupTokenNonInteractive(
ctx.runtime.exit(1);
return null;
}
try {
resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
} catch (error) {
ctx.runtime.error(
`Invalid --token-expires-in: ${error instanceof Error ? error.message : String(error)}`,
);
ctx.runtime.exit(1);
return null;
}
return rawToken;
}
async function runAnthropicSetupTokenNonInteractive(
ctx: ProviderAuthMethodNonInteractiveContext,
): Promise<ProviderAuthConfig | null> {
const rawToken = validateAnthropicSetupTokenNonInteractive(ctx);
if (!rawToken) {
return null;
}
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts.tokenProfileId);
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
@@ -836,6 +867,8 @@ export function buildAnthropicProvider(): ProviderPlugin {
groupHint: "Claude CLI + API key + token",
},
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupTokenAuth(ctx),
validateNonInteractive: async (ctx) =>
Boolean(validateAnthropicSetupTokenNonInteractive(ctx)),
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
await runAnthropicSetupTokenNonInteractive(ctx),
},

View File

@@ -36,7 +36,7 @@ function ensureWsUrl(value: string): string {
return trimmed;
}
function validateGatewayWebSocketUrl(value: string): string | undefined {
export function validateGatewayWebSocketUrl(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) {
return t("wizard.remote.validWebSocketUrl");

View File

@@ -2,14 +2,79 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ProviderAuthMethod } from "../plugins/types.js";
import type { RuntimeEnv } from "../runtime.js";
import { setupWizardCommand } from "./onboard.js";
type ConfigSnapshotStub = {
exists: boolean;
valid: boolean;
config: OpenClawConfig;
sourceConfig?: OpenClawConfig;
};
type ProviderAuthMethodNonInteractiveValidationContext = Parameters<
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
>[0];
const mocks = vi.hoisted(() => ({
runInteractiveSetup: vi.fn(async () => {}),
runGuidedOnboarding: vi.fn(async () => {}),
runNonInteractiveSetup: vi.fn(async () => {}),
readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })),
resolvePluginProviders: vi.fn(() => [
{
id: "anthropic",
label: "Anthropic",
auth: [
{
id: "setup-token",
label: "Setup token",
kind: "token",
wizard: { choiceId: "setup-token" },
run: vi.fn(),
runNonInteractive: vi.fn(),
validateNonInteractive: vi.fn(
async (ctx: ProviderAuthMethodNonInteractiveValidationContext) => {
if (ctx.opts.tokenExpiresIn === "nope") {
ctx.runtime.error("Invalid --token-expires-in: invalid duration");
ctx.runtime.exit(1);
return false;
}
return Boolean(ctx.opts.token);
},
),
},
{
id: "api-key",
label: "API key",
kind: "api_key",
wizard: { choiceId: "apiKey" },
run: vi.fn(),
runNonInteractive: vi.fn(),
validateNonInteractive: vi.fn(
async (ctx: ProviderAuthMethodNonInteractiveValidationContext) =>
Boolean(
await ctx.resolveApiKey({
provider: "anthropic",
flagValue:
typeof ctx.opts.anthropicApiKey === "string"
? ctx.opts.anthropicApiKey
: undefined,
flagName: "--anthropic-api-key",
envVar: "ANTHROPIC_API_KEY",
}),
),
),
},
],
},
]),
readConfigFileSnapshot: vi.fn<() => Promise<ConfigSnapshotStub>>(async () => ({
exists: false,
valid: false,
config: {},
})),
handleReset: vi.fn(async () => {}),
}));
@@ -27,9 +92,15 @@ vi.mock("./onboard-non-interactive.js", () => ({
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
resolveGatewayPort: () => 18_789,
}));
vi.mock("./onboard-helpers.js", () => ({
vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({
resolvePluginProviders: mocks.resolvePluginProviders,
}));
vi.mock("./onboard-helpers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./onboard-helpers.js")>()),
DEFAULT_WORKSPACE: "~/.openclaw/workspace",
handleReset: mocks.handleReset,
}));
@@ -59,6 +130,7 @@ function expectResetCall(params: { scope: string; runtime: RuntimeEnv; workspace
describe("setupWizardCommand", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: false, config: {} });
});
@@ -143,6 +215,92 @@ describe("setupWizardCommand", () => {
);
});
it("uses the parsed workspace for a full reset when the config schema is invalid", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: {
agents: {
defaults: {
workspace: "/tmp/openclaw-invalid-config-workspace",
},
},
},
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(mocks.handleReset).toHaveBeenCalledWith(
"full",
path.resolve("/tmp/openclaw-invalid-config-workspace"),
runtime,
);
expect(mocks.handleReset).not.toHaveBeenCalledWith(
"full",
path.resolve("~/.openclaw/workspace"),
runtime,
);
});
it("does not fall back to the default workspace when invalid config names no valid path", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: {
agents: {
defaults: {
workspace: 42,
},
},
} as unknown as OpenClawConfig,
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Configured workspace is invalid. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
});
it("requires an explicit workspace for a full reset when config is unreadable", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
});
await setupWizardCommand(
{
reset: true,
resetScope: "full",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Cannot determine the configured workspace from an unreadable config. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
});
it("accepts explicit --reset-scope full", async () => {
const runtime = makeRuntime();
@@ -178,6 +336,264 @@ describe("setupWizardCommand", () => {
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("fails fast for invalid non-interactive --mode before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
mode: "typo" as never,
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --mode "typo". Use "local" or "remote", or run ${formatCliCommand("openclaw onboard")} for interactive setup.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("fails fast for an empty non-interactive --mode before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
mode: "" as never,
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --mode "". Use "local" or "remote", or run ${formatCliCommand("openclaw onboard")} for interactive setup.`,
);
expect(mocks.handleReset).not.toHaveBeenCalled();
});
it("validates a remote URL before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
mode: "remote",
remoteUrl: "https://example.com",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(expect.any(String));
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates dependent gateway options before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
gatewayAuth: "password",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Missing --gateway-password for password auth. Pass --gateway-password or use --gateway-auth token.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates dependent auth-choice options before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "token",
token: "value",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
'Auth choice "token" requires --token-provider in non-interactive setup.',
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates a required setup token before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "setup-token",
tokenProvider: "anthropic",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
'Auth choice "setup-token" requires --token in non-interactive setup.',
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates setup-token expiry before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "setup-token",
tokenProvider: "anthropic",
token: "test-token",
tokenExpiresIn: "nope",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith("Invalid --token-expires-in: invalid duration");
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates the token provider before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "token",
tokenProvider: "typo",
token: "value",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
'Auth choice "token" was not matched to provider "typo".',
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates a provider-specific API key before reset", async () => {
const runtime = makeRuntime();
vi.stubEnv("ANTHROPIC_API_KEY", "");
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
authChoice: "apiKey",
tokenProvider: "anthropic",
anthropicApiKey: "",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
`Missing --anthropic-api-key (or ANTHROPIC_API_KEY in env). Export ANTHROPIC_API_KEY, pass --anthropic-api-key, or run ${formatCliCommand("openclaw onboard")} for interactive setup.`,
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates an inferred custom auth choice before reset", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
customBaseUrl: "https://example.com/v1",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
[
'Auth choice "custom-api-key" requires a base URL and model ID.',
"Use --custom-base-url and --custom-model-id.",
].join("\n"),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("validates custom credential storage before reset", async () => {
const runtime = makeRuntime();
vi.stubEnv("CUSTOM_API_KEY", "");
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
customBaseUrl: "https://example.com/v1",
customModelId: "test-model",
customApiKey: "test-token",
secretInputMode: "ref",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
[
"--custom-api-key cannot be used with --secret-input-mode ref unless CUSTOM_API_KEY is set in env.",
"Set CUSTOM_API_KEY in env and omit --custom-api-key, or use --secret-input-mode plaintext.",
].join("\n"),
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("rejects migration import before reset because provider input is not preplanned", async () => {
const runtime = makeRuntime();
await setupWizardCommand(
{
reset: true,
nonInteractive: true,
acceptRisk: true,
flow: "import",
importFrom: "hermes",
},
runtime,
);
expect(runtime.error).toHaveBeenCalledWith(
"Migration import cannot be combined with --reset because provider input must be planned before any state is removed. Run the import without --reset.",
);
expect(mocks.handleReset).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
});
it("routes flagless interactive onboarding to the guided flow", async () => {
const runtime = makeRuntime();

View File

@@ -5,8 +5,19 @@
* routes to interactive or non-interactive onboarding.
*/
import { formatCliCommand } from "../cli/command-format.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { formatInvalidPortOption } from "../cli/error-format.js";
import { readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { resolveProviderMatch } from "../plugins/provider-auth-choice-helpers.js";
import { resolvePluginProviders } from "../plugins/provider-auth-choice.runtime.js";
import {
type ProviderAuthChoiceMetadata,
resolveManifestProviderAuthChoices,
} from "../plugins/provider-auth-choices.js";
import { normalizeTokenProviderInput } from "../plugins/provider-auth-input.js";
import { resolveProviderInstallCatalogEntries } from "../plugins/provider-install-catalog.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
@@ -16,13 +27,335 @@ import {
normalizeLegacyOnboardAuthChoice,
resolveDeprecatedAuthChoiceReplacement,
} from "./auth-choice-legacy.js";
import { formatAuthChoiceChoicesForCli } from "./auth-choice-options.js";
import {
applyCustomApiConfig,
CustomApiError,
parseNonInteractiveCustomApiFlags,
resolveCustomProviderId,
} from "./onboard-custom-config.js";
import { runGuidedOnboarding } from "./onboard-guided.js";
import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js";
import { runInteractiveSetup } from "./onboard-interactive.js";
import { runNonInteractiveSetup } from "./onboard-non-interactive.js";
import { resolveNonInteractiveApiKey as resolveNonInteractiveCredential } from "./onboard-non-interactive/api-keys.js";
import { inferAuthChoiceFromFlags } from "./onboard-non-interactive/local/auth-choice-inference.js";
import { applyNonInteractiveGatewayConfig } from "./onboard-non-interactive/local/gateway-config.js";
import { validateGatewayWebSocketUrl } from "./onboard-remote.js";
import type { OnboardOptions, ResetScope } from "./onboard-types.js";
const VALID_RESET_SCOPES = new Set<ResetScope>(["config", "config+creds+sessions", "full"]);
const BUILT_IN_AUTH_CHOICES = ["setup-token", "token", "apiKey", "custom-api-key", "skip"];
function rejectOption(runtime: RuntimeEnv, message: string): false {
runtime.error(message);
runtime.exit(1);
return false;
}
function validateResetPreflightOptions(opts: OnboardOptions, runtime: RuntimeEnv): boolean {
if (opts.mode !== undefined && opts.mode !== "local" && opts.mode !== "remote") {
return rejectOption(
runtime,
`Invalid --mode "${String(opts.mode)}". Use "local" or "remote", or run ${formatCliCommand("openclaw onboard")} for interactive setup.`,
);
}
const choiceValidations: Array<readonly [string, string | undefined, readonly string[]]> = [
["--flow", opts.flow, ["quickstart", "advanced", "import"]],
["--gateway-bind", opts.gatewayBind, ["loopback", "tailnet", "lan", "auto", "custom"]],
["--gateway-auth", opts.gatewayAuth, ["token", "password"]],
["--tailscale", opts.tailscale, ["off", "serve", "funnel"]],
["--node-manager", opts.nodeManager, ["npm", "pnpm", "bun"]],
["--daemon-runtime", opts.daemonRuntime, ["node"]],
[
"--custom-compatibility",
opts.customCompatibility,
["openai", "openai-responses", "anthropic"],
],
];
for (const [flag, value, allowed] of choiceValidations) {
if (value !== undefined && !allowed.includes(value)) {
return rejectOption(
runtime,
`Invalid ${flag} ${JSON.stringify(value)}. Use ${allowed.map((choice) => JSON.stringify(choice)).join(", ")}.`,
);
}
}
if (
opts.gatewayPort !== undefined &&
(!Number.isFinite(opts.gatewayPort) || opts.gatewayPort <= 0 || opts.gatewayPort > 65_535)
) {
return rejectOption(runtime, formatInvalidPortOption("--gateway-port"));
}
if (opts.nonInteractive && opts.mode === "remote" && !opts.remoteUrl?.trim()) {
return rejectOption(
runtime,
`Missing --remote-url for remote mode. Example: ${formatCliCommand("openclaw onboard --non-interactive --mode remote --remote-url ws://127.0.0.1:3000")}.`,
);
}
if (opts.nonInteractive && opts.mode === "remote" && opts.remoteUrl?.trim()) {
const remoteUrlError = validateGatewayWebSocketUrl(opts.remoteUrl);
if (remoteUrlError) {
return rejectOption(runtime, remoteUrlError);
}
}
if (
opts.nonInteractive &&
(opts.flow === "import" || opts.importSource || opts.importSecrets) &&
!opts.importFrom?.trim()
) {
return rejectOption(
runtime,
`--import-from is required for non-interactive migration import. Run ${formatCliCommand("openclaw migrate list")} to choose a provider.`,
);
}
return true;
}
async function validateResetAuthChoice(params: {
opts: OnboardOptions;
runtime: RuntimeEnv;
baseConfig: OpenClawConfig;
workspaceDir: string;
resetScope: ResetScope;
}): Promise<boolean> {
const inferredAuthChoice =
params.opts.authChoice || !params.opts.nonInteractive
? undefined
: inferAuthChoiceFromFlags(params.opts, {
config: params.baseConfig,
workspaceDir: params.workspaceDir,
env: process.env,
});
if (inferredAuthChoice && inferredAuthChoice.matches.length > 1) {
return rejectOption(
params.runtime,
[
"Multiple API key flags were provided for non-interactive setup.",
"Use a single provider flag or pass --auth-choice explicitly.",
`Flags: ${inferredAuthChoice.matches.map((match) => match.label).join(", ")}`,
].join("\n"),
);
}
const authChoice = params.opts.authChoice ?? inferredAuthChoice?.choice;
if (!authChoice) {
return true;
}
const availableChoices = new Set([
...BUILT_IN_AUTH_CHOICES,
...formatAuthChoiceChoicesForCli({
includeLegacyAliases: true,
includeSkip: true,
config: params.baseConfig,
workspaceDir: params.workspaceDir,
env: process.env,
}).split("|"),
]);
if (!availableChoices.has(authChoice)) {
return rejectOption(
params.runtime,
`Auth choice "${authChoice}" was not matched to a provider setup flow. Run ${formatCliCommand("openclaw onboard")} to choose interactively.`,
);
}
const providerAuthChoices: Array<ProviderAuthChoiceMetadata & { providerAliases?: string[] }> = [
...resolveManifestProviderAuthChoices({
config: params.baseConfig,
workspaceDir: params.workspaceDir,
env: process.env,
includeUntrustedWorkspacePlugins: false,
}),
...resolveProviderInstallCatalogEntries({
config: params.baseConfig,
workspaceDir: params.workspaceDir,
env: process.env,
includeUntrustedWorkspacePlugins: false,
}),
];
const isGenericProviderChoice =
authChoice === "token" || authChoice === "setup-token" || authChoice === "apiKey";
const normalizedTokenProvider = normalizeTokenProviderInput(params.opts.tokenProvider);
const inferredOptionKey = inferredAuthChoice?.matches[0]?.optionKey;
const providerAuthChoice = isGenericProviderChoice
? providerAuthChoices.find((choice) => {
const providerMatches = normalizedTokenProvider
? normalizeTokenProviderInput(choice.providerId) === normalizedTokenProvider ||
choice.providerAliases?.some(
(alias) => normalizeTokenProviderInput(alias) === normalizedTokenProvider,
)
: inferredOptionKey !== undefined && choice.optionKey === inferredOptionKey;
const methodId = choice.methodId.toLowerCase();
const supportsAuthKind =
authChoice === "apiKey"
? methodId.includes("api") && methodId.includes("key")
: authChoice === "setup-token"
? methodId === "setup-token"
: methodId.includes("token");
return providerMatches && supportsAuthKind;
})
: providerAuthChoices.find((choice) => choice.choiceId === authChoice);
if (
params.opts.nonInteractive &&
isGenericProviderChoice &&
!normalizedTokenProvider &&
!inferredOptionKey
) {
return rejectOption(
params.runtime,
`Auth choice "${authChoice}" requires --token-provider in non-interactive setup.`,
);
}
if (
params.opts.nonInteractive &&
(authChoice === "token" || authChoice === "setup-token") &&
!params.opts.token?.trim()
) {
return rejectOption(
params.runtime,
`Auth choice "${authChoice}" requires --token in non-interactive setup.`,
);
}
if (params.opts.nonInteractive && isGenericProviderChoice && !providerAuthChoice) {
return rejectOption(
params.runtime,
`Auth choice "${authChoice}" was not matched to provider "${params.opts.tokenProvider?.trim()}".`,
);
}
if (params.opts.nonInteractive && authChoice === "custom-api-key") {
try {
const custom = parseNonInteractiveCustomApiFlags({
baseUrl: params.opts.customBaseUrl,
modelId: params.opts.customModelId,
compatibility: params.opts.customCompatibility,
apiKey: undefined,
providerId: params.opts.customProviderId,
supportsImageInput: params.opts.customImageInput,
});
const customProviderId = resolveCustomProviderId({
config: params.baseConfig,
baseUrl: custom.baseUrl,
providerId: custom.providerId,
}).providerId;
const customCredential = await resolveNonInteractiveCredential({
provider: customProviderId,
cfg: params.baseConfig,
flagValue: params.opts.customApiKey,
flagName: "--custom-api-key",
envVar: "CUSTOM_API_KEY",
runtime: params.runtime,
allowProfile: params.resetScope === "config",
required: false,
secretInputMode: params.opts.secretInputMode,
});
if (params.opts.customApiKey?.trim() && !customCredential) {
return false;
}
applyCustomApiConfig({
config: params.baseConfig,
baseUrl: custom.baseUrl,
modelId: custom.modelId,
compatibility: custom.compatibility,
apiKey: undefined,
providerId: custom.providerId,
supportsImageInput: custom.supportsImageInput,
});
} catch (error) {
const message =
error instanceof CustomApiError &&
(error.code === "missing_required" || error.code === "invalid_compatibility")
? error.message
: `Invalid custom provider config: ${formatErrorMessage(error)}`;
return rejectOption(params.runtime, message);
}
}
if (params.opts.nonInteractive && authChoice !== "custom-api-key" && authChoice !== "skip") {
const runtimeProvider = providerAuthChoice
? resolveProviderMatch(
resolvePluginProviders({
config: params.baseConfig,
workspaceDir: params.workspaceDir,
mode: "setup",
includeUntrustedWorkspacePlugins: false,
bundledProviderVitestCompat: true,
providerRefs: [providerAuthChoice.providerId],
activate: true,
}),
providerAuthChoice.providerId,
)
: null;
const runtimeMethod = runtimeProvider?.auth.find(
(method) =>
method.id === providerAuthChoice?.methodId ||
method.wizard?.choiceId === providerAuthChoice?.choiceId,
);
if (!runtimeMethod?.runNonInteractive || !runtimeMethod.validateNonInteractive) {
const reason = !runtimeMethod
? "provider unavailable"
: !runtimeMethod.runNonInteractive
? "non-interactive setup unsupported"
: "reset validation unavailable";
return rejectOption(
params.runtime,
`Auth choice "${authChoice}" cannot be safely preflighted with --reset (${reason}). Choose a provider method that supports non-interactive reset validation, or run setup without --reset.`,
);
}
const valid = await runtimeMethod.validateNonInteractive({
authChoice,
config: params.baseConfig,
baseConfig: params.baseConfig,
opts: params.opts,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
resolveApiKey: async (input) =>
await resolveNonInteractiveCredential({
...input,
cfg: params.baseConfig,
runtime: params.runtime,
allowProfile: input.allowProfile === false ? false : params.resetScope === "config",
secretInputMode: params.opts.secretInputMode,
}),
});
if (!valid) {
return false;
}
}
return true;
}
function validateResetMigrationImport(params: {
opts: OnboardOptions;
runtime: RuntimeEnv;
}): boolean {
if (
!params.opts.importFrom &&
!params.opts.importSource &&
!params.opts.importSecrets &&
params.opts.flow !== "import"
) {
return true;
}
return rejectOption(
params.runtime,
"Migration import cannot be combined with --reset because provider input must be planned before any state is removed. Run the import without --reset.",
);
}
function validateResetNonInteractiveGateway(params: {
opts: OnboardOptions;
runtime: RuntimeEnv;
baseConfig: OpenClawConfig;
}): boolean {
if (!params.opts.nonInteractive || (params.opts.mode ?? "local") === "remote") {
return true;
}
return Boolean(
applyNonInteractiveGatewayConfig({
nextConfig: params.baseConfig,
opts: params.opts,
runtime: params.runtime,
defaultPort: resolveGatewayPort(params.baseConfig),
}),
);
}
/**
* Interactive onboarding defaults to guided setup. Any explicit
@@ -134,17 +467,6 @@ export async function setupWizardCommand(
return;
}
if (normalizedOpts.reset) {
// Reset runs before setup mode dispatch so both interactive and
// non-interactive setup start from the same cleaned state.
const snapshot = await readConfigFileSnapshot();
const baseConfig = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {};
const workspaceDefault =
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE;
const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime);
}
if (process.platform === "win32") {
runtime.log(
[
@@ -156,15 +478,83 @@ export async function setupWizardCommand(
);
}
if (normalizedOpts.nonInteractive) {
await runNonInteractiveSetup(normalizedOpts, runtime);
return;
const runSetup = normalizedOpts.nonInteractive
? runNonInteractiveSetup
: wantsClassicInteractiveSetup(normalizedOpts)
? runInteractiveSetup
: runGuidedOnboarding;
if (normalizedOpts.reset) {
if (!validateResetPreflightOptions(normalizedOpts, runtime)) {
return;
}
const snapshot = await readConfigFileSnapshot();
const baseConfig = snapshot.sourceConfig ?? (snapshot.valid ? snapshot.config : {});
const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
// Every reset scope removes the config file. Validate setup against the
// empty config and requested/default workspace that dispatch will see.
const setupBaseConfig: OpenClawConfig = {};
const setupWorkspaceDir = resolveUserPath(normalizedOpts.workspace ?? DEFAULT_WORKSPACE);
const configuredWorkspace: unknown =
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace;
if (
resetScope === "full" &&
normalizedOpts.workspace === undefined &&
snapshot.exists &&
!snapshot.valid &&
!snapshot.sourceConfig
) {
rejectOption(
runtime,
"Cannot determine the configured workspace from an unreadable config. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
return;
}
if (
resetScope === "full" &&
configuredWorkspace !== undefined &&
(typeof configuredWorkspace !== "string" || !configuredWorkspace.trim())
) {
rejectOption(
runtime,
"Configured workspace is invalid. Pass --workspace with the workspace to remove, or use a narrower --reset-scope.",
);
return;
}
// Non-full scopes never touch the workspace, so the fallback is only an
// inert handleReset argument when an invalid config contains bad data.
const workspaceDir = resolveUserPath(
typeof configuredWorkspace === "string" && configuredWorkspace.trim()
? configuredWorkspace
: DEFAULT_WORKSPACE,
);
if (
!(await validateResetAuthChoice({
opts: normalizedOpts,
runtime,
baseConfig: setupBaseConfig,
workspaceDir: setupWorkspaceDir,
resetScope,
}))
) {
return;
}
if (
!validateResetNonInteractiveGateway({
opts: normalizedOpts,
runtime,
baseConfig: setupBaseConfig,
})
) {
return;
}
if (!validateResetMigrationImport({ opts: normalizedOpts, runtime })) {
return;
}
// Reset is deliberately the final pre-dispatch step: no rejectable option
// checks may run after user state has moved to Trash.
await handleReset(resetScope, workspaceDir, runtime);
}
if (wantsClassicInteractiveSetup(normalizedOpts)) {
await runInteractiveSetup(normalizedOpts, runtime);
return;
}
await runGuidedOnboarding(normalizedOpts, runtime);
await runSetup(normalizedOpts, runtime);
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { createProviderApiKeyAuthMethod } from "./provider-api-key-auth.js";
describe("createProviderApiKeyAuthMethod", () => {
it("exposes side-effect-free non-interactive credential validation", async () => {
const method = createProviderApiKeyAuthMethod({
providerId: "example",
methodId: "api-key",
label: "Example",
optionKey: "exampleApiKey",
flagName: "--example-api-key",
envVar: "EXAMPLE_API_KEY",
promptMessage: "Example API key",
});
const resolveApiKey = vi.fn(async () => ({ key: "test-token", source: "flag" as const }));
const valid = await method.validateNonInteractive?.({
authChoice: "example-api-key",
config: {},
baseConfig: {},
opts: { exampleApiKey: "test-token" },
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv,
resolveApiKey,
});
expect(valid).toBe(true);
expect(resolveApiKey).toHaveBeenCalledWith({
provider: "example",
flagValue: "test-token",
flagName: "--example-api-key",
envVar: "EXAMPLE_API_KEY",
});
});
});

View File

@@ -13,6 +13,10 @@ import type {
ProviderPluginWizardSetup,
} from "./types.js";
type ProviderAuthMethodNonInteractiveValidationContext = Parameters<
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
>[0];
type ProviderApiKeyAuthMethodOptions = {
providerId: string;
methodId: string;
@@ -107,6 +111,18 @@ async function applyApiKeyConfig(params: {
export function createProviderApiKeyAuthMethod(
params: ProviderApiKeyAuthMethodOptions,
): ProviderAuthMethod {
const resolveNonInteractiveCredential = async (
ctx: ProviderAuthMethodNonInteractiveValidationContext,
) => {
const opts = ctx.opts as Record<string, unknown> | undefined;
return await ctx.resolveApiKey({
provider: params.providerId,
flagValue: resolveStringOption(opts, params.optionKey),
flagName: params.flagName,
envVar: params.envVar,
...(params.allowProfile === false ? { allowProfile: false } : {}),
});
};
return {
id: params.methodId,
label: params.label,
@@ -179,15 +195,9 @@ export function createProviderApiKeyAuthMethod(
...(params.defaultModel ? { defaultModel: params.defaultModel } : {}),
};
},
validateNonInteractive: async (ctx) => Boolean(await resolveNonInteractiveCredential(ctx)),
runNonInteractive: async (ctx) => {
const opts = ctx.opts as Record<string, unknown> | undefined;
const resolved = await ctx.resolveApiKey({
provider: params.providerId,
flagValue: resolveStringOption(opts, params.optionKey),
flagName: params.flagName,
envVar: params.envVar,
...(params.allowProfile === false ? { allowProfile: false } : {}),
});
const resolved = await resolveNonInteractiveCredential(ctx);
if (!resolved) {
return null;
}

View File

@@ -113,6 +113,11 @@ export type ProviderAuthMethodNonInteractiveContext = {
) => ApiKeyCredential | null;
};
type ProviderAuthMethodNonInteractiveValidationContext = Omit<
ProviderAuthMethodNonInteractiveContext,
"toApiKeyCredential"
>;
/** Read-only context for app-guided discovery of already available inference. */
export type ProviderAppGuidedSetupContext = {
config: OpenClawConfig;
@@ -156,6 +161,10 @@ export type ProviderAuthMethod = {
runNonInteractive?: (
ctx: ProviderAuthMethodNonInteractiveContext,
) => Promise<OpenClawConfig | null>;
/** Side-effect-free prerequisite validation used before destructive reset handling. */
validateNonInteractive?: (
ctx: ProviderAuthMethodNonInteractiveValidationContext,
) => Promise<boolean>;
/** Provider-owned local model discovery for the shared guided setup ladder. */
appGuidedSetup?: ProviderAppGuidedSetup;
};