diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index e07a70e918e6..ff86a5bc74f1 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -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); diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index a5f768348cb4..a697fafdd577 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -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 +>[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 { +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 { + 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), }, diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index 7b668b7cc06e..8a20208cbd9e 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -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"); diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 8a0979c6e887..052179442925 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -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 +>[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>(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()), 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(); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 486fa3d5e778..154c9cec4ad6 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -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(["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 = [ + ["--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 { + 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 = [ + ...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); } diff --git a/src/plugins/provider-api-key-auth.test.ts b/src/plugins/provider-api-key-auth.test.ts new file mode 100644 index 000000000000..e4aa3a4d23ff --- /dev/null +++ b/src/plugins/provider-api-key-auth.test.ts @@ -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", + }); + }); +}); diff --git a/src/plugins/provider-api-key-auth.ts b/src/plugins/provider-api-key-auth.ts index 9a1b883f6c01..f9bb3c2832c0 100644 --- a/src/plugins/provider-api-key-auth.ts +++ b/src/plugins/provider-api-key-auth.ts @@ -13,6 +13,10 @@ import type { ProviderPluginWizardSetup, } from "./types.js"; +type ProviderAuthMethodNonInteractiveValidationContext = Parameters< + NonNullable +>[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 | 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 | 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; } diff --git a/src/plugins/provider-authentication.types.ts b/src/plugins/provider-authentication.types.ts index 23ee77a68561..7b7538e619d1 100644 --- a/src/plugins/provider-authentication.types.ts +++ b/src/plugins/provider-authentication.types.ts @@ -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; + /** Side-effect-free prerequisite validation used before destructive reset handling. */ + validateNonInteractive?: ( + ctx: ProviderAuthMethodNonInteractiveValidationContext, + ) => Promise; /** Provider-owned local model discovery for the shared guided setup ladder. */ appGuidedSetup?: ProviderAppGuidedSetup; };