diff --git a/src/commands/auth-choice.apply.plugin-provider.runtime.ts b/src/commands/auth-choice.apply.plugin-provider.runtime.ts index 9fb990318ad..c1a54580ca7 100644 --- a/src/commands/auth-choice.apply.plugin-provider.runtime.ts +++ b/src/commands/auth-choice.apply.plugin-provider.runtime.ts @@ -1,5 +1 @@ -export { - resolveProviderPluginChoice, - runProviderModelSelectedHook, -} from "../plugins/provider-wizard.js"; -export { resolvePluginProviders } from "../plugins/providers.js"; +export * from "../plugins/provider-auth-choice.runtime.js"; diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index 1e731fde48f..40de6a48994 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -13,7 +13,7 @@ const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), ); const runProviderModelSelectedHook = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("./auth-choice.apply.plugin-provider.runtime.js", () => ({ +vi.mock("../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook, @@ -49,20 +49,17 @@ vi.mock("../plugins/provider-auth-helpers.js", () => ({ })); const isRemoteEnvironment = vi.hoisted(() => vi.fn(() => false)); -vi.mock("./oauth-env.js", () => ({ +const openUrl = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../plugins/setup-browser.js", () => ({ isRemoteEnvironment, + openUrl, })); const createVpsAwareOAuthHandlers = vi.hoisted(() => vi.fn()); -vi.mock("./oauth-flow.js", () => ({ +vi.mock("../plugins/provider-oauth-flow.js", () => ({ createVpsAwareOAuthHandlers, })); -const openUrl = vi.hoisted(() => vi.fn(async () => {})); -vi.mock("./onboard-helpers.js", () => ({ - openUrl, -})); - function buildProvider(): ProviderPlugin { return { id: "ollama", diff --git a/src/commands/auth-choice.apply.plugin-provider.ts b/src/commands/auth-choice.apply.plugin-provider.ts index ce459020039..aa0f17e4e2f 100644 --- a/src/commands/auth-choice.apply.plugin-provider.ts +++ b/src/commands/auth-choice.apply.plugin-provider.ts @@ -1,295 +1 @@ -import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; -import { - resolveDefaultAgentId, - resolveAgentDir, - resolveAgentWorkspaceDir, -} from "../agents/agent-scope.js"; -import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; -import { enablePluginInConfig } from "../plugins/enable.js"; -import { applyAuthProfileConfig } from "../plugins/provider-auth-helpers.js"; -import type { ProviderAuthMethod, ProviderAuthOptionBag } from "../plugins/types.js"; -import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; -import { isRemoteEnvironment } from "./oauth-env.js"; -import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; -import { openUrl } from "./onboard-helpers.js"; -import type { OnboardOptions } from "./onboard-types.js"; -import { - applyDefaultModel, - mergeConfigPatch, - pickAuthMethod, - resolveProviderMatch, -} from "./provider-auth-helpers.js"; - -export type PluginProviderAuthChoiceOptions = { - authChoice: string; - pluginId: string; - providerId: string; - methodId?: string; - label: string; -}; - -function restoreConfiguredPrimaryModel( - nextConfig: ApplyAuthChoiceParams["config"], - originalConfig: ApplyAuthChoiceParams["config"], -): ApplyAuthChoiceParams["config"] { - const originalModel = originalConfig.agents?.defaults?.model; - const nextAgents = nextConfig.agents; - const nextDefaults = nextAgents?.defaults; - if (!nextDefaults) { - return nextConfig; - } - if (originalModel !== undefined) { - return { - ...nextConfig, - agents: { - ...nextAgents, - defaults: { - ...nextDefaults, - model: originalModel, - }, - }, - }; - } - const { model: _model, ...restDefaults } = nextDefaults; - return { - ...nextConfig, - agents: { - ...nextAgents, - defaults: restDefaults, - }, - }; -} - -async function loadPluginProviderRuntime() { - return import("./auth-choice.apply.plugin-provider.runtime.js"); -} - -export async function runProviderPluginAuthMethod(params: { - config: ApplyAuthChoiceParams["config"]; - runtime: ApplyAuthChoiceParams["runtime"]; - prompter: ApplyAuthChoiceParams["prompter"]; - method: ProviderAuthMethod; - agentDir?: string; - agentId?: string; - workspaceDir?: string; - emitNotes?: boolean; - secretInputMode?: OnboardOptions["secretInputMode"]; - allowSecretRefPrompt?: boolean; - opts?: Partial; -}): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> { - const agentId = params.agentId ?? resolveDefaultAgentId(params.config); - const defaultAgentId = resolveDefaultAgentId(params.config); - const agentDir = - params.agentDir ?? - (agentId === defaultAgentId - ? resolveOpenClawAgentDir() - : resolveAgentDir(params.config, agentId)); - const workspaceDir = - params.workspaceDir ?? - resolveAgentWorkspaceDir(params.config, agentId) ?? - resolveDefaultAgentWorkspaceDir(); - - const isRemote = isRemoteEnvironment(); - const result = await params.method.run({ - config: params.config, - agentDir, - workspaceDir, - prompter: params.prompter, - runtime: params.runtime, - opts: params.opts as ProviderAuthOptionBag | undefined, - secretInputMode: params.secretInputMode, - allowSecretRefPrompt: params.allowSecretRefPrompt, - isRemote, - openUrl: async (url) => { - await openUrl(url); - }, - oauth: { - createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts), - }, - }); - - let nextConfig = params.config; - if (result.configPatch) { - nextConfig = mergeConfigPatch(nextConfig, result.configPatch); - } - - for (const profile of result.profiles) { - upsertAuthProfile({ - profileId: profile.profileId, - credential: profile.credential, - agentDir, - }); - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: profile.profileId, - provider: profile.credential.provider, - mode: profile.credential.type === "token" ? "token" : profile.credential.type, - ...("email" in profile.credential && profile.credential.email - ? { email: profile.credential.email } - : {}), - }); - } - - if (params.emitNotes !== false && result.notes && result.notes.length > 0) { - await params.prompter.note(result.notes.join("\n"), "Provider notes"); - } - - return { - config: nextConfig, - defaultModel: result.defaultModel, - }; -} - -export async function applyAuthChoiceLoadedPluginProvider( - params: ApplyAuthChoiceParams, -): Promise { - const agentId = params.agentId ?? resolveDefaultAgentId(params.config); - const workspaceDir = - resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); - const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = - await loadPluginProviderRuntime(); - const providers = resolvePluginProviders({ - config: params.config, - workspaceDir, - bundledProviderAllowlistCompat: true, - bundledProviderVitestCompat: true, - }); - const resolved = resolveProviderPluginChoice({ - providers, - choice: params.authChoice, - }); - if (!resolved) { - return null; - } - - const applied = await runProviderPluginAuthMethod({ - config: params.config, - runtime: params.runtime, - prompter: params.prompter, - method: resolved.method, - agentDir: params.agentDir, - agentId: params.agentId, - workspaceDir, - secretInputMode: params.opts?.secretInputMode, - allowSecretRefPrompt: false, - opts: params.opts as ProviderAuthOptionBag | undefined, - }); - - let nextConfig = applied.config; - let agentModelOverride: string | undefined; - if (applied.defaultModel) { - if (params.setDefaultModel) { - nextConfig = applyDefaultModel(nextConfig, applied.defaultModel); - await runProviderModelSelectedHook({ - config: nextConfig, - model: applied.defaultModel, - prompter: params.prompter, - agentDir: params.agentDir, - workspaceDir, - }); - await params.prompter.note( - `Default model set to ${applied.defaultModel}`, - "Model configured", - ); - return { config: nextConfig }; - } - nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); - agentModelOverride = applied.defaultModel; - } - - return { config: nextConfig, agentModelOverride }; -} - -export async function applyAuthChoicePluginProvider( - params: ApplyAuthChoiceParams, - options: PluginProviderAuthChoiceOptions, -): Promise { - if (params.authChoice !== options.authChoice) { - return null; - } - - const enableResult = enablePluginInConfig(params.config, options.pluginId); - let nextConfig = enableResult.config; - if (!enableResult.enabled) { - await params.prompter.note( - `${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`, - options.label, - ); - return { config: nextConfig }; - } - - const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig); - const defaultAgentId = resolveDefaultAgentId(nextConfig); - const agentDir = - params.agentDir ?? - (agentId === defaultAgentId ? resolveOpenClawAgentDir() : resolveAgentDir(nextConfig, agentId)); - const workspaceDir = - resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); - - const { resolvePluginProviders, runProviderModelSelectedHook } = - await loadPluginProviderRuntime(); - const providers = resolvePluginProviders({ - config: nextConfig, - workspaceDir, - bundledProviderAllowlistCompat: true, - bundledProviderVitestCompat: true, - }); - const provider = resolveProviderMatch(providers, options.providerId); - if (!provider) { - await params.prompter.note( - `${options.label} auth plugin is not available. Enable it and re-run onboarding.`, - options.label, - ); - return { config: nextConfig }; - } - - const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0]; - if (!method) { - await params.prompter.note(`${options.label} auth method missing.`, options.label); - return { config: nextConfig }; - } - - const applied = await runProviderPluginAuthMethod({ - config: nextConfig, - runtime: params.runtime, - prompter: params.prompter, - method, - agentDir, - agentId, - workspaceDir, - secretInputMode: params.opts?.secretInputMode, - allowSecretRefPrompt: false, - opts: params.opts as ProviderAuthOptionBag | undefined, - }); - nextConfig = applied.config; - - let agentModelOverride: string | undefined; - if (applied.defaultModel) { - if (params.setDefaultModel) { - nextConfig = applyDefaultModel(nextConfig, applied.defaultModel); - await runProviderModelSelectedHook({ - config: nextConfig, - model: applied.defaultModel, - prompter: params.prompter, - agentDir, - workspaceDir, - }); - await params.prompter.note( - `Default model set to ${applied.defaultModel}`, - "Model configured", - ); - } else { - nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); - } - if (!params.setDefaultModel && params.agentId) { - agentModelOverride = applied.defaultModel; - await params.prompter.note( - `Default model set to ${applied.defaultModel} for agent "${params.agentId}".`, - "Model configured", - ); - } - } - - return { config: nextConfig, agentModelOverride }; -} +export * from "../plugins/provider-auth-choice.js"; diff --git a/src/commands/auth-choice.apply.ts b/src/commands/auth-choice.apply.ts index cf96b8f8905..a2b8f31c206 100644 --- a/src/commands/auth-choice.apply.ts +++ b/src/commands/auth-choice.apply.ts @@ -1,11 +1,11 @@ import type { OpenClawConfig } from "../config/config.js"; +import { applyAuthChoiceLoadedPluginProvider } from "../plugins/provider-auth-choice.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; import { normalizeApiKeyTokenProviderAuthChoice } from "./auth-choice.apply.api-providers.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; -import { applyAuthChoiceLoadedPluginProvider } from "./auth-choice.apply.plugin-provider.js"; import type { AuthChoice, OnboardOptions } from "./onboard-types.js"; export type ApplyAuthChoiceParams = { diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 7cab79d2215..7b8189414cf 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -1,47 +1 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { resolveManifestProviderAuthChoice } from "../plugins/provider-auth-choices.js"; -import { normalizeLegacyOnboardAuthChoice } from "./auth-choice-legacy.js"; -import type { AuthChoice } from "./onboard-types.js"; - -const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { - chutes: "chutes", - "litellm-api-key": "litellm", - "custom-api-key": "custom", -}; - -export async function resolvePreferredProviderForAuthChoice(params: { - choice: AuthChoice; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; -}): Promise { - const choice = normalizeLegacyOnboardAuthChoice(params.choice) ?? params.choice; - const manifestResolved = resolveManifestProviderAuthChoice(choice, params); - if (manifestResolved) { - return manifestResolved.providerId; - } - const [{ resolveProviderPluginChoice }, { resolvePluginProviders }] = await Promise.all([ - import("../plugins/provider-wizard.js"), - import("../plugins/providers.js"), - ]); - const providers = resolvePluginProviders({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - bundledProviderAllowlistCompat: true, - bundledProviderVitestCompat: true, - }); - const pluginResolved = resolveProviderPluginChoice({ - providers, - choice, - }); - if (pluginResolved) { - return pluginResolved.provider.id; - } - - const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice]; - if (preferred) { - return preferred; - } - return undefined; -} +export * from "../plugins/provider-auth-choice-preference.js"; diff --git a/src/commands/model-picker.runtime.ts b/src/commands/model-picker.runtime.ts index 74c4f68c605..3d033fa3e80 100644 --- a/src/commands/model-picker.runtime.ts +++ b/src/commands/model-picker.runtime.ts @@ -4,4 +4,4 @@ export { runProviderModelSelectedHook, } from "../plugins/provider-wizard.js"; export { resolvePluginProviders } from "../plugins/providers.js"; -export { runProviderPluginAuthMethod } from "./auth-choice.apply.plugin-provider.js"; +export { runProviderPluginAuthMethod } from "../plugins/provider-auth-choice.js"; diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts index f993091dd49..3ccee9bbfd3 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.test.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../../config/config.js"; import { applyNonInteractivePluginProviderChoice } from "./auth-choice.plugin-providers.js"; const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => undefined)); -vi.mock("../../auth-choice.preferred-provider.js", () => ({ +vi.mock("../../../plugins/provider-auth-choice-preference.js", () => ({ resolvePreferredProviderForAuthChoice, })); diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 54f25857441..b7a369e4674 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -7,13 +7,13 @@ import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js"; import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { enablePluginInConfig } from "../../../plugins/enable.js"; +import { resolvePreferredProviderForAuthChoice } from "../../../plugins/provider-auth-choice-preference.js"; import type { ProviderAuthOptionBag, ProviderNonInteractiveApiKeyCredentialParams, ProviderResolveNonInteractiveApiKeyParams, } from "../../../plugins/types.js"; import type { RuntimeEnv } from "../../../runtime.js"; -import { resolvePreferredProviderForAuthChoice } from "../../auth-choice.preferred-provider.js"; import type { OnboardOptions } from "../../onboard-types.js"; const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:"; diff --git a/src/commands/provider-auth-helpers.ts b/src/commands/provider-auth-helpers.ts index f36c1c3de73..a9fabf9f1bd 100644 --- a/src/commands/provider-auth-helpers.ts +++ b/src/commands/provider-auth-helpers.ts @@ -1,82 +1 @@ -import { normalizeProviderId } from "../agents/model-selection.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js"; - -export function resolveProviderMatch( - providers: ProviderPlugin[], - rawProvider?: string, -): ProviderPlugin | null { - const raw = rawProvider?.trim(); - if (!raw) { - return null; - } - const normalized = normalizeProviderId(raw); - return ( - providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? - providers.find( - (provider) => - provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, - ) ?? - null - ); -} - -export function pickAuthMethod( - provider: ProviderPlugin, - rawMethod?: string, -): ProviderAuthMethod | null { - const raw = rawMethod?.trim(); - if (!raw) { - return null; - } - const normalized = raw.toLowerCase(); - return ( - provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? - provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? - null - ); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -export function mergeConfigPatch(base: T, patch: unknown): T { - if (!isPlainRecord(base) || !isPlainRecord(patch)) { - return patch as T; - } - - const next: Record = { ...base }; - for (const [key, value] of Object.entries(patch)) { - const existing = next[key]; - if (isPlainRecord(existing) && isPlainRecord(value)) { - next[key] = mergeConfigPatch(existing, value); - } else { - next[key] = value; - } - } - return next as T; -} - -export function applyDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[model] = models[model] ?? {}; - - const existingModel = cfg.agents?.defaults?.model; - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - model: { - ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel - ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } - : undefined), - primary: model, - }, - }, - }, - }; -} +export * from "../plugins/provider-auth-choice-helpers.js"; diff --git a/src/commands/test-wizard-helpers.ts b/src/commands/test-wizard-helpers.ts index 078cd5ef87c..77d6eaa0754 100644 --- a/src/commands/test-wizard-helpers.ts +++ b/src/commands/test-wizard-helpers.ts @@ -1,92 +1 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { vi } from "vitest"; -import type { RuntimeEnv } from "../runtime.js"; -import { makeTempWorkspace } from "../test-helpers/workspace.js"; -import { captureEnv } from "../test-utils/env.js"; -import type { WizardPrompter } from "../wizard/prompts.js"; - -export const noopAsync = async () => {}; -export const noop = () => {}; - -export function createExitThrowingRuntime(): RuntimeEnv { - return { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn((code: number) => { - throw new Error(`exit:${code}`); - }), - }; -} - -export function createWizardPrompter( - overrides: Partial, - options?: { defaultSelect?: string }, -): WizardPrompter { - return { - intro: vi.fn(noopAsync), - outro: vi.fn(noopAsync), - note: vi.fn(noopAsync), - select: vi.fn(async () => (options?.defaultSelect ?? "") as never), - multiselect: vi.fn(async () => []), - text: vi.fn(async () => "") as unknown as WizardPrompter["text"], - confirm: vi.fn(async () => false), - progress: vi.fn(() => ({ update: noop, stop: noop })), - ...overrides, - }; -} - -export async function setupAuthTestEnv( - prefix = "openclaw-auth-", - options?: { agentSubdir?: string }, -): Promise<{ - stateDir: string; - agentDir: string; -}> { - const stateDir = await makeTempWorkspace(prefix); - const agentDir = path.join(stateDir, options?.agentSubdir ?? "agent"); - process.env.OPENCLAW_STATE_DIR = stateDir; - process.env.OPENCLAW_AGENT_DIR = agentDir; - process.env.PI_CODING_AGENT_DIR = agentDir; - await fs.mkdir(agentDir, { recursive: true }); - return { stateDir, agentDir }; -} - -export type AuthTestLifecycle = { - setStateDir: (stateDir: string) => void; - cleanup: () => Promise; -}; - -export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle { - const envSnapshot = captureEnv(envKeys); - let stateDir: string | null = null; - return { - setStateDir(nextStateDir: string) { - stateDir = nextStateDir; - }, - async cleanup() { - if (stateDir) { - await fs.rm(stateDir, { recursive: true, force: true }); - stateDir = null; - } - envSnapshot.restore(); - }, - }; -} - -export function requireOpenClawAgentDir(): string { - const agentDir = process.env.OPENCLAW_AGENT_DIR; - if (!agentDir) { - throw new Error("OPENCLAW_AGENT_DIR not set"); - } - return agentDir; -} - -export function authProfilePathForAgent(agentDir: string): string { - return path.join(agentDir, "auth-profiles.json"); -} - -export async function readAuthProfilesForAgent(agentDir: string): Promise { - const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8"); - return JSON.parse(raw) as T; -} +export * from "../../test/helpers/auth-wizard.js"; diff --git a/src/plugins/contracts/auth-choice.contract.test.ts b/src/plugins/contracts/auth-choice.contract.test.ts index 631df701933..7f3f6535e54 100644 --- a/src/plugins/contracts/auth-choice.contract.test.ts +++ b/src/plugins/contracts/auth-choice.contract.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; -import { applyAuthChoiceLoadedPluginProvider } from "../../commands/auth-choice.apply.plugin-provider.js"; import { createAuthTestLifecycle, createExitThrowingRuntime, @@ -8,18 +6,20 @@ import { readAuthProfilesForAgent, requireOpenClawAgentDir, setupAuthTestEnv, -} from "../../commands/test-wizard-helpers.js"; +} from "../../../test/helpers/auth-wizard.js"; +import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js"; +import { applyAuthChoiceLoadedPluginProvider } from "../../plugins/provider-auth-choice.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import { buildProviderPluginMethodChoice } from "../provider-wizard.js"; import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js"; type ResolvePluginProviders = - typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolvePluginProviders; + typeof import("../../plugins/provider-auth-choice.runtime.js").resolvePluginProviders; type ResolveProviderPluginChoice = - typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").resolveProviderPluginChoice; + typeof import("../../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice; type RunProviderModelSelectedHook = - typeof import("../../commands/auth-choice.apply.plugin-provider.runtime.js").runProviderModelSelectedHook; + typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook; const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); @@ -38,7 +38,7 @@ vi.mock("../../providers/github-copilot-auth.js", () => ({ githubCopilotLoginCommand: githubCopilotLoginCommandMock, })); -vi.mock("../../commands/auth-choice.apply.plugin-provider.runtime.js", () => ({ +vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({ resolvePluginProviders: resolvePluginProvidersMock, resolveProviderPluginChoice: resolveProviderPluginChoiceMock, runProviderModelSelectedHook: runProviderModelSelectedHookMock, @@ -54,7 +54,7 @@ vi.mock("../../plugins/providers.js", async () => { }); const { resolvePreferredProviderForAuthChoice } = - await import("../../commands/auth-choice.preferred-provider.js"); + await import("../../plugins/provider-auth-choice-preference.js"); type StoredAuthProfile = { type?: string; diff --git a/src/plugins/contracts/auth.contract.test.ts b/src/plugins/contracts/auth.contract.test.ts index cca85917c59..1b8c809f9df 100644 --- a/src/plugins/contracts/auth.contract.test.ts +++ b/src/plugins/contracts/auth.contract.test.ts @@ -14,19 +14,19 @@ import type { import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; type LoginOpenAICodexOAuth = - (typeof import("../../commands/openai-codex-oauth.js"))["loginOpenAICodexOAuth"]; + (typeof import("../../plugins/provider-openai-codex-oauth.js"))["loginOpenAICodexOAuth"]; type LoginQwenPortalOAuth = (typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"]; type GithubCopilotLoginCommand = (typeof import("../../providers/github-copilot-auth.js"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = - (typeof import("../../commands/oauth-flow.js"))["createVpsAwareOAuthHandlers"]; + (typeof import("../../plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); -vi.mock("../../commands/openai-codex-oauth.js", () => ({ +vi.mock("../../plugins/provider-openai-codex-oauth.js", () => ({ loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, })); diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts new file mode 100644 index 00000000000..d9ce7a57db8 --- /dev/null +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -0,0 +1,82 @@ +import { normalizeProviderId } from "../agents/model-selection.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { ProviderAuthMethod, ProviderPlugin } from "./types.js"; + +export function resolveProviderMatch( + providers: ProviderPlugin[], + rawProvider?: string, +): ProviderPlugin | null { + const raw = rawProvider?.trim(); + if (!raw) { + return null; + } + const normalized = normalizeProviderId(raw); + return ( + providers.find((provider) => normalizeProviderId(provider.id) === normalized) ?? + providers.find( + (provider) => + provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false, + ) ?? + null + ); +} + +export function pickAuthMethod( + provider: ProviderPlugin, + rawMethod?: string, +): ProviderAuthMethod | null { + const raw = rawMethod?.trim(); + if (!raw) { + return null; + } + const normalized = raw.toLowerCase(); + return ( + provider.auth.find((method) => method.id.toLowerCase() === normalized) ?? + provider.auth.find((method) => method.label.toLowerCase() === normalized) ?? + null + ); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +export function mergeConfigPatch(base: T, patch: unknown): T { + if (!isPlainRecord(base) || !isPlainRecord(patch)) { + return patch as T; + } + + const next: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + const existing = next[key]; + if (isPlainRecord(existing) && isPlainRecord(value)) { + next[key] = mergeConfigPatch(existing, value); + } else { + next[key] = value; + } + } + return next as T; +} + +export function applyDefaultModel(cfg: OpenClawConfig, model: string): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[model] = models[model] ?? {}; + + const existingModel = cfg.agents?.defaults?.model; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + model: { + ...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel + ? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks } + : undefined), + primary: model, + }, + }, + }, + }; +} diff --git a/src/plugins/provider-auth-choice-preference.ts b/src/plugins/provider-auth-choice-preference.ts new file mode 100644 index 00000000000..dfd247f1e31 --- /dev/null +++ b/src/plugins/provider-auth-choice-preference.ts @@ -0,0 +1,53 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { resolveManifestProviderAuthChoice } from "./provider-auth-choices.js"; + +const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { + chutes: "chutes", + "litellm-api-key": "litellm", + "custom-api-key": "custom", +}; + +function normalizeLegacyAuthChoice(choice: string): string { + if (choice === "oauth") { + return "setup-token"; + } + if (choice === "claude-cli") { + return "setup-token"; + } + if (choice === "codex-cli") { + return "openai-codex"; + } + return choice; +} + +export async function resolvePreferredProviderForAuthChoice(params: { + choice: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const choice = normalizeLegacyAuthChoice(params.choice) ?? params.choice; + const manifestResolved = resolveManifestProviderAuthChoice(choice, params); + if (manifestResolved) { + return manifestResolved.providerId; + } + + const { resolveProviderPluginChoice, resolvePluginProviders } = + await import("./provider-auth-choice.runtime.js"); + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); + const pluginResolved = resolveProviderPluginChoice({ + providers, + choice, + }); + if (pluginResolved) { + return pluginResolved.provider.id; + } + + return PREFERRED_PROVIDER_BY_AUTH_CHOICE[choice]; +} diff --git a/src/plugins/provider-auth-choice.runtime.ts b/src/plugins/provider-auth-choice.runtime.ts new file mode 100644 index 00000000000..7c83aa6da3a --- /dev/null +++ b/src/plugins/provider-auth-choice.runtime.ts @@ -0,0 +1,2 @@ +export { resolveProviderPluginChoice, runProviderModelSelectedHook } from "./provider-wizard.js"; +export { resolvePluginProviders } from "./providers.js"; diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts new file mode 100644 index 00000000000..940a26b20d1 --- /dev/null +++ b/src/plugins/provider-auth-choice.ts @@ -0,0 +1,309 @@ +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { + resolveDefaultAgentId, + resolveAgentDir, + resolveAgentWorkspaceDir, +} from "../agents/agent-scope.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { enablePluginInConfig } from "./enable.js"; +import { + applyDefaultModel, + mergeConfigPatch, + pickAuthMethod, + resolveProviderMatch, +} from "./provider-auth-choice-helpers.js"; +import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; +import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; +import { isRemoteEnvironment, openUrl } from "./setup-browser.js"; +import type { ProviderAuthMethod, ProviderAuthOptionBag, ProviderPlugin } from "./types.js"; + +export type ApplyProviderAuthChoiceParams = { + authChoice: string; + config: OpenClawConfig; + prompter: WizardPrompter; + runtime: RuntimeEnv; + agentDir?: string; + setDefaultModel: boolean; + agentId?: string; + opts?: Partial; +}; + +export type ApplyProviderAuthChoiceResult = { + config: OpenClawConfig; + agentModelOverride?: string; +}; + +export type PluginProviderAuthChoiceOptions = { + authChoice: string; + pluginId: string; + providerId: string; + methodId?: string; + label: string; +}; + +function restoreConfiguredPrimaryModel( + nextConfig: OpenClawConfig, + originalConfig: OpenClawConfig, +): OpenClawConfig { + const originalModel = originalConfig.agents?.defaults?.model; + const nextAgents = nextConfig.agents; + const nextDefaults = nextAgents?.defaults; + if (!nextDefaults) { + return nextConfig; + } + if (originalModel !== undefined) { + return { + ...nextConfig, + agents: { + ...nextAgents, + defaults: { + ...nextDefaults, + model: originalModel, + }, + }, + }; + } + const { model: _model, ...restDefaults } = nextDefaults; + return { + ...nextConfig, + agents: { + ...nextAgents, + defaults: restDefaults, + }, + }; +} + +async function loadPluginProviderRuntime() { + return import("./provider-auth-choice.runtime.js"); +} + +export async function runProviderPluginAuthMethod(params: { + config: OpenClawConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + method: ProviderAuthMethod; + agentDir?: string; + agentId?: string; + workspaceDir?: string; + emitNotes?: boolean; + secretInputMode?: ProviderAuthOptionBag["secretInputMode"]; + allowSecretRefPrompt?: boolean; + opts?: Partial; +}): Promise<{ config: OpenClawConfig; defaultModel?: string }> { + const agentId = params.agentId ?? resolveDefaultAgentId(params.config); + const defaultAgentId = resolveDefaultAgentId(params.config); + const agentDir = + params.agentDir ?? + (agentId === defaultAgentId + ? resolveOpenClawAgentDir() + : resolveAgentDir(params.config, agentId)); + const workspaceDir = + params.workspaceDir ?? + resolveAgentWorkspaceDir(params.config, agentId) ?? + resolveDefaultAgentWorkspaceDir(); + + const result = await params.method.run({ + config: params.config, + agentDir, + workspaceDir, + prompter: params.prompter, + runtime: params.runtime, + opts: params.opts, + secretInputMode: params.secretInputMode, + allowSecretRefPrompt: params.allowSecretRefPrompt, + isRemote: isRemoteEnvironment(), + openUrl: async (url) => { + await openUrl(url); + }, + oauth: { + createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts), + }, + }); + + let nextConfig = params.config; + if (result.configPatch) { + nextConfig = mergeConfigPatch(nextConfig, result.configPatch); + } + + for (const profile of result.profiles) { + upsertAuthProfile({ + profileId: profile.profileId, + credential: profile.credential, + agentDir, + }); + + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: profile.profileId, + provider: profile.credential.provider, + mode: profile.credential.type === "token" ? "token" : profile.credential.type, + ...("email" in profile.credential && profile.credential.email + ? { email: profile.credential.email } + : {}), + }); + } + + if (params.emitNotes !== false && result.notes && result.notes.length > 0) { + await params.prompter.note(result.notes.join("\n"), "Provider notes"); + } + + return { + config: nextConfig, + defaultModel: result.defaultModel, + }; +} + +export async function applyAuthChoiceLoadedPluginProvider( + params: ApplyProviderAuthChoiceParams, +): Promise { + const agentId = params.agentId ?? resolveDefaultAgentId(params.config); + const workspaceDir = + resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = + await loadPluginProviderRuntime(); + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); + const resolved = resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + }); + if (!resolved) { + return null; + } + + const applied = await runProviderPluginAuthMethod({ + config: params.config, + runtime: params.runtime, + prompter: params.prompter, + method: resolved.method, + agentDir: params.agentDir, + agentId: params.agentId, + workspaceDir, + secretInputMode: params.opts?.secretInputMode, + allowSecretRefPrompt: false, + opts: params.opts, + }); + + let nextConfig = applied.config; + let agentModelOverride: string | undefined; + if (applied.defaultModel) { + if (params.setDefaultModel) { + nextConfig = applyDefaultModel(nextConfig, applied.defaultModel); + await runProviderModelSelectedHook({ + config: nextConfig, + model: applied.defaultModel, + prompter: params.prompter, + agentDir: params.agentDir, + workspaceDir, + }); + await params.prompter.note( + `Default model set to ${applied.defaultModel}`, + "Model configured", + ); + return { config: nextConfig }; + } + nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); + agentModelOverride = applied.defaultModel; + } + + return { config: nextConfig, agentModelOverride }; +} + +export async function applyAuthChoicePluginProvider( + params: ApplyProviderAuthChoiceParams, + options: PluginProviderAuthChoiceOptions, +): Promise { + if (params.authChoice !== options.authChoice) { + return null; + } + + const enableResult = enablePluginInConfig(params.config, options.pluginId); + let nextConfig = enableResult.config; + if (!enableResult.enabled) { + await params.prompter.note( + `${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`, + options.label, + ); + return { config: nextConfig }; + } + + const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig); + const defaultAgentId = resolveDefaultAgentId(nextConfig); + const agentDir = + params.agentDir ?? + (agentId === defaultAgentId ? resolveOpenClawAgentDir() : resolveAgentDir(nextConfig, agentId)); + const workspaceDir = + resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir(); + + const { resolvePluginProviders, runProviderModelSelectedHook } = + await loadPluginProviderRuntime(); + const providers = resolvePluginProviders({ + config: nextConfig, + workspaceDir, + bundledProviderAllowlistCompat: true, + bundledProviderVitestCompat: true, + }); + const provider = resolveProviderMatch(providers, options.providerId); + if (!provider) { + await params.prompter.note( + `${options.label} auth plugin is not available. Enable it and re-run onboarding.`, + options.label, + ); + return { config: nextConfig }; + } + + const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0]; + if (!method) { + await params.prompter.note(`${options.label} auth method missing.`, options.label); + return { config: nextConfig }; + } + + const applied = await runProviderPluginAuthMethod({ + config: nextConfig, + runtime: params.runtime, + prompter: params.prompter, + method, + agentDir, + agentId, + workspaceDir, + secretInputMode: params.opts?.secretInputMode, + allowSecretRefPrompt: false, + opts: params.opts, + }); + + nextConfig = applied.config; + if (applied.defaultModel) { + if (params.setDefaultModel) { + nextConfig = applyDefaultModel(nextConfig, applied.defaultModel); + await runProviderModelSelectedHook({ + config: nextConfig, + model: applied.defaultModel, + prompter: params.prompter, + agentDir, + workspaceDir, + }); + await params.prompter.note( + `Default model set to ${applied.defaultModel}`, + "Model configured", + ); + return { config: nextConfig }; + } + if (params.agentId) { + await params.prompter.note( + `Default model set to ${applied.defaultModel} for agent "${params.agentId}".`, + "Model configured", + ); + } + nextConfig = restoreConfiguredPrimaryModel(nextConfig, params.config); + return { config: nextConfig, agentModelOverride: applied.defaultModel }; + } + + return { config: nextConfig }; +} diff --git a/test/helpers/auth-wizard.ts b/test/helpers/auth-wizard.ts new file mode 100644 index 00000000000..a9e409aa25a --- /dev/null +++ b/test/helpers/auth-wizard.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { vi } from "vitest"; +import type { RuntimeEnv } from "../../src/runtime.js"; +import { makeTempWorkspace } from "../../src/test-helpers/workspace.js"; +import { captureEnv } from "../../src/test-utils/env.js"; +import type { WizardPrompter } from "../../src/wizard/prompts.js"; + +export const noopAsync = async () => {}; +export const noop = () => {}; + +export function createExitThrowingRuntime(): RuntimeEnv { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; +} + +export function createWizardPrompter( + overrides: Partial, + options?: { defaultSelect?: string }, +): WizardPrompter { + return { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select: vi.fn(async () => (options?.defaultSelect ?? "") as never), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as unknown as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + ...overrides, + }; +} + +export async function setupAuthTestEnv( + prefix = "openclaw-auth-", + options?: { agentSubdir?: string }, +): Promise<{ + stateDir: string; + agentDir: string; +}> { + const stateDir = await makeTempWorkspace(prefix); + const agentDir = path.join(stateDir, options?.agentSubdir ?? "agent"); + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_AGENT_DIR = agentDir; + process.env.PI_CODING_AGENT_DIR = agentDir; + await fs.mkdir(agentDir, { recursive: true }); + return { stateDir, agentDir }; +} + +export type AuthTestLifecycle = { + setStateDir: (stateDir: string) => void; + cleanup: () => Promise; +}; + +export function createAuthTestLifecycle(envKeys: string[]): AuthTestLifecycle { + const envSnapshot = captureEnv(envKeys); + let stateDir: string | null = null; + return { + setStateDir(nextStateDir: string) { + stateDir = nextStateDir; + }, + async cleanup() { + if (stateDir) { + await fs.rm(stateDir, { recursive: true, force: true }); + stateDir = null; + } + envSnapshot.restore(); + }, + }; +} + +export function requireOpenClawAgentDir(): string { + const agentDir = process.env.OPENCLAW_AGENT_DIR; + if (!agentDir) { + throw new Error("OPENCLAW_AGENT_DIR not set"); + } + return agentDir; +} + +export function authProfilePathForAgent(agentDir: string): string { + return path.join(agentDir, "auth-profiles.json"); +} + +export async function readAuthProfilesForAgent(agentDir: string): Promise { + const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8"); + return JSON.parse(raw) as T; +}