mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 09:51:11 +00:00
fix(onboard): validate reset preflight (#111348)
This commit is contained in:
committed by
GitHub
parent
a868d34804
commit
d4ed08994d
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
35
src/plugins/provider-api-key-auth.test.ts
Normal file
35
src/plugins/provider-api-key-auth.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user