refactor: move provider auth-choice helpers into plugins

This commit is contained in:
Peter Steinberger
2026-03-16 22:40:26 -07:00
parent 049bb37c62
commit 0bc9c065f2
17 changed files with 563 additions and 544 deletions

View File

@@ -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";

View File

@@ -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",

View File

@@ -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<OnboardOptions>;
}): 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<ApplyAuthChoiceResult | null> {
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<ApplyAuthChoiceResult | null> {
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";

View File

@@ -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 = {

View File

@@ -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<Record<AuthChoice, string>> = {
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<string | undefined> {
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";

View File

@@ -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";

View File

@@ -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,
}));

View File

@@ -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:";

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...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";

View File

@@ -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<WizardPrompter>,
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<void>;
};
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<T>(agentDir: string): Promise<T> {
const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8");
return JSON.parse(raw) as T;
}
export * from "../../test/helpers/auth-wizard.js";

View File

@@ -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;

View File

@@ -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<LoginOpenAICodexOAuth>());
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn<LoginQwenPortalOAuth>());
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
vi.mock("../../commands/openai-codex-oauth.js", () => ({
vi.mock("../../plugins/provider-openai-codex-oauth.js", () => ({
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
}));

View File

@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...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,
},
},
},
};
}

View File

@@ -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<Record<string, string>> = {
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<string | undefined> {
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];
}

View File

@@ -0,0 +1,2 @@
export { resolveProviderPluginChoice, runProviderModelSelectedHook } from "./provider-wizard.js";
export { resolvePluginProviders } from "./providers.js";

View File

@@ -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<ProviderAuthOptionBag>;
};
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<ProviderAuthOptionBag>;
}): 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<ApplyProviderAuthChoiceResult | null> {
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<ApplyProviderAuthChoiceResult | null> {
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 };
}

View File

@@ -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<WizardPrompter>,
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<void>;
};
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<T>(agentDir: string): Promise<T> {
const raw = await fs.readFile(authProfilePathForAgent(agentDir), "utf8");
return JSON.parse(raw) as T;
}